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
|
||||
|
||||
简易网易云音乐客户端抓包工具, 适用于贡献 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