diff --git a/auto-start.sh b/auto-start.sh new file mode 100755 index 0000000..df8c3f6 --- /dev/null +++ b/auto-start.sh @@ -0,0 +1,18 @@ +#!/bin/bash +# Control Center 启动脚本 +# 在 OpenClaw 启动时自动启动 + +cd /home/node/.openclaw/workspace/openclaw-control-center + +# 检查是否已在运行 +if pgrep -f "tsx src/index.ts" > /dev/null; then + echo "[control-center] already running" + exit 0 +fi + +# 启动 Control Center +echo "[control-center] starting..." +UI_MODE=true npx tsx src/index.ts > /tmp/control-center.log 2>&1 & + +echo "[control-center] started on port 4310" +echo "[control-center] URL: https://cvdyxwfhlavk.eu-central-1.clawcloudrun.com" diff --git a/package-lock.json b/package-lock.json index a5eae1b..01a2697 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,17 +8,22 @@ "name": "openclaw-control-center", "version": "0.1.1", "license": "MIT", + "dependencies": { + "@types/ws": "^8.18.1", + "http-proxy": "^1.18.1", + "ws": "^8.20.0" + }, "devDependencies": { - "@types/node": "^22.13.10", + "@types/node": "^22.19.15", "cross-env": "^7.0.3", - "tsx": "^4.20.0", - "typescript": "^5.8.2" + "tsx": "^4.21.0", + "typescript": "^5.9.3" } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", "cpu": [ "ppc64" ], @@ -33,9 +38,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", "cpu": [ "arm" ], @@ -50,9 +55,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", "cpu": [ "arm64" ], @@ -67,9 +72,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", "cpu": [ "x64" ], @@ -84,9 +89,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", "cpu": [ "arm64" ], @@ -101,9 +106,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", "cpu": [ "x64" ], @@ -118,9 +123,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", "cpu": [ "arm64" ], @@ -135,9 +140,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", "cpu": [ "x64" ], @@ -152,9 +157,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", "cpu": [ "arm" ], @@ -169,9 +174,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", "cpu": [ "arm64" ], @@ -186,9 +191,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", "cpu": [ "ia32" ], @@ -203,9 +208,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", "cpu": [ "loong64" ], @@ -220,9 +225,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", "cpu": [ "mips64el" ], @@ -237,9 +242,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", "cpu": [ "ppc64" ], @@ -254,9 +259,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", "cpu": [ "riscv64" ], @@ -271,9 +276,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", "cpu": [ "s390x" ], @@ -288,9 +293,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", "cpu": [ "x64" ], @@ -305,9 +310,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", "cpu": [ "arm64" ], @@ -322,9 +327,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", "cpu": [ "x64" ], @@ -339,9 +344,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", "cpu": [ "arm64" ], @@ -356,9 +361,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", "cpu": [ "x64" ], @@ -373,9 +378,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", - "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", "cpu": [ "arm64" ], @@ -390,9 +395,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", "cpu": [ "x64" ], @@ -407,9 +412,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", "cpu": [ "arm64" ], @@ -424,9 +429,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", "cpu": [ "ia32" ], @@ -441,9 +446,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", "cpu": [ "x64" ], @@ -458,15 +463,23 @@ } }, "node_modules/@types/node": { - "version": "22.19.13", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.13.tgz", - "integrity": "sha512-akNQMv0wW5uyRpD2v2IEyRSZiR+BeGuoB6L310EgGObO44HSMNT8z1xzio28V8qOrgYaopIDNA18YgdXd+qTiw==", - "dev": true, + "version": "22.19.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", + "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/cross-env": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", @@ -502,9 +515,9 @@ } }, "node_modules/esbuild": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -515,32 +528,58 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.3", - "@esbuild/android-arm": "0.27.3", - "@esbuild/android-arm64": "0.27.3", - "@esbuild/android-x64": "0.27.3", - "@esbuild/darwin-arm64": "0.27.3", - "@esbuild/darwin-x64": "0.27.3", - "@esbuild/freebsd-arm64": "0.27.3", - "@esbuild/freebsd-x64": "0.27.3", - "@esbuild/linux-arm": "0.27.3", - "@esbuild/linux-arm64": "0.27.3", - "@esbuild/linux-ia32": "0.27.3", - "@esbuild/linux-loong64": "0.27.3", - "@esbuild/linux-mips64el": "0.27.3", - "@esbuild/linux-ppc64": "0.27.3", - "@esbuild/linux-riscv64": "0.27.3", - "@esbuild/linux-s390x": "0.27.3", - "@esbuild/linux-x64": "0.27.3", - "@esbuild/netbsd-arm64": "0.27.3", - "@esbuild/netbsd-x64": "0.27.3", - "@esbuild/openbsd-arm64": "0.27.3", - "@esbuild/openbsd-x64": "0.27.3", - "@esbuild/openharmony-arm64": "0.27.3", - "@esbuild/sunos-x64": "0.27.3", - "@esbuild/win32-arm64": "0.27.3", - "@esbuild/win32-ia32": "0.27.3", - "@esbuild/win32-x64": "0.27.3" + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } } }, "node_modules/fsevents": { @@ -559,9 +598,9 @@ } }, "node_modules/get-tsconfig": { - "version": "4.13.6", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", - "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", + "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", "dev": true, "license": "MIT", "dependencies": { @@ -571,6 +610,20 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -588,6 +641,12 @@ "node": ">=8" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -659,7 +718,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/which": { @@ -677,6 +735,27 @@ "engines": { "node": ">= 8" } + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index a96861b..c13e2cd 100644 --- a/package.json +++ b/package.json @@ -37,9 +37,14 @@ "dod:check": "node --import tsx scripts/dod-check.ts" }, "devDependencies": { - "@types/node": "^22.13.10", + "@types/node": "^22.19.15", "cross-env": "^7.0.3", - "tsx": "^4.20.0", - "typescript": "^5.8.2" + "tsx": "^4.21.0", + "typescript": "^5.9.3" + }, + "dependencies": { + "@types/ws": "^8.18.1", + "http-proxy": "^1.18.1", + "ws": "^8.20.0" } } diff --git a/src/auth/gateway-auth.ts b/src/auth/gateway-auth.ts new file mode 100644 index 0000000..8c3ac7d --- /dev/null +++ b/src/auth/gateway-auth.ts @@ -0,0 +1,234 @@ +/** + * Gateway 认证客户端 + * 复用 Gateway 的 token 验证机制 + */ + +import WebSocket from "ws"; + +const GATEWAY_URL = process.env.GATEWAY_URL || "ws://127.0.0.1:18789"; +const GATEWAY_TOKEN = process.env.GATEWAY_TOKEN || process.env.LOCAL_API_TOKEN || ""; + +interface GatewayMessage { + type: "req" | "res" | "event"; + id?: string; + method?: string; + params?: unknown; + ok?: boolean; + payload?: unknown; + event?: string; +} + +interface ConnectResult { + connected: boolean; + protocol?: number; + error?: string; +} + +let ws: WebSocket | null = null; +let requestId = 0; +let connectPromise: Promise | null = null; +let challengeNonce: string | null = null; +const pendingRequests = new Map(); + +/** + * 连接到 Gateway WebSocket 并完成握手 + */ +async function connect(): Promise { + if (ws && ws.readyState === WebSocket.OPEN) { + return { connected: true }; + } + + if (connectPromise) { + return connectPromise; + } + + connectPromise = new Promise((resolve, reject) => { + ws = new WebSocket(GATEWAY_URL); + + ws.on("open", () => { + console.log("[gateway-auth] WebSocket connected, waiting for challenge..."); + }); + + ws.on("message", (data) => { + try { + const msg: GatewayMessage = JSON.parse(data.toString()); + handleMessage(msg, resolve); + } catch (err) { + console.error("[gateway-auth] parse error:", err); + } + }); + + ws.on("error", (err) => { + console.error("[gateway-auth] WebSocket error:", err.message); + connectPromise = null; + reject(err); + }); + + ws.on("close", () => { + console.log("[gateway-auth] WebSocket closed"); + ws = null; + connectPromise = null; + }); + + // 超时 + setTimeout(() => { + if (connectPromise) { + connectPromise = null; + reject(new Error("Connection timeout")); + } + }, 15000); + }); + + return connectPromise; +} + +/** + * 处理 Gateway 消息 + */ +function handleMessage(msg: GatewayMessage, resolveConnect: (result: ConnectResult) => void) { + // 处理 challenge 事件 + if (msg.type === "event" && msg.event === "connect.challenge") { + const payload = msg.payload as { nonce?: string; ts?: number }; + challengeNonce = payload?.nonce || null; + console.log("[gateway-auth] received challenge, sending connect..."); + + // 发送 connect 请求 + sendConnect(); + return; + } + + // 处理响应 + if (msg.type === "res") { + const pending = pendingRequests.get(msg.id || ""); + + if (msg.method === "connect" || (pending && !pending)) { + // connect 响应 + if (msg.ok) { + console.log("[gateway-auth] connected to Gateway successfully"); + resolveConnect({ connected: true, protocol: (msg.payload as any)?.protocol }); + } else { + console.error("[gateway-auth] connect failed:", msg.payload); + resolveConnect({ connected: false, error: String(msg.payload) }); + } + return; + } + + if (pending) { + pendingRequests.delete(msg.id || ""); + if (msg.ok) { + pending.resolve(msg.payload); + } else { + pending.reject(new Error(String(msg.payload) || "Request failed")); + } + } + } +} + +/** + * 发送 connect 请求 + */ +function sendConnect() { + if (!ws || ws.readyState !== WebSocket.OPEN) { + return; + } + + const id = `connect-${Date.now()}`; + const message: GatewayMessage = { + type: "req", + id, + method: "connect", + params: { + minProtocol: 3, + maxProtocol: 3, + client: { + id: "control-center", + version: "0.1.0", + platform: "server", + mode: "operator", + }, + role: "operator", + scopes: ["operator.read", "operator.write"], + caps: [], + commands: [], + permissions: {}, + auth: { token: GATEWAY_TOKEN }, + locale: "en-US", + userAgent: "openclaw-control-center/0.1.0", + }, + }; + + pendingRequests.set(id, { + resolve: () => {}, + reject: () => {}, + }); + + ws.send(JSON.stringify(message)); +} + +/** + * 发送请求到 Gateway + */ +async function sendRequest(method: string, params: unknown = {}): Promise { + const connectResult = await connect(); + if (!connectResult.connected) { + throw new Error("Not connected to Gateway"); + } + + return new Promise((resolve, reject) => { + const id = `req-${++requestId}`; + const message: GatewayMessage = { + type: "req", + id, + method, + params, + }; + + pendingRequests.set(id, { resolve, reject }); + ws!.send(JSON.stringify(message)); + + // 超时处理 + setTimeout(() => { + if (pendingRequests.has(id)) { + pendingRequests.delete(id); + reject(new Error("Request timeout")); + } + }, 10000); + }); +} + +/** + * 验证 token + * 使用 Gateway 的 session.list 或 status API 间接验证 + */ +export async function verifyToken(token: string): Promise<{ valid: boolean }> { + try { + // 尝试使用 token 获取 sessions 列表来验证 + const result = await sendRequest("session.list", { auth: { token } }); + return { valid: true }; + } catch (err) { + console.error("[gateway-auth] verify error:", err); + return { valid: false }; + } +} + +/** + * 检查 Gateway 是否可用 + */ +export async function checkGatewayHealth(): Promise { + try { + const result = await connect(); + return result.connected; + } catch { + return false; + } +} + +/** + * 关闭连接 + */ +export function close(): void { + if (ws) { + ws.close(); + ws = null; + } +} diff --git a/src/auth/gateway-middleware.ts b/src/auth/gateway-middleware.ts new file mode 100644 index 0000000..9b67262 --- /dev/null +++ b/src/auth/gateway-middleware.ts @@ -0,0 +1,353 @@ +/** + * Gateway 认证中间件 + * 复用 Gateway 的 token 验证机制 + */ + +import type { IncomingMessage, ServerResponse } from "node:http"; +import { verifyToken, checkGatewayHealth } from "./gateway-auth"; + +const LOGIN_PATH = "/login"; +const PAIR_PATH = "/pair"; + +export interface AuthResult { + authenticated: boolean; + nodeId?: string; + role?: string; +} + +function sendJson(res: ServerResponse, status: number, data: unknown): void { + res.statusCode = status; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify(data)); +} + +function extractToken(req: IncomingMessage): string | undefined { + // 从 Header 获取 + const authHeader = req.headers.authorization; + if (authHeader?.startsWith("Bearer ")) { + return authHeader.slice(7); + } + + // 从 Cookie 获取 + const cookie = req.headers.cookie; + if (cookie) { + const match = cookie.match(/access_token=([^;]+)/); + if (match) { + return match[1]; + } + } + + // 从 URL 参数获取 + const url = new URL(req.url || "/", "http://localhost"); + const tokenParam = url.searchParams.get("access_token"); + if (tokenParam) { + return tokenParam; + } + + return undefined; +} + +/** + * 检查路径是否需要认证 + */ +export function requiresAuth(pathname: string): boolean { + // 公开路径 + const publicPaths = [ + "/login", + "/pair", + "/health", + "/favicon.ico", + "/api/health", + ]; + + // 静态资源 + if (pathname.startsWith("/static/") || pathname.startsWith("/assets/")) { + return false; + } + + return !publicPaths.some(p => pathname === p || pathname.startsWith(p + "/")); +} + +/** + * 认证中间件 + */ +export async function authMiddleware( + req: IncomingMessage, + res: ServerResponse +): Promise<{ handled: boolean; authenticated: boolean }> { + const url = new URL(req.url || "/", "http://localhost"); + const pathname = url.pathname; + const method = req.method?.toUpperCase(); + + // 健康检查 + if (pathname === "/api/health" || pathname === "/health") { + const gatewayOk = await checkGatewayHealth(); + sendJson(res, 200, { + ok: true, + gateway: gatewayOk ? "connected" : "disconnected", + }); + return { handled: true, authenticated: false }; + } + + // 登录页面 + if (pathname === LOGIN_PATH && method === "GET") { + res.statusCode = 200; + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end(renderLoginPage()); + return { handled: true, authenticated: false }; + } + + // 配对页面(使用 Gateway token) + if (pathname === PAIR_PATH && method === "GET") { + res.statusCode = 200; + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end(renderPairPage()); + return { handled: true, authenticated: false }; + } + + // 检查是否需要认证 + if (!requiresAuth(pathname)) { + return { handled: false, authenticated: false }; + } + + // 提取 token + const token = extractToken(req); + + if (!token) { + // 未提供 token,重定向到登录页 + res.statusCode = 302; + res.setHeader("Location", LOGIN_PATH); + res.end(); + return { handled: true, authenticated: false }; + } + + // 验证 token + const result = await verifyToken(token); + + if (!result.valid) { + // token 无效,重定向到登录页 + res.statusCode = 302; + res.setHeader("Location", LOGIN_PATH); + res.end(); + return { handled: true, authenticated: false }; + } + + // 认证通过 + return { handled: false, authenticated: true }; +} + +/** + * 登录页面 + */ +function renderLoginPage(): string { + return ` + + + + + OpenClaw Control Center - Login + + + +
+ + +
+ 使用你的 Gateway Token 登录。
+ Token 在 OpenClaw 配置的 gateway.auth.token 中。 +
+ +
+
+ + +
+ +
+
+
+ + + +`; +} + +/** + * 配对页面(显示 Gateway Token) + */ +function renderPairPage(): string { + return ` + + + + + OpenClaw Control Center - Pair + + + +
+ + +
+ Control Center 使用 Gateway Token 进行认证。

