mirror of
https://github.com/NeteaseCloudMusicApiEnhanced/api-clawer.git
synced 2026-08-12 17:10:36 +00:00
Compare commits
3 Commits
9f07f7d89f
...
48f35502ba
| Author | SHA1 | Date | |
|---|---|---|---|
| 48f35502ba | |||
| e2b0d2ceef | |||
| 0fe0500ae1 |
3
.gitignore
vendored
3
.gitignore
vendored
@ -70,6 +70,9 @@ web_modules/
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Captured data persistence
|
||||
captures.jsonl
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
119
bin/cli.js
Normal file
119
bin/cli.js
Normal file
@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* api-clawer — 网易云音乐客户端抓包工具
|
||||
*
|
||||
* 用法:
|
||||
* npx api-clawer # 启动 (默认端口 3000 + 9000:9001)
|
||||
* npx api-clawer --help # 查看帮助
|
||||
* npx api-clawer --version # 查看版本
|
||||
* npx api-clawer -p 8080 # 指定 HTTP 代理端口
|
||||
* npx api-clawer -p 8080:8443 # 指定 HTTP + HTTPS 代理端口
|
||||
* npx api-clawer -a 127.0.0.1 # 绑定地址
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
|
||||
// 确保 dotenv 加载项目根目录的 .env
|
||||
process.env.DOTENV_CONFIG_PATH = path.resolve(__dirname, '..', '.env');
|
||||
require('dotenv').config({ path: process.env.DOTENV_CONFIG_PATH });
|
||||
|
||||
const packageJson = require('../package.json');
|
||||
|
||||
// 简易参数解析
|
||||
const args = process.argv.slice(2);
|
||||
const flags = {
|
||||
help: args.includes('--help') || args.includes('-h'),
|
||||
version: args.includes('--version') || args.includes('-v'),
|
||||
port: null,
|
||||
address: null,
|
||||
};
|
||||
|
||||
// 解析 -p/--port
|
||||
const portIdx = args.findIndex(a => a === '-p' || a === '--port');
|
||||
if (portIdx !== -1 && args[portIdx + 1] && !args[portIdx + 1].startsWith('-')) {
|
||||
flags.port = args[portIdx + 1];
|
||||
}
|
||||
|
||||
// 解析 -a/--address
|
||||
const addrIdx = args.findIndex(a => a === '-a' || a === '--address');
|
||||
if (addrIdx !== -1 && args[addrIdx + 1] && !args[addrIdx + 1].startsWith('-')) {
|
||||
flags.address = args[addrIdx + 1];
|
||||
}
|
||||
|
||||
if (flags.help) {
|
||||
console.log(`
|
||||
api-clawer v${packageJson.version} — 网易云音乐客户端抓包工具
|
||||
|
||||
用法:
|
||||
npx api-clawer [选项]
|
||||
|
||||
选项:
|
||||
-p, --port <http[:https]> 指定代理端口 (默认: 9000:9001)
|
||||
-a, --address <address> 绑定监听地址 (默认: 0.0.0.0)
|
||||
-v, --version 输出版本号
|
||||
-h, --help 输出帮助信息
|
||||
|
||||
环境变量:
|
||||
PORT=3000 前端界面端口
|
||||
HOOK_PORT=9000:9001 代理服务器端口
|
||||
LOG_LEVEL=info 日志级别 (debug/info/warn/error)
|
||||
|
||||
示例:
|
||||
npx api-clawer
|
||||
npx api-clawer -p 8080
|
||||
npx api-clawer -p 8080:8443 -a 127.0.0.1
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (flags.version) {
|
||||
console.log(packageJson.version);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 将 CLI 参数写入环境变量,供 server/app.js 读取
|
||||
if (flags.port) process.env.HOOK_PORT = flags.port;
|
||||
if (flags.address) process.env.ADDRESS = flags.address;
|
||||
if (!process.env.PORT) process.env.PORT = '3000';
|
||||
|
||||
const { startServer } = require('../src/server/app');
|
||||
const { startClient } = require('../src/client/app');
|
||||
|
||||
/** @type {import('http').Server[]} */
|
||||
const servers = [];
|
||||
|
||||
const gracefulShutdown = async (signal) => {
|
||||
console.log(`\n收到 ${signal} 信号,正在关闭服务器...`);
|
||||
const closePromises = servers.map(s => new Promise(resolve => {
|
||||
s.close(() => resolve());
|
||||
setTimeout(() => resolve(), 5000);
|
||||
}));
|
||||
await Promise.all(closePromises);
|
||||
console.log('所有服务已关闭');
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
||||
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const [proxyResult, clientServer] = await Promise.all([
|
||||
startServer(),
|
||||
startClient()
|
||||
]);
|
||||
if (proxyResult) {
|
||||
if (proxyResult.httpServer) servers.push(proxyResult.httpServer);
|
||||
if (proxyResult.httpsServer) servers.push(proxyResult.httpsServer);
|
||||
}
|
||||
servers.push(clientServer);
|
||||
console.log('所有服务启动完成!');
|
||||
console.log(` 前端界面: http://localhost:${process.env.PORT || 3000}`);
|
||||
console.log(` HTTP 代理: http://localhost:${(flags.port || process.env.HOOK_PORT || '9000').split(':')[0]}`);
|
||||
console.log(' 按 Ctrl+C 停止服务');
|
||||
} catch (error) {
|
||||
console.error('启动服务失败:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
20
package.json
20
package.json
@ -1,16 +1,23 @@
|
||||
{
|
||||
"name": "api-clawer",
|
||||
"version": "0.4.0",
|
||||
"name": "ncm-api-clawer",
|
||||
"version": "0.5.1",
|
||||
"description": "网易云音乐客户端抓包工具",
|
||||
"main": "src/server/app.js",
|
||||
"main": "bin/cli.js",
|
||||
"bin": {
|
||||
"api-clawer": "./bin/cli.js"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node src/index.js"
|
||||
"start": "node bin/cli.js",
|
||||
"test": "jest"
|
||||
},
|
||||
"keywords": [
|
||||
"netease",
|
||||
"music",
|
||||
"api",
|
||||
"clawer"
|
||||
"clawer",
|
||||
"capture",
|
||||
"proxy",
|
||||
"mitm"
|
||||
],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
@ -23,5 +30,8 @@
|
||||
"pino": "^6.14.0",
|
||||
"pino-pretty": "^7.6.1",
|
||||
"ws": "^8.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jest": "^30.4.2"
|
||||
}
|
||||
}
|
||||
|
||||
2561
pnpm-lock.yaml
generated
2561
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -1,77 +1,311 @@
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const axios = require('axios');
|
||||
require('dotenv').config();
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
const CAPTURE_FILE = path.join(__dirname, '..', '..', 'captures.jsonl');
|
||||
|
||||
let capturedData = [];
|
||||
let clients = [];
|
||||
|
||||
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);
|
||||
|
||||
// 通知所有 SSE 客户端有新数据
|
||||
broadcastData();
|
||||
|
||||
res.status(200).send('OK');
|
||||
});
|
||||
|
||||
app.get('/api/data', (req, res) => {
|
||||
res.json(capturedData);
|
||||
});
|
||||
|
||||
// 版本信息端点
|
||||
app.get('/api/version', (req, res) => {
|
||||
/**
|
||||
* 从文件加载历史数据
|
||||
*/
|
||||
function loadFromFile() {
|
||||
try {
|
||||
const packageJson = require('../../package.json');
|
||||
res.json({ version: packageJson.version });
|
||||
} catch (error) {
|
||||
console.error('Failed to read package.json:', error);
|
||||
res.json({ version: '0.1.0' });
|
||||
if (fs.existsSync(CAPTURE_FILE)) {
|
||||
const content = fs.readFileSync(CAPTURE_FILE, 'utf-8').trim();
|
||||
if (content) {
|
||||
capturedData = content.split('\n')
|
||||
.filter(line => line.trim())
|
||||
.map(line => {
|
||||
try { return JSON.parse(line); }
|
||||
catch { return null; }
|
||||
})
|
||||
.filter(Boolean);
|
||||
console.log(`Loaded ${capturedData.length} historical records from ${CAPTURE_FILE}`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load capture file:', e.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 清空数据端点
|
||||
app.post('/api/clear', (req, res) => {
|
||||
capturedData = [];
|
||||
broadcastData();
|
||||
res.json({ success: true });
|
||||
});
|
||||
/**
|
||||
* 追加一条数据到文件
|
||||
*/
|
||||
function appendToFile(data) {
|
||||
try {
|
||||
fs.appendFileSync(CAPTURE_FILE, JSON.stringify(data) + '\n', 'utf-8');
|
||||
} catch (e) {
|
||||
console.error('Failed to append capture to file:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// SSE 端点
|
||||
app.get('/api/events', (req, res) => {
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
/**
|
||||
* 清空文件
|
||||
*/
|
||||
function clearFile() {
|
||||
try {
|
||||
fs.writeFileSync(CAPTURE_FILE, '', 'utf-8');
|
||||
} catch (e) {
|
||||
console.error('Failed to clear capture file:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 立即发送当前数据
|
||||
res.write(`data: ${JSON.stringify(capturedData)}\n\n`);
|
||||
// ======================== 请求重放 ========================
|
||||
|
||||
// 添加到客户端列表
|
||||
clients.push(res);
|
||||
/**
|
||||
* 重放一个被抓包的请求
|
||||
* @param {object} param0 { path, method, params, crypto, rawPath, requestHeaders }
|
||||
* @returns {Promise<object>} 重放结果
|
||||
*/
|
||||
async function replayRequest({ path: apiPath, method, params, crypto, rawPath, requestHeaders }) {
|
||||
const cryptoModule = require('../server/crypto');
|
||||
const url = require('url');
|
||||
|
||||
const baseUrl = 'https://music.163.com';
|
||||
const origPath = rawPath || apiPath;
|
||||
|
||||
// 根据加密类型构造请求
|
||||
let requestUrl, requestBody, requestHeadersObj = {};
|
||||
|
||||
switch (crypto) {
|
||||
case 'eapi': {
|
||||
const eapiPath = '/eapi' + apiPath.replace(/^\/api/, '');
|
||||
const encrypted = cryptoModule.eapi.encryptRequest(baseUrl + eapiPath, params);
|
||||
requestUrl = baseUrl + eapiPath;
|
||||
requestBody = encrypted.body;
|
||||
break;
|
||||
}
|
||||
case 'linuxapi': {
|
||||
const encrypted = cryptoModule.linuxapi.encryptRequest(apiPath, params);
|
||||
requestUrl = baseUrl + '/api/linux/forward';
|
||||
requestBody = encrypted.body;
|
||||
break;
|
||||
}
|
||||
case 'api': {
|
||||
requestUrl = baseUrl + apiPath;
|
||||
requestBody = new url.URLSearchParams(params).toString();
|
||||
requestHeadersObj['Content-Type'] = 'application/x-www-form-urlencoded';
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
// weapi / 未知 -> 直接 POST JSON
|
||||
requestUrl = baseUrl + apiPath;
|
||||
requestBody = params;
|
||||
requestHeadersObj['Content-Type'] = 'application/json';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
req.on('close', () => {
|
||||
clients = clients.filter(client => client !== res);
|
||||
// 合并原始请求头
|
||||
if (requestHeaders) {
|
||||
['cookie', 'user-agent', 'referer', 'origin', 'x-real-ip'].forEach(key => {
|
||||
if (requestHeaders[key]) requestHeadersObj[key] = requestHeaders[key];
|
||||
});
|
||||
}
|
||||
if (!requestHeadersObj['User-Agent'] && !requestHeadersObj['user-agent']) {
|
||||
requestHeadersObj['User-Agent'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36';
|
||||
}
|
||||
requestHeadersObj['X-Real-IP'] = '118.88.88.88';
|
||||
|
||||
const startTime = Date.now();
|
||||
const response = await axios.post(requestUrl, requestBody, {
|
||||
headers: requestHeadersObj,
|
||||
responseType: 'arraybuffer',
|
||||
timeout: 15000,
|
||||
httpsAgent: new (require('https').Agent)({ rejectUnauthorized: false }),
|
||||
});
|
||||
});
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
// 尝试解密响应
|
||||
let responseData = null;
|
||||
const resBuffer = Buffer.from(response.data);
|
||||
const contentType = response.headers['content-type'] || '';
|
||||
|
||||
if (contentType.includes('json') || contentType.includes('text')) {
|
||||
try {
|
||||
responseData = JSON.parse(resBuffer.toString());
|
||||
} catch {
|
||||
// 非 JSON
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试 eapi 解密
|
||||
if (!responseData && resBuffer.length > 0) {
|
||||
try {
|
||||
const decrypted = cryptoModule.eapi.decrypt(resBuffer).toString();
|
||||
responseData = JSON.parse(decrypted);
|
||||
} catch {
|
||||
// 无法解密
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
timestamp: new Date().toISOString(),
|
||||
path: apiPath,
|
||||
rawPath: origPath,
|
||||
crypto: crypto || 'replay',
|
||||
param: params,
|
||||
response: responseData,
|
||||
statusCode: response.status,
|
||||
method: method || 'POST',
|
||||
duration,
|
||||
requestHeaders: requestHeadersObj,
|
||||
responseHeaders: { ...response.headers },
|
||||
replay: true,
|
||||
};
|
||||
}
|
||||
|
||||
// ======================== Express App ========================
|
||||
|
||||
/**
|
||||
* 创建前端 Express app (共享 capturedData 和 clients)
|
||||
*/
|
||||
function createApp() {
|
||||
const app = express();
|
||||
|
||||
// 启动时加载历史数据
|
||||
loadFromFile();
|
||||
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(express.urlencoded({ limit: '10mb', extended: true }));
|
||||
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);
|
||||
// 持久化
|
||||
appendToFile(data);
|
||||
broadcastData();
|
||||
res.status(200).send('OK');
|
||||
});
|
||||
|
||||
app.get('/api/data', (req, res) => {
|
||||
res.json(capturedData);
|
||||
});
|
||||
|
||||
app.get('/api/version', (req, res) => {
|
||||
try {
|
||||
const packageJson = require('../../package.json');
|
||||
res.json({ version: packageJson.version });
|
||||
} catch (error) {
|
||||
console.error('Failed to read package.json:', error);
|
||||
res.json({ version: '0.1.0' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/clear', (req, res) => {
|
||||
capturedData = [];
|
||||
clearFile();
|
||||
broadcastData();
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// 请求重放端点
|
||||
app.post('/api/replay', async (req, res) => {
|
||||
const { path: apiPath, method, params, crypto, rawPath, requestHeaders } = req.body;
|
||||
|
||||
if (!apiPath) {
|
||||
return res.status(400).json({ error: 'Missing path' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await replayRequest({ path: apiPath, method, params, crypto, rawPath, requestHeaders });
|
||||
// 将重放结果也加入抓包列表
|
||||
capturedData.push(result);
|
||||
appendToFile(result);
|
||||
broadcastData();
|
||||
res.json(result);
|
||||
} catch (e) {
|
||||
console.error('Replay failed:', e.message);
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 获取/设置完整抓包模式
|
||||
app.get('/api/settings', (req, res) => {
|
||||
res.json({
|
||||
fullCapture: global.fullCapture === true,
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/settings', (req, res) => {
|
||||
const { fullCapture } = req.body;
|
||||
if (typeof fullCapture === 'boolean') {
|
||||
global.fullCapture = fullCapture;
|
||||
console.log(`Full capture mode: ${fullCapture ? 'ON' : 'OFF'}`);
|
||||
res.json({ success: true, fullCapture });
|
||||
} else {
|
||||
res.status(400).json({ error: 'fullCapture must be boolean' });
|
||||
}
|
||||
});
|
||||
|
||||
// 获取数据统计信息
|
||||
app.get('/api/stats', (req, res) => {
|
||||
const total = capturedData.length;
|
||||
const methods = {};
|
||||
const cryptos = {};
|
||||
const statusGroups = {};
|
||||
capturedData.forEach(item => {
|
||||
const m = (item.method || 'UNKNOWN').toUpperCase();
|
||||
methods[m] = (methods[m] || 0) + 1;
|
||||
const c = item.crypto || 'unknown';
|
||||
cryptos[c] = (cryptos[c] || 0) + 1;
|
||||
const sg = String(item.statusCode || '?')[0] + 'xx';
|
||||
statusGroups[sg] = (statusGroups[sg] || 0) + 1;
|
||||
});
|
||||
res.json({ total, methods, cryptos, statusGroups });
|
||||
});
|
||||
|
||||
app.get('/api/events', (req, res) => {
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.write(`data: ${JSON.stringify(capturedData)}\n\n`);
|
||||
clients.push(res);
|
||||
req.on('close', () => {
|
||||
clients = clients.filter(client => client !== res);
|
||||
});
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
function broadcastData() {
|
||||
clients.forEach(client => {
|
||||
try {
|
||||
client.write(`data: ${JSON.stringify(capturedData)}\n\n`);
|
||||
} catch (e) {
|
||||
// 连接已断开,移除客户端
|
||||
clients = clients.filter(c => c !== client);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Frontend server running at http://localhost:${PORT}`);
|
||||
});
|
||||
/**
|
||||
* 启动前端服务器
|
||||
* @returns {Promise<import('http').Server>}
|
||||
*/
|
||||
function startClient(port) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const PORT = port || process.env.PORT || 3000;
|
||||
const app = createApp();
|
||||
const server = app.listen(PORT, () => {
|
||||
console.log(`Frontend server running at http://localhost:${PORT}`);
|
||||
resolve(server);
|
||||
});
|
||||
server.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
// 直接运行时启动
|
||||
if (require.main === module) {
|
||||
startClient();
|
||||
}
|
||||
|
||||
module.exports = { startClient, createApp };
|
||||
File diff suppressed because it is too large
Load Diff
218
src/index.js
218
src/index.js
@ -1,203 +1,17 @@
|
||||
// 主入口文件,同时启动 server 和 client
|
||||
// 主入口文件,同时启动 proxy server 和 frontend client
|
||||
// 确保 Ctrl+C 时能够优雅地关闭所有服务
|
||||
|
||||
require('dotenv').config();
|
||||
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
|
||||
// 保存服务器引用以便关闭
|
||||
/** @type {import('http').Server[]} */
|
||||
let servers = [];
|
||||
|
||||
// 代理 server.app.js
|
||||
const startServer = () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const packageJson = require('../package.json');
|
||||
const config = require('./server/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(['-h', '--help'], { action: 'help' })
|
||||
.parse(process.argv);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
if (!process.env.PORT) {
|
||||
process.env.PORT = process.env.PORT || '3000';
|
||||
}
|
||||
|
||||
const { logScope } = require('./server/logger');
|
||||
const escape = require('querystring').escape;
|
||||
const hook = require('./server/hook');
|
||||
const server = require('./server/server');
|
||||
const logger = logScope('app');
|
||||
const target = Array.from(hook.target.host);
|
||||
|
||||
global.port = config.port;
|
||||
global.proxy = null;
|
||||
global.hosts = {};
|
||||
global.endpoint = 'https://music.163.com';
|
||||
|
||||
server.whitelist = [
|
||||
'://[\w.]*music\\.126\\.net',
|
||||
'://[\w.]*vod\\.126\\.net',
|
||||
'://acstatic-dun.126.net',
|
||||
'://[\w.]*\\.netease\\.com',
|
||||
'://[\w.]*\\.163yun\\.com',
|
||||
];
|
||||
|
||||
if (config.endpoint) server.whitelist.push(escape(config.endpoint));
|
||||
|
||||
const dns = (host) =>
|
||||
new Promise((resolve, reject) =>
|
||||
require('dns').lookup(host, { all: true }, (error, records) =>
|
||||
error
|
||||
? reject(error)
|
||||
: resolve(records.map((record) => record.address))
|
||||
)
|
||||
);
|
||||
|
||||
Promise.all(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]) {
|
||||
const httpServer = server.http
|
||||
.listen(port[0], address)
|
||||
.once('listening', () => {
|
||||
log(0);
|
||||
servers.push(httpServer);
|
||||
});
|
||||
}
|
||||
if (port[1]) {
|
||||
const httpsServer = server.https
|
||||
.listen(port[1], address)
|
||||
.once('listening', () => {
|
||||
log(1);
|
||||
servers.push(httpsServer);
|
||||
});
|
||||
}
|
||||
resolve();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// 启动 client
|
||||
const startClient = () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
let capturedData = [];
|
||||
let clients = [];
|
||||
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(express.urlencoded({ limit: '10mb', extended: true }));
|
||||
app.use(express.static(path.join(__dirname, 'client/public')));
|
||||
|
||||
app.post('/api/capture', (req, res) => {
|
||||
const data = req.body;
|
||||
capturedData.push(data);
|
||||
console.log('Captured data:', data.path);
|
||||
|
||||
broadcastData();
|
||||
|
||||
res.status(200).send('OK');
|
||||
});
|
||||
|
||||
app.get('/api/data', (req, res) => {
|
||||
res.json(capturedData);
|
||||
});
|
||||
|
||||
app.get('/api/version', (req, res) => {
|
||||
try {
|
||||
const packageJson = require('../package.json');
|
||||
res.json({ version: packageJson.version });
|
||||
} catch (error) {
|
||||
console.error('Failed to read package.json:', error);
|
||||
res.json({ version: '0.1.0' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/clear', (req, res) => {
|
||||
capturedData = [];
|
||||
broadcastData();
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
app.get('/api/events', (req, res) => {
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
|
||||
res.write(`data: ${JSON.stringify(capturedData)}\n\n`);
|
||||
|
||||
clients.push(res);
|
||||
|
||||
req.on('close', () => {
|
||||
clients = clients.filter(client => client !== res);
|
||||
});
|
||||
});
|
||||
|
||||
function broadcastData() {
|
||||
clients.forEach(client => {
|
||||
try {
|
||||
client.write(`data: ${JSON.stringify(capturedData)}\n\n`);
|
||||
} catch (e) {
|
||||
clients = clients.filter(c => c !== client);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const clientServer = app.listen(PORT, () => {
|
||||
console.log(`Frontend server running at http://localhost:${PORT}`);
|
||||
servers.push(clientServer);
|
||||
resolve();
|
||||
});
|
||||
|
||||
clientServer.on('error', reject);
|
||||
});
|
||||
};
|
||||
|
||||
// 优雅关闭所有服务器
|
||||
/**
|
||||
* 优雅关闭所有服务器
|
||||
*/
|
||||
const gracefulShutdown = async (signal) => {
|
||||
console.log(`\n收到 ${signal} 信号,正在关闭服务器...`);
|
||||
|
||||
// 关闭所有服务器
|
||||
const closePromises = servers.map(server => {
|
||||
return new Promise((resolve) => {
|
||||
server.close(() => {
|
||||
@ -205,9 +19,7 @@ const gracefulShutdown = async (signal) => {
|
||||
resolve();
|
||||
});
|
||||
// 设置超时,强制关闭
|
||||
setTimeout(() => {
|
||||
resolve();
|
||||
}, 5000);
|
||||
setTimeout(() => resolve(), 5000);
|
||||
});
|
||||
});
|
||||
|
||||
@ -223,10 +35,24 @@ process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
||||
// 启动所有服务
|
||||
const startAll = async () => {
|
||||
try {
|
||||
await Promise.all([
|
||||
const { startServer } = require('./server/app');
|
||||
const { startClient } = require('./client/app');
|
||||
|
||||
// 确保环境变量
|
||||
if (!process.env.PORT) process.env.PORT = '3000';
|
||||
|
||||
const [proxyResult, clientServer] = await Promise.all([
|
||||
startServer(),
|
||||
startClient()
|
||||
]);
|
||||
|
||||
// 收集服务器实例以便优雅关闭
|
||||
if (proxyResult) {
|
||||
if (proxyResult.httpServer) servers.push(proxyResult.httpServer);
|
||||
if (proxyResult.httpsServer) servers.push(proxyResult.httpsServer);
|
||||
}
|
||||
servers.push(clientServer);
|
||||
|
||||
console.log('所有服务启动完成!');
|
||||
} catch (error) {
|
||||
console.error('启动服务失败:', error);
|
||||
@ -234,4 +60,4 @@ const startAll = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
startAll();
|
||||
startAll();
|
||||
|
||||
@ -1,92 +1,108 @@
|
||||
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(['-h', '--help'], { action: 'help' })
|
||||
.parse(process.argv);
|
||||
|
||||
require('dotenv').config();
|
||||
/**
|
||||
* 启动代理服务器
|
||||
* @returns {Promise<{httpServer: import('http').Server|null, httpsServer: import('http').Server|null}>}
|
||||
*/
|
||||
function startServer(cliArgs) {
|
||||
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(['-h', '--help'], { action: 'help' })
|
||||
.parse(cliArgs || process.argv);
|
||||
|
||||
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);
|
||||
}
|
||||
require('dotenv').config();
|
||||
|
||||
// 确保 PORT 环境变量可用,供 hook.js 发送数据使用
|
||||
if (!process.env.PORT) {
|
||||
process.env.PORT = process.env.PORT || '3000';
|
||||
}
|
||||
|
||||
const { logScope } = require('./logger');
|
||||
const parse = require('url').parse;
|
||||
const hook = require('./hook');
|
||||
const server = require('./server');
|
||||
const logger = logScope('app');
|
||||
const target = Array.from(hook.target.host);
|
||||
|
||||
global.port = config.port;
|
||||
global.proxy = null;
|
||||
global.hosts = {};
|
||||
global.endpoint = 'https://music.163.com';
|
||||
|
||||
server.whitelist = [
|
||||
'://[\\w.]*music\\.126\\.net',
|
||||
'://[\\w.]*vod\\.126\\.net',
|
||||
'://acstatic-dun.126.net',
|
||||
'://[\\w.]*\\.netease.com',
|
||||
'://[\\w.]*\\.163yun.com',
|
||||
];
|
||||
|
||||
if (config.endpoint) server.whitelist.push(escape(config.endpoint));
|
||||
|
||||
const dns = (host) =>
|
||||
new Promise((resolve, reject) =>
|
||||
require('dns').lookup(host, { all: true }, (error, records) =>
|
||||
error
|
||||
? reject(error)
|
||||
: resolve(records.map((record) => record.address))
|
||||
)
|
||||
);
|
||||
|
||||
Promise.all(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));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
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';
|
||||
}
|
||||
|
||||
const { logScope } = require('./logger');
|
||||
const parse = require('url').parse;
|
||||
const hook = require('./hook');
|
||||
const server = require('./server');
|
||||
const logger = logScope('app');
|
||||
const target = Array.from(hook.target.host);
|
||||
|
||||
global.port = config.port;
|
||||
global.proxy = null;
|
||||
global.hosts = {};
|
||||
global.endpoint = 'https://music.163.com';
|
||||
|
||||
server.whitelist = [
|
||||
'://[\\w.]*music\\.126\\.net',
|
||||
'://[\\w.]*vod\\.126\\.net',
|
||||
'://acstatic-dun.126.net',
|
||||
'://[\\w.]*\\.netease.com',
|
||||
'://[\\w.]*\\.163yun.com',
|
||||
];
|
||||
|
||||
if (config.endpoint) server.whitelist.push(escape(config.endpoint));
|
||||
|
||||
const dns = (host) =>
|
||||
new Promise((resolve, reject) =>
|
||||
require('dns').lookup(host, { all: true }, (error, records) =>
|
||||
error
|
||||
? reject(error)
|
||||
: resolve(records.map((record) => record.address))
|
||||
)
|
||||
);
|
||||
|
||||
return Promise.all(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]}`
|
||||
);
|
||||
let httpServer = null, httpsServer = null;
|
||||
if (port[0])
|
||||
httpServer = server.http
|
||||
.listen(port[0], address)
|
||||
.once('listening', () => log(0));
|
||||
if (port[1])
|
||||
httpsServer = server.https
|
||||
.listen(port[1], address)
|
||||
.once('listening', () => log(1));
|
||||
return { httpServer, httpsServer };
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
// 直接运行时启动
|
||||
if (require.main === module) {
|
||||
startServer();
|
||||
}
|
||||
|
||||
module.exports = { startServer };
|
||||
97
src/server/crypto.test.js
Normal file
97
src/server/crypto.test.js
Normal file
@ -0,0 +1,97 @@
|
||||
const crypto = require('./crypto');
|
||||
|
||||
describe('crypto', () => {
|
||||
describe('eapi encrypt/decrypt', () => {
|
||||
test('should encrypt and decrypt correctly', () => {
|
||||
const original = Buffer.from('Hello, World! This is a test message for eapi.');
|
||||
const encrypted = crypto.eapi.encrypt(original);
|
||||
const decrypted = crypto.eapi.decrypt(encrypted);
|
||||
expect(decrypted.toString()).toBe(original.toString());
|
||||
});
|
||||
|
||||
test('should handle empty buffer', () => {
|
||||
const original = Buffer.from('');
|
||||
const encrypted = crypto.eapi.encrypt(original);
|
||||
const decrypted = crypto.eapi.decrypt(encrypted);
|
||||
expect(decrypted.toString()).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('linuxapi encrypt/decrypt', () => {
|
||||
test('should encrypt and decrypt correctly', () => {
|
||||
const original = Buffer.from('{"method":"POST","url":"https://music.163.com/api/test","params":{"id":1}}');
|
||||
const encrypted = crypto.linuxapi.encrypt(original);
|
||||
const decrypted = crypto.linuxapi.decrypt(encrypted);
|
||||
expect(decrypted.toString()).toBe(original.toString());
|
||||
});
|
||||
});
|
||||
|
||||
describe('base64', () => {
|
||||
test('should encode and decode correctly', () => {
|
||||
const original = 'Hello World';
|
||||
const encoded = crypto.base64.encode(original);
|
||||
const decoded = crypto.base64.decode(encoded);
|
||||
expect(decoded).toBe(original);
|
||||
});
|
||||
|
||||
test('should handle URL-safe characters', () => {
|
||||
const original = 'test+data/with=special?chars';
|
||||
const encoded = crypto.base64.encode(original);
|
||||
expect(encoded).not.toContain('+');
|
||||
expect(encoded).not.toContain('/');
|
||||
const decoded = crypto.base64.decode(encoded);
|
||||
expect(decoded).toBe(original);
|
||||
});
|
||||
});
|
||||
|
||||
describe('md5', () => {
|
||||
test('should produce consistent hash', () => {
|
||||
const hash1 = crypto.md5.digest('test');
|
||||
const hash2 = crypto.md5.digest('test');
|
||||
expect(hash1).toBe(hash2);
|
||||
expect(hash1).toHaveLength(32);
|
||||
});
|
||||
|
||||
test('should produce different hash for different inputs', () => {
|
||||
const hash1 = crypto.md5.digest('hello');
|
||||
const hash2 = crypto.md5.digest('world');
|
||||
expect(hash1).not.toBe(hash2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('random', () => {
|
||||
test('hex should produce correct length', () => {
|
||||
expect(crypto.random.hex(16)).toHaveLength(16);
|
||||
expect(crypto.random.hex(32)).toHaveLength(32);
|
||||
});
|
||||
|
||||
test('uuid should produce valid format', () => {
|
||||
const uuid = crypto.random.uuid();
|
||||
expect(uuid).toMatch(/^[0-9a-f-]{36}$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('eapi.encryptRequest', () => {
|
||||
test('should produce valid output', () => {
|
||||
const result = crypto.eapi.encryptRequest(
|
||||
'https://music.163.com/eapi/song/enhance/player/url',
|
||||
{ id: '123', br: 320000 }
|
||||
);
|
||||
expect(result).toHaveProperty('url');
|
||||
expect(result).toHaveProperty('body');
|
||||
expect(result.body).toContain('params=');
|
||||
});
|
||||
});
|
||||
|
||||
describe('linuxapi.encryptRequest', () => {
|
||||
test('should produce valid output', () => {
|
||||
const result = crypto.linuxapi.encryptRequest(
|
||||
'https://music.163.com/api/song/enhance/player/url',
|
||||
{ id: '123' }
|
||||
);
|
||||
expect(result).toHaveProperty('url');
|
||||
expect(result).toHaveProperty('body');
|
||||
expect(result.url).toContain('/api/linux/forward');
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -98,8 +98,35 @@ const domainList = [
|
||||
'interface3.music.163.com',
|
||||
];
|
||||
|
||||
/**
|
||||
* 判断是否为网易云相关域名
|
||||
*/
|
||||
function isNeteaseHost(hostname) {
|
||||
if (!hostname) return false;
|
||||
const neteasePatterns = [
|
||||
'music.163.com', 'music.126.net', 'vod.126.net',
|
||||
'iplay.163.com', 'look.163.com', 'y.163.com',
|
||||
'interface.music.163.com', '163yun.com',
|
||||
'163jiasu.com', 'netease.com',
|
||||
];
|
||||
return neteasePatterns.some(p => hostname.includes(p));
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否启用完整抓包模式 (非网易云流量也捕获)
|
||||
*/
|
||||
function isFullCapture() {
|
||||
return global.fullCapture === true;
|
||||
}
|
||||
|
||||
hook.request.before = (ctx) => {
|
||||
const { req } = ctx;
|
||||
// 记录请求开始时间和请求头
|
||||
ctx.startTime = Date.now();
|
||||
ctx.requestHeaders = { ...req.headers };
|
||||
// 标记是否网易云
|
||||
ctx.isNeteaseDomain = isNeteaseHost(req.headers.host);
|
||||
|
||||
req.url =
|
||||
(req.url.startsWith('http://')
|
||||
? ''
|
||||
@ -111,12 +138,8 @@ hook.request.before = (ctx) => {
|
||||
? 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';
|
||||
// 所有请求都走代理 (不再局限网易云)
|
||||
ctx.decision = 'proxy';
|
||||
|
||||
if (process.env.NETEASE_COOKIE && url.path.includes('url')) {
|
||||
var cookies = cookieToMap(req.headers.cookie);
|
||||
@ -321,7 +344,7 @@ hook.request.before = (ctx) => {
|
||||
}
|
||||
}
|
||||
ctx.netease = netease;
|
||||
console.log(netease.path, netease.param) // 这里输出了网易云音乐的抓包数据, 重点看这里
|
||||
logger.info({ path: netease.path, params: netease.param }, 'Captured request')
|
||||
}
|
||||
})
|
||||
.catch(
|
||||
@ -369,6 +392,12 @@ hook.request.after = (ctx) => {
|
||||
const { req, proxyRes, netease, package: pkg } = ctx;
|
||||
|
||||
if (netease) {
|
||||
// 计算请求耗时
|
||||
const duration = ctx.startTime ? Date.now() - ctx.startTime : 0;
|
||||
// 捕获响应头
|
||||
const responseHeaders = proxyRes ? { ...proxyRes.headers } : {};
|
||||
delete responseHeaders['transfer-encoding'];
|
||||
|
||||
return request
|
||||
.read(proxyRes, true)
|
||||
.then((buffer) => {
|
||||
@ -416,7 +445,10 @@ hook.request.after = (ctx) => {
|
||||
param: netease.param,
|
||||
response: netease.jsonBody,
|
||||
statusCode: proxyRes.statusCode,
|
||||
method: req.method
|
||||
method: req.method,
|
||||
duration,
|
||||
requestHeaders: ctx.requestHeaders,
|
||||
responseHeaders,
|
||||
};
|
||||
axios.post(`http://localhost:${process.env.PORT || 3000}/api/capture`, dataToSend)
|
||||
.catch(err => logger.error('Failed to send data to frontend:', err));
|
||||
@ -430,9 +462,12 @@ hook.request.after = (ctx) => {
|
||||
crypto: netease.crypto || null,
|
||||
param: netease.param,
|
||||
response: null,
|
||||
statusCode: proxyRes.statusCode,
|
||||
statusCode: proxyRes ? proxyRes.statusCode : null,
|
||||
error: error.message,
|
||||
method: req.method
|
||||
method: req.method,
|
||||
duration,
|
||||
requestHeaders: ctx.requestHeaders,
|
||||
responseHeaders,
|
||||
};
|
||||
axios.post(`http://localhost:${process.env.PORT || 3000}/api/capture`, dataToSend)
|
||||
.catch(err => logger.error('Failed to send data to frontend:', err));
|
||||
@ -455,16 +490,69 @@ hook.request.after = (ctx) => {
|
||||
proxyRes.headers['content-type'] = 'audio/*';
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 通用抓包: 捕获所有请求 (非网易云也抓) ==========
|
||||
// 只在全抓包模式或网易云域名下捕获
|
||||
if (!netease && !pkg && (isFullCapture() || ctx.isNeteaseDomain)) {
|
||||
const duration = ctx.startTime ? Date.now() - ctx.startTime : 0;
|
||||
const responseHeaders = proxyRes ? { ...proxyRes.headers } : {};
|
||||
delete responseHeaders['transfer-encoding'];
|
||||
const reqUrl = req.url || '';
|
||||
const contentType = (proxyRes && proxyRes.headers['content-type']) || '';
|
||||
|
||||
// 基本数据 (所有请求都有)
|
||||
const dataToSend = {
|
||||
timestamp: new Date().toISOString(),
|
||||
path: reqUrl,
|
||||
method: req.method || 'GET',
|
||||
statusCode: proxyRes ? proxyRes.statusCode : null,
|
||||
duration,
|
||||
requestHeaders: ctx.requestHeaders,
|
||||
responseHeaders,
|
||||
isNetease: ctx.isNeteaseDomain || false,
|
||||
hostname: parse(reqUrl).hostname || req.headers.host || '',
|
||||
};
|
||||
|
||||
// 尝试读取响应体 (仅对文本类响应,且大小限制 512KB)
|
||||
const isTextResponse = contentType.includes('json') || contentType.includes('text') || contentType.includes('javascript') || contentType.includes('xml');
|
||||
const contentLength = parseInt(proxyRes && proxyRes.headers['content-length'] || '0', 10);
|
||||
|
||||
if (proxyRes && isTextResponse && contentLength < 512 * 1024) {
|
||||
return request.read(proxyRes, true)
|
||||
.then((buffer) => {
|
||||
if (buffer && buffer.length > 0 && buffer.length < 512 * 1024) {
|
||||
const bodyStr = buffer.toString();
|
||||
try {
|
||||
dataToSend.response = JSON.parse(bodyStr);
|
||||
} catch {
|
||||
dataToSend.responseBody = bodyStr.slice(0, 10000); // 限制长度
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
.then(() => {
|
||||
axios.post(`http://localhost:${process.env.PORT || 3000}/api/capture`, dataToSend)
|
||||
.catch(err => logger.error('Failed to send capture data:', err.message));
|
||||
});
|
||||
} else {
|
||||
// 没有响应体或非文本,直接发送基础信息
|
||||
axios.post(`http://localhost:${process.env.PORT || 3000}/api/capture`, dataToSend)
|
||||
.catch(err => logger.error('Failed to send capture data:', err.message));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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)
|
||||
)
|
||||
) {
|
||||
const hostname = url.hostname || '';
|
||||
|
||||
// 网易云域名: 走本地 MITM 代理 (原有逻辑)
|
||||
const isNetease = [url.hostname, req.headers.host].some((host) =>
|
||||
hook.target.host.has(host)
|
||||
);
|
||||
|
||||
if (isNetease) {
|
||||
if (parseInt(url.port) === 80) {
|
||||
req.url = `${global.address || 'localhost'}:${global.port[0]}`;
|
||||
req.local = true;
|
||||
@ -474,7 +562,18 @@ hook.connect.before = (ctx) => {
|
||||
} else {
|
||||
ctx.decision = 'blank';
|
||||
}
|
||||
} else if (url.href.includes(global.endpoint)) ctx.decision = 'proxy';
|
||||
} else if (url.href.includes(global.endpoint)) {
|
||||
ctx.decision = 'proxy';
|
||||
} else if (isFullCapture()) {
|
||||
// 完整抓包模式: 非网易云域名也走本地 MITM 代理
|
||||
// 这样就能捕获所有 HTTPS 流量
|
||||
if (global.port[1]) {
|
||||
req.url = `${global.address || 'localhost'}:${global.port[1]}`;
|
||||
req.local = true;
|
||||
} else {
|
||||
ctx.decision = 'blank';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
hook.negotiate.before = (ctx) => {
|
||||
@ -482,6 +581,12 @@ hook.negotiate.before = (ctx) => {
|
||||
const url = parse('https://' + req.url);
|
||||
const target = hook.target.host;
|
||||
if (req.local || decision) return;
|
||||
// 完整抓包: 非网易云域名直接 MITM (sni 域名自动加入 target set)
|
||||
if (isFullCapture() && socket.sni && !target.has(socket.sni)) {
|
||||
target.add(socket.sni);
|
||||
ctx.decision = 'blank';
|
||||
return;
|
||||
}
|
||||
if (target.has(socket.sni) && !target.has(url.hostname)) {
|
||||
target.add(url.hostname);
|
||||
ctx.decision = 'blank';
|
||||
|
||||
@ -1,83 +0,0 @@
|
||||
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);
|
||||
});
|
||||
55
src/server/utilities.test.js
Normal file
55
src/server/utilities.test.js
Normal file
@ -0,0 +1,55 @@
|
||||
const { isHost, cookieToMap, mapToCookie } = require('./utilities');
|
||||
|
||||
describe('utilities', () => {
|
||||
describe('isHost', () => {
|
||||
test('should match host in URL', () => {
|
||||
expect(isHost('https://music.163.com/api/playlist', 'music.163.com')).toBe(true);
|
||||
});
|
||||
|
||||
test('should not match different host', () => {
|
||||
expect(isHost('https://example.com/api', 'music.163.com')).toBe(false);
|
||||
});
|
||||
|
||||
test('should match host in subdomain', () => {
|
||||
expect(isHost('https://interface.music.163.com/api', 'music.163.com')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cookieToMap', () => {
|
||||
test('should parse cookie string to map', () => {
|
||||
const result = cookieToMap('key1=value1; key2=value2');
|
||||
expect(result).toEqual({ key1: 'value1', key2: 'value2' });
|
||||
});
|
||||
|
||||
test('should handle empty string', () => {
|
||||
const result = cookieToMap('');
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
test('should handle cookies without space around =', () => {
|
||||
const result = cookieToMap('key1=value1; key2=value2');
|
||||
expect(result).toEqual({ key1: 'value1', key2: 'value2' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapToCookie', () => {
|
||||
test('should convert map to cookie string', () => {
|
||||
const result = mapToCookie({ key1: 'value1', key2: 'value2' });
|
||||
expect(result).toBe('key1=value1; key2=value2');
|
||||
});
|
||||
|
||||
test('should handle empty map', () => {
|
||||
const result = mapToCookie({});
|
||||
expect(result).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cookieToMap <-> mapToCookie round trip', () => {
|
||||
test('should be reversible', () => {
|
||||
const original = { a: '1', b: '2', c: '3' };
|
||||
const cookieStr = mapToCookie(original);
|
||||
const parsed = cookieToMap(cookieStr);
|
||||
expect(parsed).toEqual(original);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user