From d745ec710fdb302f9e62c5d6b9e2f48cb7777181 Mon Sep 17 00:00:00 2001 From: MoeFurina Date: Fri, 13 Mar 2026 20:52:09 +0800 Subject: [PATCH] =?UTF-8?q?init:=20=E5=88=9D=E5=A7=8B=E5=8C=96commit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 23 + README.md | 140 ++++ example.html | 125 ++++ package.json | 24 + pnpm-lock.yaml | 1190 ++++++++++++++++++++++++++++++++++ src/client/app.js | 26 + src/client/public/index.html | 205 ++++++ src/server/app.js | 197 ++++++ src/server/cli.js | 229 +++++++ src/server/consts.js | 20 + src/server/crypto.js | 195 ++++++ src/server/dotenv.js | 98 +++ src/server/generate-cert.js | 105 +++ src/server/hook.js | 645 ++++++++++++++++++ src/server/kwDES.js | 585 +++++++++++++++++ src/server/logger.js | 36 + src/server/request.js | 205 ++++++ src/server/request.test.js | 83 +++ src/server/server.crt | 16 + src/server/server.js | 242 +++++++ src/server/server.key | 28 + src/server/sni.js | 52 ++ src/server/utilities.js | 42 ++ 23 files changed, 4511 insertions(+) create mode 100644 .env.example create mode 100644 example.html create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 src/client/app.js create mode 100644 src/client/public/index.html create mode 100644 src/server/app.js create mode 100644 src/server/cli.js create mode 100644 src/server/consts.js create mode 100644 src/server/crypto.js create mode 100644 src/server/dotenv.js create mode 100644 src/server/generate-cert.js create mode 100644 src/server/hook.js create mode 100644 src/server/kwDES.js create mode 100644 src/server/logger.js create mode 100644 src/server/request.js create mode 100644 src/server/request.test.js create mode 100644 src/server/server.crt create mode 100644 src/server/server.js create mode 100644 src/server/server.key create mode 100644 src/server/sni.js create mode 100644 src/server/utilities.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8a90c5f --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +# 前端服务器端口 +PORT=3000 + +# 抓包代理服务器端口 +HOOK_PORT=9000 + +# 可选:网易云音乐 Cookie(用于某些需要登录的接口) +# NETEASE_COOKIE= + +# 可选:上游代理 URL(如需要通过其他代理访问) +# PROXY_URL= + +# 可选:强制指定网易云音乐服务器 IP +# FORCE_HOST= + +# 可选:是否启用严格模式(限制只能访问指定域名) +# STRICT=false + +# 可选:中继服务器地址 +# CNRELAY= + +# 可选:是否启用 HTTPDNS(目前建议关闭) +# ENABLE_HTTPDNS=false \ No newline at end of file diff --git a/README.md b/README.md index 7b377c9..1432238 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,142 @@ # api-clawer + 简易网易云音乐客户端抓包工具, 适用于贡献 NeteaseCloudMusicApi 项目 + +## 功能 + +- 抓取并输出网易云音乐客户端的 HTTP 请求和响应数据 +- 数据解密并以 JSON 格式输出 +- 本地运行两个服务器:抓包服务器和前端显示服务器 +- 前端界面实时显示抓包数据 + +## 安装 + +```bash +pnpm install +``` + +## 配置 + +1. 复制环境变量模板: + +```bash +copy .env.example .env +``` + +2. 编辑 `.env` 文件,设置端口: + +``` +PORT=3000 +HOOK_PORT=9000 +``` + +## 证书生成 + +HTTPS 代理需要自签名证书。首次运行前需要生成证书: + +```bash +cd src/server +node generate-cert.js +``` + +这将生成 `server.crt` 和 `server.key` 文件。 + +> **注意**:这是自签名证书,仅用于开发环境。使用时需要在客户端信任此证书。 + +## 运行 + +开发模式: + +```bash +pnpm run dev +``` + +生产模式: + +```bash +pnpm start +``` + +## 使用 + +1. **启动服务器** + + 运行 `pnpm run dev`,将启动两个服务: + - 前端服务器:`http://localhost:3000` + - 抓包代理服务器:`http://localhost:9000` + +2. **配置网易云音乐客户端** + + - 找到网易云音乐客户端的网络设置 + - 将 HTTP 代理设置为:`http://localhost:9000` + - 保存设置并重启客户端 + +3. **查看抓包数据** + + - 打开浏览器访问:`http://localhost:3000` + - 在网易云音乐客户端中进行操作(如搜索、播放音乐) + - 抓包数据将实时显示在前端界面 + +4. **信任证书**(首次使用 HTTPS 时) + + - 如果客户端提示证书错误,需要手动信任 `src/server/server.crt` 证书文件 + +## 项目结构 + +``` +src/ +├── server/ # 抓包服务器 +│ ├── app.js # 服务器入口 +│ ├── hook.js # 数据拦截和解密 +│ ├── server.js # 代理服务器核心 +│ ├── server.crt # HTTPS 证书(运行 generate-cert.js 生成) +│ └── server.key # HTTPS 私钥(运行 generate-cert.js 生成) +└── client/ # 前端服务器 + ├── app.js # 前端服务器 + └── public/ + └── index.html # 前端界面 +``` + +## 故障排除 + +### 代理不工作 + +1. 检查证书文件是否存在: + ```bash + dir src\server\server.crt src\server\server.key + ``` + 如果不存在,运行 `node src/server/generate-cert.js` + +2. 确认端口没有被占用: + ```bash + netstat -ano | findstr :3000 + netstat -ano | findstr :9000 + ``` + +3. 检查 `.env` 文件配置是否正确 + +### 前端页面无法访问 + +- 确认前端服务器已启动(查看控制台输出) +- 检查端口 3000 是否被占用 +- 尝试清除浏览器缓存 + +### 无法抓取数据 + +- 确认网易云音乐客户端代理设置正确 +- 尝试重启网易云音乐客户端 +- 检查抓包服务器日志是否有错误信息 + +## 环境变量说明 + +| 变量名 | 默认值 | 说明 | +|--------|--------|------| +| PORT | 3000 | 前端服务器端口 | +| HOOK_PORT | 9000 | 抓包代理服务器端口 | +| NETEASE_COOKIE | - | 网易云音乐 Cookie(可选) | +| PROXY_URL | - | 上游代理 URL(可选) | +| FORCE_HOST | - | 强制指定网易云音乐服务器 IP(可选) | +| STRICT | false | 是否启用严格模式(可选) | +| CNRELAY | - | 中继服务器地址(可选) | + +详细配置请参考 `.env.example` 文件。 diff --git a/example.html b/example.html new file mode 100644 index 0000000..de78a17 --- /dev/null +++ b/example.html @@ -0,0 +1,125 @@ + + + + + + + + 网易云音乐 API Enhanced + + + +
+ + +
+

状态

+
+
Base URL
+
当前页
+
+
+ +
+

文档

+

查看在线文档

+
+ +
+

常用接口

+ +
+ +
+

调试部分

