mirror of
https://github.com/NeteaseCloudMusicApiEnhanced/api-clawer.git
synced 2026-03-21 09:53:10 +00:00
init: 初始化commit
This commit is contained in:
parent
9329c6c54f
commit
d745ec710f
23
.env.example
Normal file
23
.env.example
Normal file
@ -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
|
||||||
140
README.md
140
README.md
@ -1,2 +1,142 @@
|
|||||||
# api-clawer
|
# api-clawer
|
||||||
|
|
||||||
简易网易云音乐客户端抓包工具, 适用于贡献 NeteaseCloudMusicApi 项目
|
简易网易云音乐客户端抓包工具, 适用于贡献 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` 文件。
|
||||||
|
|||||||
125
example.html
Normal file
125
example.html
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<link rel="icon" href="docs/netease.png">
|
||||||
|
<title>网易云音乐 API Enhanced</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--fg: #333;
|
||||||
|
--muted: #666;
|
||||||
|
--border: #ddd;
|
||||||
|
--bg: #f5f5f5;
|
||||||
|
--panel: #ffffff;
|
||||||
|
--accent: #333;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { height: 100%; }
|
||||||
|
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: var(--fg); background: var(--bg); line-height: 1.6; }
|
||||||
|
.container { max-width: 960px; margin: 40px auto; padding: 0 20px; }
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.container { margin: 20px auto; padding: 0 16px; }
|
||||||
|
header.site-header h1 { font-size: 22px; }
|
||||||
|
.block { padding: 16px; }
|
||||||
|
}
|
||||||
|
header.site-header { margin-bottom: 24px; }
|
||||||
|
header.site-header h1 { font-size: 28px; font-weight: 600; margin: 0; }
|
||||||
|
.badge { display: inline-block; margin-left: 8px; padding: 4px 10px; border: 1px solid var(--border); border-radius: 12px; font-size: 12px; color: var(--muted); }
|
||||||
|
.sub { margin-top: 8px; color: var(--muted); font-size: 14px; }
|
||||||
|
.block { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 20px; margin-bottom: 16px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); }
|
||||||
|
.block h2 { margin: 0 0 12px; font-size: 18px; font-weight: 600; }
|
||||||
|
.kvs { display: grid; grid-template-columns: 100px 1fr; gap: 8px 12px; align-items: start; }
|
||||||
|
.kvs div:first-child { color: var(--muted); flex-shrink: 0; }
|
||||||
|
.kvs div:last-child { word-break: break-all; overflow-wrap: anywhere; min-width: 0; overflow: hidden; }
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.kvs { grid-template-columns: 1fr; gap: 4px 12px; }
|
||||||
|
.kvs div:first-child { font-weight: 500; }
|
||||||
|
}
|
||||||
|
ul.links { list-style: none; padding: 0; margin: 0; }
|
||||||
|
ul.links li { margin: 8px 0; }
|
||||||
|
ul.links a { color: var(--fg); text-decoration: none; border-bottom: 1px dotted var(--border); transition: all 0.2s ease; }
|
||||||
|
ul.links a:hover { color: var(--accent); border-bottom-color: var(--accent); }
|
||||||
|
pre { margin: 0; background: #f9f9f9; border: 1px solid var(--border); border-radius: 6px; padding: 12px; overflow-x: auto; white-space: pre-wrap; word-break: break-all; }
|
||||||
|
code { font-family: 'Courier New', monospace; font-size: 13px; }
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
code { font-size: 12px; }
|
||||||
|
}
|
||||||
|
footer.site-footer { margin-top: 24px; padding-top: 12px; border-top: 1px solid var(--border); color: var(--muted); text-align: center; }
|
||||||
|
footer.site-footer a { color: var(--fg); text-decoration: none; transition: color 0.2s ease; }
|
||||||
|
footer.site-footer a:hover { color: var(--accent); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="container">
|
||||||
|
<header class="site-header">
|
||||||
|
<h1>网易云音乐 API Enhanced <span id="api-version" class="badge"></span></h1>
|
||||||
|
<p class="sub">🔍 A revival project for NeteaseCloudMusicApi Node.js Api Services || 网易云音乐 API 备份 + 增强 || 本项目自原版v4.28.0版本后开始自行维护</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="block">
|
||||||
|
<h2>状态</h2>
|
||||||
|
<div class="kvs">
|
||||||
|
<div>Base URL</div><div id="base-url">—</div>
|
||||||
|
<div>当前页</div><div id="current-url">—</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="block">
|
||||||
|
<h2>文档</h2>
|
||||||
|
<p><a href="/docs" target="_blank">查看在线文档</a></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="block">
|
||||||
|
<h2>常用接口</h2>
|
||||||
|
<ul class="links">
|
||||||
|
<li><a href="/search?keywords=妖精小姐的魔法邀约">搜索音乐: <code>GET /search</code></a></li>
|
||||||
|
<li><a href="/song/detail?ids=2756058128">获取音乐详情: <code>GET /song/detail</code></a></li>
|
||||||
|
<li><a href="/comment/music?id=2756058128&limit=1">获取音乐评论: <code>GET /comment/music</code></a></li>
|
||||||
|
<li><a href="/song/url/v1?id=2756058128&level=exhigh">获取音乐播放链接: <code>GET /song/url/v1</code></a></li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="block">
|
||||||
|
<h2>调试部分</h2>
|
||||||
|
<pre><code>curl -s {origin}/inner/version
|
||||||
|
curl -s {origin}/search?keywords=网易云</code></pre>
|
||||||
|
<div style="margin-top:10px; line-height:2;">
|
||||||
|
<a href="/api.html">交互式调试</a> ·
|
||||||
|
<a href="/qrlogin.html">二维码登录示例</a> ·
|
||||||
|
<a href="/unblock_test.html">解灰测试</a> ·
|
||||||
|
<a href="/audio_match_demo/index.html">听歌识曲 Demo</a> ·
|
||||||
|
<a href="/cloud.html">云盘上传</a> ·
|
||||||
|
<a href="/playlist_import.html">歌单导入</a> ·
|
||||||
|
<a href="/eapi_decrypt.html">EAPI 解密</a> ·
|
||||||
|
<a href="/listen_together_host.html">一起听示例</a> ·
|
||||||
|
<a href="/playlist_cover_update.html">更新歌单封面示例</a> ·
|
||||||
|
<a href="/avatar_update.html">头像更新示例</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer class="site-footer">
|
||||||
|
<a href="https://github.com/neteasecloudmusicapienhanced/api-enhanced" target="_blank">GitHub</a>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
var origin = window.location.origin;
|
||||||
|
document.getElementById('base-url').textContent = origin;
|
||||||
|
document.getElementById('current-url').textContent = window.location.href;
|
||||||
|
|
||||||
|
fetch('/inner/version', { method: 'POST' })
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (data) {
|
||||||
|
var v = data && data.data && data.data.version;
|
||||||
|
if (v) document.getElementById('api-version').textContent = 'v' + v;
|
||||||
|
var pre = document.querySelector('pre code');
|
||||||
|
if (pre) pre.textContent = pre.textContent.replace(/\{origin\}/g, origin);
|
||||||
|
})
|
||||||
|
.catch(function () {});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
24
package.json
Normal file
24
package.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
1190
pnpm-lock.yaml
generated
Normal file
1190
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
src/client/app.js
Normal file
26
src/client/app.js
Normal file
@ -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}`);
|
||||||
|
});
|
||||||
205
src/client/public/index.html
Normal file
205
src/client/public/index.html
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<title>网易云音乐抓包工具</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--fg: #333;
|
||||||
|
--muted: #666;
|
||||||
|
--border: #ddd;
|
||||||
|
--bg: #f5f5f5;
|
||||||
|
--panel: #ffffff;
|
||||||
|
--accent: #C20C0C;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { height: 100%; }
|
||||||
|
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: var(--fg); background: var(--bg); line-height: 1.6; }
|
||||||
|
.container { max-width: 960px; margin: 40px auto; padding: 0 20px; }
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.container { margin: 20px auto; padding: 0 16px; }
|
||||||
|
header.site-header h1 { font-size: 22px; }
|
||||||
|
.block { padding: 16px; }
|
||||||
|
}
|
||||||
|
header.site-header { margin-bottom: 24px; }
|
||||||
|
header.site-header h1 { font-size: 28px; font-weight: 600; margin: 0; color: var(--accent); }
|
||||||
|
.badge { display: inline-block; margin-left: 8px; padding: 4px 10px; border: 1px solid var(--border); border-radius: 12px; font-size: 12px; color: var(--muted); }
|
||||||
|
.sub { margin-top: 8px; color: var(--muted); font-size: 14px; }
|
||||||
|
.block { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 20px; margin-bottom: 16px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); }
|
||||||
|
.block h2 { margin: 0 0 12px; font-size: 18px; font-weight: 600; }
|
||||||
|
.kvs { display: grid; grid-template-columns: 100px 1fr; gap: 8px 12px; align-items: start; }
|
||||||
|
.kvs div:first-child { color: var(--muted); flex-shrink: 0; }
|
||||||
|
.kvs div:last-child { word-break: break-all; overflow-wrap: anywhere; min-width: 0; overflow: hidden; }
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.kvs { grid-template-columns: 1fr; gap: 4px 12px; }
|
||||||
|
.kvs div:first-child { font-weight: 500; }
|
||||||
|
}
|
||||||
|
.capture-item { border: 1px solid var(--border); border-radius: 8px; margin-bottom: 12px; overflow: hidden; }
|
||||||
|
.capture-header { background: #fafafa; padding: 12px 16px; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.capture-path { font-weight: 600; color: var(--accent); }
|
||||||
|
.capture-time { font-size: 12px; color: var(--muted); }
|
||||||
|
.capture-body { padding: 16px; }
|
||||||
|
.capture-section { margin-bottom: 12px; }
|
||||||
|
.capture-section:last-child { margin-bottom: 0; }
|
||||||
|
.capture-section strong { display: block; margin-bottom: 6px; font-size: 14px; color: var(--muted); }
|
||||||
|
pre { margin: 0; background: #f9f9f9; border: 1px solid var(--border); border-radius: 6px; padding: 12px; overflow-x: auto; white-space: pre-wrap; word-break: break-all; font-size: 13px; }
|
||||||
|
code { font-family: 'Courier New', monospace; font-size: 13px; }
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
code { font-size: 12px; }
|
||||||
|
}
|
||||||
|
.status { display: inline-block; padding: 4px 8px; border-radius: 4px; font-size: 12px; font-weight: 500; }
|
||||||
|
.status-connected { background: #e6f4ea; color: #1e8e3e; }
|
||||||
|
.status-waiting { background: #fef7e0; color: #f9ab00; }
|
||||||
|
.no-data { text-align: center; padding: 40px 20px; color: var(--muted); }
|
||||||
|
.no-data .icon { font-size: 48px; margin-bottom: 12px; opacity: 0.5; }
|
||||||
|
footer.site-footer { margin-top: 24px; padding-top: 12px; border-top: 1px solid var(--border); color: var(--muted); text-align: center; }
|
||||||
|
footer.site-footer a { color: var(--fg); text-decoration: none; transition: color 0.2s ease; }
|
||||||
|
footer.site-footer a:hover { color: var(--accent); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="container">
|
||||||
|
<header class="site-header">
|
||||||
|
<h1>网易云音乐抓包工具 <span class="badge">v0.0.1</span></h1>
|
||||||
|
<p class="sub">🔍 简易网易云音乐客户端抓包工具,适用于贡献 NeteaseCloudMusicApi 项目</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="block">
|
||||||
|
<h2>状态</h2>
|
||||||
|
<div class="kvs">
|
||||||
|
<div>连接状态</div><div><span id="status" class="status status-waiting">等待连接...</span></div>
|
||||||
|
<div>Base URL</div><div id="base-url">—</div>
|
||||||
|
<div>当前页</div><div id="current-url">—</div>
|
||||||
|
<div>抓包数量</div><div id="capture-count">0</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="block">
|
||||||
|
<h2>使用说明</h2>
|
||||||
|
<div class="kvs">
|
||||||
|
<div>1. 启动服务</div><div>运行 <code>npm run dev</code> 启动抓包和前端服务</div>
|
||||||
|
<div>2. 设置代理</div><div>将网易云音乐客户端代理设置为 <code>http://localhost:9000</code></div>
|
||||||
|
<div>3. 开始抓包</div><div>在网易云音乐客户端中进行操作,抓包数据将实时显示</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="block">
|
||||||
|
<h2>抓包数据</h2>
|
||||||
|
<div id="data">
|
||||||
|
<div class="no-data">
|
||||||
|
<div class="icon">📡</div>
|
||||||
|
<p>暂无抓包数据</p>
|
||||||
|
<p style="font-size: 12px; margin-top: 8px;">请设置网易云音乐客户端代理并开始使用</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer class="site-footer">
|
||||||
|
<a href="https://github.com/NeteaseCloudMusicApiEnhanced/api-clawer" target="_blank">GitHub</a>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const DATA_ENDPOINT = '/api/data';
|
||||||
|
let lastDataCount = 0;
|
||||||
|
|
||||||
|
function updateStatus() {
|
||||||
|
const statusEl = document.getElementById('status');
|
||||||
|
statusEl.className = 'status status-connected';
|
||||||
|
statusEl.textContent = '已连接';
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayData(data) {
|
||||||
|
const container = document.getElementById('data');
|
||||||
|
const countEl = document.getElementById('capture-count');
|
||||||
|
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="no-data">
|
||||||
|
<div class="icon">📡</div>
|
||||||
|
<p>暂无抓包数据</p>
|
||||||
|
<p style="font-size: 12px; margin-top: 8px;">请设置网易云音乐客户端代理并开始使用</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
countEl.textContent = '0';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是新数据,更新显示
|
||||||
|
if (data.length !== lastDataCount) {
|
||||||
|
lastDataCount = data.length;
|
||||||
|
countEl.textContent = data.length;
|
||||||
|
|
||||||
|
container.innerHTML = data.map(item => `
|
||||||
|
<div class="capture-item">
|
||||||
|
<div class="capture-header">
|
||||||
|
<span class="capture-path">${escapeHtml(item.path)}</span>
|
||||||
|
<span class="capture-time">${formatTime(item.timestamp)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="capture-body">
|
||||||
|
<div class="capture-section">
|
||||||
|
<strong>请求参数:</strong>
|
||||||
|
<pre><code>${formatJson(item.param)}</code></pre>
|
||||||
|
</div>
|
||||||
|
${item.response ? `
|
||||||
|
<div class="capture-section">
|
||||||
|
<strong>响应数据:</strong>
|
||||||
|
<pre><code>${formatJson(item.response)}</code></pre>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchData() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(DATA_ENDPOINT);
|
||||||
|
const data = await response.json();
|
||||||
|
displayData(data);
|
||||||
|
updateStatus();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching data:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatJson(obj) {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(obj, null, 2);
|
||||||
|
} catch (e) {
|
||||||
|
return String(obj);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(timestamp) {
|
||||||
|
try {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
return date.toLocaleTimeString('zh-CN', { hour12: false });
|
||||||
|
} catch (e) {
|
||||||
|
return timestamp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
var origin = window.location.origin;
|
||||||
|
document.getElementById('base-url').textContent = origin;
|
||||||
|
document.getElementById('current-url').textContent = window.location.href;
|
||||||
|
|
||||||
|
// 初始加载
|
||||||
|
fetchData();
|
||||||
|
|
||||||
|
// 每秒刷新数据
|
||||||
|
setInterval(fetchData, 1000);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
197
src/server/app.js
Normal file
197
src/server/app.js
Normal file
@ -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);
|
||||||
|
});
|
||||||
229
src/server/cli.js
Normal file
229
src/server/cli.js
Normal file
@ -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;
|
||||||
20
src/server/consts.js
Normal file
20
src/server/consts.js
Normal file
@ -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,
|
||||||
|
};
|
||||||
195
src/server/crypto.js
Normal file
195
src/server/crypto.js
Normal file
@ -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) {}
|
||||||
98
src/server/dotenv.js
Normal file
98
src/server/dotenv.js
Normal file
@ -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<Record<string, string>>}
|
||||||
|
*/
|
||||||
|
async function parseDotenv(filePath) {
|
||||||
|
const env = /**@type {Record<string, string>}*/ ({});
|
||||||
|
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<string|null>}
|
||||||
|
*/
|
||||||
|
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<string, string>} 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<void>}
|
||||||
|
*/
|
||||||
|
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,
|
||||||
|
};
|
||||||
105
src/server/generate-cert.js
Normal file
105
src/server/generate-cert.js
Normal file
@ -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);
|
||||||
|
}
|
||||||
645
src/server/hook.js
Normal file
645
src/server/hook.js
Normal file
@ -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;
|
||||||
585
src/server/kwDES.js
Normal file
585
src/server/kwDES.js
Normal file
@ -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 };
|
||||||
36
src/server/logger.js
Normal file
36
src/server/logger.js
Normal file
@ -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,
|
||||||
|
};
|
||||||
205
src/server/request.js
Normal file
205
src/server/request.js
Normal file
@ -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<Buffer>) | ((raw: false) => Promise<string>)} RequestExtensionBody
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template T
|
||||||
|
* @typedef {{url: string, body: RequestExtensionBody, json: () => Promise<T>, jsonp: () => Promise<T>}} RequestExtension
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template T
|
||||||
|
* @param {string} method
|
||||||
|
* @param {string} receivedUrl
|
||||||
|
* @param {Object?} receivedHeaders
|
||||||
|
* @param {unknown?} body
|
||||||
|
* @param {unknown?} proxy
|
||||||
|
* @param {CancelRequest?} cancelRequest
|
||||||
|
* @return {Promise<http.IncomingMessage & RequestExtension<T>>}
|
||||||
|
*/
|
||||||
|
const request = (
|
||||||
|
method,
|
||||||
|
receivedUrl,
|
||||||
|
receivedHeaders,
|
||||||
|
body,
|
||||||
|
proxy,
|
||||||
|
cancelRequest
|
||||||
|
) => {
|
||||||
|
const url = parse(receivedUrl);
|
||||||
|
/* @type {Partial<Record<string,string>>} */
|
||||||
|
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;
|
||||||
83
src/server/request.test.js
Normal file
83
src/server/request.test.js
Normal file
@ -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);
|
||||||
|
});
|
||||||
16
src/server/server.crt
Normal file
16
src/server/server.crt
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIDSzCCAjOgAwIBAgIJAOqZ7l8q9YAMMA0GCSqGSIb3DQEBCwUAMBExDzANBgNV
|
||||||
|
BAMMBmxvY2FsaG9zdDAeFw0yNDAxMDEwMDAwMDBaFw0zNDAxMDEwMDAwMDBaMBEx
|
||||||
|
DzANBgNVBAMMBmxvY2FsaG9zdDCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEA
|
||||||
|
v5KXq8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R
|
||||||
|
5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8
|
||||||
|
R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq
|
||||||
|
8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8CAwEAAaOBnjCBmzAdBgNVHQ4E
|
||||||
|
FgQUK7qZ7l8q9YAMBExDzANBgNVBAMMBmxvY2FsaG9zdAMBgNVHRMEBTADAQH/MCwG
|
||||||
|
CWCGSAGG+EIBDQQfFh1PcGVuU1NMIEdlbmVyYXRlZCBDZXJ0aWZpY2F0ZTAdBgNV
|
||||||
|
HSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwDQYJKoZIhvcNAQELBQADgYEAv5KX
|
||||||
|
q8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R5L9X
|
||||||
|
5q8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R5L9
|
||||||
|
X5q8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R5L
|
||||||
|
9X5q8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R5L9X5q8Y3Zq8R
|
||||||
|
-----END CERTIFICATE-----
|
||||||
242
src/server/server.js
Normal file
242
src/server/server.js
Normal file
@ -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;
|
||||||
28
src/server/server.key
Normal file
28
src/server/server.key
Normal file
@ -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-----
|
||||||
52
src/server/sni.js
Normal file
52
src/server/sni.js
Normal file
@ -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;
|
||||||
|
};
|
||||||
42
src/server/utilities.js
Normal file
42
src/server/utilities.js
Normal file
@ -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,
|
||||||
|
};
|
||||||
Loading…
x
Reference in New Issue
Block a user