├── 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 | 修改密码 7 | 123 | 124 | 125 |
126 |

修改用户名和密码

127 |
128 | 129 | 130 |
131 |
132 | 133 | 134 |
135 |
136 | 137 | 138 |
139 |
140 | 141 | 142 |
143 |
144 | 145 | 218 | 219 | -------------------------------------------------------------------------------- /public/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: Arial, sans-serif; 3 | margin: 0; 4 | padding: 5px; 5 | background-color: #f4f4f4; 6 | min-height: 100vh; 7 | overflow: hidden; 8 | } 9 | 10 | .container { 11 | max-width: 1200px; 12 | margin: 0 auto; 13 | } 14 | 15 | h1 { 16 | font-family: "Microsoft YaHei", "微软雅黑", sans-serif; 17 | font-size: 2.5em; 18 | margin-bottom: 10px; 19 | margin-top: 0px; 20 | text-align: center; 21 | color: #1f59ef; 22 | margin-bottom: 20px; 23 | } 24 | 25 | .row { 26 | display: flex; 27 | gap: 20px; 28 | margin-bottom: 10px; 29 | } 30 | 31 | .column { 32 | flex: 1; 33 | min-width: 300px; 34 | } 35 | 36 | .section { 37 | background: #fff; 38 | padding: 20px; 39 | border-radius: 8px; 40 | box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); 41 | margin-bottom: 20px; 42 | position: relative; 43 | } 44 | 45 | .input-section { 46 | height: auto; 47 | min-height: 200px; 48 | display: flex; 49 | flex-direction: column; 50 | margin-bottom: 20px; 51 | } 52 | 53 | textarea { 54 | width: 100%; 55 | padding: 10px; 56 | border: 1px solid #ccc; 57 | border-radius: 4px; 58 | box-sizing: border-box; 59 | resize: vertical; 60 | min-height: 120px; 61 | max-height: 400px; 62 | margin-bottom: 10px; 63 | font-family: monospace; 64 | font-size: 14px; 65 | line-height: 1.5; 66 | } 67 | 68 | button { 69 | width: 100%; 70 | padding: 10px; 71 | color: #fff; 72 | border: none; 73 | border-radius: 4px; 74 | cursor: pointer; 75 | margin-top: 10px; 76 | } 77 | 78 | .add-btn { 79 | background: #007bff; 80 | } 81 | 82 | .delete-btn { 83 | background: #dc3545; 84 | } 85 | 86 | .view-sub-btn { 87 | position: absolute; 88 | top: 40px; 89 | right: 20px; 90 | width: auto; 91 | padding: 8px 10px; 92 | background: #28a745; 93 | color: white; 94 | border: none; 95 | border-radius: 4px; 96 | font-size: 12px; 97 | margin: 0; 98 | z-index: 1; 99 | } 100 | 101 | .view-sub-btn:hover { 102 | background: #218838; 103 | } 104 | 105 | #data { 106 | height: 465px; 107 | background: #f8f9fa; 108 | padding: 5px 12px; 109 | border-radius: 4px; 110 | white-space: pre-wrap; 111 | word-break: break-all; 112 | font-family: monospace; 113 | font-size: 14px; 114 | line-height: 1.5; 115 | border: 1px solid #dee2e6; 116 | overflow-y: auto; 117 | margin: 0; 118 | } 119 | 120 | /* 订阅信息弹窗样式 */ 121 | .subscription-info { 122 | background: white; 123 | padding: 20px; 124 | border-radius: 8px; 125 | width: 600px; 126 | max-width: 90vw; 127 | } 128 | 129 | .subscription-line { 130 | margin: 15px 0; 131 | position: relative; 132 | padding: 10px; 133 | background: #f8f9fa; 134 | border-radius: 4px; 135 | border: 1px solid #dee2e6; 136 | } 137 | 138 | .subscription-label { 139 | display: block; 140 | color: #666; 141 | margin-bottom: 5px; 142 | font-size: 14px; 143 | } 144 | 145 | .subscription-url { 146 | word-break: break-all; 147 | color: blue; 148 | cursor: pointer; 149 | padding: 5px; 150 | background: #fff; 151 | border-radius: 3px; 152 | font-family: monospace; 153 | } 154 | 155 | .subscription-url:hover { 156 | background: #e9ecef; 157 | } 158 | 159 | .subscription-note { 160 | color: #f7022c; 161 | margin: 20px 0; 162 | padding: 0 10px; 163 | font-size: 16px; 164 | } 165 | 166 | .copy-indicator { 167 | display: none; 168 | position: absolute; 169 | right: 10px; 170 | top: 50%; 171 | transform: translateY(-50%); 172 | color: #28a745; 173 | font-size: 12px; 174 | } 175 | 176 | /* 滚动条样式 */ 177 | ::-webkit-scrollbar { 178 | width: 8px; 179 | } 180 | 181 | ::-webkit-scrollbar-track { 182 | background: #f1f1f1; 183 | border-radius: 4px; 184 | } 185 | 186 | ::-webkit-scrollbar-thumb { 187 | background: #888; 188 | border-radius: 4px; 189 | } 190 | 191 | ::-webkit-scrollbar-thumb:hover { 192 | background: #555; 193 | } 194 | 195 | .top-right { 196 | position: absolute; 197 | top: 10px; 198 | right: 10px; 199 | display: flex; 200 | gap: 20px; 201 | } 202 | 203 | .top-right a { 204 | color: #666; 205 | text-decoration: none; 206 | font-size: 14px; 207 | display: flex; 208 | align-items: center; 209 | gap: 5px; 210 | } 211 | 212 | .top-right a:hover { 213 | text-decoration: underline; 214 | } 215 | 216 | .fa-github { 217 | font-size: 16px; 218 | } 219 | 220 | .alert-overlay { 221 | position: fixed; 222 | top: 0; 223 | left: 0; 224 | width: 100%; 225 | height: 100%; 226 | background: rgba(0, 0, 0, 0.5); 227 | display: flex; 228 | justify-content: center; 229 | align-items: center; 230 | z-index: 1000; 231 | } 232 | 233 | .alert-box { 234 | background: white; 235 | padding: 10px; 236 | border-radius: 10px; 237 | text-align: center; 238 | min-width: 280px; 239 | } 240 | 241 | .alert-message { 242 | margin-bottom: 15px; 243 | font-size: 16px; 244 | } 245 | 246 | .alert-button { 247 | background-color: #28a745; 248 | color: white; 249 | border: none; 250 | padding: 8px 40px; 251 | border-radius: 4px; 252 | cursor: pointer; 253 | font-size: 14px; 254 | width: auto; 255 | } 256 | 257 | .alert-button:hover { 258 | background-color: #218838; 259 | } 260 | 261 | .footer { 262 | text-align: center; 263 | font-size: 12px; 264 | color: #666; 265 | } 266 | 267 | .footer a { 268 | color: #666; 269 | text-decoration: none; 270 | } 271 | 272 | .footer a:hover { 273 | text-decoration: underline; 274 | } 275 | 276 | /* 移动端适配 */ 277 | @media screen and (max-width: 768px) { 278 | body { 279 | padding: 10px; 280 | overflow-y: auto; 281 | } 282 | 283 | .container { 284 | max-width: 100%; 285 | } 286 | 287 | .row { 288 | flex-direction: column; 289 | gap: 10px; 290 | } 291 | 292 | .column { 293 | min-width: auto; 294 | } 295 | 296 | .section { 297 | padding: 15px; 298 | margin-bottom: 10px; 299 | } 300 | 301 | .input-section { 302 | min-height: 150px; 303 | } 304 | 305 | textarea { 306 | min-height: 100px; 307 | max-height: 200px; 308 | } 309 | 310 | #data { 311 | height: 300px; 312 | } 313 | 314 | .subscription-info { 315 | width: 80vw; 316 | padding: 10px; 317 | } 318 | 319 | .subscription-line { 320 | padding: 8px; 321 | } 322 | 323 | .subscription-url { 324 | color: blue; 325 | font-size: 12px; 326 | } 327 | 328 | .view-sub-btn { 329 | position: relative; 330 | top: auto; 331 | right: auto; 332 | width: 100%; 333 | height: 44px; 334 | margin-top: 10px; 335 | font-size: 14px; 336 | display: flex; 337 | align-items: center; 338 | justify-content: center; 339 | } 340 | 341 | button { 342 | height: 44px; 343 | font-size: 14px; 344 | margin-top: 10px; 345 | display: flex; 346 | align-items: center; 347 | justify-content: center; 348 | } 349 | 350 | .section { 351 | padding: 15px; 352 | margin-bottom: 10px; 353 | position: relative; 354 | display: flex; 355 | flex-direction: column; 356 | } 357 | 358 | h1 { 359 | font-size: 1.8em; 360 | margin-bottom: 15px; 361 | } 362 | 363 | h2 { 364 | font-size: 1.2em; 365 | margin-bottom: 8px; 366 | } 367 | 368 | .top-right { 369 | position: static; 370 | display: flex; 371 | justify-content: flex-end; 372 | margin-bottom: 10px; 373 | gap: 15px; 374 | } 375 | 376 | button { 377 | height: 44px; 378 | } 379 | 380 | .footer { 381 | margin-top: 15px; 382 | padding: 10px 0; 383 | font-size: 10px; 384 | } 385 | } 386 | 387 | /* 针对更小屏幕的适配 */ 388 | @media screen and (max-width: 480px) { 389 | body { 390 | padding: 8px; 391 | } 392 | 393 | h1 { 394 | font-size: 1.5em; 395 | } 396 | 397 | .section { 398 | padding: 12px; 399 | } 400 | 401 | .top-right { 402 | font-size: 12px; 403 | gap: 10px; 404 | } 405 | 406 | .top-right a { 407 | font-size: 12px; 408 | } 409 | } 410 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Merge Subscriptions-最好用的订阅管理系统 7 | 8 | 9 | 10 | 11 |
12 |
13 | 修改密码 14 | 15 | GitHub 16 | 17 |
18 |

