├── Dockerfile ├── README.md ├── app.js ├── install.sh ├── package.json ├── public ├── change-password.html ├── index.css ├── index.html └── script.js └── workers ├── worker_sub.js └── workers_sub_beta.js /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:lts-alpine 2 | 3 | WORKDIR /app 4 | 5 | COPY . . 6 | 7 | EXPOSE 3000 8 | 9 | RUN apk update && apk add --no-cache openssl curl wget &&\ 10 | rm -rf workers install.sh &&\ 11 | chmod +x app.js &&\ 12 | npm install 13 | 14 | CMD ["node", "app.js"] 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Merge-sub 2 | 将多个子订阅链接和单节点合并成一个订阅链接,可自定义优选域名或优选ip 3 | * 在线体验地址:https://merge.serv00.net 用户名和密码均为admin 演示站不要尝试改密码,已禁用 4 | 5 | * 默认订阅:http://ip:端口/随机token 或 https://你的用户名.serv00.net/随机token 6 | * 带优选ip订阅:http://ip:端口/随机token?CFIP=优选ip&CFPORT=优选ip端口 7 | * 例如:http://192.168.1.1:10000/sub?CFIP=47.75.222.188&CFPORT=7890 8 | * 默认用户名和密码都为admin,请及时更改 9 | 10 | ## 1: Serv00|ct8|hostuno一键部署命令 11 | * 默认用户名和密码都为admin,请及时更改 12 | ```bash 13 | bash <(curl -Ls https://raw.githubusercontent.com/eooce/Merge-sub/main/install.sh) 14 | ``` 15 | 16 | ## 2: vps一键部署, 17 | * 需nodejs环境 18 | ```bash 19 | apt update -y 20 | curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash - && install nodejs 21 | apt get-install git screen -y 22 | git clone https://github.com/eooce/Merge-sub.git 23 | cd Merge-sub && rm -rf workers Dockerfile README.md install.sh 24 | npm install 25 | screen npm start 26 | ``` 27 | 28 | ## 3: Docker镜像一键部署,容器平台等 29 | 30 | 环境变量(可选):`PORT` `USERNAME` `PASSWORD` `SUB_TOKEN` 31 | 32 | ``` 33 | ghcr.io/eooce/merge-sub:latest 34 | ``` 35 | 36 | docker-compose.yaml 37 | ```bash 38 | version: '3' 39 | 40 | services: 41 | merge-sub: 42 | image: ghcr.io/eooce/merge-sub:latest 43 | ports: 44 | - "3000:3000" 45 | volumes: 46 | - merge-sub-data:/app/data 47 | environment: 48 | - DATA_DIR=/app/data 49 | - PORT=3000 50 | - USERNAME=admin # 管理账号 51 | - PASSWORD=admin # 管理密码 52 | restart: unless-stopped 53 | 54 | volumes: 55 | merge-sub-data: 56 | ``` 57 | 58 | 59 | # 免责声明 60 | * 本程序仅供学习了解, 非盈利目的,请于下载后 24 小时内删除, 不得用作任何商业用途, 文字、数据及图片均有所属版权, 如转载须注明来源。 61 | * 使用本程序必循遵守部署免责声明,使用本程序必循遵守部署服务器所在地、所在国家和用户所在国家的法律法规, 程序作者不对使用者任何不当行为负责。 62 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const bodyParser = require('body-parser'); 3 | const axios = require('axios'); 4 | const fs = require('fs').promises; 5 | const path = require('path'); 6 | const app = express(); 7 | const crypto = require('crypto'); 8 | const basicAuth = require('basic-auth'); 9 | const { execSync } = require('child_process'); 10 | 11 | const USERNAME = process.env.USERNAME || 'admin'; 12 | const PASSWORD = process.env.PASSWORD || 'admin'; 13 | const PORT = process.env.SERVER_PORT || process.env.PORT || 3000; 14 | const SUB_TOKEN = process.env.SUB_TOKEN || generateRandomString(); 15 | 16 | let CFIP = process.env.CFIP || "time.is"; 17 | let CFPORT = process.env.CFPORT || "443"; 18 | let subscriptions = []; 19 | let nodes = ''; 20 | 21 | const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'); 22 | const DATA_FILE = path.join(DATA_DIR, 'data.json'); 23 | const CREDENTIALS_FILE = path.join(DATA_DIR, 'credentials.json'); 24 | 25 | // 检查数据目录 26 | async function ensureDataDir() { 27 | try { 28 | await fs.access(DATA_DIR); 29 | } catch { 30 | await fs.mkdir(DATA_DIR, { recursive: true }); 31 | } 32 | } 33 | 34 | // 初始化数据 35 | const initialData = { 36 | subscriptions: [], 37 | nodes: '' 38 | }; 39 | 40 | // 初始化凭证变量 41 | let credentials = { 42 | username: USERNAME, 43 | password: PASSWORD 44 | }; 45 | 46 | // 身份验证中间件 47 | const auth = async (req, res, next) => { 48 | const user = basicAuth(req); 49 | if (!user || user.name !== credentials.username || user.pass !== credentials.password) { 50 | res.set('WWW-Authenticate', 'Basic realm="Node"'); 51 | return res.status(401).send('认证失败'); 52 | } 53 | next(); 54 | }; 55 | 56 | // 静态文件服务 57 | app.use(express.static(path.join(__dirname, 'public'))); 58 | app.use(express.json()); 59 | 60 | // 中间件 61 | app.use(bodyParser.json()); 62 | app.use(bodyParser.urlencoded({ extended: true })); 63 | 64 | // 获取 SUB_TOKEN 的路由 65 | app.get('/get-sub-token', auth, (req, res) => { 66 | res.json({ token: SUB_TOKEN }); 67 | }); 68 | 69 | // 生成随机16位字符的函数 70 | function generateRandomString() { 71 | const user = getSystemUsername(); 72 | const hash = crypto.createHash('md5').update(user).digest('hex'); 73 | return hash.slice(0, 20); // 截取前20位 74 | } 75 | 76 | // 获取系统用户名 77 | function getSystemUsername() { 78 | try { 79 | return execSync('whoami').toString().trim().toLowerCase(); 80 | } catch (error) { 81 | console.error('Error getting system username:', error); 82 | return 'admin'; 83 | } 84 | } 85 | 86 | // 初始化凭证文件 87 | async function initializeCredentialsFile() { 88 | try { 89 | // 检查文件是否存在 90 | try { 91 | await fs.access(CREDENTIALS_FILE); 92 | console.log('Credentials file already exists'); 93 | return true; 94 | } catch { 95 | // 文件不存在,创建新文件 96 | const initialCredentials = { 97 | username: USERNAME, 98 | password: PASSWORD 99 | }; 100 | 101 | await fs.writeFile( 102 | CREDENTIALS_FILE, 103 | JSON.stringify(initialCredentials, null, 2), 104 | 'utf8' 105 | ); 106 | console.log('Created new credentials file with environment variables or default admin credentials'); 107 | return true; 108 | } 109 | } catch (error) { 110 | console.error('Error initializing credentials file:', error); 111 | return false; 112 | } 113 | } 114 | 115 | // 加载凭证 116 | async function loadCredentials() { 117 | try { 118 | await initializeCredentialsFile(); 119 | 120 | const data = await fs.readFile(CREDENTIALS_FILE, 'utf8'); 121 | return JSON.parse(data); 122 | } catch (error) { 123 | console.error('Error loading credentials:', error); 124 | return { 125 | username: USERNAME, 126 | password: PASSWORD 127 | }; 128 | } 129 | } 130 | 131 | // 保存凭证 132 | async function saveCredentials(newCredentials) { 133 | try { 134 | await fs.writeFile( 135 | CREDENTIALS_FILE, 136 | JSON.stringify(newCredentials, null, 2), 137 | 'utf8' 138 | ); 139 | return true; 140 | } catch (error) { 141 | console.error('Error saving credentials:', error); 142 | return false; 143 | } 144 | } 145 | 146 | 147 | // 凭证更新路由 148 | app.post('/admin/update-credentials', auth, async (req, res) => { 149 | try { 150 | console.log('Received update request:', req.body); 151 | 152 | const { username, password, currentPassword } = req.body; 153 | 154 | // 验证请求数据是否存在 155 | if (!req.body || typeof req.body !== 'object') { 156 | return res.status(400).json({ error: '无效的请求数据' }); 157 | } 158 | 159 | // 验证所有必需字段 160 | if (!username || !password || !currentPassword) { 161 | return res.status(400).json({ error: '所有字段都必须填写' }); 162 | } 163 | 164 | // 验证当前密码 165 | const currentCredentials = await loadCredentials(); 166 | if (currentPassword !== currentCredentials.password) { 167 | console.log('Current password verification failed'); 168 | return res.status(400).json({ error: '当前密码错误' }); 169 | } 170 | 171 | const newCredentials = { 172 | username: username, 173 | password: password 174 | }; 175 | 176 | const saved = await saveCredentials(newCredentials); 177 | if (!saved) { 178 | return res.status(500).json({ error: '保存密码失败' }); 179 | } 180 | 181 | credentials = newCredentials; 182 | 183 | console.log('Credentials updated successfully'); 184 | res.json({ message: '密码修改成功' }); 185 | } catch (error) { 186 | console.error('Error updating credentials:', error); 187 | res.status(500).json({ error: '修改失败: ' + error.message }); 188 | } 189 | }); 190 | 191 | // 管理页面和管理验证 192 | app.use(['/admin', '/'], (req, res, next) => { 193 | // 排除订阅链接路径 194 | if (req.path === `/${SUB_TOKEN}`) { 195 | return next(); 196 | } 197 | // 排除首页 198 | if (req.path === '/' && req.method === 'GET') { 199 | return next(); 200 | } 201 | // 排除 API 路径 202 | if (req.path.startsWith('/api/')) { 203 | return next(); 204 | } 205 | // 其他路径应用验证 206 | return auth(req, res, next); 207 | }); 208 | 209 | 210 | // 初始化数据文件 211 | async function initializeDataFile() { 212 | try { 213 | let data; 214 | try { 215 | // 尝试读取现有文件 216 | data = await fs.readFile(DATA_FILE, 'utf8'); 217 | console.log('Existing data file found'); 218 | } catch { 219 | // 文件不存在,创建新文件 220 | console.log('Creating new data file with initial data...'); 221 | data = JSON.stringify(initialData, null, 2); 222 | await fs.writeFile(DATA_FILE, data); 223 | console.log('Data file created successfully'); 224 | } 225 | 226 | const parsedData = JSON.parse(data); 227 | subscriptions = parsedData.subscriptions || []; 228 | nodes = parsedData.nodes || ''; 229 | console.log('Data loaded into memory:', { subscriptions, nodes }); 230 | } catch (error) { 231 | console.error('Error during initialization:', error); 232 | // 如果出错,使用初始数据 233 | subscriptions = initialData.subscriptions; 234 | nodes = initialData.nodes; 235 | } 236 | } 237 | 238 | // 读取数据 239 | async function loadData() { 240 | try { 241 | const data = await fs.readFile(DATA_FILE, 'utf8'); 242 | const parsedData = JSON.parse(data); 243 | 244 | subscriptions = Array.isArray(parsedData.subscriptions) 245 | ? parsedData.subscriptions 246 | : []; 247 | 248 | nodes = typeof parsedData.nodes === 'string' 249 | ? parsedData.nodes 250 | : ''; 251 | 252 | console.log('Data loaded successfully:', { subscriptions, nodes }); 253 | } catch (error) { 254 | console.error('Error loading data:', error); 255 | // 如果出错,初始化为空数据 256 | subscriptions = []; 257 | nodes = ''; 258 | } 259 | } 260 | 261 | // 添加订阅路由 262 | app.post('/admin/add-subscription', async (req, res) => { 263 | try { 264 | const newSubscriptionInput = req.body.subscription?.trim(); 265 | console.log('Attempting to add subscription(s):', newSubscriptionInput); 266 | 267 | if (!newSubscriptionInput) { 268 | return res.status(400).json({ error: 'Subscription URL is required' }); 269 | } 270 | 271 | if (!Array.isArray(subscriptions)) { 272 | subscriptions = []; 273 | } 274 | 275 | // 分割多行输入 276 | const newSubscriptions = newSubscriptionInput.split('\n') 277 | .map(sub => sub.trim()) 278 | .filter(sub => sub); // 过滤空行 279 | 280 | // 检查每个订阅是否已存在 281 | const addedSubs = []; 282 | const existingSubs = []; 283 | 284 | for (const sub of newSubscriptions) { 285 | if (subscriptions.some(existingSub => existingSub.trim() === sub)) { 286 | existingSubs.push(sub); 287 | } else { 288 | addedSubs.push(sub); 289 | subscriptions.push(sub); 290 | } 291 | } 292 | 293 | if (addedSubs.length > 0) { 294 | await saveData(subscriptions, nodes); 295 | console.log('Subscriptions added successfully. Current subscriptions:', subscriptions); 296 | 297 | const message = addedSubs.length === newSubscriptions.length 298 | ? '订阅添加成功' 299 | : `成功添加 ${addedSubs.length} 个订阅,${existingSubs.length} 个订阅已存在`; 300 | 301 | res.status(200).json({ message }); 302 | } else { 303 | res.status(400).json({ error: '所有订阅已存在' }); 304 | } 305 | } catch (error) { 306 | console.error('Error adding subscription:', error); 307 | res.status(500).json({ error: 'Failed to add subscription' }); 308 | } 309 | }); 310 | 311 | // 检查并解码 base64 312 | function tryDecodeBase64(str) { 313 | const base64Regex = /^[A-Za-z0-9+/=]+$/; 314 | try { 315 | if (base64Regex.test(str)) { 316 | const decoded = Buffer.from(str, 'base64').toString('utf-8'); 317 | if (decoded.startsWith('vmess://') || 318 | decoded.startsWith('vless://') || 319 | decoded.startsWith('trojan://') || 320 | decoded.startsWith('ss://') || 321 | decoded.startsWith('ssr://') || 322 | decoded.startsWith('snell://') || 323 | decoded.startsWith('juicity://') || 324 | decoded.startsWith('hysteria://') || 325 | decoded.startsWith('hysteria2://') || 326 | decoded.startsWith('tuic://') || 327 | decoded.startsWith('anytls://') || 328 | decoded.startsWith('wireguard://') || 329 | decoded.startsWith('socks5://') || 330 | decoded.startsWith('http://') || 331 | decoded.startsWith('https://')) { 332 | return decoded; 333 | } 334 | } 335 | // 如果不是 base64 或解码后不是有效节点,返回原始字符串 336 | return str; 337 | } catch (error) { 338 | console.log('Not a valid base64 string, using original input'); 339 | return str; 340 | } 341 | } 342 | 343 | // 添加节点路由 344 | app.post('/admin/add-node', async (req, res) => { 345 | try { 346 | const newNode = req.body.node?.trim(); 347 | console.log('Attempting to add node:', newNode); 348 | 349 | if (!newNode) { 350 | return res.status(400).json({ error: 'Node is required' }); 351 | } 352 | 353 | let nodesList = typeof nodes === 'string' 354 | ? nodes.split('\n').map(n => n.trim()).filter(n => n) 355 | : []; 356 | 357 | const newNodes = newNode.split('\n') 358 | .map(n => n.trim()) 359 | .filter(n => n) 360 | .map(n => tryDecodeBase64(n)); 361 | 362 | const addedNodes = []; 363 | const existingNodes = []; 364 | 365 | // 处理每个新节点 366 | for (const node of newNodes) { 367 | if (nodesList.some(existingNode => existingNode === node)) { 368 | existingNodes.push(node); 369 | } else { 370 | addedNodes.push(node); 371 | nodesList.push(node); 372 | } 373 | } 374 | 375 | if (addedNodes.length > 0) { 376 | nodes = nodesList.join('\n'); 377 | await saveData(subscriptions, nodes); 378 | console.log('Node(s) added successfully'); 379 | 380 | const message = addedNodes.length === newNodes.length 381 | ? '节点添加成功' 382 | : `成功添加 ${addedNodes.length} 个节点,${existingNodes.length} 个节点已存在`; 383 | 384 | res.status(200).json({ message }); 385 | } else { 386 | res.status(400).json({ error: '所有节点已存在' }); 387 | } 388 | } catch (error) { 389 | console.error('Error adding node:', error); 390 | res.status(500).json({ error: 'Failed to add node' }); 391 | } 392 | }); 393 | 394 | // 移除特殊字符 395 | function cleanNodeString(str) { 396 | return str 397 | .replace(/^["'`]+|["'`]+$/g, '') // 移除首尾的引号 398 | .replace(/,+$/g, '') // 移除末尾的逗号 399 | .replace(/\s+/g, '') // 移除所有空白字符 400 | .trim(); 401 | } 402 | 403 | // 删除订阅路由 404 | app.post('/admin/delete-subscription', async (req, res) => { 405 | try { 406 | const subsToDelete = req.body.subscription?.trim(); 407 | console.log('Attempting to delete subscription(s):', subsToDelete); 408 | 409 | if (!subsToDelete) { 410 | return res.status(400).json({ error: 'Subscription URL is required' }); 411 | } 412 | 413 | if (!Array.isArray(subscriptions)) { 414 | subscriptions = []; 415 | return res.status(404).json({ error: 'No subscriptions found' }); 416 | } 417 | 418 | // 分割多行输入 419 | const deleteList = subsToDelete.split('\n') 420 | .map(sub => sub.trim()) 421 | .filter(sub => sub); 422 | 423 | // 记录删除结果 424 | const deletedSubs = []; 425 | const notFoundSubs = []; 426 | 427 | // 处理每个要删除的订阅 428 | deleteList.forEach(subToDelete => { 429 | const index = subscriptions.findIndex(sub => 430 | sub.trim() === subToDelete.trim() 431 | ); 432 | if (index !== -1) { 433 | deletedSubs.push(subToDelete); 434 | subscriptions.splice(index, 1); 435 | } else { 436 | notFoundSubs.push(subToDelete); 437 | } 438 | }); 439 | 440 | if (deletedSubs.length > 0) { 441 | await saveData(subscriptions, nodes); 442 | console.log('Subscriptions deleted. Remaining subscriptions:', subscriptions); 443 | 444 | const message = deletedSubs.length === deleteList.length 445 | ? '订阅删除成功' 446 | : `成功删除 ${deletedSubs.length} 个订阅,${notFoundSubs.length} 个订阅不存在`; 447 | 448 | res.status(200).json({ message }); 449 | } else { 450 | res.status(404).json({ error: '未找到要删除的订阅' }); 451 | } 452 | } catch (error) { 453 | console.error('Error deleting subscription:', error); 454 | res.status(500).json({ error: 'Failed to delete subscription' }); 455 | } 456 | }); 457 | 458 | // 删除节点路由 459 | app.post('/admin/delete-node', async (req, res) => { 460 | try { 461 | const nodesToDelete = req.body.node?.trim(); 462 | console.log('Attempting to delete node(s):', nodesToDelete); 463 | 464 | if (!nodesToDelete) { 465 | return res.status(400).json({ error: 'Node is required' }); 466 | } 467 | 468 | // 分割多行输入并清理每个节点字符串 469 | const deleteList = nodesToDelete.split('\n') 470 | .map(node => cleanNodeString(node)) 471 | .filter(node => node); // 过滤空行 472 | 473 | // 获取当前节点列表并清理 474 | let nodesList = nodes.split('\n') 475 | .map(node => cleanNodeString(node)) 476 | .filter(node => node); 477 | 478 | // 记录删除结果 479 | const deletedNodes = []; 480 | const notFoundNodes = []; 481 | 482 | // 处理每个要删除的节点 483 | deleteList.forEach(nodeToDelete => { 484 | const index = nodesList.findIndex(node => { 485 | // 使用清理后的字符串进行比较 486 | return cleanNodeString(node) === cleanNodeString(nodeToDelete); 487 | }); 488 | 489 | if (index !== -1) { 490 | deletedNodes.push(nodeToDelete); 491 | nodesList.splice(index, 1); 492 | } else { 493 | notFoundNodes.push(nodeToDelete); 494 | } 495 | }); 496 | 497 | if (deletedNodes.length > 0) { 498 | // 更新节点列表 499 | nodes = nodesList.join('\n'); 500 | await saveData(subscriptions, nodes); 501 | console.log('Nodes deleted. Remaining nodes:', nodes); 502 | 503 | const message = deletedNodes.length === deleteList.length 504 | ? '节点删除成功' 505 | : `成功删除 ${deletedNodes.length} 个节点,${notFoundNodes.length} 个节点不存在`; 506 | 507 | res.status(200).json({ message }); 508 | } else { 509 | res.status(404).json({ error: '未找到要删除的节点' }); 510 | } 511 | } catch (error) { 512 | console.error('Error deleting node:', error); 513 | res.status(500).json({ error: 'Failed to delete node' }); 514 | } 515 | }); 516 | 517 | // API路由 - 添加订阅(无需验证) 518 | app.post('/api/add-subscriptions', async (req, res) => { 519 | try { 520 | const newSubscriptions = req.body.subscription; 521 | console.log('API - Attempting to add subscription(s):', newSubscriptions); 522 | 523 | if (!newSubscriptions) { 524 | return res.status(400).json({ error: 'Subscription URL is required' }); 525 | } 526 | 527 | if (!Array.isArray(subscriptions)) { 528 | subscriptions = []; 529 | } 530 | 531 | // 处理输入数据 532 | const processedSubs = Array.isArray(newSubscriptions) 533 | ? newSubscriptions.map(sub => sub.trim()).filter(sub => sub) 534 | : [newSubscriptions.trim()].filter(sub => sub); 535 | 536 | // 检查每个订阅是否已存在 537 | const addedSubs = []; 538 | const existingSubs = []; 539 | 540 | for (const sub of processedSubs) { 541 | if (subscriptions.some(existingSub => existingSub.trim() === sub)) { 542 | existingSubs.push(sub); 543 | } else { 544 | addedSubs.push(sub); 545 | subscriptions.push(sub); 546 | } 547 | } 548 | 549 | if (addedSubs.length > 0) { 550 | await saveData(subscriptions, nodes); 551 | res.status(200).json({ 552 | success: true, 553 | added: addedSubs, 554 | existing: existingSubs 555 | }); 556 | } else { 557 | res.status(400).json({ 558 | success: false, 559 | error: 'All subscriptions already exist' 560 | }); 561 | } 562 | } catch (error) { 563 | console.error('API Error adding subscription:', error); 564 | res.status(500).json({ error: 'Failed to add subscription' }); 565 | } 566 | }); 567 | 568 | // API路由 - 添加节点(无需验证) 569 | app.post('/api/add-nodes', async (req, res) => { 570 | try { 571 | const newNodes = req.body.nodes; 572 | 573 | if (!newNodes) { 574 | return res.status(400).json({ error: 'Nodes are required' }); 575 | } 576 | 577 | let nodesList = typeof nodes === 'string' 578 | ? nodes.split('\n').map(n => n.trim()).filter(n => n) 579 | : []; 580 | 581 | const processedNodes = Array.isArray(newNodes) 582 | ? newNodes 583 | : newNodes.split('\n'); 584 | 585 | const nodesToAdd = processedNodes 586 | .map(n => n.trim()) 587 | .filter(n => n) 588 | .map(n => tryDecodeBase64(n)); 589 | 590 | const addedNodes = []; 591 | const existingNodes = []; 592 | 593 | for (const node of nodesToAdd) { 594 | if (nodesList.some(existingNode => existingNode === node)) { 595 | existingNodes.push(node); 596 | } else { 597 | addedNodes.push(node); 598 | nodesList.push(node); 599 | } 600 | } 601 | 602 | if (addedNodes.length > 0) { 603 | nodes = nodesList.join('\n'); 604 | await saveData(subscriptions, nodes); 605 | res.status(200).json({ 606 | success: true, 607 | added: addedNodes, 608 | existing: existingNodes 609 | }); 610 | } else { 611 | res.status(400).json({ 612 | success: false, 613 | error: 'All nodes already exist' 614 | }); 615 | } 616 | } catch (error) { 617 | console.error('API Error adding nodes:', error); 618 | res.status(500).json({ error: 'Failed to add nodes' }); 619 | } 620 | }); 621 | 622 | // API路由 - 删除订阅(无需验证) 623 | app.delete('/api/delete-subscriptions', async (req, res) => { 624 | try { 625 | const subsToDelete = req.body.subscription; 626 | 627 | if (!subsToDelete) { 628 | return res.status(400).json({ error: 'Subscription URL is required' }); 629 | } 630 | 631 | if (!Array.isArray(subscriptions)) { 632 | subscriptions = []; 633 | return res.status(404).json({ error: 'No subscriptions found' }); 634 | } 635 | 636 | const deleteList = Array.isArray(subsToDelete) 637 | ? subsToDelete 638 | : subsToDelete.split('\n'); 639 | 640 | const processedSubs = deleteList 641 | .map(sub => cleanNodeString(sub)) 642 | .filter(sub => sub); 643 | 644 | const deletedSubs = []; 645 | const notFoundSubs = []; 646 | 647 | processedSubs.forEach(subToDelete => { 648 | const index = subscriptions.findIndex(sub => 649 | cleanNodeString(sub) === subToDelete 650 | ); 651 | if (index !== -1) { 652 | deletedSubs.push(subToDelete); 653 | subscriptions.splice(index, 1); 654 | } else { 655 | notFoundSubs.push(subToDelete); 656 | } 657 | }); 658 | 659 | if (deletedSubs.length > 0) { 660 | await saveData(subscriptions, nodes); 661 | res.status(200).json({ 662 | success: true, 663 | deleted: deletedSubs, 664 | notFound: notFoundSubs 665 | }); 666 | } else { 667 | res.status(404).json({ 668 | success: false, 669 | error: 'No subscriptions found to delete' 670 | }); 671 | } 672 | } catch (error) { 673 | console.error('API Error deleting subscription:', error); 674 | res.status(500).json({ error: 'Failed to delete subscription' }); 675 | } 676 | }); 677 | 678 | // API路由 - 删除节点(无需验证) 679 | app.delete('/api/delete-nodes', async (req, res) => { 680 | try { 681 | const nodesToDelete = req.body.nodes; 682 | 683 | if (!nodesToDelete) { 684 | return res.status(400).json({ error: 'Nodes are required' }); 685 | } 686 | 687 | const deleteList = Array.isArray(nodesToDelete) 688 | ? nodesToDelete 689 | : nodesToDelete.split('\n'); 690 | 691 | const processedNodes = deleteList 692 | .map(node => cleanNodeString(node)) 693 | .filter(node => node); 694 | 695 | let nodesList = nodes.split('\n') 696 | .map(node => cleanNodeString(node)) 697 | .filter(node => node); 698 | 699 | const deletedNodes = []; 700 | const notFoundNodes = []; 701 | 702 | processedNodes.forEach(nodeToDelete => { 703 | const index = nodesList.findIndex(node => 704 | cleanNodeString(node) === cleanNodeString(nodeToDelete) 705 | ); 706 | 707 | if (index !== -1) { 708 | deletedNodes.push(nodeToDelete); 709 | nodesList.splice(index, 1); 710 | } else { 711 | notFoundNodes.push(nodeToDelete); 712 | } 713 | }); 714 | 715 | if (deletedNodes.length > 0) { 716 | nodes = nodesList.join('\n'); 717 | await saveData(subscriptions, nodes); 718 | res.status(200).json({ 719 | success: true, 720 | deleted: deletedNodes, 721 | notFound: notFoundNodes 722 | }); 723 | } else { 724 | res.status(404).json({ 725 | success: false, 726 | error: 'No nodes found to delete' 727 | }); 728 | } 729 | } catch (error) { 730 | console.error('API Error deleting nodes:', error); 731 | res.status(500).json({ error: 'Failed to delete nodes' }); 732 | } 733 | }); 734 | 735 | // 获取数据 736 | app.get('/admin/data', async (req, res) => { 737 | try { 738 | const nodesList = typeof nodes === 'string' 739 | ? nodes.split('\n').map(n => n.trim()).filter(n => n) 740 | : []; 741 | 742 | const response = { 743 | subscriptions: Array.isArray(subscriptions) ? subscriptions : [], 744 | nodes: nodesList 745 | }; 746 | 747 | console.log('Sending data to client:', response); 748 | res.status(200).json(response); 749 | } catch (error) { 750 | console.error('Error fetching data:', error); 751 | res.status(500).json({ error: 'Failed to fetch data' }); 752 | } 753 | }); 754 | 755 | // 保存数据 756 | async function saveData(subs, nds) { 757 | try { 758 | const data = { 759 | subscriptions: Array.isArray(subs) ? subs : [], 760 | nodes: typeof nds === 'string' ? nds : '' 761 | }; 762 | 763 | await fs.writeFile(DATA_FILE, JSON.stringify(data, null, 2)); 764 | console.log('Data saved successfully:', data); 765 | 766 | subscriptions = data.subscriptions; 767 | nodes = data.nodes; 768 | } catch (error) { 769 | console.error('Error saving data:', error); 770 | throw error; 771 | } 772 | } 773 | // 订阅路由 774 | app.get(`/${SUB_TOKEN}`, async (req, res) => { 775 | try { 776 | const queryCFIP = req.query.CFIP; 777 | const queryCFPORT = req.query.CFPORT; 778 | 779 | if (queryCFIP && queryCFPORT) { 780 | CFIP = queryCFIP; 781 | CFPORT = queryCFPORT; 782 | console.log(`CFIP and CFPORT updated to ${CFIP}:${CFPORT}`); 783 | } 784 | 785 | // 从文件重新读取最新数据 786 | await loadData(); 787 | const mergedSubscription = await generateMergedSubscription(); 788 | const base64Content = Buffer.from(mergedSubscription).toString('base64'); 789 | res.setHeader('Content-Type', 'text/plain; charset=utf-8'); 790 | res.send(`${base64Content}`); 791 | } catch (error) { 792 | console.error(`Error handling /${SUB_TOKEN} route: ${error}`); 793 | res.status(500).send('Internal Server Error'); 794 | } 795 | }); 796 | 797 | // 首页 798 | app.get('/', function(req, res) { 799 | res.send('Hello world!'); 800 | }); 801 | 802 | // 生成合并订阅 803 | async function generateMergedSubscription() { 804 | try { 805 | const promises = subscriptions.map(async (subscription) => { 806 | try { 807 | const subscriptionContent = await fetchSubscriptionContent(subscription); 808 | if (subscriptionContent) { 809 | const decodedContent = decodeBase64Content(subscriptionContent); 810 | const updatedContent = replaceAddressAndPort(decodedContent); 811 | return updatedContent; 812 | } 813 | } catch (error) { 814 | console.error(`Error fetching subscription content: ${error}`); 815 | } 816 | return null; 817 | }); 818 | 819 | const mergedContentArray = await Promise.all(promises); 820 | const mergedContent = mergedContentArray.filter(content => content !== null).join('\n'); 821 | 822 | const updatedNodes = replaceAddressAndPort(nodes); 823 | return `${mergedContent}\n${updatedNodes}`; 824 | } catch (error) { 825 | console.error(`Error generating merged subscription: ${error}`); 826 | throw error; 827 | } 828 | } 829 | 830 | 831 | function decodeBase64Content(base64Content) { 832 | const decodedContent = Buffer.from(base64Content, 'base64').toString('utf-8'); 833 | return decodedContent; 834 | } 835 | 836 | async function fetchSubscriptionContent(subscription) { 837 | try { 838 | const response = await axios.get(subscription, { timeout: 10000 }); // 无效获取订阅10秒超时 839 | return response.data; 840 | } catch (error) { 841 | console.error(`Error fetching subscription content: ${error}`); 842 | return null; 843 | } 844 | } 845 | 846 | function replaceAddressAndPort(content) { 847 | if (!CFIP || !CFPORT) { 848 | return content; 849 | } 850 | 851 | return content.split('\n').map(line => { 852 | line = line.trim(); 853 | if (!line) return line; 854 | 855 | if (line.startsWith('vmess://')) { 856 | try { 857 | const base64Part = line.substring(8); // 去掉 'vmess://' 858 | const decoded = Buffer.from(base64Part, 'base64').toString('utf-8'); 859 | const nodeObj = JSON.parse(decoded); 860 | 861 | // 检查是否为 ws 协议且带 TLS 862 | if ((nodeObj.net === 'ws' || nodeObj.net === 'xhttp') && nodeObj.tls === 'tls') { 863 | nodeObj.add = CFIP; 864 | nodeObj.port = parseInt(CFPORT, 10); 865 | return 'vmess://' + Buffer.from(JSON.stringify(nodeObj)).toString('base64'); 866 | } 867 | } catch (error) { 868 | console.error('Error processing VMess node:', error); 869 | } 870 | } 871 | // 处理 VLESS 和 Trojan 节点 872 | else if (line.startsWith('vless://') || line.startsWith('trojan://')) { 873 | try { 874 | // 检查是否包含 ws 和 tls 875 | if ((line.includes('type=ws') || line.includes('type=xhttp')) && line.includes('security=tls')) { 876 | const url = new URL(line); 877 | const address = url.hostname; 878 | const params = new URLSearchParams(url.search); 879 | const host = params.get('host'); 880 | 881 | // 只有当 host 存在且与 address 不同时才替换(即不替换直连节点) 882 | if (!host || host !== address) { 883 | return line.replace(/@([\w.-]+):(\d+)/, (match, host) => { 884 | return `@${CFIP}:${CFPORT}`; 885 | }); 886 | } 887 | } 888 | } catch (error) { 889 | console.error('Error processing VLESS/Trojan node:', error); 890 | } 891 | } 892 | 893 | // 其他协议(如 tcp、hysteria、hysteria2、tuic snell等)返回原始行 894 | return line; 895 | }).join('\n'); 896 | } 897 | 898 | // 先初始化数据再启动http服务 899 | async function startServer() { 900 | try { 901 | // 初始化并加载凭证 902 | await ensureDataDir(); 903 | await initializeCredentialsFile(); 904 | credentials = await loadCredentials(); 905 | console.log('Credentials initialized and loaded successfully'); 906 | await initializeDataFile(); 907 | // 启动服务器 908 | app.listen(PORT, () => { 909 | console.log(`Server is running on port ${PORT}`); 910 | console.log(`Subscription route is /${SUB_TOKEN}`); 911 | console.log(`Admin page is available at /`); 912 | console.log(`Initial credentials: username=${credentials.username}`); 913 | }); 914 | } catch (error) { 915 | console.error('Error starting server:', error); 916 | process.exit(1); 917 | } 918 | } 919 | 920 | startServer(); 921 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | 2 | #!/bin/bash 3 | export LC_ALL=C 4 | re="\033[0m" 5 | red="\033[1;91m" 6 | green="\e[1;32m" 7 | yellow="\e[1;33m" 8 | purple="\e[1;35m" 9 | red() { echo -e "\e[1;91m$1\033[0m"; } 10 | green() { echo -e "\e[1;32m$1\033[0m"; } 11 | yellow() { echo -e "\e[1;33m$1\033[0m"; } 12 | USERNAME=$(whoami | tr '[:upper:]' '[:lower:]') 13 | HOSTNAME=$(hostname) 14 | 15 | if [[ "$HOSTNAME" =~ ct8 ]]; then 16 | CURRENT_DOMAIN="${USERNAME}.ct8.pl" 17 | elif [[ "$HOSTNAME" =~ useruno ]]; then 18 | CURRENT_DOMAIN="${USERNAME}.useruno.com" 19 | else 20 | CURRENT_DOMAIN="${USERNAME}.serv00.net" 21 | fi 22 | 23 | check_website() { 24 | yellow "正在安装中,请稍等..." 25 | CURRENT_SITE=$(devil www list | awk -v domain="${CURRENT_DOMAIN}" '$1 == domain && $2 == "nodejs"') 26 | if [ -n "$CURRENT_SITE" ]; then 27 | green "已存在 ${CURRENT_DOMAIN} 的node站点,无需修改" 28 | else 29 | EXIST_SITE=$(devil www list | awk -v domain="${CURRENT_DOMAIN}" '$1 == domain') 30 | 31 | if [ -n "$EXIST_SITE" ]; then 32 | devil www del "${CURRENT_DOMAIN}" >/dev/null 2>&1 33 | devil www add "${CURRENT_DOMAIN}" nodejs /usr/local/bin/node18 > /dev/null 2>&1 34 | green "已删除旧的站点并创建新的nodejs站点" 35 | else 36 | devil www add "${CURRENT_DOMAIN}" nodejs /usr/local/bin/node18 > /dev/null 2>&1 37 | green "已创建 ${CURRENT_DOMAIN} nodejs站点" 38 | fi 39 | fi 40 | } 41 | 42 | install_sub() { 43 | check_website 44 | WORKDIR="${HOME}/domains/${CURRENT_DOMAIN}/public_nodejs" 45 | rm -rf "$WORKDIR" && mkdir -p "$WORKDIR" && chmod 777 "$WORKDIR" >/dev/null 2>&1 46 | cd "$WORKDIR" && git clone https://github.com/eooce/Merge-sub.git >/dev/null 2>&1 47 | green "项目克隆成功,正在配置..." 48 | mv "$WORKDIR"/Merge-sub/* "$WORKDIR" >/dev/null 2>&1 49 | rm -rf workers Merge-sub Dockerfile README.md install.sh>/dev/null 2>&1 50 | ip_address=$(devil vhost list | awk '$2 ~ /web/ {print $1}') 51 | devil ssl www add $ip_address le le ${CURRENT_DOMAIN} > /dev/null 2>&1 52 | ln -fs /usr/local/bin/node18 ~/bin/node > /dev/null 2>&1 53 | ln -fs /usr/local/bin/npm18 ~/bin/npm > /dev/null 2>&1 54 | mkdir -p ~/.npm-global 55 | npm config set prefix '~/.npm-global' 56 | echo 'export PATH=~/.npm-global/bin:~/bin:$PATH' >> $HOME/.bash_profile && source $HOME/.bash_profile 57 | rm -rf $HOME/.npmrc > /dev/null 2>&1 58 | npm install -r package.json --silent > /dev/null 2>&1 59 | devil www options ${CURRENT_DOMAIN} sslonly on > /dev/null 2>&1 60 | if devil www restart ${CURRENT_DOMAIN} 2>&1 | grep -q "Ok"; then 61 | green "\n汇聚订阅已部署\n\n用户名:admin \n登录密码:admin 请及时修改\n管理页面: https://${CURRENT_DOMAIN}\n\n" 62 | yellow "汇聚节点订阅登录管理页面查看\n\n" 63 | else 64 | red "汇聚订阅安装失败\n${yellow}devil www del ${CURRENT_DOMAIN} \nrm -rf $HOME/domains/*\n${red}请依次执行上述命令后重新安装!" 65 | fi 66 | } 67 | 68 | install_sub 69 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "merge-sub", 3 | "version": "1.0.0", 4 | "description": "nodejs", 5 | "author": "eooce", 6 | "repository": "", 7 | "license": "MIT", 8 | "main": "app.js", 9 | "private": false, 10 | "scripts": { 11 | "start": "node app.js" 12 | }, 13 | "dependencies": { 14 | "axios": "^1.7.9", 15 | "basic-auth": "^2.0.1", 16 | "express": "latest" 17 | }, 18 | "engines": { 19 | "node": ">=14" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /public/change-password.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | 6 |