+ 你可以在服务器上运行以下命令查看 Token:
+ cat ~/.openclaw/openclaw.json | grep -A2 '"auth"' +
+ +
+ 输入你的 Gateway Token +
+ + +
+ +`; +} diff --git a/src/auth/index.ts b/src/auth/index.ts new file mode 100644 index 0000000..8665a69 --- /dev/null +++ b/src/auth/index.ts @@ -0,0 +1,25 @@ +/** + * 配对认证模块导出 + */ + +export { + loadPairingStore, + savePairingStore, + createPairingRequest, + completePairing, + validateAccessToken, + listPairedDevices, + revokeDevice, + getMainAccessToken, + regenerateMainAccessToken, + type PairedDevice, + type PendingPairing, + type PairingStore, +} from "./pairing-store"; + +export { + handlePairingRoutes, + authenticateRequest, + requiresAuth, + type AuthResult, +} from "./pairing-middleware"; diff --git a/src/auth/pairing-middleware.ts b/src/auth/pairing-middleware.ts new file mode 100644 index 0000000..1fbae14 --- /dev/null +++ b/src/auth/pairing-middleware.ts @@ -0,0 +1,236 @@ +/** + * 配对认证中间件 + * 用于保护 Control Center 的路由 + */ + +import type { IncomingMessage, ServerResponse } from "node:http"; +import { + validateAccessToken, + createPairingRequest, + completePairing, + listPairedDevices, + revokeDevice, + getMainAccessToken, + regenerateMainAccessToken, +} from "./pairing-store"; + +const PAIRING_PATH = "/api/pairing"; +const PAIRING_COMPLETE_PATH = "/api/pairing/complete"; +const DEVICES_PATH = "/api/devices"; +const ACCESS_TOKEN_PATH = "/api/access-token"; + +export interface AuthResult { + authenticated: boolean; + deviceId?: string; + deviceName?: string; +} + +function sendJson(res: ServerResponse, status: number, data: unknown): void { + res.statusCode = status; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify(data)); +} + +function readBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + let body = ""; + req.on("data", chunk => { + body += chunk; + }); + req.on("end", () => resolve(body)); + req.on("error", reject); + }); +} + +function extractToken(req: IncomingMessage): string | undefined { + // 从 Header 获取 + const authHeader = req.headers.authorization; + if (authHeader?.startsWith("Bearer ")) { + return authHeader.slice(7); + } + + // 从 Cookie 获取 + const cookie = req.headers.cookie; + if (cookie) { + const match = cookie.match(/access_token=([^;]+)/); + if (match) { + return match[1]; + } + } + + // 从 URL 参数获取 + const url = new URL(req.url || "/", "http://localhost"); + const tokenParam = url.searchParams.get("access_token"); + if (tokenParam) { + return tokenParam; + } + + return undefined; +} + +/** + * 处理配对相关 API + */ +export async function handlePairingRoutes( + req: IncomingMessage, + res: ServerResponse +): Promise { + const url = new URL(req.url || "/", "http://localhost"); + const pathname = url.pathname; + const method = req.method?.toUpperCase(); + + // POST /api/pairing - 创建配对请求 + if (pathname === PAIRING_PATH && method === "POST") { + const result = createPairingRequest(); + sendJson(res, 200, { + code: result.code, + expiresAt: result.expiresAt, + message: "Enter this code on your Control Center to pair this device", + }); + return true; + } + + // POST /api/pairing/complete - 完成配对 + if (pathname === PAIRING_COMPLETE_PATH && method === "POST") { + try { + const body = await readBody(req); + const data = JSON.parse(body); + const result = completePairing( + data.code, + data.deviceName || "Unknown Device", + req.headers["user-agent"], + req.socket.remoteAddress + ); + + if (result.success) { + sendJson(res, 200, { + success: true, + accessToken: result.accessToken, + message: "Device paired successfully", + }); + } else { + sendJson(res, 400, { + success: false, + error: result.error, + }); + } + } catch { + sendJson(res, 400, { error: "Invalid request body" }); + } + return true; + } + + // GET /api/devices - 列出已配对设备 + if (pathname === DEVICES_PATH && method === "GET") { + const token = extractToken(req); + const validation = token ? validateAccessToken(token) : { valid: false }; + + if (!validation.valid) { + sendJson(res, 401, { error: "Unauthorized" }); + return true; + } + + const devices = listPairedDevices(); + sendJson(res, 200, { devices }); + return true; + } + + // DELETE /api/devices/:id - 撤销设备 + if (pathname.startsWith(DEVICES_PATH + "/") && method === "DELETE") { + const token = extractToken(req); + const validation = token ? validateAccessToken(token) : { valid: false }; + + if (!validation.valid) { + sendJson(res, 401, { error: "Unauthorized" }); + return true; + } + + const deviceId = pathname.slice(DEVICES_PATH.length + 1); + const success = revokeDevice(deviceId); + sendJson(res, success ? 200 : 404, { + success, + message: success ? "Device revoked" : "Device not found", + }); + return true; + } + + // GET /api/access-token - 获取主 access token(需要已认证) + if (pathname === ACCESS_TOKEN_PATH && method === "GET") { + const token = extractToken(req); + const validation = token ? validateAccessToken(token) : { valid: false }; + + if (!validation.valid) { + sendJson(res, 401, { error: "Unauthorized" }); + return true; + } + + sendJson(res, 200, { accessToken: getMainAccessToken() }); + return true; + } + + // POST /api/access-token/regenerate - 重新生成主 access token + if (pathname === ACCESS_TOKEN_PATH + "/regenerate" && method === "POST") { + const token = extractToken(req); + const validation = token ? validateAccessToken(token) : { valid: false }; + + if (!validation.valid) { + sendJson(res, 401, { error: "Unauthorized" }); + return true; + } + + const newToken = regenerateMainAccessToken(); + sendJson(res, 200, { accessToken: newToken }); + return true; + } + + return false; +} + +/** + * 认证中间件 + * 检查请求是否已认证 + */ +export function authenticateRequest(req: IncomingMessage): AuthResult { + const token = extractToken(req); + + if (!token) { + return { authenticated: false }; + } + + const validation = validateAccessToken(token); + + if (validation.valid) { + return { + authenticated: true, + deviceId: validation.device?.id, + deviceName: validation.device?.name, + }; + } + + return { authenticated: false }; +} + +/** + * 检查路径是否需要认证 + */ +export function requiresAuth(pathname: string): boolean { + // 公开路径 + const publicPaths = [ + "/api/pairing", + "/api/pairing/complete", + "/health", + "/favicon.ico", + ]; + + // 静态资源 + if (pathname.startsWith("/static/") || pathname.startsWith("/assets/")) { + return false; + } + + // 配对页面 + if (pathname === "/pair" || pathname === "/login") { + return false; + } + + return !publicPaths.some(p => pathname === p || pathname.startsWith(p + "/")); +} diff --git a/src/auth/pairing-store.ts b/src/auth/pairing-store.ts new file mode 100644 index 0000000..0ccbb50 --- /dev/null +++ b/src/auth/pairing-store.ts @@ -0,0 +1,210 @@ +/** + * 设备配对存储 + * 管理已配对设备的持久化状态 + */ + +import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { homedir } from "node:os"; +import { randomBytes, createHash } from "node:crypto"; + +export interface PairedDevice { + id: string; + name: string; + userAgent?: string; + ipAddress?: string; + pairedAt: string; + lastAccessAt: string; + accessToken: string; +} + +export interface PendingPairing { + code: string; + createdAt: string; + expiresAt: string; +} + +export interface PairingStore { + pairedDevices: PairedDevice[]; + pendingPairings: PendingPairing[]; + accessToken: string; +} + +const STORE_FILENAME = "control-center-pairing.json"; + +function getStorePath(): string { + const openclawHome = process.env.OPENCLAW_HOME || join(homedir(), ".openclaw"); + return join(openclawHome, STORE_FILENAME); +} + +function generatePairingCode(): string { + // 生成 6 位数字配对码 + return randomBytes(3).toString("hex").toUpperCase().slice(0, 6); +} + +function generateAccessToken(): string { + return randomBytes(32).toString("hex"); +} + +function generateDeviceId(): string { + return randomBytes(16).toString("hex"); +} + +function hashToken(token: string): string { + return createHash("sha256").update(token).digest("hex"); +} + +export function loadPairingStore(): PairingStore { + const storePath = getStorePath(); + + if (!existsSync(storePath)) { + // 创建新的 store + const newStore: PairingStore = { + pairedDevices: [], + pendingPairings: [], + accessToken: generateAccessToken(), + }; + savePairingStore(newStore); + return newStore; + } + + try { + const content = readFileSync(storePath, "utf-8"); + return JSON.parse(content); + } catch { + // 如果读取失败,创建新的 + const newStore: PairingStore = { + pairedDevices: [], + pendingPairings: [], + accessToken: generateAccessToken(), + }; + savePairingStore(newStore); + return newStore; + } +} + +export function savePairingStore(store: PairingStore): void { + const storePath = getStorePath(); + const dir = dirname(storePath); + + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + writeFileSync(storePath, JSON.stringify(store, null, 2), "utf-8"); +} + +export function createPairingRequest(): { code: string; expiresAt: string } { + const store = loadPairingStore(); + const code = generatePairingCode(); + const now = new Date(); + const expiresAt = new Date(now.getTime() + 10 * 60 * 1000); // 10 分钟过期 + + store.pendingPairings.push({ + code, + createdAt: now.toISOString(), + expiresAt: expiresAt.toISOString(), + }); + + // 清理过期的配对请求 + store.pendingPairings = store.pendingPairings.filter( + p => new Date(p.expiresAt) > now + ); + + savePairingStore(store); + + return { code, expiresAt: expiresAt.toISOString() }; +} + +export function completePairing( + code: string, + deviceName: string, + userAgent?: string, + ipAddress?: string +): { success: boolean; accessToken?: string; error?: string } { + const store = loadPairingStore(); + const now = new Date(); + + // 查找配对请求 + const pendingIndex = store.pendingPairings.findIndex( + p => p.code === code && new Date(p.expiresAt) > now + ); + + if (pendingIndex === -1) { + return { success: false, error: "Invalid or expired pairing code" }; + } + + // 移除已使用的配对请求 + store.pendingPairings.splice(pendingIndex, 1); + + // 创建新设备 + const accessToken = generateAccessToken(); + const device: PairedDevice = { + id: generateDeviceId(), + name: deviceName, + userAgent, + ipAddress, + pairedAt: now.toISOString(), + lastAccessAt: now.toISOString(), + accessToken: hashToken(accessToken), + }; + + store.pairedDevices.push(device); + savePairingStore(store); + + return { success: true, accessToken }; +} + +export function validateAccessToken(token: string): { valid: boolean; device?: PairedDevice } { + const store = loadPairingStore(); + const hashedToken = hashToken(token); + + const device = store.pairedDevices.find(d => d.accessToken === hashedToken); + + if (device) { + // 更新最后访问时间 + device.lastAccessAt = new Date().toISOString(); + savePairingStore(store); + return { valid: true, device }; + } + + // 也检查主 access token + if (token === store.accessToken) { + return { valid: true }; + } + + return { valid: false }; +} + +export function listPairedDevices(): PairedDevice[] { + const store = loadPairingStore(); + return store.pairedDevices.map(d => ({ + ...d, + accessToken: "***", // 不暴露 token + })); +} + +export function revokeDevice(deviceId: string): boolean { + const store = loadPairingStore(); + const index = store.pairedDevices.findIndex(d => d.id === deviceId); + + if (index === -1) { + return false; + } + + store.pairedDevices.splice(index, 1); + savePairingStore(store); + return true; +} + +export function getMainAccessToken(): string { + const store = loadPairingStore(); + return store.accessToken; +} + +export function regenerateMainAccessToken(): string { + const store = loadPairingStore(); + store.accessToken = generateAccessToken(); + savePairingStore(store); + return store.accessToken; +} diff --git a/src/auth/pairing-ui.ts b/src/auth/pairing-ui.ts new file mode 100644 index 0000000..1692eda --- /dev/null +++ b/src/auth/pairing-ui.ts @@ -0,0 +1,455 @@ +/** + * 配对页面 HTML + */ + +export function renderPairingPage(): string { + return ` + + + + + OpenClaw Control Center - Pair Device + + + +
+ + +
+
+ +
+
+ + +
+
+ + +
+ +
+ +
+ Access Token:
+ +