Merge Subscriptions

19 | 20 |
21 |
22 |
23 |

Add Subscriptions or Nodes

24 | 25 | 26 |
27 |
28 |

Delete Subscriptions or Nodes

29 | 30 | 31 |
32 |
33 |
34 |
35 |

Current Subscriptions and Nodes

36 |

37 |                     
40 |                 
41 |
42 |
43 | 46 |
47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /public/script.js: -------------------------------------------------------------------------------- 1 | let subToken = ''; 2 | 3 | async function fetchSubToken() { 4 | try { 5 | const response = await fetch('/get-sub-token'); 6 | if (!response.ok) { 7 | console.error('获取 SUB_TOKEN 失败: 未授权'); 8 | return; 9 | } 10 | const data = await response.json(); 11 | subToken = data.token; 12 | } catch (error) { 13 | console.error('获取 SUB_TOKEN 失败:', error); 14 | } 15 | } 16 | 17 | function showAlert(message) { 18 | const overlay = document.createElement('div'); 19 | overlay.className = 'alert-overlay'; 20 | 21 | const alertBox = document.createElement('div'); 22 | alertBox.className = 'alert-box'; 23 | 24 | const messageDiv = document.createElement('div'); 25 | messageDiv.className = 'alert-message'; 26 | messageDiv.textContent = message; 27 | messageDiv.style.textAlign = 'center'; 28 | 29 | const button = document.createElement('button'); 30 | button.className = 'alert-button'; 31 | button.textContent = '确定'; 32 | button.style.margin = '10px auto'; 33 | button.style.display = 'block'; 34 | 35 | button.onclick = function() { 36 | document.body.removeChild(overlay); 37 | }; 38 | 39 | alertBox.appendChild(messageDiv); 40 | alertBox.appendChild(button); 41 | overlay.appendChild(alertBox); 42 | document.body.appendChild(overlay); 43 | 44 | overlay.onclick = function(e) { 45 | if (e.target === overlay) { 46 | document.body.removeChild(overlay); 47 | } 48 | }; 49 | } 50 | 51 | async function addItem() { 52 | const input = document.getElementById('addInput').value.trim(); 53 | if (!input) { 54 | showAlert('请输入订阅链接或节点'); 55 | return; 56 | } 57 | 58 | const isSubscription = input.startsWith('http://') || input.startsWith('https://'); 59 | const endpoint = isSubscription ? '/admin/add-subscription' : '/admin/add-node'; 60 | const body = isSubscription ? { subscription: input } : { node: input }; 61 | 62 | try { 63 | console.log('Sending add request:', { endpoint, body }); 64 | const response = await fetch(endpoint, { 65 | method: 'POST', 66 | headers: { 'Content-Type': 'application/json' }, 67 | body: JSON.stringify(body) 68 | }); 69 | 70 | const result = await response.json(); 71 | console.log('Server response:', result); 72 | 73 | if (response.ok) { 74 | showAlert(result.message); 75 | document.getElementById('addInput').value = ''; 76 | await fetchData(); 77 | } else { 78 | showAlert(result.error || '添加失败'); 79 | } 80 | } catch (error) { 81 | console.error('添加时发生错误:', error); 82 | showAlert('添加失败: ' + (error.message || '未知错误')); 83 | } 84 | } 85 | 86 | function copyToClipboard(element, text) { 87 | const textArea = document.createElement('textarea'); 88 | textArea.value = text; 89 | document.body.appendChild(textArea); 90 | 91 | try { 92 | textArea.select(); 93 | document.execCommand('copy'); 94 | const copyIndicator = document.createElement('span'); 95 | copyIndicator.textContent = '已复制'; 96 | copyIndicator.style.color = '#10d23c'; 97 | copyIndicator.style.marginLeft = '5px'; 98 | copyIndicator.style.fontWeight = 'bold'; 99 | element.appendChild(copyIndicator); 100 | setTimeout(() => { 101 | element.removeChild(copyIndicator); 102 | }, 1000); 103 | } catch (err) { 104 | console.error('复制失败:', err); 105 | } finally { 106 | document.body.removeChild(textArea); 107 | } 108 | } 109 | 110 | async function fetchData() { 111 | try { 112 | const response = await fetch('/admin/data'); 113 | const data = await response.json(); 114 | console.log('Fetched data:', data); 115 | 116 | let formattedText = '

subscriptions:

'; 117 | if (Array.isArray(data.subscriptions)) { 118 | formattedText += data.subscriptions.map(sub => 119 | `
${sub}
` 120 | ).join(''); 121 | } 122 | 123 | formattedText += '

nodes:

'; 124 | if (typeof data.nodes === 'string') { 125 | const formattedNodes = data.nodes.split('\n').map(node => { 126 | const formatted = node.replace(/(vmess|vless|trojan|ss|ssr|snell|juicity|hysteria|hysteria2|tuic|anytls|wireguard|socks5|https?):\/\//g, 127 | (match) => `${match}`); 128 | return `
${formatted}
`; 129 | }).join(''); 130 | formattedText += formattedNodes; 131 | } else if (Array.isArray(data.nodes)) { 132 | const formattedNodes = data.nodes.map(node => { 133 | const formatted = node.replace(/(vmess|vless|trojan|ss|ssr|snell|juicity|hysteria|hysteria2|tuic|anytls|wireguard|socks5|https?):\/\//g, 134 | (match) => `${match}`); 135 | return `
${formatted}
`; 136 | }).join(''); 137 | formattedText += formattedNodes; 138 | } 139 | formattedText += '
'; 140 | 141 | document.getElementById('data').innerHTML = formattedText; 142 | } catch (error) { 143 | console.error('Error fetching data:', error); 144 | document.getElementById('data').textContent = 'Error loading data'; 145 | } 146 | } 147 | 148 | async function deleteItem() { 149 | const input = document.getElementById('deleteInput').value.trim(); 150 | if (!input) { 151 | showAlert('请输入要删除的订阅链接或节点'); 152 | return; 153 | } 154 | 155 | const isSubscription = input.startsWith('http://') || input.startsWith('https://'); 156 | const endpoint = isSubscription ? '/admin/delete-subscription' : '/admin/delete-node'; 157 | const body = isSubscription ? { subscription: input } : { node: input }; 158 | 159 | try { 160 | console.log('Sending delete request:', { endpoint, body }); 161 | const response = await fetch(endpoint, { 162 | method: 'POST', 163 | headers: { 'Content-Type': 'application/json' }, 164 | body: JSON.stringify(body) 165 | }); 166 | 167 | const result = await response.json(); 168 | console.log('Server response:', result); 169 | 170 | document.getElementById('deleteInput').value = ''; 171 | await fetchData(); 172 | 173 | showAlert(result.message || '删除成功'); 174 | } catch (error) { 175 | console.error('删除时发生错误:', error); 176 | showAlert('删除失败: ' + (error.message || '未知错误')); 177 | } 178 | } 179 | 180 | function createSubscriptionLine(label, url) { 181 | const line = document.createElement('div'); 182 | line.className = 'subscription-line'; 183 | 184 | const labelSpan = document.createElement('span'); 185 | labelSpan.textContent = label; 186 | 187 | const urlDiv = document.createElement('div'); 188 | urlDiv.className = 'subscription-url'; 189 | urlDiv.textContent = url; 190 | 191 | const copyIndicator = document.createElement('span'); 192 | copyIndicator.className = 'copy-indicator'; 193 | copyIndicator.textContent = '已复制'; 194 | 195 | line.appendChild(labelSpan); 196 | line.appendChild(urlDiv); 197 | line.appendChild(copyIndicator); 198 | 199 | urlDiv.onclick = async () => { 200 | try { 201 | await navigator.clipboard.writeText(url); 202 | copyIndicator.style.display = 'inline'; 203 | setTimeout(() => { 204 | copyIndicator.style.display = 'none'; 205 | }, 1000); 206 | } catch (err) { 207 | console.error('复制失败:', err); 208 | } 209 | }; 210 | 211 | return line; 212 | } 213 | 214 | async function showSubscriptionInfo() { 215 | try { 216 | const response = await fetch('/get-sub-token'); 217 | if (!response.ok) { 218 | showAlert('请先登录'); 219 | return; 220 | } 221 | 222 | const data = await response.json(); 223 | subToken = data.token; 224 | 225 | const currentDomain = window.location.origin; 226 | 227 | const overlay = document.createElement('div'); 228 | overlay.className = 'alert-overlay'; 229 | 230 | const alertBox = document.createElement('div'); 231 | alertBox.className = 'alert-box subscription-info'; 232 | 233 | const defaultSubLine = createSubscriptionLine( 234 | '默认订阅链接:', 235 | `${currentDomain}/${subToken}` 236 | ); 237 | 238 | const customSubLine = createSubscriptionLine( 239 | '带优选IP订阅链接:', 240 | `${currentDomain}/${subToken}?CFIP=time.is&CFPORT=443` 241 | ); 242 | 243 | const noteDiv = document.createElement('div'); 244 | noteDiv.className = 'subscription-note'; 245 | noteDiv.textContent = '提醒:将time.is和443改为更快的优选ip或优选域名和对应的端口'; 246 | 247 | const closeButton = document.createElement('button'); 248 | closeButton.className = 'alert-button'; 249 | closeButton.textContent = '关闭'; 250 | closeButton.style.width = '100%'; 251 | closeButton.onclick = () => document.body.removeChild(overlay); 252 | 253 | alertBox.appendChild(defaultSubLine); 254 | alertBox.appendChild(customSubLine); 255 | alertBox.appendChild(noteDiv); 256 | alertBox.appendChild(closeButton); 257 | overlay.appendChild(alertBox); 258 | document.body.appendChild(overlay); 259 | 260 | overlay.onclick = (e) => { 261 | if (e.target === overlay) { 262 | document.body.removeChild(overlay); 263 | } 264 | }; 265 | } catch (error) { 266 | console.error('Error:', error); 267 | showAlert('获取订阅信息失败,请确保已登录'); 268 | } 269 | } 270 | 271 | document.addEventListener('DOMContentLoaded', async () => { 272 | await fetchSubToken(); 273 | await fetchData(); 274 | }); 275 | -------------------------------------------------------------------------------- /workers/worker_sub.js: -------------------------------------------------------------------------------- 1 | // 订阅:workers域名/sub sub路径可定义 2 | // 带参数订阅:workers域名/sub?CFIP=优选ip&CFPORT=优选ip端口 3 | // 例如:https://test.abc.worker.dev/sub?CFIP=47.75.222.188&CFPORT=7890 4 | 5 | let CFIP = "www.visa.com.tw"; // 优选ip或优选域名 6 | let CFPORT = "443"; // 优选ip或有序域名对应的端口 7 | const SUB_PATH = '/sub'; // 访问路径 8 | 9 | // 添加多个订阅链接,以下示列节点订阅添加前请删除 10 | const subscriptions = [ 11 | 'https://www.google/sub', 12 | 'https://www.google/sub', 13 | 'https://www.google/sub', 14 | 'https://www.google/sub' // 最后一个没有逗号 15 | 16 | // ... 添加更多订阅链接 17 | ]; 18 | 19 | // 支持添加单条或多条自建节点,可以为空,以下示列节点添加前请删除 20 | const nodes = ` 21 | vless://9afd1229-b893-40c1-84dd-51e7ce204913@www.visa.com.tw:8443?encryptio 22 | vmess://ew0KICAidiI6ICIyIiwNCiAgInBzIjogIkdsaXRjaC1VUy1BbWF6b24uY29tIiwNCiA 23 | trojan://9afd1229-b893-40c1-84dd-51e7ce204913@www.visa.com.tw:8443?security 24 | hysteria2://6991e58f-a9af-4319-a8e8-e9c4df14d650@156.255.90.161:17397/?sni 25 | tuic://6991e58f-a9af-4319-a8e8-e9c4df14d650:YDU08bdN1no66Eufx7XdGbVJ@156.25 26 | 27 | `; 28 | 29 | addEventListener('fetch', event => { 30 | event.respondWith(handleRequest(event.request)); 31 | }); 32 | 33 | const nodeArray = nodes.trim().split('\n').filter(node => node); 34 | 35 | async function handleRequest(request) { 36 | const url = new URL(request.url); 37 | 38 | // 从查询参数中获取 CFIP 和 CFPORT 39 | const queryCFIP = url.searchParams.get('CFIP'); 40 | const queryCFPORT = url.searchParams.get('CFPORT'); 41 | 42 | if (queryCFIP && queryCFPORT) { 43 | CFIP = queryCFIP; 44 | CFPORT = queryCFPORT; 45 | console.log(`CFIP and CFPORT updated to ${CFIP}:${CFPORT}`); 46 | } 47 | 48 | if (url.pathname === SUB_PATH) { 49 | const mergedSubscription = await generateMergedSubscription(); 50 | const base64Content = btoa(mergedSubscription); 51 | return new Response(base64Content, { 52 | headers: { 'Content-Type': 'text/plain; charset=utf-8' } 53 | }); 54 | } else if (url.pathname === '/add-subscription' && request.method === 'POST') { 55 | const newSubscription = await request.json(); 56 | addSubscription(newSubscription.subscription); 57 | return new Response('Subscription added successfully', { status: 200 }); 58 | } else if (url.pathname === '/add-nodes' && request.method === 'POST') { 59 | const newNodes = await request.json(); 60 | addMultipleNodes(newNodes.nodes); 61 | return new Response('Nodes added successfully', { status: 200 }); 62 | } 63 | 64 | return new Response('Hello world!', { status: 200 }); 65 | } 66 | 67 | function addSubscription(subscription) { 68 | subscriptions.push(subscription); 69 | } 70 | 71 | function addMultipleNodes(nodes) { 72 | subscriptions.push(...nodes); 73 | } 74 | 75 | async function fetchSubscriptionContent(subscription) { 76 | if (!subscription.startsWith('http://') && !subscription.startsWith('https://')) { 77 | return null; 78 | } 79 | const response = await fetch(subscription); 80 | return response.ok ? response.text() : null; 81 | } 82 | 83 | function decodeBase64Content(base64Content) { 84 | return atob(base64Content); 85 | } 86 | 87 | function replaceAddressAndPort(content) { 88 | if (!CFIP || !CFPORT) { 89 | return content; 90 | } 91 | 92 | return content.split('\n').map(line => { 93 | if (line.startsWith('vmess://')) { 94 | const base64Part = line.substring(8); 95 | const decodedVmess = decodeBase64Content(base64Part); 96 | const vmessObj = JSON.parse(decodedVmess); 97 | vmessObj.add = CFIP; 98 | vmessObj.port = parseInt(CFPORT, 10); 99 | const updatedVmess = btoa(JSON.stringify(vmessObj)); 100 | return `vmess://${updatedVmess}`; 101 | } else if (line.startsWith('vless://') || line.startsWith('trojan://')) { 102 | return line.replace(/@([\w.-]+):(\d+)/, (match, host) => { 103 | return `@${CFIP}:${CFPORT}`; 104 | }); 105 | } 106 | return line; 107 | }).join('\n'); 108 | } 109 | 110 | async function generateMergedSubscription() { 111 | const nodesContent = nodeArray.join('\n'); 112 | const promises = subscriptions.map(async (subscription) => { 113 | const subscriptionContent = await fetchSubscriptionContent(subscription); 114 | if (subscriptionContent) { 115 | const decodedContent = decodeBase64Content(subscriptionContent); 116 | const updatedContent = replaceAddressAndPort(decodedContent); 117 | return updatedContent; 118 | } 119 | return null; 120 | }); 121 | 122 | const mergedContentArray = await Promise.all(promises); 123 | mergedContentArray.unshift(nodesContent); 124 | return mergedContentArray.filter(content => content !== null).join('\n'); 125 | } 126 | -------------------------------------------------------------------------------- /workers/workers_sub_beta.js: -------------------------------------------------------------------------------- 1 | // 可带参数订阅或访问:wokers域名或绑定的域名/sub?CFIP=优选ip或优选域名&CFPORT=优选ip或优选域名对应的端口 2 | // 例如:wokers域名或绑定的域名/sub?CFIP=47.75.222.188&CFPORT=7890 3 | 4 | // 请求api自动添加节点订阅或单节点方式 5 | // curl -X POST https://wokers域名或绑定的域名/add-subscription \ 6 | // -H "Content-Type: application/json" \ 7 | // -d '{"sub": "订阅链接"}' 8 | 9 | // curl -X POST https://wokers域名或绑定的域名/add-nodes \ 10 | // -H "Content-Type: application/json" \ 11 | // -d '{"nodes": ["vless://","vmess://","tuic://","hy2://"]}' 12 | 13 | // Cloudflare API 配置 14 | const CLOUDFLARE_API_TOKEN = '8888'; // 替换为你的 Cloudflare API Token 15 | const CLOUDFLARE_ACCOUNT_ID = '8888'; // 替换为你的 Cloudflare Account ID 16 | const CLOUDFLARE_SCRIPT_NAME = 'sub'; // 替换为创建 Workers 时的名称 17 | 18 | // 安全配置 19 | const ALLOWED_IPS = []; // 允许IP访问,默认开放所有IP,若限制IP,将影响订阅上传功能,若只使用订阅归总功能,可限制IP 20 | const RATE_LIMIT = 3; // 每分钟最多 3 次请求 21 | 22 | // 订阅配置 23 | let CFIP = "www.visa.com.tw"; // 优选 IP 或优选域名 24 | let CFPORT = "443"; // 优选 IP 或域名对应的端口 25 | const SUB_PATH = '/sub'; // 订阅路径,可更换更复杂的请求路径,wokers域名/sub?CFIP=47.75.222.188&CFPORT=7890中的sub 26 | 27 | // 订阅链接,添加在双引号内, 28 | let subscriptions = [ 29 | "https://google.com/sub", 30 | "https://google.com/sub" // 最后一个没有逗号 31 | ]; 32 | 33 | // 单独节点, 添加在双引号内, 34 | let nodes = [ 35 | "vless://ew0KICAidiI6", 36 | "vmess://ew0KICAidiI6", 37 | "trojan://QwwHvrnN@", 38 | "hysteria2://89c13", 39 | "tuic://89c13786" // 最后一个没有逗号 40 | ]; 41 | 42 | addEventListener('fetch', event => { 43 | event.respondWith(handleRequest(event.request)); 44 | }); 45 | 46 | const nodeArray = nodes; 47 | const rateLimitMap = new Map(); 48 | 49 | // 获取原代码 50 | async function getOriginalCode() { 51 | try { 52 | const url = `https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/workers/scripts/${CLOUDFLARE_SCRIPT_NAME}`; 53 | const response = await fetch(url, { 54 | method: 'GET', 55 | headers: { 56 | 'Authorization': `Bearer ${CLOUDFLARE_API_TOKEN}`, 57 | 'Content-Type': 'application/javascript' 58 | } 59 | }); 60 | 61 | if (!response.ok) { 62 | const errorData = await response.json(); 63 | console.error('获取 Workers 脚本失败:', errorData); 64 | throw new Error(`获取 Workers 脚本失败: ${JSON.stringify(errorData)}`); 65 | } 66 | 67 | const data = await response.text(); 68 | console.log('获取 Workers 脚本成功'); 69 | return data; 70 | } catch (error) { 71 | console.error('获取 Workers 脚本失败:', error); 72 | throw error; 73 | } 74 | } 75 | 76 | // 更新订阅链接或单节点 77 | function updateCodeWithNewContent(originalCode, { subscription, nodes: newNodes }) { 78 | console.log('原代码内容:', originalCode); 79 | 80 | // 解析原代码中的 subscriptions 和 nodes 81 | const subscriptionsMatch = originalCode.match(/let\s+subscriptions\s*=\s*(\[[\s\S]*?\]);/); 82 | const nodesMatch = originalCode.match(/let\s+nodes\s*=\s*(\[[\s\S]*?\]);/); 83 | 84 | console.log('subscriptionsMatch:', subscriptionsMatch); 85 | console.log('nodesMatch:', nodesMatch); 86 | 87 | if (!subscriptionsMatch || !nodesMatch) { 88 | throw new Error('无法解析原代码中的 subscriptions 或 nodes'); 89 | } 90 | 91 | let subscriptions; 92 | try { 93 | subscriptions = JSON.parse(subscriptionsMatch[1]); 94 | } catch (error) { 95 | console.error('解析 subscriptions 失败:', error); 96 | throw new Error('解析 subscriptions 失败'); 97 | } 98 | 99 | let nodes; 100 | try { 101 | nodes = JSON.parse(nodesMatch[1]); 102 | } catch (error) { 103 | console.error('解析 nodes 失败:', error); 104 | throw new Error('解析 nodes 失败'); 105 | } 106 | 107 | // 更新 subscriptions 108 | if (subscription) { 109 | subscriptions.push(subscription); 110 | originalCode = originalCode.replace( 111 | /let\s+subscriptions\s*=\s*\[[\s\S]*?\];/, 112 | `let subscriptions = ${JSON.stringify(subscriptions)};` 113 | ); 114 | } 115 | 116 | // 更新 nodes 117 | if (newNodes && Array.isArray(newNodes)) { 118 | nodes.push(...newNodes); 119 | originalCode = originalCode.replace( 120 | /let\s+nodes\s*=\s*\[[\s\S]*?\];/, 121 | `let nodes = ${JSON.stringify(nodes)};` 122 | ); 123 | } 124 | 125 | return originalCode; 126 | } 127 | 128 | // 更新 Workers 脚本 129 | async function updateWorkersScript(newCode) { 130 | try { 131 | console.log('准备更新 Workers 脚本...'); 132 | console.log('新的代码内容:', newCode); 133 | 134 | const url = `https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/workers/scripts/${CLOUDFLARE_SCRIPT_NAME}`; 135 | const response = await fetch(url, { 136 | method: 'PUT', 137 | headers: { 138 | 'Authorization': `Bearer ${CLOUDFLARE_API_TOKEN}`, 139 | 'Content-Type': 'application/javascript' 140 | }, 141 | body: newCode 142 | }); 143 | 144 | if (!response.ok) { 145 | const errorData = await response.json(); 146 | console.error('更新 Workers 脚本失败:', errorData); 147 | throw new Error(`更新 Workers 脚本失败: ${JSON.stringify(errorData)}`); 148 | } 149 | 150 | console.log('Workers 脚本更新成功'); 151 | return true; 152 | } catch (error) { 153 | console.error('更新 Workers 脚本失败:', error); 154 | throw error; 155 | } 156 | } 157 | 158 | // 处理添加订阅链接的请求 159 | async function addSubscription(subscription) { 160 | try { 161 | console.log('准备添加订阅链接:', subscription); 162 | 163 | const originalCode = await getOriginalCode(); 164 | const newCode = updateCodeWithNewContent(originalCode, { subscription }); 165 | await updateWorkersScript(newCode); 166 | 167 | console.log('订阅链接添加成功'); 168 | return true; 169 | } catch (error) { 170 | console.error('订阅链接添加失败:', error); 171 | throw error; 172 | } 173 | } 174 | 175 | // 处理添加单独节点的请求 176 | async function addMultipleNodes(newNodes) { 177 | try { 178 | console.log('准备添加节点:', newNodes); 179 | 180 | const originalCode = await getOriginalCode(); 181 | const newCode = updateCodeWithNewContent(originalCode, { nodes: newNodes }); 182 | await updateWorkersScript(newCode); 183 | 184 | console.log('节点添加成功'); 185 | return true; 186 | } catch (error) { 187 | console.error('节点添加失败:', error); 188 | throw error; 189 | } 190 | } 191 | 192 | // 获取订阅内容 193 | async function generateMergedSubscription() { 194 | const nodesContent = nodeArray.join('\n'); 195 | const promises = subscriptions.map(async (subscription) => { 196 | const subscriptionContent = await fetchSubscriptionContent(subscription); 197 | if (subscriptionContent) { 198 | const decodedContent = decodeBase64Content(subscriptionContent); 199 | const updatedContent = replaceAddressAndPort(decodedContent); 200 | return updatedContent; 201 | } 202 | return null; 203 | }); 204 | 205 | const mergedContentArray = await Promise.all(promises); 206 | mergedContentArray.unshift(nodesContent); 207 | return mergedContentArray.filter(content => content !== null).join('\n'); 208 | } 209 | 210 | // 获取订阅链接内容 211 | async function fetchSubscriptionContent(subscription) { 212 | if (!subscription.startsWith('http://') && !subscription.startsWith('https://')) { 213 | return null; 214 | } 215 | const response = await fetch(subscription); 216 | return response.ok ? response.text() : null; 217 | } 218 | 219 | // 解码 Base64 内容 220 | function decodeBase64Content(base64Content) { 221 | return atob(base64Content); 222 | } 223 | 224 | // 替换地址和端口 225 | function replaceAddressAndPort(content) { 226 | if (!CFIP || !CFPORT) { 227 | return content; 228 | } 229 | 230 | return content.split('\n').map(line => { 231 | if (line.startsWith('vmess://')) { 232 | const base64Part = line.substring(8); 233 | const decodedVmess = decodeBase64Content(base64Part); 234 | const vmessObj = JSON.parse(decodedVmess); 235 | vmessObj.add = CFIP; 236 | vmessObj.port = parseInt(CFPORT, 10); 237 | const updatedVmess = btoa(JSON.stringify(vmessObj)); 238 | return `vmess://${updatedVmess}`; 239 | } else if (line.startsWith('vless://') || line.startsWith('trojan://')) { 240 | return line.replace(/@([\w.-]+):(\d+)/, (match, host) => { 241 | return `@${CFIP}:${CFPORT}`; 242 | }); 243 | } 244 | return line; 245 | }).join('\n'); 246 | } 247 | 248 | // 处理请求 249 | async function handleRequest(request) { 250 | const url = new URL(request.url); 251 | const clientIP = request.headers.get('CF-Connecting-IP'); // 获取客户端 IP 252 | 253 | // 频率限制 254 | if (!checkRateLimit(clientIP)) { 255 | return new Response('Too Many Requests', { status: 429 }); 256 | } 257 | 258 | // 处理添加订阅链接的请求 259 | if (url.pathname === '/add-subscription' && request.method === 'POST') { 260 | const { sub } = await request.json(); 261 | if (!sub) { 262 | return new Response('Missing subscription link', { status: 400 }); 263 | } 264 | 265 | await addSubscription(sub); 266 | return new Response('Subscription added successfully', { status: 200 }); 267 | } 268 | 269 | // 处理添加单独节点的请求 270 | if (url.pathname === '/add-nodes' && request.method === 'POST') { 271 | try { 272 | // 读取请求体 273 | const payload = await request.text(); 274 | console.log('Received payload:', payload); // 记录接收到的数据 275 | 276 | // 清理 JSON 数据 277 | const cleanedPayload = cleanJsonString(payload); 278 | 279 | // 解析 JSON 280 | const { nodes: newNodes } = JSON.parse(cleanedPayload); 281 | if (!newNodes || !Array.isArray(newNodes)) { 282 | return new Response('Missing or invalid nodes', { status: 400 }); 283 | } 284 | 285 | // 添加新节点 286 | await addMultipleNodes(newNodes); 287 | 288 | // 返回成功响应 289 | return new Response('Nodes added successfully', { status: 200 }); 290 | } catch (error) { 291 | // 返回错误响应 292 | console.error('Error processing request:', error); 293 | return new Response('Invalid JSON data', { status: 400 }); 294 | } 295 | } 296 | 297 | // 处理订阅请求 298 | if (url.pathname === SUB_PATH) { 299 | // 从查询参数中获取 CFIP 和 CFPORT 300 | const queryCFIP = url.searchParams.get('CFIP'); 301 | const queryCFPORT = url.searchParams.get('CFPORT'); 302 | 303 | if (queryCFIP && queryCFPORT) { 304 | CFIP = queryCFIP; 305 | CFPORT = queryCFPORT; 306 | console.log(`CFIP and CFPORT updated to ${CFIP}:${CFPORT}`); 307 | } 308 | 309 | const mergedSubscription = await generateMergedSubscription(); 310 | const base64Content = btoa(mergedSubscription); 311 | return new Response(base64Content, { 312 | headers: { 'Content-Type': 'text/plain; charset=utf-8' } 313 | }); 314 | } 315 | 316 | // 查询订阅链接 317 | if (url.pathname === '/list-subscriptions' && request.method === 'GET') { 318 | return new Response(JSON.stringify(subscriptions), { 319 | headers: { 'Content-Type': 'application/json' } 320 | }); 321 | } 322 | 323 | // 默认返回 Hello world! 324 | return new Response('Hello world!', { status: 200 }); 325 | } 326 | 327 | // 检查频率限制 328 | function checkRateLimit(clientIP) { 329 | const currentTime = Math.floor(Date.now() / 1000 / 60); // 按分钟计算 330 | 331 | if (!rateLimitMap.has(clientIP)) { 332 | rateLimitMap.set(clientIP, { count: 1, lastMinute: currentTime }); 333 | } else { 334 | const record = rateLimitMap.get(clientIP); 335 | if (record.lastMinute === currentTime) { 336 | record.count++; 337 | if (record.count > RATE_LIMIT) { 338 | return false; 339 | } 340 | } else { 341 | record.count = 1; 342 | record.lastMinute = currentTime; 343 | } 344 | } 345 | 346 | return true; 347 | } 348 | 349 | function cleanJsonString(jsonString) { 350 | return jsonString.replace(/[\x00-\x1F\x7F]/g, ''); 351 | } 352 | --------------------------------------------------------------------------------