feat(panel): reskin backend — node sortIndex, presence/speed, /api/system, credentials, subscription aggregation (Node+Go parity)

This commit is contained in:
aaaamoooo
2026-10-20 18:09:07 +00:00
parent 7c21742810
commit 011eecc096
23 changed files with 3370 additions and 146 deletions
+256
View File
@@ -0,0 +1,256 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const net = require('node:net');
const http = require('node:http');
const { spawn } = require('node:child_process');
const { trafficResetPeriodKey, armTrafficReset, applyMonthlyTrafficResets, parseResetDay } = require('../lib/reset');
const { parseLinkList, validateLinkUri, fetchExternalSource } = require('../lib/extsub');
const { aggregateEntries } = require('../lib/subscriptions');
const { customerOnline, customerSpeedBps, nodeOnlineCustomerIds } = require('../lib/presence');
// ---------- monthly reset (Node port of panel-go/reset.go) ----------
test('reset period key clamps to month end and flips at the reset instant', () => {
// Day 31 in February fires on Feb 28.
assert.equal(trafficResetPeriodKey(new Date(2026, 1, 27, 23, 59), 31), '2026-01');
assert.equal(trafficResetPeriodKey(new Date(2026, 1, 28, 0, 0), 31), '2026-02');
assert.equal(trafficResetPeriodKey(new Date(2026, 0, 31, 0, 0), 31), '2026-01');
assert.equal(trafficResetPeriodKey(new Date(2026, 0, 30, 23, 59), 31), '2025-12');
assert.equal(trafficResetPeriodKey(new Date(2026, 4, 15, 0, 0), 15), '2026-05');
assert.equal(trafficResetPeriodKey(new Date(2026, 4, 14, 23, 59), 15), '2026-04');
});
test('monthly reset arms on first sight, fires once per period, catches up after downtime', () => {
const data = { customers: [{ id: 'c1', trafficResetDay: 15, usedBytes: 500, usedUplinkBytes: 200, usedDownlinkBytes: 300 }] };
const customer = data.customers[0];
// First sight only arms.
let done = applyMonthlyTrafficResets(data, new Date(2026, 4, 20));
assert.equal(done.length, 0);
assert.equal(customer.usedBytes, 500);
assert.equal(customer.trafficResetPeriod, '2026-05');
// Same period: nothing.
done = applyMonthlyTrafficResets(data, new Date(2026, 5, 14));
assert.equal(done.length, 0);
// Crossing the instant fires exactly once (even when the panel was down
// across two boundaries, only the current period applies).
done = applyMonthlyTrafficResets(data, new Date(2026, 6, 20));
assert.equal(done.length, 1);
assert.equal(customer.usedBytes, 0);
assert.equal(customer.trafficResetPeriod, '2026-07');
done = applyMonthlyTrafficResets(data, new Date(2026, 6, 21));
assert.equal(done.length, 0);
});
test('parseResetDay validates like the Go implementation', () => {
assert.equal(parseResetDay(undefined), 0);
assert.equal(parseResetDay(null), 0);
assert.equal(parseResetDay(0), 0);
assert.equal(parseResetDay(31), 31);
assert.throws(() => parseResetDay(32), /0–31/);
assert.throws(() => parseResetDay(-1), /非负整数/);
assert.throws(() => parseResetDay(1.5), /非负整数/);
const customer = { trafficResetDay: 10 };
armTrafficReset(customer, new Date(2026, 4, 20));
assert.equal(customer.trafficResetPeriod, '2026-05');
});
// ---------- external subscription parsing ----------
test('parseLinkList accepts base64 and plaintext, drops unsupported schemes', () => {
const plain = [
'vless://11111111-1111-1111-1111-111111111111@a.example.com:443?security=reality#Node%20A',
'ss://YWVzLTEyOC1nY206cGFzcw@b.example.com:8388#NodeB',
'vmess://eyJ2IjoiMiJ9',
'trojan://pw@c.example.com:443#T',
'not a link',
'hysteria2://pw@d.example.com:443?sni=d.example.com#NodeD',
].join('\n');
const fromPlain = parseLinkList(plain);
assert.equal(fromPlain.items.length, 3);
assert.equal(fromPlain.dropped, 3);
assert.equal(fromPlain.items[0].name, 'Node A');
const fromB64 = parseLinkList(Buffer.from(plain, 'utf8').toString('base64'));
assert.deepEqual(fromB64, fromPlain);
assert.equal(validateLinkUri('socks5://u:p@h:1080'), true);
assert.equal(validateLinkUri('http://example.com/sub'), false);
});
test('fetchExternalSource follows the SSRF guard and parses a local fixture when allowed', async (t) => {
const body = Buffer.from('vless://11111111-1111-1111-1111-111111111111@a.example.com:443#A\n', 'utf8').toString('base64');
const server = http.createServer((req, res) => { res.writeHead(200, { 'content-type': 'text/plain' }); res.end(body); });
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
t.after(() => server.close());
const url = `http://127.0.0.1:${server.address().port}/sub`;
const blocked = await fetchExternalSource({ url }, {});
assert.equal(blocked.ok, false);
assert.match(blocked.error, /非公网/);
const allowed = await fetchExternalSource({ url }, { allowPrivateSubSources: true });
assert.equal(allowed.ok, true);
assert.equal(allowed.items.length, 1);
assert.equal(allowed.items[0].name, 'A');
});
// ---------- subscription aggregation merge ----------
function aggData() {
return {
servers: [{ id: 's1', publicAddress: 'a.example.com', tlsDomain: '' }],
nodes: [
{ id: 'n2', name: '第二', serverId: 's1', protocol: 'socks5-auth', status: 'active', port: 1080, sortIndex: 1, networkMode: 'ipv4' },
{ id: 'n1', name: '第一', serverId: 's1', protocol: 'socks5-auth', status: 'active', port: 1081, sortIndex: 0, networkMode: 'ipv4' },
],
nodeClients: [
{ id: 'nc2', nodeId: 'n2', customerId: 'c1', status: 'active', credentials: { clientId: 'u2', relayPassword: 'p2', shortId: 'x' } },
{ id: 'nc1', nodeId: 'n1', customerId: 'c1', status: 'active', credentials: { clientId: 'u1', relayPassword: 'p1', shortId: 'x' } },
],
subSourceCache: {
src1: { fetchedAt: 'x', okAt: 'x', failCount: 0, lastError: null, droppedCount: 0, items: [
{ uri: 'vless://11111111-1111-1111-1111-111111111111@ext.example.com:443#ExtNode', name: 'ExtNode' },
{ uri: 'socks5://ng%3Ac1:p1@a.example.com:1081#dup', name: 'dup' },
] },
},
};
}
test('aggregateEntries orders own by sortIndex, then manual, then external, with dedup', () => {
const data = aggData();
const customer = {
id: 'c1',
manualLinks: [{ id: 'lnk1', uri: 'hysteria2://pw@m.example.com:443?sni=m.example.com#Manual', remark: '', enabled: true }],
externalSources: [{ id: 'src1', url: 'https://src.example.com/sub', remark: '机场', enabled: true }],
};
const items = aggregateEntries(data, customer);
const names = items.map((item) => decodeURIComponent(item.clientUri.slice(item.clientUri.lastIndexOf('#') + 1)));
// The external socks5 entry duplicates own node n1 (same host/port/credentials) and is dropped.
assert.deepEqual(names, ['第一', '第二', 'Manual', '[机场] ExtNode']);
});
test('aggregateEntries with no aggregation fields matches own entries only', () => {
const data = aggData();
const items = aggregateEntries(data, { id: 'c1' });
assert.equal(items.length, 2);
});
// ---------- presence ----------
test('presence window and speed freshness', () => {
const now = Date.parse('2026-10-21T00:00:00.000Z');
const recent = new Date(now - 60000).toISOString();
const stale = new Date(now - 600000).toISOString();
assert.equal(customerOnline({ lastOnlineAt: recent }, now), true);
assert.equal(customerOnline({ lastOnlineAt: stale }, now), false);
assert.equal(customerOnline({}, now), false);
const customer = { serverUsage: { 'srv:s1': { speedUpBps: 100, speedDownBps: 200, speedAt: recent, lastActiveAt: recent } } };
assert.deepEqual(customerSpeedBps(customer, now), { up: 100, down: 200 });
assert.deepEqual(customerSpeedBps({ serverUsage: { 'srv:s1': { speedUpBps: 100, speedDownBps: 200, speedAt: stale } } }, now), { up: 0, down: 0 });
const data = {
customers: [{ id: 'c1', lastOnlineAt: stale, serverUsage: { 'srv:s1': { lastActiveAt: recent } } }],
nodeClients: [{ nodeId: 'n1', customerId: 'c1', status: 'active' }],
observations: [],
};
assert.deepEqual(nodeOnlineCustomerIds(data, { id: 'n1', serverId: 's1' }, now), ['c1']);
assert.deepEqual(nodeOnlineCustomerIds(data, { id: 'n1', serverId: 's2' }, now), []);
});
// ---------- API integration: sort / reorder / credentials / alignment ----------
async function freePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once('error', reject);
server.listen(0, '127.0.0.1', () => { const port = server.address().port; server.close(() => resolve(port)); });
});
}
async function waitFor(url) {
for (let i = 0; i < 60; i += 1) {
try { const response = await fetch(url); if (response.ok) return; } catch { /* retry */ }
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error('test server did not start');
}
test('api: node sortIndex, reorder, credentials, customer node alignment, presence', async (t) => {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'nexusgate-ui-'));
const port = await freePort();
const child = spawn(process.execPath, ['server.js'], {
cwd: path.resolve(__dirname, '..'),
env: { ...process.env, NG_HOST: '127.0.0.1', NG_PORT: String(port), NG_DATA_FILE: path.join(dir, 'data.json'), NG_ADMIN_PASSWORD: 'test-password', NG_COOKIE_SECURE: 'false' },
stdio: ['ignore', 'pipe', 'pipe'],
});
t.after(async () => { child.kill('SIGTERM'); await fs.promises.rm(dir, { recursive: true, force: true }); });
const base = `http://127.0.0.1:${port}`;
await waitFor(`${base}/healthz`);
const login = await fetch(`${base}/api/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ username: 'admin', password: 'test-password' }) });
const session = await login.json();
const cookie = login.headers.get('set-cookie').split(';')[0];
const request = async (pathname, method = 'GET', body) => {
const response = await fetch(`${base}${pathname}`, { method, headers: { cookie, 'x-csrf-token': session.csrf, ...(body ? { 'content-type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined });
const json = await response.json();
assert.ok(response.ok, `${method} ${pathname}: ${JSON.stringify(json)}`);
return json;
};
const server = (await request('/api/servers', 'POST', { name: 'S1', role: 'hybrid', publicAddress: 's1.example.com' })).server;
assert.equal(server.enabled, true);
const n1 = (await request('/api/nodes', 'POST', { name: 'N1', serverId: server.id, topology: 'direct', protocol: 'socks5-auth' })).node;
const n2 = (await request('/api/nodes', 'POST', { name: 'N2', serverId: server.id, topology: 'direct', protocol: 'socks5-auth' })).node;
assert.equal(n1.sortIndex, 0);
assert.equal(n2.sortIndex, 1);
// Reorder flips list order.
await request('/api/nodes/reorder', 'POST', { ids: [n2.id, n1.id] });
let listed = (await request('/api/nodes')).nodes;
assert.deepEqual(listed.map((node) => node.name), ['N2', 'N1']);
assert.deepEqual(listed.map((node) => node.sortIndex), [0, 1]);
// Create with nodeIds alignment + monthly reset + aggregation fields.
const customer = (await request('/api/customers', 'POST', {
name: '对齐客户', trafficResetDay: 15, nodeIds: [n1.id],
manualLinks: [{ uri: 'vless://11111111-1111-1111-1111-111111111111@x.example.com:443#M', remark: '手动一' }],
})).customer;
assert.equal(customer.trafficResetDay, 15);
assert.equal(customer.trafficResetPeriod, trafficResetPeriodKey(new Date(), 15));
assert.equal(customer.manualLinks.length, 1);
let nodesView = await request('/api/nodes');
assert.equal(nodesView.nodeClients.filter((nc) => nc.customerId === customer.id).length, 1);
// PATCH alignment swaps the binding set.
await request(`/api/customers/${customer.id}`, 'PATCH', { nodeIds: [n2.id] });
nodesView = await request('/api/nodes');
const bindings = nodesView.nodeClients.filter((nc) => nc.customerId === customer.id);
assert.equal(bindings.length, 1);
assert.equal(bindings[0].nodeId, n2.id);
// Credentials view + regeneration rotates the binding credential.
const before = await request(`/api/customers/${customer.id}/credentials`);
assert.equal(before.items.length, 1);
assert.ok(before.items[0].clientId);
assert.equal(before.subscriptionToken, customer.subscriptionToken);
const regenerated = (await request(`/api/customers/${customer.id}/credentials/${before.items[0].nodeClientId}/regenerate`, 'POST', {})).item;
assert.notEqual(regenerated.clientId, before.items[0].clientId);
assert.notEqual(regenerated.password, before.items[0].password);
// Presence: enroll an agent and report usage twice; the customer turns
// online after the first positive delta.
const ticket = await request(`/api/servers/${server.id}/enrollment-token`, 'POST', {});
const enrolled = await fetch(`${base}/api/agent/enroll`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ token: ticket.token, hostname: 'h' }) });
const agentKey = (await enrolled.json()).agentKey;
const usage = (samples) => fetch(`${base}/api/agent/usage`, { method: 'POST', headers: { authorization: `Bearer ${agentKey}`, 'content-type': 'application/json' }, body: JSON.stringify({ samples, epoch: 'e1' }) });
await usage([{ kind: 'user', id: customer.id, uplink: 1000, downlink: 2000 }]);
const customers = (await request('/api/customers')).customers;
const view = customers.find((item) => item.id === customer.id);
assert.equal(view.online, true);
assert.ok(view.lastActiveAt);
// System status endpoint shape.
const system = await request('/api/system');
assert.ok('host' in system && 'network' in system && 'history' in system && 'ips' in system);
assert.equal(system.network.customersTotal, 1);
});