feat: redesign remote tunnel settings and named tunnel workflow (#546)
* feat: add Cloudflare Tunnel settings for desktop app Add a 'Remote Tunnel' section in Settings (desktop-only) that lets users start/stop a Cloudflare quick tunnel on demand, with auto-generated password protection and a QR code for easy mobile access. - Server: 4 new API endpoints (check/status/start/stop) reusing the existing cloudflare-tunnel module - UI: TunnelSettings component with full state machine (checking → idle/not-available → starting → active → stopping) - QR code rendered via the qrcode package for in-app display - Hidden from VS Code extension (desktop/web only) * fix: use ?token= instead of ?p= in tunnel password URLs REST API endpoints were building passwordUrl with ?p=<token> but SessionAuthGate reads the ?token= query param, causing QR code auto-login to fail — the password was never extracted from the URL. Standardize all three tunnel URL construction sites to use ?token= so scanning the QR code correctly pre-fills and submits the password. * feat: secure remote tunnel access with one-time connect links * feat: redesign remote tunnel settings and access flow * fix: cleaned up unused desktop close code path * feat: overhaul named tunnel setup and persistence flow * chore: align codemirror language dependency resolution --------- Co-authored-by: Brian-Hwang <brian.hwang@cornelisnetworks.com>
This commit is contained in:
committed by
GitHub
co-authored by
Brian-Hwang
parent
a505378d79
commit
d5d0d35083
@@ -166,6 +166,7 @@
|
|||||||
"motion": "^12.23.24",
|
"motion": "^12.23.24",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"prismjs": "^1.30.0",
|
"prismjs": "^1.30.0",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-syntax-highlighter": "^15.6.6",
|
"react-syntax-highlighter": "^15.6.6",
|
||||||
@@ -184,6 +185,7 @@
|
|||||||
"@tauri-apps/api": "^2.9.0",
|
"@tauri-apps/api": "^2.9.0",
|
||||||
"@types/node": "^24.3.1",
|
"@types/node": "^24.3.1",
|
||||||
"@types/prismjs": "^1.26.6",
|
"@types/prismjs": "^1.26.6",
|
||||||
|
"@types/qrcode": "^1.5.5",
|
||||||
"@types/react": "^19.1.10",
|
"@types/react": "^19.1.10",
|
||||||
"@types/react-dom": "^19.1.7",
|
"@types/react-dom": "^19.1.7",
|
||||||
"@vitejs/plugin-react": "^5.0.0",
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
@@ -311,6 +313,7 @@
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
|
"@codemirror/language": "6.12.2",
|
||||||
"@codemirror/view": "6.39.13",
|
"@codemirror/view": "6.39.13",
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
@@ -576,7 +579,7 @@
|
|||||||
|
|
||||||
"@codemirror/lang-yaml": ["@codemirror/lang-yaml@6.1.2", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.2.0", "@lezer/lr": "^1.0.0", "@lezer/yaml": "^1.0.0" } }, "sha512-dxrfG8w5Ce/QbT7YID7mWZFKhdhsaTNOYjOkSIMt1qmC4VQnXSDSYVHHHn8k6kJUfIhtLo8t1JJgltlxWdsITw=="],
|
"@codemirror/lang-yaml": ["@codemirror/lang-yaml@6.1.2", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.2.0", "@lezer/lr": "^1.0.0", "@lezer/yaml": "^1.0.0" } }, "sha512-dxrfG8w5Ce/QbT7YID7mWZFKhdhsaTNOYjOkSIMt1qmC4VQnXSDSYVHHHn8k6kJUfIhtLo8t1JJgltlxWdsITw=="],
|
||||||
|
|
||||||
"@codemirror/language": ["@codemirror/language@6.12.1", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-Fa6xkSiuGKc8XC8Cn96T+TQHYj4ZZ7RdFmXA3i9xe/3hLHfwPZdM+dqfX0Cp0zQklBKhVD8Yzc8LS45rkqcwpQ=="],
|
"@codemirror/language": ["@codemirror/language@6.12.2", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-jEPmz2nGGDxhRTg3lTpzmIyGKxz3Gp3SJES4b0nAuE5SWQoKdT5GoQ69cwMmFd+wvFUhYirtDTr0/DRHpQAyWg=="],
|
||||||
|
|
||||||
"@codemirror/language-data": ["@codemirror/language-data@6.5.2", "", { "dependencies": { "@codemirror/lang-angular": "^0.1.0", "@codemirror/lang-cpp": "^6.0.0", "@codemirror/lang-css": "^6.0.0", "@codemirror/lang-go": "^6.0.0", "@codemirror/lang-html": "^6.0.0", "@codemirror/lang-java": "^6.0.0", "@codemirror/lang-javascript": "^6.0.0", "@codemirror/lang-jinja": "^6.0.0", "@codemirror/lang-json": "^6.0.0", "@codemirror/lang-less": "^6.0.0", "@codemirror/lang-liquid": "^6.0.0", "@codemirror/lang-markdown": "^6.0.0", "@codemirror/lang-php": "^6.0.0", "@codemirror/lang-python": "^6.0.0", "@codemirror/lang-rust": "^6.0.0", "@codemirror/lang-sass": "^6.0.0", "@codemirror/lang-sql": "^6.0.0", "@codemirror/lang-vue": "^0.1.1", "@codemirror/lang-wast": "^6.0.0", "@codemirror/lang-xml": "^6.0.0", "@codemirror/lang-yaml": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/legacy-modes": "^6.4.0" } }, "sha512-CPkWBKrNS8stYbEU5kwBwTf3JB1kghlbh4FSAwzGW2TEscdeHHH4FGysREW86Mqnj3Qn09s0/6Ea/TutmoTobg=="],
|
"@codemirror/language-data": ["@codemirror/language-data@6.5.2", "", { "dependencies": { "@codemirror/lang-angular": "^0.1.0", "@codemirror/lang-cpp": "^6.0.0", "@codemirror/lang-css": "^6.0.0", "@codemirror/lang-go": "^6.0.0", "@codemirror/lang-html": "^6.0.0", "@codemirror/lang-java": "^6.0.0", "@codemirror/lang-javascript": "^6.0.0", "@codemirror/lang-jinja": "^6.0.0", "@codemirror/lang-json": "^6.0.0", "@codemirror/lang-less": "^6.0.0", "@codemirror/lang-liquid": "^6.0.0", "@codemirror/lang-markdown": "^6.0.0", "@codemirror/lang-php": "^6.0.0", "@codemirror/lang-python": "^6.0.0", "@codemirror/lang-rust": "^6.0.0", "@codemirror/lang-sass": "^6.0.0", "@codemirror/lang-sql": "^6.0.0", "@codemirror/lang-vue": "^0.1.1", "@codemirror/lang-wast": "^6.0.0", "@codemirror/lang-xml": "^6.0.0", "@codemirror/lang-yaml": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/legacy-modes": "^6.4.0" } }, "sha512-CPkWBKrNS8stYbEU5kwBwTf3JB1kghlbh4FSAwzGW2TEscdeHHH4FGysREW86Mqnj3Qn09s0/6Ea/TutmoTobg=="],
|
||||||
|
|
||||||
@@ -1176,6 +1179,8 @@
|
|||||||
|
|
||||||
"@types/prismjs": ["@types/prismjs@1.26.6", "", {}, "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw=="],
|
"@types/prismjs": ["@types/prismjs@1.26.6", "", {}, "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw=="],
|
||||||
|
|
||||||
|
"@types/qrcode": ["@types/qrcode@1.5.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw=="],
|
||||||
|
|
||||||
"@types/react": ["@types/react@19.2.13", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ=="],
|
"@types/react": ["@types/react@19.2.13", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ=="],
|
||||||
|
|
||||||
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||||
@@ -1396,6 +1401,8 @@
|
|||||||
|
|
||||||
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
|
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
|
||||||
|
|
||||||
|
"camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
|
||||||
|
|
||||||
"caniuse-lite": ["caniuse-lite@1.0.30001769", "", {}, "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg=="],
|
"caniuse-lite": ["caniuse-lite@1.0.30001769", "", {}, "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg=="],
|
||||||
|
|
||||||
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
|
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
|
||||||
@@ -1514,6 +1521,8 @@
|
|||||||
|
|
||||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||||
|
|
||||||
|
"decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="],
|
||||||
|
|
||||||
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
|
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
|
||||||
|
|
||||||
"decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
|
"decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
|
||||||
@@ -1554,6 +1563,8 @@
|
|||||||
|
|
||||||
"diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
|
"diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
|
||||||
|
|
||||||
|
"dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="],
|
||||||
|
|
||||||
"dir-compare": ["dir-compare@3.3.0", "", { "dependencies": { "buffer-equal": "^1.0.0", "minimatch": "^3.0.4" } }, "sha512-J7/et3WlGUCxjdnD3HAAzQ6nsnc0WL6DD7WcwJb7c39iH1+AWfg+9OqzJNaI6PkBwBvm1mhZNL9iY/nRiZXlPg=="],
|
"dir-compare": ["dir-compare@3.3.0", "", { "dependencies": { "buffer-equal": "^1.0.0", "minimatch": "^3.0.4" } }, "sha512-J7/et3WlGUCxjdnD3HAAzQ6nsnc0WL6DD7WcwJb7c39iH1+AWfg+9OqzJNaI6PkBwBvm1mhZNL9iY/nRiZXlPg=="],
|
||||||
|
|
||||||
"dmg-builder": ["dmg-builder@24.13.3", "", { "dependencies": { "app-builder-lib": "24.13.3", "builder-util": "24.13.1", "builder-util-runtime": "9.2.4", "fs-extra": "^10.1.0", "iconv-lite": "^0.6.2", "js-yaml": "^4.1.0" }, "optionalDependencies": { "dmg-license": "^1.0.11" } }, "sha512-rcJUkMfnJpfCboZoOOPf4L29TRtEieHNOeAbYPWPxlaBw/Z1RKrRA86dOI9rwaI4tQSc/RD82zTNHprfUHXsoQ=="],
|
"dmg-builder": ["dmg-builder@24.13.3", "", { "dependencies": { "app-builder-lib": "24.13.3", "builder-util": "24.13.1", "builder-util-runtime": "9.2.4", "fs-extra": "^10.1.0", "iconv-lite": "^0.6.2", "js-yaml": "^4.1.0" }, "optionalDependencies": { "dmg-license": "^1.0.11" } }, "sha512-rcJUkMfnJpfCboZoOOPf4L29TRtEieHNOeAbYPWPxlaBw/Z1RKrRA86dOI9rwaI4tQSc/RD82zTNHprfUHXsoQ=="],
|
||||||
@@ -2342,6 +2353,8 @@
|
|||||||
|
|
||||||
"p-map": ["p-map@7.0.4", "", {}, "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ=="],
|
"p-map": ["p-map@7.0.4", "", {}, "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ=="],
|
||||||
|
|
||||||
|
"p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
|
||||||
|
|
||||||
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
|
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
|
||||||
|
|
||||||
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
|
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
|
||||||
@@ -2386,6 +2399,8 @@
|
|||||||
|
|
||||||
"pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="],
|
"pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="],
|
||||||
|
|
||||||
|
"pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="],
|
||||||
|
|
||||||
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
|
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
|
||||||
|
|
||||||
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||||
@@ -2420,6 +2435,8 @@
|
|||||||
|
|
||||||
"pupa": ["pupa@3.3.0", "", { "dependencies": { "escape-goat": "^4.0.0" } }, "sha512-LjgDO2zPtoXP2wJpDjZrGdojii1uqO0cnwKoIoUzkfS98HDmbeiGmYiXo3lXeFlq2xvne1QFQhwYXSUCLKtEuA=="],
|
"pupa": ["pupa@3.3.0", "", { "dependencies": { "escape-goat": "^4.0.0" } }, "sha512-LjgDO2zPtoXP2wJpDjZrGdojii1uqO0cnwKoIoUzkfS98HDmbeiGmYiXo3lXeFlq2xvne1QFQhwYXSUCLKtEuA=="],
|
||||||
|
|
||||||
|
"qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="],
|
||||||
|
|
||||||
"qrcode-terminal": ["qrcode-terminal@0.12.0", "", { "bin": { "qrcode-terminal": "./bin/qrcode-terminal.js" } }, "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ=="],
|
"qrcode-terminal": ["qrcode-terminal@0.12.0", "", { "bin": { "qrcode-terminal": "./bin/qrcode-terminal.js" } }, "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ=="],
|
||||||
|
|
||||||
"qs": ["qs@6.14.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ=="],
|
"qs": ["qs@6.14.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ=="],
|
||||||
@@ -2508,6 +2525,8 @@
|
|||||||
|
|
||||||
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||||
|
|
||||||
|
"require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="],
|
||||||
|
|
||||||
"requires-port": ["requires-port@1.0.0", "", {}, "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="],
|
"requires-port": ["requires-port@1.0.0", "", {}, "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="],
|
||||||
|
|
||||||
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
|
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
|
||||||
@@ -2566,6 +2585,8 @@
|
|||||||
|
|
||||||
"serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
|
"serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
|
||||||
|
|
||||||
|
"set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="],
|
||||||
|
|
||||||
"set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="],
|
"set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="],
|
||||||
|
|
||||||
"set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="],
|
"set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="],
|
||||||
@@ -2884,6 +2905,8 @@
|
|||||||
|
|
||||||
"which-collection": ["which-collection@1.0.2", "", { "dependencies": { "is-map": "^2.0.3", "is-set": "^2.0.3", "is-weakmap": "^2.0.2", "is-weakset": "^2.0.3" } }, "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw=="],
|
"which-collection": ["which-collection@1.0.2", "", { "dependencies": { "is-map": "^2.0.3", "is-set": "^2.0.3", "is-weakmap": "^2.0.2", "is-weakset": "^2.0.3" } }, "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw=="],
|
||||||
|
|
||||||
|
"which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="],
|
||||||
|
|
||||||
"which-typed-array": ["which-typed-array@1.1.20", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg=="],
|
"which-typed-array": ["which-typed-array@1.1.20", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg=="],
|
||||||
|
|
||||||
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
|
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
|
||||||
@@ -3178,6 +3201,8 @@
|
|||||||
|
|
||||||
"openai/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
|
"openai/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
|
||||||
|
|
||||||
|
"openchamber/@opencode-ai/sdk": ["@opencode-ai/sdk@1.2.10", "", {}, "sha512-SyXcVqry2hitPVvQtvXOhqsWyFhSycG/+LTLYXrcq8AFmd9FR7dyBSDB3f5Ol6IPkYOegk8P2Eg2kKPNSNiKGw=="],
|
||||||
|
|
||||||
"parse-json/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
|
"parse-json/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
|
||||||
|
|
||||||
"parse-semver/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="],
|
"parse-semver/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="],
|
||||||
@@ -3188,6 +3213,8 @@
|
|||||||
|
|
||||||
"plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
|
"plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
|
||||||
|
|
||||||
|
"qrcode/yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
|
||||||
|
|
||||||
"raw-body/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
|
"raw-body/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
|
||||||
|
|
||||||
"rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="],
|
"rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="],
|
||||||
@@ -3326,6 +3353,14 @@
|
|||||||
|
|
||||||
"openai/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
|
"openai/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
|
||||||
|
|
||||||
|
"qrcode/yargs/cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="],
|
||||||
|
|
||||||
|
"qrcode/yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
|
||||||
|
|
||||||
|
"qrcode/yargs/y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="],
|
||||||
|
|
||||||
|
"qrcode/yargs/yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="],
|
||||||
|
|
||||||
"readdir-glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
"readdir-glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||||
|
|
||||||
"source-map/whatwg-url/tr46": ["tr46@1.0.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA=="],
|
"source-map/whatwg-url/tr46": ["tr46@1.0.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA=="],
|
||||||
@@ -3448,16 +3483,24 @@
|
|||||||
|
|
||||||
"mdast-util-mdx-jsx/parse-entities/is-alphanumerical/is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
|
"mdast-util-mdx-jsx/parse-entities/is-alphanumerical/is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
|
||||||
|
|
||||||
|
"qrcode/yargs/cliui/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
|
||||||
|
|
||||||
|
"qrcode/yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
||||||
|
|
||||||
"config-file-ts/glob/jackspeak/@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
|
"config-file-ts/glob/jackspeak/@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
|
||||||
|
|
||||||
"config-file-ts/glob/jackspeak/@isaacs/cliui/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
|
"config-file-ts/glob/jackspeak/@isaacs/cliui/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
|
||||||
|
|
||||||
"config-file-ts/glob/jackspeak/@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
|
"config-file-ts/glob/jackspeak/@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
|
||||||
|
|
||||||
|
"qrcode/yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
|
||||||
|
|
||||||
"config-file-ts/glob/jackspeak/@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
|
"config-file-ts/glob/jackspeak/@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
|
||||||
|
|
||||||
"config-file-ts/glob/jackspeak/@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
"config-file-ts/glob/jackspeak/@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||||
|
|
||||||
"config-file-ts/glob/jackspeak/@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
"config-file-ts/glob/jackspeak/@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||||
|
|
||||||
|
"qrcode/yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,6 +123,7 @@
|
|||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
|
"@codemirror/language": "6.12.2",
|
||||||
"@codemirror/view": "6.39.13"
|
"@codemirror/view": "6.39.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -2698,7 +2698,7 @@ fn main() {
|
|||||||
schedule_window_state_persist(window.clone(), false);
|
schedule_window_state_persist(window.clone(), false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if matches!(event, tauri::WindowEvent::CloseRequested { .. }) {
|
if let tauri::WindowEvent::CloseRequested { .. } = event {
|
||||||
schedule_window_state_persist(window.clone(), true);
|
schedule_window_state_persist(window.clone(), true);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -68,6 +68,7 @@
|
|||||||
"motion": "^12.23.24",
|
"motion": "^12.23.24",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"prismjs": "^1.30.0",
|
"prismjs": "^1.30.0",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-syntax-highlighter": "^15.6.6",
|
"react-syntax-highlighter": "^15.6.6",
|
||||||
@@ -86,6 +87,7 @@
|
|||||||
"@tauri-apps/api": "^2.9.0",
|
"@tauri-apps/api": "^2.9.0",
|
||||||
"@types/node": "^24.3.1",
|
"@types/node": "^24.3.1",
|
||||||
"@types/prismjs": "^1.26.6",
|
"@types/prismjs": "^1.26.6",
|
||||||
|
"@types/qrcode": "^1.5.5",
|
||||||
"@types/react": "^19.1.10",
|
"@types/react": "^19.1.10",
|
||||||
"@types/react-dom": "^19.1.7",
|
"@types/react-dom": "^19.1.7",
|
||||||
"@vitejs/plugin-react": "^5.0.0",
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
|
|||||||
@@ -106,25 +106,6 @@ interface ErrorScreenProps {
|
|||||||
retryAfter?: number;
|
retryAfter?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getTokenFromUrl = (): string | null => {
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
return params.get('token');
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearTokenFromUrl = () => {
|
|
||||||
try {
|
|
||||||
const url = new URL(window.location.href);
|
|
||||||
url.searchParams.delete('token');
|
|
||||||
window.history.replaceState({}, '', url.toString());
|
|
||||||
} catch {
|
|
||||||
// Ignore errors
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) => {
|
export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) => {
|
||||||
const vscodeRuntime = React.useMemo(() => isVSCodeRuntime(), []);
|
const vscodeRuntime = React.useMemo(() => isVSCodeRuntime(), []);
|
||||||
const skipAuth = vscodeRuntime;
|
const skipAuth = vscodeRuntime;
|
||||||
@@ -134,9 +115,9 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
|
|||||||
const [isSubmitting, setIsSubmitting] = React.useState(false);
|
const [isSubmitting, setIsSubmitting] = React.useState(false);
|
||||||
const [errorMessage, setErrorMessage] = React.useState('');
|
const [errorMessage, setErrorMessage] = React.useState('');
|
||||||
const [retryAfter, setRetryAfter] = React.useState<number | undefined>(undefined);
|
const [retryAfter, setRetryAfter] = React.useState<number | undefined>(undefined);
|
||||||
|
const [isTunnelLocked, setIsTunnelLocked] = React.useState(false);
|
||||||
const passwordInputRef = React.useRef<HTMLInputElement | null>(null);
|
const passwordInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||||
const hasResyncedRef = React.useRef(skipAuth);
|
const hasResyncedRef = React.useRef(skipAuth);
|
||||||
const hasTriedUrlTokenRef = React.useRef(false);
|
|
||||||
|
|
||||||
const checkStatus = React.useCallback(async () => {
|
const checkStatus = React.useCallback(async () => {
|
||||||
if (skipAuth) {
|
if (skipAuth) {
|
||||||
@@ -161,12 +142,13 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
|
|||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
console.log('[Frontend Auth] Session is authenticated');
|
console.log('[Frontend Auth] Session is authenticated');
|
||||||
setState('authenticated');
|
setState('authenticated');
|
||||||
|
setIsTunnelLocked(false);
|
||||||
setErrorMessage('');
|
setErrorMessage('');
|
||||||
setRetryAfter(undefined);
|
setRetryAfter(undefined);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
let data: { debug?: { hasRefreshToken: boolean; message: string } } = {};
|
let data: { tunnelLocked?: boolean; debug?: { hasRefreshToken: boolean; message: string } } = {};
|
||||||
try {
|
try {
|
||||||
data = JSON.parse(responseText);
|
data = JSON.parse(responseText);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -176,6 +158,7 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
|
|||||||
if (data.debug) {
|
if (data.debug) {
|
||||||
console.warn('[Frontend Auth] Debug info:', data.debug);
|
console.warn('[Frontend Auth] Debug info:', data.debug);
|
||||||
}
|
}
|
||||||
|
setIsTunnelLocked(data.tunnelLocked === true);
|
||||||
setState('locked');
|
setState('locked');
|
||||||
setRetryAfter(undefined);
|
setRetryAfter(undefined);
|
||||||
return;
|
return;
|
||||||
@@ -188,14 +171,17 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
|
|||||||
data = {};
|
data = {};
|
||||||
}
|
}
|
||||||
setRetryAfter(data.retryAfter);
|
setRetryAfter(data.retryAfter);
|
||||||
|
setIsTunnelLocked(false);
|
||||||
setState('rate-limited');
|
setState('rate-limited');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.error('[Frontend Auth] Unexpected response status:', response.status);
|
console.error('[Frontend Auth] Unexpected response status:', response.status);
|
||||||
setState('error');
|
setState('error');
|
||||||
|
setIsTunnelLocked(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to check session status:', error);
|
console.warn('Failed to check session status:', error);
|
||||||
setState('error');
|
setState('error');
|
||||||
|
setIsTunnelLocked(false);
|
||||||
}
|
}
|
||||||
}, [skipAuth]);
|
}, [skipAuth]);
|
||||||
|
|
||||||
@@ -219,49 +205,6 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
|
|||||||
}
|
}
|
||||||
}, [state]);
|
}, [state]);
|
||||||
|
|
||||||
// Auto-login with URL token parameter
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (skipAuth || state !== 'locked' || hasTriedUrlTokenRef.current || isSubmitting) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const urlToken = getTokenFromUrl();
|
|
||||||
if (!urlToken) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
hasTriedUrlTokenRef.current = true;
|
|
||||||
clearTokenFromUrl();
|
|
||||||
|
|
||||||
// Auto-submit the password from URL
|
|
||||||
setIsSubmitting(true);
|
|
||||||
setErrorMessage('');
|
|
||||||
|
|
||||||
submitPassword(urlToken)
|
|
||||||
.then((response) => {
|
|
||||||
if (response.ok) {
|
|
||||||
setPassword('');
|
|
||||||
setState('authenticated');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (response.status === 401) {
|
|
||||||
setErrorMessage('URL token invalid. Please enter password manually.');
|
|
||||||
setState('locked');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setErrorMessage('Unexpected response from server.');
|
|
||||||
setState('error');
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.warn('Failed to submit URL token:', error);
|
|
||||||
setErrorMessage('Network error. Check connection and retry.');
|
|
||||||
setState('error');
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
});
|
|
||||||
}, [skipAuth, state, isSubmitting]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (skipAuth) {
|
if (skipAuth) {
|
||||||
return;
|
return;
|
||||||
@@ -278,6 +221,9 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
|
|||||||
|
|
||||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
if (isTunnelLocked) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!password || isSubmitting) {
|
if (!password || isSubmitting) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -296,6 +242,7 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
|
|||||||
console.log('[Frontend Auth] After login - access:', hasAccessToken, 'refresh:', hasRefreshToken);
|
console.log('[Frontend Auth] After login - access:', hasAccessToken, 'refresh:', hasRefreshToken);
|
||||||
console.log('[Frontend Auth] All cookies after login:', cookies.split(';').map(c => c.trim().split('=')[0]).filter(Boolean));
|
console.log('[Frontend Auth] All cookies after login:', cookies.split(';').map(c => c.trim().split('=')[0]).filter(Boolean));
|
||||||
setPassword('');
|
setPassword('');
|
||||||
|
setIsTunnelLocked(false);
|
||||||
setState('authenticated');
|
setState('authenticated');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -303,6 +250,7 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
|
|||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
console.warn('[Frontend Auth] Login failed: Invalid password');
|
console.warn('[Frontend Auth] Login failed: Invalid password');
|
||||||
setErrorMessage('Incorrect password. Try again.');
|
setErrorMessage('Incorrect password. Try again.');
|
||||||
|
setIsTunnelLocked(false);
|
||||||
setState('locked');
|
setState('locked');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -311,16 +259,19 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
|
|||||||
console.warn('[Frontend Auth] Login failed: Rate limited');
|
console.warn('[Frontend Auth] Login failed: Rate limited');
|
||||||
const data = await response.json().catch(() => ({}));
|
const data = await response.json().catch(() => ({}));
|
||||||
setRetryAfter(data.retryAfter);
|
setRetryAfter(data.retryAfter);
|
||||||
|
setIsTunnelLocked(false);
|
||||||
setState('rate-limited');
|
setState('rate-limited');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error('[Frontend Auth] Login failed: Unexpected response', response.status);
|
console.error('[Frontend Auth] Login failed: Unexpected response', response.status);
|
||||||
setErrorMessage('Unexpected response from server.');
|
setErrorMessage('Unexpected response from server.');
|
||||||
|
setIsTunnelLocked(false);
|
||||||
setState('error');
|
setState('error');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to submit UI password:', error);
|
console.warn('Failed to submit UI password:', error);
|
||||||
setErrorMessage('Network error. Check connection and retry.');
|
setErrorMessage('Network error. Check connection and retry.');
|
||||||
|
setIsTunnelLocked(false);
|
||||||
setState('error');
|
setState('error');
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
@@ -345,13 +296,16 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
|
|||||||
<div className="flex flex-col items-center gap-6 w-full max-w-xs">
|
<div className="flex flex-col items-center gap-6 w-full max-w-xs">
|
||||||
<div className="flex flex-col items-center gap-1 text-center">
|
<div className="flex flex-col items-center gap-1 text-center">
|
||||||
<h1 className="text-xl font-semibold text-foreground">
|
<h1 className="text-xl font-semibold text-foreground">
|
||||||
Unlock OpenChamber
|
{isTunnelLocked ? 'Tunnel access required' : 'Unlock OpenChamber'}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="typography-meta text-muted-foreground">
|
<p className="typography-meta text-muted-foreground">
|
||||||
This session is password-protected.
|
{isTunnelLocked
|
||||||
|
? 'Open this tunnel using the one-time connect link from the desktop app.'
|
||||||
|
: 'This session is password-protected.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!isTunnelLocked && (
|
||||||
<form onSubmit={handleSubmit} className="w-full space-y-2" data-keyboard-avoid="true">
|
<form onSubmit={handleSubmit} className="w-full space-y-2" data-keyboard-avoid="true">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
@@ -394,6 +348,7 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
{showHostSwitcher && (
|
{showHostSwitcher && (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { GitSettings } from './GitSettings';
|
|||||||
import { NotificationSettings } from './NotificationSettings';
|
import { NotificationSettings } from './NotificationSettings';
|
||||||
import { GitHubSettings } from './GitHubSettings';
|
import { GitHubSettings } from './GitHubSettings';
|
||||||
import { VoiceSettings } from './VoiceSettings';
|
import { VoiceSettings } from './VoiceSettings';
|
||||||
|
import { TunnelSettings } from './TunnelSettings';
|
||||||
import { OpenCodeCliSettings } from './OpenCodeCliSettings';
|
import { OpenCodeCliSettings } from './OpenCodeCliSettings';
|
||||||
import { KeyboardShortcutsSettings } from './KeyboardShortcutsSettings';
|
import { KeyboardShortcutsSettings } from './KeyboardShortcutsSettings';
|
||||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||||
@@ -75,6 +76,8 @@ export const OpenChamberPage: React.FC<OpenChamberPageProps> = ({ section }) =>
|
|||||||
return <NotificationSectionContent />;
|
return <NotificationSectionContent />;
|
||||||
case 'voice':
|
case 'voice':
|
||||||
return <VoiceSectionContent />;
|
return <VoiceSectionContent />;
|
||||||
|
case 'tunnel':
|
||||||
|
return <TunnelSectionContent />;
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -157,3 +160,10 @@ const VoiceSectionContent: React.FC = () => {
|
|||||||
}
|
}
|
||||||
return <VoiceSettings />;
|
return <VoiceSettings />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const TunnelSectionContent: React.FC = () => {
|
||||||
|
if (isVSCodeRuntime()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return <TunnelSettings />;
|
||||||
|
};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -6,4 +6,5 @@ export type OpenChamberSection =
|
|||||||
| 'git'
|
| 'git'
|
||||||
| 'github'
|
| 'github'
|
||||||
| 'notifications'
|
| 'notifications'
|
||||||
| 'voice';
|
| 'voice'
|
||||||
|
| 'tunnel';
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
RiCloudLine,
|
RiCloudLine,
|
||||||
RiFoldersLine,
|
RiFoldersLine,
|
||||||
RiGitBranchLine,
|
RiGitBranchLine,
|
||||||
|
RiGlobalLine,
|
||||||
RiMicLine,
|
RiMicLine,
|
||||||
RiNotification3Line,
|
RiNotification3Line,
|
||||||
RiPaletteLine,
|
RiPaletteLine,
|
||||||
@@ -97,6 +97,7 @@ const pageOrder: SettingsPageSlug[] = [
|
|||||||
'skills.installed',
|
'skills.installed',
|
||||||
'skills.catalog',
|
'skills.catalog',
|
||||||
'voice',
|
'voice',
|
||||||
|
'tunnel',
|
||||||
];
|
];
|
||||||
|
|
||||||
function buildRuntimeContext(isDesktop: boolean): SettingsRuntimeContext {
|
function buildRuntimeContext(isDesktop: boolean): SettingsRuntimeContext {
|
||||||
@@ -150,6 +151,8 @@ function getSettingsNavIcon(slug: SettingsPageSlug): React.ComponentType<{ class
|
|||||||
return RiBarChart2Line;
|
return RiBarChart2Line;
|
||||||
case 'voice':
|
case 'voice':
|
||||||
return RiMicLine;
|
return RiMicLine;
|
||||||
|
case 'tunnel':
|
||||||
|
return RiGlobalLine;
|
||||||
case 'home':
|
case 'home':
|
||||||
return null;
|
return null;
|
||||||
default:
|
default:
|
||||||
@@ -367,6 +370,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
|||||||
sessions: 'sessions',
|
sessions: 'sessions',
|
||||||
notifications: 'notifications',
|
notifications: 'notifications',
|
||||||
voice: 'voice',
|
voice: 'voice',
|
||||||
|
tunnel: 'tunnel',
|
||||||
}), []);
|
}), []);
|
||||||
|
|
||||||
const renderUnavailable = React.useCallback(() => {
|
const renderUnavailable = React.useCallback(() => {
|
||||||
@@ -437,7 +441,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
|||||||
case 'shortcuts':
|
case 'shortcuts':
|
||||||
case 'sessions':
|
case 'sessions':
|
||||||
case 'notifications':
|
case 'notifications':
|
||||||
case 'voice': {
|
case 'voice':
|
||||||
|
case 'tunnel': {
|
||||||
const section = openChamberSectionBySlug[slug] ?? 'visual';
|
const section = openChamberSectionBySlug[slug] ?? 'visual';
|
||||||
return <OpenChamberPage section={section} />;
|
return <OpenChamberPage section={section} />;
|
||||||
}
|
}
|
||||||
@@ -511,7 +516,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="typography-ui-label font-normal truncate">{page.title}</span>
|
<span className="typography-ui-label font-normal truncate">{page.title}</span>
|
||||||
{page.slug === 'voice' && (
|
{(page.slug === 'voice' || page.slug === 'tunnel') && (
|
||||||
<span className="shrink-0 typography-micro px-1 rounded leading-none pb-px text-[var(--status-warning)] bg-[var(--status-warning)]/10">
|
<span className="shrink-0 typography-micro px-1 rounded leading-none pb-px text-[var(--status-warning)] bg-[var(--status-warning)]/10">
|
||||||
beta
|
beta
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ export type SkillCatalogConfig = {
|
|||||||
gitIdentityId?: string;
|
gitIdentityId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type NamedTunnelPreset = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
hostname: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type DesktopSettings = {
|
export type DesktopSettings = {
|
||||||
themeId?: string;
|
themeId?: string;
|
||||||
useSystemTheme?: boolean;
|
useSystemTheme?: boolean;
|
||||||
@@ -84,6 +90,15 @@ export type DesktopSettings = {
|
|||||||
}>; // Per-provider custom model groups configuration
|
}>; // Per-provider custom model groups configuration
|
||||||
autoDeleteEnabled?: boolean;
|
autoDeleteEnabled?: boolean;
|
||||||
autoDeleteAfterDays?: number;
|
autoDeleteAfterDays?: number;
|
||||||
|
tunnelMode?: 'quick' | 'named';
|
||||||
|
tunnelBootstrapTtlMs?: number | null;
|
||||||
|
tunnelSessionTtlMs?: number;
|
||||||
|
namedTunnelHostname?: string;
|
||||||
|
namedTunnelToken?: string | null;
|
||||||
|
hasNamedTunnelToken?: boolean;
|
||||||
|
namedTunnelPresets?: NamedTunnelPreset[];
|
||||||
|
namedTunnelSelectedPresetId?: string;
|
||||||
|
namedTunnelPresetTokens?: Record<string, string>;
|
||||||
defaultModel?: string; // format: "provider/model"
|
defaultModel?: string; // format: "provider/model"
|
||||||
defaultVariant?: string;
|
defaultVariant?: string;
|
||||||
defaultAgent?: string;
|
defaultAgent?: string;
|
||||||
|
|||||||
@@ -208,6 +208,51 @@ const sanitizeProjects = (value: unknown): DesktopSettings['projects'] | undefin
|
|||||||
return result.length > 0 ? result : undefined;
|
return result.length > 0 ? result : undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sanitizeNamedTunnelPresets = (value: unknown): DesktopSettings['namedTunnelPresets'] | undefined => {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: NonNullable<DesktopSettings['namedTunnelPresets']> = [];
|
||||||
|
const seenIds = new Set<string>();
|
||||||
|
const seenHostnames = new Set<string>();
|
||||||
|
|
||||||
|
for (const entry of value) {
|
||||||
|
if (!entry || typeof entry !== 'object') continue;
|
||||||
|
const candidate = entry as Record<string, unknown>;
|
||||||
|
|
||||||
|
const id = typeof candidate.id === 'string' ? candidate.id.trim() : '';
|
||||||
|
const name = typeof candidate.name === 'string' ? candidate.name.trim() : '';
|
||||||
|
const hostname = typeof candidate.hostname === 'string' ? candidate.hostname.trim().toLowerCase() : '';
|
||||||
|
|
||||||
|
if (!id || !name || !hostname) continue;
|
||||||
|
if (seenIds.has(id) || seenHostnames.has(hostname)) continue;
|
||||||
|
seenIds.add(id);
|
||||||
|
seenHostnames.add(hostname);
|
||||||
|
|
||||||
|
result.push({ id, name, hostname });
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sanitizeNamedTunnelPresetTokens = (value: unknown): DesktopSettings['namedTunnelPresetTokens'] | undefined => {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = value as Record<string, unknown>;
|
||||||
|
const result: Record<string, string> = {};
|
||||||
|
for (const [key, tokenValue] of Object.entries(candidate)) {
|
||||||
|
const id = key.trim();
|
||||||
|
const token = typeof tokenValue === 'string' ? tokenValue.trim() : '';
|
||||||
|
if (!id || !token) continue;
|
||||||
|
result[id] = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(result).length > 0 ? result : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
const sanitizeModelRefs = (value: unknown, limit: number): Array<{ providerID: string; modelID: string }> | undefined => {
|
const sanitizeModelRefs = (value: unknown, limit: number): Array<{ providerID: string; modelID: string }> | undefined => {
|
||||||
if (!Array.isArray(value)) {
|
if (!Array.isArray(value)) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -444,6 +489,40 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
|
|||||||
if (typeof candidate.autoDeleteAfterDays === 'number' && Number.isFinite(candidate.autoDeleteAfterDays)) {
|
if (typeof candidate.autoDeleteAfterDays === 'number' && Number.isFinite(candidate.autoDeleteAfterDays)) {
|
||||||
result.autoDeleteAfterDays = candidate.autoDeleteAfterDays;
|
result.autoDeleteAfterDays = candidate.autoDeleteAfterDays;
|
||||||
}
|
}
|
||||||
|
if (typeof candidate.tunnelMode === 'string') {
|
||||||
|
const mode = candidate.tunnelMode.trim().toLowerCase();
|
||||||
|
if (mode === 'quick' || mode === 'named') {
|
||||||
|
result.tunnelMode = mode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (candidate.tunnelBootstrapTtlMs === null) {
|
||||||
|
result.tunnelBootstrapTtlMs = null;
|
||||||
|
} else if (typeof candidate.tunnelBootstrapTtlMs === 'number' && Number.isFinite(candidate.tunnelBootstrapTtlMs)) {
|
||||||
|
result.tunnelBootstrapTtlMs = candidate.tunnelBootstrapTtlMs;
|
||||||
|
}
|
||||||
|
if (typeof candidate.tunnelSessionTtlMs === 'number' && Number.isFinite(candidate.tunnelSessionTtlMs)) {
|
||||||
|
result.tunnelSessionTtlMs = candidate.tunnelSessionTtlMs;
|
||||||
|
}
|
||||||
|
if (typeof candidate.namedTunnelHostname === 'string') {
|
||||||
|
result.namedTunnelHostname = candidate.namedTunnelHostname.trim();
|
||||||
|
}
|
||||||
|
if (candidate.namedTunnelToken === null) {
|
||||||
|
result.namedTunnelToken = null;
|
||||||
|
} else if (typeof candidate.namedTunnelToken === 'string') {
|
||||||
|
result.namedTunnelToken = candidate.namedTunnelToken.trim();
|
||||||
|
}
|
||||||
|
const namedTunnelPresets = sanitizeNamedTunnelPresets(candidate.namedTunnelPresets);
|
||||||
|
if (namedTunnelPresets) {
|
||||||
|
result.namedTunnelPresets = namedTunnelPresets;
|
||||||
|
}
|
||||||
|
if (typeof candidate.namedTunnelSelectedPresetId === 'string') {
|
||||||
|
const trimmed = candidate.namedTunnelSelectedPresetId.trim();
|
||||||
|
result.namedTunnelSelectedPresetId = trimmed.length > 0 ? trimmed : undefined;
|
||||||
|
}
|
||||||
|
const namedTunnelPresetTokens = sanitizeNamedTunnelPresetTokens(candidate.namedTunnelPresetTokens);
|
||||||
|
if (namedTunnelPresetTokens) {
|
||||||
|
result.namedTunnelPresetTokens = namedTunnelPresetTokens;
|
||||||
|
}
|
||||||
if (typeof candidate.defaultModel === 'string' && candidate.defaultModel.length > 0) {
|
if (typeof candidate.defaultModel === 'string' && candidate.defaultModel.length > 0) {
|
||||||
result.defaultModel = candidate.defaultModel;
|
result.defaultModel = candidate.defaultModel;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ export type SettingsPageSlug =
|
|||||||
| 'shortcuts'
|
| 'shortcuts'
|
||||||
| 'sessions'
|
| 'sessions'
|
||||||
| 'notifications'
|
| 'notifications'
|
||||||
| 'voice';
|
| 'voice'
|
||||||
|
| 'tunnel';
|
||||||
|
|
||||||
export type SettingsPageGroup =
|
export type SettingsPageGroup =
|
||||||
| 'appearance'
|
| 'appearance'
|
||||||
@@ -168,6 +169,7 @@ export const SETTINGS_PAGE_METADATA: readonly SettingsPageMeta[] = [
|
|||||||
|
|
||||||
{ slug: 'notifications', title: 'Notifications', group: 'general', kind: 'single', keywords: ['alerts', 'native', 'summary', 'summarization'], },
|
{ slug: 'notifications', title: 'Notifications', group: 'general', kind: 'single', keywords: ['alerts', 'native', 'summary', 'summarization'], },
|
||||||
{ slug: 'voice', title: 'Voice', group: 'advanced', kind: 'single', keywords: ['tts', 'speech', 'voice'], isAvailable: (ctx) => !ctx.isVSCode },
|
{ slug: 'voice', title: 'Voice', group: 'advanced', kind: 'single', keywords: ['tts', 'speech', 'voice'], isAvailable: (ctx) => !ctx.isVSCode },
|
||||||
|
{ slug: 'tunnel', title: 'Remote Tunnel', group: 'advanced', kind: 'single', keywords: ['tunnel', 'cloudflare', 'qr', 'remote', 'mobile', 'share'], isAvailable: (ctx) => !ctx.isVSCode },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const LEGACY_SIDEBAR_SECTION_TO_SETTINGS_SLUG: Record<SidebarSection, SettingsPageSlug> = {
|
export const LEGACY_SIDEBAR_SECTION_TO_SETTINGS_SLUG: Record<SidebarSection, SettingsPageSlug> = {
|
||||||
|
|||||||
@@ -9,7 +9,13 @@ import { fileURLToPath } from 'url';
|
|||||||
import os from 'os';
|
import os from 'os';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import { createUiAuth } from './lib/opencode/ui-auth.js';
|
import { createUiAuth } from './lib/opencode/ui-auth.js';
|
||||||
import { startCloudflareTunnel, printTunnelWarning, checkCloudflaredAvailable } from './lib/cloudflare-tunnel.js';
|
import { createTunnelAuth } from './lib/opencode/tunnel-auth.js';
|
||||||
|
import {
|
||||||
|
startCloudflareQuickTunnel,
|
||||||
|
startCloudflareNamedTunnel,
|
||||||
|
printTunnelWarning,
|
||||||
|
checkCloudflaredAvailable,
|
||||||
|
} from './lib/cloudflare-tunnel.js';
|
||||||
import { prepareNotificationLastMessage } from './lib/notifications/index.js';
|
import { prepareNotificationLastMessage } from './lib/notifications/index.js';
|
||||||
import {
|
import {
|
||||||
TERMINAL_INPUT_WS_MAX_PAYLOAD_BYTES,
|
TERMINAL_INPUT_WS_MAX_PAYLOAD_BYTES,
|
||||||
@@ -36,6 +42,14 @@ const MODELS_METADATA_CACHE_TTL = 5 * 60 * 1000;
|
|||||||
const CLIENT_RELOAD_DELAY_MS = 800;
|
const CLIENT_RELOAD_DELAY_MS = 800;
|
||||||
const OPEN_CODE_READY_GRACE_MS = 12000;
|
const OPEN_CODE_READY_GRACE_MS = 12000;
|
||||||
const LONG_REQUEST_TIMEOUT_MS = 4 * 60 * 1000;
|
const LONG_REQUEST_TIMEOUT_MS = 4 * 60 * 1000;
|
||||||
|
const TUNNEL_BOOTSTRAP_TTL_DEFAULT_MS = 30 * 60 * 1000;
|
||||||
|
const TUNNEL_BOOTSTRAP_TTL_MIN_MS = 60 * 1000;
|
||||||
|
const TUNNEL_BOOTSTRAP_TTL_MAX_MS = 24 * 60 * 60 * 1000;
|
||||||
|
const TUNNEL_SESSION_TTL_DEFAULT_MS = 8 * 60 * 60 * 1000;
|
||||||
|
const TUNNEL_SESSION_TTL_MIN_MS = 5 * 60 * 1000;
|
||||||
|
const TUNNEL_SESSION_TTL_MAX_MS = 24 * 60 * 60 * 1000;
|
||||||
|
const TUNNEL_MODE_QUICK = 'quick';
|
||||||
|
const TUNNEL_MODE_NAMED = 'named';
|
||||||
const OPENCHAMBER_VERSION = (() => {
|
const OPENCHAMBER_VERSION = (() => {
|
||||||
try {
|
try {
|
||||||
const packagePath = path.resolve(__dirname, '..', 'package.json');
|
const packagePath = path.resolve(__dirname, '..', 'package.json');
|
||||||
@@ -96,6 +110,106 @@ const MAX_THEME_JSON_BYTES = 512 * 1024;
|
|||||||
|
|
||||||
const isNonEmptyString = (value) => typeof value === 'string' && value.trim().length > 0;
|
const isNonEmptyString = (value) => typeof value === 'string' && value.trim().length > 0;
|
||||||
|
|
||||||
|
const clampNumber = (value, min, max) => Math.max(min, Math.min(max, value));
|
||||||
|
|
||||||
|
const normalizeTunnelBootstrapTtlMs = (value) => {
|
||||||
|
if (value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
return TUNNEL_BOOTSTRAP_TTL_DEFAULT_MS;
|
||||||
|
}
|
||||||
|
return clampNumber(Math.round(value), TUNNEL_BOOTSTRAP_TTL_MIN_MS, TUNNEL_BOOTSTRAP_TTL_MAX_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeTunnelSessionTtlMs = (value) => {
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
return TUNNEL_SESSION_TTL_DEFAULT_MS;
|
||||||
|
}
|
||||||
|
return clampNumber(Math.round(value), TUNNEL_SESSION_TTL_MIN_MS, TUNNEL_SESSION_TTL_MAX_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeTunnelMode = (value) => {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return TUNNEL_MODE_QUICK;
|
||||||
|
}
|
||||||
|
const mode = value.trim().toLowerCase();
|
||||||
|
if (mode === TUNNEL_MODE_NAMED) {
|
||||||
|
return TUNNEL_MODE_NAMED;
|
||||||
|
}
|
||||||
|
return TUNNEL_MODE_QUICK;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeNamedTunnelHostname = (value) => {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = (() => {
|
||||||
|
try {
|
||||||
|
if (trimmed.includes('://')) {
|
||||||
|
return new URL(trimmed);
|
||||||
|
}
|
||||||
|
return new URL(`https://${trimmed}`);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
const hostname = parsed?.hostname?.trim().toLowerCase() || '';
|
||||||
|
if (!hostname) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return hostname;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeNamedTunnelPresets = (value) => {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = [];
|
||||||
|
const seenIds = new Set();
|
||||||
|
const seenHostnames = new Set();
|
||||||
|
|
||||||
|
for (const entry of value) {
|
||||||
|
if (!entry || typeof entry !== 'object') continue;
|
||||||
|
const candidate = entry;
|
||||||
|
const id = typeof candidate.id === 'string' ? candidate.id.trim() : '';
|
||||||
|
const name = typeof candidate.name === 'string' ? candidate.name.trim() : '';
|
||||||
|
const hostname = normalizeNamedTunnelHostname(candidate.hostname);
|
||||||
|
if (!id || !name || !hostname) continue;
|
||||||
|
if (seenIds.has(id) || seenHostnames.has(hostname)) continue;
|
||||||
|
seenIds.add(id);
|
||||||
|
seenHostnames.add(hostname);
|
||||||
|
result.push({ id, name, hostname });
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeNamedTunnelPresetTokens = (value) => {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = {};
|
||||||
|
for (const [rawId, rawToken] of Object.entries(value)) {
|
||||||
|
const id = typeof rawId === 'string' ? rawId.trim() : '';
|
||||||
|
const token = typeof rawToken === 'string' ? rawToken.trim() : '';
|
||||||
|
if (!id || !token) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result[id] = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(result).length > 0 ? result : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
const isValidThemeColor = (value) => isNonEmptyString(value);
|
const isValidThemeColor = (value) => isNonEmptyString(value);
|
||||||
|
|
||||||
const normalizeThemeJson = (raw) => {
|
const normalizeThemeJson = (raw) => {
|
||||||
@@ -1021,6 +1135,8 @@ const OPENCHAMBER_DATA_DIR = process.env.OPENCHAMBER_DATA_DIR
|
|||||||
: path.join(os.homedir(), '.config', 'openchamber');
|
: path.join(os.homedir(), '.config', 'openchamber');
|
||||||
const SETTINGS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'settings.json');
|
const SETTINGS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'settings.json');
|
||||||
const PUSH_SUBSCRIPTIONS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'push-subscriptions.json');
|
const PUSH_SUBSCRIPTIONS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'push-subscriptions.json');
|
||||||
|
const CLOUDFLARE_NAMED_TUNNELS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'cloudflare-named-tunnels.json');
|
||||||
|
const CLOUDFLARE_NAMED_TUNNELS_VERSION = 1;
|
||||||
const PROJECT_ICONS_DIR_PATH = path.join(OPENCHAMBER_DATA_DIR, 'project-icons');
|
const PROJECT_ICONS_DIR_PATH = path.join(OPENCHAMBER_DATA_DIR, 'project-icons');
|
||||||
const PROJECT_ICON_MIME_TO_EXTENSION = {
|
const PROJECT_ICON_MIME_TO_EXTENSION = {
|
||||||
'image/png': 'png',
|
'image/png': 'png',
|
||||||
@@ -1154,6 +1270,7 @@ const writeSettingsToDisk = async (settings) => {
|
|||||||
|
|
||||||
const PUSH_SUBSCRIPTIONS_VERSION = 1;
|
const PUSH_SUBSCRIPTIONS_VERSION = 1;
|
||||||
let persistPushSubscriptionsLock = Promise.resolve();
|
let persistPushSubscriptionsLock = Promise.resolve();
|
||||||
|
let persistNamedTunnelConfigLock = Promise.resolve();
|
||||||
|
|
||||||
const readPushSubscriptionsFromDisk = async () => {
|
const readPushSubscriptionsFromDisk = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -1201,6 +1318,167 @@ const persistPushSubscriptionUpdate = async (mutate) => {
|
|||||||
return persistPushSubscriptionsLock;
|
return persistPushSubscriptionsLock;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sanitizeNamedTunnelConfigEntries = (value) => {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = [];
|
||||||
|
const seenIds = new Set();
|
||||||
|
const seenHostnames = new Set();
|
||||||
|
for (const entry of value) {
|
||||||
|
if (!entry || typeof entry !== 'object') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = typeof entry.id === 'string' ? entry.id.trim() : '';
|
||||||
|
const name = typeof entry.name === 'string' ? entry.name.trim() : '';
|
||||||
|
const hostname = normalizeNamedTunnelHostname(entry.hostname);
|
||||||
|
const token = typeof entry.token === 'string' ? entry.token.trim() : '';
|
||||||
|
const updatedAt = Number.isFinite(entry.updatedAt) ? entry.updatedAt : Date.now();
|
||||||
|
|
||||||
|
if (!id || !name || !hostname || !token) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (seenIds.has(id) || seenHostnames.has(hostname)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
seenIds.add(id);
|
||||||
|
seenHostnames.add(hostname);
|
||||||
|
result.push({ id, name, hostname, token, updatedAt });
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readNamedTunnelConfigFromDisk = async () => {
|
||||||
|
try {
|
||||||
|
const raw = await fsPromises.readFile(CLOUDFLARE_NAMED_TUNNELS_FILE_PATH, 'utf8');
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!parsed || typeof parsed !== 'object') {
|
||||||
|
return { version: CLOUDFLARE_NAMED_TUNNELS_VERSION, tunnels: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = parsed.version === CLOUDFLARE_NAMED_TUNNELS_VERSION
|
||||||
|
? CLOUDFLARE_NAMED_TUNNELS_VERSION
|
||||||
|
: CLOUDFLARE_NAMED_TUNNELS_VERSION;
|
||||||
|
|
||||||
|
return {
|
||||||
|
version,
|
||||||
|
tunnels: sanitizeNamedTunnelConfigEntries(parsed.tunnels),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error && typeof error === 'object' && error.code === 'ENOENT') {
|
||||||
|
return { version: CLOUDFLARE_NAMED_TUNNELS_VERSION, tunnels: [] };
|
||||||
|
}
|
||||||
|
console.warn('Failed to read named tunnel config file:', error);
|
||||||
|
return { version: CLOUDFLARE_NAMED_TUNNELS_VERSION, tunnels: [] };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const writeNamedTunnelConfigToDisk = async (data) => {
|
||||||
|
await fsPromises.mkdir(path.dirname(CLOUDFLARE_NAMED_TUNNELS_FILE_PATH), { recursive: true });
|
||||||
|
await fsPromises.writeFile(CLOUDFLARE_NAMED_TUNNELS_FILE_PATH, JSON.stringify(data, null, 2), 'utf8');
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateNamedTunnelConfig = async (mutate) => {
|
||||||
|
persistNamedTunnelConfigLock = persistNamedTunnelConfigLock.then(async () => {
|
||||||
|
const current = await readNamedTunnelConfigFromDisk();
|
||||||
|
const next = mutate({
|
||||||
|
version: CLOUDFLARE_NAMED_TUNNELS_VERSION,
|
||||||
|
tunnels: sanitizeNamedTunnelConfigEntries(current.tunnels),
|
||||||
|
});
|
||||||
|
|
||||||
|
await writeNamedTunnelConfigToDisk({
|
||||||
|
version: CLOUDFLARE_NAMED_TUNNELS_VERSION,
|
||||||
|
tunnels: sanitizeNamedTunnelConfigEntries(next?.tunnels),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return persistNamedTunnelConfigLock;
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncNamedTunnelConfigWithPresets = async (presets) => {
|
||||||
|
const sanitizedPresets = normalizeNamedTunnelPresets(presets) || [];
|
||||||
|
|
||||||
|
await updateNamedTunnelConfig((current) => {
|
||||||
|
const byId = new Map(current.tunnels.map((entry) => [entry.id, entry]));
|
||||||
|
const byHostname = new Map(current.tunnels.map((entry) => [entry.hostname, entry]));
|
||||||
|
|
||||||
|
const nextTunnels = [];
|
||||||
|
for (const preset of sanitizedPresets) {
|
||||||
|
const existing = byId.get(preset.id) || byHostname.get(preset.hostname) || null;
|
||||||
|
if (!existing) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
nextTunnels.push({
|
||||||
|
...existing,
|
||||||
|
id: preset.id,
|
||||||
|
name: preset.name,
|
||||||
|
hostname: preset.hostname,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: CLOUDFLARE_NAMED_TUNNELS_VERSION,
|
||||||
|
tunnels: nextTunnels,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const upsertNamedTunnelToken = async ({ id, name, hostname, token }) => {
|
||||||
|
if (typeof id !== 'string' || typeof name !== 'string' || typeof hostname !== 'string' || typeof token !== 'string') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const normalizedId = id.trim();
|
||||||
|
const normalizedName = name.trim();
|
||||||
|
const normalizedHostname = normalizeNamedTunnelHostname(hostname);
|
||||||
|
const normalizedToken = token.trim();
|
||||||
|
if (!normalizedId || !normalizedName || !normalizedHostname || !normalizedToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateNamedTunnelConfig((current) => {
|
||||||
|
const withoutConflicts = current.tunnels.filter((entry) => entry.id !== normalizedId && entry.hostname !== normalizedHostname);
|
||||||
|
withoutConflicts.push({
|
||||||
|
id: normalizedId,
|
||||||
|
name: normalizedName,
|
||||||
|
hostname: normalizedHostname,
|
||||||
|
token: normalizedToken,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: CLOUDFLARE_NAMED_TUNNELS_VERSION,
|
||||||
|
tunnels: withoutConflicts,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveNamedTunnelToken = async ({ presetId, hostname }) => {
|
||||||
|
const normalizedPresetId = typeof presetId === 'string' ? presetId.trim() : '';
|
||||||
|
const normalizedHostname = normalizeNamedTunnelHostname(hostname);
|
||||||
|
const config = await readNamedTunnelConfigFromDisk();
|
||||||
|
|
||||||
|
if (normalizedPresetId) {
|
||||||
|
const byId = config.tunnels.find((entry) => entry.id === normalizedPresetId);
|
||||||
|
if (byId?.token) {
|
||||||
|
return byId.token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedHostname) {
|
||||||
|
const byHostname = config.tunnels.find((entry) => entry.hostname === normalizedHostname);
|
||||||
|
if (byHostname?.token) {
|
||||||
|
return byHostname.token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
const resolveDirectoryCandidate = (value) => {
|
const resolveDirectoryCandidate = (value) => {
|
||||||
if (typeof value !== 'string') {
|
if (typeof value !== 'string') {
|
||||||
return null;
|
return null;
|
||||||
@@ -1612,6 +1890,38 @@ const sanitizeSettingsUpdate = (payload) => {
|
|||||||
const normalizedDays = Math.max(1, Math.min(365, Math.round(candidate.autoDeleteAfterDays)));
|
const normalizedDays = Math.max(1, Math.min(365, Math.round(candidate.autoDeleteAfterDays)));
|
||||||
result.autoDeleteAfterDays = normalizedDays;
|
result.autoDeleteAfterDays = normalizedDays;
|
||||||
}
|
}
|
||||||
|
if (candidate.tunnelBootstrapTtlMs === null) {
|
||||||
|
result.tunnelBootstrapTtlMs = null;
|
||||||
|
} else if (typeof candidate.tunnelBootstrapTtlMs === 'number' && Number.isFinite(candidate.tunnelBootstrapTtlMs)) {
|
||||||
|
result.tunnelBootstrapTtlMs = normalizeTunnelBootstrapTtlMs(candidate.tunnelBootstrapTtlMs);
|
||||||
|
}
|
||||||
|
if (typeof candidate.tunnelSessionTtlMs === 'number' && Number.isFinite(candidate.tunnelSessionTtlMs)) {
|
||||||
|
result.tunnelSessionTtlMs = normalizeTunnelSessionTtlMs(candidate.tunnelSessionTtlMs);
|
||||||
|
}
|
||||||
|
if (typeof candidate.tunnelMode === 'string') {
|
||||||
|
result.tunnelMode = normalizeTunnelMode(candidate.tunnelMode);
|
||||||
|
}
|
||||||
|
if (typeof candidate.namedTunnelHostname === 'string') {
|
||||||
|
const hostname = normalizeNamedTunnelHostname(candidate.namedTunnelHostname);
|
||||||
|
result.namedTunnelHostname = hostname;
|
||||||
|
}
|
||||||
|
if (candidate.namedTunnelToken === null) {
|
||||||
|
result.namedTunnelToken = null;
|
||||||
|
} else if (typeof candidate.namedTunnelToken === 'string') {
|
||||||
|
result.namedTunnelToken = candidate.namedTunnelToken.trim();
|
||||||
|
}
|
||||||
|
const namedTunnelPresets = normalizeNamedTunnelPresets(candidate.namedTunnelPresets);
|
||||||
|
if (namedTunnelPresets) {
|
||||||
|
result.namedTunnelPresets = namedTunnelPresets;
|
||||||
|
}
|
||||||
|
const namedTunnelPresetTokens = normalizeNamedTunnelPresetTokens(candidate.namedTunnelPresetTokens);
|
||||||
|
if (namedTunnelPresetTokens) {
|
||||||
|
result.namedTunnelPresetTokens = namedTunnelPresetTokens;
|
||||||
|
}
|
||||||
|
if (typeof candidate.namedTunnelSelectedPresetId === 'string') {
|
||||||
|
const id = candidate.namedTunnelSelectedPresetId.trim();
|
||||||
|
result.namedTunnelSelectedPresetId = id || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
const typography = sanitizeTypographySizesPartial(candidate.typographySizes);
|
const typography = sanitizeTypographySizesPartial(candidate.typographySizes);
|
||||||
if (typography) {
|
if (typography) {
|
||||||
@@ -1892,11 +2202,14 @@ const mergePersistedSettings = (current, changes) => {
|
|||||||
|
|
||||||
const formatSettingsResponse = (settings) => {
|
const formatSettingsResponse = (settings) => {
|
||||||
const sanitized = sanitizeSettingsUpdate(settings);
|
const sanitized = sanitizeSettingsUpdate(settings);
|
||||||
|
delete sanitized.namedTunnelToken;
|
||||||
const approved = normalizeStringArray(settings.approvedDirectories);
|
const approved = normalizeStringArray(settings.approvedDirectories);
|
||||||
const bookmarks = normalizeStringArray(settings.securityScopedBookmarks);
|
const bookmarks = normalizeStringArray(settings.securityScopedBookmarks);
|
||||||
|
const hasNamedTunnelToken = typeof settings?.namedTunnelToken === 'string' && settings.namedTunnelToken.trim().length > 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...sanitized,
|
...sanitized,
|
||||||
|
hasNamedTunnelToken,
|
||||||
approvedDirectories: approved,
|
approvedDirectories: approved,
|
||||||
securityScopedBookmarks: bookmarks,
|
securityScopedBookmarks: bookmarks,
|
||||||
pinnedDirectories: normalizeStringArray(settings.pinnedDirectories),
|
pinnedDirectories: normalizeStringArray(settings.pinnedDirectories),
|
||||||
@@ -2846,6 +3159,32 @@ const persistSettings = async (changes) => {
|
|||||||
next = { ...next, activeProjectId: undefined };
|
next = { ...next, activeProjectId: undefined };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Object.prototype.hasOwnProperty.call(sanitized, 'namedTunnelPresets')) {
|
||||||
|
await syncNamedTunnelConfigWithPresets(next.namedTunnelPresets);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.prototype.hasOwnProperty.call(sanitized, 'namedTunnelPresetTokens') && sanitized.namedTunnelPresetTokens) {
|
||||||
|
const presetsById = new Map((next.namedTunnelPresets || []).map((entry) => [entry.id, entry]));
|
||||||
|
const updates = Object.entries(sanitized.namedTunnelPresetTokens)
|
||||||
|
.map(([presetId, token]) => {
|
||||||
|
const preset = presetsById.get(presetId);
|
||||||
|
if (!preset || typeof token !== 'string' || token.trim().length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: preset.id,
|
||||||
|
name: preset.name,
|
||||||
|
hostname: preset.hostname,
|
||||||
|
token: token.trim(),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
for (const update of updates) {
|
||||||
|
await upsertNamedTunnelToken(update);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await writeSettingsToDisk(next);
|
await writeSettingsToDisk(next);
|
||||||
console.log(`[persistSettings] Successfully saved ${next.projects?.length || 0} projects to disk`);
|
console.log(`[persistSettings] Successfully saved ${next.projects?.length || 0} projects to disk`);
|
||||||
return formatSettingsResponse(next);
|
return formatSettingsResponse(next);
|
||||||
@@ -2904,6 +3243,9 @@ let isExternalOpenCode = false;
|
|||||||
let exitOnShutdown = true;
|
let exitOnShutdown = true;
|
||||||
let uiAuthController = null;
|
let uiAuthController = null;
|
||||||
let cloudflareTunnelController = null;
|
let cloudflareTunnelController = null;
|
||||||
|
const tunnelAuthController = createTunnelAuth();
|
||||||
|
let runtimeNamedTunnelToken = '';
|
||||||
|
let runtimeNamedTunnelHostname = '';
|
||||||
let terminalInputWsServer = null;
|
let terminalInputWsServer = null;
|
||||||
const userProvidedOpenCodePassword =
|
const userProvidedOpenCodePassword =
|
||||||
typeof hmrState.userProvidedOpenCodePassword === 'string' && hmrState.userProvidedOpenCodePassword.length > 0
|
typeof hmrState.userProvidedOpenCodePassword === 'string' && hmrState.userProvidedOpenCodePassword.length > 0
|
||||||
@@ -5869,6 +6211,7 @@ async function gracefulShutdown(options = {}) {
|
|||||||
console.log('Stopping Cloudflare tunnel...');
|
console.log('Stopping Cloudflare tunnel...');
|
||||||
cloudflareTunnelController.stop();
|
cloudflareTunnelController.stop();
|
||||||
cloudflareTunnelController = null;
|
cloudflareTunnelController = null;
|
||||||
|
tunnelAuthController.clearActiveTunnel();
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Graceful shutdown complete');
|
console.log('Graceful shutdown complete');
|
||||||
@@ -6003,7 +6346,8 @@ async function main(options = {}) {
|
|||||||
req.path.startsWith('/api/opencode') ||
|
req.path.startsWith('/api/opencode') ||
|
||||||
req.path.startsWith('/api/push') ||
|
req.path.startsWith('/api/push') ||
|
||||||
req.path.startsWith('/api/voice') ||
|
req.path.startsWith('/api/voice') ||
|
||||||
req.path.startsWith('/api/tts')
|
req.path.startsWith('/api/tts') ||
|
||||||
|
req.path.startsWith('/api/openchamber/tunnel')
|
||||||
) {
|
) {
|
||||||
|
|
||||||
express.json({ limit: '50mb' })(req, res, next);
|
express.json({ limit: '50mb' })(req, res, next);
|
||||||
@@ -6029,16 +6373,65 @@ async function main(options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.get('/auth/session', async (req, res) => {
|
app.get('/auth/session', async (req, res) => {
|
||||||
|
const requestScope = tunnelAuthController.classifyRequestScope(req);
|
||||||
|
if (requestScope === 'tunnel' || requestScope === 'unknown-public') {
|
||||||
|
const tunnelSession = tunnelAuthController.getTunnelSessionFromRequest(req);
|
||||||
|
if (tunnelSession) {
|
||||||
|
return res.json({ authenticated: true, scope: 'tunnel' });
|
||||||
|
}
|
||||||
|
tunnelAuthController.clearTunnelSessionCookie(req, res);
|
||||||
|
return res.status(401).json({ authenticated: false, locked: true, tunnelLocked: true });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await uiAuthController.handleSessionStatus(req, res);
|
await uiAuthController.handleSessionStatus(req, res);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: 'Internal server error' });
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
app.post('/auth/session', (req, res) => uiAuthController.handleSessionCreate(req, res));
|
app.post('/auth/session', (req, res) => {
|
||||||
|
const requestScope = tunnelAuthController.classifyRequestScope(req);
|
||||||
|
if (requestScope === 'tunnel' || requestScope === 'unknown-public') {
|
||||||
|
return res.status(403).json({ error: 'Password login is disabled for tunnel scope', tunnelLocked: true });
|
||||||
|
}
|
||||||
|
return uiAuthController.handleSessionCreate(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/connect', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const token = typeof req.query?.t === 'string' ? req.query.t : '';
|
||||||
|
const settings = await readSettingsFromDiskMigrated();
|
||||||
|
const tunnelSessionTtlMs = normalizeTunnelSessionTtlMs(settings?.tunnelSessionTtlMs);
|
||||||
|
|
||||||
|
const exchange = tunnelAuthController.exchangeBootstrapToken({
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
token,
|
||||||
|
sessionTtlMs: tunnelSessionTtlMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
|
|
||||||
|
if (!exchange.ok) {
|
||||||
|
if (exchange.reason === 'rate-limited') {
|
||||||
|
res.setHeader('Retry-After', String(exchange.retryAfter || 60));
|
||||||
|
return res.status(429).type('text/plain').send('Too many attempts. Please try again later.');
|
||||||
|
}
|
||||||
|
return res.status(401).type('text/plain').send('Connection link is invalid or expired.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.redirect(302, '/');
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(500).type('text/plain').send('Failed to process connect request.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.use('/api', async (req, res, next) => {
|
app.use('/api', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
const requestScope = tunnelAuthController.classifyRequestScope(req);
|
||||||
|
if (requestScope === 'tunnel' || requestScope === 'unknown-public') {
|
||||||
|
return tunnelAuthController.requireTunnelSession(req, res, next);
|
||||||
|
}
|
||||||
await uiAuthController.requireAuth(req, res, next);
|
await uiAuthController.requireAuth(req, res, next);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
@@ -6726,6 +7119,250 @@ async function main(options = {}) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Cloudflare Tunnel API ──────────────────────────────────────────
|
||||||
|
|
||||||
|
app.get('/api/openchamber/tunnel/check', async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await checkCloudflaredAvailable();
|
||||||
|
res.json({ available: result.available, version: result.version || null });
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Cloudflare tunnel check failed:', error);
|
||||||
|
res.json({ available: false, version: null });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/openchamber/tunnel/status', async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const settings = await readSettingsFromDiskMigrated();
|
||||||
|
const mode = normalizeTunnelMode(settings?.tunnelMode);
|
||||||
|
const namedHostname = normalizeNamedTunnelHostname(settings?.namedTunnelHostname);
|
||||||
|
const namedTunnelConfig = await readNamedTunnelConfigFromDisk();
|
||||||
|
const hasLegacyNamedToken = typeof settings?.namedTunnelToken === 'string' && settings.namedTunnelToken.trim().length > 0;
|
||||||
|
const hasNamedTunnelToken = runtimeNamedTunnelToken.length > 0 || namedTunnelConfig.tunnels.length > 0 || hasLegacyNamedToken;
|
||||||
|
const bootstrapTtlMs = settings?.tunnelBootstrapTtlMs === null
|
||||||
|
? null
|
||||||
|
: normalizeTunnelBootstrapTtlMs(settings?.tunnelBootstrapTtlMs);
|
||||||
|
const sessionTtlMs = normalizeTunnelSessionTtlMs(settings?.tunnelSessionTtlMs);
|
||||||
|
const activeSessions = tunnelAuthController.listTunnelSessions();
|
||||||
|
|
||||||
|
const publicUrl = cloudflareTunnelController?.getPublicUrl?.() ?? null;
|
||||||
|
if (!publicUrl) {
|
||||||
|
return res.json({
|
||||||
|
active: false,
|
||||||
|
url: null,
|
||||||
|
mode,
|
||||||
|
hasNamedTunnelToken,
|
||||||
|
namedTunnelHostname: namedHostname || null,
|
||||||
|
namedTunnelTokenPresetIds: namedTunnelConfig.tunnels.map((entry) => entry.id),
|
||||||
|
hasBootstrapToken: false,
|
||||||
|
bootstrapExpiresAt: null,
|
||||||
|
policy: 'tunnel-gated',
|
||||||
|
activeTunnelMode: tunnelAuthController.getActiveTunnelMode() || null,
|
||||||
|
activeSessions,
|
||||||
|
localPort: activePort,
|
||||||
|
ttlConfig: {
|
||||||
|
bootstrapTtlMs,
|
||||||
|
sessionTtlMs,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeMode = cloudflareTunnelController?.mode === TUNNEL_MODE_NAMED ? TUNNEL_MODE_NAMED : TUNNEL_MODE_QUICK;
|
||||||
|
|
||||||
|
if (!tunnelAuthController.getActiveTunnelId() || !tunnelAuthController.getActiveTunnelHost()) {
|
||||||
|
tunnelAuthController.setActiveTunnel({ tunnelId: crypto.randomUUID(), publicUrl, mode: activeMode });
|
||||||
|
}
|
||||||
|
|
||||||
|
const bootstrapStatus = tunnelAuthController.getBootstrapStatus();
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
active: true,
|
||||||
|
url: publicUrl,
|
||||||
|
mode: activeMode,
|
||||||
|
hasNamedTunnelToken,
|
||||||
|
namedTunnelHostname: namedHostname || null,
|
||||||
|
namedTunnelTokenPresetIds: namedTunnelConfig.tunnels.map((entry) => entry.id),
|
||||||
|
hasBootstrapToken: bootstrapStatus.hasBootstrapToken,
|
||||||
|
bootstrapExpiresAt: bootstrapStatus.bootstrapExpiresAt,
|
||||||
|
policy: 'tunnel-gated',
|
||||||
|
activeTunnelMode: activeMode,
|
||||||
|
activeSessions: tunnelAuthController.listTunnelSessions(),
|
||||||
|
localPort: activePort,
|
||||||
|
ttlConfig: {
|
||||||
|
bootstrapTtlMs,
|
||||||
|
sessionTtlMs,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(500).json({ error: 'Failed to get tunnel status' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/openchamber/tunnel/named-token', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const presetId = typeof req?.body?.presetId === 'string' ? req.body.presetId.trim() : '';
|
||||||
|
const presetName = typeof req?.body?.presetName === 'string' ? req.body.presetName.trim() : '';
|
||||||
|
const namedTunnelHostname = normalizeNamedTunnelHostname(req?.body?.namedTunnelHostname);
|
||||||
|
const namedTunnelToken = typeof req?.body?.namedTunnelToken === 'string' ? req.body.namedTunnelToken.trim() : '';
|
||||||
|
|
||||||
|
if (!presetId || !presetName || !namedTunnelHostname || !namedTunnelToken) {
|
||||||
|
return res.status(400).json({ ok: false, error: 'presetId, presetName, namedTunnelHostname and namedTunnelToken are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await upsertNamedTunnelToken({
|
||||||
|
id: presetId,
|
||||||
|
name: presetName,
|
||||||
|
hostname: namedTunnelHostname,
|
||||||
|
token: namedTunnelToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
const namedTunnelConfig = await readNamedTunnelConfigFromDisk();
|
||||||
|
return res.json({ ok: true, namedTunnelTokenPresetIds: namedTunnelConfig.tunnels.map((entry) => entry.id) });
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(500).json({ ok: false, error: 'Failed to save named tunnel token' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/openchamber/tunnel/start', async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const settings = await readSettingsFromDiskMigrated();
|
||||||
|
const mode = normalizeTunnelMode(_req?.body?.mode ?? settings?.tunnelMode);
|
||||||
|
const selectedPresetId = typeof _req?.body?.namedTunnelPresetId === 'string' ? _req.body.namedTunnelPresetId.trim() : '';
|
||||||
|
const selectedPresetName = typeof _req?.body?.namedTunnelPresetName === 'string' ? _req.body.namedTunnelPresetName.trim() : '';
|
||||||
|
const requestNamedHostname = normalizeNamedTunnelHostname(_req?.body?.namedTunnelHostname);
|
||||||
|
const namedHostname = requestNamedHostname || normalizeNamedTunnelHostname(settings?.namedTunnelHostname);
|
||||||
|
const requestNamedToken = typeof _req?.body?.namedTunnelToken === 'string' ? _req.body.namedTunnelToken.trim() : '';
|
||||||
|
const legacyNamedToken = typeof settings?.namedTunnelToken === 'string' ? settings.namedTunnelToken.trim() : '';
|
||||||
|
const configNamedToken = await resolveNamedTunnelToken({ presetId: selectedPresetId, hostname: namedHostname });
|
||||||
|
const namedToken = requestNamedToken
|
||||||
|
|| ((runtimeNamedTunnelHostname && namedHostname && runtimeNamedTunnelHostname === namedHostname) ? runtimeNamedTunnelToken : '')
|
||||||
|
|| configNamedToken
|
||||||
|
|| legacyNamedToken
|
||||||
|
;
|
||||||
|
const bootstrapTtlMs = settings?.tunnelBootstrapTtlMs === null
|
||||||
|
? null
|
||||||
|
: normalizeTunnelBootstrapTtlMs(settings?.tunnelBootstrapTtlMs);
|
||||||
|
const sessionTtlMs = normalizeTunnelSessionTtlMs(settings?.tunnelSessionTtlMs);
|
||||||
|
|
||||||
|
let publicUrl = cloudflareTunnelController?.getPublicUrl?.() ?? null;
|
||||||
|
const activeMode = cloudflareTunnelController?.mode === TUNNEL_MODE_NAMED ? TUNNEL_MODE_NAMED : TUNNEL_MODE_QUICK;
|
||||||
|
|
||||||
|
if (publicUrl && activeMode !== mode) {
|
||||||
|
cloudflareTunnelController.stop();
|
||||||
|
cloudflareTunnelController = null;
|
||||||
|
tunnelAuthController.clearActiveTunnel();
|
||||||
|
publicUrl = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!publicUrl) {
|
||||||
|
const cfCheck = await checkCloudflaredAvailable();
|
||||||
|
if (!cfCheck.available) {
|
||||||
|
return res.status(400).json({
|
||||||
|
ok: false,
|
||||||
|
error: 'cloudflared is not installed. Install it with: brew install cloudflared',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === TUNNEL_MODE_NAMED) {
|
||||||
|
if (!namedHostname) {
|
||||||
|
return res.status(400).json({ ok: false, error: 'Named tunnel hostname is required' });
|
||||||
|
}
|
||||||
|
if (!namedToken) {
|
||||||
|
return res.status(400).json({ ok: false, error: 'Named tunnel token is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
runtimeNamedTunnelHostname = namedHostname;
|
||||||
|
runtimeNamedTunnelToken = namedToken;
|
||||||
|
|
||||||
|
if (requestNamedToken && namedHostname) {
|
||||||
|
await upsertNamedTunnelToken({
|
||||||
|
id: selectedPresetId || namedHostname,
|
||||||
|
name: selectedPresetName || namedHostname,
|
||||||
|
hostname: namedHostname,
|
||||||
|
token: requestNamedToken,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
cloudflareTunnelController = await startCloudflareNamedTunnel({
|
||||||
|
token: namedToken,
|
||||||
|
hostname: namedHostname,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const originUrl = `http://127.0.0.1:${activePort}`;
|
||||||
|
cloudflareTunnelController = await startCloudflareQuickTunnel({ originUrl, port: activePort });
|
||||||
|
}
|
||||||
|
|
||||||
|
publicUrl = cloudflareTunnelController.getPublicUrl();
|
||||||
|
|
||||||
|
if (!publicUrl) {
|
||||||
|
cloudflareTunnelController.stop();
|
||||||
|
cloudflareTunnelController = null;
|
||||||
|
tunnelAuthController.clearActiveTunnel();
|
||||||
|
return res.status(500).json({ ok: false, error: 'Tunnel started but no public URL was assigned' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === TUNNEL_MODE_QUICK) {
|
||||||
|
printTunnelWarning();
|
||||||
|
}
|
||||||
|
console.log(`Cloudflare tunnel active: ${publicUrl}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tunnelAuthController.getActiveTunnelId() || !tunnelAuthController.getActiveTunnelHost()) {
|
||||||
|
tunnelAuthController.setActiveTunnel({ tunnelId: crypto.randomUUID(), publicUrl, mode });
|
||||||
|
}
|
||||||
|
|
||||||
|
const bootstrapToken = tunnelAuthController.issueBootstrapToken({ ttlMs: bootstrapTtlMs });
|
||||||
|
const connectUrl = `${publicUrl.replace(/\/$/, '')}/connect?t=${encodeURIComponent(bootstrapToken.token)}`;
|
||||||
|
const namedTunnelConfig = await readNamedTunnelConfigFromDisk();
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
ok: true,
|
||||||
|
url: publicUrl,
|
||||||
|
mode,
|
||||||
|
namedTunnelHostname: namedHostname || null,
|
||||||
|
namedTunnelTokenPresetIds: namedTunnelConfig.tunnels.map((entry) => entry.id),
|
||||||
|
connectUrl,
|
||||||
|
bootstrapExpiresAt: bootstrapToken.expiresAt,
|
||||||
|
policy: 'tunnel-gated',
|
||||||
|
activeTunnelMode: mode,
|
||||||
|
activeSessions: tunnelAuthController.listTunnelSessions(),
|
||||||
|
localPort: activePort,
|
||||||
|
ttlConfig: {
|
||||||
|
bootstrapTtlMs,
|
||||||
|
sessionTtlMs,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to start Cloudflare tunnel:', error);
|
||||||
|
cloudflareTunnelController = null;
|
||||||
|
tunnelAuthController.clearActiveTunnel();
|
||||||
|
return res.status(500).json({ ok: false, error: error?.message || 'Failed to start tunnel' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/openchamber/tunnel/stop', (_req, res) => {
|
||||||
|
let revokedBootstrapCount = 0;
|
||||||
|
let invalidatedSessionCount = 0;
|
||||||
|
const activeTunnelId = tunnelAuthController.getActiveTunnelId();
|
||||||
|
|
||||||
|
if (activeTunnelId) {
|
||||||
|
const revoked = tunnelAuthController.revokeTunnelArtifacts(activeTunnelId);
|
||||||
|
revokedBootstrapCount = revoked.revokedBootstrapCount;
|
||||||
|
invalidatedSessionCount = revoked.invalidatedSessionCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cloudflareTunnelController) {
|
||||||
|
console.log('Stopping Cloudflare tunnel (user requested)...');
|
||||||
|
cloudflareTunnelController.stop();
|
||||||
|
cloudflareTunnelController = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
tunnelAuthController.clearActiveTunnel();
|
||||||
|
res.json({ ok: true, revokedBootstrapCount, invalidatedSessionCount });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── End Cloudflare Tunnel API ─────────────────────────────────────
|
||||||
|
|
||||||
app.get('/api/global/event', async (req, res) => {
|
app.get('/api/global/event', async (req, res) => {
|
||||||
let targetUrl;
|
let targetUrl;
|
||||||
try {
|
try {
|
||||||
@@ -7071,7 +7708,6 @@ async function main(options = {}) {
|
|||||||
|
|
||||||
app.put('/api/config/settings', async (req, res) => {
|
app.put('/api/config/settings', async (req, res) => {
|
||||||
console.log(`[API:PUT /api/config/settings] Received request`);
|
console.log(`[API:PUT /api/config/settings] Received request`);
|
||||||
console.log(`[API:PUT /api/config/settings] Request body:`, JSON.stringify(req.body, null, 2));
|
|
||||||
try {
|
try {
|
||||||
const updated = await persistSettings(req.body ?? {});
|
const updated = await persistSettings(req.body ?? {});
|
||||||
console.log(`[API:PUT /api/config/settings] Success, returning ${updated.projects?.length || 0} projects`);
|
console.log(`[API:PUT /api/config/settings] Success, returning ${updated.projects?.length || 0} projects`);
|
||||||
@@ -12176,10 +12812,22 @@ async function main(options = {}) {
|
|||||||
if (cfCheck.available) {
|
if (cfCheck.available) {
|
||||||
try {
|
try {
|
||||||
const originUrl = `http://localhost:${activePort}`;
|
const originUrl = `http://localhost:${activePort}`;
|
||||||
cloudflareTunnelController = await startCloudflareTunnel({ originUrl, port: activePort });
|
cloudflareTunnelController = await startCloudflareQuickTunnel({ originUrl, port: activePort });
|
||||||
printTunnelWarning();
|
printTunnelWarning();
|
||||||
if (onTunnelReady) {
|
|
||||||
const tunnelUrl = cloudflareTunnelController.getPublicUrl();
|
const tunnelUrl = cloudflareTunnelController.getPublicUrl();
|
||||||
|
if (tunnelUrl) {
|
||||||
|
tunnelAuthController.setActiveTunnel({
|
||||||
|
tunnelId: crypto.randomUUID(),
|
||||||
|
publicUrl: tunnelUrl,
|
||||||
|
mode: TUNNEL_MODE_QUICK,
|
||||||
|
});
|
||||||
|
const settings = await readSettingsFromDiskMigrated();
|
||||||
|
const bootstrapTtlMs = settings?.tunnelBootstrapTtlMs === null
|
||||||
|
? null
|
||||||
|
: normalizeTunnelBootstrapTtlMs(settings?.tunnelBootstrapTtlMs);
|
||||||
|
tunnelAuthController.issueBootstrapToken({ ttlMs: bootstrapTtlMs });
|
||||||
|
}
|
||||||
|
if (onTunnelReady) {
|
||||||
if (tunnelUrl) {
|
if (tunnelUrl) {
|
||||||
onTunnelReady(tunnelUrl);
|
onTunnelReady(tunnelUrl);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ const __dirname = path.dirname(__filename);
|
|||||||
|
|
||||||
const TRY_CF_URL_REGEX = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i;
|
const TRY_CF_URL_REGEX = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i;
|
||||||
|
|
||||||
|
const DEFAULT_STARTUP_TIMEOUT_MS = 30000;
|
||||||
|
|
||||||
async function searchPathFor(command) {
|
async function searchPathFor(command) {
|
||||||
const pathValue = process.env.PATH || '';
|
const pathValue = process.env.PATH || '';
|
||||||
const segments = pathValue.split(path.delimiter).filter(Boolean);
|
const segments = pathValue.split(path.delimiter).filter(Boolean);
|
||||||
@@ -86,7 +88,17 @@ Or visit: https://developers.cloudflare.com/cloudflare-one/networks/connectors/c
|
|||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startCloudflareTunnel({ originUrl, port }) {
|
const spawnCloudflared = (args, envOverrides = {}) => spawn('cloudflared', args, {
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
CF_TELEMETRY_DISABLE: '1',
|
||||||
|
...envOverrides,
|
||||||
|
},
|
||||||
|
killSignal: 'SIGINT',
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function startCloudflareQuickTunnel({ originUrl }) {
|
||||||
const cfCheck = await checkCloudflaredAvailable();
|
const cfCheck = await checkCloudflaredAvailable();
|
||||||
|
|
||||||
if (!cfCheck.available) {
|
if (!cfCheck.available) {
|
||||||
@@ -98,15 +110,7 @@ export async function startCloudflareTunnel({ originUrl, port }) {
|
|||||||
|
|
||||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-cf-'));
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-cf-'));
|
||||||
|
|
||||||
const child = spawn('cloudflared', ['tunnel', '--url', originUrl], {
|
const child = spawnCloudflared(['tunnel', '--url', originUrl], { HOME: tempDir });
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
HOME: tempDir,
|
|
||||||
CF_TELEMETRY_DISABLE: '1',
|
|
||||||
},
|
|
||||||
killSignal: 'SIGINT',
|
|
||||||
});
|
|
||||||
|
|
||||||
let publicUrl = null;
|
let publicUrl = null;
|
||||||
let tunnelReady = false;
|
let tunnelReady = false;
|
||||||
@@ -148,7 +152,7 @@ export async function startCloudflareTunnel({ originUrl, port }) {
|
|||||||
if (!publicUrl) {
|
if (!publicUrl) {
|
||||||
reject(new Error('Tunnel URL not received within 30 seconds'));
|
reject(new Error('Tunnel URL not received within 30 seconds'));
|
||||||
}
|
}
|
||||||
}, 30000);
|
}, DEFAULT_STARTUP_TIMEOUT_MS);
|
||||||
|
|
||||||
const checkReady = setInterval(() => {
|
const checkReady = setInterval(() => {
|
||||||
if (publicUrl) {
|
if (publicUrl) {
|
||||||
@@ -169,6 +173,7 @@ export async function startCloudflareTunnel({ originUrl, port }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
mode: 'quick',
|
||||||
stop: () => {
|
stop: () => {
|
||||||
try {
|
try {
|
||||||
child.kill('SIGINT');
|
child.kill('SIGINT');
|
||||||
@@ -181,6 +186,79 @@ export async function startCloudflareTunnel({ originUrl, port }) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function startCloudflareNamedTunnel({ token, hostname }) {
|
||||||
|
const cfCheck = await checkCloudflaredAvailable();
|
||||||
|
|
||||||
|
if (!cfCheck.available) {
|
||||||
|
printCloudflareTunnelInstallHelp();
|
||||||
|
throw new Error('cloudflared is not installed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedToken = typeof token === 'string' ? token.trim() : '';
|
||||||
|
const normalizedHost = typeof hostname === 'string' ? hostname.trim().toLowerCase() : '';
|
||||||
|
|
||||||
|
if (!normalizedToken) {
|
||||||
|
throw new Error('Named tunnel token is required');
|
||||||
|
}
|
||||||
|
if (!normalizedHost) {
|
||||||
|
throw new Error('Named tunnel hostname is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const child = spawnCloudflared(['tunnel', 'run', '--token', normalizedToken]);
|
||||||
|
const publicUrl = `https://${normalizedHost}`;
|
||||||
|
|
||||||
|
let exitedEarly = false;
|
||||||
|
let earlyExitCode = null;
|
||||||
|
|
||||||
|
child.stdout.on('data', () => {
|
||||||
|
// Keep stream drained, but avoid logging potentially sensitive output.
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stderr.on('data', (chunk) => {
|
||||||
|
const text = chunk.toString('utf8');
|
||||||
|
process.stderr.write(text);
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('error', (error) => {
|
||||||
|
console.error(`Cloudflared error: ${error.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const readyTimer = setTimeout(() => {
|
||||||
|
if (exitedEarly) {
|
||||||
|
reject(new Error(`Cloudflared exited early with code ${earlyExitCode ?? 'unknown'}`));
|
||||||
|
} else {
|
||||||
|
resolve(null);
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
child.once('exit', (code) => {
|
||||||
|
exitedEarly = true;
|
||||||
|
earlyExitCode = code;
|
||||||
|
clearTimeout(readyTimer);
|
||||||
|
reject(new Error(`Cloudflared exited with code ${code ?? 'unknown'}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode: 'named',
|
||||||
|
stop: () => {
|
||||||
|
try {
|
||||||
|
child.kill('SIGINT');
|
||||||
|
} catch {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
},
|
||||||
|
process: child,
|
||||||
|
getPublicUrl: () => publicUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startCloudflareTunnel({ originUrl, port }) {
|
||||||
|
void port;
|
||||||
|
return startCloudflareQuickTunnel({ originUrl });
|
||||||
|
}
|
||||||
|
|
||||||
export function printTunnelWarning() {
|
export function printTunnelWarning() {
|
||||||
console.log(`
|
console.log(`
|
||||||
⚠️ Cloudflare Quick Tunnel Limitations:
|
⚠️ Cloudflare Quick Tunnel Limitations:
|
||||||
|
|||||||
@@ -0,0 +1,491 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
const BOOTSTRAP_TOKEN_COOKIE_SAFE_BYTES = 32;
|
||||||
|
const TUNNEL_SESSION_COOKIE_NAME = 'oc_tunnel_session';
|
||||||
|
|
||||||
|
const CONNECT_RATE_LIMIT_WINDOW_MS = 5 * 60 * 1000;
|
||||||
|
const CONNECT_RATE_LIMIT_LOCK_MS = 10 * 60 * 1000;
|
||||||
|
const CONNECT_RATE_LIMIT_MAX_ATTEMPTS = 20;
|
||||||
|
const CONNECT_RATE_LIMIT_NO_IP_MAX_ATTEMPTS = 5;
|
||||||
|
|
||||||
|
const parseCookies = (cookieHeader) => {
|
||||||
|
if (!cookieHeader || typeof cookieHeader !== 'string') {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return cookieHeader.split(';').reduce((acc, segment) => {
|
||||||
|
const [name, ...rest] = segment.split('=');
|
||||||
|
if (!name) {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
const key = name.trim();
|
||||||
|
if (!key) {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
const value = rest.join('=').trim();
|
||||||
|
acc[key] = decodeURIComponent(value || '');
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSecureRequest = (req) => {
|
||||||
|
if (req.secure) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const forwardedProto = req.headers['x-forwarded-proto'];
|
||||||
|
if (typeof forwardedProto === 'string') {
|
||||||
|
const firstProto = forwardedProto.split(',')[0]?.trim().toLowerCase();
|
||||||
|
return firstProto === 'https';
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildCookie = ({ name, value, maxAge, secure }) => {
|
||||||
|
const attributes = [
|
||||||
|
`${name}=${value}`,
|
||||||
|
'Path=/',
|
||||||
|
'HttpOnly',
|
||||||
|
'SameSite=Lax',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (typeof maxAge === 'number') {
|
||||||
|
attributes.push(`Max-Age=${Math.max(0, Math.floor(maxAge))}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const expires = maxAge === 0
|
||||||
|
? 'Thu, 01 Jan 1970 00:00:00 GMT'
|
||||||
|
: new Date(Date.now() + maxAge * 1000).toUTCString();
|
||||||
|
|
||||||
|
attributes.push(`Expires=${expires}`);
|
||||||
|
|
||||||
|
if (secure) {
|
||||||
|
attributes.push('Secure');
|
||||||
|
}
|
||||||
|
|
||||||
|
return attributes.join('; ');
|
||||||
|
};
|
||||||
|
|
||||||
|
const nowTs = () => Date.now();
|
||||||
|
|
||||||
|
const hashToken = (token) => crypto.createHash('sha256').update(token).digest('hex');
|
||||||
|
|
||||||
|
const normalizeHost = (candidate) => {
|
||||||
|
if (typeof candidate !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const trimmed = candidate.trim().toLowerCase();
|
||||||
|
if (!trimmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return trimmed.replace(/:\d+$/, '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const isLocalHost = (host) => {
|
||||||
|
if (!host) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getClientIp = (req) => {
|
||||||
|
const forwarded = req.headers['x-forwarded-for'];
|
||||||
|
if (typeof forwarded === 'string') {
|
||||||
|
const ip = forwarded.split(',')[0].trim();
|
||||||
|
if (ip.startsWith('::ffff:')) {
|
||||||
|
return ip.substring(7);
|
||||||
|
}
|
||||||
|
return ip;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ip = req.ip || req.connection?.remoteAddress;
|
||||||
|
if (ip) {
|
||||||
|
if (ip.startsWith('::ffff:')) {
|
||||||
|
return ip.substring(7);
|
||||||
|
}
|
||||||
|
return ip;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRateLimitKey = (req) => {
|
||||||
|
const ip = getClientIp(req);
|
||||||
|
if (ip) {
|
||||||
|
return ip;
|
||||||
|
}
|
||||||
|
return 'connect-rate-limit:no-ip';
|
||||||
|
};
|
||||||
|
|
||||||
|
const rateLimitMaxForKey = (key) => {
|
||||||
|
if (key === 'connect-rate-limit:no-ip') {
|
||||||
|
return CONNECT_RATE_LIMIT_NO_IP_MAX_ATTEMPTS;
|
||||||
|
}
|
||||||
|
return CONNECT_RATE_LIMIT_MAX_ATTEMPTS;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createTunnelAuth = () => {
|
||||||
|
let activeTunnelId = null;
|
||||||
|
let activeTunnelHost = null;
|
||||||
|
let activeTunnelMode = null;
|
||||||
|
let activeTunnelPublicUrl = null;
|
||||||
|
let bootstrapRecord = null;
|
||||||
|
|
||||||
|
const tunnelSessions = new Map();
|
||||||
|
const connectRateLimiter = new Map();
|
||||||
|
|
||||||
|
const clearTunnelSessionCookie = (req, res) => {
|
||||||
|
const secure = isSecureRequest(req);
|
||||||
|
const header = buildCookie({
|
||||||
|
name: TUNNEL_SESSION_COOKIE_NAME,
|
||||||
|
value: '',
|
||||||
|
maxAge: 0,
|
||||||
|
secure,
|
||||||
|
});
|
||||||
|
res.setHeader('Set-Cookie', header);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setTunnelSessionCookie = (req, res, sessionId, ttlMs) => {
|
||||||
|
const secure = isSecureRequest(req);
|
||||||
|
const maxAge = Math.max(0, Math.floor(ttlMs / 1000));
|
||||||
|
const header = buildCookie({
|
||||||
|
name: TUNNEL_SESSION_COOKIE_NAME,
|
||||||
|
value: encodeURIComponent(sessionId),
|
||||||
|
maxAge,
|
||||||
|
secure,
|
||||||
|
});
|
||||||
|
res.setHeader('Set-Cookie', header);
|
||||||
|
};
|
||||||
|
|
||||||
|
const classifyRequestScope = (req) => {
|
||||||
|
const hostHeader = normalizeHost(typeof req.headers.host === 'string' ? req.headers.host : '');
|
||||||
|
const reqHost = normalizeHost(typeof req.hostname === 'string' ? req.hostname : '') || hostHeader;
|
||||||
|
|
||||||
|
if (activeTunnelHost && reqHost === activeTunnelHost) {
|
||||||
|
return 'tunnel';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLocalHost(reqHost)) {
|
||||||
|
return 'local';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'unknown-public';
|
||||||
|
};
|
||||||
|
|
||||||
|
const revokeBootstrapToken = () => {
|
||||||
|
if (!bootstrapRecord) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (bootstrapRecord.revokedAt) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (!bootstrapRecord.revokedAt) {
|
||||||
|
bootstrapRecord.revokedAt = nowTs();
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const invalidateTunnelSessions = (tunnelId, reason = 'tunnel-stopped') => {
|
||||||
|
const revokedAt = nowTs();
|
||||||
|
let count = 0;
|
||||||
|
for (const record of tunnelSessions.values()) {
|
||||||
|
if (record.tunnelId === tunnelId && !record.revokedAt) {
|
||||||
|
record.revokedAt = revokedAt;
|
||||||
|
record.revokedReason = reason;
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
};
|
||||||
|
|
||||||
|
const revokeTunnelArtifacts = (tunnelId) => {
|
||||||
|
const revokedBootstrapCount = bootstrapRecord && bootstrapRecord.tunnelId === tunnelId
|
||||||
|
? revokeBootstrapToken()
|
||||||
|
: 0;
|
||||||
|
const invalidatedSessionCount = invalidateTunnelSessions(tunnelId, 'tunnel-revoked');
|
||||||
|
return { revokedBootstrapCount, invalidatedSessionCount };
|
||||||
|
};
|
||||||
|
|
||||||
|
const setActiveTunnel = ({ tunnelId, publicUrl, mode = null }) => {
|
||||||
|
activeTunnelId = tunnelId;
|
||||||
|
activeTunnelMode = mode;
|
||||||
|
activeTunnelPublicUrl = publicUrl || null;
|
||||||
|
try {
|
||||||
|
activeTunnelHost = normalizeHost(new URL(publicUrl).host);
|
||||||
|
} catch {
|
||||||
|
activeTunnelHost = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearActiveTunnel = () => {
|
||||||
|
if (activeTunnelId) {
|
||||||
|
revokeTunnelArtifacts(activeTunnelId);
|
||||||
|
}
|
||||||
|
activeTunnelId = null;
|
||||||
|
activeTunnelHost = null;
|
||||||
|
activeTunnelMode = null;
|
||||||
|
activeTunnelPublicUrl = null;
|
||||||
|
bootstrapRecord = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isBootstrapRecordUsable = (record) => {
|
||||||
|
if (!record || record.revokedAt || record.usedAt) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (typeof record.expiresAt === 'number' && nowTs() >= record.expiresAt) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const issueBootstrapToken = ({ ttlMs }) => {
|
||||||
|
if (!activeTunnelId) {
|
||||||
|
throw new Error('Tunnel is not active');
|
||||||
|
}
|
||||||
|
|
||||||
|
revokeBootstrapToken();
|
||||||
|
|
||||||
|
const token = crypto.randomBytes(BOOTSTRAP_TOKEN_COOKIE_SAFE_BYTES).toString('base64url');
|
||||||
|
const issuedAt = nowTs();
|
||||||
|
const expiresAt = Number.isFinite(ttlMs) && ttlMs > 0 ? issuedAt + ttlMs : null;
|
||||||
|
|
||||||
|
bootstrapRecord = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
tunnelId: activeTunnelId,
|
||||||
|
tokenHash: hashToken(token),
|
||||||
|
issuedAt,
|
||||||
|
expiresAt,
|
||||||
|
usedAt: null,
|
||||||
|
revokedAt: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
expiresAt,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBootstrapStatus = () => {
|
||||||
|
if (!isBootstrapRecordUsable(bootstrapRecord)) {
|
||||||
|
return {
|
||||||
|
hasBootstrapToken: false,
|
||||||
|
bootstrapExpiresAt: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasBootstrapToken: true,
|
||||||
|
bootstrapExpiresAt: bootstrapRecord.expiresAt,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkConnectRateLimit = (req) => {
|
||||||
|
const key = getRateLimitKey(req);
|
||||||
|
const now = nowTs();
|
||||||
|
const maxAttempts = rateLimitMaxForKey(key);
|
||||||
|
const record = connectRateLimiter.get(key);
|
||||||
|
|
||||||
|
if (record?.lockedUntil && now < record.lockedUntil) {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
retryAfter: Math.ceil((record.lockedUntil - now) / 1000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!record || now - record.lastAttempt > CONNECT_RATE_LIMIT_WINDOW_MS) {
|
||||||
|
return { allowed: true, retryAfter: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record.count >= maxAttempts) {
|
||||||
|
const lockedUntil = now + CONNECT_RATE_LIMIT_LOCK_MS;
|
||||||
|
connectRateLimiter.set(key, {
|
||||||
|
count: record.count + 1,
|
||||||
|
lastAttempt: now,
|
||||||
|
lockedUntil,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
retryAfter: Math.ceil(CONNECT_RATE_LIMIT_LOCK_MS / 1000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { allowed: true, retryAfter: 0 };
|
||||||
|
};
|
||||||
|
|
||||||
|
const recordConnectFailedAttempt = (req) => {
|
||||||
|
const key = getRateLimitKey(req);
|
||||||
|
const now = nowTs();
|
||||||
|
const record = connectRateLimiter.get(key);
|
||||||
|
|
||||||
|
if (!record || now - record.lastAttempt > CONNECT_RATE_LIMIT_WINDOW_MS) {
|
||||||
|
connectRateLimiter.set(key, { count: 1, lastAttempt: now, lockedUntil: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
connectRateLimiter.set(key, {
|
||||||
|
count: record.count + 1,
|
||||||
|
lastAttempt: now,
|
||||||
|
lockedUntil: record.lockedUntil || null,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearConnectRateLimit = (req) => {
|
||||||
|
const key = getRateLimitKey(req);
|
||||||
|
connectRateLimiter.delete(key);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTunnelSessionFromRequest = (req) => {
|
||||||
|
const cookies = parseCookies(req.headers.cookie);
|
||||||
|
const token = cookies[TUNNEL_SESSION_COOKIE_NAME];
|
||||||
|
if (!token) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const session = tunnelSessions.get(token);
|
||||||
|
if (!session) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (session.revokedAt) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (session.expiresAt <= nowTs()) {
|
||||||
|
if (!session.expiredAt) {
|
||||||
|
session.expiredAt = nowTs();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (session.tunnelId !== activeTunnelId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
session.lastSeenAt = nowTs();
|
||||||
|
return session;
|
||||||
|
};
|
||||||
|
|
||||||
|
const requireTunnelSession = (req, res, next) => {
|
||||||
|
const session = getTunnelSessionFromRequest(req);
|
||||||
|
if (session) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTunnelSessionCookie(req, res);
|
||||||
|
res.status(401).json({
|
||||||
|
error: 'Tunnel authentication required',
|
||||||
|
locked: true,
|
||||||
|
tunnelLocked: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const exchangeBootstrapToken = ({ req, res, token, sessionTtlMs }) => {
|
||||||
|
const rateLimit = checkConnectRateLimit(req);
|
||||||
|
if (!rateLimit.allowed) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: 'rate-limited',
|
||||||
|
retryAfter: rateLimit.retryAfter,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!activeTunnelId || !bootstrapRecord) {
|
||||||
|
recordConnectFailedAttempt(req);
|
||||||
|
return { ok: false, reason: 'inactive' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token || typeof token !== 'string') {
|
||||||
|
recordConnectFailedAttempt(req);
|
||||||
|
return { ok: false, reason: 'missing-token' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isBootstrapRecordUsable(bootstrapRecord)) {
|
||||||
|
recordConnectFailedAttempt(req);
|
||||||
|
return { ok: false, reason: 'expired' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bootstrapRecord.tunnelId !== activeTunnelId) {
|
||||||
|
recordConnectFailedAttempt(req);
|
||||||
|
return { ok: false, reason: 'tunnel-mismatch' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const incomingHash = hashToken(token);
|
||||||
|
const expected = bootstrapRecord.tokenHash;
|
||||||
|
const validHash = incomingHash.length === expected.length
|
||||||
|
&& crypto.timingSafeEqual(Buffer.from(incomingHash), Buffer.from(expected));
|
||||||
|
|
||||||
|
if (!validHash) {
|
||||||
|
recordConnectFailedAttempt(req);
|
||||||
|
return { ok: false, reason: 'invalid-token' };
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrapRecord.usedAt = nowTs();
|
||||||
|
clearConnectRateLimit(req);
|
||||||
|
|
||||||
|
const sessionId = crypto.randomBytes(32).toString('base64url');
|
||||||
|
const createdAt = nowTs();
|
||||||
|
const expiresAt = createdAt + sessionTtlMs;
|
||||||
|
|
||||||
|
tunnelSessions.set(sessionId, {
|
||||||
|
sessionId,
|
||||||
|
tunnelId: activeTunnelId,
|
||||||
|
mode: activeTunnelMode,
|
||||||
|
publicUrl: activeTunnelPublicUrl,
|
||||||
|
createdAt,
|
||||||
|
lastSeenAt: createdAt,
|
||||||
|
expiresAt,
|
||||||
|
revokedAt: null,
|
||||||
|
revokedReason: null,
|
||||||
|
expiredAt: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
setTunnelSessionCookie(req, res, sessionId, sessionTtlMs);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
sessionExpiresAt: expiresAt,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const listTunnelSessions = () => {
|
||||||
|
const now = nowTs();
|
||||||
|
|
||||||
|
const sessions = [];
|
||||||
|
for (const record of tunnelSessions.values()) {
|
||||||
|
const isExpired = record.expiresAt <= now;
|
||||||
|
if (isExpired && !record.expiredAt) {
|
||||||
|
record.expiredAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
const active = !record.revokedAt && !isExpired && record.tunnelId === activeTunnelId;
|
||||||
|
const status = active ? 'active' : 'inactive';
|
||||||
|
const inactiveReason = record.revokedAt ? (record.revokedReason || 'revoked') : (isExpired ? 'expired' : 'inactive');
|
||||||
|
|
||||||
|
sessions.push({
|
||||||
|
sessionId: record.sessionId,
|
||||||
|
tunnelId: record.tunnelId,
|
||||||
|
mode: record.mode,
|
||||||
|
publicUrl: record.publicUrl,
|
||||||
|
createdAt: record.createdAt,
|
||||||
|
lastSeenAt: record.lastSeenAt,
|
||||||
|
expiresAt: record.expiresAt,
|
||||||
|
revokedAt: record.revokedAt,
|
||||||
|
status,
|
||||||
|
inactiveReason: status === 'inactive' ? inactiveReason : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
sessions.sort((a, b) => b.createdAt - a.createdAt);
|
||||||
|
return sessions;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
classifyRequestScope,
|
||||||
|
setActiveTunnel,
|
||||||
|
clearActiveTunnel,
|
||||||
|
revokeTunnelArtifacts,
|
||||||
|
issueBootstrapToken,
|
||||||
|
getBootstrapStatus,
|
||||||
|
requireTunnelSession,
|
||||||
|
getTunnelSessionFromRequest,
|
||||||
|
exchangeBootstrapToken,
|
||||||
|
listTunnelSessions,
|
||||||
|
clearTunnelSessionCookie,
|
||||||
|
getActiveTunnelId: () => activeTunnelId,
|
||||||
|
getActiveTunnelHost: () => activeTunnelHost,
|
||||||
|
getActiveTunnelMode: () => activeTunnelMode,
|
||||||
|
};
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user