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
+55
View File
@@ -0,0 +1,55 @@
# 订阅聚合 · 小设计(待拍板)
> 目标:客户订阅在自有节点之外,可附加 **① 外部订阅源(面板定时拉取)② 手动节点链接**,合并成一份订阅输出,"一个订阅用全部入站"。
> 现状锚点:订阅管线在 `lib/subscriptions.js`(Go:`panel-go/subscriptions.go`),入口 `formatSubscription / FormatSubscription`,条目为 `{nodeId, clientUri}`、名字挂在 URI 的 `#` 后,输出 raw/base64/clash/clash-smart/singbox/surge 五格式;Clash 等格式都经 `clashProxy` 从 URI 反解,**不支持的 scheme 会让整单生成抛错**——聚合的所有过滤必须在进管线前完成。
## 1. 数据模型(存进现有 JSON 库,跟随现状形态)
- 客户新增 `externalSources: [{id, url, remark, enabled, userAgent?}]`,上限 **5 个/客户**——防滥用且够用;字段风格与现有客户字段一致(camelCase,如 `trafficResetDay`)。
- 客户新增 `manualLinks: [{id, uri, remark, enabled}]`,上限 **50 条/客户**;保存时即做 scheme 白名单与可解析校验,坏链接不许入库。
- 拉取缓存放顶层对象 `subSourceCache: { [sourceId]: {fetchedAt, okAt, failCount, lastError, droppedCount, items:[{uri, name}]} }`,仿 `keyStore` 先例(对象形态、不入 COLLECTIONS 校验;Node 侧 `||= {}` 兜底、Go 侧 `Data` 加 map 字段)——缓存与客户配置分离,编辑客户不碰缓存,源被删时顺手清对应缓存键。
## 2. 拉取与缓存
- **刷新间隔**:全局设置 `settings.externalSubRefreshMinutes`,默认 **60 分钟**,可配 15–720;机场源普遍限频,60 分钟兼顾新鲜与不被风控。调度挂现有 **60s housekeeping**(Node `server.js housekeeping()` / Go `main.go housekeeping()`),按每源 `fetchedAt + 间隔` 判到期、加 ±10% 抖动防同刻齐发——与月重置同为"落盘标记判定、不靠内存计时器",面板重启不丢节奏。
- **单次拉取上限**:超时 **10s**、响应体 **1 MiB**、解析后条目 **200 条/源**、重定向 ≤3 跳;超限即判失败走兜底。
- **失败策略**:失败只累加 `failCount` 并退避(间隔 ×2ⁿ,上限 24h),**永远沿用上一次成功缓存输出**;`failCount ≥ 3` 时客户详情把该源标红提示。从未成功过的源输出为空、状态显示失败原因。**自有节点的生成路径与外源完全解耦**——外源拉取/解析出任何错,自有部分照常出(现状"无自有节点出 503"的判定改为"自有+手动+外源全空才 503")。
- **触发入口**:新增/编辑源保存后立即异步拉一次;客户详情每源一个「立即刷新」按钮(`POST /api/customers/:id/external-sources/:sid/refresh`,同步返回条数/错误供回显);到期由 housekeeping 自动拉。housekeeping 内串行执行、每轮最多 3 个源,护路由器 CPU。
- **缓存过期**:成功缓存**不设硬过期**——兜底优先于新鲜,源死了老节点继续发,总比订阅空掉强;新鲜度只由 `okAt` 在 UI 上展示。
## 3. 解析范围(首版只吃"链接列表")
- 输入形态按序判定:① 整体 base64 解码成功且解出含 scheme 的行 → 按行拆分;② 明文逐行(跳空行);③ 整个 body 就是一条链接(①②的自然子集)。覆盖 v2rayN 系与绝大多数机场的默认返回。
- **协议白名单 = 现管线五种**:`vless://`、`ss://`、`hysteria2://`、`anytls://`、`socks5://`(正是 `buildClientUri` 产物、`clashProxy` 可反解的集合)。`vmess://`、`trojan://` 及未知 scheme **丢弃并计数**(`droppedCount` 落缓存、UI 可见"已忽略 N 条")——一条坏链接绝不能毁整单。
- **Clash YAML / sing-box JSON 源首版不解析,放二期**:两者是完整配置语义,做 outbound→URI 逆向映射等于再造半个转换器,且与五格式输出管线重复;用下方 UA 策略拉到的基本都是链接列表,首版覆盖面已够。
## 4. 合并与输出
- **排序固定分段、不交错**:自有节点(按定版 `sortIndex` 序)→ 手动链接(列表序)→ 外部源(源添加序、源内保持原序)。自有是计费与可用性主体、排前面保证客户端默认落自有;分段固定使外源抖动不改变自有相对顺序,订阅内容 diff 稳定。
- **命名**:外部项名 = `[源备注] 原名`(源备注为空则不加前缀);手动项名取备注 → URI 自带 `#名` → `手动节点 N` 兜底。合并后全局同名沿用现有后缀逻辑(`名 (2)`、Clash 侧 `(节点N)`),与自有重名处理完全一致。
- **去重键**:`scheme + 主机(小写)+ 端口 + sha256(URI userinfo 原文) 前 16 位`;全局去重、优先级 自有 > 手动 > 外部——外部若与自有指向同一节点,保自有(凭据与流量统计都在自有侧)。
- **流量口径不变**:`subscription-userinfo` 与额度判定只读自有三计数(`usedBytes` 等),外部/手动流量不计量、不参与暂停判定;按月重置只清自有计数,互不干扰。
- **停用/暂停**:跟随现有订阅端点整单门禁(`handleSubscription` 已对非 active/过期/超额整单 403),**外部项不单开任何发放通道**。与"迁移无感"原则的关系:无感指凭据与端点不变,停用语义对客户只有一种——不出现"自有停了、外部还能用"的割裂状态;且刷新对暂停客户照常跑,恢复启用瞬间外源就是新鲜的。
## 5. 安全与边界
- **SSRF 默认禁内网**:拉取前解析主机名到 IP,命中环回/私网/链路本地段(含 169.254、::1、fc00::/8、fe80::/10)一律拒绝;重定向每一跳复检;连接时以校验过的 IP 直连并保留 Host/SNI(防 DNS rebinding)。面板在路由器上能摸到整个内网,这条是硬底线。设置 `settings.allowPrivateSubSources` 默认 **关**,用户有自托管订阅源时才手动打开。
- 只接受 http/https、URL 长度 ≤ 2048;源 URL 在 UI、日志、activity 审计里一律脱敏(只显 scheme+host,query 打码,沿用现有 redact 思路);订阅 token 与源 URL 都不落明文日志。
- **拉取 UA**:默认 `v2rayN/6.55`——机场对该 UA 普遍回 base64 链接列表,正中首版解析面;允许按源填 `userAgent` 覆盖。
- 面板出站拉取走主控自身网络,不经任何节点转发;拉取结果只进缓存、不执行其中任何内容。
## 6. 双实现影响
- **Node**:新增 `lib/extsub.js`(拉取+解析+归一化);`lib/subscriptions.js` 在 `entries()` 后拼接聚合段;`server.js` housekeeping 挂到期扫描、客户 CRUD 校验新字段、加手动刷新端点。
- **Go(生产主控,必须同步)**:新增 `panel-go/extsub.go` 对应物;`subscriptions.go` 的 `Entries/FormatSubscription` 同步拼接;`main.go` housekeeping 挂扫描;`handlers.go` 同步 API。字段同名同义、JSON 库双向兼容(两边都能读对方写的文件)。
- **agent / agent-go:零改动**——聚合纯主控侧,agent 只管自有节点的部署与计数上报,协议不变。
- 验收:`panel-go/difftest` 差分 E2E 增加聚合用例(同种子库 + 本地假外源 fixture,两实现五格式输出逐字节比对),过不了不算完。
## 7. 分期
- **首版(本设计全量)**:字段与缓存、链接列表解析、五格式合并输出、定时+手动刷新、失败兜底、SSRF 禁内网、双实现同步。
- **二期**:Clash YAML / sing-box JSON 源解析;外部节点健康探测与失效自动剔除;按源建 Clash 分组;手动/外部与自有交错排序;按源流量估算展示(仅展示、不计费)。
---
**需拍板(默认即推荐值)**:刷新 60 分钟 / 内网源默认禁 / 外部项排自有之后 / 停用整单停发 / 首版不吃 YAML。
+284
View File
@@ -0,0 +1,284 @@
'use strict';
// External subscription aggregation (panel-go/extsub.go mirrors this
// file; design: SUB-AGGREGATION-DESIGN.md). The panel fetches a
// customer's external subscription sources, parses link lists, caches
// the result in data.subSourceCache and merges manual + external items
// into the subscription output behind the customer's own nodes.
//
// Safety: http/https only, SSRF guard rejects non-global IPs unless
// settings.allowPrivateSubSources is on, every redirect hop is
// re-validated, the connection is pinned to the validated IP (no DNS
// rebinding window), 10s timeout, 1MiB body cap, 200 items per source.
const http = require('node:http');
const https = require('node:https');
const dns = require('node:dns/promises');
const net = require('node:net');
const crypto = require('node:crypto');
const { isGlobalIPv4, isGlobalIPv6 } = require('./sysstat');
const LINK_SCHEMES = ['vless:', 'ss:', 'hysteria2:', 'anytls:', 'socks5:'];
const MAX_BODY_BYTES = 1024 * 1024;
const MAX_ITEMS = 200;
const FETCH_TIMEOUT_MS = 10000;
const MAX_REDIRECTS = 3;
const DEFAULT_UA = 'v2rayN/6.55';
function isGlobalIP(ip) {
const family = net.isIP(ip);
if (family === 4) return isGlobalIPv4(ip);
if (family === 6) return isGlobalIPv6(ip.replace(/^\[|\]$/g, ''));
return false;
}
function validateLinkUri(uri) {
if (typeof uri !== 'string' || !uri || uri.length > 2048) return false;
let url;
try { url = new URL(uri); } catch { return false; }
return LINK_SCHEMES.includes(url.protocol) && Boolean(url.hostname);
}
// Parse a fetched body into a normalized link list. Accepts a base64
// blob or plaintext lines; unsupported schemes and unparseable lines are
// dropped and counted, never fatal.
function parseLinkList(body) {
let text = String(body == null ? '' : body);
const compact = text.replace(/\s+/g, '');
if (compact.length >= 16 && /^[A-Za-z0-9+/=_-]+$/.test(compact)) {
try {
const normalized = compact.replace(/-/g, '+').replace(/_/g, '/');
const decoded = Buffer.from(normalized, 'base64').toString('utf8');
if (decoded.includes('://')) text = decoded;
} catch { /* keep plaintext */ }
}
const items = [];
let dropped = 0;
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line) continue;
if (items.length >= MAX_ITEMS) { dropped += 1; continue; }
if (!validateLinkUri(line)) { dropped += 1; continue; }
let name = '';
const hash = line.lastIndexOf('#');
if (hash >= 0) {
try { name = decodeURIComponent(line.slice(hash + 1)); } catch { name = line.slice(hash + 1); }
}
items.push({ uri: line, name: name.slice(0, 120) });
}
return { items, dropped };
}
async function resolveChecked(hostname, allowPrivate) {
let addresses;
if (net.isIP(hostname)) {
addresses = [hostname];
} else {
const records = await dns.lookup(hostname, { all: true });
addresses = records.map((record) => record.address);
}
if (!addresses.length) throw new Error('域名解析无结果');
if (!allowPrivate && addresses.some((ip) => !isGlobalIP(ip))) {
throw new Error('订阅源指向非公网地址,已拒绝');
}
return addresses[0];
}
function fetchOnce(urlString, { userAgent, allowPrivate, deadline }) {
return new Promise((resolve, reject) => {
(async () => {
const url = new URL(urlString);
if (!['http:', 'https:'].includes(url.protocol)) throw new Error('仅支持 http/https 订阅源');
const hostname = url.hostname.replace(/^\[|\]$/g, '');
const ip = await resolveChecked(hostname, allowPrivate);
const isTls = url.protocol === 'https:';
const port = Number(url.port || (isTls ? 443 : 80));
const hostHeader = url.port ? `${hostname}:${url.port}` : hostname;
const options = {
hostname: ip, port, path: `${url.pathname}${url.search}`, method: 'GET',
headers: { host: hostHeader, 'user-agent': userAgent || DEFAULT_UA, accept: '*/*' },
timeout: Math.max(1000, deadline - Date.now()),
};
if (isTls) options.servername = hostname;
const transport = isTls ? https : http;
const req = transport.request(options, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
res.resume();
resolve({ redirect: new URL(res.headers.location, urlString).toString() });
return;
}
if (res.statusCode !== 200) {
res.resume();
reject(new Error(`订阅源返回 HTTP ${res.statusCode}`));
return;
}
const chunks = [];
let size = 0;
res.on('data', (chunk) => {
size += chunk.length;
if (size > MAX_BODY_BYTES) {
req.destroy(new Error('订阅源响应超过 1MiB'));
return;
}
chunks.push(chunk);
});
res.on('end', () => resolve({ body: Buffer.concat(chunks).toString('utf8') }));
res.on('error', reject);
});
req.on('timeout', () => req.destroy(new Error('订阅源请求超时')));
req.on('error', reject);
req.end();
})().catch(reject);
});
}
async function fetchSourceBody(source, settings) {
const allowPrivate = Boolean(settings && settings.allowPrivateSubSources);
const deadline = Date.now() + FETCH_TIMEOUT_MS;
let current = source.url;
for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) {
if (Date.now() > deadline) throw new Error('订阅源请求超时');
const result = await fetchOnce(current, { userAgent: source.userAgent, allowPrivate, deadline });
if (!result.redirect) return result.body;
if (hop === MAX_REDIRECTS) throw new Error('订阅源重定向次数过多');
current = result.redirect;
}
throw new Error('订阅源请求失败');
}
// Fetch + parse one source. Never throws: returns { ok, items, dropped, error }.
async function fetchExternalSource(source, settings) {
try {
const body = await fetchSourceBody(source, settings || {});
const { items, dropped } = parseLinkList(body);
return { ok: true, items, dropped, error: null };
} catch (error) {
return { ok: false, items: [], dropped: 0, error: String(error && error.message || error).slice(0, 240) };
}
}
function refreshMinutes(settings) {
const value = Number(settings && settings.externalSubRefreshMinutes);
if (!Number.isFinite(value) || value <= 0) return 60;
return Math.min(720, Math.max(15, value));
}
// Deterministic ±10% jitter from the source id so both implementations
// and restarts agree, and sources do not all fire in the same pass.
function jitterFactor(sourceId) {
const hash = crypto.createHash('sha256').update(String(sourceId)).digest();
return 1 + ((hash[0] % 21) - 10) / 100;
}
// Collect sources due for a refresh pass: [{ customerId, source }].
function dueSources(data, nowMs, limit = 3) {
const out = [];
const cache = data.subSourceCache || {};
for (const customer of data.customers) {
for (const source of customer.externalSources || []) {
if (!source.enabled) continue;
const entry = cache[source.id];
if (!entry || !entry.fetchedAt) { out.push({ customerId: customer.id, source }); continue; }
const base = refreshMinutes(data.settings) * 60000;
const backoff = Math.min(Math.pow(2, Math.min(entry.failCount || 0, 10)) * base, 24 * 3600 * 1000);
const due = Date.parse(entry.fetchedAt) + backoff * jitterFactor(source.id);
if (nowMs >= due) out.push({ customerId: customer.id, source });
if (out.length >= limit) return out;
}
if (out.length >= limit) break;
}
return out.slice(0, limit);
}
// Apply one fetch result to the cache inside a store transaction draft.
function applyFetchResult(data, sourceId, result, nowIso) {
data.subSourceCache ||= {};
const previous = data.subSourceCache[sourceId] || { fetchedAt: null, okAt: null, failCount: 0, lastError: null, droppedCount: 0, items: [] };
const entry = { ...previous, fetchedAt: nowIso };
if (result.ok) {
entry.okAt = nowIso;
entry.failCount = 0;
entry.lastError = null;
entry.droppedCount = result.dropped;
entry.items = result.items;
} else {
entry.failCount = (previous.failCount || 0) + 1;
entry.lastError = result.error;
}
data.subSourceCache[sourceId] = entry;
return entry;
}
// ---------- save-time normalization (shared by create/PATCH) ----------
function cleanTextLocal(value, max) {
return String(value == null ? '' : value).trim().slice(0, max);
}
function normalizeExternalSources(value, idFn) {
if (value == null) return undefined;
if (!Array.isArray(value)) { const e = new Error('外部订阅格式无效'); e.statusCode = 400; throw e; }
if (value.length > 5) { const e = new Error('每个客户最多 5 个外部订阅'); e.statusCode = 400; throw e; }
return value.map((raw) => {
const item = raw && typeof raw === 'object' ? raw : {};
const url = cleanTextLocal(item.url, 2048);
let parsed;
try { parsed = new URL(url); } catch { parsed = null; }
if (!parsed || !['http:', 'https:'].includes(parsed.protocol) || !parsed.hostname) {
const e = new Error('外部订阅地址无效'); e.statusCode = 400; throw e;
}
const next = {
id: typeof item.id === 'string' && item.id ? item.id.slice(0, 80) : idFn('src'),
url, remark: cleanTextLocal(item.remark, 80), enabled: item.enabled !== false,
};
const ua = cleanTextLocal(item.userAgent, 120);
if (ua) next.userAgent = ua;
return next;
});
}
function normalizeManualLinks(value, idFn) {
if (value == null) return undefined;
if (!Array.isArray(value)) { const e = new Error('外部链接格式无效'); e.statusCode = 400; throw e; }
if (value.length > 50) { const e = new Error('每个客户最多 50 条外部链接'); e.statusCode = 400; throw e; }
return value.map((raw) => {
const item = raw && typeof raw === 'object' ? raw : {};
const uri = cleanTextLocal(item.uri, 2048);
if (!validateLinkUri(uri)) { const e = new Error('外部链接无效或协议不受支持'); e.statusCode = 400; throw e; }
return {
id: typeof item.id === 'string' && item.id ? item.id.slice(0, 80) : idFn('lnk'),
uri, remark: cleanTextLocal(item.remark, 80), enabled: item.enabled !== false,
};
});
}
// ---------- merge helpers (used by lib/subscriptions.js) ----------
function dedupKey(uri) {
try {
const url = new URL(uri);
let userinfo = '';
try { userinfo = `${decodeURIComponent(url.username)}:${decodeURIComponent(url.password)}`; }
catch { userinfo = `${url.username}:${url.password}`; }
const hash = crypto.createHash('sha256').update(userinfo).digest('hex').slice(0, 16);
return `${url.protocol}//${url.hostname.toLowerCase()}:${url.port}#${hash}`;
} catch { return null; }
}
function withName(uri, name) {
const separator = uri.lastIndexOf('#');
const base = separator >= 0 ? uri.slice(0, separator) : uri;
return `${base}#${encodeURIComponent(name)}`;
}
function nameFromUri(uri) {
const hash = uri.lastIndexOf('#');
if (hash < 0) return '';
try { return decodeURIComponent(uri.slice(hash + 1)); } catch { return uri.slice(hash + 1); }
}
module.exports = {
LINK_SCHEMES, DEFAULT_UA, validateLinkUri, parseLinkList, fetchExternalSource,
refreshMinutes, dueSources, applyFetchResult, normalizeExternalSources, normalizeManualLinks,
dedupKey, withName, nameFromUri,
};
+125 -39
View File
@@ -240,6 +240,9 @@ function validateNodeDeploy(data, node) {
else validateProtocolPair(node.protocol, node.exitProtocol); else validateProtocolPair(node.protocol, node.exitProtocol);
const server = assertFound(data.servers.find((s) => s.id === node.serverId), '服务器'); const server = assertFound(data.servers.find((s) => s.id === node.serverId), '服务器');
const exitServer = direct ? null : assertFound(data.servers.find((s) => s.id === node.exitServerId), '出口服务器'); const exitServer = direct ? null : assertFound(data.servers.find((s) => s.id === node.exitServerId), '出口服务器');
for (const target of direct ? [server] : [server, exitServer]) {
if (target.enabled === false) { const error = new Error(`设备 ${target.name} 已停用`); error.statusCode = 409; throw error; }
}
if (!activeNodeClients(data, node).length) { const error = new Error('请先给节点分配至少一个客户再部署'); error.statusCode = 400; throw error; } if (!activeNodeClients(data, node).length) { const error = new Error('请先给节点分配至少一个客户再部署'); error.statusCode = 400; throw error; }
if (['vless-ws-tls', 'hysteria2', 'anytls'].includes(node.protocol)) { if (['vless-ws-tls', 'hysteria2', 'anytls'].includes(node.protocol)) {
if (!server.tlsDomain) { const error = new Error(`${node.protocol} 需要先填写设备 TLS 域名并在节点上安装证书`); error.statusCode = 400; throw error; } if (!server.tlsDomain) { const error = new Error(`${node.protocol} 需要先填写设备 TLS 域名并在节点上安装证书`); error.statusCode = 400; throw error; }
@@ -281,6 +284,8 @@ function validateNodeDeploy(data, node) {
// gates on "no in-flight jobs" instead and passes failInFlight: false. // gates on "no in-flight jobs" instead and passes failInFlight: false.
async function reconcileServerData(data, serverId, actor, { onlyNodeIds = null, failInFlight = true } = {}) { async function reconcileServerData(data, serverId, actor, { onlyNodeIds = null, failInFlight = true } = {}) {
const server = assertFound(data.servers.find((item) => item.id === serverId), '服务器'); const server = assertFound(data.servers.find((item) => item.id === serverId), '服务器');
// A disabled device receives no new deployments, including reconciles.
if (server.enabled === false) return 0;
if (failInFlight) { if (failInFlight) {
for (const job of data.jobs.filter((item) => item.serverId === serverId && ['queued', 'running'].includes(item.status))) { for (const job of data.jobs.filter((item) => item.serverId === serverId && ['queued', 'running'].includes(item.status))) {
job.status = 'failed'; job.error = 'Agent 重新注册,旧任务已由对账任务替代'; job.updatedAt = nowIso(); job.status = 'failed'; job.error = 'Agent 重新注册,旧任务已由对账任务替代'; job.updatedAt = nowIso();
@@ -314,6 +319,90 @@ async function reconcileServerData(data, serverId, actor, { onlyNodeIds = null,
return count; return count;
} }
// Data-level assign/unassign (the transaction-free cores of the
// Orchestrator methods) so the customer form's node alignment can run
// inside the customer create/PATCH transaction.
async function assignCustomerData(data, nodeId, customerId, actor = 'admin') {
const node = assertFound(data.nodes.find((n) => n.id === nodeId), '节点');
const customer = assertFound(data.customers.find((c) => c.id === customerId), '客户');
if (customer.status !== 'active') { const error = new Error('只能给状态正常的客户分配节点'); error.statusCode = 409; throw error; }
let nc = data.nodeClients.find((entry) => entry.nodeId === nodeId && entry.customerId === customerId);
if (nc && nc.status === 'active') { const error = new Error('该客户已分配到此节点'); error.statusCode = 409; throw error; }
if (nc) {
nc.status = 'active'; nc.updatedAt = nowIso();
} else {
nc = {
id: id('ncl'), nodeId, customerId, credentials: newNodeClientCredentials(node.protocol),
status: 'active', createdAt: nowIso(), updatedAt: nowIso(),
};
data.nodeClients.push(nc);
}
// A manually undeployed node stays draft until explicitly deployed.
// Automatic teardowns (suspend) are revived when clients return.
if ((node.status === 'removing' || node.status === 'draft') && !node.autoRemoving) {
// record the assignment only
} else {
if (node.status === 'removing' && node.autoRemoving) {
cancelQueuedDeletes(data, node.id, '已取消:节点恢复服务');
node.autoRemoving = false;
}
await rebuildNode(data, node);
}
audit(data, actor, 'assign_customer', node.id, { customerId, customer: customer.name });
return structuredClone(nc);
}
async function unassignCustomerData(data, nodeId, customerId, actor = 'admin') {
const node = assertFound(data.nodes.find((n) => n.id === nodeId), '节点');
const index = data.nodeClients.findIndex((entry) => entry.nodeId === nodeId && entry.customerId === customerId);
if (index < 0) { const error = new Error('该客户未分配到此节点'); error.statusCode = 404; throw error; }
data.nodeClients.splice(index, 1);
if (node.status !== 'draft' && node.status !== 'removing') await rebuildNode(data, node);
audit(data, actor, 'unassign_customer', node.id, { customerId });
return true;
}
// Full-set alignment used by the customer form's 关联入站 multi-select:
// bindings not in desiredIds are removed, missing ones are created.
// Suspended customers get suspended bindings (mirroring their resource
// state) without touching node resources.
async function alignCustomerNodes(data, customer, desiredIds, actor = 'admin') {
const desired = [...new Set((desiredIds || []).map(String).filter(Boolean))];
for (const nodeId of desired) {
const node = data.nodes.find((n) => n.id === nodeId && !n.pendingDelete);
if (!node) { const error = new Error('节点不存在'); error.statusCode = 400; throw error; }
}
const current = data.nodeClients.filter((nc) => nc.customerId === customer.id);
const rebuild = new Set();
for (const nc of [...current]) {
if (desired.includes(nc.nodeId)) continue;
data.nodeClients.splice(data.nodeClients.indexOf(nc), 1);
audit(data, actor, 'unassign_customer', nc.nodeId, { customerId: customer.id });
rebuild.add(nc.nodeId);
}
for (const nodeId of desired) {
if (current.some((nc) => nc.nodeId === nodeId)) continue;
const node = data.nodes.find((n) => n.id === nodeId);
const nc = {
id: id('ncl'), nodeId, customerId: customer.id, credentials: newNodeClientCredentials(node.protocol),
status: customer.status === 'active' ? 'active' : 'suspended', createdAt: nowIso(), updatedAt: nowIso(),
};
data.nodeClients.push(nc);
audit(data, actor, 'assign_customer', node.id, { customerId: customer.id, customer: customer.name });
if (nc.status === 'active') rebuild.add(nodeId);
}
for (const nodeId of rebuild) {
const node = data.nodes.find((n) => n.id === nodeId);
if (!node) continue;
if ((node.status === 'removing' || node.status === 'draft') && !node.autoRemoving) continue;
if (node.status === 'removing' && node.autoRemoving) {
cancelQueuedDeletes(data, node.id, '已取消:节点恢复服务');
node.autoRemoving = false;
}
await rebuildNode(data, node);
}
}
class Orchestrator { class Orchestrator {
constructor(store) { constructor(store) {
this.store = store; this.store = store;
@@ -365,47 +454,11 @@ class Orchestrator {
} }
async assignCustomer(nodeId, customerId, actor = 'admin') { async assignCustomer(nodeId, customerId, actor = 'admin') {
return this.store.transaction(async (data) => { return this.store.transaction(async (data) => assignCustomerData(data, nodeId, customerId, actor));
const node = assertFound(data.nodes.find((n) => n.id === nodeId), '节点');
const customer = assertFound(data.customers.find((c) => c.id === customerId), '客户');
if (customer.status !== 'active') { const error = new Error('只能给状态正常的客户分配节点'); error.statusCode = 409; throw error; }
let nc = data.nodeClients.find((entry) => entry.nodeId === nodeId && entry.customerId === customerId);
if (nc && nc.status === 'active') { const error = new Error('该客户已分配到此节点'); error.statusCode = 409; throw error; }
if (nc) {
nc.status = 'active'; nc.updatedAt = nowIso();
} else {
nc = {
id: id('ncl'), nodeId, customerId, credentials: newNodeClientCredentials(node.protocol),
status: 'active', createdAt: nowIso(), updatedAt: nowIso(),
};
data.nodeClients.push(nc);
}
// A manually undeployed node stays draft until explicitly deployed.
// Automatic teardowns (suspend) are revived when clients return.
if ((node.status === 'removing' || node.status === 'draft') && !node.autoRemoving) {
// record the assignment only
} else {
if (node.status === 'removing' && node.autoRemoving) {
cancelQueuedDeletes(data, node.id, '已取消:节点恢复服务');
node.autoRemoving = false;
}
await rebuildNode(data, node);
}
audit(data, actor, 'assign_customer', node.id, { customerId, customer: customer.name });
return structuredClone(nc);
});
} }
async unassignCustomer(nodeId, customerId, actor = 'admin') { async unassignCustomer(nodeId, customerId, actor = 'admin') {
return this.store.transaction(async (data) => { return this.store.transaction(async (data) => unassignCustomerData(data, nodeId, customerId, actor));
const node = assertFound(data.nodes.find((n) => n.id === nodeId), '节点');
const index = data.nodeClients.findIndex((entry) => entry.nodeId === nodeId && entry.customerId === customerId);
if (index < 0) { const error = new Error('该客户未分配到此节点'); error.statusCode = 404; throw error; }
data.nodeClients.splice(index, 1);
if (node.status !== 'draft' && node.status !== 'removing') await rebuildNode(data, node);
audit(data, actor, 'unassign_customer', node.id, { customerId });
return true;
});
} }
async reconcileServer(serverId, actor = 'system', options = {}) { async reconcileServer(serverId, actor = 'system', options = {}) {
@@ -516,8 +569,27 @@ class Orchestrator {
const key = `srv:${serverId}`; const key = `srv:${serverId}`;
customer.serverUsage = customer.serverUsage || {}; customer.serverUsage = customer.serverUsage || {};
const slot = customer.serverUsage[key] || (customer.serverUsage[key] = {}); const slot = customer.serverUsage[key] || (customer.serverUsage[key] = {});
const prevCounters = slot.usageCounters;
const prevSampleAt = slot.lastSampleAt;
const delta = applyDelta(slot, uplink, downlink, sampleEpoch); const delta = applyDelta(slot, uplink, downlink, sampleEpoch);
if (!delta) continue; if (!delta) continue;
slot.lastSampleAt = reportedAt;
// Presence + approximate speed from the same delta (no extra
// sampling channel): a positive delta is activity; speed is the
// delta over the gap since the previous processed sample.
const restarted = Boolean(sampleEpoch && prevCounters && prevCounters.epoch && sampleEpoch !== prevCounters.epoch);
if (delta.upDelta + delta.downDelta > 0) {
customer.lastOnlineAt = reportedAt;
slot.lastActiveAt = reportedAt;
}
if (prevSampleAt && !restarted) {
const dt = (Date.parse(reportedAt) - Date.parse(prevSampleAt)) / 1000;
if (dt >= 5) {
slot.speedUpBps = Math.round(delta.upDelta / dt);
slot.speedDownBps = Math.round(delta.downDelta / dt);
slot.speedAt = reportedAt;
}
}
customer.usedUplinkBytes = (customer.usedUplinkBytes || 0) + delta.upDelta; customer.usedUplinkBytes = (customer.usedUplinkBytes || 0) + delta.upDelta;
customer.usedDownlinkBytes = (customer.usedDownlinkBytes || 0) + delta.downDelta; customer.usedDownlinkBytes = (customer.usedDownlinkBytes || 0) + delta.downDelta;
customer.usedBytes = (customer.usedBytes || 0) + delta.upDelta + delta.downDelta; customer.usedBytes = (customer.usedBytes || 0) + delta.upDelta + delta.downDelta;
@@ -528,8 +600,21 @@ class Orchestrator {
// transport and is never double counted (same rule as before). // transport and is never double counted (same rule as before).
const node = data.nodes.find((item) => item.id === sample.id && item.serverId === serverId); const node = data.nodes.find((item) => item.id === sample.id && item.serverId === serverId);
if (!node) continue; if (!node) continue;
const prevCounters = node.usageCounters;
const prevSampleAt = node.lastSampleAt;
const delta = applyDelta(node, uplink, downlink, sampleEpoch); const delta = applyDelta(node, uplink, downlink, sampleEpoch);
if (!delta) continue; if (!delta) continue;
node.lastSampleAt = reportedAt;
const restarted = Boolean(sampleEpoch && prevCounters && prevCounters.epoch && sampleEpoch !== prevCounters.epoch);
if (delta.upDelta + delta.downDelta > 0) node.lastActiveAt = reportedAt;
if (prevSampleAt && !restarted) {
const dt = (Date.parse(reportedAt) - Date.parse(prevSampleAt)) / 1000;
if (dt >= 5) {
node.speedUpBps = Math.round(delta.upDelta / dt);
node.speedDownBps = Math.round(delta.downDelta / dt);
node.speedAt = reportedAt;
}
}
const metered = node.meteredTraffic || { uplink: 0, downlink: 0 }; const metered = node.meteredTraffic || { uplink: 0, downlink: 0 };
node.meteredTraffic = { uplink: metered.uplink + delta.upDelta, downlink: metered.downlink + delta.downDelta }; node.meteredTraffic = { uplink: metered.uplink + delta.upDelta, downlink: metered.downlink + delta.downDelta };
node.lastUsageAt = reportedAt; node.lastUsageAt = reportedAt;
@@ -541,4 +626,5 @@ class Orchestrator {
module.exports = { Orchestrator, allocatePort, selectPort, nodePortInUse, queueJob, audit, id, nowIso, cancelQueuedDeletes, resourceForNode, module.exports = { Orchestrator, allocatePort, selectPort, nodePortInUse, queueJob, audit, id, nowIso, cancelQueuedDeletes, resourceForNode,
resourceForNodeExit, rebuildNode, applyDelta, forwardNode, activeNodeClients, resourceForNodeExit, rebuildNode, applyDelta, forwardNode, activeNodeClients,
suspendCustomerResources, resumeCustomerResources, HEARTBEAT_RECONCILE_INTERVAL_MS }; suspendCustomerResources, resumeCustomerResources, HEARTBEAT_RECONCILE_INTERVAL_MS,
assignCustomerData, unassignCustomerData, alignCustomerNodes };
+81
View File
@@ -0,0 +1,81 @@
'use strict';
// Presence & speed derivation (panel-go/presence.go mirrors this file).
//
// Signals (agent protocol unchanged):
// - Agent usage reports carry cumulative per-user counters aggregated per
// server (NOT per node) and cumulative per-inbound (per node) counters.
// A processed sample whose delta is > 0 marks activity: per-server for
// the customer (serverUsage slot lastActiveAt + customer.lastOnlineAt),
// per-node for the inbound (node.lastActiveAt).
// - AnyTLS observations confirm customer activity and also stamp
// customer.lastOnlineAt at ingest time.
// A customer counts as "online on node N" when they hold an active
// binding on N and their last activity on N's server is inside the
// window. With several nodes on one server this attributes the activity
// to each of them — the reports cannot separate nodes on a server.
const PRESENCE_WINDOW_MS = 180000; // 3 minutes
const SPEED_STALE_MS = 150000;
function parseMs(value) {
if (!value) return 0;
const ms = Date.parse(value);
return Number.isFinite(ms) ? ms : 0;
}
function customerLastActiveAt(customer) {
return customer.lastOnlineAt || null;
}
function customerOnline(customer, nowMs) {
const ms = parseMs(customer.lastOnlineAt);
return ms > 0 && nowMs - ms <= PRESENCE_WINDOW_MS;
}
// Sum of the customer's per-server speeds that are still fresh.
function customerSpeedBps(customer, nowMs) {
let up = 0;
let down = 0;
for (const slot of Object.values(customer.serverUsage || {})) {
if (!slot || !slot.speedAt) continue;
if (nowMs - parseMs(slot.speedAt) > SPEED_STALE_MS) continue;
up += Number(slot.speedUpBps || 0);
down += Number(slot.speedDownBps || 0);
}
return { up, down };
}
function nodeSpeedBps(node, nowMs) {
if (!node.speedAt || nowMs - parseMs(node.speedAt) > SPEED_STALE_MS) return { up: 0, down: 0 };
return { up: Number(node.speedUpBps || 0), down: Number(node.speedDownBps || 0) };
}
// Customers (ids) with an active binding on this node whose last
// activity on the node's server is inside the presence window. The
// per-server usage slot is authoritative; when the customer has never
// reported usage from this server, the customer-level lastOnlineAt is
// used as a fallback (AnyTLS observations stamp it without creating a
// usage slot, and they are validated against this server at ingest).
function nodeOnlineCustomerIds(data, node, nowMs) {
const out = [];
for (const nc of data.nodeClients) {
if (nc.nodeId !== node.id || nc.status !== 'active') continue;
const customer = data.customers.find((item) => item.id === nc.customerId);
if (!customer) continue;
const slot = (customer.serverUsage || {})[`srv:${node.serverId}`];
const ms = slot && slot.lastActiveAt ? parseMs(slot.lastActiveAt) : parseMs(customer.lastOnlineAt);
if (ms > 0 && nowMs - ms <= PRESENCE_WINDOW_MS) out.push(customer.id);
}
return out;
}
function countOnlineCustomers(data, nowMs) {
return data.customers.filter((customer) => customerOnline(customer, nowMs)).length;
}
module.exports = {
PRESENCE_WINDOW_MS, SPEED_STALE_MS, parseMs,
customerLastActiveAt, customerOnline, customerSpeedBps, nodeSpeedBps,
nodeOnlineCustomerIds, countOnlineCustomers,
};
+96
View File
@@ -0,0 +1,96 @@
'use strict';
// Monthly traffic reset — Node port of panel-go/reset.go (the two panel
// implementations must stay behavior-identical). A customer with
// trafficResetDay (1-31) gets the cycle counters zeroed once per monthly
// period. Due-ness derives only from the persisted trafficResetPeriod
// marker, so a reset missed while the panel was down fires at the first
// housekeeping pass after it is back. All dates are panel-local.
function daysInMonth(year, monthIndex) {
return new Date(Date.UTC(year, monthIndex + 1, 0)).getUTCDate();
}
function resetInstant(year, monthIndex, day) {
const dim = daysInMonth(year, monthIndex);
return new Date(year, monthIndex, Math.min(day, dim), 0, 0, 0, 0);
}
function periodKeyOf(date) {
const pad = (n) => String(n).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}`;
}
// The "YYYY-MM" of the most recent scheduled reset instant at or before now.
function trafficResetPeriodKey(now, day) {
let year = now.getFullYear();
let month = now.getMonth();
let cand = resetInstant(year, month, day);
if (now.getTime() < cand.getTime()) {
month -= 1;
if (month < 0) { month = 11; year -= 1; }
cand = resetInstant(year, month, day);
}
return periodKeyOf(cand);
}
// (Re)arm a customer's period marker for its current reset day WITHOUT
// zeroing usage: the schedule takes effect from the next reset instant.
function armTrafficReset(customer, now = new Date()) {
const day = Number(customer.trafficResetDay || 0);
if (!Number.isInteger(day) || day < 1 || day > 31) {
delete customer.trafficResetPeriod;
return;
}
customer.trafficResetPeriod = trafficResetPeriodKey(now, day);
}
// Validate a trafficResetDay request value: 0 disables, 1-31 is the day of
// month. null/absent means 0. Mirrors panel-go ParseResetDay.
function parseResetDay(value) {
if (value == null) return 0;
const number = Number(value);
if (!Number.isFinite(number) || number < 0 || !Number.isInteger(number)) {
const error = new Error('每月重置日必须是非负整数');
error.statusCode = 400;
throw error;
}
if (number > 31) {
const error = new Error('每月重置日须在 0–31 之间(0 表示不重置)');
error.statusCode = 400;
throw error;
}
return number;
}
// Perform due monthly resets for all customers in data as of now. Returns
// the list of customers whose counters were zeroed (the caller mirrors the
// manual reset-usage semantics: a traffic_limit suspension may lift).
// auditFn(data, customer, previousUsed, day, period) records the event.
function applyMonthlyTrafficResets(data, now, auditFn) {
const reset = [];
for (const customer of data.customers) {
const day = Number(customer.trafficResetDay || 0);
if (!Number.isInteger(day) || day < 1 || day > 31) continue;
const key = trafficResetPeriodKey(now, day);
if (!customer.trafficResetPeriod) {
// First sight: arm only — usage already accrued in the current
// period must survive until the next reset day.
customer.trafficResetPeriod = key;
continue;
}
if (customer.trafficResetPeriod === key) continue;
const previousUsed = customer.usedBytes || 0;
customer.usedBytes = 0;
customer.usedUplinkBytes = 0;
customer.usedDownlinkBytes = 0;
customer.trafficResetPeriod = key;
customer.trafficResetAt = now.toISOString();
customer.updatedAt = now.toISOString();
if (auditFn) auditFn(data, customer, previousUsed, day, key);
reset.push(customer);
}
return reset;
}
module.exports = { trafficResetPeriodKey, armTrafficReset, parseResetDay, applyMonthlyTrafficResets, daysInMonth, resetInstant };
+1 -1
View File
@@ -13,7 +13,7 @@ function emptyData() {
schemaVersion: 1, schemaVersion: 1,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
settings: { settings: {
brandName: 'NexusGate', brandName: 'amo',
sessionHours: 12, sessionHours: 12,
observationTtlMinutes: 10, observationTtlMinutes: 10,
activityRetentionDays: 30, activityRetentionDays: 30,
+54 -3
View File
@@ -1,6 +1,12 @@
'use strict'; 'use strict';
const { buildClientUri, nodeEmail } = require('./protocols'); const { buildClientUri, nodeEmail } = require('./protocols');
const { dedupKey, withName, nameFromUri } = require('./extsub');
function nodeSortIndex(data, nodeId) {
const node = data.nodes.find((item) => item.id === nodeId);
return node && Number.isFinite(node.sortIndex) ? node.sortIndex : Number.MAX_SAFE_INTEGER;
}
// Client URLs are only issued for confirmed, running nodes. Each link is built // Client URLs are only issued for confirmed, running nodes. Each link is built
// on the fly from the node's current config and the customer's own credentials // on the fly from the node's current config and the customer's own credentials
@@ -22,7 +28,11 @@ function nodeClientUri(data, nodeClient) {
function entries(data, customerId) { function entries(data, customerId) {
const seen = new Map(); const seen = new Map();
const items = []; const items = [];
for (const nodeClient of data.nodeClients.filter((nc) => nc.customerId === customerId && nc.status === 'active')) { // Own nodes follow the panel-wide custom order (sortIndex); the sort is
// stable, so equal/unset indexes keep the binding creation order.
const bindings = data.nodeClients.filter((nc) => nc.customerId === customerId && nc.status === 'active');
bindings.sort((a, b) => nodeSortIndex(data, a.nodeId) - nodeSortIndex(data, b.nodeId));
for (const nodeClient of bindings) {
const uri = nodeClientUri(data, nodeClient); const uri = nodeClientUri(data, nodeClient);
if (!uri) continue; if (!uri) continue;
const node = data.nodes.find((item) => item.id === nodeClient.nodeId); const node = data.nodes.find((item) => item.id === nodeClient.nodeId);
@@ -38,6 +48,47 @@ function entries(data, customerId) {
return items; return items;
} }
// Full subscription item list: own nodes (sortIndex order), then manual
// links (list order), then external sources (source order, cached order).
// Global dedup by scheme+host+port+credential fingerprint, own first.
// Name collisions across segments get the " (n)" suffix treatment.
function aggregateEntries(data, customer) {
const items = entries(data, customer.id);
const usedKeys = new Set();
const usedNames = new Set();
for (const item of items) {
const key = dedupKey(item.clientUri);
if (key) usedKeys.add(key);
usedNames.add(nameFromUri(item.clientUri));
}
const append = (uri, rawName) => {
const key = dedupKey(uri);
if (key && usedKeys.has(key)) return;
let name = rawName || '未命名节点';
if (usedNames.has(name)) {
let index = 2;
while (usedNames.has(`${name} (${index})`)) index += 1;
name = `${name} (${index})`;
}
if (key) usedKeys.add(key);
usedNames.add(name);
items.push({ nodeId: '', clientUri: withName(uri, name) });
};
(customer.manualLinks || []).forEach((link, index) => {
if (!link.enabled) return;
append(link.uri, link.remark || nameFromUri(link.uri) || `手动节点 ${index + 1}`);
});
for (const source of customer.externalSources || []) {
if (!source.enabled) continue;
const cache = (data.subSourceCache || {})[source.id];
if (!cache || !Array.isArray(cache.items)) continue;
for (const cached of cache.items) {
append(cached.uri, `${source.remark ? `[${source.remark}] ` : ''}${cached.name || '未命名节点'}`);
}
}
return items;
}
function q(value) { return JSON.stringify(String(value)); } function q(value) { return JSON.stringify(String(value)); }
function clashProxy(item) { function clashProxy(item) {
@@ -149,7 +200,7 @@ function makeSingBox(items) {
} }
function formatSubscription(data, customer, format, userAgent = '') { function formatSubscription(data, customer, format, userAgent = '') {
const items = entries(data, customer.id); const items = aggregateEntries(data, customer);
if (!items.length) return null; if (!items.length) return null;
const selected = format === 'auto' ? (/clash|mihomo/i.test(userAgent) ? 'clash' : 'base64') : format; const selected = format === 'auto' ? (/clash|mihomo/i.test(userAgent) ? 'clash' : 'base64') : format;
if (selected === 'raw') return { body:items.map((item) => item.clientUri).join('\n') + '\n', contentType:'text/plain; charset=utf-8' }; if (selected === 'raw') return { body:items.map((item) => item.clientUri).join('\n') + '\n', contentType:'text/plain; charset=utf-8' };
@@ -160,4 +211,4 @@ function formatSubscription(data, customer, format, userAgent = '') {
return undefined; return undefined;
} }
module.exports = { entries, nodeClientUri, clashProxy, makeClash, makeSingBox, makeSurge, formatSubscription }; module.exports = { entries, aggregateEntries, nodeClientUri, clashProxy, makeClash, makeSingBox, makeSurge, formatSubscription };
+189
View File
@@ -0,0 +1,189 @@
'use strict';
// Host/system sampling for the system-status page (panel-go/sysstat.go
// mirrors this file). Samples are taken at most every ~30s (housekeeping
// cadence and on-demand from /api/system when stale) and kept in a
// memory-only ring buffer (240 entries ≈ 4h at 60s cadence, tens of KB).
// Nothing here writes to the data file: the "today" traffic baseline is
// also memory-only, so a panel restart restarts the day counter from
// the traffic seen since boot (named trade-off, see UI-RESKIN-REPORT).
const fs = require('node:fs');
const os = require('node:os');
const { customerOnline } = require('./presence');
const HISTORY_CAP = 240;
const SAMPLE_MIN_INTERVAL_MS = 25000;
function readCpuTimes() {
try {
const line = fs.readFileSync('/proc/stat', 'utf8').split('\n')[0];
const parts = line.trim().split(/\s+/).slice(1).map(Number);
if (parts.length < 5 || parts.some((n) => !Number.isFinite(n))) return null;
const idle = parts[3] + (parts[4] || 0);
const total = parts.reduce((a, b) => a + b, 0);
return { idle, total };
} catch { return null; }
}
function readMem() {
try {
const text = fs.readFileSync('/proc/meminfo', 'utf8');
const grab = (key) => {
const match = new RegExp(`^${key}:\\s+(\\d+)`, 'm').exec(text);
return match ? Number(match[1]) * 1024 : 0;
};
const total = grab('MemTotal');
const available = grab('MemAvailable');
return { total, used: total > 0 ? total - available : 0 };
} catch { return { total: 0, used: 0 }; }
}
function isGlobalIPv4(ip) {
const parts = String(ip).split('.').map(Number);
if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return false;
const [a, b] = parts;
if (a === 0 || a === 10 || a === 127) return false;
if (a === 172 && b >= 16 && b <= 31) return false;
if (a === 192 && b === 168) return false;
if (a === 100 && b >= 64 && b <= 127) return false; // CGNAT
if (a === 169 && b === 254) return false; // link-local
if (a >= 224) return false; // multicast / reserved
return true;
}
function isGlobalIPv6(ip) {
const lower = String(ip).toLowerCase();
if (lower === '::1' || lower === '::') return false;
if (/^f[cd]/.test(lower)) return false; // ULA fc00::/7
if (/^fe[89ab]/.test(lower)) return false; // link-local fe80::/10
if (lower.startsWith('ff')) return false; // multicast
return lower.includes(':');
}
// Pick the host's global addresses from the interface list (3x-ui
// 口径: local interface addresses, no external lookup of any kind).
function hostIPs() {
let ipv4 = null;
let ipv6 = null;
const interfaces = os.networkInterfaces();
for (const list of Object.values(interfaces)) {
for (const info of list || []) {
if (info.internal) continue;
if (info.family === 'IPv4' && !ipv4 && isGlobalIPv4(info.address)) ipv4 = info.address;
if (info.family === 'IPv6' && !ipv6 && isGlobalIPv6(info.address)) ipv6 = info.address;
}
}
return { ipv4, ipv6 };
}
class SystemMonitor {
constructor(dataDir) {
this.dataDir = dataDir;
this.startedAt = Date.now();
this.prevCpu = null;
this.cpuPercent = null;
this.history = [];
this.lastSampleAt = 0;
this.dayKey = null;
this.dayBaseUp = 0;
this.dayBaseDown = 0;
this.todayUp = 0;
this.todayDown = 0;
this.current = null;
}
sample(data, force = false) {
const now = Date.now();
if (!force && now - this.lastSampleAt < SAMPLE_MIN_INTERVAL_MS) return this.current;
this.lastSampleAt = now;
const cpu = readCpuTimes();
if (cpu && this.prevCpu) {
const totalDelta = cpu.total - this.prevCpu.total;
const idleDelta = cpu.idle - this.prevCpu.idle;
if (totalDelta > 0) this.cpuPercent = Math.max(0, Math.min(100, (1 - idleDelta / totalDelta) * 100));
}
if (cpu) this.prevCpu = cpu;
const mem = readMem();
let disk = { total: 0, used: 0 };
try {
const stat = fs.statfsSync(this.dataDir);
disk = { total: stat.blocks * stat.bsize, used: (stat.blocks - stat.bfree) * stat.bsize };
} catch { /* keep zeros */ }
let totalUp = 0;
let totalDown = 0;
let upBps = 0;
let downBps = 0;
for (const node of data.nodes || []) {
if (node.meteredTraffic) {
totalUp += Number(node.meteredTraffic.uplink || 0);
totalDown += Number(node.meteredTraffic.downlink || 0);
}
if (node.speedAt && now - Date.parse(node.speedAt) <= 150000) {
upBps += Number(node.speedUpBps || 0);
downBps += Number(node.speedDownBps || 0);
}
}
const dayKey = new Date(now).toLocaleDateString('en-CA');
if (this.dayKey !== dayKey) {
this.dayKey = dayKey;
this.dayBaseUp = totalUp;
this.dayBaseDown = totalDown;
}
this.todayUp = Math.max(0, totalUp - this.dayBaseUp);
this.todayDown = Math.max(0, totalDown - this.dayBaseDown);
const connections = (data.customers || []).filter((c) => customerOnline(c, now)).length;
const entry = {
at: new Date(now).toISOString(),
cpu: this.cpuPercent == null ? null : Math.round(this.cpuPercent * 10) / 10,
mem: mem.total > 0 ? Math.round((mem.used / mem.total) * 1000) / 10 : null,
upBps, downBps, connections,
};
this.history.push(entry);
if (this.history.length > HISTORY_CAP) this.history.splice(0, this.history.length - HISTORY_CAP);
this.current = {
at: entry.at, cpuPercent: entry.cpu, mem, disk,
upBps, downBps, connections, totalUp, totalDown,
};
return this.current;
}
status(data, version) {
const current = this.sample(data) || {
cpuPercent: null, mem: readMem(), disk: { total: 0, used: 0 },
upBps: 0, downBps: 0, connections: 0, totalUp: 0, totalDown: 0,
};
const mem = current.mem;
const disk = current.disk;
const onlineNodes = (data.nodes || []).filter((n) => n.status === 'active').length;
return {
version,
startedAt: new Date(this.startedAt).toISOString(),
panelUptimeSec: Math.floor((Date.now() - this.startedAt) / 1000),
hostUptimeSec: Math.floor(os.uptime()),
panelMemBytes: process.memoryUsage().rss,
host: {
cpuPercent: current.cpuPercent,
cpuCount: os.cpus().length,
load1: os.loadavg()[0],
memTotal: mem.total, memUsed: mem.used,
memPercent: mem.total > 0 ? Math.round((mem.used / mem.total) * 1000) / 10 : null,
diskTotal: disk.total, diskUsed: disk.used,
diskPercent: disk.total > 0 ? Math.round((disk.used / disk.total) * 1000) / 10 : null,
},
network: {
upBps: current.upBps, downBps: current.downBps,
connections: current.connections,
onlineCustomers: current.connections,
onlineNodes, nodesTotal: (data.nodes || []).length,
customersTotal: (data.customers || []).length,
},
today: { upBytes: this.todayUp, downBytes: this.todayDown },
totals: { upBytes: current.totalUp, downBytes: current.totalDown },
ips: hostIPs(),
history: this.history,
};
}
}
module.exports = { SystemMonitor, hostIPs, isGlobalIPv4, isGlobalIPv6, readCpuTimes, readMem };
+5 -1
View File
@@ -280,7 +280,7 @@ export async function fetchAllSubscriptions(client, token) {
// Learning normalizer // Learning normalizer
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const ID_PREFIXES = ['usr', 'srv', 'agt', 'enr', 'cus', 'nod', 'ncl', 'res', 'job', 'evt', 'obs', 'acc']; const ID_PREFIXES = ['usr', 'srv', 'agt', 'enr', 'cus', 'nod', 'ncl', 'res', 'job', 'evt', 'obs', 'acc', 'src', 'lnk'];
const PREFIXED_ID_RE = new RegExp(`\\b(${ID_PREFIXES.join('|')})_([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})`, 'g'); const PREFIXED_ID_RE = new RegExp(`\\b(${ID_PREFIXES.join('|')})_([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})`, 'g');
const BARE_UUID_RE = /(?<![0-9A-Za-z_-])[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?![0-9A-Za-z_-])/g; const BARE_UUID_RE = /(?<![0-9A-Za-z_-])[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?![0-9A-Za-z_-])/g;
const ISO_TS_RE = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})/g; const ISO_TS_RE = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})/g;
@@ -424,6 +424,10 @@ export class Normalizer {
return out; return out;
} }
if (typeof value === 'number') { if (typeof value === 'number') {
// Speeds are derived from adjacent usage reports over wall-clock
// time (dt differs per side by construction), so only their shape
// is comparable: positive vs zero. See UI-RESKIN-REPORT.md.
if (keyName === 'speedUpBps' || keyName === 'speedDownBps') return value > 0 ? '<speed+>' : 0;
if (keyName && PORT_FIELDS.has(keyName) && this.ports.has(value)) return this.ports.get(value); if (keyName && PORT_FIELDS.has(keyName) && this.ports.has(value)) return this.ports.get(value);
return value; return value;
} }
+199
View File
@@ -21,8 +21,11 @@
// executed and the first differing JSON path is reported per step. // executed and the first differing JSON path is reported per step.
import fs from 'node:fs'; import fs from 'node:fs';
import http from 'node:http';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
import { import {
ADMIN_USERNAME, DEFAULT_GO_BIN, DIFFTEST_DIR, Normalizer, PanelClient, ADMIN_USERNAME, DEFAULT_GO_BIN, DIFFTEST_DIR, Normalizer, PanelClient,
Reporter, UA_DEFAULT, completeJob, fetchAllSubscriptions, firstDiff, Reporter, UA_DEFAULT, completeJob, fetchAllSubscriptions, firstDiff,
@@ -284,6 +287,201 @@ async function runPhase2(runner, { seedLog, seedExpect }) {
}); });
} }
// ---------------------------------------------------------------------------
// Phase 3: UI-reskin features — node reorder, presence/speed, /api/system,
// customer-form node alignment, credentials, subscription aggregation.
// Time-derived values (speeds, /api/system numbers) are compared by shape
// only; everything else is compared exactly like phase 2.
// ---------------------------------------------------------------------------
const AGG_FIXTURE_BODY = Buffer.from([
'ss://YWVzLTI1Ni1nY206ZXh0LXB3@ext.example.com:8388#ExtOne',
'vless://22222222-2222-2222-2222-222222222222@ext2.example.com:443?security=none#ExtTwo',
'trojan://x@bad.example.com:443#Dropped',
'',
].join('\n')).toString('base64');
const AGG_MANUAL_LINK = 'ss://YWVzLTI1Ni1nY206bWFudWFsLXB3@manual.example.com:8388#ManualOne';
async function runPhase3(runner) {
// --- node reorder + subscription order ----------------------------------
await runner.step('p3/reorder-nodes', async (side) => {
const res = await side.client.admin('POST', '/api/nodes/reorder', { ids: [side.ids.n2, side.ids.n1] });
const list = await side.client.admin('GET', '/api/nodes');
return {
reorder: view(res),
order: list.json.nodes.map((n) => n.name),
sortIndexes: list.json.nodes.map((n) => n.sortIndex),
};
});
await runner.step('p3/alice-sub-order-after-reorder', async (side) => {
const res = await side.client.getSubscription(side.subTokens.alice, 'raw', UA_DEFAULT);
const names = res.body.split('\n').filter(Boolean).map((line) => {
const idx = line.lastIndexOf('#');
return idx >= 0 ? decodeURIComponent(line.slice(idx + 1)) : line;
});
return { status: res.status, names };
});
// --- presence & speed: two usage reports ~6s apart -----------------------
await runner.step('p3/usage-report-1', async (side) =>
view(await side.client.agentPost('/api/agent/usage', side.agentKeys.entry, {
epoch: 'diff-phase2-epoch-1',
samples: [
{ kind: 'user', id: side.ids.alice, uplink: 1500, downlink: 2100 },
{ kind: 'inbound', id: side.ids.n1, uplink: 5000, downlink: 6000 },
],
})));
await runner.step('p3/usage-report-2-after-6s', async (side) => {
await sleep(6000);
return view(await side.client.agentPost('/api/agent/usage', side.agentKeys.entry, {
epoch: 'diff-phase2-epoch-1',
samples: [
{ kind: 'user', id: side.ids.alice, uplink: 4500, downlink: 6300 },
{ kind: 'inbound', id: side.ids.n1, uplink: 8000, downlink: 9000 },
],
}));
});
await runner.step('p3/presence-projection', async (side) => {
const customers = await side.client.admin('GET', '/api/customers');
const nodes = await side.client.admin('GET', '/api/nodes');
const nameOf = (id) => { const c = customers.json.customers.find((x) => x.id === id); return c ? c.name : '?'; };
return {
customers: customers.json.customers.map((c) => ({
name: c.name, online: c.online,
speedPositive: (c.speedUpBps || 0) > 0 && (c.speedDownBps || 0) > 0,
hasLastActiveAt: Boolean(c.lastActiveAt),
})),
nodes: nodes.json.nodes.map((n) => ({
name: n.name, clientCount: n.clientCount, onlineCount: n.onlineCount,
onlineNames: (n.onlineCustomerIds || []).map(nameOf),
speedPositive: (n.speedUpBps || 0) > 0,
})),
};
});
await runner.step('p3/system-shape', async (side) => {
const res = await side.client.admin('GET', '/api/system');
const s = res.json || {};
return {
status: res.status, version: s.version,
hostKeys: s.host ? Object.keys(s.host).sort() : null,
networkKeys: s.network ? Object.keys(s.network).sort() : null,
todayKeys: s.today ? Object.keys(s.today).sort() : null,
ipsKeys: s.ips ? Object.keys(s.ips).sort() : null,
historyIsArray: Array.isArray(s.history),
connectionsIsNumber: Boolean(s.network) && typeof s.network.connections === 'number',
panelMemPositive: s.panelMemBytes > 0,
startedAtPresent: typeof s.startedAt === 'string',
};
});
// --- customer-form node alignment (on Carol: no golden subscriptions) ----
await runner.step('p3/align-carol-add-n1', async (side) => {
const res = await side.client.admin('PATCH', `/api/customers/${side.ids.carol}`, { nodeIds: [side.ids.n1] });
const nodes = await side.client.admin('GET', '/api/nodes');
const mine = nodes.json.nodeClients.filter((nc) => nc.customerId === side.ids.carol);
if (mine.length) side.ids.carolNc = mine[0].id;
return {
status: res.status, count: mine.length,
nodeNames: mine.map((nc) => { const n = nodes.json.nodes.find((x) => x.id === nc.nodeId); return n ? n.name : '?'; }),
statuses: mine.map((nc) => nc.status),
};
});
// --- credentials ----------------------------------------------------------
await runner.step('p3/credentials-get', async (side) => {
const res = await side.client.admin('GET', `/api/customers/${side.ids.carol}/credentials`);
return {
status: res.status,
hasToken: typeof res.json.subscriptionToken === 'string' && res.json.subscriptionToken.length > 10,
items: (res.json.items || []).map((entry) => ({
nodeName: entry.nodeName, protocol: entry.protocol, status: entry.status,
hasClientId: Boolean(entry.clientId), hasPassword: Boolean(entry.password),
hasShortId: Boolean(entry.shortId), username: entry.username,
})),
};
});
await runner.step('p3/credentials-regenerate', async (side) => {
const before = await side.client.admin('GET', `/api/customers/${side.ids.carol}/credentials`);
const regen = await side.client.admin('POST', `/api/customers/${side.ids.carol}/credentials/${side.ids.carolNc}/regenerate`, {});
const after = await side.client.admin('GET', `/api/customers/${side.ids.carol}/credentials`);
const b = (before.json.items || [])[0] || {};
const a = (after.json.items || [])[0] || {};
return {
status: regen.status,
changed: b.clientId !== a.clientId || b.password !== a.password,
itemNodeName: regen.json && regen.json.item ? regen.json.item.nodeName : null,
itemHasClientId: Boolean(regen.json && regen.json.item && regen.json.item.clientId),
};
});
await runner.step('p3/align-carol-remove-all', async (side) => {
const res = await side.client.admin('PATCH', `/api/customers/${side.ids.carol}`, { nodeIds: [] });
const nodes = await side.client.admin('GET', '/api/nodes');
return {
status: res.status,
bindingsForCarol: nodes.json.nodeClients.filter((nc) => nc.customerId === side.ids.carol).length,
};
});
// --- subscription aggregation ---------------------------------------------
// Both panels must allow private subscription sources to reach the
// loopback fixture: flip the setting in each side's data file and
// restart (there is deliberately no settings API).
const fixture = http.createServer((req, res) => {
res.writeHead(200, { 'content-type': 'text/plain' });
res.end(AGG_FIXTURE_BODY);
});
await new Promise((resolve) => fixture.listen(0, '127.0.0.1', resolve));
const fixtureUrl = `http://127.0.0.1:${fixture.address().port}/sub`;
try {
for (const side of [runner.sideA, runner.sideB]) {
await stopPanel(side.proc);
const db = JSON.parse(fs.readFileSync(side.dataFile, 'utf8'));
db.settings = db.settings || {};
db.settings.allowPrivateSubSources = true;
fs.writeFileSync(side.dataFile, JSON.stringify(db));
await restartSide(side);
}
await runner.step('p3/agg-relogin', async (side) => view(await side.client.login()));
await runner.step('p3/agg-create-dave', async (side) => {
const res = await side.client.admin('POST', '/api/customers', {
name: 'Dave',
manualLinks: [{ uri: AGG_MANUAL_LINK, remark: '手动一号', enabled: true }],
externalSources: [{ url: fixtureUrl, remark: '机场', enabled: true }],
});
if (res.json && res.json.customer) {
side.ids.dave = res.json.customer.id;
side.subTokens.dave = res.json.customer.subscriptionToken;
const source = (res.json.customer.externalSources || [])[0];
if (source) side.ids.daveSrc = source.id;
}
return {
status: res.status,
manualCount: res.json && res.json.customer ? (res.json.customer.manualLinks || []).length : null,
sourceCount: res.json && res.json.customer ? (res.json.customer.externalSources || []).length : null,
};
});
await runner.step('p3/agg-refresh', async (side) =>
view(await side.client.admin('POST', `/api/customers/${side.ids.dave}/external-sources/${side.ids.daveSrc}/refresh`, {})));
await runner.step('p3/agg-customer-status', async (side) => {
const res = await side.client.admin('GET', '/api/customers');
const dave = res.json.customers.find((c) => c.id === side.ids.dave);
const status = dave && dave.externalStatus ? Object.values(dave.externalStatus)[0] : null;
return {
hasExternalStatus: Boolean(dave && dave.externalStatus),
count: status ? status.count : null,
dropped: status ? status.droppedCount : null,
failCount: status ? status.failCount : null,
hasOkAt: Boolean(status && status.okAt),
lastError: status ? status.lastError : 'missing',
};
});
await runner.step('p3/agg-subscriptions', async (side) =>
fetchAllSubscriptions(side.client, side.subTokens.dave));
} finally {
fixture.close();
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// DB interoperability: swap the two panels' DB files and keep working. // DB interoperability: swap the two panels' DB files and keep working.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -360,6 +558,7 @@ async function runPair({ reporter, kindA, kindB, bin, seedPath, seedLog, seedExp
try { try {
const runner = new StepRunner(reporter, sideA, sideB); const runner = new StepRunner(reporter, sideA, sideB);
await runPhase2(runner, { seedLog, seedExpect }); await runPhase2(runner, { seedLog, seedExpect });
await runPhase3(runner);
await runInterop(reporter, sideA, sideB, { seedExpect }); await runInterop(reporter, sideA, sideB, { seedExpect });
} finally { } finally {
await destroySide(sideA, keep); await destroySide(sideA, keep);
+475
View File
@@ -0,0 +1,475 @@
package main
// External subscription aggregation — mirrors lib/extsub.js (design:
// SUB-AGGREGATION-DESIGN.md). SSRF guard, pinned-IP dialing, redirect
// re-validation, 10s timeout, 1MiB cap, 200 items per source.
import (
"context"
"crypto/sha256"
"crypto/tls"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"io"
"math"
"net"
"net/http"
"net/url"
"regexp"
"strings"
"time"
)
var LinkSchemes = map[string]bool{
"vless:": true, "ss:": true, "hysteria2:": true, "anytls:": true, "socks5:": true,
}
const maxSubBodyBytes = 1024 * 1024
const maxSubItems = 200
const fetchTimeout = 10 * time.Second
const defaultSubUA = "v2rayN/6.55"
func ValidateLinkUri(uri string) bool {
if uri == "" || len(uri) > 2048 {
return false
}
parsed, err := url.Parse(uri)
if err != nil {
return false
}
return LinkSchemes[parsed.Scheme+":"] && parsed.Hostname() != ""
}
var base64CharsetRe = regexp.MustCompile(`^[A-Za-z0-9+/=_-]+$`)
// ParseLinkList mirrors parseLinkList in lib/extsub.js.
func ParseLinkList(body string) ([]SubCacheItem, int) {
text := body
compact := strings.Join(strings.Fields(text), "")
if len(compact) >= 16 && base64CharsetRe.MatchString(compact) {
normalized := strings.NewReplacer("-", "+", "_", "/").Replace(compact)
if pad := len(normalized) % 4; pad != 0 {
normalized += strings.Repeat("=", 4-pad)
}
if decoded, err := base64.StdEncoding.DecodeString(normalized); err == nil && strings.Contains(string(decoded), "://") {
text = string(decoded)
}
}
items := []SubCacheItem{}
dropped := 0
for _, rawLine := range strings.Split(text, "\n") {
line := strings.TrimSpace(rawLine)
if line == "" {
continue
}
if len(items) >= maxSubItems {
dropped++
continue
}
if !ValidateLinkUri(line) {
dropped++
continue
}
name := ""
if idx := strings.LastIndex(line, "#"); idx >= 0 {
rawName := line[idx+1:]
if decoded, err := url.PathUnescape(rawName); err == nil {
name = decoded
} else {
name = rawName
}
}
items = append(items, SubCacheItem{URI: line, Name: truncateRunes(name, 120)})
}
return items, dropped
}
func isGlobalIPString(ip string) bool {
parsed := net.ParseIP(strings.Trim(ip, "[]"))
if parsed == nil {
return false
}
if parsed.To4() != nil {
return isGlobalIPv4(parsed)
}
return isGlobalIPv6(parsed)
}
func resolveCheckedIP(ctx context.Context, hostname string, allowPrivate bool) (string, error) {
var addresses []string
if ip := net.ParseIP(hostname); ip != nil {
addresses = []string{hostname}
} else {
records, err := net.DefaultResolver.LookupIPAddr(ctx, hostname)
if err != nil {
return "", errors.New("域名解析无结果")
}
for _, record := range records {
addresses = append(addresses, record.IP.String())
}
}
if len(addresses) == 0 {
return "", errors.New("域名解析无结果")
}
if !allowPrivate {
for _, ip := range addresses {
if !isGlobalIPString(ip) {
return "", errors.New("订阅源指向非公网地址,已拒绝")
}
}
}
return addresses[0], nil
}
func fetchSourceBody(source ExtSource, settings *Settings) (string, error) {
parsed, err := url.Parse(source.URL)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return "", errors.New("仅支持 http/https 订阅源")
}
allowPrivate := settings != nil && settings.AllowPrivateSubSources
dialer := &net.Dialer{Timeout: 5 * time.Second}
pin := func(ctx context.Context, addr string) (string, string, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return "", "", err
}
ip, err := resolveCheckedIP(ctx, host, allowPrivate)
if err != nil {
return "", "", err
}
return ip, port, nil
}
transport := &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
ip, port, err := pin(ctx, addr)
if err != nil {
return nil, err
}
return dialer.DialContext(ctx, network, net.JoinHostPort(ip, port))
},
DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
host, _, _ := net.SplitHostPort(addr)
ip, port, err := pin(ctx, addr)
if err != nil {
return nil, err
}
tlsDialer := &tls.Dialer{NetDialer: dialer, Config: &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}}
return tlsDialer.DialContext(ctx, network, net.JoinHostPort(ip, port))
},
ResponseHeaderTimeout: fetchTimeout,
}
client := &http.Client{
Transport: transport,
Timeout: fetchTimeout,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 3 {
return errors.New("订阅源重定向次数过多")
}
return nil
},
}
ua := source.UserAgent
if ua == "" {
ua = defaultSubUA
}
req, err := http.NewRequest(http.MethodGet, source.URL, nil)
if err != nil {
return "", err
}
req.Header.Set("User-Agent", ua)
req.Header.Set("Accept", "*/*")
resp, err := client.Do(req)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return "", errors.New("订阅源请求超时")
}
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("订阅源返回 HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxSubBodyBytes+1))
if err != nil {
return "", err
}
if len(body) > maxSubBodyBytes {
return "", errors.New("订阅源响应超过 1MiB")
}
return string(body), nil
}
type FetchResult struct {
OK bool
Items []SubCacheItem
Dropped int
Error string
}
// FetchExternalSource mirrors fetchExternalSource: never fails hard.
func FetchExternalSource(source ExtSource, settings *Settings) FetchResult {
body, err := fetchSourceBody(source, settings)
if err != nil {
message := err.Error()
if len([]rune(message)) > 240 {
message = truncateRunes(message, 240)
}
return FetchResult{OK: false, Items: []SubCacheItem{}, Error: message}
}
items, dropped := ParseLinkList(body)
return FetchResult{OK: true, Items: items, Dropped: dropped}
}
func RefreshMinutes(settings *Settings) float64 {
if settings == nil || settings.ExternalSubRefreshMinutes <= 0 {
return 60
}
return math.Min(720, math.Max(15, settings.ExternalSubRefreshMinutes))
}
func jitterFactor(sourceID string) float64 {
hash := sha256.Sum256([]byte(sourceID))
return 1 + float64(int(hash[0]%21)-10)/100
}
type dueSource struct {
CustomerID string
Source ExtSource
}
// DueSources mirrors dueSources in lib/extsub.js.
func DueSources(d *Data, nowMs int64, limit int) []dueSource {
out := []dueSource{}
for _, customer := range d.Customers {
for _, source := range customer.ExternalSources {
if !source.Enabled {
continue
}
entry := d.SubSourceCache[source.ID]
if entry == nil || entry.FetchedAt == nil {
out = append(out, dueSource{customer.ID, source})
continue
}
base := RefreshMinutes(&d.Settings) * 60000
failCount := entry.FailCount
if failCount > 10 {
failCount = 10
}
backoff := math.Min(math.Pow(2, float64(failCount))*base, 24*3600*1000)
fetchedMs, _ := ParseISOMs(*entry.FetchedAt)
if float64(nowMs) >= float64(fetchedMs)+backoff*jitterFactor(source.ID) {
out = append(out, dueSource{customer.ID, source})
}
if len(out) >= limit {
return out
}
}
if len(out) >= limit {
break
}
}
if len(out) > limit {
out = out[:limit]
}
return out
}
// ApplyFetchResult mirrors applyFetchResult in lib/extsub.js.
func ApplyFetchResult(d *Data, sourceID string, result FetchResult, nowISO string) *SubSourceCacheEntry {
if d.SubSourceCache == nil {
d.SubSourceCache = map[string]*SubSourceCacheEntry{}
}
entry := d.SubSourceCache[sourceID]
if entry == nil {
entry = &SubSourceCacheEntry{Items: []SubCacheItem{}}
}
entry.FetchedAt = StrPtr(nowISO)
if result.OK {
entry.OkAt = StrPtr(nowISO)
entry.FailCount = 0
entry.LastError = nil
entry.DroppedCount = result.Dropped
entry.Items = result.Items
if entry.Items == nil {
entry.Items = []SubCacheItem{}
}
} else {
entry.FailCount++
entry.LastError = StrPtr(result.Error)
}
d.SubSourceCache[sourceID] = entry
return entry
}
// ---------- save-time normalization ----------
func NormalizeExternalSources(value any) ([]ExtSource, error) {
if value == nil {
return nil, nil
}
arr, ok := value.([]any)
if !ok {
return nil, httpErr(400, "外部订阅格式无效")
}
if len(arr) > 5 {
return nil, httpErr(400, "每个客户最多 5 个外部订阅")
}
out := []ExtSource{}
for _, raw := range arr {
item, _ := raw.(map[string]any)
rawURL := CleanText(item["url"], 2048)
parsed, err := url.Parse(rawURL)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Hostname() == "" {
return nil, httpErr(400, "外部订阅地址无效")
}
idValue := ""
if s, isStr := item["id"].(string); isStr && s != "" {
idValue = truncateRunes(s, 80)
} else {
idValue = NewID("src")
}
next := ExtSource{
ID: idValue,
URL: rawURL,
Remark: CleanText(item["remark"], 80),
Enabled: true,
}
if enabled, isBool := item["enabled"].(bool); isBool && !enabled {
next.Enabled = false
}
if ua := CleanText(item["userAgent"], 120); ua != "" {
next.UserAgent = ua
}
out = append(out, next)
}
return out, nil
}
func NormalizeManualLinks(value any) ([]ManualLink, error) {
if value == nil {
return nil, nil
}
arr, ok := value.([]any)
if !ok {
return nil, httpErr(400, "外部链接格式无效")
}
if len(arr) > 50 {
return nil, httpErr(400, "每个客户最多 50 条外部链接")
}
out := []ManualLink{}
for _, raw := range arr {
item, _ := raw.(map[string]any)
uri := CleanText(item["uri"], 2048)
if !ValidateLinkUri(uri) {
return nil, httpErr(400, "外部链接无效或协议不受支持")
}
idValue := ""
if s, isStr := item["id"].(string); isStr && s != "" {
idValue = truncateRunes(s, 80)
} else {
idValue = NewID("lnk")
}
next := ManualLink{
ID: idValue,
URI: uri,
Remark: CleanText(item["remark"], 80),
Enabled: true,
}
if enabled, isBool := item["enabled"].(bool); isBool && !enabled {
next.Enabled = false
}
out = append(out, next)
}
return out, nil
}
// ---------- merge helpers (used by subscriptions.go) ----------
func DedupKey(uri string) string {
parsed, err := url.Parse(uri)
if err != nil {
return ""
}
username, password := "", ""
if parsed.User != nil {
username = parsed.User.Username()
password, _ = parsed.User.Password()
}
hash := sha256.Sum256([]byte(username + ":" + password))
return fmt.Sprintf("%s//%s:%s#%s", parsed.Scheme+":", strings.ToLower(parsed.Hostname()), parsed.Port(), hex.EncodeToString(hash[:])[:16])
}
func WithName(uri, name string) string {
base := uri
if idx := strings.LastIndex(uri, "#"); idx >= 0 {
base = uri[:idx]
}
return base + "#" + encodeURIComponent(name)
}
func NameFromURI(uri string) string {
idx := strings.LastIndex(uri, "#")
if idx < 0 {
return ""
}
raw := uri[idx+1:]
if decoded, err := url.PathUnescape(raw); err == nil {
return decoded
}
return raw
}
func strOrNil(s string) any {
if s == "" {
return nil
}
return s
}
// ---------- App-level refresh machinery ----------
// RefreshOneSource fetches one source now and persists the outcome.
func (a *App) RefreshOneSource(customerID, sourceID string) *FetchResult {
data := a.store.Snapshot()
customer := findCustomer(data, customerID)
if customer == nil {
return nil
}
var source *ExtSource
for i := range customer.ExternalSources {
if customer.ExternalSources[i].ID == sourceID {
source = &customer.ExternalSources[i]
break
}
}
if source == nil {
return nil
}
settings := data.Settings
result := FetchExternalSource(*source, &settings)
_, err := a.store.Transaction(func(d *Data) (any, error) {
ApplyFetchResult(d, sourceID, result, NowISO())
return nil, nil
})
if err != nil {
return nil
}
return &result
}
func (a *App) RefreshSourcesInBackground(customerID string, sourceIDs []string) {
for _, sourceID := range sourceIDs {
go func(id string) {
_ = a.RefreshOneSource(customerID, id)
}(sourceID)
}
}
func (a *App) RefreshDueSources() {
data := a.store.Snapshot()
for _, due := range DueSources(data, time.Now().UnixMilli(), 3) {
_ = a.RefreshOneSource(due.CustomerID, due.Source.ID)
}
}
+343 -3
View File
@@ -14,6 +14,7 @@ import (
"net/http" "net/http"
"os" "os"
"regexp" "regexp"
"sort"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@@ -36,6 +37,25 @@ type App struct {
loginMu sync.Mutex loginMu sync.Mutex
loginAttempts map[string]*loginAttempt loginAttempts map[string]*loginAttempt
sysmonMu sync.Mutex
sysmon *SystemMonitor
}
// monitor lazily creates the system monitor (data dir = store file dir).
func (a *App) monitor() *SystemMonitor {
a.sysmonMu.Lock()
defer a.sysmonMu.Unlock()
if a.sysmon == nil {
dir := "."
if a.store != nil && a.store.File != "" {
if idx := strings.LastIndex(a.store.File, "/"); idx >= 0 {
dir = a.store.File[:idx]
}
}
a.sysmon = NewSystemMonitor(dir)
}
return a.sysmon
} }
func NewApp(store *Store, sessions *SessionManager, orch *Orchestrator) *App { func NewApp(store *Store, sessions *SessionManager, orch *Orchestrator) *App {
@@ -158,6 +178,16 @@ func NormalizeServer(body map[string]any, current *Server) (*Server, error) {
if next.TLSDomain != "" && !validTLSDomain(next.TLSDomain) { if next.TLSDomain != "" && !validTLSDomain(next.TLSDomain) {
return nil, httpErr(400, "TLS 域名必须是有效的公网域名") return nil, httpErr(400, "TLS 域名必须是有效的公网域名")
} }
if _, ok := body["enabled"]; ok {
enabled := true
if b, isBool := body["enabled"].(bool); isBool && !b {
enabled = false
}
next.Enabled = &enabled
} else if next.Enabled == nil {
enabled := true
next.Enabled = &enabled
}
return &next, nil return &next, nil
} }
@@ -413,6 +443,7 @@ func PublicServer(item *Server, data *Data) map[string]any {
} }
} }
out["pendingCleanup"] = pending out["pendingCleanup"] = pending
out["enabled"] = item.Enabled == nil || *item.Enabled
return out return out
} }
@@ -421,9 +452,76 @@ func PublicCustomer(item *Customer, data *Data) map[string]any {
out := recordToMap(item) out := recordToMap(item)
out["observedIpCount"] = len(ObservedIPs(data, item.ID, time.Now())) out["observedIpCount"] = len(ObservedIPs(data, item.ID, time.Now()))
out["subscriptionClientCount"] = len(ActiveClients(data, item.ID, time.Now())) out["subscriptionClientCount"] = len(ActiveClients(data, item.ID, time.Now()))
if len(item.ExternalSources) > 0 {
status := map[string]any{}
for _, source := range item.ExternalSources {
entry := data.SubSourceCache[source.ID]
summary := map[string]any{
"fetchedAt": nil, "okAt": nil, "failCount": 0,
"lastError": nil, "droppedCount": 0, "count": 0,
}
if entry != nil {
if entry.FetchedAt != nil {
summary["fetchedAt"] = *entry.FetchedAt
}
if entry.OkAt != nil {
summary["okAt"] = *entry.OkAt
}
summary["failCount"] = entry.FailCount
if entry.LastError != nil {
summary["lastError"] = *entry.LastError
}
summary["droppedCount"] = entry.DroppedCount
summary["count"] = len(entry.Items)
}
status[source.ID] = summary
}
out["externalStatus"] = status
}
return out return out
} }
// credentialItems mirrors credentialItems in server.js.
func credentialItems(d *Data, customer *Customer) []map[string]any {
items := []map[string]any{}
for _, nc := range d.NodeClients {
if nc.CustomerID != customer.ID {
continue
}
node := findNode(d, nc.NodeID)
if node == nil {
continue
}
username := ""
if node.Protocol == "socks5-auth" {
username = NodeEmail(customer.ID)
}
creds := nc.Credentials
var clientID, password, shortID any
if creds.ClientID != "" {
clientID = creds.ClientID
}
if creds.RelayPassword != "" {
password = creds.RelayPassword
}
if creds.ShortID != "" {
shortID = creds.ShortID
}
items = append(items, map[string]any{
"nodeClientId": nc.ID,
"nodeId": node.ID,
"nodeName": node.Name,
"protocol": node.Protocol,
"status": nc.Status,
"clientId": clientID,
"password": password,
"shortId": shortID,
"username": username,
})
}
return items
}
// CustomerBlockReason is customerBlockReason: "expired" / "traffic_limit" / // CustomerBlockReason is customerBlockReason: "expired" / "traffic_limit" /
// "ip_limit", or "" for none (JS null). // "ip_limit", or "" for none (JS null).
func CustomerBlockReason(data *Data, customer *Customer) string { func CustomerBlockReason(data *Data, customer *Customer) string {
@@ -1074,6 +1172,9 @@ func (a *App) handleAgent(w http.ResponseWriter, r *http.Request, pathname strin
LastSeenAt: now, LastSeenAt: now,
}) })
} }
if customer := findCustomer(d, customerID); customer != nil {
customer.LastOnlineAt = StrPtr(now)
}
if !affectedSet[customerID] { if !affectedSet[customerID] {
affectedSet[customerID] = true affectedSet[customerID] = true
affected = append(affected, customerID) affected = append(affected, customerID)
@@ -1241,6 +1342,12 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
return true, nil return true, nil
} }
if r.Method == http.MethodGet && pathname == "/api/system" {
data := a.store.Snapshot()
SendJSON(w, 200, a.monitor().Status(data, VERSION), nil)
return true, nil
}
if r.Method == http.MethodGet && pathname == "/api/protocols" { if r.Method == http.MethodGet && pathname == "/api/protocols" {
SendJSON(w, 200, map[string]any{"profiles": ProfileCatalog, "realityPresets": RealityPresets}, nil) SendJSON(w, 200, map[string]any{"profiles": ProfileCatalog, "realityPresets": RealityPresets}, nil)
return true, nil return true, nil
@@ -1558,9 +1665,21 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
if r.Method == http.MethodGet && pathname == "/api/customers" { if r.Method == http.MethodGet && pathname == "/api/customers" {
data := a.store.Snapshot() data := a.store.Snapshot()
now := time.Now()
nowMs := now.UnixMilli()
customers := []map[string]any{} customers := []map[string]any{}
for _, item := range data.Customers { for _, item := range data.Customers {
customers = append(customers, PublicCustomer(item, data)) out := PublicCustomer(item, data)
out["online"] = CustomerOnline(item, nowMs)
if item.LastOnlineAt != nil {
out["lastActiveAt"] = *item.LastOnlineAt
} else {
out["lastActiveAt"] = nil
}
up, down := CustomerSpeedBps(item, nowMs)
out["speedUpBps"] = up
out["speedDownBps"] = down
customers = append(customers, out)
} }
SendJSON(w, 200, map[string]any{"customers": customers}, nil) SendJSON(w, 200, map[string]any{"customers": customers}, nil)
return true, nil return true, nil
@@ -1631,6 +1750,16 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
if err != nil { if err != nil {
return true, err return true, err
} }
externalSources, err := NormalizeExternalSources(body["externalSources"])
if err != nil {
return true, err
}
manualLinks, err := NormalizeManualLinks(body["manualLinks"])
if err != nil {
return true, err
}
var createdID string
var refreshIDs []string
customerAny, err := a.store.Transaction(func(d *Data) (any, error) { customerAny, err := a.store.Transaction(func(d *Data) (any, error) {
name, err := RequiredText(body["name"], "客户名称", 120) name, err := RequiredText(body["name"], "客户名称", 120)
if err != nil { if err != nil {
@@ -1678,13 +1807,31 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
// Arm the monthly-reset schedule: it starts with the next // Arm the monthly-reset schedule: it starts with the next
// reset instant, it never fires retroactively at creation. // reset instant, it never fires retroactively at creation.
ArmTrafficReset(next, time.Now()) ArmTrafficReset(next, time.Now())
if len(externalSources) > 0 {
next.ExternalSources = externalSources
}
if len(manualLinks) > 0 {
next.ManualLinks = manualLinks
}
d.Customers = append(d.Customers, next) d.Customers = append(d.Customers, next)
if body["nodeIds"] != nil {
if err := AlignCustomerNodes(d, next, asIds(body["nodeIds"]), actor); err != nil {
return nil, err
}
}
Audit(d, actor, "create_customer", next.ID, map[string]any{"name": next.Name}) Audit(d, actor, "create_customer", next.ID, map[string]any{"name": next.Name})
createdID = next.ID
for _, source := range externalSources {
refreshIDs = append(refreshIDs, source.ID)
}
return cloneJSONValue(next), nil return cloneJSONValue(next), nil
}) })
if err != nil { if err != nil {
return true, err return true, err
} }
if len(refreshIDs) > 0 {
a.RefreshSourcesInBackground(createdID, refreshIDs)
}
SendJSON(w, 201, map[string]any{"customer": customerAny}, nil) SendJSON(w, 201, map[string]any{"customer": customerAny}, nil)
return true, nil return true, nil
} }
@@ -1693,6 +1840,21 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
if err != nil { if err != nil {
return true, err return true, err
} }
var patchSources []ExtSource
if body["externalSources"] != nil {
patchSources, err = NormalizeExternalSources(body["externalSources"])
if err != nil {
return true, err
}
}
var patchLinks []ManualLink
if body["manualLinks"] != nil {
patchLinks, err = NormalizeManualLinks(body["manualLinks"])
if err != nil {
return true, err
}
}
var patchRefreshIDs []string
customerAny, err := a.store.Transaction(func(d *Data) (any, error) { customerAny, err := a.store.Transaction(func(d *Data) (any, error) {
item := findCustomer(d, params["id"]) item := findCustomer(d, params["id"])
if item == nil { if item == nil {
@@ -1700,6 +1862,7 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
} }
wasActive := item.Status == "active" wasActive := item.Status == "active"
previousReason := strVal(item.SuspendReason) previousReason := strVal(item.SuspendReason)
previousSources := append([]ExtSource{}, item.ExternalSources...)
if _, ok := body["name"]; ok { if _, ok := body["name"]; ok {
name, err := RequiredText(body["name"], "客户名称", 120) name, err := RequiredText(body["name"], "客户名称", 120)
if err != nil { if err != nil {
@@ -1778,6 +1941,41 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
item.SuspendReason = nil item.SuspendReason = nil
item.SuspendReasonPresent = true item.SuspendReasonPresent = true
} }
if body["externalSources"] != nil {
if len(patchSources) > 0 {
item.ExternalSources = patchSources
} else {
item.ExternalSources = nil
}
keptIDs := map[string]bool{}
for _, source := range patchSources {
keptIDs[source.ID] = true
}
for _, old := range previousSources {
if !keptIDs[old.ID] && d.SubSourceCache != nil {
delete(d.SubSourceCache, old.ID)
}
}
for _, source := range patchSources {
found := false
for _, old := range previousSources {
if old.ID == source.ID && old.URL == source.URL {
found = true
break
}
}
if !found {
patchRefreshIDs = append(patchRefreshIDs, source.ID)
}
}
}
if body["manualLinks"] != nil {
if len(patchLinks) > 0 {
item.ManualLinks = patchLinks
} else {
item.ManualLinks = nil
}
}
if wasActive && item.Status == "suspended" { if wasActive && item.Status == "suspended" {
_, _ = SuspendCustomerResources(d, item, actor) _, _ = SuspendCustomerResources(d, item, actor)
} }
@@ -1786,6 +1984,11 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
return nil, err return nil, err
} }
} }
if body["nodeIds"] != nil {
if err := AlignCustomerNodes(d, item, asIds(body["nodeIds"]), actor); err != nil {
return nil, err
}
}
item.UpdatedAt = NowISO() item.UpdatedAt = NowISO()
Audit(d, actor, "update_customer", item.ID, nil) Audit(d, actor, "update_customer", item.ID, nil)
return cloneJSONValue(item), nil return cloneJSONValue(item), nil
@@ -1793,9 +1996,78 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
if err != nil { if err != nil {
return true, err return true, err
} }
if len(patchRefreshIDs) > 0 {
a.RefreshSourcesInBackground(params["id"], patchRefreshIDs)
}
SendJSON(w, 200, map[string]any{"customer": customerAny}, nil) SendJSON(w, 200, map[string]any{"customer": customerAny}, nil)
return true, nil return true, nil
} }
if params, ok := RoutePattern("/api/customers/:id/credentials", pathname); ok && r.Method == http.MethodGet {
data := a.store.Snapshot()
customer := findCustomer(data, params["id"])
if customer == nil {
return true, httpErr(404, "客户不存在")
}
SendJSON(w, 200, map[string]any{
"customerId": customer.ID,
"subscriptionToken": customer.SubscriptionToken,
"items": credentialItems(data, customer),
}, nil)
return true, nil
}
if params, ok := RoutePattern("/api/customers/:id/credentials/:ncid/regenerate", pathname); ok && r.Method == http.MethodPost {
var responseItem map[string]any
_, err := a.store.Transaction(func(d *Data) (any, error) {
customer := findCustomer(d, params["id"])
if customer == nil {
return nil, httpErr(404, "客户不存在")
}
var binding *NodeClient
for _, nc := range d.NodeClients {
if nc.ID == params["ncid"] && nc.CustomerID == customer.ID {
binding = nc
break
}
}
if binding == nil {
return nil, httpErr(404, "凭据不存在")
}
node := findNode(d, binding.NodeID)
if node == nil {
return nil, httpErr(404, "节点不存在")
}
binding.Credentials = NewNodeClientCredentials(node.Protocol)
binding.UpdatedAt = NowISO()
if node.Status != "draft" && node.Status != "removing" {
if err := RebuildNode(d, node, false); err != nil {
return nil, err
}
}
Audit(d, actor, "regenerate_credentials", customer.ID, map[string]any{"nodeId": node.ID, "node": node.Name})
for _, entry := range credentialItems(d, customer) {
if entry["nodeClientId"] == binding.ID {
responseItem = entry
break
}
}
return nil, nil
})
if err != nil {
return true, err
}
SendJSON(w, 200, map[string]any{"item": responseItem}, nil)
return true, nil
}
if params, ok := RoutePattern("/api/customers/:id/external-sources/:sid/refresh", pathname); ok && r.Method == http.MethodPost {
result := a.RefreshOneSource(params["id"], params["sid"])
if result == nil {
return true, httpErr(404, "订阅源不存在")
}
SendJSON(w, 200, map[string]any{
"ok": result.OK, "count": len(result.Items), "dropped": result.Dropped, "error": strOrNil(result.Error),
}, nil)
return true, nil
}
if params, ok := RoutePattern("/api/customers/:id/reset-usage", pathname); ok && r.Method == http.MethodPost { if params, ok := RoutePattern("/api/customers/:id/reset-usage", pathname); ok && r.Method == http.MethodPost {
_, err := a.store.Transaction(func(d *Data) (any, error) { _, err := a.store.Transaction(func(d *Data) (any, error) {
item := findCustomer(d, params["id"]) item := findCustomer(d, params["id"])
@@ -1860,6 +2132,12 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
return nil, httpErr(409, "客户仍有节点分配。请先移除全部分配") return nil, httpErr(409, "客户仍有节点分配。请先移除全部分配")
} }
} }
removedCustomer := d.Customers[index]
for _, source := range removedCustomer.ExternalSources {
if d.SubSourceCache != nil {
delete(d.SubSourceCache, source.ID)
}
}
d.Customers = append(d.Customers[:index], d.Customers[index+1:]...) d.Customers = append(d.Customers[:index], d.Customers[index+1:]...)
access := d.SubscriptionAccess[:0] access := d.SubscriptionAccess[:0]
for _, item := range d.SubscriptionAccess { for _, item := range d.SubscriptionAccess {
@@ -1894,14 +2172,31 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
if r.Method == http.MethodGet && pathname == "/api/nodes" { if r.Method == http.MethodGet && pathname == "/api/nodes" {
data := a.store.Snapshot() data := a.store.Snapshot()
nowMs := time.Now().UnixMilli()
sortedNodes := append([]*Node{}, data.Nodes...)
sort.SliceStable(sortedNodes, func(i, j int) bool { return sortedNodes[i].SortIndex < sortedNodes[j].SortIndex })
nodes := []map[string]any{} nodes := []map[string]any{}
visibleIDs := map[string]bool{} visibleIDs := map[string]bool{}
for _, item := range data.Nodes { for _, item := range sortedNodes {
if item.PendingDelete { if item.PendingDelete {
continue continue
} }
visibleIDs[item.ID] = true visibleIDs[item.ID] = true
nodes = append(nodes, PublicNode(item)) out := PublicNode(item)
clientCount := 0
for _, nc := range data.NodeClients {
if nc.NodeID == item.ID {
clientCount++
}
}
onlineIDs := NodeOnlineCustomerIds(data, item, nowMs)
out["clientCount"] = clientCount
out["onlineCount"] = len(onlineIDs)
out["onlineCustomerIds"] = onlineIDs
up, down := NodeSpeedBps(item, nowMs)
out["speedUpBps"] = up
out["speedDownBps"] = down
nodes = append(nodes, out)
} }
nodeClients := []map[string]any{} nodeClients := []map[string]any{}
for _, nc := range data.NodeClients { for _, nc := range data.NodeClients {
@@ -1921,6 +2216,44 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
SendJSON(w, 200, map[string]any{"nodes": nodes, "nodeClients": nodeClients}, nil) SendJSON(w, 200, map[string]any{"nodes": nodes, "nodeClients": nodeClients}, nil)
return true, nil return true, nil
} }
if r.Method == http.MethodPost && pathname == "/api/nodes/reorder" {
body, err := ReadJSON(r, defaultMaxBody)
if err != nil {
return true, err
}
_, err = a.store.Transaction(func(d *Data) (any, error) {
ids := asIds(body["ids"])
byID := map[string]*Node{}
for _, item := range d.Nodes {
byID[item.ID] = item
}
ordered := []*Node{}
seen := map[string]bool{}
for _, id := range ids {
if node := byID[id]; node != nil && !seen[id] {
seen[id] = true
ordered = append(ordered, node)
}
}
rest := append([]*Node{}, d.Nodes...)
sort.SliceStable(rest, func(i, j int) bool { return rest[i].SortIndex < rest[j].SortIndex })
for _, item := range rest {
if !seen[item.ID] {
ordered = append(ordered, item)
}
}
for index, item := range ordered {
item.SortIndex = index
}
Audit(d, actor, "reorder_nodes", "nodes", map[string]any{"count": len(ordered)})
return nil, nil
})
if err != nil {
return true, err
}
SendJSON(w, 200, map[string]any{"ok": true}, nil)
return true, nil
}
if r.Method == http.MethodPost && pathname == "/api/nodes" { if r.Method == http.MethodPost && pathname == "/api/nodes" {
body, err := ReadJSON(r, defaultMaxBody) body, err := ReadJSON(r, defaultMaxBody)
if err != nil { if err != nil {
@@ -1948,6 +2281,13 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
next.CreatedAt = NowISO() next.CreatedAt = NowISO()
next.UpdatedAt = NowISO() next.UpdatedAt = NowISO()
EnsureNodeSecrets(next) EnsureNodeSecrets(next)
maxIndex := -1
for _, item := range d.Nodes {
if item.SortIndex > maxIndex {
maxIndex = item.SortIndex
}
}
next.SortIndex = maxIndex + 1
d.Nodes = append(d.Nodes, next) d.Nodes = append(d.Nodes, next)
Audit(d, actor, "create_node", next.ID, map[string]any{"name": next.Name}) Audit(d, actor, "create_node", next.ID, map[string]any{"name": next.Name})
return cloneJSONValue(next), nil return cloneJSONValue(next), nil
+30
View File
@@ -272,6 +272,10 @@ func (a *App) housekeeping() {
return return
} }
a.getSessions().Prune() a.getSessions().Prune()
// System-status sampling + due external-source refreshes (outside the
// transaction; fetches must not hold the store lock).
a.monitor().Sample(a.store.Snapshot(), false)
a.RefreshDueSources()
} }
// ---------- main ---------- // ---------- main ----------
@@ -361,6 +365,32 @@ func run() error {
} }
} }
// Node sort order migration (idempotent): unless the stored
// sortIndex values are exactly the permutation 0..n-1, renumber by
// storage order. Mirrors the Node implementation's boot migration.
{
nodes := store.Snapshot().Nodes
seen := map[int]bool{}
valid := true
for _, node := range nodes {
if node.SortIndex < 0 || node.SortIndex >= len(nodes) || seen[node.SortIndex] {
valid = false
break
}
seen[node.SortIndex] = true
}
if !valid {
if _, err := store.Transaction(func(d *Data) (any, error) {
for index, node := range d.Nodes {
node.SortIndex = index
}
return nil, nil
}); err != nil {
return err
}
}
}
app := NewApp(store, nil, NewOrchestrator(store)) app := NewApp(store, nil, NewOrchestrator(store))
app.replaceSessions(NewSessionManager(app.sessionHours())) app.replaceSessions(NewSessionManager(app.sessionHours()))
+222 -70
View File
@@ -612,6 +612,15 @@ func ValidateNodeDeploy(d *Data, node *Node) error {
return orchNotFound("出口服务器") return orchNotFound("出口服务器")
} }
} }
targets := []*Server{server}
if !direct {
targets = append(targets, exitServer)
}
for _, target := range targets {
if target.Enabled != nil && !*target.Enabled {
return httpErr(409, "设备 %s 已停用", target.Name)
}
}
if len(activeNodeClients(d, node)) == 0 { if len(activeNodeClients(d, node)) == 0 {
return httpErr(400, "请先给节点分配至少一个客户再部署") return httpErr(400, "请先给节点分配至少一个客户再部署")
} }
@@ -720,6 +729,10 @@ func reconcileServerData(d *Data, serverID, actor string, opts ReconcileOptions)
if server == nil { if server == nil {
return 0, orchNotFound("服务器") return 0, orchNotFound("服务器")
} }
// A disabled device receives no new deployments, including reconciles.
if server.Enabled != nil && !*server.Enabled {
return 0, nil
}
failInFlight := true failInFlight := true
if opts.FailInFlight != nil { if opts.FailInFlight != nil {
failInFlight = *opts.FailInFlight failInFlight = *opts.FailInFlight
@@ -935,61 +948,178 @@ func (o *Orchestrator) RepairNode(nodeID, actor string) (bool, error) {
return err == nil, err return err == nil, err
} }
// assignCustomerData is the transaction-free core of AssignCustomer,
// shared with the customer form's node alignment (AlignCustomerNodes).
func assignCustomerData(d *Data, nodeID, customerID, actor string) (*NodeClient, error) {
node := orchFindNode(d, nodeID)
if node == nil {
return nil, orchNotFound("节点")
}
customer := orchFindCustomer(d, customerID)
if customer == nil {
return nil, orchNotFound("客户")
}
if customer.Status != "active" {
return nil, httpErr(409, "只能给状态正常的客户分配节点")
}
var nc *NodeClient
for _, entry := range d.NodeClients {
if entry.NodeID == nodeID && entry.CustomerID == customerID {
nc = entry
break
}
}
if nc != nil && nc.Status == "active" {
return nil, httpErr(409, "该客户已分配到此节点")
}
if nc != nil {
nc.Status = "active"
nc.UpdatedAt = NowISO()
} else {
now := NowISO()
nc = &NodeClient{
ID: NewID("ncl"),
NodeID: nodeID,
CustomerID: customerID,
Credentials: NewNodeClientCredentials(node.Protocol),
Status: "active",
CreatedAt: now,
UpdatedAt: now,
}
d.NodeClients = append(d.NodeClients, nc)
}
if (node.Status == "removing" || node.Status == "draft") && !boolVal(node.AutoRemoving) {
// Record the assignment only.
} else {
if node.Status == "removing" && boolVal(node.AutoRemoving) {
cancelQueuedDeletes(d, node.ID, "已取消:节点恢复服务")
node.AutoRemoving = BoolPtr(false)
}
if err := RebuildNode(d, node, false); err != nil {
return nil, err
}
}
Audit(d, actor, "assign_customer", node.ID, map[string]any{"customerId": customerID, "customer": customer.Name})
return nc, nil
}
func unassignCustomerData(d *Data, nodeID, customerID, actor string) error {
node := orchFindNode(d, nodeID)
if node == nil {
return orchNotFound("节点")
}
index := -1
for i, entry := range d.NodeClients {
if entry.NodeID == nodeID && entry.CustomerID == customerID {
index = i
break
}
}
if index < 0 {
return httpErr(404, "该客户未分配到此节点")
}
d.NodeClients = append(d.NodeClients[:index], d.NodeClients[index+1:]...)
if node.Status != "draft" && node.Status != "removing" {
if err := RebuildNode(d, node, false); err != nil {
return err
}
}
Audit(d, actor, "unassign_customer", node.ID, map[string]any{"customerId": customerID})
return nil
}
// AlignCustomerNodes mirrors alignCustomerNodes in lib/orchestrator.js:
// full-set alignment of one customer's node bindings (customer form's
// 关联入站 multi-select). Suspended customers get suspended bindings.
func AlignCustomerNodes(d *Data, customer *Customer, desiredIds []string, actor string) error {
desired := []string{}
seen := map[string]bool{}
for _, nodeID := range desiredIds {
if nodeID == "" || seen[nodeID] {
continue
}
seen[nodeID] = true
node := orchFindNode(d, nodeID)
if node == nil || node.PendingDelete {
return httpErr(400, "节点不存在")
}
desired = append(desired, nodeID)
}
rebuild := map[string]bool{}
kept := []*NodeClient{}
for _, nc := range d.NodeClients {
if nc.CustomerID != customer.ID {
kept = append(kept, nc)
continue
}
if seen[nc.NodeID] {
kept = append(kept, nc)
continue
}
Audit(d, actor, "unassign_customer", nc.NodeID, map[string]any{"customerId": customer.ID})
rebuild[nc.NodeID] = true
}
d.NodeClients = kept
for _, nodeID := range desired {
exists := false
for _, nc := range d.NodeClients {
if nc.CustomerID == customer.ID && nc.NodeID == nodeID {
exists = true
break
}
}
if exists {
continue
}
node := orchFindNode(d, nodeID)
status := "suspended"
if customer.Status == "active" {
status = "active"
}
now := NowISO()
d.NodeClients = append(d.NodeClients, &NodeClient{
ID: NewID("ncl"),
NodeID: nodeID,
CustomerID: customer.ID,
Credentials: NewNodeClientCredentials(node.Protocol),
Status: status,
CreatedAt: now,
UpdatedAt: now,
})
Audit(d, actor, "assign_customer", node.ID, map[string]any{"customerId": customer.ID, "customer": customer.Name})
if status == "active" {
rebuild[nodeID] = true
}
}
for nodeID := range rebuild {
node := orchFindNode(d, nodeID)
if node == nil {
continue
}
if (node.Status == "removing" || node.Status == "draft") && !boolVal(node.AutoRemoving) {
continue
}
if node.Status == "removing" && boolVal(node.AutoRemoving) {
cancelQueuedDeletes(d, node.ID, "已取消:节点恢复服务")
node.AutoRemoving = BoolPtr(false)
}
if err := RebuildNode(d, node, false); err != nil {
return err
}
}
return nil
}
func (o *Orchestrator) AssignCustomer(nodeID, customerID, actor string) (*NodeClient, error) { func (o *Orchestrator) AssignCustomer(nodeID, customerID, actor string) (*NodeClient, error) {
if actor == "" { if actor == "" {
actor = "admin" actor = "admin"
} }
var result *NodeClient var result *NodeClient
err := o.tx(func(d *Data) error { err := o.tx(func(d *Data) error {
node := orchFindNode(d, nodeID) nc, err := assignCustomerData(d, nodeID, customerID, actor)
if node == nil { if err != nil {
return orchNotFound("节点") return err
} }
customer := orchFindCustomer(d, customerID)
if customer == nil {
return orchNotFound("客户")
}
if customer.Status != "active" {
return httpErr(409, "只能给状态正常的客户分配节点")
}
var nc *NodeClient
for _, entry := range d.NodeClients {
if entry.NodeID == nodeID && entry.CustomerID == customerID {
nc = entry
break
}
}
if nc != nil && nc.Status == "active" {
return httpErr(409, "该客户已分配到此节点")
}
if nc != nil {
nc.Status = "active"
nc.UpdatedAt = NowISO()
} else {
now := NowISO()
nc = &NodeClient{
ID: NewID("ncl"),
NodeID: nodeID,
CustomerID: customerID,
Credentials: NewNodeClientCredentials(node.Protocol),
Status: "active",
CreatedAt: now,
UpdatedAt: now,
}
d.NodeClients = append(d.NodeClients, nc)
}
if (node.Status == "removing" || node.Status == "draft") && !boolVal(node.AutoRemoving) {
// Record the assignment only.
} else {
if node.Status == "removing" && boolVal(node.AutoRemoving) {
cancelQueuedDeletes(d, node.ID, "已取消:节点恢复服务")
node.AutoRemoving = BoolPtr(false)
}
if err := RebuildNode(d, node, false); err != nil {
return err
}
}
Audit(d, actor, "assign_customer", node.ID, map[string]any{"customerId": customerID, "customer": customer.Name})
result = cloneNodeClientRecord(nc) result = cloneNodeClientRecord(nc)
return nil return nil
}) })
@@ -1001,28 +1131,7 @@ func (o *Orchestrator) UnassignCustomer(nodeID, customerID, actor string) (bool,
actor = "admin" actor = "admin"
} }
err := o.tx(func(d *Data) error { err := o.tx(func(d *Data) error {
node := orchFindNode(d, nodeID) return unassignCustomerData(d, nodeID, customerID, actor)
if node == nil {
return orchNotFound("节点")
}
index := -1
for i, entry := range d.NodeClients {
if entry.NodeID == nodeID && entry.CustomerID == customerID {
index = i
break
}
}
if index < 0 {
return httpErr(404, "该客户未分配到此节点")
}
d.NodeClients = append(d.NodeClients[:index], d.NodeClients[index+1:]...)
if node.Status != "draft" && node.Status != "removing" {
if err := RebuildNode(d, node, false); err != nil {
return err
}
}
Audit(d, actor, "unassign_customer", node.ID, map[string]any{"customerId": customerID})
return nil
}) })
return err == nil, err return err == nil, err
} }
@@ -1298,6 +1407,9 @@ func (o *Orchestrator) RecordUsage(serverID string, samples []UsageSample, errTe
slot = &UsageSlot{} slot = &UsageSlot{}
customer.ServerUsage[key] = slot customer.ServerUsage[key] = slot
} }
prevCounters := slot.UsageCounters
prevSampleISO := slot.LastSampleAt
restarted := prevCounters != nil && prevCounters.Epoch != nil && sampleEpoch != nil && *sampleEpoch != *prevCounters.Epoch
delta := ApplyDelta(SlotHolder(slot), uplink, downlink, sampleEpoch) delta := ApplyDelta(SlotHolder(slot), uplink, downlink, sampleEpoch)
if delta == nil { if delta == nil {
continue continue
@@ -1307,11 +1419,33 @@ func (o *Orchestrator) RecordUsage(serverID string, samples []UsageSample, errTe
customer.UsedBytes += delta.UpDelta + delta.DownDelta customer.UsedBytes += delta.UpDelta + delta.DownDelta
customer.LastUsageAt = StrPtr(reportedAt) customer.LastUsageAt = StrPtr(reportedAt)
customer.UpdatedAt = reportedAt customer.UpdatedAt = reportedAt
slot.LastSampleAt = StrPtr(reportedAt)
if delta.UpDelta > 0 || delta.DownDelta > 0 {
slot.LastActiveAt = StrPtr(reportedAt)
customer.LastOnlineAt = StrPtr(reportedAt)
}
if !restarted && prevCounters != nil && prevSampleISO != nil {
if prevMs, ok1 := ParseISOMs(*prevSampleISO); ok1 {
if nowMs, ok2 := ParseISOMs(reportedAt); ok2 {
dt := float64(nowMs-prevMs) / 1000
if dt >= 5 {
up := float64(int64(math.Round(float64(uplink-prevCounters.Uplink) / dt)))
down := float64(int64(math.Round(float64(downlink-prevCounters.Downlink) / dt)))
slot.SpeedUpBps = &up
slot.SpeedDownBps = &down
slot.SpeedAt = StrPtr(reportedAt)
}
}
}
}
case "inbound": case "inbound":
node := orchFindNode(d, sample.ID) node := orchFindNode(d, sample.ID)
if node == nil || node.ServerID != serverID { if node == nil || node.ServerID != serverID {
continue continue
} }
prevCounters := node.UsageCounters
prevSampleISO := node.LastSampleAt
restarted := prevCounters != nil && prevCounters.Epoch != nil && sampleEpoch != nil && *sampleEpoch != *prevCounters.Epoch
delta := ApplyDelta(NodeHolder(node), uplink, downlink, sampleEpoch) delta := ApplyDelta(NodeHolder(node), uplink, downlink, sampleEpoch)
if delta == nil { if delta == nil {
continue continue
@@ -1323,6 +1457,24 @@ func (o *Orchestrator) RecordUsage(serverID string, samples []UsageSample, errTe
} }
node.MeteredTraffic = &MeteredTraffic{Uplink: mu + delta.UpDelta, Downlink: md + delta.DownDelta} node.MeteredTraffic = &MeteredTraffic{Uplink: mu + delta.UpDelta, Downlink: md + delta.DownDelta}
node.LastUsageAt = StrPtr(reportedAt) node.LastUsageAt = StrPtr(reportedAt)
node.LastSampleAt = StrPtr(reportedAt)
if delta.UpDelta > 0 || delta.DownDelta > 0 {
node.LastActiveAt = StrPtr(reportedAt)
}
if !restarted && prevCounters != nil && prevSampleISO != nil {
if prevMs, ok1 := ParseISOMs(*prevSampleISO); ok1 {
if nowMs, ok2 := ParseISOMs(reportedAt); ok2 {
dt := float64(nowMs-prevMs) / 1000
if dt >= 5 {
up := float64(int64(math.Round(float64(uplink-prevCounters.Uplink) / dt)))
down := float64(int64(math.Round(float64(downlink-prevCounters.Downlink) / dt)))
node.SpeedUpBps = &up
node.SpeedDownBps = &down
node.SpeedAt = StrPtr(reportedAt)
}
}
}
}
} }
} }
return nil return nil
+94
View File
@@ -0,0 +1,94 @@
package main
// Presence & speed derivation — mirrors lib/presence.js. See that file
// for the signal model (per-server user counters, per-node inbound
// counters, AnyTLS observations; 3-minute window; server-level activity
// attributed to the customer's nodes on that server).
import "time"
const PresenceWindowMs = 180000
const SpeedStaleMs = 150000
func parseISOMsOr0(value *string) int64 {
if value == nil {
return 0
}
ms, ok := ParseISOMs(*value)
if !ok {
return 0
}
return ms
}
func CustomerOnline(customer *Customer, nowMs int64) bool {
ms := parseISOMsOr0(customer.LastOnlineAt)
return ms > 0 && nowMs-ms <= PresenceWindowMs
}
func CustomerSpeedBps(customer *Customer, nowMs int64) (float64, float64) {
var up, down float64
for _, slot := range customer.ServerUsage {
if slot == nil || slot.SpeedAt == nil {
continue
}
if nowMs-parseISOMsOr0(slot.SpeedAt) > SpeedStaleMs {
continue
}
if slot.SpeedUpBps != nil {
up += *slot.SpeedUpBps
}
if slot.SpeedDownBps != nil {
down += *slot.SpeedDownBps
}
}
return up, down
}
func NodeSpeedBps(node *Node, nowMs int64) (float64, float64) {
if node.SpeedAt == nil || nowMs-parseISOMsOr0(node.SpeedAt) > SpeedStaleMs {
return 0, 0
}
var up, down float64
if node.SpeedUpBps != nil {
up = *node.SpeedUpBps
}
if node.SpeedDownBps != nil {
down = *node.SpeedDownBps
}
return up, down
}
// NodeOnlineCustomerIds mirrors nodeOnlineCustomerIds in lib/presence.js.
func NodeOnlineCustomerIds(d *Data, node *Node, nowMs int64) []string {
out := []string{}
for _, nc := range d.NodeClients {
if nc.NodeID != node.ID || nc.Status != "active" {
continue
}
customer := findCustomer(d, nc.CustomerID)
if customer == nil {
continue
}
var ms int64
if slot := customer.ServerUsage["srv:"+node.ServerID]; slot != nil && slot.LastActiveAt != nil {
ms = parseISOMsOr0(slot.LastActiveAt)
} else {
ms = parseISOMsOr0(customer.LastOnlineAt)
}
if ms > 0 && nowMs-ms <= PresenceWindowMs {
out = append(out, customer.ID)
}
}
return out
}
func CountOnlineCustomers(d *Data, now time.Time) int {
count := 0
for _, customer := range d.Customers {
if CustomerOnline(customer, now.UnixMilli()) {
count++
}
}
return count
}
+1 -1
View File
@@ -43,7 +43,7 @@ func EmptyData() *Data {
SchemaVersion: 1, SchemaVersion: 1,
CreatedAt: NowISO(), CreatedAt: NowISO(),
Settings: Settings{ Settings: Settings{
BrandName: "NexusGate", BrandName: "amo",
SessionHours: 12, SessionHours: 12,
ObservationTtlMinutes: 10, ObservationTtlMinutes: 10,
ActivityRetentionDays: 30, ActivityRetentionDays: 30,
+1 -1
View File
@@ -30,7 +30,7 @@ func TestInitCreatesMissingFile(t *testing.T) {
if s.Data.SchemaVersion != 1 { if s.Data.SchemaVersion != 1 {
t.Errorf("SchemaVersion = %d", s.Data.SchemaVersion) t.Errorf("SchemaVersion = %d", s.Data.SchemaVersion)
} }
if s.Data.Settings.BrandName != "NexusGate" || s.Data.Settings.SessionHours != 12 || if s.Data.Settings.BrandName != "amo" || s.Data.Settings.SessionHours != 12 ||
s.Data.Settings.ObservationTtlMinutes != 10 || s.Data.Settings.ActivityRetentionDays != 30 || s.Data.Settings.ObservationTtlMinutes != 10 || s.Data.Settings.ActivityRetentionDays != 30 ||
s.Data.Settings.CompletedJobRetentionDays != 7 { s.Data.Settings.CompletedJobRetentionDays != 7 {
t.Errorf("settings defaults = %+v", s.Data.Settings) t.Errorf("settings defaults = %+v", s.Data.Settings)
+90 -3
View File
@@ -8,6 +8,7 @@ import (
"fmt" "fmt"
"net/url" "net/url"
"regexp" "regexp"
"sort"
"strconv" "strconv"
"strings" "strings"
) )
@@ -129,13 +130,29 @@ func NodeClientURI(d *Data, nc *NodeClient) string {
return uri return uri
} }
// sortIndexOf mirrors sortIndexOf in lib/subscriptions.js.
func sortIndexOf(d *Data, nodeID string) int {
for _, item := range d.Nodes {
if item.ID == nodeID {
return item.SortIndex
}
}
return 1 << 30
}
func Entries(d *Data, customerID string) []SubEntry { func Entries(d *Data, customerID string) []SubEntry {
seen := map[string]int{} seen := map[string]int{}
items := []SubEntry{} items := []SubEntry{}
bindings := []*NodeClient{}
for _, nc := range d.NodeClients { for _, nc := range d.NodeClients {
if nc.CustomerID != customerID || nc.Status != "active" { if nc.CustomerID == customerID && nc.Status == "active" {
continue bindings = append(bindings, nc)
} }
}
sort.SliceStable(bindings, func(i, j int) bool {
return sortIndexOf(d, bindings[i].NodeID) < sortIndexOf(d, bindings[j].NodeID)
})
for _, nc := range bindings {
uri := NodeClientURI(d, nc) uri := NodeClientURI(d, nc)
if uri == "" { if uri == "" {
continue continue
@@ -663,8 +680,78 @@ func MakeSingBox(items []SubEntry) string {
var clashUARe = regexp.MustCompile(`(?i)clash|mihomo`) var clashUARe = regexp.MustCompile(`(?i)clash|mihomo`)
// AggregateEntries mirrors aggregateEntries in lib/subscriptions.js:
// own entries (sortIndex order) → manual links → external sources,
// deduped by scheme+host+port+credential fingerprint, first wins.
func AggregateEntries(d *Data, customer *Customer) []SubEntry {
own := Entries(d, customer.ID)
out := []SubEntry{}
seenKeys := map[string]bool{}
seenNames := map[string]int{}
push := func(uri, name string) {
if uri == "" {
return
}
key := DedupKey(uri)
if key != "" && seenKeys[key] {
return
}
if key != "" {
seenKeys[key] = true
}
index := seenNames[name] + 1
seenNames[name] = index
finalName := name
if index != 1 {
finalName = fmt.Sprintf("%s (%d)", name, index)
}
out = append(out, SubEntry{ClientURI: WithName(uri, finalName)})
}
for _, entry := range own {
key := DedupKey(entry.ClientURI)
if key != "" {
seenKeys[key] = true
}
seenNames[NameFromURI(entry.ClientURI)]++
out = append(out, entry)
}
for i, link := range customer.ManualLinks {
if !link.Enabled {
continue
}
name := link.Remark
if name == "" {
name = NameFromURI(link.URI)
}
if name == "" {
name = fmt.Sprintf("手动节点 %d", i+1)
}
push(link.URI, name)
}
for _, source := range customer.ExternalSources {
if !source.Enabled {
continue
}
entry := d.SubSourceCache[source.ID]
if entry == nil || entry.OkAt == nil {
continue
}
for _, item := range entry.Items {
name := item.Name
if name == "" {
name = "未命名节点"
}
if source.Remark != "" {
name = "[" + source.Remark + "] " + name
}
push(item.URI, name)
}
}
return out
}
func FormatSubscription(d *Data, customer *Customer, format, userAgent string) (*SubResult, bool) { func FormatSubscription(d *Data, customer *Customer, format, userAgent string) (*SubResult, bool) {
items := Entries(d, customer.ID) items := AggregateEntries(d, customer)
if len(items) == 0 { if len(items) == 0 {
return nil, false return nil, false
} }
+382
View File
@@ -0,0 +1,382 @@
package main
// Host/system sampling for the system-status page — mirrors
// lib/sysstat.js. Memory-only ring history (240 samples), ~30s minimum
// sampling interval, "today" traffic baseline kept in memory (a panel
// restart restarts the day counter from traffic seen since boot).
import (
"net"
"os"
"runtime"
"strconv"
"strings"
"sync"
"syscall"
"time"
)
const historyCap = 240
const sampleMinIntervalMs = 25000
type sysSample struct {
At string `json:"at"`
CPU *float64 `json:"cpu"`
Mem *float64 `json:"mem"`
UpBps float64 `json:"upBps"`
DownBps float64 `json:"downBps"`
Connections int `json:"connections"`
}
type sysCurrent struct {
CPUPct *float64
MemTotal int64
MemUsed int64
DiskTotal int64
DiskUsed int64
UpBps float64
DownBps float64
Conns int
TotalUp int64
TotalDown int64
}
type SystemMonitor struct {
mu sync.Mutex
dataDir string
startedAt time.Time
prevIdle int64
prevTotal int64
hasPrev bool
cpuPct *float64
history []sysSample
lastSample time.Time
dayKey string
dayBaseUp int64
dayBaseDown int64
todayUp int64
todayDown int64
current *sysCurrent
}
func NewSystemMonitor(dataDir string) *SystemMonitor {
return &SystemMonitor{dataDir: dataDir, startedAt: time.Now()}
}
func readCPUTimes() (idle, total int64, ok bool) {
raw, err := os.ReadFile("/proc/stat")
if err != nil {
return 0, 0, false
}
line := strings.SplitN(string(raw), "\n", 2)[0]
fields := strings.Fields(line)
if len(fields) < 6 {
return 0, 0, false
}
var values []int64
for _, f := range fields[1:] {
n, err := strconv.ParseInt(f, 10, 64)
if err != nil {
return 0, 0, false
}
values = append(values, n)
}
idle = values[3]
if len(values) > 4 {
idle += values[4]
}
for _, v := range values {
total += v
}
return idle, total, true
}
func readMemInfo() (total, used int64) {
raw, err := os.ReadFile("/proc/meminfo")
if err != nil {
return 0, 0
}
grab := func(key string) int64 {
for _, line := range strings.Split(string(raw), "\n") {
if strings.HasPrefix(line, key+":") {
fields := strings.Fields(line)
if len(fields) >= 2 {
if n, err := strconv.ParseInt(fields[1], 10, 64); err == nil {
return n * 1024
}
}
}
}
return 0
}
total = grab("MemTotal")
available := grab("MemAvailable")
if total > 0 {
used = total - available
}
return total, used
}
func isGlobalIPv4(ip net.IP) bool {
v4 := ip.To4()
if v4 == nil {
return false
}
a, b := v4[0], v4[1]
switch {
case a == 0 || a == 10 || a == 127:
return false
case a == 172 && b >= 16 && b <= 31:
return false
case a == 192 && b == 168:
return false
case a == 100 && b >= 64 && b <= 127:
return false
case a == 169 && b == 254:
return false
case a >= 224:
return false
}
return true
}
func isGlobalIPv6(ip net.IP) bool {
if ip.To4() != nil {
return false
}
if ip.IsLoopback() || ip.IsUnspecified() || ip.IsMulticast() {
return false
}
b := ip.To16()
if b == nil {
return false
}
if b[0] == 0xfc || b[0] == 0xfd { // ULA fc00::/7
return false
}
if b[0] == 0xfe && b[1]&0xc0 == 0x80 { // link-local fe80::/10
return false
}
return true
}
// HostIPs picks the host's global interface addresses (no external
// lookup of any kind), mirroring hostIPs in lib/sysstat.js.
func HostIPs() (ipv4, ipv6 *string) {
ifaces, err := net.Interfaces()
if err != nil {
return nil, nil
}
for _, iface := range ifaces {
if iface.Flags&net.FlagLoopback != 0 || iface.Flags&net.FlagUp == 0 {
continue
}
addrs, err := iface.Addrs()
if err != nil {
continue
}
for _, addr := range addrs {
var ip net.IP
switch a := addr.(type) {
case *net.IPNet:
ip = a.IP
case *net.IPAddr:
ip = a.IP
}
if ip == nil {
continue
}
if ipv4 == nil && isGlobalIPv4(ip) {
s := ip.String()
ipv4 = &s
}
if ipv6 == nil && isGlobalIPv6(ip) {
s := ip.String()
ipv6 = &s
}
}
}
return ipv4, ipv6
}
func round1(v float64) float64 {
return float64(int64(v*10+0.5)) / 10
}
func (m *SystemMonitor) Sample(d *Data, force bool) *sysCurrent {
m.mu.Lock()
defer m.mu.Unlock()
now := time.Now()
if !force && m.current != nil && now.Sub(m.lastSample).Milliseconds() < sampleMinIntervalMs {
return m.current
}
m.lastSample = now
if idle, total, ok := readCPUTimes(); ok {
if m.hasPrev {
totalDelta := total - m.prevTotal
idleDelta := idle - m.prevIdle
if totalDelta > 0 {
pct := (1 - float64(idleDelta)/float64(totalDelta)) * 100
if pct < 0 {
pct = 0
}
if pct > 100 {
pct = 100
}
m.cpuPct = &pct
}
}
m.prevIdle, m.prevTotal, m.hasPrev = idle, total, true
}
memTotal, memUsed := readMemInfo()
var diskTotal, diskUsed int64
var statfs syscall.Statfs_t
if err := syscall.Statfs(m.dataDir, &statfs); err == nil {
diskTotal = int64(statfs.Blocks) * int64(statfs.Bsize)
diskUsed = (int64(statfs.Blocks) - int64(statfs.Bfree)) * int64(statfs.Bsize)
}
var totalUp, totalDown int64
var upBps, downBps float64
nowMs := now.UnixMilli()
for _, node := range d.Nodes {
if node.MeteredTraffic != nil {
totalUp += node.MeteredTraffic.Uplink
totalDown += node.MeteredTraffic.Downlink
}
up, down := NodeSpeedBps(node, nowMs)
upBps += up
downBps += down
}
dayKey := now.Format("2006-01-02")
if m.dayKey != dayKey {
m.dayKey = dayKey
m.dayBaseUp = totalUp
m.dayBaseDown = totalDown
}
m.todayUp = totalUp - m.dayBaseUp
if m.todayUp < 0 {
m.todayUp = 0
}
m.todayDown = totalDown - m.dayBaseDown
if m.todayDown < 0 {
m.todayDown = 0
}
conns := CountOnlineCustomers(d, now)
var cpuVal, memVal *float64
if m.cpuPct != nil {
v := round1(*m.cpuPct)
cpuVal = &v
}
if memTotal > 0 {
v := round1(float64(memUsed) / float64(memTotal) * 100)
memVal = &v
}
m.history = append(m.history, sysSample{
At: ISOFromTime(now), CPU: cpuVal, Mem: memVal,
UpBps: upBps, DownBps: downBps, Connections: conns,
})
if len(m.history) > historyCap {
m.history = m.history[len(m.history)-historyCap:]
}
m.current = &sysCurrent{
CPUPct: cpuVal, MemTotal: memTotal, MemUsed: memUsed,
DiskTotal: diskTotal, DiskUsed: diskUsed,
UpBps: upBps, DownBps: downBps, Conns: conns,
TotalUp: totalUp, TotalDown: totalDown,
}
return m.current
}
func panelMemBytes() int64 {
raw, err := os.ReadFile("/proc/self/status")
if err != nil {
return 0
}
for _, line := range strings.Split(string(raw), "\n") {
if strings.HasPrefix(line, "VmRSS:") {
fields := strings.Fields(line)
if len(fields) >= 2 {
if n, err := strconv.ParseInt(fields[1], 10, 64); err == nil {
return n * 1024
}
}
}
}
return 0
}
func hostUptimeSec() int64 {
raw, err := os.ReadFile("/proc/uptime")
if err != nil {
return 0
}
fields := strings.Fields(string(raw))
if len(fields) == 0 {
return 0
}
if v, err := strconv.ParseFloat(fields[0], 64); err == nil {
return int64(v)
}
return 0
}
func (m *SystemMonitor) Status(d *Data, version string) map[string]any {
current := m.Sample(d, false)
m.mu.Lock()
history := make([]sysSample, len(m.history))
copy(history, m.history)
todayUp, todayDown := m.todayUp, m.todayDown
startedAt := m.startedAt
m.mu.Unlock()
if current == nil {
current = &sysCurrent{}
}
memTotal, memUsed := current.MemTotal, current.MemUsed
var memPct, diskPct *float64
if memTotal > 0 {
v := round1(float64(memUsed) / float64(memTotal) * 100)
memPct = &v
}
if current.DiskTotal > 0 {
v := round1(float64(current.DiskUsed) / float64(current.DiskTotal) * 100)
diskPct = &v
}
onlineNodes := 0
for _, node := range d.Nodes {
if node.Status == "active" {
onlineNodes++
}
}
ipv4, ipv6 := HostIPs()
load1 := float64(0)
// load average from /proc/loadavg
if raw, err := os.ReadFile("/proc/loadavg"); err == nil {
fields := strings.Fields(string(raw))
if len(fields) > 0 {
load1, _ = strconv.ParseFloat(fields[0], 64)
}
}
return map[string]any{
"version": version,
"startedAt": ISOFromTime(startedAt),
"panelUptimeSec": int64(time.Since(startedAt).Seconds()),
"hostUptimeSec": hostUptimeSec(),
"panelMemBytes": panelMemBytes(),
"host": map[string]any{
"cpuPercent": current.CPUPct,
"cpuCount": runtime.NumCPU(),
"load1": load1,
"memTotal": memTotal, "memUsed": memUsed, "memPercent": memPct,
"diskTotal": current.DiskTotal, "diskUsed": current.DiskUsed, "diskPercent": diskPct,
},
"network": map[string]any{
"upBps": current.UpBps, "downBps": current.DownBps,
"connections": current.Conns, "onlineCustomers": current.Conns,
"onlineNodes": onlineNodes, "nodesTotal": len(d.Nodes), "customersTotal": len(d.Customers),
},
"today": map[string]any{"upBytes": todayUp, "downBytes": todayDown},
"totals": map[string]any{"upBytes": current.TotalUp, "downBytes": current.TotalDown},
"ips": map[string]any{"ipv4": ipv4, "ipv6": ipv6},
"history": history,
}
}
+69 -17
View File
@@ -217,23 +217,24 @@ func recordJSON[T any](raw []byte, dst *T, extra *extraFields) error {
// ---------- database ---------- // ---------- database ----------
type Data struct { type Data struct {
SchemaVersion int `json:"schemaVersion"` SchemaVersion int `json:"schemaVersion"`
CreatedAt string `json:"createdAt"` CreatedAt string `json:"createdAt"`
Settings Settings `json:"settings"` Settings Settings `json:"settings"`
Users []*User `json:"users"` Users []*User `json:"users"`
Servers []*Server `json:"servers"` Servers []*Server `json:"servers"`
Agents []*Agent `json:"agents"` Agents []*Agent `json:"agents"`
EnrollmentTokens []*EnrollmentToken `json:"enrollmentTokens"` EnrollmentTokens []*EnrollmentToken `json:"enrollmentTokens"`
Customers []*Customer `json:"customers"` Customers []*Customer `json:"customers"`
Nodes []*Node `json:"nodes"` Nodes []*Node `json:"nodes"`
NodeClients []*NodeClient `json:"nodeClients"` NodeClients []*NodeClient `json:"nodeClients"`
Jobs []*Job `json:"jobs"` Jobs []*Job `json:"jobs"`
Activity []*Activity `json:"activity"` Activity []*Activity `json:"activity"`
Observations []*Observation `json:"observations"` Observations []*Observation `json:"observations"`
SubscriptionAccess []*SubscriptionAccess `json:"subscriptionAccess"` SubscriptionAccess []*SubscriptionAccess `json:"subscriptionAccess"`
SubscriptionClients []*SubscriptionClient `json:"subscriptionClients"` SubscriptionClients []*SubscriptionClient `json:"subscriptionClients"`
KeyStore map[string]*KeyEntry `json:"keyStore"` KeyStore map[string]*KeyEntry `json:"keyStore"`
Extra extraFields `json:"-"` SubSourceCache map[string]*SubSourceCacheEntry `json:"subSourceCache,omitempty"`
Extra extraFields `json:"-"`
} }
func (d *Data) UnmarshalJSON(raw []byte) error { func (d *Data) UnmarshalJSON(raw []byte) error {
@@ -272,6 +273,8 @@ type Settings struct {
ObservationTtlMinutes float64 `json:"observationTtlMinutes"` ObservationTtlMinutes float64 `json:"observationTtlMinutes"`
ActivityRetentionDays float64 `json:"activityRetentionDays"` ActivityRetentionDays float64 `json:"activityRetentionDays"`
CompletedJobRetentionDays float64 `json:"completedJobRetentionDays"` CompletedJobRetentionDays float64 `json:"completedJobRetentionDays"`
ExternalSubRefreshMinutes float64 `json:"externalSubRefreshMinutes,omitempty"`
AllowPrivateSubSources bool `json:"allowPrivateSubSources,omitempty"`
Extra extraFields `json:"-"` Extra extraFields `json:"-"`
} }
@@ -379,6 +382,7 @@ type Server struct {
PortRangeEnd int `json:"portRangeEnd"` PortRangeEnd int `json:"portRangeEnd"`
Labels []string `json:"labels"` Labels []string `json:"labels"`
Status string `json:"status"` Status string `json:"status"`
Enabled *bool `json:"enabled,omitempty"`
CreatedAt string `json:"createdAt"` CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"` UpdatedAt string `json:"updatedAt"`
LastSeenAt *string `json:"lastSeenAt,omitempty"` LastSeenAt *string `json:"lastSeenAt,omitempty"`
@@ -501,6 +505,11 @@ func (c UsageCounters) MarshalJSON() ([]byte, error) {
type UsageSlot struct { type UsageSlot struct {
UsageCounters *UsageCounters `json:"usageCounters,omitempty"` UsageCounters *UsageCounters `json:"usageCounters,omitempty"`
RetiredUsageEpochs []string `json:"retiredUsageEpochs,omitempty"` RetiredUsageEpochs []string `json:"retiredUsageEpochs,omitempty"`
LastSampleAt *string `json:"lastSampleAt,omitempty"`
LastActiveAt *string `json:"lastActiveAt,omitempty"`
SpeedUpBps *float64 `json:"speedUpBps,omitempty"`
SpeedDownBps *float64 `json:"speedDownBps,omitempty"`
SpeedAt *string `json:"speedAt,omitempty"`
Extra extraFields `json:"-"` Extra extraFields `json:"-"`
} }
@@ -545,9 +554,46 @@ type Customer struct {
ServerUsage map[string]*UsageSlot `json:"serverUsage,omitempty"` ServerUsage map[string]*UsageSlot `json:"serverUsage,omitempty"`
LastUsageAt *string `json:"lastUsageAt,omitempty"` LastUsageAt *string `json:"lastUsageAt,omitempty"`
LastOnlineAt *string `json:"lastOnlineAt,omitempty"` LastOnlineAt *string `json:"lastOnlineAt,omitempty"`
ExternalSources []ExtSource `json:"externalSources,omitempty"`
ManualLinks []ManualLink `json:"manualLinks,omitempty"`
Extra extraFields `json:"-"` Extra extraFields `json:"-"`
} }
// ExtSource is one external subscription source on a customer
// (SUB-AGGREGATION-DESIGN.md). UserAgent is optional.
type ExtSource struct {
ID string `json:"id"`
URL string `json:"url"`
Remark string `json:"remark"`
Enabled bool `json:"enabled"`
UserAgent string `json:"userAgent,omitempty"`
}
// ManualLink is one hand-added node link on a customer.
type ManualLink struct {
ID string `json:"id"`
URI string `json:"uri"`
Remark string `json:"remark"`
Enabled bool `json:"enabled"`
}
// SubCacheItem is one parsed entry of a fetched external source.
type SubCacheItem struct {
URI string `json:"uri"`
Name string `json:"name"`
}
// SubSourceCacheEntry is the fetch cache for one external source,
// stored top-level in data.subSourceCache keyed by source id.
type SubSourceCacheEntry struct {
FetchedAt *string `json:"fetchedAt"`
OkAt *string `json:"okAt"`
FailCount int `json:"failCount"`
LastError *string `json:"lastError"`
DroppedCount int `json:"droppedCount"`
Items []SubCacheItem `json:"items"`
}
func (c *Customer) UnmarshalJSON(raw []byte) error { func (c *Customer) UnmarshalJSON(raw []byte) error {
type plain Customer type plain Customer
var p plain var p plain
@@ -678,6 +724,12 @@ type Node struct {
RetiredUsageEpochs []string `json:"retiredUsageEpochs"` RetiredUsageEpochs []string `json:"retiredUsageEpochs"`
MeteredTraffic *MeteredTraffic `json:"meteredTraffic"` MeteredTraffic *MeteredTraffic `json:"meteredTraffic"`
LastUsageAt *string `json:"lastUsageAt"` LastUsageAt *string `json:"lastUsageAt"`
SortIndex int `json:"sortIndex"`
SpeedUpBps *float64 `json:"speedUpBps,omitempty"`
SpeedDownBps *float64 `json:"speedDownBps,omitempty"`
SpeedAt *string `json:"speedAt,omitempty"`
LastActiveAt *string `json:"lastActiveAt,omitempty"`
LastSampleAt *string `json:"lastSampleAt,omitempty"`
CreatedAt string `json:"createdAt"` CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"` UpdatedAt string `json:"updatedAt"`
Extra extraFields `json:"-"` Extra extraFields `json:"-"`
+103
View File
@@ -0,0 +1,103 @@
package main
import (
"encoding/base64"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestParseLinkListBase64AndPlain(t *testing.T) {
plain := "vless://11111111-1111-1111-1111-111111111111@a.example.com:443?security=reality#One\n" +
"trojan://x@b.example.com:443#Bad\n" +
"ss://YWVzLTI1Ni1nY206cGFzcw@b.example.com:8388#Two\n"
encoded := base64.StdEncoding.EncodeToString([]byte(plain))
items, dropped := ParseLinkList(encoded)
if len(items) != 2 || dropped != 1 {
t.Fatalf("base64 list: items=%d dropped=%d", len(items), dropped)
}
if items[0].Name != "One" || items[1].Name != "Two" {
t.Fatalf("names: %+v", items)
}
items2, dropped2 := ParseLinkList(plain)
if len(items2) != 2 || dropped2 != 1 {
t.Fatalf("plain list: items=%d dropped=%d", len(items2), dropped2)
}
}
func TestFetchExternalSourceGuards(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("vless://11111111-1111-1111-1111-111111111111@a.example.com:443#X\n"))
}))
defer server.Close()
source := ExtSource{ID: "src_t", URL: server.URL, Enabled: true}
settings := &Settings{}
blocked := FetchExternalSource(source, settings)
if blocked.OK {
t.Fatal("loopback source must be rejected by default")
}
settings.AllowPrivateSubSources = true
allowed := FetchExternalSource(source, settings)
if !allowed.OK || len(allowed.Items) != 1 {
t.Fatalf("allowPrivate fetch: %+v", allowed)
}
}
func TestAggregateEntriesOrderAndDedup(t *testing.T) {
d := &Data{
Nodes: []*Node{
{ID: "nod_b", Name: "B", SortIndex: 1, ServerID: "srv_1", Protocol: "shadowsocks-aes256-gcm", Status: "active", Port: 20003},
{ID: "nod_a", Name: "A", SortIndex: 0, ServerID: "srv_1", Protocol: "shadowsocks-aes256-gcm", Status: "active", Port: 20001},
},
Servers: []*Server{{ID: "srv_1", Name: "S", PublicAddress: "s.example.com"}},
NodeClients: []*NodeClient{
{ID: "ncl_b", NodeID: "nod_b", CustomerID: "cus_1", Status: "active", Credentials: Credentials{RelayPassword: "pw-b"}},
{ID: "ncl_a", NodeID: "nod_a", CustomerID: "cus_1", Status: "active", Credentials: Credentials{RelayPassword: "pw-a"}},
},
SubSourceCache: map[string]*SubSourceCacheEntry{
"src_1": {Items: []SubCacheItem{{URI: "ss://YWVzLTI1Ni1nY206cHctYQ@s.example.com:20002#Dup", Name: "Dup"}}},
},
}
okAt := NowISO()
d.SubSourceCache["src_1"].OkAt = &okAt
customer := &Customer{
ID: "cus_1",
ManualLinks: []ManualLink{
{ID: "lnk_1", URI: "ss://YWVzLTI1Ni1nY206cHctYQ@s.example.com:20002#Whatever", Remark: "手动一号", Enabled: true},
},
ExternalSources: []ExtSource{{ID: "src_1", URL: "https://example.com/sub", Remark: "机场", Enabled: true}},
}
entries := AggregateEntries(d, customer)
names := []string{}
for _, entry := range entries {
names = append(names, NameFromURI(entry.ClientURI))
}
// Own entries first in sortIndex order (A before B although B's
// binding is stored first); the manual link follows; the external
// item duplicates the manual link (same host/port/credential) and
// is dropped.
joined := strings.Join(names, ",")
if joined != "A,B,手动一号" {
t.Fatalf("aggregate order/dedup: %v", names)
}
}
func TestPresenceWindow(t *testing.T) {
now := time.Now()
recent := ISOFromTime(now.Add(-60 * time.Second))
customer := &Customer{LastOnlineAt: &recent}
if !CustomerOnline(customer, now.UnixMilli()) {
t.Fatal("customer active 60s ago should be online")
}
old := ISOFromTime(now.Add(-10 * time.Minute))
customer.LastOnlineAt = &old
if CustomerOnline(customer, now.UnixMilli()) {
t.Fatal("customer active 10m ago should be offline")
}
}
func TestHostIPsNoPanic(t *testing.T) {
HostIPs() // just must not panic on any host
}
+215 -7
View File
@@ -7,11 +7,15 @@ const net = require('node:net');
const { Store } = require('./lib/store'); const { Store } = require('./lib/store');
const { hashSecret, verifySecret, randomToken, SessionManager } = require('./lib/auth'); const { hashSecret, verifySecret, randomToken, SessionManager } = require('./lib/auth');
const { sendJson, sendError, readJson, route, serveStatic } = require('./lib/http'); const { sendJson, sendError, readJson, route, serveStatic } = require('./lib/http');
const { PROFILE_CATALOG, REALITY_PRESETS, SHADOWSOCKS_METHODS, ensureNodeSecrets, newNodeClientCredentials, validateEntryProtocol, validateProtocolPair, isRealityProtocol, dropRealityKey } = require('./lib/protocols'); const { PROFILE_CATALOG, REALITY_PRESETS, SHADOWSOCKS_METHODS, ensureNodeSecrets, newNodeClientCredentials, validateEntryProtocol, validateProtocolPair, isRealityProtocol, dropRealityKey, nodeEmail } = require('./lib/protocols');
const { Orchestrator, audit, id, nowIso, queueJob, forwardNode, rebuildNode, selectPort, suspendCustomerResources, resumeCustomerResources } = require('./lib/orchestrator'); const { Orchestrator, audit, id, nowIso, queueJob, forwardNode, rebuildNode, selectPort, suspendCustomerResources, resumeCustomerResources, alignCustomerNodes } = require('./lib/orchestrator');
const { formatSubscription, nodeClientUri } = require('./lib/subscriptions'); const { formatSubscription, nodeClientUri } = require('./lib/subscriptions');
const { redactSecrets } = require('./lib/redact'); const { redactSecrets } = require('./lib/redact');
const { clientIdentity, activeClients, observedIps, pruneAccess } = require('./lib/access'); const { clientIdentity, activeClients, observedIps, pruneAccess } = require('./lib/access');
const { parseResetDay, armTrafficReset, applyMonthlyTrafficResets } = require('./lib/reset');
const { customerOnline, customerSpeedBps, nodeSpeedBps, nodeOnlineCustomerIds } = require('./lib/presence');
const { SystemMonitor } = require('./lib/sysstat');
const { fetchExternalSource, dueSources, applyFetchResult, normalizeExternalSources, normalizeManualLinks } = require('./lib/extsub');
const APP_ROOT = __dirname; const APP_ROOT = __dirname;
const PUBLIC_DIR = path.join(APP_ROOT, 'public'); const PUBLIC_DIR = path.join(APP_ROOT, 'public');
@@ -24,6 +28,7 @@ const VERSION = '0.6.8';
const store = new Store(DATA_FILE); const store = new Store(DATA_FILE);
let sessions; let sessions;
let orchestrator; let orchestrator;
let systemMonitor = null;
const loginAttempts = new Map(); const loginAttempts = new Map();
function cleanText(value, max = 120) { function cleanText(value, max = 120) {
@@ -70,6 +75,7 @@ function normalizeServer(body, current = {}) {
publicAddress: 'publicAddress' in body ? requiredText(body.publicAddress, '公网地址', 255) : current.publicAddress, publicAddress: 'publicAddress' in body ? requiredText(body.publicAddress, '公网地址', 255) : current.publicAddress,
publicAddressV6: 'publicAddressV6' in body ? cleanText(body.publicAddressV6, 255) : (current.publicAddressV6 || ''), publicAddressV6: 'publicAddressV6' in body ? cleanText(body.publicAddressV6, 255) : (current.publicAddressV6 || ''),
tlsDomain: 'tlsDomain' in body ? cleanText(body.tlsDomain, 253).toLowerCase() : (current.tlsDomain || ''), tlsDomain: 'tlsDomain' in body ? cleanText(body.tlsDomain, 253).toLowerCase() : (current.tlsDomain || ''),
enabled: 'enabled' in body ? body.enabled !== false : (current.enabled !== undefined ? current.enabled : true),
portRangeStart: 'portRangeStart' in body ? Number(body.portRangeStart) : Number(current.portRangeStart || 20000), portRangeStart: 'portRangeStart' in body ? Number(body.portRangeStart) : Number(current.portRangeStart || 20000),
portRangeEnd: 'portRangeEnd' in body ? Number(body.portRangeEnd) : Number(current.portRangeEnd || 50000), portRangeEnd: 'portRangeEnd' in body ? Number(body.portRangeEnd) : Number(current.portRangeEnd || 50000),
labels: 'labels' in body ? asIds(body.labels).slice(0, 20) : (current.labels || []) labels: 'labels' in body ? asIds(body.labels).slice(0, 20) : (current.labels || [])
@@ -134,14 +140,47 @@ function publicNode(item) {
function publicServer(item) { function publicServer(item) {
return { ...item, ...(item.engine ? { engine: { ...item.engine, detail: redactSecrets(item.engine.detail) } } : {}), return { ...item, ...(item.engine ? { engine: { ...item.engine, detail: redactSecrets(item.engine.detail) } } : {}),
enabled: item.enabled !== false,
agentVersion: store.data.agents.find((agent) => agent.serverId === item.id && agent.status === 'active')?.version || null, agentVersion: store.data.agents.find((agent) => agent.serverId === item.id && agent.status === 'active')?.version || null,
pendingCleanup: store.data.nodes.filter((entry) => (entry.serverId === item.id || entry.exitServerId === item.id) && entry.status === 'removing').length }; pendingCleanup: store.data.nodes.filter((entry) => (entry.serverId === item.id || entry.exitServerId === item.id) && entry.status === 'removing').length };
} }
function publicCustomer(item) { function publicCustomer(item) {
return { ...item, const now = Date.now();
const speed = customerSpeedBps(item, now);
const out = { ...item,
observedIpCount: observedIps(store.data, item.id).length, observedIpCount: observedIps(store.data, item.id).length,
subscriptionClientCount: activeClients(store.data, item.id).length }; subscriptionClientCount: activeClients(store.data, item.id).length,
online: customerOnline(item, now),
lastActiveAt: item.lastOnlineAt || null,
speedUpBps: speed.up, speedDownBps: speed.down };
if ((item.externalSources || []).length) {
out.externalStatus = {};
for (const source of item.externalSources) {
const cache = (store.data.subSourceCache || {})[source.id];
out.externalStatus[source.id] = cache
? { fetchedAt: cache.fetchedAt || null, okAt: cache.okAt || null, failCount: cache.failCount || 0,
lastError: cache.lastError || null, droppedCount: cache.droppedCount || 0, count: (cache.items || []).length }
: { fetchedAt: null, okAt: null, failCount: 0, lastError: null, droppedCount: 0, count: 0 };
}
}
return out;
}
// One customer's credential rows (凭据 tab): the subscription token plus
// one row per node binding, holding that binding's own credentials.
function credentialItems(data, customer) {
return data.nodeClients.filter((nc) => nc.customerId === customer.id).map((nc) => {
const node = data.nodes.find((item) => item.id === nc.nodeId);
return {
nodeClientId: nc.id, nodeId: nc.nodeId, nodeName: node ? node.name : nc.nodeId,
protocol: node ? node.protocol : '', status: nc.status,
clientId: nc.credentials ? nc.credentials.clientId || '' : '',
password: nc.credentials ? nc.credentials.relayPassword || '' : '',
shortId: nc.credentials ? nc.credentials.shortId || '' : '',
username: node && node.protocol === 'socks5-auth' ? nodeEmail(customer.id) : '',
};
});
} }
function customerBlockReason(data, customer) { function customerBlockReason(data, customer) {
@@ -398,6 +437,10 @@ async function handleAgent(req, res, pathname) {
const existing = data.observations.find((item) => item.customerId === customerId && item.ip === ip); const existing = data.observations.find((item) => item.customerId === customerId && item.ip === ip);
if (existing) existing.lastSeenAt = nowIso(); if (existing) existing.lastSeenAt = nowIso();
else data.observations.push({ id: id('obs'), customerId, ip, firstSeenAt: nowIso(), lastSeenAt: nowIso() }); else data.observations.push({ id: id('obs'), customerId, ip, firstSeenAt: nowIso(), lastSeenAt: nowIso() });
// An accepted observation is a live connection on this server:
// it is the customer's freshest presence signal.
const observedCustomer = data.customers.find((item) => item.id === customerId);
if (observedCustomer) observedCustomer.lastOnlineAt = nowIso();
affected.add(customerId); affected.add(customerId);
} }
for (const customerId of affected) { for (const customerId of affected) {
@@ -418,6 +461,33 @@ async function handleAgent(req, res, pathname) {
return false; return false;
} }
// Fetch one external source now and persist the outcome. Used by the
// manual refresh endpoint (awaited) and by save/housekeeping triggers.
async function refreshOneSource(customerId, sourceId) {
const customer = store.data.customers.find((item) => item.id === customerId);
const source = customer && (customer.externalSources || []).find((item) => item.id === sourceId);
if (!source) return null;
const result = await fetchExternalSource(source, store.data.settings);
await store.transaction((data) => { applyFetchResult(data, sourceId, result, nowIso()); });
return result;
}
function refreshSourcesInBackground(customerId, sourceIds) {
for (const sourceId of sourceIds || []) {
refreshOneSource(customerId, sourceId).catch((error) => console.error('External subscription refresh failed:', error));
}
}
async function refreshDueSources() {
try {
for (const { customerId, source } of dueSources(store.data, Date.now(), 3)) {
await refreshOneSource(customerId, source.id);
}
} catch (error) {
console.error('External subscription refresh failed:', error);
}
}
async function handleAdminApi(req, res, pathname) { async function handleAdminApi(req, res, pathname) {
if (!pathname.startsWith('/api/')) return false; if (!pathname.startsWith('/api/')) return false;
const changing = !['GET', 'HEAD'].includes(req.method); const changing = !['GET', 'HEAD'].includes(req.method);
@@ -440,6 +510,11 @@ async function handleAdminApi(req, res, pathname) {
}); });
return true; return true;
} }
if (req.method === 'GET' && pathname === '/api/system') {
if (!systemMonitor) systemMonitor = new SystemMonitor(path.dirname(DATA_FILE));
sendJson(res, 200, systemMonitor.status(store.data, VERSION));
return true;
}
if (req.method === 'GET' && pathname === '/api/protocols') { if (req.method === 'GET' && pathname === '/api/protocols') {
sendJson(res, 200, { profiles: PROFILE_CATALOG, realityPresets: REALITY_PRESETS }); sendJson(res, 200, { profiles: PROFILE_CATALOG, realityPresets: REALITY_PRESETS });
return true; return true;
@@ -602,7 +677,7 @@ async function handleAdminApi(req, res, pathname) {
} }
if (req.method === 'POST' && pathname === '/api/customers') { if (req.method === 'POST' && pathname === '/api/customers') {
const body = await readJson(req); const body = await readJson(req);
const customer = await store.transaction((data) => { const customer = await store.transaction(async (data) => {
const next = { const next = {
id: id('cus'), subscriptionToken: randomToken(32), name: requiredText(body.name, '客户名称'), group: cleanText(body.group, 80), id: id('cus'), subscriptionToken: randomToken(32), name: requiredText(body.name, '客户名称'), group: cleanText(body.group, 80),
status: 'active', trafficLimitBytes: nonNegativeNumber(body.trafficLimitBytes, '流量上限'), usedBytes: 0, usedUplinkBytes: 0, usedDownlinkBytes: 0, status: 'active', trafficLimitBytes: nonNegativeNumber(body.trafficLimitBytes, '流量上限'), usedBytes: 0, usedUplinkBytes: 0, usedDownlinkBytes: 0,
@@ -611,16 +686,25 @@ async function handleAdminApi(req, res, pathname) {
tags: asIds(body.tags).slice(0, 20), notes: cleanText(body.notes, 1000), tags: asIds(body.tags).slice(0, 20), notes: cleanText(body.notes, 1000),
createdAt: nowIso(), updatedAt: nowIso() createdAt: nowIso(), updatedAt: nowIso()
}; };
const resetDay = parseResetDay(body.trafficResetDay);
if (resetDay > 0) { next.trafficResetDay = resetDay; armTrafficReset(next); }
const sources = normalizeExternalSources(body.externalSources, id);
if (sources && sources.length) next.externalSources = sources;
const links = normalizeManualLinks(body.manualLinks, id);
if (links && links.length) next.manualLinks = links;
data.customers.push(next); data.customers.push(next);
if (Array.isArray(body.nodeIds)) await alignCustomerNodes(data, next, body.nodeIds, actor);
audit(data, actor, 'create_customer', next.id, { name: next.name }); audit(data, actor, 'create_customer', next.id, { name: next.name });
return structuredClone(next); return structuredClone(next);
}); });
refreshSourcesInBackground(customer.id, (customer.externalSources || []).map((source) => source.id));
sendJson(res, 201, { customer }); sendJson(res, 201, { customer });
return true; return true;
} }
const customerItem = route('/api/customers/:id', pathname); const customerItem = route('/api/customers/:id', pathname);
if (customerItem && req.method === 'PATCH') { if (customerItem && req.method === 'PATCH') {
const body = await readJson(req); const body = await readJson(req);
let changedSourceIds = [];
const customer = await store.transaction(async (data) => { const customer = await store.transaction(async (data) => {
const item = data.customers.find((entry) => entry.id === customerItem.id); const item = data.customers.find((entry) => entry.id === customerItem.id);
if (!item) throw Object.assign(new Error('客户不存在'), { statusCode: 404 }); if (!item) throw Object.assign(new Error('客户不存在'), { statusCode: 404 });
@@ -634,6 +718,32 @@ async function handleAdminApi(req, res, pathname) {
if ('deviceLimit' in body) item.deviceLimit = nonNegativeNumber(body.deviceLimit, '设备上限', true); if ('deviceLimit' in body) item.deviceLimit = nonNegativeNumber(body.deviceLimit, '设备上限', true);
if ('expiresAt' in body) item.expiresAt = optionalIso(body.expiresAt, '到期日期'); if ('expiresAt' in body) item.expiresAt = optionalIso(body.expiresAt, '到期日期');
if ('tags' in body) item.tags = asIds(body.tags).slice(0, 20); if ('tags' in body) item.tags = asIds(body.tags).slice(0, 20);
if ('trafficResetDay' in body) {
const resetDay = parseResetDay(body.trafficResetDay);
if (resetDay !== (item.trafficResetDay || 0)) {
// A changed schedule is re-armed rather than fired (mirrors
// panel-go); an unchanged echo leaves the marker alone.
if (resetDay > 0) item.trafficResetDay = resetDay; else delete item.trafficResetDay;
armTrafficReset(item);
}
}
if ('externalSources' in body) {
const previous = new Map((item.externalSources || []).map((source) => [source.id, source]));
const sources = normalizeExternalSources(body.externalSources, id) || [];
if (sources.length) item.externalSources = sources; else delete item.externalSources;
const kept = new Set(sources.map((source) => source.id));
if (data.subSourceCache) {
for (const oldId of previous.keys()) if (!kept.has(oldId)) delete data.subSourceCache[oldId];
}
changedSourceIds = sources.filter((source) => {
const old = previous.get(source.id);
return !old || old.url !== source.url || (old.userAgent || '') !== (source.userAgent || '');
}).map((source) => source.id);
}
if ('manualLinks' in body) {
const links = normalizeManualLinks(body.manualLinks, id) || [];
if (links.length) item.manualLinks = links; else delete item.manualLinks;
}
const reason = customerBlockReason(data, item); const reason = customerBlockReason(data, item);
if (body.status === 'active' && reason) { if (body.status === 'active' && reason) {
const error = new Error(`客户仍受${{ expired:'到期时间', traffic_limit:'流量额度', ip_limit:'节点 IP 上限' }[reason]}限制,请先修改限制再启用`); const error = new Error(`客户仍受${{ expired:'到期时间', traffic_limit:'流量额度', ip_limit:'节点 IP 上限' }[reason]}限制,请先修改限制再启用`);
@@ -646,10 +756,12 @@ async function handleAdminApi(req, res, pathname) {
} }
if (wasActive && item.status === 'suspended') suspendCustomerResources(data, item, actor); if (wasActive && item.status === 'suspended') suspendCustomerResources(data, item, actor);
if (!wasActive && item.status === 'active') await resumeCustomerResources(data, item, actor); if (!wasActive && item.status === 'active') await resumeCustomerResources(data, item, actor);
if (Array.isArray(body.nodeIds)) await alignCustomerNodes(data, item, body.nodeIds, actor);
item.updatedAt = nowIso(); item.updatedAt = nowIso();
audit(data, actor, 'update_customer', item.id); audit(data, actor, 'update_customer', item.id);
return structuredClone(item); return structuredClone(item);
}); });
refreshSourcesInBackground(customer.id, changedSourceIds);
sendJson(res, 200, { customer }); sendJson(res, 200, { customer });
return true; return true;
} }
@@ -686,24 +798,98 @@ async function handleAdminApi(req, res, pathname) {
data.nodes.some((node) => node.id === item.nodeId && !node.pendingDelete))) { data.nodes.some((node) => node.id === item.nodeId && !node.pendingDelete))) {
throw Object.assign(new Error('客户仍有节点分配。请先移除全部分配'), { statusCode: 409 }); throw Object.assign(new Error('客户仍有节点分配。请先移除全部分配'), { statusCode: 409 });
} }
const removed = data.customers[index];
data.customers.splice(index, 1); data.customers.splice(index, 1);
data.subscriptionAccess = data.subscriptionAccess.filter((item) => item.customerId !== customerItem.id); data.subscriptionAccess = data.subscriptionAccess.filter((item) => item.customerId !== customerItem.id);
data.subscriptionClients = data.subscriptionClients.filter((item) => item.customerId !== customerItem.id); data.subscriptionClients = data.subscriptionClients.filter((item) => item.customerId !== customerItem.id);
data.observations = data.observations.filter((item) => item.customerId !== customerItem.id); data.observations = data.observations.filter((item) => item.customerId !== customerItem.id);
if (data.subSourceCache) {
for (const source of removed.externalSources || []) delete data.subSourceCache[source.id];
}
audit(data, actor, 'delete_customer', customerItem.id); audit(data, actor, 'delete_customer', customerItem.id);
}); });
sendJson(res, 200, { ok: true }); sendJson(res, 200, { ok: true });
return true; return true;
} }
const credentialsRoute = route('/api/customers/:id/credentials', pathname);
if (credentialsRoute && req.method === 'GET') {
const customer = store.data.customers.find((item) => item.id === credentialsRoute.id);
if (!customer) throw Object.assign(new Error('客户不存在'), { statusCode: 404 });
sendJson(res, 200, { customerId: customer.id, subscriptionToken: customer.subscriptionToken,
items: credentialItems(store.data, customer) });
return true;
}
const regenerateRoute = route('/api/customers/:id/credentials/:ncid/regenerate', pathname);
if (regenerateRoute && req.method === 'POST') {
const item = await store.transaction(async (data) => {
const customer = data.customers.find((entry) => entry.id === regenerateRoute.id);
if (!customer) throw Object.assign(new Error('客户不存在'), { statusCode: 404 });
const nc = data.nodeClients.find((entry) => entry.id === regenerateRoute.ncid && entry.customerId === customer.id);
if (!nc) throw Object.assign(new Error('凭据不存在'), { statusCode: 404 });
const node = data.nodes.find((entry) => entry.id === nc.nodeId);
if (!node) throw Object.assign(new Error('节点不存在'), { statusCode: 404 });
// Same generation rules as a fresh assignment; the node resource is
// rebuilt so the old credential stops working on the wire.
nc.credentials = newNodeClientCredentials(node.protocol);
nc.updatedAt = nowIso();
if (['active', 'deploying', 'failed', 'degraded'].includes(node.status)) await rebuildNode(data, node);
audit(data, actor, 'regenerate_credentials', customer.id, { nodeId: node.id });
return credentialItems(data, customer).find((entry) => entry.nodeClientId === nc.id);
});
sendJson(res, 200, { item });
return true;
}
const sourceRefresh = route('/api/customers/:id/external-sources/:sid/refresh', pathname);
if (sourceRefresh && req.method === 'POST') {
const customer = store.data.customers.find((item) => item.id === sourceRefresh.id);
if (!customer) throw Object.assign(new Error('客户不存在'), { statusCode: 404 });
if (!(customer.externalSources || []).some((item) => item.id === sourceRefresh.sid)) {
throw Object.assign(new Error('外部订阅不存在'), { statusCode: 404 });
}
const result = await refreshOneSource(customer.id, sourceRefresh.sid);
sendJson(res, 200, { ok: Boolean(result && result.ok), count: result ? result.items.length : 0,
dropped: result ? result.dropped : 0, error: result ? result.error : '刷新失败' });
return true;
}
if (req.method === 'GET' && pathname === '/api/nodes') { if (req.method === 'GET' && pathname === '/api/nodes') {
const visibleNodes = store.data.nodes.filter((item) => !item.pendingDelete); const now = Date.now();
const visibleNodes = store.data.nodes.filter((item) => !item.pendingDelete)
.sort((a, b) => (Number.isFinite(a.sortIndex) ? a.sortIndex : Number.MAX_SAFE_INTEGER) - (Number.isFinite(b.sortIndex) ? b.sortIndex : Number.MAX_SAFE_INTEGER));
const visibleIds = new Set(visibleNodes.map((item) => item.id)); const visibleIds = new Set(visibleNodes.map((item) => item.id));
const nodeClients = store.data.nodeClients.filter((nc) => visibleIds.has(nc.nodeId)).map((nc) => { const nodeClients = store.data.nodeClients.filter((nc) => visibleIds.has(nc.nodeId)).map((nc) => {
const { credentials, ...safe } = nc; const { credentials, ...safe } = nc;
return { ...safe, clientUri: nodeClientUri(store.data, nc) }; return { ...safe, clientUri: nodeClientUri(store.data, nc) };
}); });
sendJson(res, 200, { nodes: visibleNodes.map(publicNode), nodeClients }); const nodes = visibleNodes.map((node) => {
const onlineCustomerIds = nodeOnlineCustomerIds(store.data, node, now);
const speed = nodeSpeedBps(node, now);
return { ...publicNode(node),
clientCount: store.data.nodeClients.filter((nc) => nc.nodeId === node.id && nc.status === 'active').length,
onlineCount: onlineCustomerIds.length, onlineCustomerIds,
speedUpBps: speed.up, speedDownBps: speed.down };
});
sendJson(res, 200, { nodes, nodeClients });
return true;
}
if (req.method === 'POST' && pathname === '/api/nodes/reorder') {
const body = await readJson(req);
const ids = asIds(body.ids);
if (!ids.length) throw Object.assign(new Error('排序列表不能为空'), { statusCode: 400 });
await store.transaction((data) => {
const byId = new Map(data.nodes.map((node) => [node.id, node]));
for (const nodeId of ids) {
if (!byId.has(nodeId)) throw Object.assign(new Error('节点不存在'), { statusCode: 400 });
}
const listed = new Set(ids);
const rest = data.nodes.filter((node) => !listed.has(node.id))
.sort((a, b) => (Number.isFinite(a.sortIndex) ? a.sortIndex : Number.MAX_SAFE_INTEGER) - (Number.isFinite(b.sortIndex) ? b.sortIndex : Number.MAX_SAFE_INTEGER));
[...ids.map((nodeId) => byId.get(nodeId)), ...rest].forEach((node, index) => { node.sortIndex = index; });
audit(data, actor, 'reorder_nodes', 'nodes', { count: ids.length });
return null;
});
sendJson(res, 200, { ok: true });
return true; return true;
} }
if (req.method === 'POST' && pathname === '/api/nodes') { if (req.method === 'POST' && pathname === '/api/nodes') {
@@ -713,6 +899,7 @@ async function handleAdminApi(req, res, pathname) {
const next = { const next = {
id: id('nod'), status: 'draft', entryReady: false, exitReady: false, id: id('nod'), status: 'draft', entryReady: false, exitReady: false,
resourceId: id('res'), exitResourceId: id('res'), resourceId: id('res'), exitResourceId: id('res'),
sortIndex: data.nodes.reduce((max, node) => Math.max(max, Number.isFinite(node.sortIndex) ? node.sortIndex : -1), -1) + 1,
...normalized, ...normalized,
artifacts: {}, error: null, pendingDelete: false, artifacts: {}, error: null, pendingDelete: false,
usageCounters: null, retiredUsageEpochs: [], meteredTraffic: null, lastUsageAt: null, usageCounters: null, retiredUsageEpochs: [], meteredTraffic: null, lastUsageAt: null,
@@ -1066,6 +1253,16 @@ async function housekeeping() {
try { try {
await store.transaction(async (data) => { await store.transaction(async (data) => {
const now = Date.now(); const now = Date.now();
// Monthly traffic resets run before the quota evaluation below,
// mirroring panel-go housekeeping.
const resetDone = applyMonthlyTrafficResets(data, new Date(), (draft, customer, previousUsed, day, period) =>
audit(draft, 'system:monthly_reset', 'reset_customer_usage_monthly', customer.id, { previousUsedBytes: previousUsed, resetDay: day, period }));
for (const customer of resetDone) {
if (customer.status === 'suspended' && customer.suspendReason === 'traffic_limit' && !customerBlockReason(data, customer)) {
customer.status = 'active'; customer.suspendReason = null; customer.updatedAt = nowIso();
await resumeCustomerResources(data, customer, 'system:monthly_reset');
}
}
for (const server of data.servers) { for (const server of data.servers) {
if (server.lastSeenAt && now - new Date(server.lastSeenAt).getTime() > 180000) server.status = 'offline'; if (server.lastSeenAt && now - new Date(server.lastSeenAt).getTime() > 180000) server.status = 'offline';
} }
@@ -1103,6 +1300,9 @@ async function housekeeping() {
return null; return null;
}); });
sessions.prune(); sessions.prune();
if (!systemMonitor) systemMonitor = new SystemMonitor(path.dirname(DATA_FILE));
systemMonitor.sample(store.data);
await refreshDueSources();
} catch (error) { } catch (error) {
console.error('Housekeeping failed:', error); console.error('Housekeeping failed:', error);
} }
@@ -1126,6 +1326,14 @@ async function main() {
if (store.data.customers.some((item) => !item.subscriptionToken)) { if (store.data.customers.some((item) => !item.subscriptionToken)) {
await store.transaction((data) => { for (const customer of data.customers) if (!customer.subscriptionToken) customer.subscriptionToken = randomToken(32); }); await store.transaction((data) => { for (const customer of data.customers) if (!customer.subscriptionToken) customer.subscriptionToken = randomToken(32); });
} }
// Node custom order: legacy records carry no sortIndex. Renumber by the
// stored array order whenever the values are not exactly 0..n-1.
const sortValues = store.data.nodes.map((node) => node.sortIndex);
const sortBroken = !sortValues.every((value) => Number.isFinite(value)) ||
[...sortValues].sort((a, b) => a - b).some((value, index) => value !== index);
if (store.data.nodes.length && sortBroken) {
await store.transaction((data) => { data.nodes.forEach((node, index) => { node.sortIndex = index; }); });
}
if (!store.data.users.length) { if (!store.data.users.length) {
const password = process.env.NG_ADMIN_PASSWORD || randomToken(15); const password = process.env.NG_ADMIN_PASSWORD || randomToken(15);
await store.transaction((data) => { await store.transaction((data) => {
+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);
});