81 lines
3 KiB
JavaScript
81 lines
3 KiB
JavaScript
|
|
// 铸渊专线订阅服务 · 流量计数 + 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));
|