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/.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/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 71fd6ef..c3eadc0 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" @@ -1254,14 +1389,14 @@ }, "packages/cli": { "name": "wormkey", - "version": "0.1.5", + "version": "0.4.0", "license": "MIT", "dependencies": { "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" @@ -1283,10 +1418,10 @@ }, "packages/control-plane": { "name": "@wormkey/control-plane", - "version": "0.1.0", + "version": "0.4.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,9 +1429,27 @@ "typescript": "^5.3.0" } }, + "packages/mcp": { + "name": "@wormkey/mcp", + "version": "0.4.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", + "version": "0.4.0", "devDependencies": { "@types/node": "^20.10.0", "@types/react": "^18.0.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..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" @@ -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..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": { @@ -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..11b1076 --- /dev/null +++ b/packages/mcp/package.json @@ -0,0 +1,27 @@ +{ + "name": "@wormkey/mcp", + "version": "0.4.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..2b46157 --- /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.4.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"] +} 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/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 && (