From 58eead7a98304e245e78af8345da82578d3cfa39 Mon Sep 17 00:00:00 2001 From: Max-pantom Date: Sat, 25 Jul 2026 19:22:37 +0100 Subject: [PATCH 1/5] Split control-plane routes into public, owner-authenticated, and internal edge APIs in packages/control-plane/src/index.ts:110. Replaced Math.random() with randomBytes, randomInt, and randomUUID in packages/control-plane/src/index.ts:6. Store SHA-256 hashes of owner and tunnel tokens; raw credentials are issued only at session creation. Added WORMKEY_INTERNAL_API_KEY authentication between gateway and control plane in packages/gateway/main.go:158. Gateway validates tunnel credentials before WebSocket upgrade in packages/gateway/main.go:799. --- .composer/messages.jsonl | 0 .composer/project.json | 15 + .env.example | 5 +- packages/cli/src/api.ts | 1 + packages/cli/src/cli.ts | 7 +- packages/cli/src/tunnel.ts | 76 ++- packages/control-plane/package.json | 3 +- packages/control-plane/src/index.ts | 485 +++++++++++-------- packages/control-plane/test/security.test.ts | 96 ++++ packages/gateway/main.go | 74 +-- 10 files changed, 513 insertions(+), 249 deletions(-) create mode 100644 .composer/messages.jsonl create mode 100644 .composer/project.json create mode 100644 packages/control-plane/test/security.test.ts diff --git a/.composer/messages.jsonl b/.composer/messages.jsonl new file mode 100644 index 0000000..e69de29 diff --git a/.composer/project.json b/.composer/project.json new file mode 100644 index 0000000..f571822 --- /dev/null +++ b/.composer/project.json @@ -0,0 +1,15 @@ +{ + "id": "proj_worm", + "name": "worm", + "slug": "worm", + "path": "/Users/Macbook/desktop/code/worm", + "workspaceRoot": "/Users/Macbook/desktop/code", + "createdAt": "2026-03-28T23:12:03.971Z", + "updatedAt": "2026-03-30T10:23:32.295Z", + "lastOpenedAt": "2026-03-30T10:23:32.295Z", + "chatTitle": "worm", + "status": "active", + "linkedRepo": "/Users/Macbook/desktop/code/worm", + "historyFile": ".composer/messages.jsonl", + "threads": [] +} diff --git a/.env.example b/.env.example index 7633de2..20aad2d 100644 --- a/.env.example +++ b/.env.example @@ -20,5 +20,6 @@ WORMKEY_EDGE_BASE_URL=ws://localhost:3002 # Create a free database at https://console.upstash.com/ # UPSTASH_REDIS_REST_URL= # UPSTASH_REDIS_REST_TOKEN= -UPSTASH_REDIS_REST_URL="https://desired-mallard-4407.upstash.io" -UPSTASH_REDIS_REST_TOKEN="ARE3AAImcDI2MDZjZjQyZWQyZTA0Y2YxYWYxNTJhNzQ0ZDBiNmQ4OXAyNDQwNw" +UPSTASH_REDIS_REST_URL= +UPSTASH_REDIS_REST_TOKEN= +WORMKEY_INTERNAL_API_KEY=YXdz1MOl-2r0LlI8qF3970u3Xxm1fU0TXxNIu-fvZFxUjRAq4sX8RXXfhpbyMcHB diff --git a/packages/cli/src/api.ts b/packages/cli/src/api.ts index 35c752e..2e91f0d 100644 --- a/packages/cli/src/api.ts +++ b/packages/cli/src/api.ts @@ -7,6 +7,7 @@ export interface CreateSessionResponse { slug: string; publicUrl: string; ownerUrl: string; + ownerToken: string; overlayScriptUrl: string; edgeUrl: string; sessionToken: string; diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 6ad1f09..e76f98e 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -23,7 +23,7 @@ function getSessionStatePath(): string { const dir = process.env.XDG_STATE_HOME ? path.join(process.env.XDG_STATE_HOME, "wormkey") : path.join(os.homedir(), ".wormkey"); - return path.join(dir, "p.json"); + return path.join(dir, "sessions.json"); } function writeSessionState( @@ -32,7 +32,7 @@ function writeSessionState( ): void { const filePath = getSessionStatePath(); const dir = path.dirname(filePath); - fs.mkdirSync(dir, { recursive: true }); + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); fs.writeFileSync( filePath, JSON.stringify({ @@ -41,7 +41,7 @@ function writeSessionState( publicUrl: session.publicUrl, startedAt: new Date().toISOString(), }), - "utf8" + { encoding: "utf8", mode: 0o600 } ); } @@ -133,6 +133,7 @@ program localPort: portNum, edgeUrl, sessionToken: session.sessionToken, + ownerToken: session.ownerToken, publicUrl: session.publicUrl, onStatus: (msg) => console.error(msg), }); diff --git a/packages/cli/src/tunnel.ts b/packages/cli/src/tunnel.ts index a5c70ff..8eb4a1d 100644 --- a/packages/cli/src/tunnel.ts +++ b/packages/cli/src/tunnel.ts @@ -19,6 +19,7 @@ export interface TunnelConfig { localPort: number; edgeUrl: string; sessionToken: string; + ownerToken: string; publicUrl: string; onStatus?: (msg: string) => void; } @@ -27,6 +28,27 @@ const PING_INTERVAL_MS = 25000; const PONG_TIMEOUT_MS = 30000; const HEARTBEAT_FAILURES_BEFORE_CLOSE = 2; const BACKOFF_MS = [1000, 2000, 5000, 10000]; +const MAX_REQUEST_BODY_BYTES = 10 * 1024 * 1024; +const MAX_PENDING_STREAMS = 128; +const REQUEST_TIMEOUT_MS = 60_000; +const STRIPPED_REQUEST_HEADERS = new Set([ + "connection", + "upgrade", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "host", + "content-length", + "x-wormkey-request-id", + "x-wormkey-viewer-id", + "x-wormkey-forwarded-for", + "x-forwarded-for", + "x-forwarded-host", + "x-forwarded-proto", +]); export class TunnelClient { private ws: WebSocket | null = null; @@ -34,7 +56,8 @@ export class TunnelClient { private reconnectTimer: ReturnType | null = null; private pingTimer: ReturnType | null = null; private pongTimeout: ReturnType | null = null; - private pendingStreams = new Map(); + private pendingStreams = new Map(); + private activeRequests = new Map(); private shouldRun = true; private reconnectAttempt = 0; private heartbeatFailures = 0; @@ -69,6 +92,7 @@ export class TunnelClient { this.ws = new WebSocket(url, { headers: { Authorization: `Bearer ${this.config.sessionToken}`, + "X-Wormkey-Owner-Token": this.config.ownerToken, }, }); @@ -111,13 +135,29 @@ export class TunnelClient { } if (type === FrameType.OPEN_STREAM && payload) { - this.pendingStreams.set(streamId, { openPayload: payload, bodyChunks: [] }); + if (this.pendingStreams.size >= MAX_PENDING_STREAMS) { + this.send(FrameType.RESPONSE_HEADERS, streamId, serializeResponseHeaders(503, { "content-type": "text/plain" })); + this.send(FrameType.STREAM_DATA, streamId, Buffer.from("Too many pending requests", "utf-8")); + this.send(FrameType.STREAM_END, streamId); + return; + } + this.pendingStreams.set(streamId, { openPayload: payload, bodyChunks: [], bodyBytes: 0 }); return; } if (type === FrameType.STREAM_DATA && payload) { const pending = this.pendingStreams.get(streamId); - if (pending) pending.bodyChunks.push(payload); + if (pending) { + pending.bodyBytes += payload.length; + if (pending.bodyBytes > MAX_REQUEST_BODY_BYTES) { + this.pendingStreams.delete(streamId); + this.send(FrameType.RESPONSE_HEADERS, streamId, serializeResponseHeaders(413, { "content-type": "text/plain" })); + this.send(FrameType.STREAM_DATA, streamId, Buffer.from("Request body too large", "utf-8")); + this.send(FrameType.STREAM_END, streamId); + return; + } + pending.bodyChunks.push(payload); + } return; } @@ -132,6 +172,7 @@ export class TunnelClient { if (type === FrameType.STREAM_CANCEL) { this.pendingStreams.delete(streamId); + this.activeRequests.get(streamId)?.abort(); return; } } @@ -139,14 +180,28 @@ export class TunnelClient { private async handleOpenStream(streamId: number, openStreamPayload: Buffer, bodyChunks: Buffer[]) { const { method, path, headers } = parseOpenStream(openStreamPayload); const body = bodyChunks.length > 0 ? Buffer.concat(bodyChunks) : undefined; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + this.activeRequests.set(streamId, controller); + + const forwardedHeaders: Record = {}; + for (const [name, value] of Object.entries(headers)) { + if (!STRIPPED_REQUEST_HEADERS.has(name.toLowerCase())) forwardedHeaders[name] = value; + } + forwardedHeaders["x-wormkey-request-id"] = `req_${streamId}`; + forwardedHeaders["x-forwarded-proto"] = "https"; + try { + forwardedHeaders["x-forwarded-host"] = new URL(this.config.publicUrl).host; + } catch {} const localUrl = `http://127.0.0.1:${this.config.localPort}${path}`; try { const { statusCode, headers: resHeaders, body: resBody } = await request(localUrl, { method: method as "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS", - headers: headers as Record, + headers: forwardedHeaders, body, + signal: controller.signal, }); const resHeadersObj: Record = {}; @@ -163,9 +218,14 @@ export class TunnelClient { } } } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - this.send(FrameType.RESPONSE_HEADERS, streamId, serializeResponseHeaders(502, { "content-type": "text/plain" })); - this.send(FrameType.STREAM_DATA, streamId, Buffer.from(`Bad Gateway: ${msg}`, "utf-8")); + if (!controller.signal.aborted) { + const msg = err instanceof Error ? err.message : String(err); + this.send(FrameType.RESPONSE_HEADERS, streamId, serializeResponseHeaders(502, { "content-type": "text/plain" })); + this.send(FrameType.STREAM_DATA, streamId, Buffer.from(`Bad Gateway: ${msg}`, "utf-8")); + } + } finally { + clearTimeout(timeout); + this.activeRequests.delete(streamId); } this.send(FrameType.STREAM_END, streamId); } @@ -207,6 +267,8 @@ export class TunnelClient { private handleClose() { this.ws = null; this.stopHeartbeat(); + for (const controller of this.activeRequests.values()) controller.abort(); + this.activeRequests.clear(); if (!this.shouldRun) return; if (this.reconnectTimer) return; diff --git a/packages/control-plane/package.json b/packages/control-plane/package.json index 7dfffb7..b9211a5 100644 --- a/packages/control-plane/package.json +++ b/packages/control-plane/package.json @@ -6,7 +6,8 @@ "scripts": { "build": "tsc", "dev": "tsx watch src/index.ts", - "start": "node dist/index.js" + "start": "node dist/index.js", + "test": "tsx --test test/**/*.test.ts" }, "dependencies": { "fastify": "^4.25.0", diff --git a/packages/control-plane/src/index.ts b/packages/control-plane/src/index.ts index d99bb1c..fae02e8 100644 --- a/packages/control-plane/src/index.ts +++ b/packages/control-plane/src/index.ts @@ -3,258 +3,327 @@ * Session creation, slug allocation, lifecycle */ -import Fastify from "fastify"; +import { createHash, randomBytes, randomInt, randomUUID, timingSafeEqual } from "node:crypto"; +import Fastify, { type FastifyRequest } from "fastify"; import cors from "@fastify/cors"; const ADJECTIVES = [ "quiet", "bold", "swift", "calm", "bright", "soft", "warm", "cool", - "deep", "flat", "wild", "mild", "dark", "pale", "pure", "rare", "max" , + "deep", "flat", "wild", "mild", "dark", "pale", "pure", "rare", "max", ]; const NOUNS = [ "lime", "mint", "sage", "rose", "sky", "sea", "sand", "snow", - "mist", "dawn", "dusk", "flame", "storm", "wave", "wind", "frost", "tooth" + "mist", "dawn", "dusk", "flame", "storm", "wave", "wind", "frost", "tooth", ]; function randomSlug(): string { - const adj = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)]; - const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)]; - const num = Math.floor(Math.random() * 99) + 1; - return `${adj}-${noun}-${num}`; + const adjective = ADJECTIVES[randomInt(ADJECTIVES.length)]; + const noun = NOUNS[randomInt(NOUNS.length)]; + const suffix = randomBytes(3).toString("base64url").toLowerCase(); + return `${adjective}-${noun}-${suffix}`; } function randomToken(): string { - const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; - let s = ""; - for (let i = 0; i < 32; i++) { - s += chars[Math.floor(Math.random() * chars.length)]; - } - return s; + return randomBytes(32).toString("base64url"); } -const PUBLIC_BASE_URL = - process.env.WORMKEY_PUBLIC_BASE_URL ?? "http://localhost:3002"; -const EDGE_BASE_URL = - process.env.WORMKEY_EDGE_BASE_URL ?? "ws://localhost:3002"; +function hashToken(token: string): string { + return createHash("sha256").update(token).digest("base64url"); +} -async function main() { - const fastify = Fastify({ logger: true }); +function safeEqual(left: string, right: string): boolean { + const leftBuffer = Buffer.from(left); + const rightBuffer = Buffer.from(right); + return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer); +} - fastify.log.info({ PUBLIC_BASE_URL, EDGE_BASE_URL }, "Resolved Wormkey base URLs"); +function bearerToken(request: FastifyRequest): string | null { + const authorization = request.headers.authorization; + if (!authorization?.startsWith("Bearer ")) return null; + return authorization.slice(7).trim() || null; +} - await fastify.register(cors, { origin: true }); +const PUBLIC_BASE_URL = process.env.WORMKEY_PUBLIC_BASE_URL ?? "http://localhost:3002"; +const EDGE_BASE_URL = process.env.WORMKEY_EDGE_BASE_URL ?? "ws://localhost:3002"; +const INTERNAL_API_KEY = process.env.WORMKEY_INTERNAL_API_KEY ?? ""; - fastify.get("/", async (_req, reply) => { - return reply.send({ status: "control plane alive" }); - }); +interface Viewer { + id: string; + lastSeenAt: string; + requests: number; + ip?: string; +} - fastify.get("/health", async (_req, reply) => { - return reply.send("ok"); - }); +interface Session { + sessionId: string; + slug: string; + sessionTokenHash: string; + ownerTokenHash: string; + ownerUrl: string; + overlayScriptUrl: string; + publicUrl: string; + edgeUrl: string; + expiresAt: string; + createdAt: string; + authMode: string; + policy: { + public: boolean; + maxConcurrentViewers: number; + blockPaths: string[]; + password: string; + }; + activeViewers: Viewer[]; + kickedViewerIds: string[]; + closed: boolean; + username?: string; + password?: string; +} + +function publicSession(session: Session) { + return { + sessionId: session.sessionId, + slug: session.slug, + publicUrl: session.publicUrl, + expiresAt: session.expiresAt, + createdAt: session.createdAt, + authMode: session.authMode, + closed: session.closed, + available: !session.closed && Date.parse(session.expiresAt) > Date.now(), + }; +} + +function ownerSession(session: Session) { + return { + ...publicSession(session), + ownerUrl: session.ownerUrl, + overlayScriptUrl: session.overlayScriptUrl, + edgeUrl: session.edgeUrl, + policy: session.policy, + activeViewers: session.activeViewers, + kickedViewerIds: session.kickedViewerIds, + username: session.username, + password: session.password, + }; +} - // In-memory session store (v0) +export function buildApp() { + const fastify = Fastify({ logger: true, bodyLimit: 10 * 1024 * 1024 }); const sessions = new Map(); + const sessionIdsBySlug = new Map(); - interface Session { - sessionId: string; - slug: string; - sessionToken: string; - ownerToken: string; - ownerUrl: string; - overlayScriptUrl: string; - publicUrl: string; - edgeUrl: string; - expiresAt: string; - createdAt: string; - authMode: string; - policy: { - public: boolean; - maxConcurrentViewers: number; - blockPaths: string[]; - password: string; - }; - activeViewers: Array<{ id: string; lastSeenAt: string; requests: number; ip?: string }>; - kickedViewerIds: string[]; - closed: boolean; - username?: string; - password?: string; - } - - fastify.post<{ - Body: { port?: number; authMode?: string; expiresIn?: string }; - }>("/sessions", async (req, reply) => { - const { port = 3000, authMode = "none", expiresIn = "24h" } = req.body ?? {}; - - const slug = randomSlug(); - const ownerToken = randomToken(); - const sessionToken = `${slug}.${ownerToken}`; - const sessionId = `sess_${randomToken()}`; - - // All URLs derived from env (canonical origin). Never use request host. - const publicBase = PUBLIC_BASE_URL.replace(/\/$/, ""); - const edgeBase = EDGE_BASE_URL.replace(/\/$/, ""); - const publicUrl = `${publicBase}/s/${slug}`; - const edgeUrl = `${edgeBase}/tunnel`; - const ownerUrl = `${publicBase}/.wormkey/owner?slug=${slug}&token=${ownerToken}`; - const overlayScriptUrl = `${publicBase}/.wormkey/overlay.js?slug=${slug}`; - - const expiresMs = - expiresIn.endsWith("m") - ? parseInt(expiresIn, 10) * 60 * 1000 - : expiresIn.endsWith("h") - ? parseInt(expiresIn, 10) * 60 * 60 * 1000 - : 24 * 60 * 60 * 1000; - const expiresAt = new Date(Date.now() + expiresMs).toISOString(); - - const session: Session = { - sessionId, - slug, - sessionToken, - ownerToken, - ownerUrl, - overlayScriptUrl, - publicUrl, - edgeUrl, - expiresAt, - createdAt: new Date().toISOString(), - authMode, - policy: { - public: true, - maxConcurrentViewers: 20, - blockPaths: [], - password: "", - }, - activeViewers: [], - kickedViewerIds: [], - closed: false, - }; - - if (authMode === "basic") { - session.username = "worm"; - session.password = randomToken().slice(0, 8); - } - - sessions.set(sessionId, session); - - return reply.send({ - sessionId: session.sessionId, - slug: session.slug, - publicUrl: session.publicUrl, - ownerUrl: session.ownerUrl, - overlayScriptUrl: session.overlayScriptUrl, - edgeUrl: session.edgeUrl, - sessionToken: session.sessionToken, - expiresAt: session.expiresAt, - ...(session.username && { username: session.username }), - ...(session.password && { password: session.password }), - }); + const findBySlug = (slug: string) => { + const sessionId = sessionIdsBySlug.get(slug); + return sessionId ? sessions.get(sessionId) : undefined; + }; + + const requireOwner = (request: FastifyRequest, session: Session) => { + const token = bearerToken(request); + return token !== null && safeEqual(hashToken(token), session.ownerTokenHash); + }; + + const requireInternal = (request: FastifyRequest) => { + const token = bearerToken(request); + return INTERNAL_API_KEY !== "" && token !== null && safeEqual(token, INTERNAL_API_KEY); + }; + + fastify.register(cors, { origin: true }); + + fastify.get("/", async () => ({ status: "control plane alive" })); + fastify.get("/health", async (_request, reply) => reply.send("ok")); + + fastify.post<{ Body: { port?: number; authMode?: string; expiresIn?: string } }>( + "/sessions", + async (request, reply) => { + const { authMode = "none", expiresIn = "24h" } = request.body ?? {}; + const slug = randomSlug(); + const ownerToken = randomToken(); + const tunnelToken = randomToken(); + const sessionToken = `${slug}.${tunnelToken}`; + const sessionId = `sess_${randomUUID()}`; + const publicBase = PUBLIC_BASE_URL.replace(/\/$/, ""); + const edgeBase = EDGE_BASE_URL.replace(/\/$/, ""); + const publicUrl = `${publicBase}/s/${slug}`; + const edgeUrl = `${edgeBase}/tunnel`; + const ownerUrl = `${publicBase}/.wormkey/owner?slug=${slug}&token=${ownerToken}`; + const overlayScriptUrl = `${publicBase}/.wormkey/overlay.js?slug=${slug}`; + const parsedExpiry = Number.parseInt(expiresIn, 10); + const expiresMs = Number.isFinite(parsedExpiry) + ? expiresIn.endsWith("m") + ? parsedExpiry * 60_000 + : expiresIn.endsWith("h") + ? parsedExpiry * 3_600_000 + : 24 * 3_600_000 + : 24 * 3_600_000; + const session: Session = { + sessionId, + slug, + sessionTokenHash: hashToken(sessionToken), + ownerTokenHash: hashToken(ownerToken), + ownerUrl, + overlayScriptUrl, + publicUrl, + edgeUrl, + expiresAt: new Date(Date.now() + expiresMs).toISOString(), + createdAt: new Date().toISOString(), + authMode, + policy: { public: true, maxConcurrentViewers: 20, blockPaths: [], password: "" }, + activeViewers: [], + kickedViewerIds: [], + closed: false, + }; + if (authMode === "basic") { + session.username = "worm"; + session.password = randomBytes(12).toString("base64url"); + } + sessions.set(sessionId, session); + sessionIdsBySlug.set(slug, sessionId); + return reply.status(201).send({ + sessionId, + slug, + publicUrl, + ownerUrl, + ownerToken, + overlayScriptUrl, + edgeUrl, + sessionToken, + expiresAt: session.expiresAt, + ...(session.username && { username: session.username }), + ...(session.password && { password: session.password }), + }); + }, + ); + + fastify.get("/sessions/:id", async (request, reply) => { + const session = sessions.get((request.params as { id: string }).id); + if (!session) return reply.status(404).send({ error: "Session not found" }); + if (!requireOwner(request, session)) return reply.status(401).send({ error: "Unauthorized" }); + return ownerSession(session); }); - fastify.get("/sessions/:id", async (req, reply) => { - const { id } = req.params as { id: string }; + fastify.delete("/sessions/:id", async (request, reply) => { + const { id } = request.params as { id: string }; const session = sessions.get(id); if (!session) return reply.status(404).send({ error: "Session not found" }); - return session; + if (!requireOwner(request, session)) return reply.status(401).send({ error: "Unauthorized" }); + sessions.delete(id); + sessionIdsBySlug.delete(session.slug); + return reply.send({ ok: true }); }); - fastify.delete("/sessions/:id", async (req, reply) => { - const { id } = req.params as { id: string }; - sessions.delete(id); + fastify.get("/sessions/by-slug/:slug", async (request, reply) => { + const session = findBySlug((request.params as { slug: string }).slug); + if (!session) return reply.status(404).send({ error: "Session not found" }); + return publicSession(session); + }); + + fastify.get("/owner/sessions/by-slug/:slug", async (request, reply) => { + const session = findBySlug((request.params as { slug: string }).slug); + if (!session) return reply.status(404).send({ error: "Session not found" }); + if (!requireOwner(request, session)) return reply.status(401).send({ error: "Unauthorized" }); + return ownerSession(session); + }); + + fastify.post<{ Params: { slug: string }; Body: Partial }>( + "/owner/sessions/by-slug/:slug/policy", + async (request, reply) => { + const session = findBySlug(request.params.slug); + if (!session) return reply.status(404).send({ error: "Session not found" }); + if (!requireOwner(request, session)) return reply.status(401).send({ error: "Unauthorized" }); + if (typeof request.body.public === "boolean") session.policy.public = request.body.public; + if (typeof request.body.maxConcurrentViewers === "number") session.policy.maxConcurrentViewers = request.body.maxConcurrentViewers; + if (Array.isArray(request.body.blockPaths)) session.policy.blockPaths = request.body.blockPaths; + if (typeof request.body.password === "string") session.policy.password = request.body.password; + return reply.send({ ok: true, policy: session.policy }); + }, + ); + + fastify.post<{ Params: { slug: string }; Body: { viewerId: string } }>( + "/owner/sessions/by-slug/:slug/kick", + async (request, reply) => { + const session = findBySlug(request.params.slug); + if (!session) return reply.status(404).send({ error: "Session not found" }); + if (!requireOwner(request, session)) return reply.status(401).send({ error: "Unauthorized" }); + if (request.body.viewerId && !session.kickedViewerIds.includes(request.body.viewerId)) session.kickedViewerIds.push(request.body.viewerId); + session.activeViewers = session.activeViewers.filter((viewer) => viewer.id !== request.body.viewerId); + return reply.send({ ok: true, kickedViewerIds: session.kickedViewerIds }); + }, + ); + + fastify.post<{ Params: { slug: string } }>("/owner/sessions/by-slug/:slug/close", async (request, reply) => { + const session = findBySlug(request.params.slug); + if (!session) return reply.status(404).send({ error: "Session not found" }); + if (!requireOwner(request, session)) return reply.status(401).send({ error: "Unauthorized" }); + session.closed = true; return reply.send({ ok: true }); }); - fastify.get("/sessions/by-slug/:slug", async (req, reply) => { - const { slug } = req.params as { slug: string }; - let found: Session | undefined; - for (const session of sessions.values()) { - if (session.slug === slug) { - found = session; - break; - } - } - if (!found) return reply.status(404).send({ error: "Session not found" }); - return found; + fastify.post<{ Body: { sessionToken?: string } }>("/internal/sessions/validate", async (request, reply) => { + if (!requireInternal(request)) return reply.status(401).send({ error: "Unauthorized" }); + const sessionToken = request.body?.sessionToken; + if (!sessionToken) return reply.status(400).send({ error: "sessionToken is required" }); + const slug = sessionToken.split(".", 1)[0]; + const session = findBySlug(slug); + if (!session || !safeEqual(hashToken(sessionToken), session.sessionTokenHash)) return reply.status(401).send({ error: "Invalid session token" }); + return reply.send({ ok: true, slug, closed: session.closed, expiresAt: session.expiresAt }); }); - fastify.post<{ - Params: { slug: string }; - Body: { public?: boolean; maxConcurrentViewers?: number; blockPaths?: string[]; password?: string }; - }>("/sessions/by-slug/:slug/policy", async (req, reply) => { - const { slug } = req.params; - let found: Session | undefined; - for (const session of sessions.values()) { - if (session.slug === slug) { - found = session; - break; - } - } - if (!found) return reply.status(404).send({ error: "Session not found" }); - - if (typeof req.body.public === "boolean") found.policy.public = req.body.public; - if (typeof req.body.maxConcurrentViewers === "number") { - found.policy.maxConcurrentViewers = req.body.maxConcurrentViewers; - } - if (Array.isArray(req.body.blockPaths)) found.policy.blockPaths = req.body.blockPaths; - if (typeof req.body.password === "string") found.policy.password = req.body.password; - return reply.send({ ok: true, policy: found.policy }); + fastify.get("/internal/sessions/by-slug/:slug", async (request, reply) => { + if (!requireInternal(request)) return reply.status(401).send({ error: "Unauthorized" }); + const session = findBySlug((request.params as { slug: string }).slug); + if (!session) return reply.status(404).send({ error: "Session not found" }); + return ownerSession(session); }); - fastify.post<{ - Params: { slug: string }; - Body: { viewers: Array<{ id: string; lastSeenAt: string; requests: number; ip?: string }> }; - }>("/sessions/by-slug/:slug/viewers", async (req, reply) => { - const { slug } = req.params; - let found: Session | undefined; - for (const session of sessions.values()) { - if (session.slug === slug) { - found = session; - break; - } - } - if (!found) return reply.status(404).send({ error: "Session not found" }); - found.activeViewers = req.body.viewers ?? []; + fastify.post<{ Params: { slug: string }; Body: Partial }>("/internal/sessions/by-slug/:slug/policy", async (request, reply) => { + if (!requireInternal(request)) return reply.status(401).send({ error: "Unauthorized" }); + const session = findBySlug(request.params.slug); + if (!session) return reply.status(404).send({ error: "Session not found" }); + if (typeof request.body.public === "boolean") session.policy.public = request.body.public; + if (typeof request.body.maxConcurrentViewers === "number") session.policy.maxConcurrentViewers = request.body.maxConcurrentViewers; + if (Array.isArray(request.body.blockPaths)) session.policy.blockPaths = request.body.blockPaths; + if (typeof request.body.password === "string") session.policy.password = request.body.password; + return reply.send({ ok: true, policy: session.policy }); + }); + + fastify.post<{ Params: { slug: string }; Body: { viewers?: Viewer[] } }>("/internal/sessions/by-slug/:slug/viewers", async (request, reply) => { + if (!requireInternal(request)) return reply.status(401).send({ error: "Unauthorized" }); + const session = findBySlug(request.params.slug); + if (!session) return reply.status(404).send({ error: "Session not found" }); + session.activeViewers = request.body.viewers ?? []; return reply.send({ ok: true }); }); - fastify.post<{ - Params: { slug: string }; - Body: { viewerId: string }; - }>("/sessions/by-slug/:slug/kick", async (req, reply) => { - const { slug } = req.params; - let found: Session | undefined; - for (const session of sessions.values()) { - if (session.slug === slug) { - found = session; - break; - } - } - if (!found) return reply.status(404).send({ error: "Session not found" }); - if (req.body.viewerId && !found.kickedViewerIds.includes(req.body.viewerId)) { - found.kickedViewerIds.push(req.body.viewerId); - } - found.activeViewers = found.activeViewers.filter((v) => v.id !== req.body.viewerId); - return reply.send({ ok: true, kickedViewerIds: found.kickedViewerIds }); + fastify.post<{ Params: { slug: string }; Body: { viewerId: string } }>("/internal/sessions/by-slug/:slug/kick", async (request, reply) => { + if (!requireInternal(request)) return reply.status(401).send({ error: "Unauthorized" }); + const session = findBySlug(request.params.slug); + if (!session) return reply.status(404).send({ error: "Session not found" }); + if (request.body.viewerId && !session.kickedViewerIds.includes(request.body.viewerId)) session.kickedViewerIds.push(request.body.viewerId); + session.activeViewers = session.activeViewers.filter((viewer) => viewer.id !== request.body.viewerId); + return reply.send({ ok: true, kickedViewerIds: session.kickedViewerIds }); }); - fastify.post<{ Params: { slug: string } }>("/sessions/by-slug/:slug/close", async (req, reply) => { - const { slug } = req.params; - let found: Session | undefined; - for (const session of sessions.values()) { - if (session.slug === slug) { - found = session; - break; - } - } - if (!found) return reply.status(404).send({ error: "Session not found" }); - found.closed = true; + fastify.post<{ Params: { slug: string } }>("/internal/sessions/by-slug/:slug/close", async (request, reply) => { + if (!requireInternal(request)) return reply.status(401).send({ error: "Unauthorized" }); + const session = findBySlug(request.params.slug); + if (!session) return reply.status(404).send({ error: "Session not found" }); + session.closed = true; return reply.send({ ok: true }); }); - const port = parseInt(process.env.PORT ?? "3001", 10); + return fastify; +} + +async function main() { + const fastify = buildApp(); + fastify.log.info({ PUBLIC_BASE_URL, EDGE_BASE_URL }, "Resolved Wormkey base URLs"); + if (!INTERNAL_API_KEY) fastify.log.warn("WORMKEY_INTERNAL_API_KEY is unset; internal edge API is disabled"); + const port = Number.parseInt(process.env.PORT ?? "3001", 10); await fastify.listen({ port, host: "0.0.0.0" }); - console.log(`Control plane listening on :${port}`); } -main().catch((err) => { - console.error(err); - process.exit(1); -}); +if (process.env.NODE_ENV !== "test") { + main().catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/packages/control-plane/test/security.test.ts b/packages/control-plane/test/security.test.ts new file mode 100644 index 0000000..7c715af --- /dev/null +++ b/packages/control-plane/test/security.test.ts @@ -0,0 +1,96 @@ +import assert from "node:assert/strict"; +import { after, before, test } from "node:test"; + +process.env.NODE_ENV = "test"; +process.env.WORMKEY_INTERNAL_API_KEY = "test-internal-key"; + +const { buildApp } = await import("../src/index.js"); +const app = buildApp(); +let session: { + sessionId: string; + slug: string; + ownerToken: string; + sessionToken: string; +}; + +before(async () => { + await app.ready(); + const response = await app.inject({ + method: "POST", + url: "/sessions", + payload: { expiresIn: "1h" }, + }); + assert.equal(response.statusCode, 201); + session = response.json(); +}); + +after(async () => { + await app.close(); +}); + +test("public session status excludes credentials and viewer details", async () => { + const response = await app.inject({ + method: "GET", + url: `/sessions/by-slug/${session.slug}`, + }); + assert.equal(response.statusCode, 200); + const body = response.json(); + assert.equal(body.slug, session.slug); + assert.equal("ownerToken" in body, false); + assert.equal("sessionToken" in body, false); + assert.equal("ownerUrl" in body, false); + assert.equal("activeViewers" in body, false); + assert.equal("policy" in body, false); +}); + +test("owner session routes require the owner bearer token", async () => { + const unauthorized = await app.inject({ + method: "GET", + url: `/sessions/${session.sessionId}`, + }); + assert.equal(unauthorized.statusCode, 401); + + const authorized = await app.inject({ + method: "GET", + url: `/sessions/${session.sessionId}`, + headers: { authorization: `Bearer ${session.ownerToken}` }, + }); + assert.equal(authorized.statusCode, 200); + assert.equal(authorized.json().slug, session.slug); +}); + +test("owner mutations reject missing credentials", async () => { + const response = await app.inject({ + method: "POST", + url: `/owner/sessions/by-slug/${session.slug}/close`, + }); + assert.equal(response.statusCode, 401); +}); + +test("internal routes require the shared service key", async () => { + const unauthorized = await app.inject({ + method: "POST", + url: "/internal/sessions/validate", + payload: { sessionToken: session.sessionToken }, + }); + assert.equal(unauthorized.statusCode, 401); + + const authorized = await app.inject({ + method: "POST", + url: "/internal/sessions/validate", + headers: { authorization: "Bearer test-internal-key" }, + payload: { sessionToken: session.sessionToken }, + }); + assert.equal(authorized.statusCode, 200); + assert.equal(authorized.json().slug, session.slug); +}); + +test("invalid tunnel credentials are rejected", async () => { + const response = await app.inject({ + method: "POST", + url: "/internal/sessions/validate", + headers: { authorization: "Bearer test-internal-key" }, + payload: { sessionToken: `${session.slug}.invalid` }, + }); + assert.equal(response.statusCode, 401); +}); diff --git a/packages/gateway/main.go b/packages/gateway/main.go index 6b4c602..26b7600 100644 --- a/packages/gateway/main.go +++ b/packages/gateway/main.go @@ -4,10 +4,10 @@ package main import ( - _ "embed" "bufio" "bytes" "crypto/rand" + _ "embed" "encoding/binary" "encoding/hex" "encoding/json" @@ -148,7 +148,6 @@ type viewerState struct { } type persistedSession struct { - OwnerToken string `json:"ownerToken"` OwnerUrl string `json:"ownerUrl"` Policy tunnelPolicy `json:"policy"` KickedViewerIds []string `json:"kickedViewerIds"` @@ -160,8 +159,15 @@ func fetchSession(controlPlaneURL, slug string) (persistedSession, int, error) { if controlPlaneURL == "" { return persistedSession{}, 0, fmt.Errorf("control plane url is empty") } - url := strings.TrimRight(controlPlaneURL, "/") + "/sessions/by-slug/" + slug - resp, err := http.Get(url) + url := strings.TrimRight(controlPlaneURL, "/") + "/internal/sessions/by-slug/" + slug + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return persistedSession{}, 0, err + } + if key := os.Getenv("WORMKEY_INTERNAL_API_KEY"); key != "" { + req.Header.Set("Authorization", "Bearer "+key) + } + resp, err := http.DefaultClient.Do(req) if err != nil { return persistedSession{}, 0, err } @@ -176,6 +182,30 @@ func fetchSession(controlPlaneURL, slug string) (persistedSession, int, error) { return sess, resp.StatusCode, nil } +func validateSession(controlPlaneURL, sessionToken string) (int, error) { + if controlPlaneURL == "" { + return 0, fmt.Errorf("control plane url is empty") + } + body, err := json.Marshal(map[string]string{"sessionToken": sessionToken}) + if err != nil { + return 0, err + } + req, err := http.NewRequest(http.MethodPost, strings.TrimRight(controlPlaneURL, "/")+"/internal/sessions/validate", bytes.NewReader(body)) + if err != nil { + return 0, err + } + req.Header.Set("Content-Type", "application/json") + if key := os.Getenv("WORMKEY_INTERNAL_API_KEY"); key != "" { + req.Header.Set("Authorization", "Bearer "+key) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + return resp.StatusCode, nil +} + func randomSecret(n int) string { b := make([]byte, n) if _, err := rand.Read(b); err != nil { @@ -407,9 +437,6 @@ func hydrateFromControlPlane(controlPlaneURL, slug string, tc *tunnelConn) { if status != http.StatusOK { return } - if tc.ownerToken == "" && sess.OwnerToken != "" { - tc.ownerToken = sess.OwnerToken - } tc.policyMu.Lock() if sess.Policy.MaxConcurrentViewers > 0 || sess.Policy.Public || len(sess.Policy.BlockPaths) > 0 || sess.Policy.Password != "" { tc.policy = sess.Policy @@ -436,6 +463,9 @@ func postJSON(url string, body any) { return } req.Header.Set("Content-Type", "application/json") + if key := os.Getenv("WORMKEY_INTERNAL_API_KEY"); key != "" { + req.Header.Set("Authorization", "Bearer "+key) + } resp, err := http.DefaultClient.Do(req) if err != nil { return @@ -447,7 +477,7 @@ func syncPolicy(controlPlaneURL, slug string, policy tunnelPolicy) { if controlPlaneURL == "" { return } - url := strings.TrimRight(controlPlaneURL, "/") + "/sessions/by-slug/" + slug + "/policy" + url := strings.TrimRight(controlPlaneURL, "/") + "/internal/sessions/by-slug/" + slug + "/policy" postJSON(url, map[string]any{ "public": policy.Public, "maxConcurrentViewers": policy.MaxConcurrentViewers, @@ -460,7 +490,7 @@ func syncViewers(controlPlaneURL, slug string, viewers []viewerState) { if controlPlaneURL == "" { return } - url := strings.TrimRight(controlPlaneURL, "/") + "/sessions/by-slug/" + slug + "/viewers" + url := strings.TrimRight(controlPlaneURL, "/") + "/internal/sessions/by-slug/" + slug + "/viewers" postJSON(url, map[string]any{"viewers": viewers}) } @@ -468,7 +498,7 @@ func syncKick(controlPlaneURL, slug, viewerID string) { if controlPlaneURL == "" { return } - url := strings.TrimRight(controlPlaneURL, "/") + "/sessions/by-slug/" + slug + "/kick" + url := strings.TrimRight(controlPlaneURL, "/") + "/internal/sessions/by-slug/" + slug + "/kick" postJSON(url, map[string]any{"viewerId": viewerID}) } @@ -476,7 +506,7 @@ func syncClose(controlPlaneURL, slug string) { if controlPlaneURL == "" { return } - url := strings.TrimRight(controlPlaneURL, "/") + "/sessions/by-slug/" + slug + "/close" + url := strings.TrimRight(controlPlaneURL, "/") + "/internal/sessions/by-slug/" + slug + "/close" postJSON(url, map[string]any{}) } @@ -564,9 +594,6 @@ func main() { base := strings.TrimSuffix(getEnv("WORMKEY_PUBLIC_BASE_URL", getEnv("WORMKEY_PUBLIC_BASE", "http://localhost:3002")), "/") publicUrl := base + "/s/" + slug ownerUrl := sess.OwnerUrl - if ownerUrl == "" && sess.OwnerToken != "" { - ownerUrl = base + "/.wormkey/owner?slug=" + slug + "&token=" + sess.OwnerToken - } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]string{"publicUrl": publicUrl, "ownerUrl": ownerUrl}) }) @@ -756,12 +783,8 @@ func handleTunnel(tunnels *sync.Map, closedSlugs *sync.Map, controlPlaneURL stri } rawToken := strings.TrimSpace(token[7:]) slug := rawToken - ownerToken := "" if dot := strings.IndexByte(rawToken, '.'); dot > 0 { slug = rawToken[:dot] - if dot+1 < len(rawToken) { - ownerToken = rawToken[dot+1:] - } } if len(slug) > 64 { slug = slug[:64] @@ -770,17 +793,12 @@ func handleTunnel(tunnels *sync.Map, closedSlugs *sync.Map, controlPlaneURL stri http.Error(w, "Session closed", http.StatusGone) return } - if sess, status, err := fetchSession(controlPlaneURL, slug); err == nil && status == http.StatusOK { - if sess.Closed { - closedSlugs.Store(slug, struct{}{}) - http.Error(w, "Session closed", http.StatusGone) - return - } - if sess.OwnerToken != "" && ownerToken != "" && sess.OwnerToken != ownerToken { - http.Error(w, "Invalid session token", http.StatusUnauthorized) - return - } + status, err := validateSession(controlPlaneURL, rawToken) + if err != nil || status != http.StatusOK { + http.Error(w, "Invalid session token", http.StatusUnauthorized) + return } + ownerToken := strings.TrimSpace(r.Header.Get("X-Wormkey-Owner-Token")) conn, err := upgrader.Upgrade(w, r, nil) if err != nil { log.Printf("Upgrade error: %v", err) From 27afdcb359e82a8c6daa9644a5538b53d0b2c15a Mon Sep 17 00:00:00 2001 From: Max-pantom Date: Sat, 25 Jul 2026 19:37:42 +0100 Subject: [PATCH 2/5] secure tunnels and add agent-native workflows --- .env.example | 25 - .github/workflows/ci.yml | 47 ++ .github/workflows/codeql.yml | 26 + .gitignore | 3 +- package-lock.json | 531 +++++++++++------ package.json | 1 + packages/cli/package.json | 4 +- packages/cli/src/api.ts | 51 ++ packages/cli/src/cli.ts | 536 +++++++++--------- packages/cli/src/state.ts | 103 ++++ packages/cli/src/tunnel.ts | 36 +- packages/control-plane/package.json | 4 +- packages/control-plane/src/index.ts | 40 +- packages/control-plane/test/security.test.ts | 19 + .../gateway/internal/controlplane/client.go | 106 ++++ packages/gateway/internal/protocol/frame.go | 31 + packages/gateway/main.go | 169 ++---- packages/mcp/package.json | 27 + packages/mcp/src/index.ts | 91 +++ packages/mcp/tsconfig.json | 14 + 20 files changed, 1277 insertions(+), 587 deletions(-) delete mode 100644 .env.example create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/codeql.yml create mode 100644 packages/cli/src/state.ts create mode 100644 packages/gateway/internal/controlplane/client.go create mode 100644 packages/gateway/internal/protocol/frame.go create mode 100644 packages/mcp/package.json create mode 100644 packages/mcp/src/index.ts create mode 100644 packages/mcp/tsconfig.json diff --git a/.env.example b/.env.example deleted file mode 100644 index 20aad2d..0000000 --- a/.env.example +++ /dev/null @@ -1,25 +0,0 @@ -# Wormkey - copy to .env and fill in values - -# CLI (local dev or production) -WORMKEY_CONTROL_PLANE_URL=http://localhost:3001 -WORMKEY_EDGE_URL=ws://localhost:3002/tunnel - -# Production (wormkey.run) -# WORMKEY_CONTROL_PLANE_URL=https://wormkey-control-plane.onrender.com -# WORMKEY_EDGE_URL=wss://wormkey-gateway.onrender.com/tunnel - -# Control plane env (Render deployment - canonical public base) -WORMKEY_PUBLIC_BASE_URL=http://localhost:3002 -WORMKEY_EDGE_BASE_URL=ws://localhost:3002 - -# Production control plane -# WORMKEY_PUBLIC_BASE_URL=https://wormkey.run -# WORMKEY_EDGE_BASE_URL=wss://wormkey-gateway.onrender.com - -# Website: global mascot download counter (Upstash Redis) -# Create a free database at https://console.upstash.com/ -# UPSTASH_REDIS_REST_URL= -# UPSTASH_REDIS_REST_TOKEN= -UPSTASH_REDIS_REST_URL= -UPSTASH_REDIS_REST_TOKEN= -WORMKEY_INTERNAL_API_KEY=YXdz1MOl-2r0LlI8qF3970u3Xxm1fU0TXxNIu-fvZFxUjRAq4sX8RXXfhpbyMcHB diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bc155a0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,47 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + typescript: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - run: npm ci + - run: npm --prefix packages/control-plane run build + - run: npm --prefix packages/control-plane run test + - run: npm --prefix packages/cli run build + - run: npm --prefix packages/overlay run build + - run: npm audit --omit=dev --audit-level=high + + go: + runs-on: ubuntu-latest + defaults: + run: + working-directory: packages/gateway + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: packages/gateway/go.mod + cache-dependency-path: packages/gateway/go.sum + - run: go test ./... + - run: go vet ./... + - run: go run golang.org/x/vuln/cmd/govulncheck@latest ./... + + dependency-review: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/dependency-review-action@v4 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..67cf495 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,26 @@ +name: CodeQL + +on: + push: + branches: [main] + pull_request: + schedule: + - cron: "17 3 * * 1" + +permissions: + contents: read + security-events: write + +jobs: + analyze: + runs-on: ubuntu-latest + strategy: + matrix: + language: [javascript-typescript, go] + steps: + - uses: actions/checkout@v4 + - uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + - uses: github/codeql-action/autobuild@v3 + - uses: github/codeql-action/analyze@v3 diff --git a/.gitignore b/.gitignore index 380c8fc..a1ae4b1 100644 --- a/.gitignore +++ b/.gitignore @@ -3,11 +3,10 @@ dist/ *.log .env .env.* -!.env.example # Go binaries packages/gateway/gateway release/ releases/ -RELEASE_NOTES.md \ No newline at end of file +RELEASE_NOTES.md diff --git a/package-lock.json b/package-lock.json index 71fd6ef..0e8d4d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "workspaces": [ "packages/cli", "packages/control-plane", + "packages/mcp", "packages/overlay" ] }, @@ -454,48 +455,134 @@ } }, "node_modules/@fastify/ajv-compiler": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-3.6.0.tgz", - "integrity": "sha512-LwdXQJjmMD+GwLOkP7TVC68qa+pSSogeWWmznRJ/coyTcfe9qA05AHFSe1eZFwK6q+xVRpChnvFUkf1iYaSZsQ==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", + "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "MIT", "dependencies": { - "ajv": "^8.11.0", - "ajv-formats": "^2.1.1", - "fast-uri": "^2.0.0" + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0" } }, "node_modules/@fastify/cors": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-8.5.0.tgz", - "integrity": "sha512-/oZ1QSb02XjP0IK1U0IXktEsw/dUBTxJOW7IpIeO8c/tNalw/KjoNSJv1Sf6eqoBPO+TDGkifq6ynFK3v68HFQ==", + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.3.0.tgz", + "integrity": "sha512-ggQGua+xHv1MvePbPr0v//xLYEsCXbWspquXCJS9Ot5YoRXq8J8ZWzHnxDBVnbtXosvistXo6LtNzOJswf64Fw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "MIT", "dependencies": { - "fastify-plugin": "^4.0.0", - "mnemonist": "0.39.6" + "fastify-plugin": "^6.0.0", + "toad-cache": "^3.7.0" } }, "node_modules/@fastify/error": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/@fastify/error/-/error-3.4.1.tgz", - "integrity": "sha512-wWSvph+29GR783IhmvdwWnN4bUxTD01Vm5Xad4i7i1VuAOItLvbPAb69sb0IQ2N57yprvhNIwAP5B6xfKTmjmQ==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "MIT" }, "node_modules/@fastify/fast-json-stringify-compiler": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-4.3.0.tgz", - "integrity": "sha512-aZAXGYo6m22Fk1zZzEUKBvut/CIIQe/BapEORnxiD5Qr0kPHqqI69NtEMCme74h+at72sPhbkb4ZrLd1W3KRLA==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.1.0.tgz", + "integrity": "sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "MIT", "dependencies": { - "fast-json-stringify": "^5.7.0" + "fast-json-stringify": "^7.0.0" } }, + "node_modules/@fastify/forwarded": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz", + "integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/@fastify/merge-json-schemas": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.1.1.tgz", - "integrity": "sha512-fERDVz7topgNjtXsJTTW1JKLy0rhuLRcquYqNR9rF7OcVpCa2OVW49ZPDIhaRRCaUuvVxI+N416xUoF76HNSXA==", + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.3" + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" } }, "node_modules/@pinojs/redact": { @@ -546,6 +633,10 @@ "resolved": "packages/control-plane", "link": true }, + "node_modules/@wormkey/mcp": { + "resolved": "packages/mcp", + "link": true + }, "node_modules/@wormkey/overlay": { "resolved": "packages/overlay", "link": true @@ -557,9 +648,9 @@ "license": "MIT" }, "node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -573,9 +664,9 @@ } }, "node_modules/ajv-formats": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", "license": "MIT", "dependencies": { "ajv": "^8.0.0" @@ -589,22 +680,6 @@ } } }, - "node_modules/ajv/node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, "node_modules/atomic-sleep": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", @@ -615,12 +690,22 @@ } }, "node_modules/avvio": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/avvio/-/avvio-8.4.0.tgz", - "integrity": "sha512-CDSwaxINFy59iNwhYnkvALBwZiTydGkOecZyPkqBpABYR1KqGEsET0VOOYDwtleZSUIdeY36DC2bSZ24CO1igA==", + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.3.0.tgz", + "integrity": "sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "MIT", "dependencies": { - "@fastify/error": "^3.3.0", + "@fastify/error": "^4.0.0", "fastq": "^1.17.1" } }, @@ -634,12 +719,16 @@ } }, "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/csstype": { @@ -649,6 +738,15 @@ "dev": true, "license": "MIT" }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -703,12 +801,6 @@ "@esbuild/win32-x64": "0.27.3" } }, - "node_modules/fast-content-type-parse": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-1.1.0.tgz", - "integrity": "sha512-fBHHqSTFLVnR61C+gltJuE5GkVQMV0S2nqUO8TJ+5Z3qAKG8vAx4FKai1s5jq/inV1+sREynIWSuQ6HgoSXpDQ==", - "license": "MIT" - }, "node_modules/fast-decode-uri-component": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", @@ -722,36 +814,44 @@ "license": "MIT" }, "node_modules/fast-json-stringify": { - "version": "5.16.1", - "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-5.16.1.tgz", - "integrity": "sha512-KAdnLvy1yu/XrRtP+LJnxbBGrhN+xXu+gt3EUvZhYGKCr3lFHq/7UFJHHFgmJKoqlh6B40bZLEv7w46B0mqn1g==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-7.0.1.tgz", + "integrity": "sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "MIT", "dependencies": { - "@fastify/merge-json-schemas": "^0.1.0", - "ajv": "^8.10.0", + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", "ajv-formats": "^3.0.1", - "fast-deep-equal": "^3.1.3", - "fast-uri": "^2.1.0", - "json-schema-ref-resolver": "^1.0.1", + "fast-uri": "^4.0.0", + "json-schema-ref-resolver": "^3.0.0", "rfdc": "^1.2.0" } }, - "node_modules/fast-json-stringify/node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true + "node_modules/fast-json-stringify/node_modules/fast-uri": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.1.tgz", + "integrity": "sha512-YPOs1zD5TG2+EZt+r88LwF6mclA7TPkpwMP7ZN3TO2HiHS8TXvq7QA/17iJsV9dubcLo/f8eEYqMBruyQV21hQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" } - } + ], + "license": "BSD-3-Clause" }, "node_modules/fast-querystring": { "version": "1.1.2", @@ -763,15 +863,25 @@ } }, "node_modules/fast-uri": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-2.4.0.tgz", - "integrity": "sha512-ypuAmmMKInk5q7XcepxlnUWDLWv4GFtaJqAzWKqn62IpQ3pejtr5dTVbt3vwqVaMKmkNR55sTT+CqUKIaT21BA==", - "license": "MIT" + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" }, "node_modules/fastify": { - "version": "4.29.1", - "resolved": "https://registry.npmjs.org/fastify/-/fastify-4.29.1.tgz", - "integrity": "sha512-m2kMNHIG92tSNWv+Z3UeTR9AWLLuo7KctC7mlFPtMEVrfjIhmQhkQnT9v15qA/BfVq3vvj134Y0jl9SBje3jXQ==", + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.10.0.tgz", + "integrity": "sha512-A9L0ziuWGQHgEEVgF3davQ9vbD93IuX+lo2IsxapQmu5b/Y/ynn9m9K5JHt9dvyJXOFc5iN0Zk5GHEOqnzhWjg==", "funding": [ { "type": "github", @@ -784,28 +894,37 @@ ], "license": "MIT", "dependencies": { - "@fastify/ajv-compiler": "^3.5.0", - "@fastify/error": "^3.4.0", - "@fastify/fast-json-stringify-compiler": "^4.3.0", + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", "abstract-logging": "^2.0.1", - "avvio": "^8.3.0", - "fast-content-type-parse": "^1.1.0", - "fast-json-stringify": "^5.8.0", - "find-my-way": "^8.0.0", - "light-my-request": "^5.11.0", - "pino": "^9.0.0", - "process-warning": "^3.0.0", - "proxy-addr": "^2.0.7", - "rfdc": "^1.3.0", - "secure-json-parse": "^2.7.0", - "semver": "^7.5.4", - "toad-cache": "^3.3.0" + "avvio": "^9.0.0", + "fast-json-stringify": "^7.0.0", + "find-my-way": "^9.6.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.0.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" } }, "node_modules/fastify-plugin": { - "version": "4.5.1", - "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-4.5.1.tgz", - "integrity": "sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-6.0.0.tgz", + "integrity": "sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "MIT" }, "node_modules/fastq": { @@ -818,26 +937,17 @@ } }, "node_modules/find-my-way": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-8.2.2.tgz", - "integrity": "sha512-Dobi7gcTEq8yszimcfp/R7+owiT4WncAJ7VTTgFH1jYJ5GaG1FbhjwDG820hptN0QDFvzVY3RfCzdInvGPGzjA==", + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.7.0.tgz", + "integrity": "sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", "fast-querystring": "^1.0.0", - "safe-regex2": "^3.1.0" + "safe-regex2": "^5.0.0" }, "engines": { - "node": ">=14" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", - "engines": { - "node": ">= 0.6" + "node": ">=20" } }, "node_modules/fsevents": { @@ -869,12 +979,12 @@ } }, "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz", + "integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==", "license": "MIT", "engines": { - "node": ">= 0.10" + "node": ">= 10" } }, "node_modules/js-tokens": { @@ -885,12 +995,22 @@ "license": "MIT" }, "node_modules/json-schema-ref-resolver": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-1.0.1.tgz", - "integrity": "sha512-EJAj1pgHc1hxF6vo2Z3s69fMjO1INq6eGHXZ8Z6wCQeldCuwxGK9Sxf4/cScGn3FZubCVUehfWtcDM/PLteCQw==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.3" + "dequal": "^2.0.3" } }, "node_modules/json-schema-traverse": { @@ -900,16 +1020,42 @@ "license": "MIT" }, "node_modules/light-my-request": { - "version": "5.14.0", - "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-5.14.0.tgz", - "integrity": "sha512-aORPWntbpH5esaYpGOOmri0OHDOe3wC5M2MQxZ9dvMLZm6DnaAn0kJlcbU9hwsQgLzmZyReKwFwwPkR+nHu5kA==", + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "BSD-3-Clause", "dependencies": { - "cookie": "^0.7.0", - "process-warning": "^3.0.0", - "set-cookie-parser": "^2.4.1" + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" } }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -923,21 +1069,6 @@ "loose-envify": "cli.js" } }, - "node_modules/mnemonist": { - "version": "0.39.6", - "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.6.tgz", - "integrity": "sha512-A/0v5Z59y63US00cRSLiloEIw3t5G+MiKz4BhX21FI+YBJXBOGW0ohFxTxO08dsOYlzxo87T7vGfZKYp2bcAWA==", - "license": "MIT", - "dependencies": { - "obliterator": "^2.0.1" - } - }, - "node_modules/obliterator": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz", - "integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==", - "license": "MIT" - }, "node_modules/on-exit-leak-free": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", @@ -984,7 +1115,7 @@ "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", "license": "MIT" }, - "node_modules/pino/node_modules/process-warning": { + "node_modules/process-warning": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", @@ -1000,25 +1131,6 @@ ], "license": "MIT" }, - "node_modules/process-warning": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-3.0.0.tgz", - "integrity": "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==", - "license": "MIT" - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/qrcode-terminal": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz", @@ -1075,9 +1187,9 @@ } }, "node_modules/ret": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/ret/-/ret-0.4.3.tgz", - "integrity": "sha512-0f4Memo5QP7WQyUEAYUO3esD/XjOc3Zjjg5CPsAq1p8sIu0XPeMbHJemKA0BO7tV0X7+A0FoEpbmHXWxPyD3wQ==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", "license": "MIT", "engines": { "node": ">=10" @@ -1100,12 +1212,25 @@ "license": "MIT" }, "node_modules/safe-regex2": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-3.1.0.tgz", - "integrity": "sha512-RAAZAGbap2kBfbVhvmnTFv73NWLMvDGOITFYTZBAaY8eR+Ir4ef7Up/e7amo+y1+AH+3PtLkrt9mvcTsG9LXug==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", + "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "MIT", "dependencies": { - "ret": "~0.4.0" + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" } }, "node_modules/safe-stable-stringify": { @@ -1118,9 +1243,19 @@ } }, "node_modules/secure-json-parse": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", - "integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "BSD-3-Clause" }, "node_modules/semver": { @@ -1212,9 +1347,9 @@ } }, "node_modules/undici": { - "version": "6.23.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz", - "integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==", + "version": "6.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", + "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", "license": "MIT", "engines": { "node": ">=18.17" @@ -1232,9 +1367,9 @@ "link": true }, "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -1260,8 +1395,8 @@ "commander": "^12.0.0", "dotenv": "^16.4.0", "qrcode-terminal": "^0.12.0", - "undici": "^6.2.0", - "ws": "^8.18.0" + "undici": "^6.28.0", + "ws": "^8.21.1" }, "bin": { "wormkey": "dist/cli.js" @@ -1285,8 +1420,8 @@ "name": "@wormkey/control-plane", "version": "0.1.0", "dependencies": { - "@fastify/cors": "^8.5.0", - "fastify": "^4.25.0" + "@fastify/cors": "^11.3.0", + "fastify": "^5.10.0" }, "devDependencies": { "@types/node": "^20.10.0", @@ -1294,6 +1429,24 @@ "typescript": "^5.3.0" } }, + "packages/mcp": { + "name": "@wormkey/mcp", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "wormkey": "file:../cli" + }, + "bin": { + "wormkey-mcp": "dist/index.js" + }, + "devDependencies": { + "@types/node": "^20.10.0", + "typescript": "^5.3.0" + }, + "engines": { + "node": ">=18" + } + }, "packages/overlay": { "name": "@wormkey/overlay", "version": "0.2.0", diff --git a/package.json b/package.json index be48cc9..3537c00 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "workspaces": [ "packages/cli", "packages/control-plane", + "packages/mcp", "packages/overlay" ] } diff --git a/packages/cli/package.json b/packages/cli/package.json index 8366058..2d35e0d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -34,8 +34,8 @@ "commander": "^12.0.0", "dotenv": "^16.4.0", "qrcode-terminal": "^0.12.0", - "undici": "^6.2.0", - "ws": "^8.18.0" + "undici": "^6.28.0", + "ws": "^8.21.1" }, "devDependencies": { "@types/node": "^20.10.0", diff --git a/packages/cli/src/api.ts b/packages/cli/src/api.ts index 2e91f0d..55fde83 100644 --- a/packages/cli/src/api.ts +++ b/packages/cli/src/api.ts @@ -37,3 +37,54 @@ export async function createSession( return res.json() as Promise; } + +export async function getOwnerSession(controlPlaneUrl: string, slug: string, ownerToken: string) { + const response = await fetch(`${controlPlaneUrl.replace(/\/$/, "")}/owner/sessions/by-slug/${slug}`, { + headers: { Authorization: `Bearer ${ownerToken}` }, + }); + if (!response.ok) throw new Error(`Session lookup failed: ${response.status} ${await response.text()}`); + return response.json() as Promise>; +} + +export async function closeSession(controlPlaneUrl: string, slug: string, ownerToken: string): Promise { + const response = await fetch(`${controlPlaneUrl.replace(/\/$/, "")}/owner/sessions/by-slug/${slug}/close`, { + method: "POST", + headers: { Authorization: `Bearer ${ownerToken}` }, + }); + if (!response.ok) throw new Error(`Session close failed: ${response.status} ${await response.text()}`); +} + +export async function updateSessionPolicy( + controlPlaneUrl: string, + slug: string, + ownerToken: string, + policy: Record, +): Promise { + const response = await fetch(`${controlPlaneUrl.replace(/\/$/, "")}/owner/sessions/by-slug/${slug}/policy`, { + method: "POST", + headers: { + Authorization: `Bearer ${ownerToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(policy), + }); + if (!response.ok) throw new Error(`Policy update failed: ${response.status} ${await response.text()}`); +} + +export async function createScopedToken( + controlPlaneUrl: string, + sessionId: string, + ownerToken: string, + options: { name: string; scopes: string[]; maxTtl: string }, +) { + const response = await fetch(`${controlPlaneUrl.replace(/\/$/, "")}/owner/sessions/${sessionId}/tokens`, { + method: "POST", + headers: { + Authorization: `Bearer ${ownerToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(options), + }); + if (!response.ok) throw new Error(`Token creation failed: ${response.status} ${await response.text()}`); + return response.json() as Promise<{ token: string; name: string; scopes: string[]; expiresAt: string }>; +} diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index e76f98e..30207e2 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1,295 +1,325 @@ #!/usr/bin/env node -/** - * Wormkey CLI - * Open a wormhole to your localhost. - */ - import "dotenv/config"; -import { createRequire } from "module"; -import * as fs from "fs"; -import * as path from "path"; -import * as os from "os"; -import { spawn } from "child_process"; +import { createRequire } from "node:module"; +import * as fs from "node:fs"; +import { spawn, type ChildProcess } from "node:child_process"; import { program } from "commander"; import qrcode from "qrcode-terminal"; +import { closeSession, createScopedToken, createSession, getOwnerSession } from "./api.js"; import { TunnelClient } from "./tunnel.js"; -import { createSession } from "./api.js"; -import type { CreateSessionResponse } from "./api.js"; +import { + appendRequestLog, + findRequest, + findSession, + readRequestLogs, + readSessions, + saveSession, + updateSession, + type StoredSession, +} from "./state.js"; const require = createRequire(import.meta.url); const pkg = require("../package.json"); -function getSessionStatePath(): string { - const dir = process.env.XDG_STATE_HOME - ? path.join(process.env.XDG_STATE_HOME, "wormkey") - : path.join(os.homedir(), ".wormkey"); - return path.join(dir, "sessions.json"); +const ExitCode = { + Success: 0, + InvalidArguments: 10, + LocalUnavailable: 11, + ControlPlaneUnavailable: 12, + GatewayAuthenticationFailed: 13, + TunnelExpired: 14, + PolicyRejected: 15, + RateLimited: 16, +} as const; + +function durationMs(value: string): number { + const match = /^(\d+)(ms|s|m|h)?$/.exec(value); + if (!match) throw new Error(`Invalid duration: ${value}`); + const amount = Number.parseInt(match[1], 10); + const unit = match[2] ?? "s"; + return amount * ({ ms: 1, s: 1_000, m: 60_000, h: 3_600_000 }[unit] ?? 1_000); } -function writeSessionState( - controlPlaneUrl: string, - session: CreateSessionResponse -): void { - const filePath = getSessionStatePath(); - const dir = path.dirname(filePath); - fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); - fs.writeFileSync( - filePath, - JSON.stringify({ - slug: session.slug, - controlPlaneUrl, - publicUrl: session.publicUrl, - startedAt: new Date().toISOString(), - }), - { encoding: "utf8", mode: 0o600 } - ); +async function waitUntilReady(port: number, path: string, timeout: string): Promise { + const deadline = Date.now() + durationMs(timeout); + const url = `http://127.0.0.1:${port}${path.startsWith("/") ? path : `/${path}`}`; + while (Date.now() < deadline) { + try { + const response = await fetch(url, { signal: AbortSignal.timeout(2_000) }); + if (response.ok) return; + } catch {} + await new Promise((resolve) => setTimeout(resolve, 500)); + } + throw Object.assign(new Error(`Local service did not become ready at ${url}`), { exitCode: ExitCode.LocalUnavailable }); } -function deleteSessionState(): void { - try { - fs.unlinkSync(getSessionStatePath()); - } catch { - // ignore +function resolveEndpoints(options: { local?: boolean; controlPlane?: string; edge?: string }) { + const local = process.env.WORMKEY_ENV === "local" || options.local === true; + return { + controlPlane: process.env.WORMKEY_CONTROL_PLANE_URL ?? options.controlPlane ?? (local ? "http://localhost:3001" : "https://wormkey-control-plane.onrender.com"), + edge: process.env.WORMKEY_EDGE_URL ?? options.edge ?? (local ? "ws://localhost:3002/tunnel" : "wss://t.wormkey.run/tunnel"), + }; +} + +function emitJson(value: unknown, output?: string): void { + const serialized = `${JSON.stringify(value)}\n`; + if (output) { + const temporary = `${output}.${process.pid}.tmp`; + fs.writeFileSync(temporary, serialized, { encoding: "utf8", mode: 0o600 }); + fs.renameSync(temporary, output); } + process.stdout.write(serialized); } -function openUrl(url: string): void { - const isWin = process.platform === "win32"; - const cmd = process.platform === "darwin" ? "open" : isWin ? "cmd" : "xdg-open"; - const args = isWin ? ["/c", "start", url] : [url]; - spawn(cmd, args, { stdio: "ignore", detached: true }).unref(); +function sessionOutput(session: StoredSession) { + return { + session_id: session.sessionId, + url: session.publicUrl, + owner_url: session.ownerUrl, + expires_at: session.expiresAt, + pid: session.pid, + status: session.status, + }; } -function copyToClipboard(text: string): void { - const cmd = - process.platform === "darwin" - ? "pbcopy" - : process.platform === "win32" - ? "clip" - : "xclip"; - const args = process.platform === "linux" ? ["-selection", "clipboard"] : []; - const proc = spawn(cmd, args, { stdio: ["pipe", "ignore", "ignore"] }); - proc.stdin?.write(text, () => { - proc.stdin?.end(); +async function startTunnel(port: number, options: { + auth?: boolean; + expires: string; + controlPlane?: string; + edge?: string; + local?: boolean; + json?: boolean; + interactive?: boolean; + waitFor?: string; + timeout: string; + inspect?: boolean; + output?: string; +}): Promise<{ tunnel: TunnelClient; session: StoredSession }> { + if (!Number.isInteger(port) || port < 1 || port > 65_535) { + throw Object.assign(new Error("Invalid port. Use 1-65535."), { exitCode: ExitCode.InvalidArguments }); + } + if (options.waitFor) await waitUntilReady(port, options.waitFor, options.timeout); + const endpoints = resolveEndpoints(options); + const created = await createSession(endpoints.controlPlane, { port, auth: options.auth, expires: options.expires }); + const session: StoredSession = { + sessionId: created.sessionId, + slug: created.slug, + controlPlaneUrl: endpoints.controlPlane, + publicUrl: created.publicUrl, + ownerUrl: created.ownerUrl, + ownerToken: created.ownerToken, + expiresAt: created.expiresAt, + startedAt: new Date().toISOString(), + pid: process.pid, + status: "connected", + localPort: port, + }; + const tunnel = new TunnelClient({ + localPort: port, + edgeUrl: created.edgeUrl || endpoints.edge, + sessionToken: created.sessionToken, + ownerToken: created.ownerToken, + publicUrl: created.publicUrl, + sessionId: created.sessionId, + onStatus: (message) => console.error(message), + onRequest: options.inspect ? appendRequestLog : undefined, }); + await tunnel.connect(); + saveSession(session); + if (options.json) { + emitJson(sessionOutput(session), options.output); + } else { + console.log("\nTunnel ready.\n"); + console.log(`Share:\n${created.publicUrl}\n`); + if (options.interactive !== false) { + console.log("Scan to open"); + qrcode.generate(created.publicUrl, { small: true }); + console.log(`\nOwner claim URL:\n${created.ownerUrl}\n`); + } + } + return { tunnel, session }; } -program - .name("wormkey") - .description("Open a wormhole to your localhost") - .version(pkg.version); +function installLifecycle(tunnel: TunnelClient, session: StoredSession, child?: ChildProcess): void { + const cleanup = async (exitCode: number = ExitCode.Success) => { + tunnel.close(); + updateSession(session.sessionId, { status: "closed" }); + if (child && !child.killed) child.kill("SIGTERM"); + process.exit(exitCode); + }; + process.on("SIGINT", () => void cleanup()); + process.on("SIGTERM", () => void cleanup()); + const expiresIn = Date.parse(session.expiresAt) - Date.now(); + if (expiresIn > 0) setTimeout(() => void cleanup(ExitCode.TunnelExpired), expiresIn); +} + +program.name("wormkey").description("Open a wormhole to your localhost").version(pkg.version); program .command("http ") - .description("Expose local port via wormhole") - .option("--auth", "Enable basic auth (prints username/password)") - .option("--expires ", "Session expiry (e.g. 30m, 1h, 24h)", "24h") + .description("Expose a local HTTP port") + .option("--auth", "Enable generated basic auth") + .option("--expires ", "Session expiry", "24h") .option("--control-plane ", "Control plane URL") .option("--edge ", "Edge tunnel URL") - .option("--local", "Use localhost control plane and edge") - .action(async (port: string, opts) => { - const portNum = parseInt(port, 10); - if (isNaN(portNum) || portNum < 1 || portNum > 65535) { - console.error("Invalid port. Use 1-65535."); - process.exit(1); + .option("--local", "Use local services") + .option("--json", "Write one JSON object to stdout") + .option("--no-interactive", "Disable QR codes and controls") + .option("--wait-for ", "Wait for a local readiness path") + .option("--timeout ", "Readiness timeout", "60s") + .option("--inspect", "Capture request metadata for logs and replay") + .option("--output ", "Atomically write session JSON") + .action(async (portValue: string, options) => { + try { + const { tunnel, session } = await startTunnel(Number.parseInt(portValue, 10), options); + installLifecycle(tunnel, session); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exit((error as { exitCode?: number }).exitCode ?? ExitCode.ControlPlaneUnavailable); } + }); +program + .command("run") + .description("Start a process and expose it through Wormkey") + .requiredOption("--port ", "Local application port") + .option("--wait-for ", "Readiness path", "/") + .option("--timeout ", "Readiness timeout", "60s") + .option("--expires ", "Session expiry", "24h") + .option("--json", "Write one JSON object to stdout") + .option("--no-interactive", "Disable interactive output") + .option("--inspect", "Capture request metadata") + .allowUnknownOption(true) + .allowExcessArguments(true) + .action(async (options, command) => { + const args = command.args; + if (args[0] === "--") args.shift(); + if (args.length === 0) { + console.error("A command is required after --"); + process.exit(ExitCode.InvalidArguments); + } + const child = spawn(args[0], args.slice(1), { stdio: "inherit", env: { ...process.env, PORT: options.port } }); try { - const isLocal = - process.env.WORMKEY_ENV === "local" || opts.local === true; - const defaultControlPlane = isLocal - ? "http://localhost:3001" - : "https://wormkey-control-plane.onrender.com"; - const defaultEdge = isLocal - ? "ws://localhost:3002/tunnel" - : "wss://t.wormkey.run/tunnel"; - - const controlPlane = - process.env.WORMKEY_CONTROL_PLANE_URL ?? - opts.controlPlane ?? - defaultControlPlane; - console.error("Control plane:", controlPlane); - - const session = await createSession(controlPlane, { - port: portNum, - auth: opts.auth, - expires: opts.expires, - }); - - if (opts.auth && session.username && session.password) { - console.log("\nBasic auth enabled:"); - console.log(` Username: ${session.username}`); - console.log(` Password: ${session.password}`); - console.log(); - } - - const edgeUrl = - process.env.WORMKEY_EDGE_URL ?? - opts.edge ?? - session.edgeUrl ?? - defaultEdge; - console.error("Edge tunnel:", edgeUrl); - const tunnel = new TunnelClient({ - localPort: portNum, - edgeUrl, - sessionToken: session.sessionToken, - ownerToken: session.ownerToken, - publicUrl: session.publicUrl, - onStatus: (msg) => console.error(msg), - }); - - await tunnel.connect(); - - writeSessionState(controlPlane, session); - - let expirationTimer: ReturnType | null = null; - if (session.expiresAt) { - const expiresMs = new Date(session.expiresAt).getTime() - Date.now(); - if (expiresMs > 0) { - expirationTimer = setTimeout(() => { - deleteSessionState(); - console.error("\nTunnel expired."); - tunnel.close(); - process.exit(0); - }, expiresMs); - } - } - - const cleanup = () => { - if (expirationTimer) clearTimeout(expirationTimer); - deleteSessionState(); + const { tunnel, session } = await startTunnel(Number.parseInt(options.port, 10), options); + installLifecycle(tunnel, session, child); + child.on("exit", (code) => { tunnel.close(); - process.exit(0); - }; - - const publicUrl = session.publicUrl; - - console.log("\nTunnel ready.\n"); - console.log("Share:"); - console.log(publicUrl); - console.log(); - - console.log("Scan to open"); - qrcode.generate(publicUrl, { small: true }); - console.log(); - - if (opts.auth && session.username && session.password) { - console.log("Basic auth:"); - console.log(` Username: ${session.username}`); - console.log(` Password: ${session.password}`); - console.log(); - } - - console.log("Owner claim URL (open once):"); - console.log(session.ownerUrl); - console.log("\nPath B integration (add in app layout):"); - console.log(``); - console.log(); - - const isTty = process.stdin.isTTY; - if (isTty) { - console.log("Press L to open in browser"); - console.log("Press C to copy URL"); - console.log("Press P to pause / R to resume"); - console.log("Press Q to close"); - console.log(); - - process.stdin.setRawMode(true); - process.stdin.resume(); - process.stdin.setEncoding("utf8"); - - process.stdin.on("data", (key: string) => { - const k = key.toLowerCase(); - if (k === "q" || k === "\u0003") { - process.stdin.setRawMode?.(false); - process.stdin.pause(); - cleanup(); - } else if (k === "l") { - openUrl(publicUrl); - } else if (k === "c") { - copyToClipboard(publicUrl); - console.error("URL copied to clipboard."); - } else if (k === "p") { - tunnel.pause(); - console.error("Tunnel paused."); - } else if (k === "r") { - tunnel.resume(); - console.error("Tunnel resumed."); - } - }); - } else { - console.log("Press Ctrl+C to close.\n"); - } - - process.on("SIGINT", () => { - process.stdin.setRawMode?.(false); - process.stdin.pause?.(); - cleanup(); + updateSession(session.sessionId, { status: "closed" }); + process.exit(code ?? 0); }); - } catch (err) { - console.error("Error:", err instanceof Error ? err.message : err); - process.exit(1); + } catch (error) { + child.kill("SIGTERM"); + console.error(error instanceof Error ? error.message : error); + process.exit((error as { exitCode?: number }).exitCode ?? ExitCode.ControlPlaneUnavailable); } }); -program - .command("login") - .description("Authenticate with Wormkey (device flow)") - .action(() => { - console.log("Login not implemented in v0. Use local control plane."); +program.command("list").description("List saved tunnels").option("--json", "Output JSON").action((options) => { + const sessions = readSessions(); + if (options.json) return emitJson(sessions.map(sessionOutput)); + if (sessions.length === 0) return console.log("No saved tunnels."); + for (const session of sessions) console.log(`${session.status.padEnd(10)} ${session.slug.padEnd(24)} ${session.publicUrl}`); +}); + +program.command("close [session]").description("Close a tunnel remotely").option("--json", "Output JSON").action(async (identifier, options) => { + const session = findSession(identifier); + if (!session) { + process.exitCode = ExitCode.InvalidArguments; + return; + } + try { + await closeSession(session.controlPlaneUrl, session.slug, session.ownerToken); + updateSession(session.sessionId, { status: "closed" }); + if (options.json) emitJson({ session_id: session.sessionId, status: "closed" }); + else console.log(`Closed ${session.publicUrl}`); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exitCode = ExitCode.ControlPlaneUnavailable; + } +}); + +program.command("status [session]").description("Show tunnel status").option("--json", "Output JSON").action(async (identifier, options) => { + const session = findSession(identifier); + if (!session) return console.log("No active tunnel."); + try { + const remote = await getOwnerSession(session.controlPlaneUrl, session.slug, session.ownerToken); + if (options.json) emitJson({ ...sessionOutput(session), remote }); + else console.log(`${session.status}: ${session.publicUrl}`); + } catch { + console.log("Tunnel may have expired."); + } +}); + +program.command("logs [session]").description("Show captured request logs").option("--json", "Output JSON lines").action((identifier, options) => { + const session = findSession(identifier); + const logs = readRequestLogs(session?.sessionId); + for (const entry of logs) { + if (options.json) console.log(JSON.stringify(entry)); + else console.log(`${entry.requestId} ${entry.method} ${entry.path} ${entry.status} ${entry.durationMs}ms ${entry.requestBytes}B→${entry.responseBytes}B`); + } +}); + +program.command("inspect [request]").description("Inspect captured requests").action((requestId) => { + if (requestId) { + const entry = findRequest(requestId); + if (!entry) return console.log("Request not found."); + return console.log(JSON.stringify(entry, null, 2)); + } + for (const entry of readRequestLogs().slice(-20)) console.log(`${entry.requestId} ${entry.method} ${entry.path} ${entry.status}`); +}); + +program.command("replay ").description("Replay a captured request to localhost").action(async (requestId) => { + const entry = findRequest(requestId); + const session = entry ? readSessions().find((candidate) => candidate.sessionId === entry.sessionId) : undefined; + if (!entry || !session) { + process.exitCode = ExitCode.InvalidArguments; + return; + } + const response = await fetch(`http://127.0.0.1:${session.localPort}${entry.path}`, { + method: entry.method, + headers: entry.requestHeaders, + body: entry.requestBodyBase64 ? Buffer.from(entry.requestBodyBase64, "base64") : undefined, }); + console.log(JSON.stringify({ request_id: requestId, status: response.status, body: await response.text() })); +}); -program - .command("status") - .description("Show active tunnel status") - .action(async () => { - const filePath = getSessionStatePath(); - if (!fs.existsSync(filePath)) { - console.log("No active tunnel."); +program.command("wait-until-ready ").description("Wait for a local service").option("--path ", "Readiness path", "/").option("--timeout ", "Timeout", "60s").action(async (port, options) => { + try { + await waitUntilReady(Number.parseInt(port, 10), options.path, options.timeout); + emitJson({ ready: true, port: Number.parseInt(port, 10), path: options.path }); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exitCode = ExitCode.LocalUnavailable; + } +}); + +const tokenCommand = program.command("token").description("Manage restricted session tokens"); + +tokenCommand.command("create [session]") + .description("Create a restricted session token") + .requiredOption("--name ", "Token name") + .option("--scope ", "Comma-separated scopes", "tunnel:read") + .option("--max-ttl ", "Maximum token lifetime", "2h") + .action(async (identifier, options) => { + const session = findSession(identifier); + if (!session) { + process.exitCode = ExitCode.InvalidArguments; return; } try { - const raw = fs.readFileSync(filePath, "utf8"); - const state = JSON.parse(raw) as { - slug: string; - controlPlaneUrl: string; - publicUrl: string; - startedAt: string; - }; - const res = await fetch( - `${state.controlPlaneUrl.replace(/\/$/, "")}/sessions/by-slug/${state.slug}` - ); - if (!res.ok) { - console.log("Tunnel may have expired."); - return; - } - const session = (await res.json()) as { - activeViewers?: Array<{ id: string }>; - createdAt?: string; - }; - const viewers = session.activeViewers?.length ?? 0; - const startedAt = new Date(state.startedAt).getTime(); - const uptimeMs = Date.now() - startedAt; - const uptimeM = Math.floor(uptimeMs / 60000); - const uptimeH = Math.floor(uptimeM / 60); - const uptimeMRem = uptimeM % 60; - const uptimeStr = - uptimeH > 0 ? `${uptimeH}h ${uptimeMRem}m` : `${uptimeM}m`; - - console.log("Tunnel: running"); - console.log(`URL: ${state.publicUrl}`); - console.log(`Viewers: ${viewers}`); - console.log(`Uptime: ${uptimeStr}`); - } catch { - console.log("No active tunnel."); + const token = await createScopedToken(session.controlPlaneUrl, session.sessionId, session.ownerToken, { + name: options.name, + scopes: options.scope.split(",").map((scope: string) => scope.trim()).filter(Boolean), + maxTtl: options.maxTtl, + }); + emitJson(token); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exitCode = ExitCode.ControlPlaneUnavailable; } }); -program - .command("close") - .description("Close active tunnel") - .action(() => { - console.log("Close not implemented in v0. Use Ctrl+C on the tunnel process."); - }); - -program.parse(); +program.parseAsync().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exit(ExitCode.InvalidArguments); +}); diff --git a/packages/cli/src/state.ts b/packages/cli/src/state.ts new file mode 100644 index 0000000..997a562 --- /dev/null +++ b/packages/cli/src/state.ts @@ -0,0 +1,103 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +export interface StoredSession { + sessionId: string; + slug: string; + controlPlaneUrl: string; + publicUrl: string; + ownerUrl: string; + ownerToken: string; + expiresAt: string; + startedAt: string; + pid: number; + status: "connected" | "paused" | "closed" | "expired"; + localPort: number; +} + +export interface RequestLogEntry { + requestId: string; + sessionId: string; + method: string; + path: string; + status: number; + durationMs: number; + requestBytes: number; + responseBytes: number; + timestamp: string; + requestHeaders: Record; + requestBodyBase64?: string; +} + +function stateDirectory(): string { + return process.env.XDG_STATE_HOME + ? path.join(process.env.XDG_STATE_HOME, "wormkey") + : path.join(os.homedir(), ".wormkey"); +} + +function ensureStateDirectory(): string { + const directory = stateDirectory(); + fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); + return directory; +} + +function sessionsPath(): string { + return path.join(stateDirectory(), "sessions.json"); +} + +function logsPath(): string { + return path.join(stateDirectory(), "requests.jsonl"); +} + +export function readSessions(): StoredSession[] { + try { + const parsed = JSON.parse(fs.readFileSync(sessionsPath(), "utf8")) as StoredSession[] | unknown; + return Array.isArray(parsed) ? parsed as StoredSession[] : []; + } catch { + return []; + } +} + +export function writeSessions(sessions: StoredSession[]): void { + const filePath = path.join(ensureStateDirectory(), "sessions.json"); + const temporaryPath = `${filePath}.${process.pid}.tmp`; + fs.writeFileSync(temporaryPath, JSON.stringify(sessions, null, 2), { encoding: "utf8", mode: 0o600 }); + fs.renameSync(temporaryPath, filePath); +} + +export function saveSession(session: StoredSession): void { + const sessions = readSessions().filter((existing) => existing.sessionId !== session.sessionId); + sessions.push(session); + writeSessions(sessions); +} + +export function updateSession(sessionId: string, patch: Partial): void { + writeSessions(readSessions().map((session) => session.sessionId === sessionId ? { ...session, ...patch } : session)); +} + +export function findSession(identifier?: string): StoredSession | undefined { + const active = readSessions().filter((session) => session.status !== "closed" && session.status !== "expired"); + if (!identifier) return active.at(-1); + return active.find((session) => session.sessionId === identifier || session.slug === identifier); +} + +export function appendRequestLog(entry: RequestLogEntry): void { + fs.appendFileSync(path.join(ensureStateDirectory(), "requests.jsonl"), `${JSON.stringify(entry)}\n`, { encoding: "utf8", mode: 0o600 }); +} + +export function readRequestLogs(sessionId?: string): RequestLogEntry[] { + try { + return fs.readFileSync(logsPath(), "utf8") + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as RequestLogEntry) + .filter((entry) => !sessionId || entry.sessionId === sessionId); + } catch { + return []; + } +} + +export function findRequest(requestId: string): RequestLogEntry | undefined { + return readRequestLogs().find((entry) => entry.requestId === requestId); +} diff --git a/packages/cli/src/tunnel.ts b/packages/cli/src/tunnel.ts index 8eb4a1d..7a5d015 100644 --- a/packages/cli/src/tunnel.ts +++ b/packages/cli/src/tunnel.ts @@ -4,6 +4,7 @@ */ import WebSocket from "ws"; +import { randomUUID } from "node:crypto"; import { request } from "undici"; import { FrameType, @@ -21,7 +22,21 @@ export interface TunnelConfig { sessionToken: string; ownerToken: string; publicUrl: string; + sessionId: string; onStatus?: (msg: string) => void; + onRequest?: (entry: { + requestId: string; + sessionId: string; + method: string; + path: string; + status: number; + durationMs: number; + requestBytes: number; + responseBytes: number; + timestamp: string; + requestHeaders: Record; + requestBodyBase64?: string; + }) => void; } const PING_INTERVAL_MS = 25000; @@ -180,6 +195,10 @@ export class TunnelClient { private async handleOpenStream(streamId: number, openStreamPayload: Buffer, bodyChunks: Buffer[]) { const { method, path, headers } = parseOpenStream(openStreamPayload); const body = bodyChunks.length > 0 ? Buffer.concat(bodyChunks) : undefined; + const requestId = `req_${randomUUID()}`; + const startedAt = Date.now(); + let responseStatus = 502; + let responseBytes = 0; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); this.activeRequests.set(streamId, controller); @@ -188,7 +207,7 @@ export class TunnelClient { for (const [name, value] of Object.entries(headers)) { if (!STRIPPED_REQUEST_HEADERS.has(name.toLowerCase())) forwardedHeaders[name] = value; } - forwardedHeaders["x-wormkey-request-id"] = `req_${streamId}`; + forwardedHeaders["x-wormkey-request-id"] = requestId; forwardedHeaders["x-forwarded-proto"] = "https"; try { forwardedHeaders["x-forwarded-host"] = new URL(this.config.publicUrl).host; @@ -203,6 +222,7 @@ export class TunnelClient { body, signal: controller.signal, }); + responseStatus = statusCode; const resHeadersObj: Record = {}; for (const [k, v] of Object.entries(resHeaders)) { @@ -214,6 +234,7 @@ export class TunnelClient { // Stream chunks as they arrive (don't buffer) so Next.js RSC and streaming responses render progressively for await (const chunk of resBody) { if (chunk && chunk.length > 0) { + responseBytes += chunk.length; this.send(FrameType.STREAM_DATA, streamId, Buffer.from(chunk)); } } @@ -226,6 +247,19 @@ export class TunnelClient { } finally { clearTimeout(timeout); this.activeRequests.delete(streamId); + this.config.onRequest?.({ + requestId, + sessionId: this.config.sessionId, + method, + path, + status: responseStatus, + durationMs: Date.now() - startedAt, + requestBytes: body?.length ?? 0, + responseBytes, + timestamp: new Date(startedAt).toISOString(), + requestHeaders: forwardedHeaders, + ...(body && { requestBodyBase64: body.toString("base64") }), + }); } this.send(FrameType.STREAM_END, streamId); } diff --git a/packages/control-plane/package.json b/packages/control-plane/package.json index b9211a5..818c572 100644 --- a/packages/control-plane/package.json +++ b/packages/control-plane/package.json @@ -10,8 +10,8 @@ "test": "tsx --test test/**/*.test.ts" }, "dependencies": { - "fastify": "^4.25.0", - "@fastify/cors": "^8.5.0" + "fastify": "^5.10.0", + "@fastify/cors": "^11.3.0" }, "devDependencies": { "@types/node": "^20.10.0", diff --git a/packages/control-plane/src/index.ts b/packages/control-plane/src/index.ts index fae02e8..0084ef2 100644 --- a/packages/control-plane/src/index.ts +++ b/packages/control-plane/src/index.ts @@ -77,6 +77,12 @@ interface Session { closed: boolean; username?: string; password?: string; + agentTokens: Array<{ + name: string; + tokenHash: string; + scopes: string[]; + expiresAt: string; + }>; } function publicSession(session: Session) { @@ -170,6 +176,7 @@ export function buildApp() { activeViewers: [], kickedViewerIds: [], closed: false, + agentTokens: [], }; if (authMode === "basic") { session.username = "worm"; @@ -263,10 +270,41 @@ export function buildApp() { if (!sessionToken) return reply.status(400).send({ error: "sessionToken is required" }); const slug = sessionToken.split(".", 1)[0]; const session = findBySlug(slug); - if (!session || !safeEqual(hashToken(sessionToken), session.sessionTokenHash)) return reply.status(401).send({ error: "Invalid session token" }); + const tokenHash = hashToken(sessionToken); + const validAgentToken = session?.agentTokens.some((token) => + Date.parse(token.expiresAt) > Date.now() + && token.scopes.includes("tunnel:connect") + && safeEqual(tokenHash, token.tokenHash) + ); + if (!session || (!safeEqual(tokenHash, session.sessionTokenHash) && !validAgentToken)) return reply.status(401).send({ error: "Invalid session token" }); return reply.send({ ok: true, slug, closed: session.closed, expiresAt: session.expiresAt }); }); + fastify.post<{ + Params: { id: string }; + Body: { name?: string; scopes?: string[]; maxTtl?: string }; + }>("/owner/sessions/:id/tokens", async (request, reply) => { + const session = sessions.get(request.params.id); + if (!session) return reply.status(404).send({ error: "Session not found" }); + if (!requireOwner(request, session)) return reply.status(401).send({ error: "Unauthorized" }); + const scopes = request.body.scopes?.filter((scope) => ["tunnel:connect", "tunnel:read", "tunnel:close"].includes(scope)) ?? ["tunnel:read"]; + const ttl = request.body.maxTtl ?? "2h"; + const amount = Number.parseInt(ttl, 10); + if (!Number.isFinite(amount) || amount <= 0 || (!ttl.endsWith("m") && !ttl.endsWith("h"))) { + return reply.status(400).send({ error: "maxTtl must use minutes or hours" }); + } + const ttlMs = ttl.endsWith("m") ? amount * 60_000 : amount * 3_600_000; + const rawToken = `${session.slug}.${randomToken()}`; + const agentToken = { + name: request.body.name ?? "agent", + tokenHash: hashToken(rawToken), + scopes, + expiresAt: new Date(Date.now() + Math.min(ttlMs, 24 * 3_600_000)).toISOString(), + }; + session.agentTokens.push(agentToken); + return reply.status(201).send({ token: rawToken, name: agentToken.name, scopes, expiresAt: agentToken.expiresAt }); + }); + fastify.get("/internal/sessions/by-slug/:slug", async (request, reply) => { if (!requireInternal(request)) return reply.status(401).send({ error: "Unauthorized" }); const session = findBySlug((request.params as { slug: string }).slug); diff --git a/packages/control-plane/test/security.test.ts b/packages/control-plane/test/security.test.ts index 7c715af..aa96e4e 100644 --- a/packages/control-plane/test/security.test.ts +++ b/packages/control-plane/test/security.test.ts @@ -94,3 +94,22 @@ test("invalid tunnel credentials are rejected", async () => { }); assert.equal(response.statusCode, 401); }); + +test("owner can create a scoped tunnel token", async () => { + const created = await app.inject({ + method: "POST", + url: `/owner/sessions/${session.sessionId}/tokens`, + headers: { authorization: `Bearer ${session.ownerToken}` }, + payload: { name: "codex-preview", scopes: ["tunnel:connect", "tunnel:read"], maxTtl: "2h" }, + }); + assert.equal(created.statusCode, 201); + const token = created.json().token as string; + + const validated = await app.inject({ + method: "POST", + url: "/internal/sessions/validate", + headers: { authorization: "Bearer test-internal-key" }, + payload: { sessionToken: token }, + }); + assert.equal(validated.statusCode, 200); +}); diff --git a/packages/gateway/internal/controlplane/client.go b/packages/gateway/internal/controlplane/client.go new file mode 100644 index 0000000..3a751b5 --- /dev/null +++ b/packages/gateway/internal/controlplane/client.go @@ -0,0 +1,106 @@ +package controlplane + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" +) + +type Policy struct { + Public bool `json:"public"` + MaxConcurrentViewers int `json:"maxConcurrentViewers"` + BlockPaths []string `json:"blockPaths"` + Password string `json:"password"` +} + +type Viewer struct { + ID string `json:"id"` + LastSeenAt string `json:"lastSeenAt"` + Requests int `json:"requests"` + IP string `json:"ip,omitempty"` +} + +type Session struct { + OwnerURL string `json:"ownerUrl"` + Policy Policy `json:"policy"` + KickedViewerIDs []string `json:"kickedViewerIds"` + ActiveViewers []Viewer `json:"activeViewers"` + Closed bool `json:"closed"` +} + +type Client struct { + baseURL string + internalKey string + httpClient *http.Client +} + +func New(baseURL, internalKey string) *Client { + return &Client{ + baseURL: strings.TrimRight(baseURL, "/"), + internalKey: internalKey, + httpClient: &http.Client{Timeout: 10 * time.Second}, + } +} + +func (client *Client) request(method, path string, body any) (*http.Response, error) { + var requestBody bytes.Buffer + if body != nil { + if err := json.NewEncoder(&requestBody).Encode(body); err != nil { + return nil, err + } + } + request, err := http.NewRequest(method, client.baseURL+path, &requestBody) + if err != nil { + return nil, err + } + if body != nil { + request.Header.Set("Content-Type", "application/json") + } + if client.internalKey != "" { + request.Header.Set("Authorization", "Bearer "+client.internalKey) + } + return client.httpClient.Do(request) +} + +func (client *Client) FetchSession(slug string) (Session, int, error) { + if client.baseURL == "" { + return Session{}, 0, fmt.Errorf("control plane url is empty") + } + response, err := client.request(http.MethodGet, "/internal/sessions/by-slug/"+slug, nil) + if err != nil { + return Session{}, 0, err + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return Session{}, response.StatusCode, nil + } + var session Session + if err := json.NewDecoder(response.Body).Decode(&session); err != nil { + return Session{}, response.StatusCode, err + } + return session, response.StatusCode, nil +} + +func (client *Client) ValidateSession(sessionToken string) (int, error) { + response, err := client.request(http.MethodPost, "/internal/sessions/validate", map[string]string{"sessionToken": sessionToken}) + if err != nil { + return 0, err + } + defer response.Body.Close() + return response.StatusCode, nil +} + +func (client *Client) Post(path string, body any) error { + response, err := client.request(http.MethodPost, path, body) + if err != nil { + return err + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + return fmt.Errorf("control plane returned %s", response.Status) + } + return nil +} diff --git a/packages/gateway/internal/protocol/frame.go b/packages/gateway/internal/protocol/frame.go new file mode 100644 index 0000000..d148766 --- /dev/null +++ b/packages/gateway/internal/protocol/frame.go @@ -0,0 +1,31 @@ +package protocol + +import "encoding/binary" + +const ( + FrameOpenStream = 0x01 + FrameStreamData = 0x02 + FrameStreamEnd = 0x03 + FrameStreamCancel = 0x04 + FrameResponseHdrs = 0x05 + FrameWSUpgrade = 0x06 + FrameWSData = 0x07 + FrameWSClose = 0x08 + FramePing = 0x09 + FramePong = 0x0a + FramePause = 0x0b + FrameResume = 0x0c + ControlStreamID = 0 +) + +func StreamID(frame []byte) uint32 { + return binary.BigEndian.Uint32(frame[1:5]) +} + +func Frame(frameType byte, streamID uint32, payload []byte) []byte { + frame := make([]byte, 5+len(payload)) + frame[0] = frameType + binary.BigEndian.PutUint32(frame[1:5], streamID) + copy(frame[5:], payload) + return frame +} diff --git a/packages/gateway/main.go b/packages/gateway/main.go index 26b7600..da88472 100644 --- a/packages/gateway/main.go +++ b/packages/gateway/main.go @@ -8,7 +8,6 @@ import ( "bytes" "crypto/rand" _ "embed" - "encoding/binary" "encoding/hex" "encoding/json" "fmt" @@ -24,25 +23,24 @@ import ( "time" "github.com/gorilla/websocket" + "github.com/wormkey/gateway/internal/controlplane" + "github.com/wormkey/gateway/internal/protocol" ) //go:embed overlay.js var overlayJS []byte const ( - FrameOpenStream = 0x01 - FrameStreamData = 0x02 - FrameStreamEnd = 0x03 - FrameStreamCancel = 0x04 - FrameResponseHdrs = 0x05 - FrameWSUpgrade = 0x06 - FrameWSData = 0x07 - FrameWSClose = 0x08 - FramePing = 0x09 - FramePong = 0x0a - FramePause = 0x0b - FrameResume = 0x0c - ControlStreamID = 0 + FrameOpenStream = protocol.FrameOpenStream + FrameStreamData = protocol.FrameStreamData + FrameStreamEnd = protocol.FrameStreamEnd + FrameStreamCancel = protocol.FrameStreamCancel + FrameResponseHdrs = protocol.FrameResponseHdrs + FramePing = protocol.FramePing + FramePong = protocol.FramePong + FramePause = protocol.FramePause + FrameResume = protocol.FrameResume + ControlStreamID = protocol.ControlStreamID ) var upgrader = websocket.Upgrader{ @@ -65,12 +63,7 @@ type tunnelConn struct { kickedViewers map[string]struct{} } -type tunnelPolicy struct { - Public bool `json:"public"` - MaxConcurrentViewers int `json:"maxConcurrentViewers"` - BlockPaths []string `json:"blockPaths"` - Password string `json:"password"` -} +type tunnelPolicy = controlplane.Policy type streamCtx struct { w http.ResponseWriter @@ -140,70 +133,20 @@ type policyPatch struct { BlockPaths []string `json:"blockPaths"` } -type viewerState struct { - ID string `json:"id"` - LastSeenAt string `json:"lastSeenAt"` - Requests int `json:"requests"` - IP string `json:"ip,omitempty"` -} +type viewerState = controlplane.Viewer -type persistedSession struct { - OwnerUrl string `json:"ownerUrl"` - Policy tunnelPolicy `json:"policy"` - KickedViewerIds []string `json:"kickedViewerIds"` - ActiveViewers []viewerState `json:"activeViewers"` - Closed bool `json:"closed"` +type persistedSession = controlplane.Session + +func controlPlaneClient(controlPlaneURL string) *controlplane.Client { + return controlplane.New(controlPlaneURL, os.Getenv("WORMKEY_INTERNAL_API_KEY")) } func fetchSession(controlPlaneURL, slug string) (persistedSession, int, error) { - if controlPlaneURL == "" { - return persistedSession{}, 0, fmt.Errorf("control plane url is empty") - } - url := strings.TrimRight(controlPlaneURL, "/") + "/internal/sessions/by-slug/" + slug - req, err := http.NewRequest(http.MethodGet, url, nil) - if err != nil { - return persistedSession{}, 0, err - } - if key := os.Getenv("WORMKEY_INTERNAL_API_KEY"); key != "" { - req.Header.Set("Authorization", "Bearer "+key) - } - resp, err := http.DefaultClient.Do(req) - if err != nil { - return persistedSession{}, 0, err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return persistedSession{}, resp.StatusCode, nil - } - var sess persistedSession - if err := json.NewDecoder(resp.Body).Decode(&sess); err != nil { - return persistedSession{}, resp.StatusCode, err - } - return sess, resp.StatusCode, nil + return controlPlaneClient(controlPlaneURL).FetchSession(slug) } func validateSession(controlPlaneURL, sessionToken string) (int, error) { - if controlPlaneURL == "" { - return 0, fmt.Errorf("control plane url is empty") - } - body, err := json.Marshal(map[string]string{"sessionToken": sessionToken}) - if err != nil { - return 0, err - } - req, err := http.NewRequest(http.MethodPost, strings.TrimRight(controlPlaneURL, "/")+"/internal/sessions/validate", bytes.NewReader(body)) - if err != nil { - return 0, err - } - req.Header.Set("Content-Type", "application/json") - if key := os.Getenv("WORMKEY_INTERNAL_API_KEY"); key != "" { - req.Header.Set("Authorization", "Bearer "+key) - } - resp, err := http.DefaultClient.Do(req) - if err != nil { - return 0, err - } - defer resp.Body.Close() - return resp.StatusCode, nil + return controlPlaneClient(controlPlaneURL).ValidateSession(sessionToken) } func randomSecret(n int) string { @@ -443,7 +386,7 @@ func hydrateFromControlPlane(controlPlaneURL, slug string, tc *tunnelConn) { } tc.policyMu.Unlock() tc.viewerMu.Lock() - for _, id := range sess.KickedViewerIds { + for _, id := range sess.KickedViewerIDs { tc.kickedViewers[id] = struct{}{} } for _, viewer := range sess.ActiveViewers { @@ -454,23 +397,9 @@ func hydrateFromControlPlane(controlPlaneURL, slug string, tc *tunnelConn) { } func postJSON(url string, body any) { - b, err := json.Marshal(body) - if err != nil { - return - } - req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(b)) - if err != nil { - return - } - req.Header.Set("Content-Type", "application/json") - if key := os.Getenv("WORMKEY_INTERNAL_API_KEY"); key != "" { - req.Header.Set("Authorization", "Bearer "+key) - } - resp, err := http.DefaultClient.Do(req) - if err != nil { - return - } - defer resp.Body.Close() + baseURL := strings.Split(url, "/internal/")[0] + path := strings.TrimPrefix(url, baseURL) + _ = controlPlaneClient(baseURL).Post(path, body) } func syncPolicy(controlPlaneURL, slug string, policy tunnelPolicy) { @@ -593,7 +522,7 @@ func main() { } base := strings.TrimSuffix(getEnv("WORMKEY_PUBLIC_BASE_URL", getEnv("WORMKEY_PUBLIC_BASE", "http://localhost:3002")), "/") publicUrl := base + "/s/" + slug - ownerUrl := sess.OwnerUrl + ownerUrl := sess.OwnerURL w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]string{"publicUrl": publicUrl, "ownerUrl": ownerUrl}) }) @@ -813,7 +742,26 @@ func handleTunnel(tunnels *sync.Map, closedSlugs *sync.Map, controlPlaneURL stri } } tunnels.Store(slug, tc) + monitorDone := make(chan struct{}) + go func() { + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + for { + select { + case <-monitorDone: + return + case <-ticker.C: + session, status, fetchErr := fetchSession(controlPlaneURL, slug) + if fetchErr == nil && status == http.StatusOK && session.Closed { + closedSlugs.Store(slug, struct{}{}) + _ = conn.Close() + return + } + } + } + }() defer func() { + close(monitorDone) if current, ok := tunnels.Load(slug); ok { if active, okActive := current.(*tunnelConn); okActive && active == tc { tunnels.Delete(slug) @@ -831,13 +779,11 @@ func handleTunnel(tunnels *sync.Map, closedSlugs *sync.Map, controlPlaneURL stri continue } ftype := data[0] - streamID := binary.BigEndian.Uint32(data[1:5]) + streamID := protocol.StreamID(data) payload := data[5:] switch ftype { case FramePing: - pong := make([]byte, 5) - pong[0] = FramePong - binary.BigEndian.PutUint32(pong[1:5], ControlStreamID) + pong := protocol.Frame(FramePong, ControlStreamID, nil) _ = tc.writeFrame(pong) case FramePong: case FramePause: @@ -973,10 +919,7 @@ func handleProxy(tunnels *sync.Map, controlPlaneURL string) http.HandlerFunc { fmt.Fprintf(&buf, "%s %s HTTP/1.1\r\n", r.Method, r.URL.RequestURI()) r.Header.Write(&buf) buf.WriteString("\r\n") - frame := make([]byte, 5+buf.Len()) - frame[0] = FrameOpenStream - binary.BigEndian.PutUint32(frame[1:5], streamID) - copy(frame[5:], buf.Bytes()) + frame := protocol.Frame(FrameOpenStream, streamID, buf.Bytes()) if err := tc.writeFrame(frame); err != nil { writeTunnelWriteFailed(w) return @@ -994,9 +937,7 @@ func handleProxy(tunnels *sync.Map, controlPlaneURL string) http.HandlerFunc { tc.activeStreams.Add(1) tc.streams.Store(streamID, &streamCtx{w: respW, done: done, flusher: flusher, setCookie: setCookie}) sendStreamEnd := func() { - f := make([]byte, 5) - f[0] = FrameStreamEnd - binary.BigEndian.PutUint32(f[1:5], streamID) + f := protocol.Frame(FrameStreamEnd, streamID, nil) tc.writeFrame(f) } if r.Body != nil && r.ContentLength != 0 { @@ -1007,10 +948,7 @@ func handleProxy(tunnels *sync.Map, controlPlaneURL string) http.HandlerFunc { chunk := make([]byte, 32*1024) n, err := br.Read(chunk) if n > 0 { - f := make([]byte, 5+n) - f[0] = FrameStreamData - binary.BigEndian.PutUint32(f[1:5], streamID) - copy(f[5:], chunk[:n]) + f := protocol.Frame(FrameStreamData, streamID, chunk[:n]) tc.writeFrame(f) } if err == io.EOF { @@ -1028,6 +966,13 @@ func handleProxy(tunnels *sync.Map, controlPlaneURL string) http.HandlerFunc { } sendStreamEnd() } - <-done + select { + case <-done: + case <-r.Context().Done(): + if _, loaded := tc.streams.LoadAndDelete(streamID); loaded { + tc.activeStreams.Add(-1) + _ = tc.writeFrame(protocol.Frame(FrameStreamCancel, streamID, nil)) + } + } } } diff --git a/packages/mcp/package.json b/packages/mcp/package.json new file mode 100644 index 0000000..fdc2238 --- /dev/null +++ b/packages/mcp/package.json @@ -0,0 +1,27 @@ +{ + "name": "@wormkey/mcp", + "version": "0.1.0", + "description": "MCP server for Wormkey tunnels", + "type": "module", + "bin": { + "wormkey-mcp": "dist/index.js" + }, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": ["dist"], + "scripts": { + "build": "tsc", + "start": "node dist/index.js" + }, + "dependencies": { + "wormkey": "file:../cli" + }, + "devDependencies": { + "@types/node": "^20.10.0", + "typescript": "^5.3.0" + }, + "engines": { + "node": ">=18" + }, + "license": "MIT" +} diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts new file mode 100644 index 0000000..221f31b --- /dev/null +++ b/packages/mcp/src/index.ts @@ -0,0 +1,91 @@ +#!/usr/bin/env node +import { execFile, spawn, type ChildProcess } from "node:child_process"; +import { createInterface } from "node:readline"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const wormkeyBin = process.env.WORMKEY_BIN ?? "wormkey"; +const tunnels = new Map(); + +const tools = [ + { name: "wormkey_start_tunnel", description: "Start a Wormkey HTTP tunnel", inputSchema: { type: "object", properties: { port: { type: "number" }, wait_for: { type: "string" }, timeout: { type: "string" }, inspect: { type: "boolean" } }, required: ["port"] } }, + { name: "wormkey_get_tunnel", description: "Get a tunnel status", inputSchema: { type: "object", properties: { session: { type: "string" } } } }, + { name: "wormkey_list_tunnels", description: "List Wormkey tunnels", inputSchema: { type: "object", properties: {} } }, + { name: "wormkey_close_tunnel", description: "Close a Wormkey tunnel", inputSchema: { type: "object", properties: { session: { type: "string" } } } }, + { name: "wormkey_get_requests", description: "Read captured request logs", inputSchema: { type: "object", properties: { session: { type: "string" } } } }, + { name: "wormkey_replay_request", description: "Replay a captured request", inputSchema: { type: "object", properties: { request_id: { type: "string" } }, required: ["request_id"] } }, + { name: "wormkey_wait_until_ready", description: "Wait for a local HTTP service", inputSchema: { type: "object", properties: { port: { type: "number" }, path: { type: "string" }, timeout: { type: "string" } }, required: ["port"] } }, +]; + +async function runCommand(args: string[]): Promise { + const { stdout } = await execFileAsync(wormkeyBin, args, { maxBuffer: 10 * 1024 * 1024 }); + const lines = stdout.trim().split("\n").filter(Boolean); + if (lines.length === 0) return { ok: true }; + return lines.map((line) => JSON.parse(line)); +} + +async function startTunnel(argumentsValue: Record): Promise { + const args = ["http", String(argumentsValue.port), "--json", "--no-interactive"]; + if (argumentsValue.wait_for) args.push("--wait-for", String(argumentsValue.wait_for)); + if (argumentsValue.timeout) args.push("--timeout", String(argumentsValue.timeout)); + if (argumentsValue.inspect) args.push("--inspect"); + const child = spawn(wormkeyBin, args, { stdio: ["ignore", "pipe", "pipe"] }); + const output = createInterface({ input: child.stdout! }); + const firstLine = await new Promise((resolve, reject) => { + output.once("line", resolve); + child.once("error", reject); + child.once("exit", (code) => reject(new Error(`wormkey exited before readiness with code ${code}`))); + }); + output.close(); + const session = JSON.parse(firstLine) as { session_id: string }; + tunnels.set(session.session_id, child); + child.once("exit", () => tunnels.delete(session.session_id)); + return session; +} + +async function callTool(name: string, argumentsValue: Record): Promise { + if (name === "wormkey_start_tunnel") return startTunnel(argumentsValue); + if (name === "wormkey_get_tunnel") return runCommand(["status", ...argumentsValue.session ? [String(argumentsValue.session)] : [], "--json"]); + if (name === "wormkey_list_tunnels") return runCommand(["list", "--json"]); + if (name === "wormkey_close_tunnel") return runCommand(["close", ...argumentsValue.session ? [String(argumentsValue.session)] : [], "--json"]); + if (name === "wormkey_get_requests") return runCommand(["logs", ...argumentsValue.session ? [String(argumentsValue.session)] : [], "--json"]); + if (name === "wormkey_replay_request") return runCommand(["replay", String(argumentsValue.request_id)]); + if (name === "wormkey_wait_until_ready") return runCommand(["wait-until-ready", String(argumentsValue.port), "--path", String(argumentsValue.path ?? "/"), "--timeout", String(argumentsValue.timeout ?? "60s")]); + throw new Error(`Unknown tool: ${name}`); +} + +function send(message: unknown): void { + process.stdout.write(`${JSON.stringify(message)}\n`); +} + +const input = createInterface({ input: process.stdin }); +input.on("line", async (line) => { + let request: { id?: string | number; method: string; params?: Record } = { method: "" }; + try { + request = JSON.parse(line); + if (request.method === "initialize") { + send({ jsonrpc: "2.0", id: request.id, result: { protocolVersion: "2025-06-18", capabilities: { tools: {} }, serverInfo: { name: "wormkey", version: "0.1.0" } } }); + return; + } + if (request.method === "notifications/initialized") return; + if (request.method === "tools/list") { + send({ jsonrpc: "2.0", id: request.id, result: { tools } }); + return; + } + if (request.method === "tools/call") { + const result = await callTool(request.params?.name, request.params?.arguments ?? {}); + send({ jsonrpc: "2.0", id: request.id, result: { content: [{ type: "text", text: JSON.stringify(result) }] } }); + return; + } + send({ jsonrpc: "2.0", id: request.id, error: { code: -32601, message: "Method not found" } }); + } catch (error) { + send({ jsonrpc: "2.0", id: request?.id, error: { code: -32000, message: error instanceof Error ? error.message : String(error) } }); + } +}); + +function shutdown(): void { + for (const child of tunnels.values()) child.kill("SIGTERM"); + process.exit(0); +} +process.on("SIGINT", shutdown); +process.on("SIGTERM", shutdown); diff --git a/packages/mcp/tsconfig.json b/packages/mcp/tsconfig.json new file mode 100644 index 0000000..8365ea0 --- /dev/null +++ b/packages/mcp/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true + }, + "include": ["src/**/*.ts"] +} From 871795b89510cf5c7d5dcbe7eec68a18999bce96 Mon Sep 17 00:00:00 2001 From: Max-pantom Date: Sat, 25 Jul 2026 19:38:19 +0100 Subject: [PATCH 3/5] deleted cause of github logins --- .github/workflows/ci.yml | 47 ------------------------------------ .github/workflows/codeql.yml | 26 -------------------- 2 files changed, 73 deletions(-) delete mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/codeql.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index bc155a0..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - -permissions: - contents: read - -jobs: - typescript: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm - - run: npm ci - - run: npm --prefix packages/control-plane run build - - run: npm --prefix packages/control-plane run test - - run: npm --prefix packages/cli run build - - run: npm --prefix packages/overlay run build - - run: npm audit --omit=dev --audit-level=high - - go: - runs-on: ubuntu-latest - defaults: - run: - working-directory: packages/gateway - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 - with: - go-version-file: packages/gateway/go.mod - cache-dependency-path: packages/gateway/go.sum - - run: go test ./... - - run: go vet ./... - - run: go run golang.org/x/vuln/cmd/govulncheck@latest ./... - - dependency-review: - if: github.event_name == 'pull_request' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/dependency-review-action@v4 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml deleted file mode 100644 index 67cf495..0000000 --- a/.github/workflows/codeql.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: CodeQL - -on: - push: - branches: [main] - pull_request: - schedule: - - cron: "17 3 * * 1" - -permissions: - contents: read - security-events: write - -jobs: - analyze: - runs-on: ubuntu-latest - strategy: - matrix: - language: [javascript-typescript, go] - steps: - - uses: actions/checkout@v4 - - uses: github/codeql-action/init@v3 - with: - languages: ${{ matrix.language }} - - uses: github/codeql-action/autobuild@v3 - - uses: github/codeql-action/analyze@v3 From e57b4b0f7aab4de3f0a5f6e6eddfde42277d56d9 Mon Sep 17 00:00:00 2001 From: Max-pantom Date: Sat, 25 Jul 2026 19:43:22 +0100 Subject: [PATCH 4/5] v0.4 almost ready to roll out --- CHANGELOG.md | 27 ++++++++++++++++++++++++++- package-lock.json | 8 ++++---- packages/cli/package.json | 2 +- packages/control-plane/package.json | 2 +- packages/mcp/package.json | 2 +- packages/mcp/src/index.ts | 2 +- packages/overlay/package.json | 2 +- website/package-lock.json | 4 ++-- website/package.json | 2 +- 9 files changed, 38 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f2e638d..e59070a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,32 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). --- -## [0.2.0] - TBD +## [0.4.0] - 2026-07-25 + +### Added + +- Agent-native JSON and noninteractive CLI modes +- Readiness waiting and process lifecycle management +- Multi-session state, remote close, request inspection, logs, and replay +- Scoped session tokens and the `@wormkey/mcp` package +- GitHub Actions CI, dependency review, npm audit, Go vet, and CodeQL + +### Security + +- Public, owner, and internal control-plane authorization boundaries +- Cryptographically secure session identifiers, slugs, and credentials +- Hashed owner and tunnel tokens +- Authenticated gateway session validation +- Request cancellation, body limits, timeouts, and unsafe-header filtering + +### Changed + +- Gateway control-plane and protocol code moved into internal packages +- Local state now uses `~/.wormkey/sessions.json` with restrictive permissions + +--- + +## [0.2.0] - Previous ### Added diff --git a/package-lock.json b/package-lock.json index 0e8d4d5..c3eadc0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1389,7 +1389,7 @@ }, "packages/cli": { "name": "wormkey", - "version": "0.1.5", + "version": "0.4.0", "license": "MIT", "dependencies": { "commander": "^12.0.0", @@ -1418,7 +1418,7 @@ }, "packages/control-plane": { "name": "@wormkey/control-plane", - "version": "0.1.0", + "version": "0.4.0", "dependencies": { "@fastify/cors": "^11.3.0", "fastify": "^5.10.0" @@ -1431,7 +1431,7 @@ }, "packages/mcp": { "name": "@wormkey/mcp", - "version": "0.1.0", + "version": "0.4.0", "license": "MIT", "dependencies": { "wormkey": "file:../cli" @@ -1449,7 +1449,7 @@ }, "packages/overlay": { "name": "@wormkey/overlay", - "version": "0.2.0", + "version": "0.4.0", "devDependencies": { "@types/node": "^20.10.0", "@types/react": "^18.0.0", diff --git a/packages/cli/package.json b/packages/cli/package.json index 2d35e0d..b592714 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "wormkey", - "version": "0.1.5", + "version": "0.4.0", "description": "Open a wormhole to your localhost", "bin": { "wormkey": "dist/cli.js" diff --git a/packages/control-plane/package.json b/packages/control-plane/package.json index 818c572..f35de35 100644 --- a/packages/control-plane/package.json +++ b/packages/control-plane/package.json @@ -1,6 +1,6 @@ { "name": "@wormkey/control-plane", - "version": "0.1.0", + "version": "0.4.0", "private": true, "type": "module", "scripts": { diff --git a/packages/mcp/package.json b/packages/mcp/package.json index fdc2238..11b1076 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@wormkey/mcp", - "version": "0.1.0", + "version": "0.4.0", "description": "MCP server for Wormkey tunnels", "type": "module", "bin": { diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 221f31b..2b46157 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -64,7 +64,7 @@ input.on("line", async (line) => { try { request = JSON.parse(line); if (request.method === "initialize") { - send({ jsonrpc: "2.0", id: request.id, result: { protocolVersion: "2025-06-18", capabilities: { tools: {} }, serverInfo: { name: "wormkey", version: "0.1.0" } } }); + send({ jsonrpc: "2.0", id: request.id, result: { protocolVersion: "2025-06-18", capabilities: { tools: {} }, serverInfo: { name: "wormkey", version: "0.4.0" } } }); return; } if (request.method === "notifications/initialized") return; diff --git a/packages/overlay/package.json b/packages/overlay/package.json index 0173485..a529d16 100644 --- a/packages/overlay/package.json +++ b/packages/overlay/package.json @@ -1,6 +1,6 @@ { "name": "@wormkey/overlay", - "version": "0.2.0", + "version": "0.4.0", "description": "Wormkey in-app overlay helpers", "type": "module", "main": "dist/index.js", diff --git a/website/package-lock.json b/website/package-lock.json index 18eaeb5..0737b66 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -1,12 +1,12 @@ { "name": "wormkey-website", - "version": "0.1.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "wormkey-website", - "version": "0.1.0", + "version": "0.4.0", "dependencies": { "@types/react-syntax-highlighter": "^15.5.13", "@upstash/redis": "^1.36.3", diff --git a/website/package.json b/website/package.json index 36c8212..ecb1533 100644 --- a/website/package.json +++ b/website/package.json @@ -1,6 +1,6 @@ { "name": "wormkey-website", - "version": "0.1.0", + "version": "0.4.0", "private": true, "scripts": { "dev": "next dev", From d052e30a8f10ce7f30cb78f88eb5026c9e555ec0 Mon Sep 17 00:00:00 2001 From: Max-pantom Date: Sat, 25 Jul 2026 19:49:52 +0100 Subject: [PATCH 5/5] add blog archive and v0.4 release story --- website/app/blog/how-wormkey-works/page.tsx | 270 ++++++++++++++++ website/app/blog/page.tsx | 300 +++--------------- website/app/blog/posts.ts | 20 ++ .../blog/wormkey-0-4-agent-native/page.tsx | 189 +++++++++++ website/app/components/AnnouncementBar.tsx | 13 +- website/app/page.tsx | 6 + 6 files changed, 547 insertions(+), 251 deletions(-) create mode 100644 website/app/blog/how-wormkey-works/page.tsx create mode 100644 website/app/blog/posts.ts create mode 100644 website/app/blog/wormkey-0-4-agent-native/page.tsx diff --git a/website/app/blog/how-wormkey-works/page.tsx b/website/app/blog/how-wormkey-works/page.tsx new file mode 100644 index 0000000..8f14ad6 --- /dev/null +++ b/website/app/blog/how-wormkey-works/page.tsx @@ -0,0 +1,270 @@ +import Link from "next/link"; +import { BlogMascot } from "../BlogMascot"; + +export const metadata = { + title: "How Wormkey works — and why I built it", + description: + "A short story about building a localhost tunnel that just works. No account, no dashboard, no friction.", +}; + +export default function BlogPage() { + return ( +
+
+ + ← All posts + + +
+
+

