zhuyuan-ice-gl-zy001/TRAE-DEV-20260820/outputs-vpn-20260823/deploy-zy-sub-service/server.js

81 lines
3 KiB
JavaScript
Raw Normal View History

// 铸渊专线订阅服务 · 流量计数 + subscription-userinfo 头
// 部署:BS-GZ-006 · 2026-08-23 · 配额 1000GiB/自然月重置
// 计数源:iptables 计数规则(relay 端口 8443-8446 进出字节)
const http = require('http');
const fs = require('fs');
const path = require('path');
const { execFile } = require('child_process');
const PORT = 3944;
const DATA_DIR = '/var/www/guanghulab-front-door/zy-sub';
const STATE_FILE = '/opt/zy-sub-service/state.json';
const TOTAL_QUOTA = 1000 * 1024 * 1024 * 1024;
let state = { month: '', upload: 0, download: 0, lastIn: 0, lastOut: 0 };
try { Object.assign(state, JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'))); } catch {}
function monthKey(d = new Date()) {
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0');
}
function save() {
try { fs.writeFileSync(STATE_FILE, JSON.stringify(state)); }
catch (e) { console.error('save fail:', e.message); }
}
function readCounter(chain, tag, cb) {
execFile('iptables-save', ['-c'], (err, out) => {
let bytes = 0;
if (!err) {
for (const line of out.split('\n')) {
if (line.includes('zy-sub-acct-' + tag) && line.includes('-A ' + chain + ' ')) {
const m = line.match(/^\[(\d+):(\d+)\]/);
if (m) bytes += parseInt(m[2], 10);
}
}
}
cb(bytes);
});
}
function poll() {
readCounter('INPUT', 'in', (inB) => {
readCounter('OUTPUT', 'out', (outB) => {
const mk = monthKey();
if (state.month !== mk) { state.month = mk; state.upload = 0; state.download = 0; }
if (inB < state.lastIn) state.lastIn = inB; // 计数器重启归零时对齐
if (outB < state.lastOut) state.lastOut = outB;
state.upload += inB - state.lastIn;
state.download += outB - state.lastOut;
state.lastIn = inB; state.lastOut = outB;
save();
});
});
}
setInterval(poll, 60 * 1000);
poll();
const used = () => state.upload + state.download;
const userinfo = () =>
`upload=${state.upload}; download=${state.download}; total=${TOTAL_QUOTA}; expire=0`;
http.createServer((req, res) => {
const url = req.url.split('?')[0];
const m = url.match(/^\/zy-sub\/([0-9a-f]{16,64})\/(clash\.yaml|shadowrocket\.txt|status)$/);
if (!m) { res.writeHead(404); return res.end('not found'); }
if (m[2] === 'status') {
res.writeHead(200, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({
month: state.month, upload: state.upload, download: state.download,
total: TOTAL_QUOTA, used: used(), remaining: Math.max(0, TOTAL_QUOTA - used())
}, null, 2));
}
fs.readFile(path.join(DATA_DIR, m[1], m[2]), (err, data) => {
if (err) { res.writeHead(404); return res.end('not found'); }
res.writeHead(200, {
'Content-Type': m[2].endsWith('.yaml') ? 'text/yaml; charset=utf-8' : 'text/plain; charset=utf-8',
'subscription-userinfo': userinfo(),
'profile-update-interval': '24'
});
res.end(data);
});
}).listen(PORT, '127.0.0.1', () => console.log('zy-sub-service on', PORT));