+ Save this token securely. You'll need it to access Control Center. +
+ +
or
+ +
+ Have an access token? Login with token +
+
+ + + +`; +} + +export function renderLoginPage(): string { + return ` + + + + + OpenClaw Control Center - Login + + + +
+ + +
+ +
+
+ + +
+ +
+ +
or
+ +
+ Need to pair a new device? Pair device +
+
+ + + +`; +} diff --git a/src/auth/server-patch.ts b/src/auth/server-patch.ts new file mode 100644 index 0000000..71b0bb8 --- /dev/null +++ b/src/auth/server-patch.ts @@ -0,0 +1,105 @@ +/** + * 配对认证集成补丁 + * + * 这个文件提供了将配对认证集成到 Control Center 服务器的函数 + */ + +import type { IncomingMessage, ServerResponse } from "node:http"; +import { + handlePairingRoutes, + authenticateRequest, + requiresAuth, +} from "./index"; +import { renderPairingPage, renderLoginPage } from "./pairing-ui"; + +/** + * 处理配对相关的 UI 路由 + */ +export async function handleAuthUiRoutes( + req: IncomingMessage, + res: ServerResponse +): Promise { + const url = new URL(req.url || "/", "http://localhost"); + const pathname = url.pathname; + const method = req.method?.toUpperCase(); + + // GET /pair - 配对页面 + if (pathname === "/pair" && method === "GET") { + res.statusCode = 200; + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end(renderPairingPage()); + return true; + } + + // GET /login - 登录页面 + if (pathname === "/login" && method === "GET") { + res.statusCode = 200; + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end(renderLoginPage()); + return true; + } + + return false; +} + +/** + * 认证检查中间件 + * 返回 true 表示请求已处理(未认证,已重定向到登录页) + * 返回 false 表示请求应该继续处理 + */ +export function checkAuthentication( + req: IncomingMessage, + res: ServerResponse +): boolean { + const url = new URL(req.url || "/", "http://localhost"); + const pathname = url.pathname; + + // 检查是否需要认证 + if (!requiresAuth(pathname)) { + return false; // 不需要认证,继续处理 + } + + // 检查认证状态 + const authResult = authenticateRequest(req); + + if (!authResult.authenticated) { + // 未认证,重定向到登录页 + res.statusCode = 302; + res.setHeader("Location", "/login"); + res.end(); + return true; + } + + // 已认证,继续处理 + return false; +} + +/** + * 完整的认证中间件 + * 应该在服务器请求处理的最开始调用 + */ +export async function authMiddleware( + req: IncomingMessage, + res: ServerResponse +): Promise<{ handled: boolean; authenticated: boolean }> { + // 1. 处理配对 API 路由 + const pairingHandled = await handlePairingRoutes(req, res); + if (pairingHandled) { + return { handled: true, authenticated: false }; + } + + // 2. 处理认证 UI 路由 + const authUiHandled = await handleAuthUiRoutes(req, res); + if (authUiHandled) { + return { handled: true, authenticated: false }; + } + + // 3. 检查认证状态 + const authBlocked = checkAuthentication(req, res); + if (authBlocked) { + return { handled: true, authenticated: false }; + } + + // 4. 认证通过,继续处理 + return { handled: false, authenticated: true }; +} diff --git a/src/auth/simple-auth.ts b/src/auth/simple-auth.ts new file mode 100644 index 0000000..f29b825 --- /dev/null +++ b/src/auth/simple-auth.ts @@ -0,0 +1,314 @@ +/** + * 简化认证中间件 + * 直接使用 Gateway Token 作为访问密码 + */ + +import type { IncomingMessage, ServerResponse } from "node:http"; +import { readFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; + +const LOGIN_PATH = "/login"; + +// 从 OpenClaw 配置读取 Gateway Token +function getGatewayToken(): string { + try { + const configPath = process.env.OPENCLAW_HOME + ? join(process.env.OPENCLAW_HOME, "openclaw.json") + : join(homedir(), ".openclaw", "openclaw.json"); + + if (existsSync(configPath)) { + const config = JSON.parse(readFileSync(configPath, "utf-8")); + return config?.gateway?.auth?.token || ""; + } + } catch (err) { + console.error("[auth] failed to read gateway token:", err); + } + return process.env.GATEWAY_TOKEN || process.env.LOCAL_API_TOKEN || ""; +} + +const GATEWAY_TOKEN = getGatewayToken(); + +export interface AuthResult { + authenticated: boolean; +} + +function sendJson(res: ServerResponse, status: number, data: unknown): void { + res.statusCode = status; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify(data)); +} + +function extractToken(req: IncomingMessage): string | undefined { + // 从 Header 获取 + const authHeader = req.headers.authorization; + if (authHeader?.startsWith("Bearer ")) { + return authHeader.slice(7); + } + + // 从 Cookie 获取 + const cookie = req.headers.cookie; + if (cookie) { + const match = cookie.match(/access_token=([^;]+)/); + if (match) { + return match[1]; + } + } + + // 从 URL 参数获取 + const url = new URL(req.url || "/", "http://localhost"); + const tokenParam = url.searchParams.get("access_token"); + if (tokenParam) { + return tokenParam; + } + + return undefined; +} + +/** + * 检查路径是否需要认证 + */ +export function requiresAuth(pathname: string): boolean { + const publicPaths = ["/login", "/health", "/favicon.ico", "/api/health"]; + + if (pathname.startsWith("/static/") || pathname.startsWith("/assets/")) { + return false; + } + + return !publicPaths.some(p => pathname === p || pathname.startsWith(p + "/")); +} + +/** + * 认证中间件 + */ +export async function authMiddleware( + req: IncomingMessage, + res: ServerResponse +): Promise<{ handled: boolean; authenticated: boolean }> { + const url = new URL(req.url || "/", "http://localhost"); + const pathname = url.pathname; + const method = req.method?.toUpperCase(); + + // 健康检查 + if (pathname === "/api/health" || pathname === "/health") { + sendJson(res, 200, { ok: true, gateway: "configured" }); + return { handled: true, authenticated: false }; + } + + // 登录页面 + if (pathname === LOGIN_PATH && method === "GET") { + res.statusCode = 200; + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end(renderLoginPage()); + return { handled: true, authenticated: false }; + } + + // 登录 API + if (pathname === "/api/login" && method === "POST") { + return handleLogin(req, res); + } + + // 检查是否需要认证 + if (!requiresAuth(pathname)) { + return { handled: false, authenticated: false }; + } + + // 提取 token + const token = extractToken(req); + + if (!token) { + res.statusCode = 302; + res.setHeader("Location", LOGIN_PATH); + res.end(); + return { handled: true, authenticated: false }; + } + + // 验证 token(直接比较) + if (token === GATEWAY_TOKEN) { + return { handled: false, authenticated: true }; + } + + // token 无效 + res.statusCode = 302; + res.setHeader("Location", LOGIN_PATH); + res.end(); + return { handled: true, authenticated: false }; +} + +/** + * 处理登录请求 + */ +async function handleLogin( + req: IncomingMessage, + res: ServerResponse +): Promise<{ handled: boolean; authenticated: boolean }> { + return new Promise((resolve) => { + let body = ""; + req.on("data", (chunk) => (body += chunk)); + req.on("end", () => { + try { + const { token } = JSON.parse(body); + + if (token === GATEWAY_TOKEN) { + sendJson(res, 200, { success: true, message: "Login successful" }); + } else { + sendJson(res, 401, { success: false, error: "Invalid token" }); + } + } catch { + sendJson(res, 400, { success: false, error: "Invalid request" }); + } + resolve({ handled: true, authenticated: false }); + }); + }); +} + +/** + * 登录页面 + */ +function renderLoginPage(): string { + return ` + + + + + OpenClaw Control Center - Login + + + +
+ + +
+ 使用你的 Gateway Token 登录。