+ How Wormkey works — and why I built it +

+

+ A short story about building a localhost tunnel that just works. +

+
+ +
+

+ Why I built it +

+ +

+ A friend of mine couldn't share his demo because of something to do with Vercel. I don't know why exactly, but we tried a lot of things and couldn't get it to work. So I built Wormkey. +

+ +

+ It's much faster than ngrok or Cloudflare Tunnel. It's more accessible for non-developers — no signup, no dashboard, no DNS. Just run a command and share the link. Easy. +

+ +

+ It's not a deploy platform. It's not a hosting provider. It's a controlled wormhole from your laptop to the internet. When you're done, you close the tunnel and it's gone. +

+ +

+ How it works +

+ +

+ When you run wormkey http 3000, three things happen: +

+ +
    +
  1. + Session — The CLI talks to a control plane and gets a unique slug (e.g. quiet-lime-82). No auth, no signup. Just a slug. +
  2. +
  3. + Tunnel — The CLI opens a WebSocket to an edge gateway and keeps it open. Traffic flows through that pipe: HTTP, WebSocket, everything. Your app stays on your machine. +
  4. +
  5. + Public URL — Anyone can hit wormkey.run/s/quiet-lime-82 and reach your localhost. The gateway terminates TLS, routes by slug, and streams requests back and forth. +
  6. +
