From d287bb9be91a9973f79927b4d87e5243cb134009 Mon Sep 17 00:00:00 2001 From: aaaamoooo Date: Tue, 20 Oct 2026 18:09:07 +0000 Subject: [PATCH] =?UTF-8?q?feat(panel):=20reskin=20backend=20=E2=80=94=20n?= =?UTF-8?q?ode=20sortIndex,=20presence/speed,=20/api/system,=20credentials?= =?UTF-8?q?,=20subscription=20aggregation=20(Node+Go=20parity)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SUB-AGGREGATION-DESIGN.md | 55 +++++ lib/extsub.js | 284 +++++++++++++++++++++++ lib/orchestrator.js | 164 +++++++++---- lib/presence.js | 81 +++++++ lib/reset.js | 96 ++++++++ lib/store.js | 2 +- lib/subscriptions.js | 57 ++++- lib/sysstat.js | 189 +++++++++++++++ panel-go/difftest/lib.mjs | 6 +- panel-go/difftest/run.mjs | 199 ++++++++++++++++ panel-go/extsub.go | 475 ++++++++++++++++++++++++++++++++++++++ panel-go/handlers.go | 346 ++++++++++++++++++++++++++- panel-go/main.go | 30 +++ panel-go/orchestrator.go | 292 +++++++++++++++++------ panel-go/presence.go | 94 ++++++++ panel-go/store.go | 2 +- panel-go/store_test.go | 2 +- panel-go/subscriptions.go | 93 +++++++- panel-go/sysstat.go | 382 ++++++++++++++++++++++++++++++ panel-go/types.go | 86 +++++-- panel-go/uireskin_test.go | 103 +++++++++ server.js | 222 +++++++++++++++++- test/uireskin.test.js | 256 ++++++++++++++++++++ 23 files changed, 3370 insertions(+), 146 deletions(-) create mode 100644 SUB-AGGREGATION-DESIGN.md create mode 100644 lib/extsub.js create mode 100644 lib/presence.js create mode 100644 lib/reset.js create mode 100644 lib/sysstat.js create mode 100644 panel-go/extsub.go create mode 100644 panel-go/presence.go create mode 100644 panel-go/sysstat.go create mode 100644 panel-go/uireskin_test.go create mode 100644 test/uireskin.test.js diff --git a/SUB-AGGREGATION-DESIGN.md b/SUB-AGGREGATION-DESIGN.md new file mode 100644 index 0000000..fb6fa27 --- /dev/null +++ b/SUB-AGGREGATION-DESIGN.md @@ -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。 diff --git a/lib/extsub.js b/lib/extsub.js new file mode 100644 index 0000000..b9cdb65 --- /dev/null +++ b/lib/extsub.js @@ -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, +}; diff --git a/lib/orchestrator.js b/lib/orchestrator.js index 3a5d4c4..c4b54dd 100644 --- a/lib/orchestrator.js +++ b/lib/orchestrator.js @@ -240,6 +240,9 @@ function validateNodeDeploy(data, node) { else validateProtocolPair(node.protocol, node.exitProtocol); const server = assertFound(data.servers.find((s) => s.id === node.serverId), '服务器'); 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 (['vless-ws-tls', 'hysteria2', 'anytls'].includes(node.protocol)) { 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. async function reconcileServerData(data, serverId, actor, { onlyNodeIds = null, failInFlight = true } = {}) { 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) { 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(); @@ -314,6 +319,90 @@ async function reconcileServerData(data, serverId, actor, { onlyNodeIds = null, 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 { constructor(store) { this.store = store; @@ -365,47 +454,11 @@ class Orchestrator { } async assignCustomer(nodeId, customerId, actor = 'admin') { - return this.store.transaction(async (data) => { - 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); - }); + return this.store.transaction(async (data) => assignCustomerData(data, nodeId, customerId, actor)); } async unassignCustomer(nodeId, customerId, actor = 'admin') { - return this.store.transaction(async (data) => { - 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; - }); + return this.store.transaction(async (data) => unassignCustomerData(data, nodeId, customerId, actor)); } async reconcileServer(serverId, actor = 'system', options = {}) { @@ -516,8 +569,27 @@ class Orchestrator { const key = `srv:${serverId}`; customer.serverUsage = customer.serverUsage || {}; const slot = customer.serverUsage[key] || (customer.serverUsage[key] = {}); + const prevCounters = slot.usageCounters; + const prevSampleAt = slot.lastSampleAt; const delta = applyDelta(slot, uplink, downlink, sampleEpoch); 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.usedDownlinkBytes = (customer.usedDownlinkBytes || 0) + 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). const node = data.nodes.find((item) => item.id === sample.id && item.serverId === serverId); if (!node) continue; + const prevCounters = node.usageCounters; + const prevSampleAt = node.lastSampleAt; const delta = applyDelta(node, uplink, downlink, sampleEpoch); 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 }; node.meteredTraffic = { uplink: metered.uplink + delta.upDelta, downlink: metered.downlink + delta.downDelta }; node.lastUsageAt = reportedAt; @@ -541,4 +626,5 @@ class Orchestrator { module.exports = { Orchestrator, allocatePort, selectPort, nodePortInUse, queueJob, audit, id, nowIso, cancelQueuedDeletes, resourceForNode, resourceForNodeExit, rebuildNode, applyDelta, forwardNode, activeNodeClients, - suspendCustomerResources, resumeCustomerResources, HEARTBEAT_RECONCILE_INTERVAL_MS }; + suspendCustomerResources, resumeCustomerResources, HEARTBEAT_RECONCILE_INTERVAL_MS, + assignCustomerData, unassignCustomerData, alignCustomerNodes }; diff --git a/lib/presence.js b/lib/presence.js new file mode 100644 index 0000000..5eb06d3 --- /dev/null +++ b/lib/presence.js @@ -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, +}; diff --git a/lib/reset.js b/lib/reset.js new file mode 100644 index 0000000..20c51d4 --- /dev/null +++ b/lib/reset.js @@ -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 }; diff --git a/lib/store.js b/lib/store.js index 48330f1..00091fe 100644 --- a/lib/store.js +++ b/lib/store.js @@ -13,7 +13,7 @@ function emptyData() { schemaVersion: 1, createdAt: new Date().toISOString(), settings: { - brandName: 'NexusGate', + brandName: 'amo', sessionHours: 12, observationTtlMinutes: 10, activityRetentionDays: 30, diff --git a/lib/subscriptions.js b/lib/subscriptions.js index 7f10e5a..a958694 100644 --- a/lib/subscriptions.js +++ b/lib/subscriptions.js @@ -1,6 +1,12 @@ 'use strict'; 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 // 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) { const seen = new Map(); 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); if (!uri) continue; const node = data.nodes.find((item) => item.id === nodeClient.nodeId); @@ -38,6 +48,47 @@ function entries(data, customerId) { 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 clashProxy(item) { @@ -149,7 +200,7 @@ function makeSingBox(items) { } function formatSubscription(data, customer, format, userAgent = '') { - const items = entries(data, customer.id); + const items = aggregateEntries(data, customer); if (!items.length) return null; 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' }; @@ -160,4 +211,4 @@ function formatSubscription(data, customer, format, userAgent = '') { return undefined; } -module.exports = { entries, nodeClientUri, clashProxy, makeClash, makeSingBox, makeSurge, formatSubscription }; +module.exports = { entries, aggregateEntries, nodeClientUri, clashProxy, makeClash, makeSingBox, makeSurge, formatSubscription }; diff --git a/lib/sysstat.js b/lib/sysstat.js new file mode 100644 index 0000000..3c97176 --- /dev/null +++ b/lib/sysstat.js @@ -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 }; diff --git a/panel-go/difftest/lib.mjs b/panel-go/difftest/lib.mjs index 6ee8753..b5ad7ba 100644 --- a/panel-go/difftest/lib.mjs +++ b/panel-go/difftest/lib.mjs @@ -280,7 +280,7 @@ export async function fetchAllSubscriptions(client, token) { // 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 BARE_UUID_RE = /(? 0 ? '' : 0; if (keyName && PORT_FIELDS.has(keyName) && this.ports.has(value)) return this.ports.get(value); return value; } diff --git a/panel-go/difftest/run.mjs b/panel-go/difftest/run.mjs index a1312ed..ea64f22 100644 --- a/panel-go/difftest/run.mjs +++ b/panel-go/difftest/run.mjs @@ -21,8 +21,11 @@ // executed and the first differing JSON path is reported per step. import fs from 'node:fs'; +import http from 'node:http'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); import { ADMIN_USERNAME, DEFAULT_GO_BIN, DIFFTEST_DIR, Normalizer, PanelClient, 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. // --------------------------------------------------------------------------- @@ -360,6 +558,7 @@ async function runPair({ reporter, kindA, kindB, bin, seedPath, seedLog, seedExp try { const runner = new StepRunner(reporter, sideA, sideB); await runPhase2(runner, { seedLog, seedExpect }); + await runPhase3(runner); await runInterop(reporter, sideA, sideB, { seedExpect }); } finally { await destroySide(sideA, keep); diff --git a/panel-go/extsub.go b/panel-go/extsub.go new file mode 100644 index 0000000..1d665d6 --- /dev/null +++ b/panel-go/extsub.go @@ -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) + } +} diff --git a/panel-go/handlers.go b/panel-go/handlers.go index efa9d96..4c72b4e 100644 --- a/panel-go/handlers.go +++ b/panel-go/handlers.go @@ -14,6 +14,7 @@ import ( "net/http" "os" "regexp" + "sort" "strconv" "strings" "sync" @@ -36,6 +37,25 @@ type App struct { loginMu sync.Mutex 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 { @@ -158,6 +178,16 @@ func NormalizeServer(body map[string]any, current *Server) (*Server, error) { if next.TLSDomain != "" && !validTLSDomain(next.TLSDomain) { 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 } @@ -413,6 +443,7 @@ func PublicServer(item *Server, data *Data) map[string]any { } } out["pendingCleanup"] = pending + out["enabled"] = item.Enabled == nil || *item.Enabled return out } @@ -421,9 +452,76 @@ func PublicCustomer(item *Customer, data *Data) map[string]any { out := recordToMap(item) out["observedIpCount"] = len(ObservedIPs(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 } +// 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" / // "ip_limit", or "" for none (JS null). 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, }) } + if customer := findCustomer(d, customerID); customer != nil { + customer.LastOnlineAt = StrPtr(now) + } if !affectedSet[customerID] { affectedSet[customerID] = true affected = append(affected, customerID) @@ -1241,6 +1342,12 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st 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" { SendJSON(w, 200, map[string]any{"profiles": ProfileCatalog, "realityPresets": RealityPresets}, 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" { data := a.store.Snapshot() + now := time.Now() + nowMs := now.UnixMilli() customers := []map[string]any{} 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) return true, nil @@ -1631,6 +1750,16 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st if err != nil { 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) { name, err := RequiredText(body["name"], "客户名称", 120) 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 // reset instant, it never fires retroactively at creation. ArmTrafficReset(next, time.Now()) + if len(externalSources) > 0 { + next.ExternalSources = externalSources + } + if len(manualLinks) > 0 { + next.ManualLinks = manualLinks + } 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}) + createdID = next.ID + for _, source := range externalSources { + refreshIDs = append(refreshIDs, source.ID) + } return cloneJSONValue(next), nil }) if err != nil { return true, err } + if len(refreshIDs) > 0 { + a.RefreshSourcesInBackground(createdID, refreshIDs) + } SendJSON(w, 201, map[string]any{"customer": customerAny}, nil) return true, nil } @@ -1693,6 +1840,21 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st if err != nil { 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) { item := findCustomer(d, params["id"]) if item == nil { @@ -1700,6 +1862,7 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st } wasActive := item.Status == "active" previousReason := strVal(item.SuspendReason) + previousSources := append([]ExtSource{}, item.ExternalSources...) if _, ok := body["name"]; ok { name, err := RequiredText(body["name"], "客户名称", 120) if err != nil { @@ -1778,6 +1941,41 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st item.SuspendReason = nil 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" { _, _ = SuspendCustomerResources(d, item, actor) } @@ -1786,6 +1984,11 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st return nil, err } } + if body["nodeIds"] != nil { + if err := AlignCustomerNodes(d, item, asIds(body["nodeIds"]), actor); err != nil { + return nil, err + } + } item.UpdatedAt = NowISO() Audit(d, actor, "update_customer", item.ID, nil) return cloneJSONValue(item), nil @@ -1793,9 +1996,78 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st if err != nil { return true, err } + if len(patchRefreshIDs) > 0 { + a.RefreshSourcesInBackground(params["id"], patchRefreshIDs) + } SendJSON(w, 200, map[string]any{"customer": customerAny}, 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 { _, err := a.store.Transaction(func(d *Data) (any, error) { 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, "客户仍有节点分配。请先移除全部分配") } } + 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:]...) access := d.SubscriptionAccess[:0] 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" { 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{} visibleIDs := map[string]bool{} - for _, item := range data.Nodes { + for _, item := range sortedNodes { if item.PendingDelete { continue } 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{} 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) 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" { body, err := ReadJSON(r, defaultMaxBody) if err != nil { @@ -1948,6 +2281,13 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st next.CreatedAt = NowISO() next.UpdatedAt = NowISO() 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) Audit(d, actor, "create_node", next.ID, map[string]any{"name": next.Name}) return cloneJSONValue(next), nil diff --git a/panel-go/main.go b/panel-go/main.go index 0c1fb49..24fd982 100644 --- a/panel-go/main.go +++ b/panel-go/main.go @@ -272,6 +272,10 @@ func (a *App) housekeeping() { return } 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 ---------- @@ -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.replaceSessions(NewSessionManager(app.sessionHours())) diff --git a/panel-go/orchestrator.go b/panel-go/orchestrator.go index 0c4e9e6..f8f5d98 100644 --- a/panel-go/orchestrator.go +++ b/panel-go/orchestrator.go @@ -612,6 +612,15 @@ func ValidateNodeDeploy(d *Data, node *Node) error { 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 { return httpErr(400, "请先给节点分配至少一个客户再部署") } @@ -720,6 +729,10 @@ func reconcileServerData(d *Data, serverID, actor string, opts ReconcileOptions) if server == nil { return 0, orchNotFound("服务器") } + // A disabled device receives no new deployments, including reconciles. + if server.Enabled != nil && !*server.Enabled { + return 0, nil + } failInFlight := true if opts.FailInFlight != nil { failInFlight = *opts.FailInFlight @@ -935,61 +948,178 @@ func (o *Orchestrator) RepairNode(nodeID, actor string) (bool, error) { 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) { if actor == "" { actor = "admin" } var result *NodeClient err := o.tx(func(d *Data) error { - node := orchFindNode(d, nodeID) - if node == nil { - return orchNotFound("节点") + nc, err := assignCustomerData(d, nodeID, customerID, actor) + if err != nil { + 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) return nil }) @@ -1001,28 +1131,7 @@ func (o *Orchestrator) UnassignCustomer(nodeID, customerID, actor string) (bool, actor = "admin" } err := o.tx(func(d *Data) 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 + return unassignCustomerData(d, nodeID, customerID, actor) }) return err == nil, err } @@ -1298,6 +1407,9 @@ func (o *Orchestrator) RecordUsage(serverID string, samples []UsageSample, errTe slot = &UsageSlot{} 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) if delta == nil { continue @@ -1307,11 +1419,33 @@ func (o *Orchestrator) RecordUsage(serverID string, samples []UsageSample, errTe customer.UsedBytes += delta.UpDelta + delta.DownDelta customer.LastUsageAt = StrPtr(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": node := orchFindNode(d, sample.ID) if node == nil || node.ServerID != serverID { 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) if delta == nil { 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.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 diff --git a/panel-go/presence.go b/panel-go/presence.go new file mode 100644 index 0000000..85fefde --- /dev/null +++ b/panel-go/presence.go @@ -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 +} diff --git a/panel-go/store.go b/panel-go/store.go index be5cbe2..6bbf503 100644 --- a/panel-go/store.go +++ b/panel-go/store.go @@ -43,7 +43,7 @@ func EmptyData() *Data { SchemaVersion: 1, CreatedAt: NowISO(), Settings: Settings{ - BrandName: "NexusGate", + BrandName: "amo", SessionHours: 12, ObservationTtlMinutes: 10, ActivityRetentionDays: 30, diff --git a/panel-go/store_test.go b/panel-go/store_test.go index 05ef8c7..a1ffeb5 100644 --- a/panel-go/store_test.go +++ b/panel-go/store_test.go @@ -30,7 +30,7 @@ func TestInitCreatesMissingFile(t *testing.T) { if s.Data.SchemaVersion != 1 { 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.CompletedJobRetentionDays != 7 { t.Errorf("settings defaults = %+v", s.Data.Settings) diff --git a/panel-go/subscriptions.go b/panel-go/subscriptions.go index 00848ad..44b8d4f 100644 --- a/panel-go/subscriptions.go +++ b/panel-go/subscriptions.go @@ -8,6 +8,7 @@ import ( "fmt" "net/url" "regexp" + "sort" "strconv" "strings" ) @@ -129,13 +130,29 @@ func NodeClientURI(d *Data, nc *NodeClient) string { 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 { seen := map[string]int{} items := []SubEntry{} + bindings := []*NodeClient{} for _, nc := range d.NodeClients { - if nc.CustomerID != customerID || nc.Status != "active" { - continue + if nc.CustomerID == customerID && nc.Status == "active" { + 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) if uri == "" { continue @@ -663,8 +680,78 @@ func MakeSingBox(items []SubEntry) string { 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) { - items := Entries(d, customer.ID) + items := AggregateEntries(d, customer) if len(items) == 0 { return nil, false } diff --git a/panel-go/sysstat.go b/panel-go/sysstat.go new file mode 100644 index 0000000..b7aff63 --- /dev/null +++ b/panel-go/sysstat.go @@ -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, + } +} diff --git a/panel-go/types.go b/panel-go/types.go index 5125cd4..35d7126 100644 --- a/panel-go/types.go +++ b/panel-go/types.go @@ -217,23 +217,24 @@ func recordJSON[T any](raw []byte, dst *T, extra *extraFields) error { // ---------- database ---------- type Data struct { - SchemaVersion int `json:"schemaVersion"` - CreatedAt string `json:"createdAt"` - Settings Settings `json:"settings"` - Users []*User `json:"users"` - Servers []*Server `json:"servers"` - Agents []*Agent `json:"agents"` - EnrollmentTokens []*EnrollmentToken `json:"enrollmentTokens"` - Customers []*Customer `json:"customers"` - Nodes []*Node `json:"nodes"` - NodeClients []*NodeClient `json:"nodeClients"` - Jobs []*Job `json:"jobs"` - Activity []*Activity `json:"activity"` - Observations []*Observation `json:"observations"` - SubscriptionAccess []*SubscriptionAccess `json:"subscriptionAccess"` - SubscriptionClients []*SubscriptionClient `json:"subscriptionClients"` - KeyStore map[string]*KeyEntry `json:"keyStore"` - Extra extraFields `json:"-"` + SchemaVersion int `json:"schemaVersion"` + CreatedAt string `json:"createdAt"` + Settings Settings `json:"settings"` + Users []*User `json:"users"` + Servers []*Server `json:"servers"` + Agents []*Agent `json:"agents"` + EnrollmentTokens []*EnrollmentToken `json:"enrollmentTokens"` + Customers []*Customer `json:"customers"` + Nodes []*Node `json:"nodes"` + NodeClients []*NodeClient `json:"nodeClients"` + Jobs []*Job `json:"jobs"` + Activity []*Activity `json:"activity"` + Observations []*Observation `json:"observations"` + SubscriptionAccess []*SubscriptionAccess `json:"subscriptionAccess"` + SubscriptionClients []*SubscriptionClient `json:"subscriptionClients"` + KeyStore map[string]*KeyEntry `json:"keyStore"` + SubSourceCache map[string]*SubSourceCacheEntry `json:"subSourceCache,omitempty"` + Extra extraFields `json:"-"` } func (d *Data) UnmarshalJSON(raw []byte) error { @@ -272,6 +273,8 @@ type Settings struct { ObservationTtlMinutes float64 `json:"observationTtlMinutes"` ActivityRetentionDays float64 `json:"activityRetentionDays"` CompletedJobRetentionDays float64 `json:"completedJobRetentionDays"` + ExternalSubRefreshMinutes float64 `json:"externalSubRefreshMinutes,omitempty"` + AllowPrivateSubSources bool `json:"allowPrivateSubSources,omitempty"` Extra extraFields `json:"-"` } @@ -379,6 +382,7 @@ type Server struct { PortRangeEnd int `json:"portRangeEnd"` Labels []string `json:"labels"` Status string `json:"status"` + Enabled *bool `json:"enabled,omitempty"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` LastSeenAt *string `json:"lastSeenAt,omitempty"` @@ -501,6 +505,11 @@ func (c UsageCounters) MarshalJSON() ([]byte, error) { type UsageSlot struct { UsageCounters *UsageCounters `json:"usageCounters,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:"-"` } @@ -545,9 +554,46 @@ type Customer struct { ServerUsage map[string]*UsageSlot `json:"serverUsage,omitempty"` LastUsageAt *string `json:"lastUsageAt,omitempty"` LastOnlineAt *string `json:"lastOnlineAt,omitempty"` + ExternalSources []ExtSource `json:"externalSources,omitempty"` + ManualLinks []ManualLink `json:"manualLinks,omitempty"` 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 { type plain Customer var p plain @@ -678,6 +724,12 @@ type Node struct { RetiredUsageEpochs []string `json:"retiredUsageEpochs"` MeteredTraffic *MeteredTraffic `json:"meteredTraffic"` 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"` UpdatedAt string `json:"updatedAt"` Extra extraFields `json:"-"` diff --git a/panel-go/uireskin_test.go b/panel-go/uireskin_test.go new file mode 100644 index 0000000..ec0f51c --- /dev/null +++ b/panel-go/uireskin_test.go @@ -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 +} diff --git a/server.js b/server.js index ccdea2e..4d28ebe 100644 --- a/server.js +++ b/server.js @@ -7,11 +7,15 @@ const net = require('node:net'); const { Store } = require('./lib/store'); const { hashSecret, verifySecret, randomToken, SessionManager } = require('./lib/auth'); 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 { Orchestrator, audit, id, nowIso, queueJob, forwardNode, rebuildNode, selectPort, suspendCustomerResources, resumeCustomerResources } = require('./lib/orchestrator'); +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, alignCustomerNodes } = require('./lib/orchestrator'); const { formatSubscription, nodeClientUri } = require('./lib/subscriptions'); const { redactSecrets } = require('./lib/redact'); 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 PUBLIC_DIR = path.join(APP_ROOT, 'public'); @@ -24,6 +28,7 @@ const VERSION = '0.6.8'; const store = new Store(DATA_FILE); let sessions; let orchestrator; +let systemMonitor = null; const loginAttempts = new Map(); function cleanText(value, max = 120) { @@ -70,6 +75,7 @@ function normalizeServer(body, current = {}) { publicAddress: 'publicAddress' in body ? requiredText(body.publicAddress, '公网地址', 255) : current.publicAddress, publicAddressV6: 'publicAddressV6' in body ? cleanText(body.publicAddressV6, 255) : (current.publicAddressV6 || ''), 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), portRangeEnd: 'portRangeEnd' in body ? Number(body.portRangeEnd) : Number(current.portRangeEnd || 50000), labels: 'labels' in body ? asIds(body.labels).slice(0, 20) : (current.labels || []) @@ -134,14 +140,47 @@ function publicNode(item) { function publicServer(item) { 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, pendingCleanup: store.data.nodes.filter((entry) => (entry.serverId === item.id || entry.exitServerId === item.id) && entry.status === 'removing').length }; } function publicCustomer(item) { - return { ...item, + const now = Date.now(); + const speed = customerSpeedBps(item, now); + const out = { ...item, 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) { @@ -398,6 +437,10 @@ async function handleAgent(req, res, pathname) { const existing = data.observations.find((item) => item.customerId === customerId && item.ip === ip); if (existing) existing.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); } for (const customerId of affected) { @@ -418,6 +461,33 @@ async function handleAgent(req, res, pathname) { 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) { if (!pathname.startsWith('/api/')) return false; const changing = !['GET', 'HEAD'].includes(req.method); @@ -440,6 +510,11 @@ async function handleAdminApi(req, res, pathname) { }); 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') { sendJson(res, 200, { profiles: PROFILE_CATALOG, realityPresets: REALITY_PRESETS }); return true; @@ -602,7 +677,7 @@ async function handleAdminApi(req, res, pathname) { } if (req.method === 'POST' && pathname === '/api/customers') { const body = await readJson(req); - const customer = await store.transaction((data) => { + const customer = await store.transaction(async (data) => { const next = { 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, @@ -611,16 +686,25 @@ async function handleAdminApi(req, res, pathname) { tags: asIds(body.tags).slice(0, 20), notes: cleanText(body.notes, 1000), 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); + if (Array.isArray(body.nodeIds)) await alignCustomerNodes(data, next, body.nodeIds, actor); audit(data, actor, 'create_customer', next.id, { name: next.name }); return structuredClone(next); }); + refreshSourcesInBackground(customer.id, (customer.externalSources || []).map((source) => source.id)); sendJson(res, 201, { customer }); return true; } const customerItem = route('/api/customers/:id', pathname); if (customerItem && req.method === 'PATCH') { const body = await readJson(req); + let changedSourceIds = []; const customer = await store.transaction(async (data) => { const item = data.customers.find((entry) => entry.id === customerItem.id); 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 ('expiresAt' in body) item.expiresAt = optionalIso(body.expiresAt, '到期日期'); 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); if (body.status === 'active' && 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 === 'active') await resumeCustomerResources(data, item, actor); + if (Array.isArray(body.nodeIds)) await alignCustomerNodes(data, item, body.nodeIds, actor); item.updatedAt = nowIso(); audit(data, actor, 'update_customer', item.id); return structuredClone(item); }); + refreshSourcesInBackground(customer.id, changedSourceIds); sendJson(res, 200, { customer }); return true; } @@ -686,24 +798,98 @@ async function handleAdminApi(req, res, pathname) { data.nodes.some((node) => node.id === item.nodeId && !node.pendingDelete))) { throw Object.assign(new Error('客户仍有节点分配。请先移除全部分配'), { statusCode: 409 }); } + const removed = data.customers[index]; data.customers.splice(index, 1); data.subscriptionAccess = data.subscriptionAccess.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); + if (data.subSourceCache) { + for (const source of removed.externalSources || []) delete data.subSourceCache[source.id]; + } audit(data, actor, 'delete_customer', customerItem.id); }); sendJson(res, 200, { ok: 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') { - 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 nodeClients = store.data.nodeClients.filter((nc) => visibleIds.has(nc.nodeId)).map((nc) => { const { credentials, ...safe } = 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; } if (req.method === 'POST' && pathname === '/api/nodes') { @@ -713,6 +899,7 @@ async function handleAdminApi(req, res, pathname) { const next = { id: id('nod'), status: 'draft', entryReady: false, exitReady: false, 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, artifacts: {}, error: null, pendingDelete: false, usageCounters: null, retiredUsageEpochs: [], meteredTraffic: null, lastUsageAt: null, @@ -1066,6 +1253,16 @@ async function housekeeping() { try { await store.transaction(async (data) => { 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) { if (server.lastSeenAt && now - new Date(server.lastSeenAt).getTime() > 180000) server.status = 'offline'; } @@ -1103,6 +1300,9 @@ async function housekeeping() { return null; }); sessions.prune(); + if (!systemMonitor) systemMonitor = new SystemMonitor(path.dirname(DATA_FILE)); + systemMonitor.sample(store.data); + await refreshDueSources(); } catch (error) { console.error('Housekeeping failed:', error); } @@ -1126,6 +1326,14 @@ async function main() { 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); }); } + // 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) { const password = process.env.NG_ADMIN_PASSWORD || randomToken(15); await store.transaction((data) => { diff --git a/test/uireskin.test.js b/test/uireskin.test.js new file mode 100644 index 0000000..912b882 --- /dev/null +++ b/test/uireskin.test.js @@ -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); +});