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
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 调试部分
+ curl -s {origin}/inner/version
+curl -s {origin}/search?keywords=网易云
+
+
+
+
+
+
+
+
+
\ 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,
+};