init: 初始化commit

This commit is contained in:
ElyPrism 2026-03-13 20:52:09 +08:00
parent 9329c6c54f
commit d745ec710f
No known key found for this signature in database
23 changed files with 4511 additions and 0 deletions

23
.env.example Normal file
View 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
View File

@ -1,2 +1,142 @@
# api-clawer
简易网易云音乐客户端抓包工具, 适用于贡献 NeteaseCloudMusicApi 项目
## 功能
- 抓取并输出网易云音乐客户端的 HTTP 请求和响应数据
- 数据解密并以 JSON 格式输出
- 本地运行两个服务器:抓包服务器和前端显示服务器
- 前端界面实时显示抓包数据
## 安装
```bash
pnpm install
```
## 配置
1. 复制环境变量模板:
```bash
copy .env.example .env
```
2. 编辑 `.env` 文件,设置端口:
```
PORT=3000
HOOK_PORT=9000
```
## 证书生成
HTTPS 代理需要自签名证书。首次运行前需要生成证书:
```bash
cd src/server
node generate-cert.js
```
这将生成 `server.crt``server.key` 文件。
> **注意**:这是自签名证书,仅用于开发环境。使用时需要在客户端信任此证书。
## 运行
开发模式:
```bash
pnpm run dev
```
生产模式:
```bash
pnpm start
```
## 使用
1. **启动服务器**
运行 `pnpm run dev`,将启动两个服务:
- 前端服务器:`http://localhost:3000`
- 抓包代理服务器:`http://localhost:9000`
2. **配置网易云音乐客户端**
- 找到网易云音乐客户端的网络设置
- 将 HTTP 代理设置为:`http://localhost:9000`
- 保存设置并重启客户端
3. **查看抓包数据**
- 打开浏览器访问:`http://localhost:3000`
- 在网易云音乐客户端中进行操作(如搜索、播放音乐)
- 抓包数据将实时显示在前端界面
4. **信任证书**(首次使用 HTTPS 时)
- 如果客户端提示证书错误,需要手动信任 `src/server/server.crt` 证书文件
## 项目结构
```
src/
├── server/ # 抓包服务器
│ ├── app.js # 服务器入口
│ ├── hook.js # 数据拦截和解密
│ ├── server.js # 代理服务器核心
│ ├── server.crt # HTTPS 证书(运行 generate-cert.js 生成)
│ └── server.key # HTTPS 私钥(运行 generate-cert.js 生成)
└── client/ # 前端服务器
├── app.js # 前端服务器
└── public/
└── index.html # 前端界面
```
## 故障排除
### 代理不工作
1. 检查证书文件是否存在:
```bash
dir src\server\server.crt src\server\server.key
```
如果不存在,运行 `node src/server/generate-cert.js`
2. 确认端口没有被占用:
```bash
netstat -ano | findstr :3000
netstat -ano | findstr :9000
```
3. 检查 `.env` 文件配置是否正确
### 前端页面无法访问
- 确认前端服务器已启动(查看控制台输出)
- 检查端口 3000 是否被占用
- 尝试清除浏览器缓存
### 无法抓取数据
- 确认网易云音乐客户端代理设置正确
- 尝试重启网易云音乐客户端
- 检查抓包服务器日志是否有错误信息
## 环境变量说明
| 变量名 | 默认值 | 说明 |
|--------|--------|------|
| PORT | 3000 | 前端服务器端口 |
| HOOK_PORT | 9000 | 抓包代理服务器端口 |
| NETEASE_COOKIE | - | 网易云音乐 Cookie可选 |
| PROXY_URL | - | 上游代理 URL可选 |
| FORCE_HOST | - | 强制指定网易云音乐服务器 IP可选 |
| STRICT | false | 是否启用严格模式(可选) |
| CNRELAY | - | 中继服务器地址(可选) |
详细配置请参考 `.env.example` 文件。

125
example.html Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

26
src/client/app.js Normal file
View 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}`);
});

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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;

View 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
View 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
View 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
View 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
View 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
View 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,
};