+ +

+ The protocol is simple: binary frames over WebSocket. Open stream, send data, close stream. HTTP requests become streams. WebSocket upgrades become duplex pipes. No magic, just plumbing. +

+ +

+ Architecture +

+ +

+ Here’s how the pieces fit together: +

+ +
+ + + + + + + {/* Viewer */} + + + Viewer + + + browser + + + {/* Edge Gateway */} + + + Edge Gateway + + + TLS, routing + + + {/* CLI */} + + + CLI + + + wormkey + + + {/* Localhost */} + + + localhost + + + :3000 + + + {/* Control Plane */} + + + Control Plane + + + session, slug + + + {/* Arrows: Viewer → Gateway */} + + HTTPS + + {/* Arrows: Gateway → CLI */} + + WebSocket + + {/* Arrows: CLI → Localhost */} + + HTTP + + {/* Arrows: CLI → Control Plane (get slug) */} + + get slug + +
+ +

+ If that sounds useful, give it a try: +

+ +
+              npm i -g wormkey{`\n`}wormkey http 3000
+            
+ +

+ — Built for developers who just want to share. +

+
+
+ +
+ +
+ +
+ + Get started → + +
+ + X + + + GitHub + + + npm + + + wormkey.run + +
+
+
+
+ ); +} diff --git a/website/app/blog/page.tsx b/website/app/blog/page.tsx index 0b80364..ea3811d 100644 --- a/website/app/blog/page.tsx +++ b/website/app/blog/page.tsx @@ -1,268 +1,72 @@ import Link from "next/link"; import { BlogMascot } from "./BlogMascot"; +import { blogPosts } from "./posts"; export const metadata = { - title: "How Wormkey works — and why I built it", - description: - "A short story about building a localhost tunnel that just works. No account, no dashboard, no friction.", + title: "Blog — Wormkey", + description: "Notes about building Wormkey and making localhost shareable.", }; -export default function BlogPage() { +export default function BlogIndexPage() { return (
-
+
← Back -
-
-

- How Wormkey works — and why I built it -

-

- A short story about building a localhost tunnel that just works. -

-
- -
-

- Why I built it -

- -

- A friend of mine couldn't share his demo because of something to do with Vercel. I don't know why exactly, but we tried a lot of things and couldn't get it to work. So I built Wormkey. -

- -

- It's much faster than ngrok or Cloudflare Tunnel. It's more accessible for non-developers — no signup, no dashboard, no DNS. Just run a command and share the link. Easy. -

- -

- It's not a deploy platform. It's not a hosting provider. It's a controlled wormhole from your laptop to the internet. When you're done, you close the tunnel and it's gone. -

- -

- How it works -

- -

- When you run wormkey http 3000, three things happen: -

- -
    -
  1. - Session — The CLI talks to a control plane and gets a unique slug (e.g. quiet-lime-82). No auth, no signup. Just a slug. -
  2. -
  3. - Tunnel — The CLI opens a WebSocket to an edge gateway and keeps it open. Traffic flows through that pipe: HTTP, WebSocket, everything. Your app stays on your machine. -
  4. -
  5. - Public URL — Anyone can hit wormkey.run/s/quiet-lime-82 and reach your localhost. The gateway terminates TLS, routes by slug, and streams requests back and forth. -
  6. -
- -

- The protocol is simple: binary frames over WebSocket. Open stream, send data, close stream. HTTP requests become streams. WebSocket upgrades become duplex pipes. No magic, just plumbing. -

- -

- Architecture -

- -

- Here’s how the pieces fit together: -

- -
- +

+ Wormkey blog +

+

+ Notes from inside the wormhole +

+

+ What we are building, why it works this way, and what changed along + the way. +

+ + +
+ {blogPosts.map((post, index) => ( +
+
+ {index === 0 && ( + + Latest + + )} + {post.date} + + {post.readingTime} +
+

+ + {post.title} + +

+

+ {post.description} +

+ - - - - - - {/* Viewer */} - - - Viewer - - - browser - - - {/* Edge Gateway */} - - - Edge Gateway - - - TLS, routing - - - {/* CLI */} - - - CLI - - - wormkey - - - {/* Localhost */} - - - localhost - - - :3000 - - - {/* Control Plane */} - - - Control Plane - - - session, slug - - - {/* Arrows: Viewer → Gateway */} - - HTTPS - - {/* Arrows: Gateway → CLI */} - - WebSocket - - {/* Arrows: CLI → Localhost */} - - HTTP - - {/* Arrows: CLI → Control Plane (get slug) */} - - get slug - -
- -

- If that sounds useful, give it a try: -

- -
-              npm i -g wormkey{`\n`}wormkey http 3000
-            
- -

- — Built for developers who just want to share. -

-
-
- -
- + Read post → + + + ))}
-
- - Get started → - -
- - X - - - GitHub - - - npm - - - wormkey.run - -
+
+
diff --git a/website/app/blog/posts.ts b/website/app/blog/posts.ts new file mode 100644 index 0000000..8f24861 --- /dev/null +++ b/website/app/blog/posts.ts @@ -0,0 +1,20 @@ +export const blogPosts = [ + { + slug: "wormkey-0-4-agent-native", + title: "Wormkey 0.4 — built for agents, safer for everyone", + description: + "A security-first release with remote tunnel controls, JSON output, request inspection, replay, and an MCP server.", + date: "July 25, 2026", + readingTime: "6 min read", + }, + { + slug: "how-wormkey-works", + title: "How Wormkey works — and why I built it", + description: + "A short story about building a localhost tunnel that just works. No account, no dashboard, no friction.", + date: "March 7, 2026", + readingTime: "5 min read", + }, +] as const; + +export const latestBlogPost = blogPosts[0]; diff --git a/website/app/blog/wormkey-0-4-agent-native/page.tsx b/website/app/blog/wormkey-0-4-agent-native/page.tsx new file mode 100644 index 0000000..96ca1f7 --- /dev/null +++ b/website/app/blog/wormkey-0-4-agent-native/page.tsx @@ -0,0 +1,189 @@ +import Link from "next/link"; +import { BlogMascot } from "../BlogMascot"; + +export const metadata = { + title: "Wormkey 0.4 — built for agents, safer for everyone", + description: + "A security-first Wormkey release with remote controls, JSON output, request inspection, replay, and MCP.", +}; + +const Code = ({ children }: { children: React.ReactNode }) => ( + + {children} + +); + +export default function WormkeyAgentNativePost() { + return ( +
+
+ + ← All posts + + +
+
+
+ July 25, 2026 + + Wormkey 0.4 +
+

+ Wormkey 0.4 — built for agents, safer for everyone +

+

+ Today Wormkey became more than a command that opens a public URL. + It became a proper handoff layer for unfinished software. +

+
+ +
+

+ Wormkey started with one promise: run one command, get one public + link, share what is running on your machine. That part is still + the center of the product. But the way software gets built is + changing. More of it is being written, tested, and reviewed by + agents that need stable output instead of decorated terminal + text. +

+ +

+ So this release does two things at once: it makes the tunnel safer + for people, and much easier for agents to operate. +

+ +

+ The security work came first +

+ +

+ The control plane now has three clear boundaries. Public routes + only return safe tunnel status. Owner routes require the owner + bearer token. Gateway synchronization uses a separate internal + service key. +

+ +

+ Session IDs, slugs, and credentials now use cryptographically + secure randomness. Owner and tunnel tokens are stored as hashes. + The gateway validates a session before completing its WebSocket + upgrade. +

+ +

+ Request forwarding is stricter too. Wormkey now limits request + bodies and pending streams, strips unsafe forwarding headers, + adds timeouts, and cancels the localhost request when the viewer + disconnects. +

+ +

+ The CLI can finally speak machine +

+ +

+ Agents should not need to scrape QR codes or guess whether a + sentence means success. The new JSON mode writes one stable object + to stdout while status messages stay on stderr. +

+ +
+              {`wormkey http 3000 --json --no-interactive\n\n{\n  "session_id": "sess_123",\n  "url": "https://quiet-lime-k7m4.wormkey.run",\n  "expires_at": "2026-07-25T21:00:00Z",\n  "status": "connected"\n}`}
+            
+ +

+ Wormkey can also wait for your application before exposing it: +

+ +
+              {`wormkey http 3000 --wait-for /health --timeout 60s`}
+            
+ +

+ Or manage the application and tunnel as one process: +

+ +
+              {`wormkey run --port 3000 -- npm run dev`}
+            
+ +

+ A tunnel you can inspect +

+ +

+ Run wormkey http 3000 --inspect and Wormkey records + request metadata locally: method, path, status, duration, request + bytes, response bytes, and request ID. +

+ +
+              {`wormkey logs --json\nwormkey inspect req_123\nwormkey replay req_123`}
+            
+ +

+ That makes Wormkey useful for webhook work, API debugging, and + agent loops where a failed request should become the next piece of + context. +

+ +

+ Remote controls and MCP +

+ +

+ Sessions now live in a secure multi-tunnel state file. You can + list them, inspect them, and close one remotely even if the + original terminal is gone. +

+ +
+              {`wormkey list\nwormkey status quiet-lime-k7m4\nwormkey close quiet-lime-k7m4`}
+            
+ +

+ The new @wormkey/mcp package exposes tools for + starting, reading, listing, closing, inspecting, replaying, and + waiting on tunnels. Coding agents can now use Wormkey without + pretending to be a person at a terminal. +

+ +

+ What Wormkey is becoming +

+ +

+ Deployment assumes software is ready to live somewhere. Wormkey + assumes software is still being made, but needs to become briefly + reachable, inspectable, and collaborative. +

+ +

+ That is the category we are building toward:{" "} + + the handoff layer between local execution and external + intelligence. + +

+ +
+ + View release notes → + +
+
+
+ +
+ +
+
+
+ ); +} diff --git a/website/app/components/AnnouncementBar.tsx b/website/app/components/AnnouncementBar.tsx index d11d98b..674f915 100644 --- a/website/app/components/AnnouncementBar.tsx +++ b/website/app/components/AnnouncementBar.tsx @@ -2,21 +2,28 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; +import { latestBlogPost } from "../blog/posts"; export function AnnouncementBar() { const pathname = usePathname(); - if (pathname === "/blog" || pathname === "/mascot") return null; + if ( + pathname.startsWith("/blog") || + pathname.startsWith("/release") || + pathname === "/mascot" + ) { + return null; + } return (
New:{" "} - How Wormkey works — and why I built it + {latestBlogPost.title}
diff --git a/website/app/page.tsx b/website/app/page.tsx index a86cfd6..a735e1d 100644 --- a/website/app/page.tsx +++ b/website/app/page.tsx @@ -246,6 +246,12 @@ export default function Layout({ children }) { > blog + + releases + {mounted && (