feat(panel): reskin backend — node sortIndex, presence/speed, /api/system, credentials, subscription aggregation (Node+Go parity)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user