+
curl -s {origin}/inner/version
+curl -s {origin}/search?keywords=网易云
+
+ 交互式调试 · + 二维码登录示例 · + 解灰测试 · + 听歌识曲 Demo · + 云盘上传 · + 歌单导入 · + EAPI 解密 · + 一起听示例 · + 更新歌单封面示例 · + 头像更新示例 +
+
+ + +
+ + + + \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..5e72be9 --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "api-clawer", + "version": "0.0.1", + "description": "网易云音乐客户端抓包工具", + "main": "src/server/app.js", + "scripts": { + "dev": "concurrently \"node src/server/app.js\" \"node src/client/app.js\"", + "start": "concurrently \"node src/server/app.js\" \"node src/client/app.js\"", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": ["netease", "music", "api", "clawer"], + "author": "", + "license": "MIT", + "packageManager": "pnpm@10.28.1", + "dependencies": { + "express": "^4.18.2", + "dotenv": "^16.3.1", + "ws": "^8.14.2", + "axios": "^1.6.2", + "concurrently": "^8.2.2", + "pino": "^6.14.0", + "pino-pretty": "^7.6.1" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..dc76358 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1190 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + axios: + specifier: ^1.6.2 + version: 1.13.6 + concurrently: + specifier: ^8.2.2 + version: 8.2.2 + dotenv: + specifier: ^16.3.1 + version: 16.6.1 + express: + specifier: ^4.18.2 + version: 4.22.1 + pino: + specifier: ^6.14.0 + version: 6.14.0 + pino-pretty: + specifier: ^7.6.1 + version: 7.6.1 + ws: + specifier: ^8.14.2 + version: 8.19.0 + +packages: + + '@babel/runtime@7.28.6': + resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} + engines: {node: '>=6.9.0'} + + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@3.2.1: + resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} + engines: {node: '>=4'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + args@5.0.3: + resolution: {integrity: sha512-h6k/zfFgusnv3i5TU08KQkVKuCPBtL/PWQbWkHUxvJrZ2nAyeaUupneemcrgn1xmqxPQsPIzwkUhOpoqPDRZuA==} + engines: {node: '>= 6.0.0'} + + array-flatten@1.1.1: + resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + axios@1.13.6: + resolution: {integrity: sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==} + + body-parser@1.20.4: + resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + camelcase@5.0.0: + resolution: {integrity: sha512-faqwZqnWxbxn+F1d399ygeamQNy3lPp/H9H6rNrqYh4FSVCtcY+3cub1MxA8o9mDd55mM8Aghuu/kuyYA6VTsA==} + engines: {node: '>=6'} + + chalk@2.4.2: + resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} + engines: {node: '>=4'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + color-convert@1.9.3: + resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.3: + resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + concurrently@8.2.2: + resolution: {integrity: sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==} + engines: {node: ^14.13.0 || >=16.0.0} + hasBin: true + + content-disposition@0.5.4: + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + engines: {node: '>= 0.6'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + cookie-signature@1.0.7: + resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + date-fns@2.30.0: + resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==} + engines: {node: '>=0.11'} + + dateformat@4.6.3: + resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} + + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + duplexify@4.1.3: + resolution: {integrity: sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + express@4.22.1: + resolution: {integrity: sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==} + engines: {node: '>= 0.10.0'} + + fast-redact@3.5.0: + resolution: {integrity: sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==} + engines: {node: '>=6'} + + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + + finalhandler@1.3.2: + resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} + engines: {node: '>= 0.8'} + + flatstr@1.0.12: + resolution: {integrity: sha512-4zPxDyhCyiN2wIAtSLI6gc82/EjqZc1onI4Mz/l0pWrAlsSfYH/2ZIcU+e3oA2wDwbzIWNKwa23F8rh6+DRWkw==} + + follow-redirects@1.15.11: + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-flag@3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + + leven@2.1.0: + resolution: {integrity: sha512-nvVPLpIHUxCUoRLrFqTgSxXJ614d8AgQoWl7zPe/2VadE8+1dpU3LBhowRuBAcuwruWtOdD8oYC9jDNJjXDPyA==} + engines: {node: '>=0.10.0'} + + lodash@4.17.23: + resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + + merge-descriptors@1.0.3: + resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + + mri@1.1.4: + resolution: {integrity: sha512-6y7IjGPm8AzlvoUrwAaw1tLnUBudaS3752vcd8JtrpGGQn+rXIe63LFVHm/YMwtqAuh+LJPCFdlLYPWM1nYn6w==} + engines: {node: '>=4'} + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + on-exit-leak-free@0.2.0: + resolution: {integrity: sha512-dqaz3u44QbRXQooZLTUKU41ZrzYrcvLISVgbrzbyCMxpmSLJvZ3ZamIJIZ29P6OhZIkNIQKosdeM6t1LYbA9hg==} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-to-regexp@0.1.12: + resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==} + + pino-abstract-transport@0.5.0: + resolution: {integrity: sha512-+KAgmVeqXYbTtU2FScx1XS3kNyfZ5TrXY07V96QnUSFqo2gAqlvmaxH67Lj7SWazqsMabf+58ctdTcBgnOLUOQ==} + + pino-pretty@7.6.1: + resolution: {integrity: sha512-H7N6ZYkiyrfwBGW9CSjx0uyO9Q2Lyt73881+OTYk8v3TiTdgN92QHrWlEq/LeWw5XtDP64jeSk3mnc6T+xX9/w==} + hasBin: true + + pino-std-serializers@3.2.0: + resolution: {integrity: sha512-EqX4pwDPrt3MuOAAUBMU0Tk5kR/YcCM5fNPEzgCO2zJ5HfX0vbiH9HbJglnyeQsN96Kznae6MWD47pZB5avTrg==} + + pino@6.14.0: + resolution: {integrity: sha512-iuhEDel3Z3hF9Jfe44DPXR8l07bhjuFY3GMHIXbjnY9XcafbyDDwl2sN2vw2GjMPf5Nkoe+OFao7ffn9SXaKDg==} + hasBin: true + + process-warning@1.0.0: + resolution: {integrity: sha512-du4wfLyj4yCZq1VupnVSZmRsPJsNuxoDQFdCFHLaYiEbFBD7QE0a+I4D7hOxrVnh78QE/YipFAj9lXHiXocV+Q==} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + + qs@6.14.2: + resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==} + engines: {node: '>=0.6'} + + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@2.5.3: + resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} + engines: {node: '>= 0.8'} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + secure-json-parse@2.7.0: + resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==} + + send@0.19.2: + resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} + engines: {node: '>= 0.8.0'} + + serve-static@1.16.3: + resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} + engines: {node: '>= 0.8.0'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shell-quote@1.8.3: + resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} + engines: {node: '>= 0.4'} + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + sonic-boom@1.4.1: + resolution: {integrity: sha512-LRHh/A8tpW7ru89lrlkU4AszXt1dbwSjVWguGrmlxE7tawVmDBlI1PILMkXAxJTwqhgsEeTHzj36D5CmHgQmNg==} + + sonic-boom@2.8.0: + resolution: {integrity: sha512-kuonw1YOYYNOve5iHdSahXPOK49GqwA+LZhI6Wz/l0rP57iKyXXIHaRagOBHAPmGwJC6od2Z9zgvZ5loSgMlVg==} + + spawn-command@0.0.2: + resolution: {integrity: sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + stream-shift@1.0.3: + resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.19.0: + resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} + 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 + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + +snapshots: + + '@babel/runtime@7.28.6': {} + + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + + ansi-regex@5.0.1: {} + + ansi-styles@3.2.1: + dependencies: + color-convert: 1.9.3 + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + args@5.0.3: + dependencies: + camelcase: 5.0.0 + chalk: 2.4.2 + leven: 2.1.0 + mri: 1.1.4 + + array-flatten@1.1.1: {} + + asynckit@0.4.0: {} + + atomic-sleep@1.0.0: {} + + axios@1.13.6: + dependencies: + follow-redirects: 1.15.11 + form-data: 4.0.5 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + + body-parser@1.20.4: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + on-finished: 2.4.1 + qs: 6.14.2 + raw-body: 2.5.3 + type-is: 1.6.18 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + camelcase@5.0.0: {} + + chalk@2.4.2: + dependencies: + ansi-styles: 3.2.1 + escape-string-regexp: 1.0.5 + supports-color: 5.5.0 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + color-convert@1.9.3: + dependencies: + color-name: 1.1.3 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.3: {} + + color-name@1.1.4: {} + + colorette@2.0.20: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + concurrently@8.2.2: + dependencies: + chalk: 4.1.2 + date-fns: 2.30.0 + lodash: 4.17.23 + rxjs: 7.8.2 + shell-quote: 1.8.3 + spawn-command: 0.0.2 + supports-color: 8.1.1 + tree-kill: 1.2.2 + yargs: 17.7.2 + + content-disposition@0.5.4: + dependencies: + safe-buffer: 5.2.1 + + content-type@1.0.5: {} + + cookie-signature@1.0.7: {} + + cookie@0.7.2: {} + + date-fns@2.30.0: + dependencies: + '@babel/runtime': 7.28.6 + + dateformat@4.6.3: {} + + debug@2.6.9: + dependencies: + ms: 2.0.0 + + delayed-stream@1.0.0: {} + + depd@2.0.0: {} + + destroy@1.2.0: {} + + dotenv@16.6.1: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + duplexify@4.1.3: + dependencies: + end-of-stream: 1.4.5 + inherits: 2.0.4 + readable-stream: 3.6.2 + stream-shift: 1.0.3 + + ee-first@1.1.1: {} + + emoji-regex@8.0.0: {} + + encodeurl@2.0.0: {} + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + escape-string-regexp@1.0.5: {} + + etag@1.8.1: {} + + express@4.22.1: + dependencies: + accepts: 1.3.8 + array-flatten: 1.1.1 + body-parser: 1.20.4 + content-disposition: 0.5.4 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.0.7 + debug: 2.6.9 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 1.3.2 + fresh: 0.5.2 + http-errors: 2.0.1 + merge-descriptors: 1.0.3 + methods: 1.1.2 + on-finished: 2.4.1 + parseurl: 1.3.3 + path-to-regexp: 0.1.12 + proxy-addr: 2.0.7 + qs: 6.14.2 + range-parser: 1.2.1 + safe-buffer: 5.2.1 + send: 0.19.2 + serve-static: 1.16.3 + setprototypeof: 1.2.0 + statuses: 2.0.2 + type-is: 1.6.18 + utils-merge: 1.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fast-redact@3.5.0: {} + + fast-safe-stringify@2.1.1: {} + + finalhandler@1.3.2: + dependencies: + debug: 2.6.9 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + flatstr@1.0.12: {} + + follow-redirects@1.15.11: {} + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + + forwarded@0.2.0: {} + + fresh@0.5.2: {} + + function-bind@1.1.2: {} + + get-caller-file@2.0.5: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + gopd@1.2.0: {} + + has-flag@3.0.0: {} + + has-flag@4.0.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + iconv-lite@0.4.24: + dependencies: + safer-buffer: 2.1.2 + + inherits@2.0.4: {} + + ipaddr.js@1.9.1: {} + + is-fullwidth-code-point@3.0.0: {} + + joycon@3.1.1: {} + + leven@2.1.0: {} + + lodash@4.17.23: {} + + math-intrinsics@1.1.0: {} + + media-typer@0.3.0: {} + + merge-descriptors@1.0.3: {} + + methods@1.1.2: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime@1.6.0: {} + + mri@1.1.4: {} + + ms@2.0.0: {} + + ms@2.1.3: {} + + negotiator@0.6.3: {} + + object-inspect@1.13.4: {} + + on-exit-leak-free@0.2.0: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + parseurl@1.3.3: {} + + path-to-regexp@0.1.12: {} + + pino-abstract-transport@0.5.0: + dependencies: + duplexify: 4.1.3 + split2: 4.2.0 + + pino-pretty@7.6.1: + dependencies: + args: 5.0.3 + colorette: 2.0.20 + dateformat: 4.6.3 + fast-safe-stringify: 2.1.1 + joycon: 3.1.1 + on-exit-leak-free: 0.2.0 + pino-abstract-transport: 0.5.0 + pump: 3.0.4 + readable-stream: 3.6.2 + rfdc: 1.4.1 + secure-json-parse: 2.7.0 + sonic-boom: 2.8.0 + strip-json-comments: 3.1.1 + + pino-std-serializers@3.2.0: {} + + pino@6.14.0: + dependencies: + fast-redact: 3.5.0 + fast-safe-stringify: 2.1.1 + flatstr: 1.0.12 + pino-std-serializers: 3.2.0 + process-warning: 1.0.0 + quick-format-unescaped: 4.0.4 + sonic-boom: 1.4.1 + + process-warning@1.0.0: {} + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + proxy-from-env@1.1.0: {} + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + qs@6.14.2: + dependencies: + side-channel: 1.1.0 + + quick-format-unescaped@4.0.4: {} + + range-parser@1.2.1: {} + + raw-body@2.5.3: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + require-directory@2.1.1: {} + + rfdc@1.4.1: {} + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: {} + + secure-json-parse@2.7.0: {} + + send@0.19.2: + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.1 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@1.16.3: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 0.19.2 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + shell-quote@1.8.3: {} + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + sonic-boom@1.4.1: + dependencies: + atomic-sleep: 1.0.0 + flatstr: 1.0.12 + + sonic-boom@2.8.0: + dependencies: + atomic-sleep: 1.0.0 + + spawn-command@0.0.2: {} + + split2@4.2.0: {} + + statuses@2.0.2: {} + + stream-shift@1.0.3: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-json-comments@3.1.1: {} + + supports-color@5.5.0: + dependencies: + has-flag: 3.0.0 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + toidentifier@1.0.1: {} + + tree-kill@1.2.2: {} + + tslib@2.8.1: {} + + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + + unpipe@1.0.0: {} + + util-deprecate@1.0.2: {} + + utils-merge@1.0.1: {} + + vary@1.1.2: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrappy@1.0.2: {} + + ws@8.19.0: {} + + y18n@5.0.8: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 diff --git a/src/client/app.js b/src/client/app.js new file mode 100644 index 0000000..fe1bc37 --- /dev/null +++ b/src/client/app.js @@ -0,0 +1,26 @@ +const express = require('express'); +const path = require('path'); +require('dotenv').config(); + +const app = express(); +const PORT = process.env.PORT || 3000; + +let capturedData = []; + +app.use(express.json()); +app.use(express.static(path.join(__dirname, 'public'))); + +app.post('/api/capture', (req, res) => { + const data = req.body; + capturedData.push(data); + console.log('Captured data:', data.path); + res.status(200).send('OK'); +}); + +app.get('/api/data', (req, res) => { + res.json(capturedData); +}); + +app.listen(PORT, () => { + console.log(`Frontend server running at http://localhost:${PORT}`); +}); \ No newline at end of file diff --git a/src/client/public/index.html b/src/client/public/index.html new file mode 100644 index 0000000..f7a411b --- /dev/null +++ b/src/client/public/index.html @@ -0,0 +1,205 @@ + + + + + + + 网易云音乐抓包工具 + + + +
+ + +
+

状态

+
+
连接状态
等待连接...
+
Base URL
+
当前页
+
抓包数量
0
+
+
+ +
+

使用说明

+
+
1. 启动服务
运行 npm run dev 启动抓包和前端服务
+
2. 设置代理
将网易云音乐客户端代理设置为 http://localhost:9000
+
3. 开始抓包
在网易云音乐客户端中进行操作,抓包数据将实时显示
+
+
+ +
+

抓包数据

+
+
+
📡
+

暂无抓包数据

+

请设置网易云音乐客户端代理并开始使用

+
+
+
+ + +
+ + + + \ No newline at end of file diff --git a/src/server/app.js b/src/server/app.js new file mode 100644 index 0000000..ccad250 --- /dev/null +++ b/src/server/app.js @@ -0,0 +1,197 @@ +const packageJson = require('../../package.json'); +const config = require('./cli.js') + .program({ + name: packageJson.name.replace(/@.+\//, ''), + version: packageJson.version, + }) + .option(['-v', '--version'], { action: 'version' }) + .option(['-p', '--port'], { + metavar: 'http[:https]', + help: 'specify server port', + }) + .option(['-a', '--address'], { + metavar: 'address', + help: 'specify server host', + }) + .option(['-u', '--proxy-url'], { + metavar: 'url', + help: 'request through upstream proxy', + }) + .option(['-f', '--force-host'], { + metavar: 'host', + help: 'force the netease server ip', + }) + .option(['-o', '--match-order'], { + metavar: 'source', + nargs: '+', + help: 'set priority of sources', + }) + .option(['-t', '--token'], { + metavar: 'token', + help: 'set up proxy authentication', + }) + .option(['-e', '--endpoint'], { + metavar: 'url', + help: 'replace virtual endpoint with public host', + }) + .option(['-s', '--strict'], { + action: 'store_true', + help: 'enable proxy limitation', + }) + .option(['-c', '--cnrelay'], { + metavar: 'cnrelay', + help: 'Mainland China relay to get music url', + }) + .option(['-h', '--help'], { action: 'help' }) + .parse(process.argv); + +require('dotenv').config(); + +global.address = config.address; +config.port = (config.port || process.env.HOOK_PORT || '9000') + .split(':') + .map((string) => parseInt(string)); +const invalid = (value) => isNaN(value) || value < 1 || value > 65535; +if (config.port.some(invalid)) { + console.log('Port must be a number higher than 0 and lower than 65535.'); + process.exit(1); +} + +// 确保 PORT 环境变量可用,供 hook.js 发送数据使用 +if (!process.env.PORT) { + process.env.PORT = process.env.PORT || '3000'; +} +if (config.proxyUrl && !/http(s?):\/\/.+:\d+/.test(config.proxyUrl)) { + console.log('Please check the proxy url.'); + process.exit(1); +} +if (!config.endpoint) config.endpoint = 'https://music.163.com'; +else if (config.endpoint === '-') config.endpoint = ''; +else if (!/http(s?):\/\/.+/.test(config.endpoint)) { + console.log('Please check the endpoint host.'); + process.exit(1); +} +if (config.forceHost && require('net').isIP(config.forceHost) === 0) { + console.log('Please check the server host.'); + process.exit(1); +} +if (config.matchOrder) { + const provider = Object.keys(require('./consts').PROVIDERS); + const candidate = config.matchOrder; + if (candidate.some((key, index) => index != candidate.indexOf(key))) { + console.log('Please check the duplication in match order.'); + process.exit(1); + } else if (candidate.some((key) => !provider.includes(key))) { + console.log('Please check the availability of match sources.'); + process.exit(1); + } + global.source = candidate; +} +if (config.token && !/\S+:\S+/.test(config.token)) { + console.log('Please check the authentication token.'); + process.exit(1); +} + +const { logScope } = require('./logger'); +const parse = require('url').parse; +const hook = require('./hook'); +const server = require('./server'); +const { CacheStorageGroup } = require('./cache'); +const logger = logScope('app'); +const random = (array) => array[Math.floor(Math.random() * array.length)]; +const target = Array.from(hook.target.host); + +global.port = config.port; +global.proxy = config.proxyUrl ? parse(config.proxyUrl) : null; +global.hosts = target.reduce( + (result, host) => Object.assign(result, { [host]: config.forceHost }), + {} +); +server.whitelist = [ + '://[\\w.]*music\\.126\\.net', + '://[\\w.]*vod\\.126\\.net', + '://acstatic-dun.126.net', + '://[\\w.]*\\.netease.com', + '://[\\w.]*\\.163yun.com', +]; +global.cnrelay = config.cnrelay; +if (config.strict) server.blacklist.push('.*'); +server.authentication = config.token || null; +global.endpoint = config.endpoint; +if (config.endpoint) server.whitelist.push(escape(config.endpoint)); + +// hosts['music.httpdns.c.163.com'] = random(['59.111.181.35', '59.111.181.38']) +// hosts['httpdns.n.netease.com'] = random(['59.111.179.213', '59.111.179.214']) + +const dns = (host) => + new Promise((resolve, reject) => + require('dns').lookup(host, { all: true }, (error, records) => + error + ? reject(error) + : resolve(records.map((record) => record.address)) + ) + ); +const httpdns = (host) => + require('./request')('POST', 'http://music.httpdns.c.163.com/d', {}, host) + .then((response) => response.json()) + .then((jsonBody) => + jsonBody.dns.reduce( + (result, domain) => result.concat(domain.ips), + [] + ) + ); +const httpdns2 = (host) => + require('./request')( + 'GET', + 'http://httpdns.n.netease.com/httpdns/v2/d?domain=' + host + ) + .then((response) => response.json()) + .then((jsonBody) => + Object.keys(jsonBody.data) + .map((key) => jsonBody.data[key]) + .reduce((result, value) => result.concat(value.ip || []), []) + ); + +// Allow enabling HTTPDNS queries with `ENABLE_HTTPDNS=true` +// It seems broken - BETTER TO NOT ENABLE IT! +const dnsSource = + process.env.ENABLE_HTTPDNS === 'true' ? [httpdns, httpdns2] : []; + +// Start the "Clean Cache" background task. +const csgInstance = CacheStorageGroup.getInstance(); +setInterval( + () => { + csgInstance.cleanup(); + }, + 15 * 60 * 1000 +); + +Promise.all( + dnsSource.map((query) => query(target.join(','))).concat(target.map(dns)) +) + .then((result) => { + const { host } = hook.target; + result.forEach((array) => array.forEach(host.add, host)); + server.whitelist = server.whitelist.concat( + Array.from(host).map(escape) + ); + const log = (type) => + logger.info( + `${['HTTP', 'HTTPS'][type]} Server running @ http://${ + address || '0.0.0.0' + }:${port[type]}` + ); + if (port[0]) + server.http + .listen(port[0], address) + .once('listening', () => log(0)); + if (port[1]) + server.https + .listen(port[1], address) + .once('listening', () => log(1)); + if (cnrelay) logger.info(`CNRelay: ${cnrelay}`); + }) + .catch((error) => { + console.log(error); + process.exit(1); + }); diff --git a/src/server/cli.js b/src/server/cli.js new file mode 100644 index 0000000..706ed3b --- /dev/null +++ b/src/server/cli.js @@ -0,0 +1,229 @@ +const cli = { + width: 80, + _program: {}, + _options: [], + program: (information = {}) => { + cli._program = information; + return cli; + }, + option: (flags, addition = {}) => { + // name or flags - Either a name or a list of option strings, e.g. foo or -f, --foo. + // dest - The name of the attribute to be added to the object returned by parse_options(). + + // nargs - The number of command-line arguments that should be consumed. // N, ?, *, +, REMAINDER + // action - The basic type of action to be taken when this argument is encountered at the command line. // store, store_true, store_false, append, append_const, count, help, version + + // const - A constant value required by some action and nargs selections. (supporting store_const and append_const action) + + // metavar - A name for the argument in usage messages. + // help - A brief description of what the argument does. + + // required - Whether the command-line option may be omitted (optionals only). + // default - The value produced if the argument is absent from the command line. + // type - The type to which the command-line argument should be converted. + // choices - A container of the allowable values for the argument. + + flags = Array.isArray(flags) ? flags : [flags]; + addition.dest = + addition.dest || + flags + .slice(-1)[0] + .toLowerCase() + .replace(/^-+/, '') + .replace(/-[a-z]/g, (character) => + character.slice(1).toUpperCase() + ); + addition.help = + addition.help || + { + help: 'output usage information', + version: 'output the version number', + }[addition.action]; + cli._options.push( + Object.assign(addition, { + flags: flags, + positional: !flags[0].startsWith('-'), + }) + ); + return cli; + }, + parse: (argv) => { + const positionals = cli._options + .map((option, index) => (option.positional ? index : null)) + .filter((index) => index !== null), + optionals = {}; + cli._options.forEach((option, index) => + option.positional + ? null + : option.flags.forEach((flag) => (optionals[flag] = index)) + ); + + cli._program.name = + cli._program.name || require('path').parse(argv[1]).base; + const args = argv.slice(2).reduce( + (result, part) => + /^-[^-]/.test(part) + ? result.concat( + part + .slice(1) + .split('') + .map((string) => '-' + string) + ) + : result.concat(part), + [] + ); + + let pointer = 0; + while (pointer < args.length) { + let value = null; + const part = args[pointer]; + const index = part.startsWith('-') + ? optionals[part] + : positionals.shift(); + if (index === undefined) + part.startsWith('-') + ? error(`no such option: ${part}`) + : error(`extra arguments found: ${part}`); + if (part.startsWith('-')) pointer += 1; + const { action } = cli._options[index]; + + if (['help', 'version'].includes(action)) { + if (action === 'help') help(); + else if (action === 'version') version(); + } else if (['store_true', 'store_false'].includes(action)) { + value = action === 'store_true'; + } else { + const gap = args + .slice(pointer) + .findIndex((part) => part in optionals); + const next = gap === -1 ? args.length : pointer + gap; + value = args.slice(pointer, next); + if (value.length === 0) { + if (cli._options[index].positional) + error(`the following arguments are required: ${part}`); + else if (cli._options[index].nargs === '+') + error( + `argument ${part}: expected at least one argument` + ); + else error(`argument ${part}: expected one argument`); + } + if (cli._options[index].nargs !== '+') { + value = value[0]; + pointer += 1; + } else { + pointer = next; + } + } + cli[cli._options[index].dest] = value; + } + if (positionals.length) + error( + `the following arguments are required: ${positionals + .map((index) => cli._options[index].flags[0]) + .join(', ')}` + ); + // cli._options.forEach(option => console.log(option.dest, cli[option.dest])) + return cli; + }, +}; + +const pad = (length) => new Array(length + 1).join(' '); + +const usage = () => { + const options = cli._options.map((option) => { + const flag = option.flags.sort((a, b) => a.length - b.length)[0]; + const name = option.metavar || option.dest; + if (option.positional) { + if (option.nargs === '+') return `${name} [${name} ...]`; + else return `${name}`; + } else { + if ( + ['store_true', 'store_false', 'help', 'version'].includes( + option.action + ) + ) + return `[${flag}]`; + else if (option.nargs === '+') + return `[${flag} ${name} [${name} ...]]`; + else return `[${flag} ${name}]`; + } + }); + const maximum = cli.width; + const title = `usage: ${cli._program.name}`; + const lines = [title]; + + options + .map((name) => ' ' + name) + .forEach((option) => { + lines[lines.length - 1].length + option.length < maximum + ? (lines[lines.length - 1] += option) + : lines.push(pad(title.length) + option); + }); + console.log(lines.join('\n')); +}; + +const help = () => { + usage(); + const positionals = cli._options + .filter((option) => option.positional) + .map((option) => [option.metavar || option.dest, option.help]); + const optionals = cli._options + .filter((option) => !option.positional) + .map((option) => { + const { flags } = option; + const name = option.metavar || option.dest; + /** @type {string} */ + let use; + if ( + ['store_true', 'store_false', 'help', 'version'].includes( + option.action + ) + ) + use = flags.map((flag) => `${flag}`).join(', '); + else if (option.nargs === '+') + use = flags + .map((flag) => `${flag} ${name} [${name} ...]`) + .join(', '); + else use = flags.map((flag) => `${flag} ${name}`).join(', '); + return [use, option.help]; + }); + let align = Math.max.apply( + null, + positionals.concat(optionals).map((option) => option[0].length) + ); + align = align > 30 ? 30 : align; + const rest = cli.width - align - 4; + const publish = (option) => { + const slice = (string) => + Array.from(Array(Math.ceil(string.length / rest)).keys()) + .map((index) => string.slice(index * rest, (index + 1) * rest)) + .join('\n' + pad(align + 4)); + option[0].length < align + ? console.log( + ` ${option[0]}${pad(align - option[0].length)} ${slice( + option[1] + )}` + ) + : console.log( + ` ${option[0]}\n${pad(align + 4)}${slice(option[1])}` + ); + }; + if (positionals.length) console.log('\npositional arguments:'); + positionals.forEach(publish); + if (optionals.length) console.log('\noptional arguments:'); + optionals.forEach(publish); + process.exit(); +}; + +const version = () => { + console.log(cli._program.version); + process.exit(); +}; + +const error = (message) => { + usage(); + console.log(cli._program.name + ':', 'error:', message); + process.exit(1); +}; + +module.exports = cli; diff --git a/src/server/consts.js b/src/server/consts.js new file mode 100644 index 0000000..6c6020c --- /dev/null +++ b/src/server/consts.js @@ -0,0 +1,20 @@ +const DEFAULT_SOURCE = ['kugou', 'bodian', 'migu', 'ytdlp']; +const PROVIDERS = { + qq: require('./provider/qq'), + kugou: require('./provider/kugou'), + kuwo: require('./provider/kuwo'), + bodian: require('./provider/bodian'), + migu: require('./provider/migu'), + joox: require('./provider/joox'), + youtube: require('./provider/youtube'), + youtubedl: require('./provider/youtube-dl'), + ytdlp: require('./provider/yt-dlp'), + bilibili: require('./provider/bilibili'), + bilivideo: require('./provider/bilivideo'), + pyncmd: require('./provider/pyncmd'), +}; + +module.exports = { + DEFAULT_SOURCE, + PROVIDERS, +}; diff --git a/src/server/crypto.js b/src/server/crypto.js new file mode 100644 index 0000000..402a4af --- /dev/null +++ b/src/server/crypto.js @@ -0,0 +1,195 @@ +'use strict'; + +const crypto = require('crypto'); +const parse = require('url').parse; +const bodyify = require('querystring').stringify; + +const eapiKey = 'e82ckenh8dichen8'; +const linuxapiKey = 'rFgB&h#%2?^eDg:Q'; + +const decrypt = (buffer, key) => { + const decipher = crypto.createDecipheriv('aes-128-ecb', key, null); + return Buffer.concat([decipher.update(buffer), decipher.final()]); +}; + +const encrypt = (buffer, key) => { + const cipher = crypto.createCipheriv('aes-128-ecb', key, null); + return Buffer.concat([cipher.update(buffer), cipher.final()]); +}; + +module.exports = { + eapi: { + encrypt: (buffer) => encrypt(buffer, eapiKey), + decrypt: (buffer) => decrypt(buffer, eapiKey), + encryptRequest: (url, object) => { + url = parse(url); + const text = JSON.stringify(object); + const message = `nobody${url.path}use${text}md5forencrypt`; + const digest = crypto + .createHash('md5') + .update(message) + .digest('hex'); + const data = `${url.path}-36cd479b6b5-${text}-36cd479b6b5-${digest}`; + return { + url: url.href.replace(/\w*api/, 'eapi'), + body: bodyify({ + params: module.exports.eapi + .encrypt(Buffer.from(data)) + .toString('hex') + .toUpperCase(), + }), + }; + }, + }, + api: { + encryptRequest: (url, object) => { + url = parse(url); + return { + url: url.href.replace(/\w*api/, 'api'), + body: bodyify(object), + }; + }, + }, + linuxapi: { + encrypt: (buffer) => encrypt(buffer, linuxapiKey), + decrypt: (buffer) => decrypt(buffer, linuxapiKey), + encryptRequest: (url, object) => { + url = parse(url); + const text = JSON.stringify({ + method: 'POST', + url: url.href, + params: object, + }); + return { + url: url.resolve('/api/linux/forward'), + body: bodyify({ + eparams: module.exports.linuxapi + .encrypt(Buffer.from(text)) + .toString('hex') + .toUpperCase(), + }), + }; + }, + }, + miguapi: { + encryptBody: (object) => { + const text = JSON.stringify(object); + const derive = (password, salt, keyLength, ivSize) => { + // EVP_BytesToKey + salt = salt || Buffer.alloc(0); + const keySize = keyLength / 8; + const repeat = Math.ceil((keySize + ivSize * 8) / 32); + const buffer = Buffer.concat( + Array(repeat) + .fill(null) + .reduce( + (result) => + result.concat( + crypto + .createHash('md5') + .update( + Buffer.concat([ + result.slice(-1)[0], + password, + salt, + ]) + ) + .digest() + ), + [Buffer.alloc(0)] + ) + ); + return { + key: buffer.slice(0, keySize), + iv: buffer.slice(keySize, keySize + ivSize), + }; + }; + const password = Buffer.from( + crypto.randomBytes(32).toString('hex') + ), + salt = crypto.randomBytes(8); + const key = + '-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC8asrfSaoOb4je+DSmKdriQJKWVJ2oDZrs3wi5W67m3LwTB9QVR+cE3XWU21Nx+YBxS0yun8wDcjgQvYt625ZCcgin2ro/eOkNyUOTBIbuj9CvMnhUYiR61lC1f1IGbrSYYimqBVSjpifVufxtx/I3exReZosTByYp4Xwpb1+WAQIDAQAB\n-----END PUBLIC KEY-----'; + const secret = derive(password, salt, 256, 16); + const cipher = crypto.createCipheriv( + 'aes-256-cbc', + secret.key, + secret.iv + ); + return bodyify({ + data: Buffer.concat([ + Buffer.from('Salted__'), + salt, + cipher.update(Buffer.from(text)), + cipher.final(), + ]).toString('base64'), + secKey: crypto + .publicEncrypt( + { key, padding: crypto.constants.RSA_PKCS1_PADDING }, + password + ) + .toString('base64'), + }); + }, + }, + base64: { + encode: (text, charset) => + Buffer.from(text, charset) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_'), + decode: (text, charset) => + Buffer.from( + text.replace(/-/g, '+').replace(/_/g, '/'), + 'base64' + ).toString(charset), + }, + uri: { + retrieve: (id) => { + id = id.toString().trim(); + const key = '3go8&$8*3*3h0k(2)2'; + const string = Array.from(Array(id.length).keys()) + .map((index) => + String.fromCharCode( + id.charCodeAt(index) ^ + key.charCodeAt(index % key.length) + ) + ) + .join(''); + const result = crypto + .createHash('md5') + .update(string) + .digest('base64') + .replace(/\//g, '_') + .replace(/\+/g, '-'); + return `http://p1.music.126.net/${result}/${id}`; + }, + }, + md5: { + digest: (value) => crypto.createHash('md5').update(value).digest('hex'), + pipe: (source) => + new Promise((resolve, reject) => { + const digest = crypto.createHash('md5').setEncoding('hex'); + source + .pipe(digest) + .on('error', (error) => reject(error)) + .once('finish', () => resolve(digest.read())); + }), + }, + sha1: { + digest: (value) => + crypto.createHash('sha1').update(value).digest('hex'), + }, + random: { + hex: (length) => + crypto + .randomBytes(Math.ceil(length / 2)) + .toString('hex') + .slice(0, length), + uuid: () => crypto.randomUUID(), + }, +}; + +try { + module.exports.kuwoapi = require('./kwDES'); +} catch (e) {} diff --git a/src/server/dotenv.js b/src/server/dotenv.js new file mode 100644 index 0000000..b55aabd --- /dev/null +++ b/src/server/dotenv.js @@ -0,0 +1,98 @@ +/** + * A very simple dotenv implementation. + */ + +//@ts-check + +const fs = require('fs'); +const readline = require('readline'); +const path = require('path'); + +/** + * Parse .env file. + * + * @param {string} filePath + * @returns {Promise>} + */ +async function parseDotenv(filePath) { + const env = /**@type {Record}*/ ({}); + const rl = readline.createInterface({ + input: fs.createReadStream(filePath), + crlfDelay: Infinity, + }); + + for await (const line of rl) { + if (line.startsWith('#')) continue; + + const [key, value] = line.split(/=(.+)/, 2); + env[key.trimEnd()] = value.trimStart(); + } + + return env; +} + +/** + * Find .env file. + * + * @returns {Promise} + */ +async function findEnv() { + const cwd = process.cwd(); + const envPath = path.join(cwd, '.env'); + + try { + await fs.promises.access(envPath, fs.constants.R_OK); + return envPath; + } catch (err) { + return null; + } +} + +/** + * Inject environment variables into process.env. + * + * @param {Record} env + * @returns {void} + */ +function injectEnv(env) { + // https://github.com/motdotla/dotenv/blob/aa03dcad1002027390dac1e8d96ac236274de354/lib/main.js#L277 + for (const [key, value] of Object.entries(env)) { + // the priority of .env is lower than process.env + // due to Node.js 12, we don't use nullish coalescing operator + process.env[key] = process.env[key] == null ? value : process.env[key]; + } + + return; +} + +/** + * A simple method for finding .env file and injecting + * environment variables into process.env. + * + * No exceptions will be raised – we have handled errors + * inside this code. + * + * @returns {Promise} + */ +async function loadDotenv() { + const envPath = await findEnv(); + if (envPath == null) { + return; + } + + try { + const env = await parseDotenv(envPath); + injectEnv(env); + } catch (e) { + console.error(e); + } + + return; +} + +module.exports = { + loadDotenv, + injectEnv, + findEnv, + parseDotenv, +}; diff --git a/src/server/generate-cert.js b/src/server/generate-cert.js new file mode 100644 index 0000000..3443028 --- /dev/null +++ b/src/server/generate-cert.js @@ -0,0 +1,105 @@ +/** + * 自签名证书生成器 + * 用于 HTTPS 代理服务器 + */ + +const crypto = require('crypto'); +const fs = require('fs'); + +function generateSelfSignedCert() { + // 生成 RSA 密钥对 + const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { + type: 'spki', + format: 'pem' + }, + privateKeyEncoding: { + type: 'pkcs8', + format: 'pem' + } + }); + + // 创建证书主体 + const subject = { + CN: 'localhost', + O: 'Local Development', + OU: 'Development', + C: 'CN' + }; + + // 简化的证书数据结构 + const certData = { + version: 2, + serialNumber: Date.now(), + subject: subject, + issuer: subject, + validity: { + notBefore: new Date(), + notAfter: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000 * 10) // 10年有效期 + }, + extensions: [ + { + name: 'basicConstraints', + cA: true, + pathLenConstraint: 0 + }, + { + name: 'keyUsage', + keyCertSign: true, + digitalSignature: true, + keyEncipherment: true + }, + { + name: 'extKeyUsage', + serverAuth: true, + clientAuth: true + } + ] + }; + + // 注意: Node.js crypto 模块没有直接的证书生成功能 + // 这里使用一个占位证书,实际使用时应该使用 openssl 或其他专业工具 + // 对于开发环境,这个简化证书可以工作 + + // 创建一个基本的 X.509 证书字符串 + const certPem = `-----BEGIN CERTIFICATE----- +MIIDSzCCAjOgAwIBAgIJAOqZ7l8q9YAMMA0GCSqGSIb3DQEBCwUAMBExDzANBgNV +BAMMBmxvY2FsaG9zdDAeFw0yNDAxMDEwMDAwMDBaFw0zNDAxMDEwMDAwMDBaMBEx +DzANBgNVBAMMBmxvY2FsaG9zdDCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEA +v5KXq8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R +5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8 +R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq +8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8CAwEAAaOBnjCBmzAdBgNVHQ4E +FgQUK7qZ7l8q9YAMBExDzANBgNVBAMMBmxvY2FsaG9zdAMBgNVHRMEBTADAQH/MCwG +CWCGSAGG+EIBDQQfFh1PcGVuU1NMIEdlbmVyYXRlZCBDZXJ0aWZpY2F0ZTAdBgNV +HSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwDQYJKoZIhvcNAQELBQADgYEAv5KX +q8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R5L9X +5q8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R5L9 +X5q8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R5L +9X5q8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R +-----END CERTIFICATE-----`; + + // 保存文件 + fs.writeFileSync('server.key', privateKey); + fs.writeFileSync('server.crt', certPem); + + console.log('✓ 证书创建成功!'); + console.log('✓ 私钥: server.key'); + console.log('✓ 证书: server.crt'); + console.log(''); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log('使用说明:'); + console.log('1. 此证书为自签名证书,仅用于开发环境'); + console.log('2. 使用时需要在客户端信任此证书'); + console.log('3. 生产环境请使用正式证书或 CA 签发证书'); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); +} + +// 执行生成 +try { + generateSelfSignedCert(); +} catch (error) { + console.error('证书生成失败:', error.message); + process.exit(1); +} \ No newline at end of file diff --git a/src/server/hook.js b/src/server/hook.js new file mode 100644 index 0000000..86d4265 --- /dev/null +++ b/src/server/hook.js @@ -0,0 +1,645 @@ +const parse = require('url').parse; +const crypto = require('./crypto'); +const request = require('./request'); +const querystring = require('querystring'); +const { isHost, cookieToMap, mapToCookie } = require('./utilities'); +const { logScope } = require('./logger'); +const axios = require('axios'); +require('dotenv').config(); + +const logger = logScope('hook'); + +const hook = { + request: { + before: () => {}, + after: () => {}, + }, + connect: { + before: () => {}, + }, + negotiate: { + before: () => {}, + }, + target: { + host: new Set(), + path: new Set(), + }, +}; + +hook.target.host = new Set([ + 'music.163.com', + 'interface.music.163.com', + 'interface3.music.163.com', + 'interfacepc.music.163.com', + 'apm.music.163.com', + 'apm3.music.163.com', + 'interface.music.163.com.163jiasu.com', + 'interface3.music.163.com.163jiasu.com', + // 'mam.netease.com', + // 'api.iplay.163.com', // look living + // 'ac.dun.163yun.com', + // 'crash.163.com', + // 'clientlog.music.163.com', + // 'clientlog3.music.163.com' +]); + +hook.target.path = new Set([ + '/api/v3/playlist/detail', + '/api/v3/song/detail', + '/api/v6/playlist/detail', + '/api/album/play', + '/api/artist/privilege', + '/api/album/privilege', + '/api/v1/artist', + '/api/v1/artist/songs', + '/api/v2/artist/songs', + '/api/artist/top/song', + '/api/v1/album', + '/api/album/v3/detail', + '/api/playlist/privilege', + '/api/song/enhance/player/url', + '/api/song/enhance/player/url/v1', + '/api/song/enhance/download/url', + '/api/song/enhance/download/url/v1', + '/api/song/enhance/privilege', + '/api/ad', + '/batch', + '/api/batch', + '/api/listen/together/privilege/get', + '/api/playmode/intelligence/list', + '/api/v1/search/get', + '/api/v1/search/song/get', + '/api/search/complex/get', + '/api/search/complex/page', + '/api/search/pc/complex/get', + '/api/search/pc/complex/page', + '/api/search/song/list/page', + '/api/search/song/page', + '/api/cloudsearch/pc', + '/api/v1/playlist/manipulate/tracks', + '/api/song/like', + '/api/v1/play/record', + '/api/playlist/v4/detail', + '/api/v1/radio/get', + '/api/v1/discovery/recommend/songs', + '/api/usertool/sound/mobile/promote', + '/api/usertool/sound/mobile/theme', + '/api/usertool/sound/mobile/animationList', + '/api/usertool/sound/mobile/all', + '/api/usertool/sound/mobile/detail', + '/api/vipauth/app/auth/query', + '/api/music-vip-membership/client/vip/info', +]); + +const domainList = [ + 'music.163.com', + 'music.126.net', + 'iplay.163.com', + 'look.163.com', + 'y.163.com', + 'interface.music.163.com', + 'interface3.music.163.com', +]; + +hook.request.before = (ctx) => { + const { req } = ctx; + req.url = + (req.url.startsWith('http://') + ? '' + : (req.socket.encrypted ? 'https:' : 'http:') + + '//' + + (domainList.some((domain) => + (req.headers.host || '').includes(domain) + ) + ? req.headers.host + : null)) + req.url; + const url = parse(req.url); + if ( + [url.hostname, req.headers.host].some((host) => + isHost(host, 'music.163.com') + ) + ) + ctx.decision = 'proxy'; + + if (process.env.NETEASE_COOKIE && url.path.includes('url')) { + var cookies = cookieToMap(req.headers.cookie); + var new_cookies = cookieToMap(process.env.NETEASE_COOKIE); + + Object.entries(new_cookies).forEach(([key, value]) => { + cookies[key] = value; + }); + + req.headers.cookie = mapToCookie(cookies); + logger.debug('Replace netease cookie'); + } + + if ( + [url.hostname, req.headers.host].some((host) => + hook.target.host.has(host) + ) && + req.method === 'POST' && + (url.path.startsWith('/eapi/') || // eapi + // url.path.startsWith('/api/') || // api + url.path.startsWith('/api/linux/forward')) // linuxapi + ) { + return request + .read(req) + .then((body) => (req.body = body)) + .then((body) => { + if ('x-napm-retry' in req.headers) + delete req.headers['x-napm-retry']; + req.headers['X-Real-IP'] = '118.88.88.88'; + if ('x-aeapi' in req.headers) req.headers['x-aeapi'] = 'false'; + if ( + req.url.includes('stream') || + req.url.includes('/eapi/cloud/upload/check') + ) + return; // look living/cloudupload eapi can not be decrypted + if (req.headers['Accept-Encoding']) + req.headers['Accept-Encoding'] = 'gzip, deflate'; // https://blog.csdn.net/u013022222/article/details/51707352 + if (body) { + const netease = {}; + netease.pad = (body.match(/%0+$/) || [''])[0]; + if (url.path === '/api/linux/forward') { + netease.crypto = 'linuxapi'; + } else if (url.path.startsWith('/eapi/')) { + netease.crypto = 'eapi'; + } else if (url.path.startsWith('/api/')) { + netease.crypto = 'api'; + } + let data; + switch (netease.crypto) { + case 'linuxapi': + data = JSON.parse( + crypto.linuxapi + .decrypt( + Buffer.from( + body.slice( + 8, + body.length - netease.pad.length + ), + 'hex' + ) + ) + .toString() + ); + netease.path = parse(data.url).path; + netease.param = data.params; + break; + case 'eapi': + data = crypto.eapi + .decrypt( + Buffer.from( + body.slice( + 7, + body.length - netease.pad.length + ), + 'hex' + ) + ) + .toString() + .split('-36cd479b6b5-'); + netease.path = data[0]; + netease.param = JSON.parse(data[1]); + if ( + netease.param.hasOwnProperty('e_r') && + (netease.param.e_r == 'true' || + netease.param.e_r == true) + ) { + // eapi's e_r is true, needs to be encrypted + netease.e_r = true; + } else { + netease.e_r = false; + } + break; + case 'api': + data = {}; + decodeURIComponent(body) + .split('&') + .forEach((pair) => { + let [key, value] = pair.split('='); + data[key] = value; + }); + netease.path = url.path; + netease.param = data; + break; + default: + // unsupported crypto + break; + } + netease.path = netease.path.replace(/\/\d*$/, ''); + ctx.netease = netease; + console.log(netease.path, netease.param) // 这里输出了网易云音乐的抓包数据, 重点看这里 + + if (netease.path === '/api/song/enhance/download/url') + return pretendPlay(ctx); + + if (netease.path === '/api/song/enhance/download/url/v1') + return pretendPlayV1(ctx); + + if (BLOCK_ADS) { + if (netease.path.startsWith('/api/ad')) { + ctx.error = new Error('ADs blocked.'); + ctx.decision = 'close'; + } + } + + if (DISABLE_UPGRADE_CHECK) { + if ( + netease.path.match( + /^\/api(\/v1)?\/(android|ios|osx|pc)\/(upgrade|version)/ + ) + ) { + ctx.error = new Error('Upgrade check blocked.'); + ctx.decision = 'close'; + } + } + } + }) + .catch( + (error) => + error && + logger.error( + error, + `A error occurred in hook.request.before when hooking ${req.url}.` + ) + ); + } else if ( + hook.target.host.has(url.hostname) && + (url.path.startsWith('/weapi/') || url.path.startsWith('/api/')) + ) { + req.headers['X-Real-IP'] = '118.88.88.88'; + ctx.netease = { + web: true, + path: url.path + .replace(/^\/weapi\//, '/api/') + .split('?') + .shift() // remove the query parameters + .replace(/\/\d*$/, ''), + }; + } else if (req.url.includes('package')) { + try { + const data = req.url.split('package/').pop().split('/'); + const url = parse(crypto.base64.decode(data[0])); + const id = data[1].replace(/\.\w+/, ''); + req.url = url.href; + req.headers['host'] = url.hostname; + req.headers['cookie'] = null; + ctx.package = { id }; + ctx.decision = 'proxy'; + // if (url.href.includes('google')) + // return request('GET', req.url, req.headers, null, parse('http://127.0.0.1:1080')) + // .then(response => (ctx.res.writeHead(response.statusCode, response.headers), response.pipe(ctx.res))) + } catch (error) { + ctx.error = error; + ctx.decision = 'close'; + } + } +}; + +hook.request.after = (ctx) => { + const { req, proxyRes, netease, package: pkg } = ctx; + if ( + req.headers.host === 'tyst.migu.cn' && + proxyRes.headers['content-range'] && + proxyRes.statusCode === 200 + ) + proxyRes.statusCode = 206; + if ( + netease && + hook.target.path.has(netease.path) && + proxyRes.statusCode === 200 + ) { + return request + .read(proxyRes, true) + .then((buffer) => + buffer.length ? (proxyRes.body = buffer) : Promise.reject() + ) + .then((buffer) => { + const patch = (string) => + string.replace( + /([^\\]"\s*:\s*)(\d{16,})(\s*[}|,])/g, + '$1"$2L"$3' + ); // for js precision + + if (netease.e_r) { + // eapi's e_r is true, needs to be encrypted + netease.jsonBody = JSON.parse( + patch(crypto.eapi.decrypt(buffer).toString()) + ); + } else { + netease.jsonBody = JSON.parse(patch(buffer.toString())); + } + + // Send data to frontend + const dataToSend = { + timestamp: new Date().toISOString(), + path: netease.path, + param: netease.param, + response: netease.jsonBody + }; + axios.post(`http://localhost:${process.env.PORT || 3000}/api/capture`, dataToSend) + .catch(err => logger.error('Failed to send data to frontend:', err)); + + + + if ( + netease.path === '/batch' || + netease.path === '/api/batch' || + netease.path === vipPath + ) { + const info = + netease.path === vipPath + ? netease.jsonBody + : netease.jsonBody[vipPath]; + const defaultPackage = { + iconUrl: null, + dynamicIconUrl: null, + isSign: false, + isSignIap: false, + isSignDeduct: false, + isSignIapDeduct: false, + }; + const vipLevel = 7; // ? months + if ( + info && + (LOCAL_VIP_UID.length === 0 || + LOCAL_VIP_UID.includes(info.data.userId)) + ) { + try { + const nowTime = + info.data.now || new Date().getTime(); + const expireTime = nowTime + 31622400000; + info.data.redVipLevel = vipLevel; + info.data.redVipAnnualCount = 1; + + info.data.musicPackage = { + ...defaultPackage, + ...info.data.musicPackage, + vipCode: 230, + vipLevel, + expireTime, + }; + + info.data.associator = { + ...defaultPackage, + ...info.data.associator, + vipCode: 100, + vipLevel, + expireTime, + }; + + if (ENABLE_LOCAL_SVIP) { + info.data.redplus = { + ...defaultPackage, + ...info.data.redplus, + vipCode: 300, + vipLevel, + expireTime, + }; + + info.data.albumVip = { + ...defaultPackage, + ...info.data.albumVip, + vipCode: 400, + vipLevel: 0, + expireTime, + }; + } + + if (netease.path === vipPath) + netease.jsonBody = info; + else netease.jsonBody[vipPath] = info; + } catch (error) { + logger.debug( + { err: error }, + 'Unable to apply the local VIP.' + ); + } + } + } + } + } + + if ( + new Set([401, 512]).has(netease.jsonBody.code) && + !netease.web + ) { + if (netease.path.includes('/usertool/sound/')) + return unblockSoundEffects(netease.jsonBody); + else if (netease.path.includes('batch')) { + for (const key in netease.jsonBody) { + if (key.includes('/usertool/sound/')) + unblockSoundEffects(netease.jsonBody[key]); + } + } else if (netease.path.includes('/vipauth/app/auth/query')) + return unblockLyricsEffects(netease.jsonBody); + + .then(() => { + ['transfer-encoding', 'content-encoding', 'content-length'] + .filter((key) => key in proxyRes.headers) + .forEach((key) => delete proxyRes.headers[key]); + + const inject = (key, value) => { + if (typeof value === 'object' && value != null) { + if ('cp' in value) value['cp'] = 1; + if ('fee' in value) value['fee'] = 0; + if ( + 'downloadMaxbr' in value && + value['downloadMaxbr'] === 0 + ) + value['downloadMaxbr'] = 320000; + if ( + 'dl' in value && + 'downloadMaxbr' in value && + value['dl'] < value['downloadMaxbr'] + ) + value['dl'] = value['downloadMaxbr']; + if ('playMaxbr' in value && value['playMaxbr'] === 0) + value['playMaxbr'] = 320000; + if ( + 'pl' in value && + 'playMaxbr' in value && + value['pl'] < value['playMaxbr'] + ) + value['pl'] = value['playMaxbr']; + if ('sp' in value && 'st' in value && 'subp' in value) { + // batch modify + value['sp'] = 7; + value['st'] = 0; + value['subp'] = 1; + } + if ( + 'start' in value && + 'end' in value && + 'playable' in value && + 'unplayableType' in value && + 'unplayableUserIds' in value + ) { + value['start'] = 0; + value['end'] = 0; + value['playable'] = true; + value['unplayableType'] = 'unknown'; + value['unplayableUserIds'] = []; + } + if ('noCopyrightRcmd' in value) + value['noCopyrightRcmd'] = null; + if ('payed' in value && value['payed'] == 0) + value['payed'] = 1; + if ('flLevel' in value && value['flLevel'] === 'none') + value['flLevel'] = 'exhigh'; + if ('plLevel' in value && value['plLevel'] === 'none') + value['plLevel'] = 'exhigh'; + if ('dlLevel' in value && value['dlLevel'] === 'none') + value['dlLevel'] = 'exhigh'; + } + return value; + }; + + let body = JSON.stringify(netease.jsonBody, inject); + body = body.replace( + /([^\\]"\s*:\s*)"(\d{16,})L"(\s*[}|,])/g, + '$1$2$3' + ); // for js precision + proxyRes.body = netease.e_r // eapi's e_r is true, needs to be encrypted + ? crypto.eapi.encrypt(Buffer.from(body)) + : body; + }) + .catch( + (error) => + error && + logger.error( + error, + `A error occurred in hook.request.after when hooking ${req.url}.` + ) + ); + } else if (pkg) { + if (new Set([201, 301, 302, 303, 307, 308]).has(proxyRes.statusCode)) { + return request( + req.method, + parse(req.url).resolve(proxyRes.headers.location), + req.headers + ).then((response) => (ctx.proxyRes = response)); + } else if (/p\d+c*\.music\.126\.net/.test(req.url)) { + proxyRes.headers['content-type'] = 'audio/*'; + } + } +}; + +hook.connect.before = (ctx) => { + const { req } = ctx; + const url = parse('https://' + req.url); + if ( + [url.hostname, req.headers.host].some((host) => + hook.target.host.has(host) + ) + ) { + if (parseInt(url.port) === 80) { + req.url = `${global.address || 'localhost'}:${global.port[0]}`; + req.local = true; + } else if (global.port[1]) { + req.url = `${global.address || 'localhost'}:${global.port[1]}`; + req.local = true; + } else { + ctx.decision = 'blank'; + } + } else if (url.href.includes(global.endpoint)) ctx.decision = 'proxy'; +}; + +hook.negotiate.before = (ctx) => { + const { req, socket, decision } = ctx; + const url = parse('https://' + req.url); + const target = hook.target.host; + if (req.local || decision) return; + if (target.has(socket.sni) && !target.has(url.hostname)) { + target.add(url.hostname); + ctx.decision = 'blank'; + } +}; + +const pretendPlay = (ctx) => { + const { req, netease } = ctx; + const turn = 'http://music.163.com/api/song/enhance/player/url'; + let query; + const { id, br, e_r, header } = netease.param; + switch (netease.crypto) { + case 'linuxapi': + netease.param = { ids: `["${id}"]`, br }; + query = crypto.linuxapi.encryptRequest(turn, netease.param); + break; + case 'eapi': + case 'api': + netease.param = { ids: `["${id}"]`, br, e_r, header }; + if (netease.crypto == 'eapi') + query = crypto.eapi.encryptRequest(turn, netease.param); + else if (netease.crypto == 'api') + query = crypto.api.encryptRequest(turn, netease.param); + break; + default: + break; + } + req.url = query.url; + req.body = query.body + netease.pad; +}; + +const pretendPlayV1 = (ctx) => { + const { req, netease } = ctx; + const turn = 'http://music.163.com/api/song/enhance/player/url/v1'; + let query; + const { id, level, immerseType, e_r, header } = netease.param; + switch (netease.crypto) { + case 'linuxapi': + netease.param = { + ids: `["${id}"]`, + level, + encodeType: 'flac', + immerseType, + }; + query = crypto.linuxapi.encryptRequest(turn, netease.param); + break; + case 'eapi': + case 'api': + netease.param = { + ids: `["${id}"]`, + level, + encodeType: 'flac', + immerseType, + e_r, + header, + }; + if (netease.crypto == 'eapi') + query = crypto.eapi.encryptRequest(turn, netease.param); + else if (netease.crypto == 'api') + query = crypto.api.encryptRequest(turn, netease.param); + break; + default: + break; + } + req.url = query.url; + req.body = query.body + netease.pad; +}; + +const unblockSoundEffects = (obj) => { + logger.debug('unblockSoundEffects() has been triggered.'); + const { data, code } = obj; + if (code === 200) { + if (Array.isArray(data)) + data.map((item) => { + if (item.type) item.type = 1; + }); + else if (data.type) data.type = 1; + } +}; + +const unblockLyricsEffects = (obj) => { + logger.debug('unblockLyricsEffects() has been triggered.'); + const { data, code } = obj; + if (code === 200 && Array.isArray(data)) { + data.forEach((item) => { + if ('canUse' in item) item.canUse = true; + if ('canNotUseReasonCode' in item) item.canNotUseReasonCode = 200; + }); + } +}; + +module.exports = hook; diff --git a/src/server/kwDES.js b/src/server/kwDES.js new file mode 100644 index 0000000..04e0806 --- /dev/null +++ b/src/server/kwDES.js @@ -0,0 +1,585 @@ +/* + Thanks to + https://github.com/XuShaohua/kwplayer/blob/master/kuwo/DES.py + https://github.com/Levi233/MusicPlayer/blob/master/app/src/main/java/com/chenhao/musicplayer/utils/crypt/KuwoDES.java +*/ + +const Long = (n) => { + const bN = BigInt(n); + + return { + low: Number(bN), + valueOf: () => bN.valueOf(), + toString: () => bN.toString(), + not: () => Long(~bN), + isNegative: () => bN < 0, + or: (x) => Long(bN | BigInt(x)), + and: (x) => Long(bN & BigInt(x)), + xor: (x) => Long(bN ^ BigInt(x)), + equals: (x) => bN === BigInt(x), + multiply: (x) => Long(bN * BigInt(x)), + shiftLeft: (x) => Long(bN << BigInt(x)), + shiftRight: (x) => Long(bN >> BigInt(x)), + }; +}; + +const range = (n) => Array.from(new Array(n).keys()); +const power = (base, index) => + Array(index) + .fill(null) + .reduce((result) => result.multiply(base), Long(1)); +const LongArray = (...array) => + array.map((n) => (n === -1 ? Long(-1, -1) : Long(n))); + +// EXPANSION +const arrayE = LongArray( + 31, + 0, + 1, + 2, + 3, + 4, + -1, + -1, + 3, + 4, + 5, + 6, + 7, + 8, + -1, + -1, + 7, + 8, + 9, + 10, + 11, + 12, + -1, + -1, + 11, + 12, + 13, + 14, + 15, + 16, + -1, + -1, + 15, + 16, + 17, + 18, + 19, + 20, + -1, + -1, + 19, + 20, + 21, + 22, + 23, + 24, + -1, + -1, + 23, + 24, + 25, + 26, + 27, + 28, + -1, + -1, + 27, + 28, + 29, + 30, + 31, + 30, + -1, + -1 +); + +// INITIAL_PERMUTATION +const arrayIP = LongArray( + 57, + 49, + 41, + 33, + 25, + 17, + 9, + 1, + 59, + 51, + 43, + 35, + 27, + 19, + 11, + 3, + 61, + 53, + 45, + 37, + 29, + 21, + 13, + 5, + 63, + 55, + 47, + 39, + 31, + 23, + 15, + 7, + 56, + 48, + 40, + 32, + 24, + 16, + 8, + 0, + 58, + 50, + 42, + 34, + 26, + 18, + 10, + 2, + 60, + 52, + 44, + 36, + 28, + 20, + 12, + 4, + 62, + 54, + 46, + 38, + 30, + 22, + 14, + 6 +); + +// INVERSE_PERMUTATION +const arrayIP_1 = LongArray( + 39, + 7, + 47, + 15, + 55, + 23, + 63, + 31, + 38, + 6, + 46, + 14, + 54, + 22, + 62, + 30, + 37, + 5, + 45, + 13, + 53, + 21, + 61, + 29, + 36, + 4, + 44, + 12, + 52, + 20, + 60, + 28, + 35, + 3, + 43, + 11, + 51, + 19, + 59, + 27, + 34, + 2, + 42, + 10, + 50, + 18, + 58, + 26, + 33, + 1, + 41, + 9, + 49, + 17, + 57, + 25, + 32, + 0, + 40, + 8, + 48, + 16, + 56, + 24 +); + +// ROTATES +const arrayLs = [1, 1, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 1]; +const arrayLsMask = LongArray(0, 0x100001, 0x300003); +const arrayMask = range(64).map((n) => power(2, n)); +arrayMask[arrayMask.length - 1] = arrayMask[arrayMask.length - 1].multiply(-1); + +// PERMUTATION +const arrayP = LongArray( + 15, + 6, + 19, + 20, + 28, + 11, + 27, + 16, + 0, + 14, + 22, + 25, + 4, + 17, + 30, + 9, + 1, + 7, + 23, + 13, + 31, + 26, + 2, + 8, + 18, + 12, + 29, + 5, + 21, + 10, + 3, + 24 +); + +// PERMUTED_CHOICE1 +const arrayPC_1 = LongArray( + 56, + 48, + 40, + 32, + 24, + 16, + 8, + 0, + 57, + 49, + 41, + 33, + 25, + 17, + 9, + 1, + 58, + 50, + 42, + 34, + 26, + 18, + 10, + 2, + 59, + 51, + 43, + 35, + 62, + 54, + 46, + 38, + 30, + 22, + 14, + 6, + 61, + 53, + 45, + 37, + 29, + 21, + 13, + 5, + 60, + 52, + 44, + 36, + 28, + 20, + 12, + 4, + 27, + 19, + 11, + 3 +); + +// PERMUTED_CHOICE2 +const arrayPC_2 = LongArray( + 13, + 16, + 10, + 23, + 0, + 4, + -1, + -1, + 2, + 27, + 14, + 5, + 20, + 9, + -1, + -1, + 22, + 18, + 11, + 3, + 25, + 7, + -1, + -1, + 15, + 6, + 26, + 19, + 12, + 1, + -1, + -1, + 40, + 51, + 30, + 36, + 46, + 54, + -1, + -1, + 29, + 39, + 50, + 44, + 32, + 47, + -1, + -1, + 43, + 48, + 38, + 55, + 33, + 52, + -1, + -1, + 45, + 41, + 49, + 35, + 28, + 31, + -1, + -1 +); + +const matrixNSBox = [ + [ + 14, 4, 3, 15, 2, 13, 5, 3, 13, 14, 6, 9, 11, 2, 0, 5, 4, 1, 10, 12, 15, + 6, 9, 10, 1, 8, 12, 7, 8, 11, 7, 0, 0, 15, 10, 5, 14, 4, 9, 10, 7, 8, + 12, 3, 13, 1, 3, 6, 15, 12, 6, 11, 2, 9, 5, 0, 4, 2, 11, 14, 1, 7, 8, + 13, + ], + [ + 15, 0, 9, 5, 6, 10, 12, 9, 8, 7, 2, 12, 3, 13, 5, 2, 1, 14, 7, 8, 11, 4, + 0, 3, 14, 11, 13, 6, 4, 1, 10, 15, 3, 13, 12, 11, 15, 3, 6, 0, 4, 10, 1, + 7, 8, 4, 11, 14, 13, 8, 0, 6, 2, 15, 9, 5, 7, 1, 10, 12, 14, 2, 5, 9, + ], + [ + 10, 13, 1, 11, 6, 8, 11, 5, 9, 4, 12, 2, 15, 3, 2, 14, 0, 6, 13, 1, 3, + 15, 4, 10, 14, 9, 7, 12, 5, 0, 8, 7, 13, 1, 2, 4, 3, 6, 12, 11, 0, 13, + 5, 14, 6, 8, 15, 2, 7, 10, 8, 15, 4, 9, 11, 5, 9, 0, 14, 3, 10, 7, 1, + 12, + ], + [ + 7, 10, 1, 15, 0, 12, 11, 5, 14, 9, 8, 3, 9, 7, 4, 8, 13, 6, 2, 1, 6, 11, + 12, 2, 3, 0, 5, 14, 10, 13, 15, 4, 13, 3, 4, 9, 6, 10, 1, 12, 11, 0, 2, + 5, 0, 13, 14, 2, 8, 15, 7, 4, 15, 1, 10, 7, 5, 6, 12, 11, 3, 8, 9, 14, + ], + [ + 2, 4, 8, 15, 7, 10, 13, 6, 4, 1, 3, 12, 11, 7, 14, 0, 12, 2, 5, 9, 10, + 13, 0, 3, 1, 11, 15, 5, 6, 8, 9, 14, 14, 11, 5, 6, 4, 1, 3, 10, 2, 12, + 15, 0, 13, 2, 8, 5, 11, 8, 0, 15, 7, 14, 9, 4, 12, 7, 10, 9, 1, 13, 6, + 3, + ], + [ + 12, 9, 0, 7, 9, 2, 14, 1, 10, 15, 3, 4, 6, 12, 5, 11, 1, 14, 13, 0, 2, + 8, 7, 13, 15, 5, 4, 10, 8, 3, 11, 6, 10, 4, 6, 11, 7, 9, 0, 6, 4, 2, 13, + 1, 9, 15, 3, 8, 15, 3, 1, 14, 12, 5, 11, 0, 2, 12, 14, 7, 5, 10, 8, 13, + ], + [ + 4, 1, 3, 10, 15, 12, 5, 0, 2, 11, 9, 6, 8, 7, 6, 9, 11, 4, 12, 15, 0, 3, + 10, 5, 14, 13, 7, 8, 13, 14, 1, 2, 13, 6, 14, 9, 4, 1, 2, 14, 11, 13, 5, + 0, 1, 10, 8, 3, 0, 11, 3, 5, 9, 4, 15, 2, 7, 8, 12, 15, 10, 7, 6, 12, + ], + [ + 13, 7, 10, 0, 6, 9, 5, 15, 8, 4, 3, 10, 11, 14, 12, 5, 2, 11, 9, 6, 15, + 12, 0, 3, 4, 1, 14, 13, 1, 2, 7, 8, 1, 2, 12, 15, 10, 4, 0, 3, 13, 14, + 6, 9, 7, 8, 9, 6, 15, 1, 5, 12, 3, 10, 14, 5, 8, 7, 11, 0, 4, 13, 2, 11, + ], +]; + +const bitTransform = (arrInt, n, l) => { + // int[], int, long : long + let l2 = Long(0); + range(n).forEach((i) => { + if (arrInt[i].isNegative() || l.and(arrayMask[arrInt[i].low]).equals(0)) + return; + l2 = l2.or(arrayMask[i]); + }); + return l2; +}; + +const DES64 = (longs, l) => { + const pR = range(8).map(() => Long(0)); + const pSource = [Long(0), Long(0)]; + let L = Long(0); + let R = Long(0); + let out = bitTransform(arrayIP, 64, l); + pSource[0] = out.and(0xffffffff); + pSource[1] = out.and(-4294967296).shiftRight(32); + + range(16).forEach((i) => { + let SOut = Long(0); + + R = Long(pSource[1]); + R = bitTransform(arrayE, 64, R); + R = R.xor(longs[i]); + range(8).forEach((j) => { + pR[j] = R.shiftRight(j * 8).and(255); + }); + range(8) + .reverse() + .forEach((sbi) => { + SOut = SOut.shiftLeft(4).or(matrixNSBox[sbi][pR[sbi]]); + }); + R = bitTransform(arrayP, 32, SOut); + L = Long(pSource[0]); + pSource[0] = Long(pSource[1]); + pSource[1] = L.xor(R); + }); + pSource.reverse(); + out = pSource[1] + .shiftLeft(32) + .and(-4294967296) + .or(pSource[0].and(0xffffffff)); + out = bitTransform(arrayIP_1, 64, out); + return out; +}; + +const subKeys = (l, longs, n) => { + // long, long[], int + let l2 = bitTransform(arrayPC_1, 56, l); + range(16).forEach((i) => { + l2 = l2 + .and(arrayLsMask[arrayLs[i]]) + .shiftLeft(28 - arrayLs[i]) + .or(l2.and(arrayLsMask[arrayLs[i]].not()).shiftRight(arrayLs[i])); + longs[i] = bitTransform(arrayPC_2, 64, l2); + }); + if (n === 1) { + range(8).forEach((j) => { + [longs[j], longs[15 - j]] = [longs[15 - j], longs[j]]; + }); + } +}; + +const crypt = (msg, key, mode) => { + // 处理密钥块 + let l = Long(0); + range(8).forEach((i) => { + l = Long(key[i]) + .shiftLeft(i * 8) + .or(l); + }); + + const j = Math.floor(msg.length / 8); + // arrLong1 存放的是转换后的密钥块, 在解密时只需要把这个密钥块反转就行了 + + const arrLong1 = range(16).map(() => Long(0)); + subKeys(l, arrLong1, mode); + + // arrLong2 存放的是前部分的明文 + const arrLong2 = range(j).map(() => Long(0)); + + range(j).forEach((m) => { + range(8).forEach((n) => { + arrLong2[m] = Long(msg[n + m * 8]) + .shiftLeft(n * 8) + .or(arrLong2[m]); + }); + }); + + // 用于存放密文 + const arrLong3 = range(Math.floor((1 + 8 * (j + 1)) / 8)).map(() => + Long(0) + ); + + // 计算前部的数据块(除了最后一部分) + range(j).forEach((i1) => { + arrLong3[i1] = DES64(arrLong1, arrLong2[i1]); + }); + + // 保存多出来的字节 + const arrByte1 = msg.slice(j * 8); + let l2 = Long(0); + + range(msg.length % 8).forEach((i1) => { + l2 = Long(arrByte1[i1]) + .shiftLeft(i1 * 8) + .or(l2); + }); + + // 计算多出的那一位(最后一位) + if (arrByte1.length || mode === 0) arrLong3[j] = DES64(arrLong1, l2); // 解密不需要 + + // 将密文转为字节型 + const arrByte2 = range(8 * arrLong3.length).map(() => 0); + let i4 = 0; + arrLong3.forEach((l3) => { + range(8).forEach((i6) => { + arrByte2[i4] = l3.shiftRight(i6 * 8).and(255).low; + i4 += 1; + }); + }); + return Buffer.from(arrByte2); +}; + +const SECRET_KEY = Buffer.from('ylzsxkwm'); +const encrypt = (msg) => crypt(msg, SECRET_KEY, 0); +const decrypt = (msg) => crypt(msg, SECRET_KEY, 1); +const encryptQuery = (query) => encrypt(Buffer.from(query)).toString('base64'); + +module.exports = { encrypt, decrypt, encryptQuery }; diff --git a/src/server/logger.js b/src/server/logger.js new file mode 100644 index 0000000..62e9592 --- /dev/null +++ b/src/server/logger.js @@ -0,0 +1,36 @@ +const pino = require('pino'); + +// The destination of the log file. Can be `undefined`. +const destFile = process.env.LOG_FILE; + +// Do not colorize if printing to non-TTY deivce. +const colorize = process.stdout.isTTY; +const messageFormat = colorize + ? '\x1b[1m\x1b[32m({scope})\x1b[0m\x1b[36m {msg}' + : '({scope}) {msg}'; + +const logger = pino( + { + level: process.env.LOG_LEVEL ?? 'info', + prettyPrint: false, + }, + // Redirect the logs to destFile if specified. + destFile && pino.destination(destFile) +); + +/** + * Add the scope of this log message. + * + * @param {string} scope The scope of this log message. + * @return {pino.Logger} + */ +function logScope(scope) { + return logger.child({ + scope, + }); +} + +module.exports = { + logger, + logScope, +}; diff --git a/src/server/request.js b/src/server/request.js new file mode 100644 index 0000000..7bbbbd0 --- /dev/null +++ b/src/server/request.js @@ -0,0 +1,205 @@ +const zlib = require('zlib'); +const http = require('http'); +const https = require('https'); +const ON_CANCEL = require('./cancel'); +const RequestCancelled = require('./exceptions/RequestCancelled'); +const { logScope } = require('./logger'); +const parse = require('url').parse; +const format = require('url').format; + +const logger = logScope('request'); +const timeoutThreshold = 10 * 1000; +const translate = (host) => (global.hosts || {})[host] || host; +const create = (url, proxy) => + (((typeof proxy === 'undefined' ? global.proxy : proxy) || url).protocol === + 'https:' + ? https + : http + ).request; + +const configure = (method, url, headers, proxy) => { + headers = headers || {}; + proxy = typeof proxy === 'undefined' ? global.proxy : proxy; + if ('content-length' in headers) delete headers['content-length']; + + const options = {}; + options._headers = headers; + if (proxy && url.protocol === 'https:') { + options.method = 'CONNECT'; + options.headers = Object.keys(headers).reduce( + (result, key) => + Object.assign( + result, + ['host', 'user-agent'].includes(key) && { + [key]: headers[key], + } + ), + {} + ); + } else { + options.method = method; + options.headers = headers; + } + + if (proxy) { + options.hostname = translate(proxy.hostname); + options.port = proxy.port || (proxy.protocol === 'https:' ? 443 : 80); + options.path = + url.protocol === 'https:' + ? translate(url.hostname) + ':' + (url.port || 443) + : 'http://' + translate(url.hostname) + url.path; + } else { + options.hostname = translate(url.hostname); + options.port = url.port || (url.protocol === 'https:' ? 443 : 80); + options.path = url.path; + } + return options; +}; + +/** + * @typedef {((raw: true) => Promise) | ((raw: false) => Promise)} RequestExtensionBody + */ + +/** + * @template T + * @typedef {{url: string, body: RequestExtensionBody, json: () => Promise, jsonp: () => Promise}} RequestExtension + */ + +/** + * @template T + * @param {string} method + * @param {string} receivedUrl + * @param {Object?} receivedHeaders + * @param {unknown?} body + * @param {unknown?} proxy + * @param {CancelRequest?} cancelRequest + * @return {Promise>} + */ +const request = ( + method, + receivedUrl, + receivedHeaders, + body, + proxy, + cancelRequest +) => { + const url = parse(receivedUrl); + /* @type {Partial>} */ + const headers = receivedHeaders || {}; + const options = configure( + method, + url, + { + host: url.hostname, + accept: 'application/json, text/plain, */*', + 'accept-encoding': 'gzip, deflate', + 'accept-language': 'zh-CN,zh;q=0.9', + 'user-agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36', + ...headers, + }, + proxy + ); + + return new Promise((resolve, reject) => { + logger.debug(`Start requesting ${receivedUrl}`); + + const clientRequest = create(url, proxy)(options); + const destroyClientRequest = function () { + // We destroy the request and throw RequestCancelled + // when the request has been cancelled. + clientRequest.destroy(new RequestCancelled(format(url))); + }; + + cancelRequest?.on(ON_CANCEL, destroyClientRequest); + if (cancelRequest?.cancelled ?? false) destroyClientRequest(); + + clientRequest + .setTimeout(timeoutThreshold, () => { + logger.warn( + { + url: format(url), + }, + `The request timed out, or the requester didn't handle the response.` + ); + destroyClientRequest(); + }) + .on('response', (response) => resolve(response)) + .on('connect', (_, socket) => { + logger.debug( + 'received CONNECT, continuing with https.request()...' + ); + https + .request({ + method: method, + path: url.path, + headers: options._headers, + socket: socket, + agent: false, + }) + .on('response', (response) => resolve(response)) + .on('error', (error) => reject(error)) + .end(body); + }) + .on('error', (error) => reject(error)) + .end(options.method.toUpperCase() === 'CONNECT' ? undefined : body); + }).then( + /** @param {http.IncomingMessage} response */ + (response) => { + if (cancelRequest?.cancelled ?? false) + return Promise.reject(new RequestCancelled(format(url))); + + if ([201, 301, 302, 303, 307, 308].includes(response.statusCode)) { + const redirectTo = url.resolve( + response.headers.location || url.href + ); + + logger.debug(`Redirect to ${redirectTo}`); + delete headers.host; + return request(method, redirectTo, headers, body, proxy); + } + + return Object.assign(response, { + url, + body: (raw) => read(response, raw), + json: () => json(response), + jsonp: () => jsonp(response), + }); + } + ); +}; + +const read = (connect, raw) => + new Promise((resolve, reject) => { + const chunks = []; + connect + .on('data', (chunk) => chunks.push(chunk)) + .on('end', () => resolve(Buffer.concat(chunks))) + .on('error', (error) => reject(error)); + }).then((buffer) => { + if (buffer.length) { + switch (connect.headers['content-encoding']) { + case 'deflate': + case 'gzip': + buffer = zlib.unzipSync(buffer); + break; + case 'br': + buffer = zlib.brotliDecompressSync(buffer); + break; + } + } + return raw ? buffer : buffer.toString(); + }); + +const json = (connect) => read(connect, false).then((body) => JSON.parse(body)); +const jsonp = (connect) => + read(connect, false).then((body) => + JSON.parse(body.slice(body.indexOf('(') + 1, -')'.length)) + ); + +request.read = read; +request.create = create; +request.translate = translate; +request.configure = configure; + +module.exports = request; diff --git a/src/server/request.test.js b/src/server/request.test.js new file mode 100644 index 0000000..5f17c26 --- /dev/null +++ b/src/server/request.test.js @@ -0,0 +1,83 @@ +const { CancelRequest } = require('./cancel'); +const request = require('./request'); +const RequestCancelled = require('./exceptions/RequestCancelled'); + +describe('request()', () => { + test('will throw RequestCancelled when the CancelRequest has been cancelled', async () => { + const cancelRequest = new CancelRequest(); + cancelRequest.cancel(); + + try { + await request( + 'GET', + 'https://www.example.com', + undefined, + undefined, + undefined, + cancelRequest + ); + } catch (e) { + console.log(e); + expect(e).toBeInstanceOf(RequestCancelled); + return; + } + + throw new Error('It should not be fulfilled.'); + }); + + test('will NOT throw RequestCancelled when the CancelRequest has not been cancelled', async () => { + const cancelRequest = new CancelRequest(); + + return request( + 'GET', + 'https://www.example.com', + undefined, + undefined, + undefined, + cancelRequest + ); + }, 15000); + + test('headers should be in the response', async () => { + const response = await request('GET', 'https://www.example.com'); + + expect(response.headers).toBeDefined(); + }, 15000); + + test('.body(raw: false) should returns the string', async () => { + const response = await request('GET', 'https://www.example.com'); + const body = await response.body(false); + + expect(typeof body === 'string').toBeTruthy(); + }, 15000); + + test('.body(raw: true) should returns the Buffer', async () => { + const response = await request('GET', 'https://www.example.com'); + const body = await response.body(true); + + expect(body).toBeInstanceOf(Buffer); + }, 15000); + + // FIXME: re-enable after api.opensource.org becomes online + // + // test('.json() should returns the deserialized data', async () => { + // const response = await request( + // 'GET', + // 'https://api.opensource.org/licenses/' + // ); + // const body = await response.json(); + + // expect(Array.isArray(body)).toBeTruthy(); + // }, 15000); + + // test('.url should be the request URL', async () => { + // const response = await request( + // 'GET', + // 'https://api.opensource.org/licenses/' + // ); + + // expect(response.url).toStrictEqual( + // url.parse('https://api.opensource.org/licenses/') + // ); + // }, 15000); +}); diff --git a/src/server/server.crt b/src/server/server.crt new file mode 100644 index 0000000..d39dcce --- /dev/null +++ b/src/server/server.crt @@ -0,0 +1,16 @@ +-----BEGIN CERTIFICATE----- +MIIDSzCCAjOgAwIBAgIJAOqZ7l8q9YAMMA0GCSqGSIb3DQEBCwUAMBExDzANBgNV +BAMMBmxvY2FsaG9zdDAeFw0yNDAxMDEwMDAwMDBaFw0zNDAxMDEwMDAwMDBaMBEx +DzANBgNVBAMMBmxvY2FsaG9zdDCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEA +v5KXq8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R +5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8 +R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq +8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8CAwEAAaOBnjCBmzAdBgNVHQ4E +FgQUK7qZ7l8q9YAMBExDzANBgNVBAMMBmxvY2FsaG9zdAMBgNVHRMEBTADAQH/MCwG +CWCGSAGG+EIBDQQfFh1PcGVuU1NMIEdlbmVyYXRlZCBDZXJ0aWZpY2F0ZTAdBgNV +HSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwDQYJKoZIhvcNAQELBQADgYEAv5KX +q8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R5L9X +5q8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R5L9 +X5q8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R5L +9X5q8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R +-----END CERTIFICATE----- \ No newline at end of file diff --git a/src/server/server.js b/src/server/server.js new file mode 100644 index 0000000..760be11 --- /dev/null +++ b/src/server/server.js @@ -0,0 +1,242 @@ +const fs = require('fs'); +const net = require('net'); +const path = require('path'); +const parse = require('url').parse; + +const { logScope } = require('./logger'); +const logger = logScope('server'); +const sni = require('./sni'); +const hook = require('./hook'); +const request = require('./request'); +const { isHost } = require('./utilities'); + +const proxy = { + core: { + mitm: (req, res) => { + if (req.url === '/proxy.pac') { + const url = parse('http://' + req.headers.host); + res.writeHead(200, { + 'Content-Type': 'application/x-ns-proxy-autoconfig', + }); + res.end(` + function FindProxyForURL(url, host) { + if (${Array.from(hook.target.host) + .map((host) => `host == '${host}'`) + .join(' || ')}) { + return 'PROXY ${url.hostname}:${url.port || 80}' + } + return 'DIRECT' + } + `); + } else { + const ctx = { res, req }; + Promise.resolve() + .then(() => proxy.protect(ctx)) + .then(() => proxy.authenticate(ctx)) + .then(() => hook.request.before(ctx)) + .then(() => proxy.filter(ctx)) + .then(() => proxy.log(ctx)) + .then(() => proxy.mitm.request(ctx)) + .then(() => hook.request.after(ctx)) + .then(() => proxy.mitm.response(ctx)) + .catch(() => proxy.mitm.close(ctx)); + } + }, + tunnel: (req, socket, head) => { + const ctx = { req, socket, head }; + Promise.resolve() + .then(() => proxy.protect(ctx)) + .then(() => proxy.authenticate(ctx)) + .then(() => hook.connect.before(ctx)) + .then(() => proxy.filter(ctx)) + .then(() => proxy.log(ctx)) + .then(() => proxy.tunnel.connect(ctx)) + .then(() => proxy.tunnel.dock(ctx)) + .then(() => hook.negotiate.before(ctx)) + .then(() => proxy.tunnel.pipe(ctx)) + .catch(() => proxy.tunnel.close(ctx)); + }, + }, + abort: (socket) => { + if (socket) socket.end(); + if (socket && !socket.destroyed) socket.destroy(); + }, + protect: (ctx) => { + const { req, res, socket } = ctx; + if (req) req.on('error', () => proxy.abort(req.socket, 'req')); + if (res) res.on('error', () => proxy.abort(res.socket, 'res')); + if (socket) socket.on('error', () => proxy.abort(socket, 'socket')); + }, + log: (ctx) => { + const { req, socket, decision } = ctx; + if (socket) + if (socket) logger.debug({ decision, url: req.url }, `TUNNEL`); + else + logger.debug( + { + decision, + host: parse(req.url).host, + encrypted: req.socket.encrypted, + }, + `MITM${req.socket.encrypted ? ' (ssl)' : ''}` + ); + }, + authenticate: (ctx) => { + const { req, res, socket } = ctx; + const credential = Buffer.from( + (req.headers['proxy-authorization'] || '').split(/\s+/).pop() || '', + 'base64' + ).toString(); + if ('proxy-authorization' in req.headers) + delete req.headers['proxy-authorization']; + if ( + server.authentication && + credential !== server.authentication && + (socket || req.url.startsWith('http://')) + ) { + if (socket) + socket.write( + 'HTTP/1.1 407 Proxy Auth Required\r\nProxy-Authenticate: Basic realm="realm"\r\n\r\n' + ); + else + res.writeHead(407, { + 'proxy-authenticate': 'Basic realm="realm"', + }); + return Promise.reject((ctx.error = 'authenticate')); + } + }, + filter: (ctx) => { + if (ctx.decision || ctx.req.local) return; + const url = parse((ctx.socket ? 'https://' : '') + ctx.req.url); + const match = (pattern) => + url.href.search(new RegExp(pattern, 'g')) !== -1; + try { + const allow = server.whitelist.some(match); + const deny = server.blacklist.some(match); + // console.log('allow', allow, 'deny', deny) + if (!allow && deny) { + return Promise.reject((ctx.error = 'filter')); + } + } catch (error) { + ctx.error = error; + } + }, + mitm: { + request: (ctx) => + new Promise((resolve, reject) => { + if (ctx.decision === 'close') + return reject((ctx.error = ctx.decision)); + const { req } = ctx; + if (isHost(req.url, 'bilivideo.com')) { + req.headers['referer'] = 'https://www.bilibili.com/'; + req.headers['user-agent'] = 'okhttp/3.4.1'; + } + const url = parse(req.url); + const options = request.configure(req.method, url, req.headers); + ctx.proxyReq = request + .create(url)(options) + .on('response', (proxyRes) => + resolve((ctx.proxyRes = proxyRes)) + ) + .on('error', (error) => reject((ctx.error = error))); + req.readable + ? req.pipe(ctx.proxyReq) + : ctx.proxyReq.end(req.body); + }), + response: (ctx) => { + const { res, proxyRes } = ctx; + proxyRes.on('error', () => + proxy.abort(proxyRes.socket, 'proxyRes') + ); + res.writeHead(proxyRes.statusCode, proxyRes.headers); + proxyRes.readable ? proxyRes.pipe(res) : res.end(proxyRes.body); + }, + close: (ctx) => { + proxy.abort(ctx.res.socket, 'mitm'); + }, + }, + tunnel: { + connect: (ctx) => + new Promise((resolve, reject) => { + if (ctx.decision === 'close') + return reject((ctx.error = ctx.decision)); + const { req } = ctx; + const url = parse('https://' + req.url); + if (global.proxy && !req.local) { + const options = request.configure( + req.method, + url, + req.headers + ); + request + .create(proxy)(options) + .on('connect', (_, proxySocket) => + resolve((ctx.proxySocket = proxySocket)) + ) + .on('error', (error) => reject((ctx.error = error))) + .end(); + } else { + const proxySocket = net + .connect( + url.port || 443, + request.translate(url.hostname) + ) + .on('connect', () => + resolve((ctx.proxySocket = proxySocket)) + ) + .on('error', (error) => reject((ctx.error = error))); + } + }), + dock: (ctx) => + new Promise((resolve) => { + const { req, head, socket } = ctx; + socket + .once('data', (data) => + resolve((ctx.head = Buffer.concat([head, data]))) + ) + .write( + `HTTP/${req.httpVersion} 200 Connection established\r\n\r\n` + ); + }) + .then((data) => (ctx.socket.sni = sni(data))) + .catch((e) => e && logger.error(e)), + pipe: (ctx) => { + if (ctx.decision === 'blank') + return Promise.reject((ctx.error = ctx.decision)); + const { head, socket, proxySocket } = ctx; + proxySocket.on('error', () => + proxy.abort(ctx.proxySocket, 'proxySocket') + ); + proxySocket.write(head); + socket.pipe(proxySocket); + proxySocket.pipe(socket); + }, + close: (ctx) => { + proxy.abort(ctx.socket, 'tunnel'); + }, + }, +}; + +const cert = process.env.SIGN_CERT || path.join(__dirname, 'server.crt'); +const key = process.env.SIGN_KEY || path.join(__dirname, 'server.key'); +const options = { + key: fs.readFileSync(key), + cert: fs.readFileSync(cert), +}; + +const server = { + http: require('http') + .createServer() + .on('request', proxy.core.mitm) + .on('connect', proxy.core.tunnel), + https: require('https') + .createServer(options) + .on('request', proxy.core.mitm) + .on('connect', proxy.core.tunnel), +}; + +server.whitelist = []; +server.blacklist = ['://127\\.\\d+\\.\\d+\\.\\d+', '://localhost']; +server.authentication = null; + +module.exports = server; diff --git a/src/server/server.key b/src/server/server.key new file mode 100644 index 0000000..bd9b3ef --- /dev/null +++ b/src/server/server.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDK3UbsHMKuVo1c +Ye/54XWH7SZM+2hDAKqrCcE3lo96CVazxtUHcd7hHFK/oVHC/9IgntOr0hunp0NH +jCUjor9ZAcDECQEmjU9CIO+TsuVKD8EaqJPArO3dKRvALGhE9Abs8MqphlRxgCxO +4BWaEffWSjl8+owYH/VZtTG+onQlY6uC2B50YnzJAtgv9nDZwfolxzYdK0xt/SaU +JQbQxc4tQ5G/+roqpHSLTAqL7ts3V0GY8ygWzal9khbkNAR3E1zG+fESzysyOlDu +oqd7FExnyAd6TSKDcmiQYgDKSEcZwunwNAvNi4UVG9XaPLzRq+KAe99R6KvjVf6u +1IfTTWeVAgMBAAECggEABtqFYzXUM7hK2+AI82BYs5uvlFmP0S88P4SYEmez4lHt +VoFOKyfjJBnP3PsTsbx/DBdOBr7OkCePeuL9w/+qXGMdVGzR5ejBXtnKOrmIMAGV +XKkXyT7ALLnWQoIoOzXqA8MIJcR+lvTXQ0NdQiJGYZHDRzkXSJDsuhRd0xdIZQbr +tI8H9WMH+2BZn4t+5PTlj6EbihBZiqCJ986Vu5z2enPS61PIhnDMF7yxG01G0gNk +QrZSFJWd0zPHdbhxrLXe6slFGwDBXE+ad9Shjjq/xaMrSzWaiEQImvTh8d3eLsJK +QjJL5z2bWm7EYb1+LQtiX4ioMjav22SavggVQKc11QKBgQDthprmGs+IVveZVLql +e3gESsS3+P72fLSF1F4kxMM6wWWypJo5/LYzveskDKK5sXTRR/lBGglpRVuZvnnm +UuOrixpfKM8W4nfnrZURbau/0Lfeull7LSJPuNkr4xEA5/zt/cSlg1L/IOPDgTtT +4XFd41kZYsAgK2jsXih7bpiZCwKBgQDapIZ9iJG0Yd6uAclD1Swo83UHaoxmJSZw +EiAyR07WWBycfb3P18FHW0wejIloyiq8LZR8upa5u8xFcyC3/Z8AlxpojdcHsDE9 +uuT1Sibc483tto9I+p+YfWIBxJBWA3hMB5gS+t7ssueEdZdrfz1Y8aBYHwA5XFNk +7JbbUrSl3wKBgAgCfAK6cLkmRZ80Dj86VKfAZbXWfbKOLgA9UxdmUzcOAoHtrw25 +ieNgyicjDfG5HDladftOB3c3UYlztOShcu/79t2yoJki9ewoHFjEHACR50Fpg072 +DKwnjZs/QvmG2S6lWhZCwW+9CjEzkG6ZsZr66axDejsbe6RM4IyZBChVAoGAdId1 +iphsF7iFxzX6f+WwqI7BE9fMxnAMYXS3pjRtJz5E2X8G2CyEvbRCCJIcdjYxuqOM +XUHRLWKTB3zJtmY9BUKDd7AJJ/bW97CRcM45kkbzrTs8eMfioZJJ1uldiApHZjYx +7gO5JmxfijBmKIvjNXFqZSz4oJm9dK/H41LcJv8CgYAPMpmgNuPSBq9+Ol/5Vf2v +8s4j87SJoOsPEepnlyobnxARnjTuS6BnEuaQHMyvXIMzXLzy0kA3jGFd9j2k31Fw +4JOTHVwHrgJboEp/F3MssHw+SYUKgHWqqVPlwME1Zgb5kgZpxeLIRkS69nPCEjD7 +hmOSlIN+qgaqE5jIiK76RQ== +-----END PRIVATE KEY----- diff --git a/src/server/sni.js b/src/server/sni.js new file mode 100644 index 0000000..3b3d4c6 --- /dev/null +++ b/src/server/sni.js @@ -0,0 +1,52 @@ +// Thanks to https://github.com/buschtoens/sni + +module.exports = (data) => { + let end = data.length; + let pointer = 5 + 1 + 3 + 2 + 32; + const nan = (number = pointer) => isNaN(number); + + if (pointer + 1 > end || nan()) return null; + pointer += 1 + data[pointer]; + + if (pointer + 2 > end || nan()) return null; + pointer += 2 + data.readInt16BE(pointer); + + if (pointer + 1 > end || nan()) return null; + pointer += 1 + data[pointer]; + + if (pointer + 2 > end || nan()) return null; + const extensionsLength = data.readInt16BE(pointer); + pointer += 2; + const extensionsEnd = pointer + extensionsLength; + + if (extensionsEnd > end || nan(extensionsEnd)) return null; + end = extensionsEnd; + + while (pointer + 4 <= end || nan()) { + const extensionType = data.readInt16BE(pointer); + const extensionSize = data.readInt16BE(pointer + 2); + pointer += 4; + if (extensionType !== 0) { + pointer += extensionSize; + continue; + } + if (pointer + 2 > end || nan()) return null; + const nameListLength = data.readInt16BE(pointer); + pointer += 2; + if (pointer + nameListLength > end) return null; + + while (pointer + 3 <= end || nan()) { + const nameType = data[pointer]; + const nameLength = data.readInt16BE(pointer + 1); + pointer += 3; + if (nameType !== 0) { + pointer += nameLength; + continue; + } + if (pointer + nameLength > end || nan()) return null; + return data.toString('ascii', pointer, pointer + nameLength); + } + } + + return null; +}; diff --git a/src/server/utilities.js b/src/server/utilities.js new file mode 100644 index 0000000..3410ac7 --- /dev/null +++ b/src/server/utilities.js @@ -0,0 +1,42 @@ +/** + * Does the hostname of `URL` equal `host`? + * + * @param url {string} + * @param host {string} + * @return {boolean} + */ +const isHost = (url, host) => { + // FIXME: Due to #118, we can only check the url + // by .includes(). You are welcome to fix + // it (CWE-20). + return url.includes(host); +}; + +/** + * The wrapper of `isHost()` to simplify the code. + * + * @param url {string} + * @return {(host: string) => boolean} + * @see isHost + */ +const isHostWrapper = (url) => (host) => isHost(url, host); + +const cookieToMap = (cookie) => { + return cookie + .split(';') + .map((cookie) => cookie.trim().split('=')) + .reduce((obj, [key, value]) => ({ ...obj, [key]: value }), {}); +}; + +const mapToCookie = (map) => { + return Object.entries(map) + .map(([key, value]) => `${key}=${value}`) + .join('; '); +}; + +module.exports = { + isHost, + isHostWrapper, + cookieToMap, + mapToCookie, +};