From d5d0d350838630f2eabce569009da17022e89037 Mon Sep 17 00:00:00 2001 From: Iuliia Ivashko Date: Sat, 28 Feb 2026 04:21:46 +0200 Subject: [PATCH] feat: redesign remote tunnel settings and named tunnel workflow (#546) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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= 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 --- bun.lock | 45 +- package.json | 1 + packages/desktop/src-tauri/src/main.rs | 2 +- packages/ui/package.json | 2 + .../src/components/auth/SessionAuthGate.tsx | 205 +-- .../sections/openchamber/OpenChamberPage.tsx | 10 + .../sections/openchamber/TunnelSettings.tsx | 1373 +++++++++++++++++ .../components/sections/openchamber/types.ts | 3 +- .../ui/src/components/views/SettingsView.tsx | 11 +- packages/ui/src/lib/desktop.ts | 15 + packages/ui/src/lib/persistence.ts | 79 + packages/ui/src/lib/settings/metadata.ts | 4 +- packages/web/server/index.js | 660 +++++++- packages/web/server/lib/cloudflare-tunnel.js | 102 +- .../web/server/lib/opencode/tunnel-auth.js | 491 ++++++ 15 files changed, 2853 insertions(+), 150 deletions(-) create mode 100644 packages/ui/src/components/sections/openchamber/TunnelSettings.tsx create mode 100644 packages/web/server/lib/opencode/tunnel-auth.js diff --git a/bun.lock b/bun.lock index 4446fc2c..25ec1750 100644 --- a/bun.lock +++ b/bun.lock @@ -166,6 +166,7 @@ "motion": "^12.23.24", "next-themes": "^0.4.6", "prismjs": "^1.30.0", + "qrcode": "^1.5.4", "react": "^19.1.1", "react-dom": "^19.1.1", "react-syntax-highlighter": "^15.6.6", @@ -184,6 +185,7 @@ "@tauri-apps/api": "^2.9.0", "@types/node": "^24.3.1", "@types/prismjs": "^1.26.6", + "@types/qrcode": "^1.5.5", "@types/react": "^19.1.10", "@types/react-dom": "^19.1.7", "@vitejs/plugin-react": "^5.0.0", @@ -311,6 +313,7 @@ }, }, "overrides": { + "@codemirror/language": "6.12.2", "@codemirror/view": "6.39.13", }, "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/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=="], @@ -1176,6 +1179,8 @@ "@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-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=="], + "camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="], + "caniuse-lite": ["caniuse-lite@1.0.30001769", "", {}, "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg=="], "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=="], + "decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="], + "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=="], @@ -1554,6 +1563,8 @@ "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=="], "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-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=="], "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=="], + "pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="], + "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=="], @@ -2420,6 +2435,8 @@ "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=="], "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-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="], + "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=="], @@ -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=="], + "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-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-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=="], "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=="], + "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-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=="], + "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=="], "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=="], + "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=="], "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=="], + "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/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=="], + "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/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=="], + + "qrcode/yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], } } diff --git a/package.json b/package.json index be050e08..dc9c85f4 100644 --- a/package.json +++ b/package.json @@ -123,6 +123,7 @@ "zustand": "^5.0.8" }, "overrides": { + "@codemirror/language": "6.12.2", "@codemirror/view": "6.39.13" }, "devDependencies": { diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs index 809d2ad2..4275a8cd 100644 --- a/packages/desktop/src-tauri/src/main.rs +++ b/packages/desktop/src-tauri/src/main.rs @@ -2698,7 +2698,7 @@ fn main() { 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); } }) diff --git a/packages/ui/package.json b/packages/ui/package.json index 756c3b9b..63282b50 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -68,6 +68,7 @@ "motion": "^12.23.24", "next-themes": "^0.4.6", "prismjs": "^1.30.0", + "qrcode": "^1.5.4", "react": "^19.1.1", "react-dom": "^19.1.1", "react-syntax-highlighter": "^15.6.6", @@ -86,6 +87,7 @@ "@tauri-apps/api": "^2.9.0", "@types/node": "^24.3.1", "@types/prismjs": "^1.26.6", + "@types/qrcode": "^1.5.5", "@types/react": "^19.1.10", "@types/react-dom": "^19.1.7", "@vitejs/plugin-react": "^5.0.0", diff --git a/packages/ui/src/components/auth/SessionAuthGate.tsx b/packages/ui/src/components/auth/SessionAuthGate.tsx index 45eea891..d112d4c4 100644 --- a/packages/ui/src/components/auth/SessionAuthGate.tsx +++ b/packages/ui/src/components/auth/SessionAuthGate.tsx @@ -106,25 +106,6 @@ interface ErrorScreenProps { 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 = ({ children }) => { const vscodeRuntime = React.useMemo(() => isVSCodeRuntime(), []); const skipAuth = vscodeRuntime; @@ -134,9 +115,9 @@ export const SessionAuthGate: React.FC = ({ children }) => const [isSubmitting, setIsSubmitting] = React.useState(false); const [errorMessage, setErrorMessage] = React.useState(''); const [retryAfter, setRetryAfter] = React.useState(undefined); + const [isTunnelLocked, setIsTunnelLocked] = React.useState(false); const passwordInputRef = React.useRef(null); const hasResyncedRef = React.useRef(skipAuth); - const hasTriedUrlTokenRef = React.useRef(false); const checkStatus = React.useCallback(async () => { if (skipAuth) { @@ -158,28 +139,30 @@ export const SessionAuthGate: React.FC = ({ children }) => const responseText = await response.text(); console.log('[Frontend Auth] Raw response:', response.status, responseText); - if (response.ok) { - console.log('[Frontend Auth] Session is authenticated'); - setState('authenticated'); - setErrorMessage(''); - setRetryAfter(undefined); - return; - } - if (response.status === 401) { - let data: { debug?: { hasRefreshToken: boolean; message: string } } = {}; - try { - data = JSON.parse(responseText); - } catch { - data = {}; + if (response.ok) { + console.log('[Frontend Auth] Session is authenticated'); + setState('authenticated'); + setIsTunnelLocked(false); + setErrorMessage(''); + setRetryAfter(undefined); + return; } + if (response.status === 401) { + let data: { tunnelLocked?: boolean; debug?: { hasRefreshToken: boolean; message: string } } = {}; + try { + data = JSON.parse(responseText); + } catch { + data = {}; + } console.warn('[Frontend Auth] Session is locked (401)', data); - if (data.debug) { - console.warn('[Frontend Auth] Debug info:', data.debug); + if (data.debug) { + console.warn('[Frontend Auth] Debug info:', data.debug); + } + setIsTunnelLocked(data.tunnelLocked === true); + setState('locked'); + setRetryAfter(undefined); + return; } - setState('locked'); - setRetryAfter(undefined); - return; - } if (response.status === 429) { let data: { retryAfter?: number } = {}; try { @@ -188,14 +171,17 @@ export const SessionAuthGate: React.FC = ({ children }) => data = {}; } setRetryAfter(data.retryAfter); + setIsTunnelLocked(false); setState('rate-limited'); return; } console.error('[Frontend Auth] Unexpected response status:', response.status); setState('error'); + setIsTunnelLocked(false); } catch (error) { console.warn('Failed to check session status:', error); setState('error'); + setIsTunnelLocked(false); } }, [skipAuth]); @@ -219,49 +205,6 @@ export const SessionAuthGate: React.FC = ({ children }) => } }, [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(() => { if (skipAuth) { return; @@ -278,6 +221,9 @@ export const SessionAuthGate: React.FC = ({ children }) => const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); + if (isTunnelLocked) { + return; + } if (!password || isSubmitting) { return; } @@ -296,6 +242,7 @@ export const SessionAuthGate: React.FC = ({ children }) => 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)); setPassword(''); + setIsTunnelLocked(false); setState('authenticated'); return; } @@ -303,6 +250,7 @@ export const SessionAuthGate: React.FC = ({ children }) => if (response.status === 401) { console.warn('[Frontend Auth] Login failed: Invalid password'); setErrorMessage('Incorrect password. Try again.'); + setIsTunnelLocked(false); setState('locked'); return; } @@ -311,16 +259,19 @@ export const SessionAuthGate: React.FC = ({ children }) => console.warn('[Frontend Auth] Login failed: Rate limited'); const data = await response.json().catch(() => ({})); setRetryAfter(data.retryAfter); + setIsTunnelLocked(false); setState('rate-limited'); return; } console.error('[Frontend Auth] Login failed: Unexpected response', response.status); setErrorMessage('Unexpected response from server.'); + setIsTunnelLocked(false); setState('error'); } catch (error) { console.warn('Failed to submit UI password:', error); setErrorMessage('Network error. Check connection and retry.'); + setIsTunnelLocked(false); setState('error'); } finally { setIsSubmitting(false); @@ -345,55 +296,59 @@ export const SessionAuthGate: React.FC = ({ children }) =>

- Unlock OpenChamber + {isTunnelLocked ? 'Tunnel access required' : 'Unlock OpenChamber'}

- This session is password-protected. + {isTunnelLocked + ? 'Open this tunnel using the one-time connect link from the desktop app.' + : 'This session is password-protected.'}

-
-
-
- - { - setPassword(event.target.value); - if (errorMessage) { - setErrorMessage(''); - } - }} - className="pl-10" - aria-invalid={Boolean(errorMessage) || undefined} - aria-describedby={errorMessage ? 'oc-ui-auth-error' : undefined} - disabled={isSubmitting} - /> + {!isTunnelLocked && ( + +
+
+ + { + setPassword(event.target.value); + if (errorMessage) { + setErrorMessage(''); + } + }} + className="pl-10" + aria-invalid={Boolean(errorMessage) || undefined} + aria-describedby={errorMessage ? 'oc-ui-auth-error' : undefined} + disabled={isSubmitting} + /> +
+
- -
- {errorMessage && ( -

- {errorMessage} -

- )} - + {errorMessage && ( +

+ {errorMessage} +

+ )} + + )} {showHostSwitcher && (
diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx index 36871189..a6bdba81 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx @@ -8,6 +8,7 @@ import { GitSettings } from './GitSettings'; import { NotificationSettings } from './NotificationSettings'; import { GitHubSettings } from './GitHubSettings'; import { VoiceSettings } from './VoiceSettings'; +import { TunnelSettings } from './TunnelSettings'; import { OpenCodeCliSettings } from './OpenCodeCliSettings'; import { KeyboardShortcutsSettings } from './KeyboardShortcutsSettings'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; @@ -75,6 +76,8 @@ export const OpenChamberPage: React.FC = ({ section }) => return ; case 'voice': return ; + case 'tunnel': + return ; default: return null; } @@ -157,3 +160,10 @@ const VoiceSectionContent: React.FC = () => { } return ; }; + +const TunnelSectionContent: React.FC = () => { + if (isVSCodeRuntime()) { + return null; + } + return ; +}; diff --git a/packages/ui/src/components/sections/openchamber/TunnelSettings.tsx b/packages/ui/src/components/sections/openchamber/TunnelSettings.tsx new file mode 100644 index 00000000..8b66a380 --- /dev/null +++ b/packages/ui/src/components/sections/openchamber/TunnelSettings.tsx @@ -0,0 +1,1373 @@ +import React from 'react'; +import QRCode from 'qrcode'; +import { + RiAddLine, + RiArrowDownSLine, + RiArrowRightSLine, + RiCheckboxBlankCircleFill, + RiCheckLine, + RiDeleteBinLine, + RiErrorWarningLine, + RiExternalLinkLine, + RiFileCopyLine, + RiInformationLine, + RiLoader4Line, + RiRestartLine, +} from '@remixicon/react'; +import { toast } from '@/components/ui'; +import { ButtonSmall } from '@/components/ui/button-small'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import { GridLoader } from '@/components/ui/grid-loader'; +import { Input } from '@/components/ui/input'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { updateDesktopSettings } from '@/lib/persistence'; +import { cn } from '@/lib/utils'; + +type TunnelState = + | 'checking' + | 'not-available' + | 'idle' + | 'starting' + | 'active' + | 'stopping' + | 'error'; + +type TtlOption = { value: string; label: string; ms: number | null }; +type TunnelMode = 'quick' | 'named'; + +interface NamedTunnelPreset { + id: string; + name: string; + hostname: string; +} + +const BOOTSTRAP_TTL_OPTIONS: TtlOption[] = [ + { value: '1800000', label: '30m', ms: 30 * 60 * 1000 }, + { value: '180000', label: '3m', ms: 3 * 60 * 1000 }, + { value: '7200000', label: '2h', ms: 2 * 60 * 60 * 1000 }, + { value: '28800000', label: '8h', ms: 8 * 60 * 60 * 1000 }, + { value: '86400000', label: '24h', ms: 24 * 60 * 60 * 1000 }, +]; + +const SESSION_TTL_OPTIONS: TtlOption[] = [ + { value: '3600000', label: '1h', ms: 60 * 60 * 1000 }, + { value: '28800000', label: '8h', ms: 8 * 60 * 60 * 1000 }, + { value: '43200000', label: '12h', ms: 12 * 60 * 60 * 1000 }, + { value: '86400000', label: '24h', ms: 24 * 60 * 60 * 1000 }, +]; + +const QUICK_TUNNEL_TOOLTIP = 'Quick Tunnel is best effort and Cloudflare does not guarantee uptime. For more reliable long-lived access, switch to Named Tunnel mode.'; +const NAMED_TUNNEL_TOOLTIP = 'Named Tunnel mode uses your Cloudflare account and custom hostname for more reliable remote access.'; +const NAMED_TUNNEL_DOC_URL = 'https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/get-started/create-remote-tunnel/'; + +interface TunnelInfo { + url: string; + connectUrl: string | null; + bootstrapExpiresAt: number | null; +} + +interface TunnelSessionRecord { + sessionId: string; + mode: TunnelMode | null; + status: 'active' | 'inactive'; + inactiveReason?: string | null; + createdAt: number; + lastSeenAt: number; + expiresAt: number; + publicUrl?: string | null; +} + +interface TunnelStatusResponse { + active: boolean; + url: string | null; + mode?: TunnelMode; + hasNamedTunnelToken?: boolean; + namedTunnelHostname?: string | null; + hasBootstrapToken?: boolean; + bootstrapExpiresAt?: number | null; + namedTunnelTokenPresetIds?: string[]; + activeTunnelMode?: TunnelMode | null; + activeSessions?: TunnelSessionRecord[]; + localPort?: number; + policy?: string; + ttlConfig?: { + bootstrapTtlMs?: number | null; + sessionTtlMs?: number; + }; +} + +const ttlOptionValue = (options: TtlOption[], ttlMs: number | null, fallback: string) => { + const matched = options.find((entry) => entry.ms === ttlMs); + return matched?.value || fallback; +}; + +const formatRemaining = (remainingMs: number): string => { + const safeMs = Math.max(0, remainingMs); + const totalSeconds = Math.floor(safeMs / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + if (hours > 0) { + return `${hours}h ${minutes}m`; + } + if (minutes > 0) { + return `${minutes}m ${seconds}s`; + } + return `${seconds}s`; +}; + +const formatAbsoluteTime = (timestamp: number): string => { + return new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); +}; + +const normalizePresetHostname = (value: string): string => { + const trimmed = value.trim(); + if (!trimmed) { + return ''; + } + + try { + const parsed = trimmed.includes('://') ? new URL(trimmed) : new URL(`https://${trimmed}`); + return parsed.hostname.trim().toLowerCase(); + } catch { + return trimmed.toLowerCase(); + } +}; + +const sanitizePresets = (value: unknown): NamedTunnelPreset[] => { + if (!Array.isArray(value)) { + return []; + } + + const seenIds = new Set(); + const seenHosts = new Set(); + const result: NamedTunnelPreset[] = []; + + for (const entry of value) { + if (!entry || typeof entry !== 'object') { + continue; + } + const candidate = entry as Record; + const id = typeof candidate.id === 'string' ? candidate.id.trim() : ''; + const name = typeof candidate.name === 'string' ? candidate.name.trim() : ''; + const hostname = normalizePresetHostname(typeof candidate.hostname === 'string' ? candidate.hostname : ''); + if (!id || !name || !hostname) { + continue; + } + if (seenIds.has(id) || seenHosts.has(hostname)) { + continue; + } + seenIds.add(id); + seenHosts.add(hostname); + result.push({ id, name, hostname }); + } + + return result; +}; + +const createPresetId = (): string => { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; +}; + +export const TunnelSettings: React.FC = () => { + const [state, setState] = React.useState('checking'); + const [tunnelInfo, setTunnelInfo] = React.useState(null); + const [activeTunnelMode, setActiveTunnelMode] = React.useState(null); + const [qrDataUrl, setQrDataUrl] = React.useState(null); + const [errorMessage, setErrorMessage] = React.useState(null); + const [namedValidationError, setNamedValidationError] = React.useState(null); + const [copied, setCopied] = React.useState(false); + const [isSavingTtl, setIsSavingTtl] = React.useState(false); + const [isSavingMode, setIsSavingMode] = React.useState(false); + const [tunnelMode, setTunnelMode] = React.useState('quick'); + const [namedTunnelPresets, setNamedTunnelPresets] = React.useState([]); + const [expandedNamedTunnels, setExpandedNamedTunnels] = React.useState>({}); + const [selectedPresetId, setSelectedPresetId] = React.useState(''); + const [sessionTokensByPresetId, setSessionTokensByPresetId] = React.useState>({}); + const [savedTokenPresetIds, setSavedTokenPresetIds] = React.useState>(new Set()); + const [isAddingPreset, setIsAddingPreset] = React.useState(false); + const [newPresetName, setNewPresetName] = React.useState(''); + const [newPresetHostname, setNewPresetHostname] = React.useState(''); + const [newPresetToken, setNewPresetToken] = React.useState(''); + const [bootstrapTtlMs, setBootstrapTtlMs] = React.useState(30 * 60 * 1000); + const [sessionTtlMs, setSessionTtlMs] = React.useState(8 * 60 * 60 * 1000); + const [remainingText, setRemainingText] = React.useState(''); + const [sessionRecords, setSessionRecords] = React.useState([]); + const [nowTs, setNowTs] = React.useState(() => Date.now()); + const [localPort, setLocalPort] = React.useState(null); + + const selectedPreset = React.useMemo( + () => namedTunnelPresets.find((preset) => preset.id === selectedPresetId) || namedTunnelPresets[0] || null, + [namedTunnelPresets, selectedPresetId] + ); + const renderedSessionRecords = React.useMemo(() => { + return sessionRecords.map((record) => { + const isExpired = record.expiresAt <= nowTs; + const isActive = record.status === 'active' && !isExpired; + const remainingTextForSession = isActive + ? formatRemaining(record.expiresAt - nowTs) + : (record.inactiveReason === 'expired' || isExpired ? 'expired' : 'inactive'); + const inactiveLabel = remainingTextForSession === 'expired' + ? 'Expired' + : (record.inactiveReason === 'tunnel-revoked' ? 'Revoked' : 'Inactive'); + + const mode = record.mode === 'named' ? 'named' : 'quick'; + return { + ...record, + isActive, + mode, + remainingTextForSession, + inactiveLabel, + }; + }); + }, [nowTs, sessionRecords]); + const isConnectLinkLive = React.useMemo(() => { + if (!tunnelInfo?.connectUrl) { + return false; + } + if (tunnelInfo.bootstrapExpiresAt === null) { + return true; + } + return tunnelInfo.bootstrapExpiresAt > nowTs; + }, [nowTs, tunnelInfo?.bootstrapExpiresAt, tunnelInfo?.connectUrl]); + const isSelectedModeTunnelReady = React.useMemo(() => { + if (!tunnelInfo) { + return false; + } + if (state !== 'active' && state !== 'stopping') { + return false; + } + return activeTunnelMode === tunnelMode; + }, [activeTunnelMode, state, tunnelInfo, tunnelMode]); + const suggestedConnectorPort = React.useMemo(() => { + if (typeof localPort === 'number' && Number.isFinite(localPort) && localPort > 0) { + return localPort; + } + if (typeof window === 'undefined') { + return null; + } + const parsed = Number(window.location.port); + if (Number.isFinite(parsed) && parsed > 0) { + return parsed; + } + return null; + }, [localPort]); + const openExternal = React.useCallback(async (url: string) => { + if (typeof window === 'undefined') { + return; + } + + type TauriShell = { shell?: { open?: (url: string) => Promise } }; + const tauri = (window as unknown as { __TAURI__?: TauriShell }).__TAURI__; + if (tauri?.shell?.open) { + try { + await tauri.shell.open(url); + return; + } catch { + // fall through + } + } + + try { + window.open(url, '_blank', 'noopener,noreferrer'); + } catch { + // ignore + } + }, []); + + const checkAvailabilityAndStatus = React.useCallback(async (signal: AbortSignal) => { + try { + const [checkRes, statusRes, settingsRes] = await Promise.all([ + fetch('/api/openchamber/tunnel/check', { signal }), + fetch('/api/openchamber/tunnel/status', { signal }), + fetch('/api/config/settings', { signal, headers: { Accept: 'application/json' } }), + ]); + + const checkData = await checkRes.json(); + const statusData = (await statusRes.json()) as TunnelStatusResponse; + const settingsData = settingsRes.ok ? await settingsRes.json() : {}; + + const loadedBootstrapTtl = statusData.ttlConfig?.bootstrapTtlMs + ?? (settingsData?.tunnelBootstrapTtlMs === null + ? null + : typeof settingsData?.tunnelBootstrapTtlMs === 'number' + ? settingsData.tunnelBootstrapTtlMs + : 30 * 60 * 1000); + const loadedSessionTtl = typeof statusData.ttlConfig?.sessionTtlMs === 'number' + ? statusData.ttlConfig.sessionTtlMs + : typeof settingsData?.tunnelSessionTtlMs === 'number' + ? settingsData.tunnelSessionTtlMs + : 8 * 60 * 60 * 1000; + + const loadedMode: TunnelMode = statusData.mode === 'named' + ? 'named' + : settingsData?.tunnelMode === 'named' + ? 'named' + : 'quick'; + + const loadedHostname = typeof statusData.namedTunnelHostname === 'string' + ? statusData.namedTunnelHostname + : typeof settingsData?.namedTunnelHostname === 'string' + ? settingsData.namedTunnelHostname + : ''; + + const loadedPresets = sanitizePresets(settingsData?.namedTunnelPresets); + const presets = loadedPresets.length > 0 + ? loadedPresets + : (loadedHostname + ? [{ id: 'legacy-default', name: 'Default', hostname: normalizePresetHostname(loadedHostname) }] + : []); + + const requestedPresetId = typeof settingsData?.namedTunnelSelectedPresetId === 'string' + ? settingsData.namedTunnelSelectedPresetId.trim() + : ''; + + const selectedId = (requestedPresetId && presets.some((preset) => preset.id === requestedPresetId)) + ? requestedPresetId + : presets[0]?.id || ''; + + setBootstrapTtlMs(loadedBootstrapTtl); + setSessionTtlMs(loadedSessionTtl); + setTunnelMode(loadedMode); + setNamedTunnelPresets(presets); + setSelectedPresetId(selectedId); + setSessionRecords(Array.isArray(statusData.activeSessions) ? statusData.activeSessions : []); + setActiveTunnelMode(statusData.activeTunnelMode || (statusData.active && statusData.mode ? statusData.mode : null)); + setSavedTokenPresetIds(new Set(Array.isArray(statusData.namedTunnelTokenPresetIds) ? statusData.namedTunnelTokenPresetIds : [])); + setLocalPort(typeof statusData.localPort === 'number' ? statusData.localPort : null); + + if (statusData.active && statusData.url) { + setTunnelInfo({ + url: statusData.url, + connectUrl: null, + bootstrapExpiresAt: typeof statusData.bootstrapExpiresAt === 'number' ? statusData.bootstrapExpiresAt : null, + }); + setState('active'); + return; + } + + setState(checkData.available ? 'idle' : 'not-available'); + } catch { + if (!signal.aborted) { + setState('error'); + setErrorMessage('Failed to check tunnel availability'); + } + } + }, []); + + React.useEffect(() => { + const controller = new AbortController(); + void checkAvailabilityAndStatus(controller.signal); + return () => controller.abort(); + }, [checkAvailabilityAndStatus]); + + React.useEffect(() => { + if (!tunnelInfo?.connectUrl) { + setQrDataUrl(null); + return; + } + + let cancelled = false; + QRCode.toDataURL(tunnelInfo.connectUrl, { + width: 256, + margin: 2, + color: { dark: '#000000', light: '#ffffff' }, + }).then((dataUrl) => { + if (!cancelled) { + setQrDataUrl(dataUrl); + } + }).catch(() => { + if (!cancelled) { + setQrDataUrl(null); + } + }); + + return () => { + cancelled = true; + }; + }, [tunnelInfo?.connectUrl]); + + React.useEffect(() => { + if (!tunnelInfo?.bootstrapExpiresAt) { + setRemainingText('No expiry'); + return; + } + + const updateRemaining = () => { + const remaining = tunnelInfo.bootstrapExpiresAt ? tunnelInfo.bootstrapExpiresAt - Date.now() : 0; + if (remaining <= 0) { + setRemainingText('Expired'); + } else { + setRemainingText(formatRemaining(remaining)); + } + }; + + updateRemaining(); + const timer = window.setInterval(updateRemaining, 1000); + return () => window.clearInterval(timer); + }, [tunnelInfo?.bootstrapExpiresAt]); + + React.useEffect(() => { + const timer = window.setInterval(() => setNowTs(Date.now()), 1000); + return () => window.clearInterval(timer); + }, []); + + React.useEffect(() => { + if (state === 'starting' || state === 'stopping' || state === 'checking') { + return; + } + + let cancelled = false; + const refreshSessions = async () => { + try { + const statusRes = await fetch('/api/openchamber/tunnel/status'); + if (!statusRes.ok || cancelled) { + return; + } + const statusData = (await statusRes.json()) as TunnelStatusResponse; + if (cancelled) { + return; + } + setSessionRecords(Array.isArray(statusData.activeSessions) ? statusData.activeSessions : []); + setSavedTokenPresetIds(new Set(Array.isArray(statusData.namedTunnelTokenPresetIds) ? statusData.namedTunnelTokenPresetIds : [])); + setLocalPort(typeof statusData.localPort === 'number' ? statusData.localPort : null); + } catch { + // ignore transient refresh failures + } + }; + + const timer = window.setInterval(() => { + void refreshSessions(); + }, 5000); + + return () => { + cancelled = true; + window.clearInterval(timer); + }; + }, [state]); + + const saveTunnelSettings = React.useCallback(async (payload: { + tunnelMode?: TunnelMode; + namedTunnelHostname?: string; + namedTunnelPresets?: NamedTunnelPreset[]; + namedTunnelSelectedPresetId?: string; + namedTunnelPresetTokens?: Record; + tunnelBootstrapTtlMs?: number | null; + tunnelSessionTtlMs?: number; + }) => { + setIsSavingMode(true); + try { + await updateDesktopSettings(payload); + if (Object.prototype.hasOwnProperty.call(payload, 'tunnelMode') && payload.tunnelMode) { + setTunnelMode(payload.tunnelMode); + } + if (Object.prototype.hasOwnProperty.call(payload, 'namedTunnelPresets') && payload.namedTunnelPresets) { + setNamedTunnelPresets(payload.namedTunnelPresets); + } + if (Object.prototype.hasOwnProperty.call(payload, 'namedTunnelSelectedPresetId')) { + setSelectedPresetId(payload.namedTunnelSelectedPresetId || ''); + } + } catch { + toast.error('Failed to save tunnel settings'); + } finally { + setIsSavingMode(false); + } + }, []); + + const saveTtlSettings = React.useCallback(async (nextBootstrapTtlMs: number | null, nextSessionTtlMs: number) => { + setIsSavingTtl(true); + try { + await updateDesktopSettings({ + tunnelBootstrapTtlMs: nextBootstrapTtlMs, + tunnelSessionTtlMs: nextSessionTtlMs, + }); + } catch { + toast.error('Failed to save tunnel TTL settings'); + } finally { + setIsSavingTtl(false); + } + }, []); + + const persistNamedTunnelToken = React.useCallback(async (payload: { + presetId: string; + presetName: string; + hostname: string; + token: string; + }) => { + const token = payload.token.trim(); + if (!token) { + return; + } + + try { + const tokenMap = { + ...sessionTokensByPresetId, + [payload.presetId]: token, + }; + await updateDesktopSettings({ + namedTunnelPresetTokens: tokenMap, + }); + setSavedTokenPresetIds((prev) => { + const next = new Set(prev); + next.add(payload.presetId); + return next; + }); + } catch { + toast.error('Failed to save named tunnel token'); + } + }, [sessionTokensByPresetId]); + + const handleStart = React.useCallback(async () => { + setState('starting'); + setErrorMessage(null); + setNamedValidationError(null); + + try { + let namedTunnelHostname = ''; + let namedTunnelToken = ''; + + if (tunnelMode === 'named') { + if (!selectedPreset) { + setState('idle'); + setNamedValidationError('Select or add a named tunnel first'); + toast.error('Select or add a named tunnel first'); + return; + } + + namedTunnelHostname = selectedPreset.hostname; + namedTunnelToken = (sessionTokensByPresetId[selectedPreset.id] || '').trim(); + + await saveTunnelSettings({ + tunnelMode: 'named', + namedTunnelHostname, + namedTunnelPresets, + namedTunnelSelectedPresetId: selectedPreset.id, + }); + } + + const res = await fetch('/api/openchamber/tunnel/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + mode: tunnelMode, + ...(tunnelMode === 'named' && selectedPreset ? { + namedTunnelPresetId: selectedPreset.id, + namedTunnelPresetName: selectedPreset.name, + } : {}), + ...(tunnelMode === 'named' && namedTunnelHostname ? { namedTunnelHostname } : {}), + ...(tunnelMode === 'named' && namedTunnelToken ? { namedTunnelToken } : {}), + }), + }); + const data = await res.json(); + + if (!res.ok || !data.ok) { + if (tunnelMode === 'named' && typeof data.error === 'string' && data.error.includes('Named tunnel token is required')) { + setState('idle'); + setNamedValidationError('Named tunnel token is required before starting'); + toast.error('Add a named tunnel token before starting'); + return; + } + setState('error'); + setErrorMessage(data.error || 'Failed to start tunnel'); + toast.error(data.error || 'Failed to start tunnel'); + return; + } + + setTunnelInfo({ + url: data.url, + connectUrl: typeof data.connectUrl === 'string' ? data.connectUrl : null, + bootstrapExpiresAt: typeof data.bootstrapExpiresAt === 'number' ? data.bootstrapExpiresAt : null, + }); + setActiveTunnelMode(data.activeTunnelMode === 'named' || data.activeTunnelMode === 'quick' + ? data.activeTunnelMode + : (data.mode === 'named' || data.mode === 'quick' ? data.mode : tunnelMode)); + setSessionRecords(Array.isArray(data.activeSessions) ? data.activeSessions : []); + if (Array.isArray(data.namedTunnelTokenPresetIds)) { + setSavedTokenPresetIds(new Set(data.namedTunnelTokenPresetIds)); + } + if (typeof data.localPort === 'number') { + setLocalPort(data.localPort); + } + if (data.mode === 'named' || data.mode === 'quick') { + setTunnelMode(data.mode); + } + setState('active'); + toast.success('Tunnel link ready'); + } catch { + setState('error'); + setErrorMessage('Failed to start tunnel'); + toast.error('Failed to start tunnel'); + } + }, [ + namedTunnelPresets, + saveTunnelSettings, + selectedPreset, + sessionTokensByPresetId, + tunnelMode, + ]); + + const handleStop = React.useCallback(async () => { + setState('stopping'); + + try { + await fetch('/api/openchamber/tunnel/stop', { method: 'POST' }); + const statusRes = await fetch('/api/openchamber/tunnel/status'); + if (statusRes.ok) { + const statusData = (await statusRes.json()) as TunnelStatusResponse; + setSessionRecords(Array.isArray(statusData.activeSessions) ? statusData.activeSessions : []); + setSavedTokenPresetIds(new Set(Array.isArray(statusData.namedTunnelTokenPresetIds) ? statusData.namedTunnelTokenPresetIds : [])); + setLocalPort(typeof statusData.localPort === 'number' ? statusData.localPort : null); + } + setTunnelInfo(null); + setActiveTunnelMode(null); + setQrDataUrl(null); + setState('idle'); + toast.success('Tunnel stopped'); + } catch { + setState('error'); + setErrorMessage('Failed to stop tunnel'); + toast.error('Failed to stop tunnel'); + } + }, []); + + const handleCopyUrl = React.useCallback(async () => { + if (!tunnelInfo?.connectUrl) { + return; + } + + try { + await navigator.clipboard.writeText(tunnelInfo.connectUrl); + setCopied(true); + toast.success('Connect link copied'); + setTimeout(() => setCopied(false), 2000); + } catch { + toast.error('Failed to copy URL'); + } + }, [tunnelInfo?.connectUrl]); + + const handleBootstrapTtlChange = React.useCallback(async (value: string) => { + const option = BOOTSTRAP_TTL_OPTIONS.find((entry) => entry.value === value); + if (!option) { + return; + } + setBootstrapTtlMs(option.ms); + await saveTtlSettings(option.ms, sessionTtlMs); + }, [saveTtlSettings, sessionTtlMs]); + + const handleSessionTtlChange = React.useCallback(async (value: string) => { + const option = SESSION_TTL_OPTIONS.find((entry) => entry.value === value); + if (!option || option.ms === null) { + return; + } + setSessionTtlMs(option.ms); + await saveTtlSettings(bootstrapTtlMs, option.ms); + }, [bootstrapTtlMs, saveTtlSettings]); + + const handleModeChange = React.useCallback(async (value: TunnelMode) => { + setNamedValidationError(null); + setErrorMessage(null); + if (state !== 'active' && state !== 'stopping' && state !== 'starting') { + setState('idle'); + } + + const nextHostname = value === 'named' && selectedPreset ? selectedPreset.hostname : undefined; + await saveTunnelSettings({ + tunnelMode: value, + ...(nextHostname ? { namedTunnelHostname: nextHostname } : {}), + namedTunnelPresets, + namedTunnelSelectedPresetId: selectedPresetId || undefined, + }); + }, [namedTunnelPresets, saveTunnelSettings, selectedPreset, selectedPresetId, state]); + + const persistSelectedPreset = React.useCallback(async (preset: NamedTunnelPreset, presets: NamedTunnelPreset[]) => { + try { + await updateDesktopSettings({ + namedTunnelSelectedPresetId: preset.id, + namedTunnelHostname: preset.hostname, + namedTunnelPresets: presets, + }); + } catch { + toast.error('Failed to save selected named tunnel'); + } + }, []); + + const handleSelectPreset = React.useCallback((presetId: string) => { + const preset = namedTunnelPresets.find((entry) => entry.id === presetId); + if (!preset) { + return; + } + + setSelectedPresetId(preset.id); + setNamedValidationError(null); + void persistSelectedPreset(preset, namedTunnelPresets); + }, [namedTunnelPresets, persistSelectedPreset]); + + const handleSaveNewPreset = React.useCallback(async () => { + const name = newPresetName.trim(); + const hostname = normalizePresetHostname(newPresetHostname); + const token = newPresetToken.trim(); + + if (!name) { + toast.error('Tunnel name is required'); + return; + } + if (!hostname) { + toast.error('Named tunnel hostname is required'); + return; + } + if (!token) { + toast.error('Named tunnel token is required'); + return; + } + + if (namedTunnelPresets.some((preset) => preset.hostname === hostname)) { + toast.error('This hostname already exists'); + return; + } + + const nextPreset: NamedTunnelPreset = { + id: createPresetId(), + name, + hostname, + }; + const nextPresets = [...namedTunnelPresets, nextPreset]; + + setNamedTunnelPresets(nextPresets); + setSelectedPresetId(nextPreset.id); + setExpandedNamedTunnels((prev) => ({ ...prev, [nextPreset.id]: true })); + setSessionTokensByPresetId((prev) => ({ ...prev, [nextPreset.id]: token })); + setNamedValidationError(null); + setIsAddingPreset(false); + setNewPresetName(''); + setNewPresetHostname(''); + setNewPresetToken(''); + + await saveTunnelSettings({ + tunnelMode: 'named', + namedTunnelHostname: nextPreset.hostname, + namedTunnelPresets: nextPresets, + namedTunnelSelectedPresetId: nextPreset.id, + namedTunnelPresetTokens: { + ...sessionTokensByPresetId, + [nextPreset.id]: token, + }, + }); + await persistNamedTunnelToken({ + presetId: nextPreset.id, + presetName: nextPreset.name, + hostname: nextPreset.hostname, + token, + }); + toast.success('Named tunnel saved'); + }, [namedTunnelPresets, newPresetHostname, newPresetName, newPresetToken, persistNamedTunnelToken, saveTunnelSettings, sessionTokensByPresetId]); + + const handleRemovePreset = React.useCallback(async (presetId: string) => { + const preset = namedTunnelPresets.find((entry) => entry.id === presetId); + if (!preset) { + return; + } + + const nextPresets = namedTunnelPresets.filter((entry) => entry.id !== preset.id); + const fallbackSelectedId = nextPresets[0]?.id || ''; + const nextSelectedId = selectedPresetId === preset.id ? fallbackSelectedId : selectedPresetId; + const nextSelectedPreset = nextPresets.find((entry) => entry.id === nextSelectedId) || null; + const nextHostname = nextSelectedPreset?.hostname; + const nextTokenMap = Object.fromEntries( + Object.entries(sessionTokensByPresetId) + .filter(([id, tokenValue]) => id !== preset.id && tokenValue.trim().length > 0) + ); + + setNamedTunnelPresets(nextPresets); + setSelectedPresetId(nextSelectedId); + setExpandedNamedTunnels((prev) => { + const next = { ...prev }; + delete next[preset.id]; + return next; + }); + setSessionTokensByPresetId((prev) => { + const next = { ...prev }; + delete next[preset.id]; + return next; + }); + setSavedTokenPresetIds((prev) => { + const next = new Set(prev); + next.delete(preset.id); + return next; + }); + setNamedValidationError(null); + + await saveTunnelSettings({ + namedTunnelPresets: nextPresets, + namedTunnelSelectedPresetId: nextSelectedId || undefined, + namedTunnelHostname: nextHostname, + namedTunnelPresetTokens: nextTokenMap, + }); + + toast.success('Named tunnel removed'); + }, [namedTunnelPresets, saveTunnelSettings, selectedPresetId, sessionTokensByPresetId]); + + const primaryCtaClass = 'gap-2 border-[var(--primary-base)] bg-[var(--primary-base)] text-[var(--primary-foreground)] hover:bg-[var(--primary-hover)] hover:text-[var(--primary-foreground)]'; + + if (state === 'checking') { + return ( +
+ +
+ ); + } + + return ( +
+
+

Remote Tunnel

+

+ Configure secure remote access with quick links or your own named Cloudflare tunnel. +

+

+ Secure Tunnel access is enforced server-side. +

+

+ Connect links are one-time and are revoked when tunnel stops or Connect link TTL expired. +

+
+ + {renderedSessionRecords.length > 0 && ( +
+
+
+ +

Redeemed access links

+
+
+ {renderedSessionRecords.map((record) => { + const isQuick = record.mode === 'quick'; + const modeBadgeClass = isQuick + ? 'border-[var(--status-warning-border)] bg-[var(--status-warning-background)] text-[var(--status-warning)]' + : 'border-[var(--status-info-border)] bg-[var(--status-info-background)] text-[var(--status-info)]'; + const statusDotClass = record.isActive + ? (isQuick ? 'text-[var(--status-warning)]' : 'text-[var(--status-info)]') + : 'text-muted-foreground/50'; + + return ( +
+ + + {isQuick ? 'QUICK' : 'NAMED'} + + + Redeemed {formatAbsoluteTime(record.createdAt)} + + + {record.isActive + ? `Expires in ${record.remainingTextForSession}` + : (record.inactiveLabel === 'Inactive' ? 'Inactive' : `Inactive (${record.inactiveLabel})`)} + +
+ ); + })} +
+
+
+ )} + + {state === 'not-available' && ( +
+
+ +
+

cloudflared not found

+

Install it to enable remote tunnel access:

+ + brew install cloudflared + +
+
+
+ )} + + {state !== 'not-available' && ( +
+
+

Tunnel type

+
+ + + { + void handleModeChange('quick'); + }} + disabled={isSavingMode || state === 'starting' || state === 'stopping'} + > + Quick Tunnel + + + + {QUICK_TUNNEL_TOOLTIP} + + + + + + { + void handleModeChange('named'); + }} + disabled={isSavingMode || state === 'starting' || state === 'stopping'} + > + Named Tunnel + + + + {NAMED_TUNNEL_TOOLTIP} + + +
+
+ +
+
+ Connect link TTL + +
+ +
+ Tunnel session TTL + +
+
+ + {tunnelMode === 'quick' && ( +
+
+ +
+

+ Quick Tunnel is best effort and Cloudflare does not guarantee uptime. +

+

+ For more reliable long-lived access, switch to Named Tunnel mode. +

+
+
+
+ )} + + {tunnelMode === 'named' && ( +
+ {typeof suggestedConnectorPort === 'number' && ( +
+

+ Cloudflare connector target: http://localhost:{suggestedConnectorPort} +

+
+ )} + +
+

Saved named tunnels

+ setIsAddingPreset((prev) => !prev)} + disabled={state === 'starting' || state === 'stopping' || isSavingMode} + > + + Add + +
+ + {namedTunnelPresets.length > 0 ? ( +
+ {namedTunnelPresets.map((preset, index) => { + const rowToken = sessionTokensByPresetId[preset.id] || ''; + const hasSavedToken = savedTokenPresetIds.has(preset.id); + const isOpen = expandedNamedTunnels[preset.id] ?? false; + + return ( +
+ { + setExpandedNamedTunnels((prev) => ({ ...prev, [preset.id]: open })); + if (open) { + void handleSelectPreset(preset.id); + } + }} + className="py-1.5" + > +
+ + {isOpen + ? + : } + {preset.name} + + + { + void handleRemovePreset(preset.id); + }} + disabled={state === 'starting' || state === 'stopping' || isSavingMode} + > + + +
+ + +
+

Hostname: {preset.hostname}

+ { + const nextValue = event.target.value; + setNamedValidationError(null); + setSessionTokensByPresetId((prev) => ({ ...prev, [preset.id]: nextValue })); + }} + onBlur={(event) => { + const tokenToSave = event.currentTarget.value.trim(); + if (!tokenToSave) { + return; + } + void persistNamedTunnelToken({ + presetId: preset.id, + presetName: preset.name, + hostname: preset.hostname, + token: tokenToSave, + }); + }} + placeholder={hasSavedToken ? 'Saved token available (optional to replace)' : 'Paste token for this tunnel'} + className="h-7" + disabled={state === 'starting' || state === 'stopping'} + /> +
+ { + void persistNamedTunnelToken({ + presetId: preset.id, + presetName: preset.name, + hostname: preset.hostname, + token: rowToken, + }); + }} + > + Save token + +
+
+
+
+
+ ); + })} +
+ ) : ( +

No named tunnels saved yet.

+ )} + + {isAddingPreset && ( +
+ setNewPresetName(event.target.value)} + placeholder="Tunnel name (e.g. Production)" + className="h-7" + disabled={isSavingMode || state === 'starting' || state === 'stopping'} + /> + setNewPresetHostname(event.target.value)} + placeholder="Hostname (e.g. oc.example.com)" + className="h-7" + disabled={isSavingMode || state === 'starting' || state === 'stopping'} + /> + setNewPresetToken(event.target.value)} + placeholder="Token" + className="h-7" + disabled={isSavingMode || state === 'starting' || state === 'stopping'} + /> + {typeof suggestedConnectorPort === 'number' && ( +

+ For Cloudflare connector target, use http://localhost:{suggestedConnectorPort}. +

+ )} +
+ { + void handleSaveNewPreset(); + }} + disabled={isSavingMode || state === 'starting' || state === 'stopping'} + > + Save + + { + setIsAddingPreset(false); + setNewPresetName(''); + setNewPresetHostname(''); + setNewPresetToken(''); + }} + disabled={isSavingMode || state === 'starting' || state === 'stopping'} + > + Cancel + +
+
+ )} + +
+

