From 5defb6dbfcde7cb7b324f2c15424a48b8525714b Mon Sep 17 00:00:00 2001 From: weishu Date: Sun, 28 Dec 2025 20:31:48 +0800 Subject: [PATCH] feat: add wireguard relay integration for public server access with End-to-End Encryption Integrates tunwg (WireGuard tunnel) to enable optional public access to the hapi server. Tunnel is disabled by default and enabled via --relay flag or HAPI_TUNNEL=true environment variable. Users can now run 'hapi server --relay' and get a direct link like: https://app.hapi.run/?server=https://xxx.relay.hapi.run&token=xxx --- .gitignore | 4 + bun.lock | 56 +++++ cli/src/commands/claude.ts | 1 + cli/src/commands/server.ts | 13 +- cli/src/index.ts | 0 cli/src/runtime/assets.ts | 35 ++- cli/src/runtime/embeddedAssets.bun.ts | 64 ++++-- cli/src/types/assetImports.d.ts | 30 +++ package.json | 5 +- server/package.json | 2 + server/scripts/download-tunwg.ts | 74 +++++++ server/src/config/serverSettings.ts | 17 +- server/src/config/settings.ts | 1 + server/src/configuration.ts | 11 +- server/src/index.ts | 72 ++++++- server/src/tunnel/index.ts | 1 + server/src/tunnel/tunnelManager.ts | 294 ++++++++++++++++++++++++++ server/tools/.gitignore | 3 + server/tools/tunwg/LICENSE | 21 ++ web/src/App.tsx | 11 + web/src/hooks/useAuthSource.ts | 15 ++ web/src/hooks/useServerUrl.ts | 21 +- 22 files changed, 723 insertions(+), 28 deletions(-) mode change 100755 => 100644 cli/src/index.ts create mode 100644 server/scripts/download-tunwg.ts create mode 100644 server/src/tunnel/index.ts create mode 100644 server/src/tunnel/tunnelManager.ts create mode 100644 server/tools/.gitignore create mode 100644 server/tools/tunwg/LICENSE diff --git a/.gitignore b/.gitignore index 55a92a1d..9c2717e2 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,10 @@ **/*.tsbuildinfo server/src/web/embeddedAssets.generated.ts +# Downloaded tools (fetched at build time) +server/tools/tunwg/tunwg-* +server/tools/tunwg/*.exe + # Env files (can contain secrets) **/.env **/.env.* diff --git a/bun.lock b/bun.lock index c29306ee..3ecd1dc2 100644 --- a/bun.lock +++ b/bun.lock @@ -66,12 +66,14 @@ "grammy": "^1.38.4", "hono": "^4.11.2", "jose": "^6.1.3", + "qrcode": "^1.5.4", "socket.io": "^4.8.3", "web-push": "^3.6.7", "zod": "^4.2.1", }, "devDependencies": { "@types/node": ">=25", + "@types/qrcode": "^1.5.6", "@types/web-push": "^3.6.4", "bun-types": "^1.3.5", "typescript": "^5", @@ -959,6 +961,8 @@ "@types/ps-list": ["@types/ps-list@6.2.1", "", { "dependencies": { "ps-list": "*" } }, "sha512-YGrVUmLaAu89QffaehCcCp5kphlbCf72+vDxnRyP1soC9y7xaatnoB0Fgo/tEGHk8teglcLwY6b4iHcyd/UWFA=="], + "@types/qrcode": ["@types/qrcode@1.5.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw=="], + "@types/qs": ["@types/qs@6.14.0", "", {}, "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ=="], "@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="], @@ -1141,6 +1145,8 @@ "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + "camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="], + "caniuse-lite": ["caniuse-lite@1.0.30001760", "", {}, "sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw=="], "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], @@ -1315,6 +1321,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-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="], "decode-named-character-reference": ["decode-named-character-reference@1.2.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q=="], @@ -1345,6 +1353,8 @@ "diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="], + "dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="], + "dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="], "dompurify": ["dompurify@3.3.1", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q=="], @@ -1465,6 +1475,8 @@ "find-my-way": ["find-my-way@9.3.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-querystring": "^1.0.0", "safe-regex2": "^5.0.0" } }, "sha512-eRoFWQw+Yv2tuYlK2pjFS2jGXSxSppAs3hSQjfxVKxM5amECzIgYYc1FEI8ZmhSh/Ig+FrKEz43NLRKJjYCZVg=="], + "find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], + "focus-trap": ["focus-trap@7.7.1", "", { "dependencies": { "tabbable": "^6.4.0" } }, "sha512-Pkp8m55GjxBLnhBoT6OXdMvfRr4TjMAKLvFM566zlIryq5plbhaTmLAJWTGR0EkRwLjEte1lCOG9MxF1ipJrOg=="], "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="], @@ -1761,6 +1773,8 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="], + "locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], + "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], "lodash-es": ["lodash-es@4.17.22", "", {}, "sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q=="], @@ -1961,6 +1975,12 @@ "own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="], + "p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], + + "p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], + + "p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="], + "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], "package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="], @@ -1975,6 +1995,8 @@ "path-data-parser": ["path-data-parser@0.1.0", "", {}, "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w=="], + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], @@ -2003,6 +2025,8 @@ "pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], + "pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="], + "pnpm": ["pnpm@10.27.0", "", { "bin": { "pnpm": "bin/pnpm.cjs", "pnpx": "bin/pnpx.cjs" } }, "sha512-ctaZ2haxF5wUup5k3HHJpAmIy9xlwmTLDkidt96RfyDc9NZNhyNiXylpulLUt+KhFwaC2awqXcrqq3MrfhbwSg=="], "points-on-curve": ["points-on-curve@0.2.0", "", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="], @@ -2037,6 +2061,8 @@ "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="], + "qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="], "quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="], @@ -2139,6 +2165,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=="], + "resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="], "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], @@ -2195,6 +2223,8 @@ "serve-static": ["serve-static@1.16.3", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "~0.19.1" } }, "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA=="], + "set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="], + "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], "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=="], @@ -2487,6 +2517,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.19", "", { "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-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw=="], "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], @@ -2829,6 +2861,8 @@ "proxy-addr/ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + "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=="], + "router/path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], "send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], @@ -3009,6 +3043,14 @@ "hapi-website/vitest/vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="], + "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/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "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=="], + "send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "source-map/whatwg-url/tr46": ["tr46@1.0.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA=="], @@ -3249,6 +3291,14 @@ "hapi-website/vitest/vite/esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], + "qrcode/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "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/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "qrcode/yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "vite-node/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="], "vite-node/vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="], @@ -3390,5 +3440,11 @@ "hapi-website/vitest/vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="], "hapi-website/vitest/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="], + + "qrcode/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "qrcode/yargs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "qrcode/yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], } } diff --git a/cli/src/commands/claude.ts b/cli/src/commands/claude.ts index dbab8b3a..c0c285a6 100644 --- a/cli/src/commands/claude.ts +++ b/cli/src/commands/claude.ts @@ -68,6 +68,7 @@ ${chalk.bold('Usage:')} hapi connect (not available in direct-connect mode) hapi notify (not available in direct-connect mode) hapi server Start the API + web server + hapi server --relay Start with public relay hapi daemon Manage background service that allows to spawn new sessions away from your computer hapi doctor System diagnostics & troubleshooting diff --git a/cli/src/commands/server.ts b/cli/src/commands/server.ts index 4275ef24..86f8b118 100644 --- a/cli/src/commands/server.ts +++ b/cli/src/commands/server.ts @@ -1,8 +1,8 @@ import chalk from 'chalk' import type { CommandDefinition, CommandContext } from './types' -function parseServerArgs(args: string[]): { host?: string; port?: string } { - const result: { host?: string; port?: string } = {} +function parseServerArgs(args: string[]): { host?: string; port?: string; relay?: boolean } { + const result: { host?: string; port?: string; relay?: boolean } = {} for (let i = 0; i < args.length; i++) { const arg = args[i] @@ -14,6 +14,10 @@ function parseServerArgs(args: string[]): { host?: string; port?: string } { result.host = arg.slice('--host='.length) } else if (arg.startsWith('--port=')) { result.port = arg.slice('--port='.length) + } else if (arg === '--relay') { + result.relay = true + } else if (arg === '--no-relay') { + result.relay = false } } @@ -25,7 +29,7 @@ export const serverCommand: CommandDefinition = { requiresRuntimeAssets: false, run: async (context: CommandContext) => { try { - const { host, port } = parseServerArgs(context.commandArgs) + const { host, port, relay } = parseServerArgs(context.commandArgs) if (host) { process.env.WEBAPP_HOST = host @@ -33,6 +37,9 @@ export const serverCommand: CommandDefinition = { if (port) { process.env.WEBAPP_PORT = port } + if (relay !== undefined) { + process.env.HAPI_RELAY = relay ? 'true' : 'false' + } await import('../../../server/src/index') } catch (error) { diff --git a/cli/src/index.ts b/cli/src/index.ts old mode 100755 new mode 100644 diff --git a/cli/src/runtime/assets.ts b/cli/src/runtime/assets.ts index 4f5cde1a..09bfb367 100644 --- a/cli/src/runtime/assets.ts +++ b/cli/src/runtime/assets.ts @@ -67,6 +67,24 @@ function areToolsUnpacked(unpackedPath: string): boolean { return expectedFiles.every((file) => existsSync(file)); } +function isTunwgReady(runtimeRoot: string): boolean { + const isWin = platform() === 'win32'; + const tunwgBinary = isWin ? 'tunwg.exe' : 'tunwg'; + const tunwgPath = join(runtimeRoot, 'tools', 'tunwg', tunwgBinary); + return existsSync(tunwgPath); +} + +function ensureTunwgExecutable(runtimeRoot: string): void { + if (platform() === 'win32') { + return; + } + + const tunwgPath = join(runtimeRoot, 'tools', 'tunwg', 'tunwg'); + if (existsSync(tunwgPath)) { + chmodSync(tunwgPath, 0o755); + } +} + function unpackTools(runtimeRoot: string): void { const platformDir = getPlatformDir(); const toolsDir = join(runtimeRoot, 'tools'); @@ -114,7 +132,7 @@ function unpackTools(runtimeRoot: string): void { } function runtimeAssetsReady(runtimeRoot: string): boolean { - return areToolsUnpacked(join(runtimeRoot, 'tools', 'unpacked')); + return areToolsUnpacked(join(runtimeRoot, 'tools', 'unpacked')) && isTunwgReady(runtimeRoot); } export async function ensureRuntimeAssets(): Promise { @@ -142,5 +160,20 @@ export async function ensureRuntimeAssets(): Promise { } unpackTools(runtimeRoot); + ensureTunwgExecutable(runtimeRoot); writeFileSync(markerPath, packageJson.version, 'utf-8'); } + +export function getTunwgPath(): string { + const isWin = platform() === 'win32'; + const tunwgBinary = isWin ? 'tunwg.exe' : 'tunwg'; + + if (isBunCompiled()) { + return join(runtimePath(), 'tools', 'tunwg', tunwgBinary); + } + + // Development mode: use downloaded binary from server/tools/tunwg + const platformDir = getPlatformDir(); + const devBinaryName = isWin ? `tunwg-${platformDir}.exe` : `tunwg-${platformDir}`; + return join(__dirname, '..', '..', '..', 'server', 'tools', 'tunwg', devBinaryName); +} diff --git a/cli/src/runtime/embeddedAssets.bun.ts b/cli/src/runtime/embeddedAssets.bun.ts index 83fd6605..0df87800 100644 --- a/cli/src/runtime/embeddedAssets.bun.ts +++ b/cli/src/runtime/embeddedAssets.bun.ts @@ -4,6 +4,7 @@ import difftasticArchiveLicense from '../../tools/archives/difftastic-LICENSE' a import ripgrepArchiveLicense from '../../tools/archives/ripgrep-LICENSE' assert { type: 'file' }; import difftasticLicense from '../../tools/licenses/difftastic-LICENSE' assert { type: 'file' }; import ripgrepLicense from '../../tools/licenses/ripgrep-LICENSE' assert { type: 'file' }; +import tunwgLicense from '../../../server/tools/tunwg/LICENSE' assert { type: 'file' }; export interface EmbeddedAsset { relativePath: string; @@ -21,67 +22,98 @@ const COMMON_ASSETS: EmbeddedAsset[] = [ asset('tools/archives/difftastic-LICENSE', difftasticArchiveLicense), asset('tools/archives/ripgrep-LICENSE', ripgrepArchiveLicense), asset('tools/licenses/difftastic-LICENSE', difftasticLicense), - asset('tools/licenses/ripgrep-LICENSE', ripgrepLicense) + asset('tools/licenses/ripgrep-LICENSE', ripgrepLicense), + asset('tools/tunwg/LICENSE', tunwgLicense) ]; async function selectEmbeddedAssets(): Promise { if (feature('HAPI_TARGET_DARWIN_ARM64')) { - const [{ default: difftasticArm64Darwin }, { default: ripgrepArm64Darwin }] = await Promise.all([ + const [ + { default: difftasticArm64Darwin }, + { default: ripgrepArm64Darwin }, + { default: tunwgArm64Darwin } + ] = await Promise.all([ import('../../tools/archives/difftastic-arm64-darwin.tar.gz', { assert: { type: 'file' } }), - import('../../tools/archives/ripgrep-arm64-darwin.tar.gz', { assert: { type: 'file' } }) + import('../../tools/archives/ripgrep-arm64-darwin.tar.gz', { assert: { type: 'file' } }), + import('../../../server/tools/tunwg/tunwg-arm64-darwin', { assert: { type: 'file' } }) ]); return [ ...COMMON_ASSETS, asset('tools/archives/difftastic-arm64-darwin.tar.gz', difftasticArm64Darwin), - asset('tools/archives/ripgrep-arm64-darwin.tar.gz', ripgrepArm64Darwin) + asset('tools/archives/ripgrep-arm64-darwin.tar.gz', ripgrepArm64Darwin), + asset('tools/tunwg/tunwg', tunwgArm64Darwin) ]; } if (feature('HAPI_TARGET_DARWIN_X64')) { - const [{ default: difftasticX64Darwin }, { default: ripgrepX64Darwin }] = await Promise.all([ + const [ + { default: difftasticX64Darwin }, + { default: ripgrepX64Darwin }, + { default: tunwgX64Darwin } + ] = await Promise.all([ import('../../tools/archives/difftastic-x64-darwin.tar.gz', { assert: { type: 'file' } }), - import('../../tools/archives/ripgrep-x64-darwin.tar.gz', { assert: { type: 'file' } }) + import('../../tools/archives/ripgrep-x64-darwin.tar.gz', { assert: { type: 'file' } }), + import('../../../server/tools/tunwg/tunwg-x64-darwin', { assert: { type: 'file' } }) ]); return [ ...COMMON_ASSETS, asset('tools/archives/difftastic-x64-darwin.tar.gz', difftasticX64Darwin), - asset('tools/archives/ripgrep-x64-darwin.tar.gz', ripgrepX64Darwin) + asset('tools/archives/ripgrep-x64-darwin.tar.gz', ripgrepX64Darwin), + asset('tools/tunwg/tunwg', tunwgX64Darwin) ]; } if (feature('HAPI_TARGET_LINUX_ARM64')) { - const [{ default: difftasticArm64Linux }, { default: ripgrepArm64Linux }] = await Promise.all([ + const [ + { default: difftasticArm64Linux }, + { default: ripgrepArm64Linux }, + { default: tunwgArm64Linux } + ] = await Promise.all([ import('../../tools/archives/difftastic-arm64-linux.tar.gz', { assert: { type: 'file' } }), - import('../../tools/archives/ripgrep-arm64-linux.tar.gz', { assert: { type: 'file' } }) + import('../../tools/archives/ripgrep-arm64-linux.tar.gz', { assert: { type: 'file' } }), + import('../../../server/tools/tunwg/tunwg-arm64-linux', { assert: { type: 'file' } }) ]); return [ ...COMMON_ASSETS, asset('tools/archives/difftastic-arm64-linux.tar.gz', difftasticArm64Linux), - asset('tools/archives/ripgrep-arm64-linux.tar.gz', ripgrepArm64Linux) + asset('tools/archives/ripgrep-arm64-linux.tar.gz', ripgrepArm64Linux), + asset('tools/tunwg/tunwg', tunwgArm64Linux) ]; } if (feature('HAPI_TARGET_LINUX_X64')) { - const [{ default: difftasticX64Linux }, { default: ripgrepX64Linux }] = await Promise.all([ + const [ + { default: difftasticX64Linux }, + { default: ripgrepX64Linux }, + { default: tunwgX64Linux } + ] = await Promise.all([ import('../../tools/archives/difftastic-x64-linux.tar.gz', { assert: { type: 'file' } }), - import('../../tools/archives/ripgrep-x64-linux.tar.gz', { assert: { type: 'file' } }) + import('../../tools/archives/ripgrep-x64-linux.tar.gz', { assert: { type: 'file' } }), + import('../../../server/tools/tunwg/tunwg-x64-linux', { assert: { type: 'file' } }) ]); return [ ...COMMON_ASSETS, asset('tools/archives/difftastic-x64-linux.tar.gz', difftasticX64Linux), - asset('tools/archives/ripgrep-x64-linux.tar.gz', ripgrepX64Linux) + asset('tools/archives/ripgrep-x64-linux.tar.gz', ripgrepX64Linux), + asset('tools/tunwg/tunwg', tunwgX64Linux) ]; } if (feature('HAPI_TARGET_WIN32_X64')) { - const [{ default: difftasticX64Win32 }, { default: ripgrepX64Win32 }] = await Promise.all([ + const [ + { default: difftasticX64Win32 }, + { default: ripgrepX64Win32 }, + { default: tunwgX64Win32 } + ] = await Promise.all([ import('../../tools/archives/difftastic-x64-win32.tar.gz', { assert: { type: 'file' } }), - import('../../tools/archives/ripgrep-x64-win32.tar.gz', { assert: { type: 'file' } }) + import('../../tools/archives/ripgrep-x64-win32.tar.gz', { assert: { type: 'file' } }), + import('../../../server/tools/tunwg/tunwg-x64-win32.exe', { assert: { type: 'file' } }) ]); return [ ...COMMON_ASSETS, asset('tools/archives/difftastic-x64-win32.tar.gz', difftasticX64Win32), - asset('tools/archives/ripgrep-x64-win32.tar.gz', ripgrepX64Win32) + asset('tools/archives/ripgrep-x64-win32.tar.gz', ripgrepX64Win32), + asset('tools/tunwg/tunwg.exe', tunwgX64Win32) ]; } diff --git a/cli/src/types/assetImports.d.ts b/cli/src/types/assetImports.d.ts index 69135431..ed8032be 100644 --- a/cli/src/types/assetImports.d.ts +++ b/cli/src/types/assetImports.d.ts @@ -12,3 +12,33 @@ declare module '*-LICENSE' { const path: string; export default path; } + +declare module '*/tunwg/LICENSE' { + const path: string; + export default path; +} + +declare module '*/tunwg/tunwg-arm64-darwin' { + const path: string; + export default path; +} + +declare module '*/tunwg/tunwg-x64-darwin' { + const path: string; + export default path; +} + +declare module '*/tunwg/tunwg-arm64-linux' { + const path: string; + export default path; +} + +declare module '*/tunwg/tunwg-x64-linux' { + const path: string; + export default path; +} + +declare module '*/tunwg/tunwg-x64-win32.exe' { + const path: string; + export default path; +} diff --git a/package.json b/package.json index 436dbdf7..5a213490 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,9 @@ "dev": "concurrently \"bun run dev:server\" \"bun run dev:web\" --kill-others-on-exit", "build": "bun run build:cli && bun run build:server && bun run build:web", "build:cli": "cd cli && bun run build", - "build:single-exe": "bun run build:web && (cd server && bun run generate:embedded-web-assets) && (cd cli && bun run build:exe:allinone)", - "build:single-exe:all": "bun run build:web && (cd server && bun run generate:embedded-web-assets) && (cd cli && bun run build:exe:allinone:all)", + "build:single-exe": "bun run download:tunwg && bun run build:web && (cd server && bun run generate:embedded-web-assets) && (cd cli && bun run build:exe:allinone)", + "build:single-exe:all": "bun run download:tunwg && bun run build:web && (cd server && bun run generate:embedded-web-assets) && (cd cli && bun run build:exe:allinone:all)", + "download:tunwg": "bun run server/scripts/download-tunwg.ts", "build:server": "cd server && bun run build", "build:web": "cd web && bun run build", "dev:server": "cd server && bun run dev", diff --git a/server/package.json b/server/package.json index 12ec567a..d5cffa69 100644 --- a/server/package.json +++ b/server/package.json @@ -20,12 +20,14 @@ "grammy": "^1.38.4", "hono": "^4.11.2", "jose": "^6.1.3", + "qrcode": "^1.5.4", "socket.io": "^4.8.3", "web-push": "^3.6.7", "zod": "^4.2.1" }, "devDependencies": { "@types/node": ">=25", + "@types/qrcode": "^1.5.6", "@types/web-push": "^3.6.4", "bun-types": "^1.3.5", "typescript": "^5" diff --git a/server/scripts/download-tunwg.ts b/server/scripts/download-tunwg.ts new file mode 100644 index 00000000..df9ee4a0 --- /dev/null +++ b/server/scripts/download-tunwg.ts @@ -0,0 +1,74 @@ +/** + * Download tunwg binaries for all platforms + * + * Downloads pre-built tunwg binaries from GitHub releases. + * Output directory: server/tools/tunwg/ + */ + +import { existsSync, mkdirSync, writeFileSync, chmodSync } from 'node:fs'; +import { join, dirname } from 'node:path'; + +const TUNWG_RELEASES: Record = { + 'x64-linux': 'https://github.com/tiann/tunwg/releases/latest/download/tunwg', + 'arm64-linux': 'https://github.com/tiann/tunwg/releases/latest/download/tunwg-arm64', + 'x64-darwin': 'https://github.com/tiann/tunwg/releases/latest/download/tunwg-darwin', + 'arm64-darwin': 'https://github.com/tiann/tunwg/releases/latest/download/tunwg-darwin-arm64', + 'x64-win32': 'https://github.com/tiann/tunwg/releases/latest/download/tunwg.exe' +}; + +const LICENSE_URL = 'https://raw.githubusercontent.com/tiann/tunwg/refs/heads/main/LICENSE'; + +async function downloadFile(url: string, destPath: string): Promise { + console.log(`Downloading ${url}...`); + + const response = await fetch(url, { redirect: 'follow' }); + if (!response.ok) { + throw new Error(`Failed to download ${url}: ${response.status} ${response.statusText}`); + } + + const buffer = await response.arrayBuffer(); + mkdirSync(dirname(destPath), { recursive: true }); + writeFileSync(destPath, Buffer.from(buffer)); + + console.log(` -> ${destPath} (${(buffer.byteLength / 1024 / 1024).toFixed(2)} MB)`); +} + +async function main(): Promise { + const scriptDir = dirname(new URL(import.meta.url).pathname); + const toolsDir = join(scriptDir, '..', 'tools', 'tunwg'); + + console.log('Downloading tunwg binaries...\n'); + + // Download all platform binaries + for (const [platform, url] of Object.entries(TUNWG_RELEASES)) { + const filename = `tunwg-${platform}${platform.includes('win32') ? '.exe' : ''}`; + const destPath = join(toolsDir, filename); + + if (existsSync(destPath)) { + console.log(`Skipping ${filename} (already exists)`); + continue; + } + + await downloadFile(url, destPath); + + // Make executable on Unix + if (!platform.includes('win32')) { + chmodSync(destPath, 0o755); + } + } + + // Download LICENSE + const licensePath = join(toolsDir, 'LICENSE'); + if (!existsSync(licensePath)) { + await downloadFile(LICENSE_URL, licensePath); + } else { + console.log('Skipping LICENSE (already exists)'); + } + + console.log('\nDone!'); +} + +main().catch((error) => { + console.error('Error:', error); + process.exit(1); +}); diff --git a/server/src/config/serverSettings.ts b/server/src/config/serverSettings.ts index b03ba7f7..d04f41be 100644 --- a/server/src/config/serverSettings.ts +++ b/server/src/config/serverSettings.ts @@ -5,7 +5,7 @@ * Priority: environment variable > settings.json > default value * * When a value is loaded from environment variable and not present in settings.json, - * it will be saved to settings.json for future use. + * it will be saved to settings.json for future use */ import { getSettingsFile, readSettings, writeSettings } from './settings' @@ -17,6 +17,7 @@ export interface ServerSettings { webappPort: number webappUrl: string corsOrigins: string[] + tunnelEnabled: boolean } export interface ServerSettingsResult { @@ -28,6 +29,7 @@ export interface ServerSettingsResult { webappPort: 'env' | 'file' | 'default' webappUrl: 'env' | 'file' | 'default' corsOrigins: 'env' | 'file' | 'default' + tunnelEnabled: 'env' | 'file' | 'default' } savedToFile: boolean } @@ -91,8 +93,8 @@ export async function loadServerSettings(dataDir: string): Promise file > null let telegramBotToken: string | null = null if (process.env.TELEGRAM_BOT_TOKEN) { @@ -183,6 +185,16 @@ export async function loadServerSettings(dataDir: string): Promise file > false (default disabled) + let tunnelEnabled = false + if (process.env.HAPI_RELAY !== undefined) { + tunnelEnabled = process.env.HAPI_RELAY === 'true' || process.env.HAPI_RELAY === '1' + sources.tunnelEnabled = 'env' + } else if (settings.tunnelEnabled !== undefined) { + tunnelEnabled = settings.tunnelEnabled + sources.tunnelEnabled = 'file' + } + // Save settings if any new values were added if (needsSave) { await writeSettings(settingsFile, settings) @@ -196,6 +208,7 @@ export async function loadServerSettings(dataDir: string): Promise settings.json > default * When values are read from environment variables and not present in settings.json, - * they are automatically saved for future use. + * they are automatically saved for future use * * Optional environment variables: * - CLI_API_TOKEN: Shared secret for hapi CLI authentication (auto-generated if not set) @@ -12,6 +12,10 @@ * - WEBAPP_PORT: Port for Mini App HTTP server (default: 3006) * - WEBAPP_URL: Public URL for Telegram Mini App * - CORS_ORIGINS: Comma-separated CORS origins + * - HAPI_RELAY: Enable/disable tunnel (default: false) + * - HAPI_RELAY_API: Relay API domain for tunwg (default: relay.hapi.run) + * - HAPI_RELAY_AUTH: Relay auth key for tunwg (default: hapi) + * - HAPI_RELAY_FORCE_TCP: Force TCP relay mode when UDP is unavailable (true/1) * - VAPID_SUBJECT: Contact email or URL for Web Push (defaults to mailto:admin@hapi.run) * - HAPI_HOME: Data directory (default: ~/.hapi) * - DB_PATH: SQLite database path (default: {HAPI_HOME}/hapi.db) @@ -34,6 +38,7 @@ export interface ConfigSources { webappUrl: ConfigSource corsOrigins: ConfigSource cliApiToken: 'env' | 'file' | 'generated' + tunnelEnabled: ConfigSource } class Configuration { @@ -76,6 +81,9 @@ class Configuration { /** Allowed CORS origins for Mini App + Socket.IO (comma-separated env override) */ public readonly corsOrigins: string[] + /** Whether tunnel is enabled (default: true) */ + public readonly tunnelEnabled: boolean + /** Sources of each configuration value */ public readonly sources: ConfigSources @@ -98,6 +106,7 @@ class Configuration { this.webappPort = serverSettings.webappPort this.miniAppUrl = serverSettings.webappUrl this.corsOrigins = serverSettings.corsOrigins + this.tunnelEnabled = serverSettings.tunnelEnabled // CLI API token - will be set by _setCliApiToken() before create() returns this.cliApiToken = '' diff --git a/server/src/index.ts b/server/src/index.ts index e3c7df12..42f68c1b 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -22,6 +22,8 @@ import { getOrCreateVapidKeys } from './config/vapidKeys' import { PushService } from './push/pushService' import { PushNotificationChannel } from './push/pushNotificationChannel' import { VisibilityTracker } from './visibility/visibilityTracker' +import { TunnelManager } from './tunnel' +import QRCode from 'qrcode' import type { Server as BunServer } from 'bun' import type { WebSocketData } from '@socket.io/bun-engine' @@ -45,12 +47,14 @@ let webServer: BunServer | null = null let sseManager: SSEManager | null = null let visibilityTracker: VisibilityTracker | null = null let notificationHub: NotificationHub | null = null +let tunnelManager: TunnelManager | null = null async function main() { console.log('HAPI Server starting...') // Load configuration (async - loads from env/file with persistence) const config = await createConfiguration() + const relayApiDomain = process.env.HAPI_RELAY_API || null // Display CLI API token information if (config.cliApiTokenIsNew) { @@ -83,6 +87,14 @@ async function main() { console.log(`[Server] Telegram notifications: ${config.telegramNotification ? 'enabled' : 'disabled'} (${notificationSource})`) } + // Display tunnel status + if (config.tunnelEnabled) { + const apiDomain = relayApiDomain || 'relay.hapi.run' + console.log(`[Server] Tunnel: enabled (${formatSource(config.sources.tunnelEnabled)}), API: ${apiDomain}`) + } else { + console.log(`[Server] Tunnel: disabled (${formatSource(config.sources.tunnelEnabled)})`) + } + const store = new Store(config.dbPath) const jwtSecret = await getOrCreateJwtSecret() const vapidKeys = await getOrCreateVapidKeys(config.dataDir) @@ -124,7 +136,7 @@ async function main() { notificationHub = new NotificationHub(syncEngine, notificationChannels) - // Start HTTP server for Telegram Mini App + // Start HTTP server first (before tunnel, so tunnel has something to forward to) webServer = await startWebServer({ getSyncEngine: () => syncEngine, getSseManager: () => sseManager, @@ -140,11 +152,67 @@ async function main() { await happyBot.start() } - console.log('\nHAPI Server is ready!') + console.log('') + console.log('[Web] Server listening on :' + config.webappPort) + console.log('[Web] Local: http://localhost:' + config.webappPort) + + // Initialize tunnel AFTER web server is ready + let tunnelUrl: string | null = null + if (config.tunnelEnabled) { + tunnelManager = new TunnelManager({ + localPort: config.webappPort, + enabled: true, + apiDomain: relayApiDomain, + authKey: process.env.HAPI_RELAY_AUTH || null, + useRelay: process.env.HAPI_RELAY_FORCE_TCP === 'true' || process.env.HAPI_RELAY_FORCE_TCP === '1' + }) + + try { + tunnelUrl = await tunnelManager.start() + } catch (error) { + console.error('[Tunnel] Failed to start:', error instanceof Error ? error.message : error) + console.log('[Tunnel] Server continuing without tunnel. Use --no-relay to disable.') + } + } + + if (tunnelUrl) { + console.log('[Web] Public: ' + tunnelUrl) + + // Generate direct access link with server and token + const officialWebUrl = process.env.HAPI_OFFICIAL_WEB_URL || 'https://app.hapi.run' + const params = new URLSearchParams({ + server: tunnelUrl, + token: config.cliApiToken + }) + const directAccessUrl = `${officialWebUrl}/?${params.toString()}` + + console.log('') + console.log('Open in browser:') + console.log(` ${directAccessUrl}`) + console.log('') + console.log('or scan the QR code to open:') + + // Display QR code for easy mobile access + try { + const qrString = await QRCode.toString(directAccessUrl, { + type: 'terminal', + small: true, + margin: 1, + errorCorrectionLevel: 'L' + }) + console.log('') + console.log(qrString) + } catch { + // QR code generation failure should not affect main flow + } + } + console.log('') + console.log('HAPI Server is ready!') // Handle shutdown const shutdown = async () => { console.log('\nShutting down...') + await tunnelManager?.stop() await happyBot?.stop() notificationHub?.stop() syncEngine?.stop() diff --git a/server/src/tunnel/index.ts b/server/src/tunnel/index.ts new file mode 100644 index 00000000..ced0f928 --- /dev/null +++ b/server/src/tunnel/index.ts @@ -0,0 +1 @@ +export { TunnelManager, type TunnelConfig } from './tunnelManager' diff --git a/server/src/tunnel/tunnelManager.ts b/server/src/tunnel/tunnelManager.ts new file mode 100644 index 00000000..1b25c6b0 --- /dev/null +++ b/server/src/tunnel/tunnelManager.ts @@ -0,0 +1,294 @@ +/** + * Tunnel Manager - Manages tunwg subprocess lifecycle + * + * Responsibilities: + * - Spawn tunwg with proper environment variables + * - Parse stdout for tunnel URL + * - Monitor and restart on failure + * - Clean shutdown on process exit + */ + +import { spawn, type Subprocess } from 'bun' +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { platform, arch, homedir } from 'node:os' + +function isBunCompiled(): boolean { + return typeof process.execPath === 'string' && !process.execPath.includes('bun') +} + +function getPlatformDir(): string { + const platformName = platform() + const archName = arch() + + if (platformName === 'darwin') { + if (archName === 'arm64') return 'arm64-darwin' + if (archName === 'x64') return 'x64-darwin' + } else if (platformName === 'linux') { + if (archName === 'arm64') return 'arm64-linux' + if (archName === 'x64') return 'x64-linux' + } else if (platformName === 'win32') { + if (archName === 'x64') return 'x64-win32' + } + + throw new Error(`Unsupported platform: ${archName}-${platformName}`) +} + +function getTunwgPath(): string { + const isWin = platform() === 'win32' + const tunwgBinary = isWin ? 'tunwg.exe' : 'tunwg' + + if (isBunCompiled()) { + const hapiHome = process.env.HAPI_HOME || join(homedir(), '.hapi') + const packageJson = require('../../../cli/package.json') + const runtimePath = join(hapiHome, 'runtime', packageJson.version) + return join(runtimePath, 'tools', 'tunwg', tunwgBinary) + } + + // Development mode: use downloaded binary from server/tools/tunwg + const platformDir = getPlatformDir() + const devBinaryName = isWin ? `tunwg-${platformDir}.exe` : `tunwg-${platformDir}` + return join(__dirname, '..', '..', 'tools', 'tunwg', devBinaryName) +} + +export interface TunnelConfig { + localPort: number + enabled: boolean + apiDomain?: string | null // TUNWG_API - default: relay.hapi.run (official relay) + authKey?: string | null // TUNWG_AUTH - default: hapi + useRelay?: boolean // TUNWG_RELAY +} + +interface TunnelState { + process: Subprocess | null + tunnelUrl: string | null + isConnected: boolean + lastError: string | null + retryCount: number +} + +export class TunnelManager { + private config: TunnelConfig + private state: TunnelState + private readonly maxRetries = 5 + private readonly retryDelayMs = 3000 + private retryTimeout: ReturnType | null = null + private stopped = false + + constructor(config: TunnelConfig) { + this.config = config + this.state = { + process: null, + tunnelUrl: null, + isConnected: false, + lastError: null, + retryCount: 0 + } + } + + async start(): Promise { + if (!this.config.enabled) { + return null + } + + this.stopped = false + return this.spawnTunwg() + } + + private async spawnTunwg(): Promise { + const tunwgPath = getTunwgPath() + + if (!existsSync(tunwgPath)) { + throw new Error(`tunwg binary not found at ${tunwgPath}`) + } + + const forwardUrl = `http://localhost:${this.config.localPort}` + + const env: Record = { ...process.env as Record } + + if (this.config.apiDomain) { + env.TUNWG_API = this.config.apiDomain + } + env.TUNWG_AUTH = this.config.authKey ?? 'hapi' + if (this.config.useRelay) { + env.TUNWG_RELAY = 'true' + } + + return new Promise((resolve, reject) => { + console.log(`[Tunnel] Starting tunnel to ${forwardUrl}...`) + + const proc = spawn({ + cmd: [tunwgPath, '--json', `--forward=${forwardUrl}`], + env, + stdout: 'pipe', + stderr: 'pipe' + }) + + this.state.process = proc + + // Buffer for incomplete lines + let stdoutBuffer = '' + + let resolved = false + + const readStdout = async (): Promise => { + const reader = proc.stdout.getReader() + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + + const text = new TextDecoder().decode(value) + stdoutBuffer += text + + const lines = stdoutBuffer.split('\n') + stdoutBuffer = lines.pop() || '' + + for (const line of lines) { + const trimmed = line.trim() + if (!trimmed) { + continue + } + + const parsed = this.parseTunwgEvent(trimmed) + if (parsed && parsed.event === 'ready' && typeof parsed.url === 'string') { + if (!resolved) { + this.state.tunnelUrl = parsed.url + this.state.isConnected = true + this.state.retryCount = 0 + resolved = true + resolve(parsed.url) + } + continue + } + + console.log(`[Tunnel] ${trimmed}`) + } + } + } catch (err) { + console.error('[Tunnel] stdout read error:', err) + } + } + + readStdout() + + // Handle stderr (logs and warnings) + const readStderr = async (): Promise => { + const reader = proc.stderr.getReader() + let stderrBuffer = '' + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + const text = new TextDecoder().decode(value) + stderrBuffer += text + + const lines = stderrBuffer.split('\n') + stderrBuffer = lines.pop() || '' + + for (const line of lines) { + const trimmed = line.trim() + if (trimmed) { + console.log(`[Tunnel] ${trimmed}`) + } + } + } + } catch { + // Ignore stderr read errors + } + } + + readStderr() + + // Handle process exit + proc.exited.then(exitCode => { + this.state.isConnected = false + this.state.process = null + + if (this.stopped) { + // Stopped intentionally - reject if still pending + if (!resolved) { + resolved = true + reject(new Error('Tunnel stopped')) + } + return + } + + if (exitCode !== 0) { + this.state.lastError = `tunwg exited with code ${exitCode}` + console.error(`[Tunnel] ${this.state.lastError}`) + + // Reject the promise immediately if we haven't got a URL yet + if (!resolved) { + resolved = true + reject(new Error(this.state.lastError)) + return + } + + // Auto-restart with exponential backoff (only if we had a successful connection before) + if (this.state.retryCount < this.maxRetries) { + this.state.retryCount++ + const delay = this.retryDelayMs * Math.pow(2, this.state.retryCount - 1) + console.log(`[Tunnel] Restarting in ${delay}ms (attempt ${this.state.retryCount}/${this.maxRetries})`) + this.retryTimeout = setTimeout(() => { + this.spawnTunwg().catch(err => { + console.error('[Tunnel] Restart failed:', err) + }) + }, delay) + } else { + console.error('[Tunnel] Max retries reached. Tunnel disabled.') + } + } else if (!resolved) { + // Process exited cleanly but no URL - shouldn't happen, but handle gracefully + resolved = true + reject(new Error('tunwg exited without providing a URL')) + } + }) + + // Timeout for initial URL capture + setTimeout(() => { + if (!resolved) { + resolved = true + reject(new Error('Timeout waiting for tunnel URL')) + } + }, 30000) + }) + } + + private parseTunwgEvent(line: string): { event?: string; url?: string } | null { + try { + return JSON.parse(line) as { event?: string; url?: string } + } catch { + return null + } + } + + async stop(): Promise { + this.stopped = true + + if (this.retryTimeout) { + clearTimeout(this.retryTimeout) + this.retryTimeout = null + } + + if (this.state.process) { + this.state.process.kill() + try { + await this.state.process.exited + } catch { + // Ignore exit errors + } + this.state.process = null + } + this.state.isConnected = false + this.state.tunnelUrl = null + } + + getTunnelUrl(): string | null { + return this.state.tunnelUrl + } + + isConnected(): boolean { + return this.state.isConnected + } +} diff --git a/server/tools/.gitignore b/server/tools/.gitignore new file mode 100644 index 00000000..a59d59dc --- /dev/null +++ b/server/tools/.gitignore @@ -0,0 +1,3 @@ +tunwg/* +!tunwg/ +!tunwg/LICENSE diff --git a/server/tools/tunwg/LICENSE b/server/tools/tunwg/LICENSE new file mode 100644 index 00000000..abbafa36 --- /dev/null +++ b/server/tools/tunwg/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Nitin Jain + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/web/src/App.tsx b/web/src/App.tsx index 9f835120..f2c43666 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -125,6 +125,17 @@ function AppInner() { queryClient.clear() }, [baseUrl, queryClient]) + // Clean up URL params after successful auth (for direct access links) + useEffect(() => { + if (!token || !api) return + const url = new URL(window.location.href) + if (url.searchParams.has('server') || url.searchParams.has('token')) { + url.searchParams.delete('server') + url.searchParams.delete('token') + window.history.replaceState({}, '', url.toString()) + } + }, [token, api]) + useEffect(() => { if (!api || !token) { pushPromptedRef.current = false diff --git a/web/src/hooks/useAuthSource.ts b/web/src/hooks/useAuthSource.ts index 59b128f8..b2f08c41 100644 --- a/web/src/hooks/useAuthSource.ts +++ b/web/src/hooks/useAuthSource.ts @@ -21,6 +21,12 @@ function getTelegramInitData(): string | null { return initData || null } +function getTokenFromUrlParams(): string | null { + if (typeof window === 'undefined') return null + const query = new URLSearchParams(window.location.search) + return query.get('token') +} + function getAccessTokenKey(baseUrl: string): string { return `${ACCESS_TOKEN_PREFIX}${baseUrl}` } @@ -79,6 +85,15 @@ export function useAuthSource(baseUrl: string): { return } + // Check for URL token parameter (for direct access links) + const urlToken = getTokenFromUrlParams() + if (urlToken) { + storeAccessToken(accessTokenKey, urlToken) // Save to localStorage for refresh + setAuthSource({ type: 'accessToken', token: urlToken }) + setIsLoading(false) + return + } + // Check for stored access token as fallback const storedToken = getStoredAccessToken(accessTokenKey) if (storedToken) { diff --git a/web/src/hooks/useServerUrl.ts b/web/src/hooks/useServerUrl.ts index f223f1e8..69e739e1 100644 --- a/web/src/hooks/useServerUrl.ts +++ b/web/src/hooks/useServerUrl.ts @@ -26,6 +26,17 @@ export function normalizeServerUrl(input: string): ServerUrlResult { return { ok: true, value: parsed.origin } } +function getServerFromUrlParams(): string | null { + if (typeof window === 'undefined') return null + const query = new URLSearchParams(window.location.search) + const server = query.get('server') + if (server) { + const normalized = normalizeServerUrl(server) + return normalized.ok ? normalized.value : null + } + return null +} + function readStoredServerUrl(): string | null { try { const stored = localStorage.getItem(SERVER_URL_KEY) @@ -65,7 +76,15 @@ export function useServerUrl(): { setServerUrl: (input: string) => ServerUrlResult clearServerUrl: () => void } { - const [serverUrl, setServerUrlState] = useState(() => readStoredServerUrl()) + const [serverUrl, setServerUrlState] = useState(() => { + // Priority: URL params > localStorage + const fromUrl = getServerFromUrlParams() + if (fromUrl) { + writeStoredServerUrl(fromUrl) // Save to localStorage for refresh + return fromUrl + } + return readStoredServerUrl() + }) const fallbackOrigin = typeof window !== 'undefined' ? window.location.origin : '' const baseUrl = useMemo(() => serverUrl ?? fallbackOrigin, [serverUrl, fallbackOrigin])