+ Token 在 OpenClaw 配置文件中:
+ ~/.openclaw/openclaw.json
+ gateway.auth.token +
+ +
+
+ + +
+ +
+
+
+ + + +`; +} diff --git a/src/config.ts b/src/config.ts index 036b4e1..c7bee45 100644 --- a/src/config.ts +++ b/src/config.ts @@ -17,6 +17,7 @@ export const IMPORT_MUTATION_DRY_RUN = process.env.IMPORT_MUTATION_DRY_RUN === " export const LOCAL_TOKEN_AUTH_REQUIRED = process.env.LOCAL_TOKEN_AUTH_REQUIRED !== "false"; export const LOCAL_API_TOKEN = (process.env.LOCAL_API_TOKEN ?? "").trim(); export const LOCAL_TOKEN_HEADER = "x-local-token" as const; +export const PAIRING_AUTH_ENABLED = process.env.PAIRING_AUTH_ENABLED === "true"; export const TASK_HEARTBEAT_ENABLED = process.env.TASK_HEARTBEAT_ENABLED !== "false"; export const TASK_HEARTBEAT_DRY_RUN = process.env.TASK_HEARTBEAT_DRY_RUN !== "false"; export const TASK_HEARTBEAT_MAX_TASKS_PER_RUN = parsePositiveInt( diff --git a/src/ui/server.ts b/src/ui/server.ts index 6362dec..7592306 100644 --- a/src/ui/server.ts +++ b/src/ui/server.ts @@ -12,9 +12,11 @@ import { LOCAL_API_TOKEN, LOCAL_TOKEN_AUTH_REQUIRED, LOCAL_TOKEN_HEADER, + PAIRING_AUTH_ENABLED, POLLING_INTERVALS_MS, READONLY_MODE, } from "../config"; +import { authMiddleware } from "../auth/simple-auth"; import type { ToolClient } from "../clients/tool-client"; import { mapSessionsListToSummaries } from "../mappers/openclaw-mappers"; import { buildApiDocs } from "../runtime/api-docs"; @@ -923,6 +925,12 @@ export function startUiServer(port: number, toolClient: ToolClient): Server { const requestId = resolveRequestId(req); res.setHeader("x-request-id", requestId); + // 配对认证中间件 + if (PAIRING_AUTH_ENABLED) { + const { handled } = await authMiddleware(req, res); + if (handled) return; + } + try { const url = new URL(req.url ?? "/", "http://127.0.0.1"); const path = url.pathname; diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..9748772 --- /dev/null +++ b/start.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# OpenClaw Control Center 启动脚本 + +cd "$(dirname "$0")" + +# 检查 .env 文件 +if [ ! -f .env ]; then + echo "Creating .env from .env.example..." + cp .env.example .env +fi + +# 启动服务 +echo "Starting OpenClaw Control Center..." +echo "Access at: http://:4310" +echo "" +echo "First time? You need to:" +echo "1. Get pairing code from server: curl -X POST http://localhost:4310/api/pairing" +echo "2. Enter the code at: http://:4310/pair" +echo "" + +UI_MODE=true node --import tsx src/index.ts