Tokens are saved per tunnel and reused from disk

+ + + + + + Tokens are saved in ~/.config/openchamber/cloudflare-named-tunnels.json. + + +
+ + {!selectedPreset && namedValidationError && ( +

{namedValidationError}

+ )} +
+ )} + + {!isSelectedModeTunnelReady && ( +
+
+
+ +
+ {tunnelMode === 'named' && ( + <> +

+ Named tunnels require a bought domain in your Cloudflare account. +

+ + + )} +

+ Start a {tunnelMode === 'named' ? 'named' : 'quick'} tunnel and generate a one-time connect link. Do not close the app while this tunnel is in use. +

+
+
+
+ + {tunnelMode === 'named' && ( +
+

Named tunnel to connect

+ +
+ )} + + + {state === 'starting' + ? <> Starting tunnel... + : 'Start Tunnel'} + +
+ )} + +
+ )} + + {isSelectedModeTunnelReady && tunnelInfo && ( +
+
+
+
+

Tunnel ready

+
+ +
+

Public URL (Not accessible without a token)

+ + {tunnelInfo.url} + +
+ + {isConnectLinkLive && tunnelInfo.connectUrl && ( + <> +
+

Connect link

+
+ + {tunnelInfo.connectUrl} + + + {copied + ? + : } + {copied ? 'Copied' : 'Copy'} + +
+

+ Expires: {tunnelInfo.bootstrapExpiresAt ? remainingText : 'Never'} +

+
+ +
+ {qrDataUrl + ? Tunnel connect QR code + :
} +

Scan with your phone to connect

+
+ + )} +
+ +
+
+ + + New connect link + + + + {state === 'stopping' + ? <> Stopping... + : 'Stop Tunnel'} + +
+
+
+ )} + + {state === 'error' && errorMessage && ( +
+

{errorMessage}

+ Retry +
+ )} +
+ ); +}; diff --git a/packages/ui/src/components/sections/openchamber/types.ts b/packages/ui/src/components/sections/openchamber/types.ts index 2e7303a7..d67577f3 100644 --- a/packages/ui/src/components/sections/openchamber/types.ts +++ b/packages/ui/src/components/sections/openchamber/types.ts @@ -6,4 +6,5 @@ export type OpenChamberSection = | 'git' | 'github' | 'notifications' - | 'voice'; + | 'voice' + | 'tunnel'; diff --git a/packages/ui/src/components/views/SettingsView.tsx b/packages/ui/src/components/views/SettingsView.tsx index 95394ae4..8d4e62bf 100644 --- a/packages/ui/src/components/views/SettingsView.tsx +++ b/packages/ui/src/components/views/SettingsView.tsx @@ -20,7 +20,7 @@ import { RiCloudLine, RiFoldersLine, RiGitBranchLine, - + RiGlobalLine, RiMicLine, RiNotification3Line, RiPaletteLine, @@ -97,6 +97,7 @@ const pageOrder: SettingsPageSlug[] = [ 'skills.installed', 'skills.catalog', 'voice', + 'tunnel', ]; function buildRuntimeContext(isDesktop: boolean): SettingsRuntimeContext { @@ -150,6 +151,8 @@ function getSettingsNavIcon(slug: SettingsPageSlug): React.ComponentType<{ class return RiBarChart2Line; case 'voice': return RiMicLine; + case 'tunnel': + return RiGlobalLine; case 'home': return null; default: @@ -367,6 +370,7 @@ export const SettingsView: React.FC = ({ onClose, forceMobile sessions: 'sessions', notifications: 'notifications', voice: 'voice', + tunnel: 'tunnel', }), []); const renderUnavailable = React.useCallback(() => { @@ -437,7 +441,8 @@ export const SettingsView: React.FC = ({ onClose, forceMobile case 'shortcuts': case 'sessions': case 'notifications': - case 'voice': { + case 'voice': + case 'tunnel': { const section = openChamberSectionBySlug[slug] ?? 'visual'; return ; } @@ -511,7 +516,7 @@ export const SettingsView: React.FC = ({ onClose, forceMobile )} > {page.title} - {page.slug === 'voice' && ( + {(page.slug === 'voice' || page.slug === 'tunnel') && ( beta diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index 342c863b..88a28b64 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -29,6 +29,12 @@ export type SkillCatalogConfig = { gitIdentityId?: string; }; +export type NamedTunnelPreset = { + id: string; + name: string; + hostname: string; +}; + export type DesktopSettings = { themeId?: string; useSystemTheme?: boolean; @@ -84,6 +90,15 @@ export type DesktopSettings = { }>; // Per-provider custom model groups configuration autoDeleteEnabled?: boolean; autoDeleteAfterDays?: number; + tunnelMode?: 'quick' | 'named'; + tunnelBootstrapTtlMs?: number | null; + tunnelSessionTtlMs?: number; + namedTunnelHostname?: string; + namedTunnelToken?: string | null; + hasNamedTunnelToken?: boolean; + namedTunnelPresets?: NamedTunnelPreset[]; + namedTunnelSelectedPresetId?: string; + namedTunnelPresetTokens?: Record; defaultModel?: string; // format: "provider/model" defaultVariant?: string; defaultAgent?: string; diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts index c355e067..c3485670 100644 --- a/packages/ui/src/lib/persistence.ts +++ b/packages/ui/src/lib/persistence.ts @@ -208,6 +208,51 @@ const sanitizeProjects = (value: unknown): DesktopSettings['projects'] | undefin return result.length > 0 ? result : undefined; }; +const sanitizeNamedTunnelPresets = (value: unknown): DesktopSettings['namedTunnelPresets'] | undefined => { + if (!Array.isArray(value)) { + return undefined; + } + + const result: NonNullable = []; + const seenIds = new Set(); + const seenHostnames = new Set(); + + for (const entry of value) { + if (!entry || typeof entry !== 'object') continue; + const candidate = entry as Record; + + 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; + const result: Record = {}; + 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 => { if (!Array.isArray(value)) { return undefined; @@ -444,6 +489,40 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => { if (typeof candidate.autoDeleteAfterDays === 'number' && Number.isFinite(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) { result.defaultModel = candidate.defaultModel; } diff --git a/packages/ui/src/lib/settings/metadata.ts b/packages/ui/src/lib/settings/metadata.ts index 9eeb0992..ddd60b19 100644 --- a/packages/ui/src/lib/settings/metadata.ts +++ b/packages/ui/src/lib/settings/metadata.ts @@ -17,7 +17,8 @@ export type SettingsPageSlug = | 'shortcuts' | 'sessions' | 'notifications' - | 'voice'; + | 'voice' + | 'tunnel'; export type SettingsPageGroup = | '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: '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; export const LEGACY_SIDEBAR_SECTION_TO_SETTINGS_SLUG: Record = { diff --git a/packages/web/server/index.js b/packages/web/server/index.js index e5cc810a..dd1f34f7 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -9,7 +9,13 @@ import { fileURLToPath } from 'url'; import os from 'os'; import crypto from 'crypto'; 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 { 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 OPEN_CODE_READY_GRACE_MS = 12000; 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 = (() => { try { 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 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 normalizeThemeJson = (raw) => { @@ -1021,6 +1135,8 @@ const OPENCHAMBER_DATA_DIR = process.env.OPENCHAMBER_DATA_DIR : path.join(os.homedir(), '.config', 'openchamber'); 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 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_ICON_MIME_TO_EXTENSION = { 'image/png': 'png', @@ -1154,6 +1270,7 @@ const writeSettingsToDisk = async (settings) => { const PUSH_SUBSCRIPTIONS_VERSION = 1; let persistPushSubscriptionsLock = Promise.resolve(); +let persistNamedTunnelConfigLock = Promise.resolve(); const readPushSubscriptionsFromDisk = async () => { try { @@ -1201,6 +1318,167 @@ const persistPushSubscriptionUpdate = async (mutate) => { 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) => { if (typeof value !== 'string') { return null; @@ -1612,6 +1890,38 @@ const sanitizeSettingsUpdate = (payload) => { const normalizedDays = Math.max(1, Math.min(365, Math.round(candidate.autoDeleteAfterDays))); 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); if (typography) { @@ -1892,11 +2202,14 @@ const mergePersistedSettings = (current, changes) => { const formatSettingsResponse = (settings) => { const sanitized = sanitizeSettingsUpdate(settings); + delete sanitized.namedTunnelToken; const approved = normalizeStringArray(settings.approvedDirectories); const bookmarks = normalizeStringArray(settings.securityScopedBookmarks); + const hasNamedTunnelToken = typeof settings?.namedTunnelToken === 'string' && settings.namedTunnelToken.trim().length > 0; return { ...sanitized, + hasNamedTunnelToken, approvedDirectories: approved, securityScopedBookmarks: bookmarks, pinnedDirectories: normalizeStringArray(settings.pinnedDirectories), @@ -2846,6 +3159,32 @@ const persistSettings = async (changes) => { 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); console.log(`[persistSettings] Successfully saved ${next.projects?.length || 0} projects to disk`); return formatSettingsResponse(next); @@ -2904,6 +3243,9 @@ let isExternalOpenCode = false; let exitOnShutdown = true; let uiAuthController = null; let cloudflareTunnelController = null; +const tunnelAuthController = createTunnelAuth(); +let runtimeNamedTunnelToken = ''; +let runtimeNamedTunnelHostname = ''; let terminalInputWsServer = null; const userProvidedOpenCodePassword = typeof hmrState.userProvidedOpenCodePassword === 'string' && hmrState.userProvidedOpenCodePassword.length > 0 @@ -5869,6 +6211,7 @@ async function gracefulShutdown(options = {}) { console.log('Stopping Cloudflare tunnel...'); cloudflareTunnelController.stop(); cloudflareTunnelController = null; + tunnelAuthController.clearActiveTunnel(); } console.log('Graceful shutdown complete'); @@ -6003,7 +6346,8 @@ async function main(options = {}) { req.path.startsWith('/api/opencode') || req.path.startsWith('/api/push') || 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); @@ -6029,16 +6373,65 @@ async function main(options = {}) { } 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 { await uiAuthController.handleSessionStatus(req, res); } catch (err) { 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) => { try { + const requestScope = tunnelAuthController.classifyRequestScope(req); + if (requestScope === 'tunnel' || requestScope === 'unknown-public') { + return tunnelAuthController.requireTunnelSession(req, res, next); + } await uiAuthController.requireAuth(req, res, next); } catch (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) => { let targetUrl; try { @@ -7071,7 +7708,6 @@ async function main(options = {}) { app.put('/api/config/settings', async (req, res) => { 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 { const updated = await persistSettings(req.body ?? {}); 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) { try { const originUrl = `http://localhost:${activePort}`; - cloudflareTunnelController = await startCloudflareTunnel({ originUrl, port: activePort }); + cloudflareTunnelController = await startCloudflareQuickTunnel({ originUrl, port: activePort }); printTunnelWarning(); + 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) { - const tunnelUrl = cloudflareTunnelController.getPublicUrl(); if (tunnelUrl) { onTunnelReady(tunnelUrl); } diff --git a/packages/web/server/lib/cloudflare-tunnel.js b/packages/web/server/lib/cloudflare-tunnel.js index 3efa2f35..29efb9b1 100644 --- a/packages/web/server/lib/cloudflare-tunnel.js +++ b/packages/web/server/lib/cloudflare-tunnel.js @@ -9,6 +9,8 @@ const __dirname = path.dirname(__filename); const TRY_CF_URL_REGEX = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i; +const DEFAULT_STARTUP_TIMEOUT_MS = 30000; + async function searchPathFor(command) { const pathValue = process.env.PATH || ''; 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(); if (!cfCheck.available) { @@ -98,15 +110,7 @@ export async function startCloudflareTunnel({ originUrl, port }) { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-cf-')); - const child = spawn('cloudflared', ['tunnel', '--url', originUrl], { - stdio: ['ignore', 'pipe', 'pipe'], - env: { - ...process.env, - HOME: tempDir, - CF_TELEMETRY_DISABLE: '1', - }, - killSignal: 'SIGINT', - }); + const child = spawnCloudflared(['tunnel', '--url', originUrl], { HOME: tempDir }); let publicUrl = null; let tunnelReady = false; @@ -148,7 +152,7 @@ export async function startCloudflareTunnel({ originUrl, port }) { if (!publicUrl) { reject(new Error('Tunnel URL not received within 30 seconds')); } - }, 30000); + }, DEFAULT_STARTUP_TIMEOUT_MS); const checkReady = setInterval(() => { if (publicUrl) { @@ -169,6 +173,7 @@ export async function startCloudflareTunnel({ originUrl, port }) { }); return { + mode: 'quick', stop: () => { try { 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() { console.log(` ⚠️ Cloudflare Quick Tunnel Limitations: @@ -193,4 +271,4 @@ export function printTunnelWarning() { For production use, set up a named Cloudflare Tunnel: https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/ `); -} \ No newline at end of file +} diff --git a/packages/web/server/lib/opencode/tunnel-auth.js b/packages/web/server/lib/opencode/tunnel-auth.js new file mode 100644 index 00000000..329bef19 --- /dev/null +++ b/packages/web/server/lib/opencode/tunnel-auth.js @@ -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, + }; +};