9 Commits
Author SHA1 Message Date
Muse a81eff8c0d install-agent: RELEASE_BASE -> V2.2.1 (sh + ps1)
CI / go-test (push) Waiting to run
CI / postgres-durable-first (push) Waiting to run
CI / codegen (push) Waiting to run
CI / govulncheck (push) Waiting to run
CI / race (push) Waiting to run
CI / fuzz-smoke (push) Waiting to run
CI / golangci (push) Waiting to run
CI / frontend (push) Waiting to run
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (amd64) (push) Waiting to run
Release 3X-UI / Publish rolling dev release (push) Blocked by required conditions
CodeQL Advanced / Analyze (go) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
2026-10-23 15:25:09 +00:00
Muse 1d7eb16784 merge feat/v221: windows probe degradation + agent heartbeat probe + update-check fallback + procd/OpenRC script fixes + probe traffic GB unification + public remark name (V2.2.1) 2026-10-23 15:24:55 +00:00
Muse a13179ed9b v221 ⑦ public views: node name prefers the public remark
buildProbeNodeView now sources its 'name' from probeDisplayName:
the node's public remark (probe_meta.public_remark) when non-empty
after trimming, else the node name as before. Contract field name
unchanged, theme packages untouched, both audiences (anonymous and
admin frames share the builder); the panel's internal node list is
a different surface and keeps the plain node name.

Tests: display-name unit cases (remark / empty / whitespace) and
view-level assertions for both audiences; the public contract
test's seeded expectation updated to the remark (its fixture sets
public_remark='shown publicly' on node 'PubNode').
2026-10-23 15:02:12 +00:00
Muse 93c1620c15 v221 ⑥ probe traffic: binary GB convention + one-shot calibration
Unit unification: GB entries converted at 1e9 while every byte
formatter is binary (1024^3), so a 3000 GB limit rendered 2.73 TB
instead of 2.93 TB. calBytes and the form's limit conversion now
use 1024^3. Stored traffic_limits (bytes, typed x1e9) are rescaled
x1.073741824 exactly once at store open, guarded by the
probe_setting mark gb_1024_migrated_v1; calibration values are
stored in GB (no rescale — only their conversion changed) and
price/sort/reset-day are unitless.

One-shot calibration: the form's cal fields are deltas to apply
now. probe.MergeCalDelta folds a non-zero submitted value into the
stored standing correction and keeps the standing value on a
zero/absent submission; putMeta merges against the stored row
(SaveMeta/UpsertMeta keep plain replace — the notify sweep relies
on it). GET no longer echoes the standing correction into the form,
and the frontend keeps the cal inputs as local state that starts
empty and clears on successful save, so a later unrelated save
resubmits nothing.

Tests: calBytes(3000)=3000x2^30; migration 3.0e12 -> 3000x2^30 with
no second rescale on reopen; one-shot flow (save delta, save
unrelated with zero cal -> calibrated traffic unmoved, second delta
accumulates once). Existing TestTrafficCalibrationAppliedAndFloored
expectation updated to the binary figure. No vitest covers
ProbeMetaSection (disclosed in the report).
2026-10-23 15:02:11 +00:00
Muse ba57fb3c00 v221 ⑤ install-agent: OpenRC respawn_period=0 rejected by supervise-daemon
The item-23 template wrote respawn_period=0 assuming it meant 'no
window' like respawn_max=0; supervise-daemon validates the value at
service start and refuses: Invalid respawn-period value '0'
(Alpine real-machine report). Template now writes 1800, the value
the machine's other supervise-daemon services use; with
respawn_max=0 (unlimited) the period is only the counting window.

Why existing Alpine nodes (Leg-US) never hit it: the respawn lines
exist only since 27a5097 (2026-10-22, item 23) — before that the
template used command_background with no respawn parameters at all,
and panel one-click agent updates swap only the binary, never
regenerate /etc/init.d/x-ui-agent. Only fresh installs (or script
reruns) on OpenRC with the post-item-23 template were exposed.
2026-10-23 14:27:23 +00:00
Muse 151b07b73f v221 ④ install-agent: OpenWrt/procd support + honest nohup fallback
detect_init_system gains a procd tier ([ -f /etc/rc.common ] &&
command -v procd, after systemd/OpenRC; XUI_INIT_SYSTEM=procd also
accepted). start_via_procd writes /etc/init.d/xui-agent byte-equal
to the unit hand-verified on AX6600 (paths via AGENT_BIN/CONF_DIR),
stops any previous instance before overwriting, enables + starts,
and verifies the process is alive (pgrep, ps fallback). The nohup
fallback now errors out immediately and clearly when nohup itself
is missing (BusyBox-trimmed systems) instead of pretending to start
and failing at the end.
2026-10-23 14:26:31 +00:00
Muse cec5905483 v221 ③ update check: fall back to raw version when display is unparseable
displayUpdateFields returned avail=false whenever the running
build's display version could not be compared (the VV2.1.0
double-letter builds), so the panel swallowed a real update prompt
and the native updater refused to run. displayUpdateFieldsFor now
takes the current display/raw versions as inputs and, when the
display comparison cannot judge, compares the latest tag against
the raw base version with the same tolerant display parser —
unparseable raw base yields false. (Not isNewerVersion: on this
fork's uppercase-V tags it degrades to string inequality, which
would phantom-report an update on form difference alone.)
Parseable display versions keep the segment rule untouched.
Regression tests cover the VV scenario in both raw forms.
2026-10-23 14:26:31 +00:00
Muse 1df78dd874 v221 ② node probe: agent nodes answer from heartbeat, not a dead dial
NodeService.Probe now checks for a bound agent first: agent-managed
nodes synthesize the result from agent_nodes.last_seen (MAX over
bound agents, ms) — fresh within the same 180s window the heartbeat
job expires by = online with the heartbeat age as latency; stale or
never-seen = offline with LatencyMs -1. No dial, and LastError is
never written for agent nodes (the stored dial error from the
placeholder address https://agent:2053 is also healed on next ⚡,
since the synthesized patch carries an empty error through
UpdateHeartbeat). Traditional-node dial path unchanged; verdict is a
pure function with unit tests.

Frontend: the ⚡ tooltip/menu used t('pages.nodes.probe'), which
since the probe-metadata work resolves to the status-page SECTION
object, so i18next fell back to rendering the raw key. The existing
pages.nodes.testConnection key (present in all 13 locales) is the
right label — point the three usages at it. No new i18n keys.
2026-10-23 14:26:31 +00:00
Muse 92dfdc542c v221 ① windows probe: gate optional procs via Find + per-source recover guard
LazyProc.Call must-finds and panics on a missing export; the trimmed
Tianyi cloud-PC image has no FreeMibIfTable2 in iphlpapi.dll, which
killed the agent on its first collection after a successful register.
Optional procs (GetSystemTimes/GlobalMemoryStatusEx/GetIfTable2Ex+
FreeMibIfTable2 as one gated pair) now resolve through sourceGate
(Find once, missing = source disabled with one warning); the iface
pair is gated together so a missing free proc never leaks the
system table. collectMetrics additionally runs every source through
guardedCall so any residual panic zeroes that source alone.
Pure gate/guard logic lives in probe_guard.go (no build tag) with
Linux-runnable regression tests.
2026-10-23 14:26:22 +00:00
20 changed files with 796 additions and 44 deletions
+3 -3
View File
@@ -307,13 +307,13 @@ export default function NodeList({
</Tooltip> </Tooltip>
) : ( ) : (
<Space> <Space>
<Tooltip title={t('pages.nodes.probe')}> <Tooltip title={t('pages.nodes.testConnection')}>
<Button <Button
type="text" type="text"
size="small" size="small"
style={{ fontSize: 16 }} style={{ fontSize: 16 }}
icon={<ThunderboltOutlined />} icon={<ThunderboltOutlined />}
aria-label={t('pages.nodes.probe')} aria-label={t('pages.nodes.testConnection')}
onClick={() => onProbe(record)} onClick={() => onProbe(record)}
/> />
</Tooltip> </Tooltip>
@@ -716,7 +716,7 @@ export default function NodeList({
key: 'probe', key: 'probe',
label: ( label: (
<> <>
<ThunderboltOutlined /> {t('pages.nodes.probe')} <ThunderboltOutlined /> {t('pages.nodes.testConnection')}
</> </>
), ),
onClick: () => onProbe(record), onClick: () => onProbe(record),
+30 -8
View File
@@ -24,12 +24,20 @@ interface ProbeMetaWire {
ipv4Pin: string; ipv4Pin: string;
ipv6Pin: string; ipv6Pin: string;
// V2.0.1: ledger corrections in GB (either sign) and the counted // V2.0.1: ledger corrections in GB (either sign) and the counted
// interface pin ('' = automatic selection on the agent). // interface pin ('' = automatic selection on the agent). Since
// V2.2.1 the corrections are one-shot deltas: the inputs below are
// local state (never prefilled from the wire), a save submits them
// once, and they clear back to empty on success.
trafficCalUpGb: number; trafficCalUpGb: number;
trafficCalDownGb: number; trafficCalDownGb: number;
statIface: string; statIface: string;
} }
// GB entry converts on the binary convention every byte formatter in
// the panel and on the public page uses (V2.2.1: was decimal 1e9,
// which made a 3000 GB limit render as 2.73 TB instead of 2.93 TB).
const BYTES_PER_GB = 1024 ** 3;
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } }; const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } };
const NAMED_CYCLES = [ const NAMED_CYCLES = [
@@ -59,6 +67,9 @@ export default function ProbeMetaSection({ nodeId }: { nodeId: number }) {
// in NodeFormModal); visibility derives during render. // in NodeFormModal); visibility derives during render.
const [loaded, setLoaded] = useState<{ nodeId: number; meta: ProbeMetaWire } | null>(null); const [loaded, setLoaded] = useState<{ nodeId: number; meta: ProbeMetaWire } | null>(null);
const [trafficGb, setTrafficGb] = useState(0); const [trafficGb, setTrafficGb] = useState(0);
// One-shot calibration deltas (GB, either sign): null = empty box.
const [calUpGb, setCalUpGb] = useState<number | null>(null);
const [calDownGb, setCalDownGb] = useState<number | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
useEffect(() => { useEffect(() => {
@@ -68,7 +79,7 @@ export default function ProbeMetaSection({ nodeId }: { nodeId: number }) {
}).then((msg) => { }).then((msg) => {
if (!cancelled && msg?.success && msg.obj) { if (!cancelled && msg?.success && msg.obj) {
setLoaded({ nodeId, meta: msg.obj }); setLoaded({ nodeId, meta: msg.obj });
setTrafficGb(msg.obj.trafficLimit / 1e9); setTrafficGb(msg.obj.trafficLimit / BYTES_PER_GB);
} }
}); });
return () => { return () => {
@@ -116,14 +127,23 @@ export default function ProbeMetaSection({ nodeId }: { nodeId: number }) {
try { try {
const payload: ProbeMetaWire = { const payload: ProbeMetaWire = {
...meta, ...meta,
trafficLimit: Math.round(trafficGb * 1e9), trafficLimit: Math.round(trafficGb * BYTES_PER_GB),
trafficCalUpGb: calUpGb ?? 0,
trafficCalDownGb: calDownGb ?? 0,
}; };
const msg = await HttpUtil.put(`/panel/api/agent/probe-meta/${nodeId}`, payload, { const msg = await HttpUtil.put(`/panel/api/agent/probe-meta/${nodeId}`, payload, {
silent: true, silent: true,
...JSON_HEADERS, ...JSON_HEADERS,
}); });
if (msg?.success) { if (msg?.success) {
setLoaded({ nodeId, meta: payload }); // One-shot: the submitted corrections are folded in server-
// side; the boxes clear so a later save resubmits nothing.
setLoaded({
nodeId,
meta: { ...payload, trafficCalUpGb: 0, trafficCalDownGb: 0 },
});
setCalUpGb(null);
setCalDownGb(null);
messageApi.success(t('pages.nodes.probe.saved')); messageApi.success(t('pages.nodes.probe.saved'));
} else { } else {
messageApi.error(msg?.msg || t('pages.nodes.probe.saveFailed')); messageApi.error(msg?.msg || t('pages.nodes.probe.saveFailed'));
@@ -249,9 +269,10 @@ export default function ProbeMetaSection({ nodeId }: { nodeId: number }) {
<Col xs={24} md={8}> <Col xs={24} md={8}>
<Field label={t('pages.nodes.probe.calUp')}> <Field label={t('pages.nodes.probe.calUp')}>
<InputNumber <InputNumber
value={meta.trafficCalUpGb} value={calUpGb}
step={0.1} step={0.1}
onChange={(v) => set('trafficCalUpGb', v ?? 0)} placeholder="0"
onChange={(v) => setCalUpGb(v)}
style={{ width: '100%' }} style={{ width: '100%' }}
/> />
</Field> </Field>
@@ -259,9 +280,10 @@ export default function ProbeMetaSection({ nodeId }: { nodeId: number }) {
<Col xs={24} md={8}> <Col xs={24} md={8}>
<Field label={t('pages.nodes.probe.calDown')}> <Field label={t('pages.nodes.probe.calDown')}>
<InputNumber <InputNumber
value={meta.trafficCalDownGb} value={calDownGb}
step={0.1} step={0.1}
onChange={(v) => set('trafficCalDownGb', v ?? 0)} placeholder="0"
onChange={(v) => setCalDownGb(v)}
style={{ width: '100%' }} style={{ width: '100%' }}
/> />
</Field> </Field>
+1 -1
View File
@@ -29,7 +29,7 @@ $ErrorActionPreference = "Stop"
# Release download base, same shape as install-agent.sh's # Release download base, same shape as install-agent.sh's
# RELEASE_BASE (the version segment is bumped by the release flow). # RELEASE_BASE (the version segment is bumped by the release flow).
$ReleaseBase = "https://git.881218.xyz/rogue/3x-ui-agent/releases/download/V2.2.0" $ReleaseBase = "https://git.881218.xyz/rogue/3x-ui-agent/releases/download/V2.2.1"
if (-not $AgentUrl) { if (-not $AgentUrl) {
$AgentUrl = "$ReleaseBase/x-ui-agent-windows-amd64.exe" $AgentUrl = "$ReleaseBase/x-ui-agent-windows-amd64.exe"
} }
+62 -5
View File
@@ -11,7 +11,7 @@
# #
# 测试用环境变量(一般无需设置): # 测试用环境变量(一般无需设置):
# XUI_ROOT 安装路径前缀(假根目录验证用),默认空 = 真实路径 # XUI_ROOT 安装路径前缀(假根目录验证用),默认空 = 真实路径
# XUI_INIT_SYSTEM 强制 init 系统: systemd | openrc | none,默认 auto 探测 # XUI_INIT_SYSTEM 强制 init 系统: systemd | openrc | procd | none,默认 auto 探测
# XUI_URL agent 二进制下载地址覆盖 # XUI_URL agent 二进制下载地址覆盖
# #
# 二进制命名(P12 改名):agent 独立装为 /usr/local/x-ui/xui-agent,不再与主控共用 # 二进制命名(P12 改名):agent 独立装为 /usr/local/x-ui/xui-agent,不再与主控共用
@@ -81,7 +81,7 @@ esac
# sing-box 核心在 NexusGate 仓的专用发行里(见下方 SINGBOX_BASE)。 # sing-box 核心在 NexusGate 仓的专用发行里(见下方 SINGBOX_BASE)。
# 注意:本脚本是 V2 版——只装 sing-box 运行时;主控未升 V2 前不要用它 # 注意:本脚本是 V2 版——只装 sing-box 运行时;主控未升 V2 前不要用它
# 重装节点(节点升级走面板一键更新,不需要本脚本)。 # 重装节点(节点升级走面板一键更新,不需要本脚本)。
RELEASE_BASE="https://git.881218.xyz/rogue/3x-ui-agent/releases/download/V2.2.0" RELEASE_BASE="https://git.881218.xyz/rogue/3x-ui-agent/releases/download/V2.2.1"
XUI_URL="${XUI_URL:-${RELEASE_BASE}/x-ui-linux-${BIN_ARCH}}" XUI_URL="${XUI_URL:-${RELEASE_BASE}/x-ui-linux-${BIN_ARCH}}"
ROOT="${XUI_ROOT:-}" ROOT="${XUI_ROOT:-}"
@@ -220,7 +220,7 @@ stop_legacy_agent() {
detect_init_system() { detect_init_system() {
case "${XUI_INIT_SYSTEM:-auto}" in case "${XUI_INIT_SYSTEM:-auto}" in
systemd|openrc|none) echo "${XUI_INIT_SYSTEM}"; return;; systemd|openrc|procd|none) echo "${XUI_INIT_SYSTEM}"; return;;
esac esac
if [ -d /run/systemd/system ] && command -v systemctl >/dev/null 2>&1; then if [ -d /run/systemd/system ] && command -v systemctl >/dev/null 2>&1; then
echo systemd; return echo systemd; return
@@ -228,6 +228,9 @@ detect_init_system() {
if command -v rc-update >/dev/null 2>&1 && command -v rc-service >/dev/null 2>&1; then if command -v rc-update >/dev/null 2>&1 && command -v rc-service >/dev/null 2>&1; then
echo openrc; return echo openrc; return
fi fi
if [ -f /etc/rc.common ] && command -v procd >/dev/null 2>&1; then
echo procd; return
fi
echo none echo none
} }
@@ -286,10 +289,14 @@ command_args="agent"
# supervise-daemon 守护(第 23 条):进程崩溃后按 respawn 参数自动拉起, # supervise-daemon 守护(第 23 条):进程崩溃后按 respawn 参数自动拉起,
# 对齐 systemd 支的 Restart=on-failure + RestartSec=5;旧版 command_background # 对齐 systemd 支的 Restart=on-failure + RestartSec=5;旧版 command_background
# 只负责后台化、崩溃不重启。respawn_max=0 = 不限次(同 systemd 的持续重试)。 # 只负责后台化、崩溃不重启。respawn_max=0 = 不限次(同 systemd 的持续重试)。
# respawn_period 必须为正数:supervise-daemon 启动时校验该值,0 直接报
# "Invalid respawn-period value '0'" 拒绝拉起(V2.2.1 ⑤,Alpine 真机实证);
# 取 1800(真机上其他 supervise-daemon 服务同值)。max=0 不限次时 period
# 仅是计数窗口,不影响持续重试语义。
supervisor="supervise-daemon" supervisor="supervise-daemon"
respawn_delay=5 respawn_delay=5
respawn_max=0 respawn_max=0
respawn_period=0 respawn_period=1800
pidfile="/run/x-ui-agent.pid" pidfile="/run/x-ui-agent.pid"
output_log="/var/log/x-ui-agent.log" output_log="/var/log/x-ui-agent.log"
error_log="/var/log/x-ui-agent.log" error_log="/var/log/x-ui-agent.log"
@@ -322,9 +329,58 @@ EOF
fi fi
} }
# 以 procd 服务方式安装并启动(开机自启,OpenWrt)
start_via_procd() {
local init_dir="${ROOT}/etc/init.d"
mkdir -p "$init_dir"
# 服务脚本与 2026-10-23 AX6600 真机手工验证版逐字一致(V2.2.1 ④
# 收编):路径按脚本变量展开,XUI_ROOT 为空时即验证版的
# /usr/local/x-ui/xui-agent 与 /etc/x-ui/agent.json。procd 托管
# + respawn 守护,stdout/stderr 进 syslog(logread 查看)。
cat > "${init_dir}/xui-agent" <<EOF
#!/bin/sh /etc/rc.common
START=99
STOP=10
USE_PROCD=1
start_service() {
procd_open_instance
procd_set_param command ${AGENT_BIN} agent -config ${CONF_DIR}/agent.json
procd_set_param respawn 3600 5 5
procd_set_param stdout 1
procd_set_param stderr 1
procd_close_instance
}
EOF
chmod +x "${init_dir}/xui-agent"
info "已生成 procd 服务文件: ${init_dir}/xui-agent"
if [ -n "$ROOT" ]; then
info "(XUI_ROOT 模式:跳过 /etc/init.d 实际操作)"
return 0
fi
/etc/init.d/xui-agent stop 2>/dev/null || true
stop_legacy_agent
pkill -f "${AGENT_BIN} agent" 2>/dev/null || true; sleep 1
/etc/init.d/xui-agent enable
/etc/init.d/xui-agent start
sleep 2
if pgrep -f "${AGENT_BIN} agent" >/dev/null 2>&1 || ps w 2>/dev/null | grep "[x]ui-agent agent" >/dev/null 2>&1; then
info "agent 已由 procd 托管启动(开机自启)"
echo ""
echo " 主控: $MASTER"
echo " 节点名: $NAME"
echo " 日志: logread -f"
echo " agent 会在 30 秒内从主控拉取配置"
else
error "agent 服务启动失败,查看: logread | tail -n 50"
fi
}
# 无服务管理器时回落 nohup(无开机自启,明确警告) # 无服务管理器时回落 nohup(无开机自启,明确警告)
start_via_nohup() { start_via_nohup() {
warn "未检测到 systemd / OpenRC,退回 nohup 方式启动" # BusyBox 精简系统可能连 nohup 都没有(AX6600 实证):先讲清再退,
# 不要假装启动、走到最后才报一个看不懂的失败。
command -v nohup >/dev/null 2>&1 || error "未检测到 systemd / OpenRC / procd,且本机无 nohup 命令,无法启动 agent"
warn "未检测到 systemd / OpenRC / procd,退回 nohup 方式启动"
warn "注意:nohup 方式没有开机自启,机器重启后 agent 不会自动恢复,需手动重跑本脚本!" warn "注意:nohup 方式没有开机自启,机器重启后 agent 不会自动恢复,需手动重跑本脚本!"
stop_legacy_agent stop_legacy_agent
pkill -f "${AGENT_BIN} agent" 2>/dev/null || true; sleep 1 pkill -f "${AGENT_BIN} agent" 2>/dev/null || true; sleep 1
@@ -348,5 +404,6 @@ info "启动 agent..."
case "$(detect_init_system)" in case "$(detect_init_system)" in
systemd) start_via_systemd;; systemd) start_via_systemd;;
openrc) start_via_openrc;; openrc) start_via_openrc;;
procd) start_via_procd;;
*) start_via_nohup;; *) start_via_nohup;;
esac esac
+56
View File
@@ -0,0 +1,56 @@
package agent
import (
"sync"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
)
// This file holds the probe collector's degrade-don't-die plumbing:
// the per-source independence discipline as reusable mechanics,
// shared by every platform sampler but exercised today by the Windows
// sampler (the Linux sampler reads /proc files, whose failure mode is
// an error return, not a panic).
//
// Two layers:
//
// - source gates: an optional OS facility (a Win32 procedure, say)
// is probed once; when it is missing the source is skipped —
// zeroed — with a single warning, never re-probed, and never
// reached through a call path that panics on a missing export the
// way LazyProc.Call does when it must-finds a procedure;
// - guarded calls: a panicking source is recovered, zeroed and
// logged, so one bad source can never take the agent process (and
// every other source with it) down.
// sourceGate caches one availability probe. The zero value is ready.
type sourceGate struct {
once sync.Once
available bool
}
// enabled runs probe on first use and reports whether the source may
// be used. probe returns nil when the facility is present. A failed
// probe disables the source for the process lifetime and logs once.
func (g *sourceGate) enabled(name string, probe func() error) bool {
g.once.Do(func() {
if err := probe(); err != nil {
logger.Warningf("probe: %s unavailable, source disabled: %v", name, err)
return
}
g.available = true
})
return g.available
}
// guardedCall runs fn and converts a panic into the zero value plus a
// warning, so one failing metrics source degrades alone.
func guardedCall[T any](name string, fn func() T) (out T) {
defer func() {
if r := recover(); r != nil {
logger.Warningf("probe: %s panicked, source zeroed: %v", name, r)
out = *new(T)
}
}()
return fn()
}
+63
View File
@@ -0,0 +1,63 @@
package agent
import (
"errors"
"testing"
)
// The degrade-don't-die mechanics behind V2.2.1 ①: on the trimmed
// Tianyi cloud-PC image the Windows sampler died on its first
// collection because a missing iphlpapi export made LazyProc.Call
// panic. These tests pin the two layers that replace that failure
// mode — a failed availability probe disables the source (probed
// once, not per poll), and a panicking source is zeroed, never
// propagated.
func TestSourceGateDisablesFailedProbeOnce(t *testing.T) {
var g sourceGate
calls := 0
probe := func() error {
calls++
return errors.New("procedure not found")
}
for i := range 3 {
if g.enabled("test-source", probe) {
t.Fatalf("call %d: gate enabled despite failed probe", i)
}
}
if calls != 1 {
t.Fatalf("probe ran %d times, want exactly 1 (cached verdict)", calls)
}
}
func TestSourceGateEnablesSuccessfulProbeOnce(t *testing.T) {
var g sourceGate
calls := 0
probe := func() error {
calls++
return nil
}
for i := range 3 {
if !g.enabled("test-source", probe) {
t.Fatalf("call %d: gate disabled despite successful probe", i)
}
}
if calls != 1 {
t.Fatalf("probe ran %d times, want exactly 1 (cached verdict)", calls)
}
}
func TestGuardedCallRecoversPanicToZero(t *testing.T) {
got := guardedCall("test-source", func() uint64 {
panic("Failed to find FreeMibIfTable2 procedure in iphlpapi.dll")
})
if got != 0 {
t.Fatalf("guardedCall after panic = %d, want zero value", got)
}
}
func TestGuardedCallPassesValueThrough(t *testing.T) {
if got := guardedCall("test-source", func() string { return "ok" }); got != "ok" {
t.Fatalf("guardedCall = %q, want %q", got, "ok")
}
}
+81 -14
View File
@@ -35,6 +35,14 @@ package agent
// degradation discipline Load stays zero and TCP/UDP stay 0 // degradation discipline Load stays zero and TCP/UDP stay 0
// until a wrapper exists; // until a wrapper exists;
// - every call fails soft to zeros, like an unreadable /proc file. // - every call fails soft to zeros, like an unreadable /proc file.
// Optional procedures are resolved through source gates
// (probe_guard.go): LazyProc.Call must-finds and PANICS on a
// missing export, and trimmed images do ship DLLs without the
// full surface — the Tianyi cloud PC's iphlpapi.dll has no
// FreeMibIfTable2, which killed the V2.2.0 agent on its first
// collection (V2.2.1 ①). A missing procedure now disables just
// its source, and collectMetrics additionally guards each source
// so a panic anywhere degrades that source alone.
import ( import (
"net" "net"
@@ -50,15 +58,35 @@ import (
"golang.org/x/sys/windows/registry" "golang.org/x/sys/windows/registry"
) )
// kernel32 procs x/sys/windows v0.47.0 does not wrap. // kernel32 procs x/sys/windows v0.47.0 does not wrap, plus the
// iphlpapi pair this file calls directly (gated below, so x/sys'
// own GetIfTable2Ex wrapper — whose Addr() panics the same way on a
// missing export — is deliberately not used).
var ( var (
kernel32DLL = windows.NewLazySystemDLL("kernel32.dll") kernel32DLL = windows.NewLazySystemDLL("kernel32.dll")
procGetSystemTimes = kernel32DLL.NewProc("GetSystemTimes") procGetSystemTimes = kernel32DLL.NewProc("GetSystemTimes")
procGlobalMemoryStatus = kernel32DLL.NewProc("GlobalMemoryStatusEx") procGlobalMemoryStatus = kernel32DLL.NewProc("GlobalMemoryStatusEx")
iphlpapiDLL = windows.NewLazySystemDLL("iphlpapi.dll") iphlpapiDLL = windows.NewLazySystemDLL("iphlpapi.dll")
procGetIfTable2Ex = iphlpapiDLL.NewProc("GetIfTable2Ex")
procFreeMibIfTable2 = iphlpapiDLL.NewProc("FreeMibIfTable2") procFreeMibIfTable2 = iphlpapiDLL.NewProc("FreeMibIfTable2")
) )
// Availability gates for the optional procedures above. A LazyProc
// must never be Call()ed before its gate has proven the export
// exists: Call must-finds, so a missing procedure is a process-killing
// panic, not an error.
var (
gateSystemTimes sourceGate
gateMemoryStatus sourceGate
gateIfTable sourceGate
)
// procProbe adapts a LazyProc to a source-gate probe: Find resolves
// the export WITHOUT the mustFind panic baked into Call/Addr.
func procProbe(p *windows.LazyProc) func() error {
return func() error { return p.Find() }
}
func sampleFacts() *ProbeFacts { func sampleFacts() *ProbeFacts {
memTotal, _, swapTotal, _ := winMemory() memTotal, _, swapTotal, _ := winMemory()
diskTotal, _ := winDiskUsage() diskTotal, _ := winDiskUsage()
@@ -80,12 +108,26 @@ func sampleFacts() *ProbeFacts {
} }
} }
// memSnapshot / diskSnapshot pack the multi-value samplers so each
// can run as one guarded source.
type memSnapshot struct{ total, used, swapTotal, swapUsed uint64 }
type diskSnapshot struct{ total, used uint64 }
func collectMetrics() *ProbeMetrics { func collectMetrics() *ProbeMetrics {
probeColl.mu.Lock() probeColl.mu.Lock()
defer probeColl.mu.Unlock() defer probeColl.mu.Unlock()
memTotal, memUsed, swapTotal, swapUsed := winMemory() // Every source runs guarded: a panic in one (a Win32 surface the
diskTotal, diskUsed := winDiskUsage() // gates did not foresee) zeroes that source alone instead of
rows := winIfaceRows() // killing the agent on its first collection.
mem := guardedCall("memory", func() memSnapshot {
total, used, swapTotal, swapUsed := winMemory()
return memSnapshot{total, used, swapTotal, swapUsed}
})
disk := guardedCall("disk", func() diskSnapshot {
total, used := winDiskUsage()
return diskSnapshot{total, used}
})
rows := guardedCall("interfaces", winIfaceRows)
all := make([]netCounters, 0, len(rows)) all := make([]netCounters, 0, len(rows))
counted := make([]netCounters, 0, len(rows)) counted := make([]netCounters, 0, len(rows))
for _, r := range rows { for _, r := range rows {
@@ -108,22 +150,24 @@ func collectMetrics() *ProbeMetrics {
rxRate, txRate := probeColl.netRate(counted, time.Now()) rxRate, txRate := probeColl.netRate(counted, time.Now())
return &ProbeMetrics{ return &ProbeMetrics{
BootID: bootEpoch(winMachineGUID(), names), BootID: bootEpoch(winMachineGUID(), names),
Uptime: uint64(windows.DurationSinceBoot().Seconds()), Uptime: guardedCall("uptime", func() uint64 {
CPU: probeColl.cpuPercent(winCPUSample()), return uint64(windows.DurationSinceBoot().Seconds())
}),
CPU: probeColl.cpuPercent(guardedCall("cpu", winCPUSample)),
Load: [3]float32{}, Load: [3]float32{},
MemTotal: memTotal, MemTotal: mem.total,
MemUsed: memUsed, MemUsed: mem.used,
SwapTotal: swapTotal, SwapTotal: mem.swapTotal,
SwapUsed: swapUsed, SwapUsed: mem.swapUsed,
DiskTotal: diskTotal, DiskTotal: disk.total,
DiskUsed: diskUsed, DiskUsed: disk.used,
NetRxTotal: rxTotal, NetRxTotal: rxTotal,
NetTxTotal: txTotal, NetTxTotal: txTotal,
NetRx: rxRate, NetRx: rxRate,
NetTx: txRate, NetTx: txRate,
TCP: 0, // no exported x/sys wrapper (see header) TCP: 0, // no exported x/sys wrapper (see header)
UDP: 0, // no exported x/sys wrapper (see header) UDP: 0, // no exported x/sys wrapper (see header)
Procs: winProcCount(), Procs: guardedCall("processes", winProcCount),
} }
} }
@@ -212,6 +256,9 @@ func filetime100ns(ft windows.Filetime) uint64 {
// winCPUSample reads the cumulative system times as one cpuSample: // winCPUSample reads the cumulative system times as one cpuSample:
// total = kernel + user (kernel already includes idle, like jiffies). // total = kernel + user (kernel already includes idle, like jiffies).
func winCPUSample() *cpuSample { func winCPUSample() *cpuSample {
if !gateSystemTimes.enabled("GetSystemTimes", procProbe(procGetSystemTimes)) {
return nil
}
var idle, kernel, user windows.Filetime var idle, kernel, user windows.Filetime
r, _, _ := procGetSystemTimes.Call( r, _, _ := procGetSystemTimes.Call(
uintptr(unsafe.Pointer(&idle)), uintptr(unsafe.Pointer(&idle)),
@@ -248,6 +295,9 @@ func usedFromTotalAvail(total, avail uint64) uint64 {
} }
func winMemory() (total, used, swapTotal, swapUsed uint64) { func winMemory() (total, used, swapTotal, swapUsed uint64) {
if !gateMemoryStatus.enabled("GlobalMemoryStatusEx", procProbe(procGlobalMemoryStatus)) {
return 0, 0, 0, 0
}
var ms memoryStatusEx var ms memoryStatusEx
ms.Length = uint32(unsafe.Sizeof(ms)) ms.Length = uint32(unsafe.Sizeof(ms))
r, _, _ := procGlobalMemoryStatus.Call(uintptr(unsafe.Pointer(&ms))) r, _, _ := procGlobalMemoryStatus.Call(uintptr(unsafe.Pointer(&ms)))
@@ -359,8 +409,25 @@ func winIfaceCounts(r winIfaceRow) bool {
// derives both the all-interfaces list (for the operator pin) and the // derives both the all-interfaces list (for the operator pin) and the
// counted set from it. // counted set from it.
func winIfaceRows() []winIfaceRow { func winIfaceRows() []winIfaceRow {
// The get/free pair is gated as ONE source: calling GetIfTable2Ex
// without FreeMibIfTable2 would leak the system-allocated table
// on every poll, and either export missing must degrade the
// source to zero rows instead of panicking — the trimmed Tianyi
// cloud-PC image ships an iphlpapi.dll without FreeMibIfTable2,
// and the V2.2.0 sampler's unguarded LazyProc.Call died on it.
if !gateIfTable.enabled("GetIfTable2Ex/FreeMibIfTable2 (iphlpapi)", func() error {
if err := procGetIfTable2Ex.Find(); err != nil {
return err
}
return procFreeMibIfTable2.Find()
}) {
return nil
}
var table *windows.MibIfTable2 var table *windows.MibIfTable2
if err := windows.GetIfTable2Ex(windows.MibIfTableNormal, &table); err != nil || table == nil { r, _, _ := procGetIfTable2Ex.Call(
uintptr(windows.MibIfTableNormal),
uintptr(unsafe.Pointer(&table)))
if r != 0 || table == nil {
return nil return nil
} }
defer procFreeMibIfTable2.Call(uintptr(unsafe.Pointer(table))) defer procFreeMibIfTable2.Call(uintptr(unsafe.Pointer(table)))
+19 -4
View File
@@ -52,7 +52,10 @@ type probeMetaForm struct {
IPv4Pin string `json:"ipv4Pin"` IPv4Pin string `json:"ipv4Pin"`
IPv6Pin string `json:"ipv6Pin"` IPv6Pin string `json:"ipv6Pin"`
// V2.0.1: traffic calibration (GB, either sign) and the counted // V2.0.1: traffic calibration (GB, either sign) and the counted
// interface pin ("" = automatic). // interface pin ("" = automatic). Since V2.2.1 ⑥b the calibration
// fields are ONE-SHOT deltas: a non-zero value is folded into the
// stored standing correction on save (see probe.MergeCalDelta);
// zero/absent applies nothing.
TrafficCalUpGB float64 `json:"trafficCalUpGb"` TrafficCalUpGB float64 `json:"trafficCalUpGb"`
TrafficCalDownGB float64 `json:"trafficCalDownGb"` TrafficCalDownGB float64 `json:"trafficCalDownGb"`
StatIface string `json:"statIface"` StatIface string `json:"statIface"`
@@ -75,8 +78,11 @@ func metaToForm(m probe.Meta) probeMetaForm {
CountryPin: m.CountryPin, CountryPin: m.CountryPin,
IPv4Pin: m.IPv4Pin, IPv4Pin: m.IPv4Pin,
IPv6Pin: m.IPv6Pin, IPv6Pin: m.IPv6Pin,
TrafficCalUpGB: m.TrafficCalUpGB, // One-shot delta inputs (V2.2.1 ⑥b): the form's calibration
TrafficCalDownGB: m.TrafficCalDownGB, // boxes always start empty; the standing correction folded
// into the stored row is never echoed back into them.
TrafficCalUpGB: 0,
TrafficCalDownGB: 0,
StatIface: m.StatIface, StatIface: m.StatIface,
} }
} }
@@ -150,7 +156,16 @@ func (pc *ProbeMetaController) putMeta(c *gin.Context) {
jsonMsg(c, msg, errRequest) jsonMsg(c, msg, errRequest)
return return
} }
if err := probe.SaveMeta(form.toMeta(int64(agent.Id))); err != nil { // Calibration is one-shot (V2.2.1 ⑥b): fold the submitted deltas
// into the stored standing correction; all other fields replace.
meta := form.toMeta(int64(agent.Id))
if existing, found, err := probe.MetaFor(int64(agent.Id)); err != nil {
jsonMsg(c, "save probe meta failed", err)
return
} else if found {
meta = probe.MergeCalDelta(existing, meta)
}
if err := probe.SaveMeta(meta); err != nil {
jsonMsg(c, "save probe meta failed", err) jsonMsg(c, "save probe meta failed", err)
return return
} }
+3 -1
View File
@@ -323,7 +323,9 @@ func TestProbePublicContract(t *testing.T) {
assertKeys(t, keySet(t, nodeBody), anonNodeKeys) assertKeys(t, keySet(t, nodeBody), anonNodeKeys)
var node map[string]any var node map[string]any
_ = json.Unmarshal(nodeBody, &node) _ = json.Unmarshal(nodeBody, &node)
if node["id"] != float64(pubID) || node["name"] != "PubNode" || node["online"] != true || // V2.2.1 ⑦: the public view's name is the public remark when one
// is set (the seed sets "shown publicly"), not the node name.
if node["id"] != float64(pubID) || node["name"] != "shown publicly" || node["online"] != true ||
node["month_rx"] != float64(33) || node["month_used"] != float64(77) || node["month_rx"] != float64(33) || node["month_used"] != float64(77) ||
node["total_tx"] != float64(222) || node["price"] != float64(10) { node["total_tx"] != float64(222) || node["price"] != float64(10) {
t.Fatalf("anon node values wrong: %v", node) t.Fatalf("anon node values wrong: %v", node)
+67
View File
@@ -1234,7 +1234,74 @@ func (s *NodeService) AggregateNodeMetric(id int, metric string, bucketSeconds i
return nodeMetrics.aggregate(nodeMetricKey(id, metric), bucketSeconds, maxPoints) return nodeMetrics.aggregate(nodeMetricKey(id, metric), bucketSeconds, maxPoints)
} }
// agentOnlineWindow is how fresh an agent heartbeat must be for its
// node to read online — the same 180s the heartbeat job expires agent
// nodes by (agentHeartbeatStaleAfter in internal/web/job: max(3
// heartbeat intervals, 120s), so two lost heartbeats don't flap).
const agentOnlineWindow = 180 * time.Second
var errAgentOffline = errors.New("agent node offline: no fresh heartbeat")
// agentHeartbeatVerdict is the pure decision behind the agent-node
// probe: a fresh stamp reads online with the heartbeat AGE as the
// latency (there is no dial to time); a stale or never-seen stamp
// reads offline with LatencyMs -1. LastError stays empty either way —
// the answer comes from the heartbeat, not from a dial failure.
func agentHeartbeatVerdict(lastSeenMs int64, now time.Time) (HeartbeatPatch, bool) {
patch := HeartbeatPatch{LatencyMs: -1}
if lastSeenMs <= 0 {
return patch, false
}
patch.LastHeartbeat = lastSeenMs / 1000
if age := now.Sub(time.UnixMilli(lastSeenMs)); age <= agentOnlineWindow {
patch.LastHeartbeat = now.Unix()
patch.LatencyMs = int(age / time.Millisecond)
return patch, true
}
return patch, false
}
// agentNodeProbe answers a probe for an agent-managed node from the
// bound agent's last_seen stamp (Unix ms, the unit the agent
// controller stamps). ok=false means the node has no bound agent — or
// the lookup failed — and the caller takes the dial path. The stored
// telemetry fields carry through unchanged (same as the heartbeat
// job's agent-expiry patch); only the error is dropped, so a ⚡ click
// also heals the bogus dial errors recorded before this change.
func (s *NodeService) agentNodeProbe(n *model.Node) (patch HeartbeatPatch, err error, ok bool) {
var lastSeen *int64 // MAX() over zero rows is NULL
qerr := database.GetDB().Model(&model.AgentNode{}).
Where("node_id = ?", n.Id).
Select("MAX(last_seen)").
Scan(&lastSeen).Error
if qerr != nil || lastSeen == nil {
return HeartbeatPatch{}, nil, false
}
patch, online := agentHeartbeatVerdict(*lastSeen, time.Now())
patch.XrayVersion = n.XrayVersion
patch.PanelVersion = n.PanelVersion
patch.Guid = n.Guid
patch.CpuPct = n.CpuPct
patch.MemPct = n.MemPct
patch.UptimeSecs = n.UptimeSecs
patch.NetUp = n.NetUp
patch.NetDown = n.NetDown
patch.XrayState = n.XrayState
patch.XrayError = n.XrayError
if !online {
return patch, errAgentOffline, true
}
return patch, nil, true
}
func (s *NodeService) Probe(ctx context.Context, n *model.Node) (HeartbeatPatch, error) { func (s *NodeService) Probe(ctx context.Context, n *model.Node) (HeartbeatPatch, error) {
// Agent-managed nodes have no panel to dial — their address row
// is a placeholder ("agent") — so a probe answers from the agent
// heartbeat instead of dialing a dead URL and persisting a
// LastError the agent heartbeat never clears (V2.2.1 ②).
if patch, err, ok := s.agentNodeProbe(n); ok {
return patch, err
}
proxyURL := "" proxyURL := ""
if n.OutboundTag != "" { if n.OutboundTag != "" {
if mgr := runtime.GetManager(); mgr != nil { if mgr := runtime.GetManager(); mgr != nil {
@@ -0,0 +1,70 @@
package service
import (
"testing"
"time"
)
// V2.2.1 ②: the ⚡ probe for an agent-managed node is synthesized
// from the agent's heartbeat stamp instead of dialing the placeholder
// address. These tests pin the pure verdict: fresh stamp → online
// with the heartbeat age as latency; stale / never-seen → offline
// with LatencyMs -1; LastError empty in every case (a dial error must
// never be recorded for an agent node again).
func TestAgentHeartbeatVerdictFreshIsOnlineWithAgeLatency(t *testing.T) {
now := time.Unix(1_700_000_000, 0)
lastSeenMs := now.Add(-30 * time.Second).UnixMilli()
patch, online := agentHeartbeatVerdict(lastSeenMs, now)
if !online {
t.Fatal("30s-old heartbeat must read online")
}
if patch.LatencyMs != 30_000 {
t.Fatalf("LatencyMs = %d, want heartbeat age 30000", patch.LatencyMs)
}
if patch.LastHeartbeat != now.Unix() {
t.Fatalf("LastHeartbeat = %d, want now %d", patch.LastHeartbeat, now.Unix())
}
if patch.LastError != "" {
t.Fatalf("LastError = %q, want empty", patch.LastError)
}
}
func TestAgentHeartbeatVerdictWindowEdge(t *testing.T) {
now := time.Unix(1_700_000_000, 0)
if _, online := agentHeartbeatVerdict(now.Add(-agentOnlineWindow).UnixMilli(), now); !online {
t.Fatal("heartbeat exactly at the window edge must read online")
}
if _, online := agentHeartbeatVerdict(now.Add(-agentOnlineWindow-time.Second).UnixMilli(), now); online {
t.Fatal("heartbeat past the window must read offline")
}
}
func TestAgentHeartbeatVerdictStaleIsOffline(t *testing.T) {
now := time.Unix(1_700_000_000, 0)
lastSeenMs := now.Add(-10 * time.Minute).UnixMilli()
patch, online := agentHeartbeatVerdict(lastSeenMs, now)
if online {
t.Fatal("10min-old heartbeat must read offline")
}
if patch.LatencyMs != -1 {
t.Fatalf("LatencyMs = %d, want -1", patch.LatencyMs)
}
if patch.LastHeartbeat != lastSeenMs/1000 {
t.Fatalf("LastHeartbeat = %d, want the truthful last-seen %d", patch.LastHeartbeat, lastSeenMs/1000)
}
if patch.LastError != "" {
t.Fatalf("LastError = %q, want empty", patch.LastError)
}
}
func TestAgentHeartbeatVerdictNeverSeenIsOffline(t *testing.T) {
now := time.Unix(1_700_000_000, 0)
patch, online := agentHeartbeatVerdict(0, now)
if online {
t.Fatal("never-seen agent must read offline")
}
if patch.LatencyMs != -1 || patch.LastHeartbeat != 0 || patch.LastError != "" {
t.Fatalf("patch = %+v, want zero stamp, latency -1, no error", patch)
}
}
+27 -2
View File
@@ -164,13 +164,38 @@ func (s *PanelService) GetUpdateInfo() (*PanelUpdateInfo, error) {
// display version. An unparseable tag (not a V-series release) yields "" // display version. An unparseable tag (not a V-series release) yields ""
// so the UI falls back to the raw fields. // so the UI falls back to the raw fields.
func displayUpdateFields(latestTag string) (string, bool) { func displayUpdateFields(latestTag string) (string, bool) {
return displayUpdateFieldsFor(latestTag, config.GetMasterDisplayVersion(), config.GetBaseVersion())
}
// displayUpdateFieldsFor is displayUpdateFields with the running
// build's versions as inputs. When the DISPLAY comparison cannot
// judge — the running build's display version is itself unparseable,
// the "VV2.1.0" double-letter builds — the verdict falls back to the
// raw version comparison instead of reporting "no update": the
// display path used to swallow a real update entirely (the panel
// showed no prompt while /releases/latest happily served the newer
// tag, and the native updater refused to run). A parseable display
// version keeps the segment rule exactly as before.
func displayUpdateFieldsFor(latestTag, currentDisplay, currentRaw string) (string, bool) {
parts, ok := config.ParseDisplayVersion(latestTag) parts, ok := config.ParseDisplayVersion(latestTag)
if !ok { if !ok {
return "", false return "", false
} }
disp := fmt.Sprintf("V%d.%d.%d", parts[0], parts[1], parts[2]) disp := fmt.Sprintf("V%d.%d.%d", parts[0], parts[1], parts[2])
cmp, ok := config.CompareDisplayVersion(disp, config.GetMasterDisplayVersion()) cmp, ok := config.CompareDisplayVersion(disp, currentDisplay)
return disp, ok && cmp > 0 if !ok {
// Raw fallback: judge the latest tag against the running
// build's raw base version, parsed with the same tolerant
// display parser (bare and V/A forms both parse). An
// unparseable raw base yields no verdict → false. This is
// deliberately NOT isNewerVersion: on this fork's uppercase-V
// tags that helper degrades to string inequality (its
// normalizer strips only a lowercase "v"), which would report
// a phantom update whenever latest merely differs in form.
rawCmp, rawOk := config.CompareDisplayVersion(disp, currentRaw)
return disp, rawOk && rawCmp > 0
}
return disp, cmp > 0
} }
// devChannelActive reports whether self-update should track the rolling dev // devChannelActive reports whether self-update should track the rolling dev
@@ -25,6 +25,42 @@ func TestDisplayUpdateFields(t *testing.T) {
} }
} }
// V2.2.1 ③: when the running build's display version is unparseable
// (the "VV2.1.0" double-letter builds), the display comparison cannot
// judge and the verdict must fall back to the raw version comparison
// instead of swallowing the update. A parseable display version keeps
// the segment rule — the raw string plays no part there.
func TestDisplayUpdateFieldsForFallback(t *testing.T) {
// Unparseable display "VV2.1.0", newer latest → available via raw.
if disp, avail := displayUpdateFieldsFor("V2.2.0", "VV2.1.0", "2.1.0"); disp != "V2.2.0" || !avail {
t.Fatalf("VV display, newer latest = %q, %v; want V2.2.0, true", disp, avail)
}
// Unparseable display, latest equal to the raw base → not
// available, in both raw forms (bare and V-prefixed).
if disp, avail := displayUpdateFieldsFor("V2.1.0", "VV2.1.0", "2.1.0"); disp != "V2.1.0" || avail {
t.Fatalf("VV display, same latest (bare raw) = %q, %v; want V2.1.0, false", disp, avail)
}
if _, avail := displayUpdateFieldsFor("V2.1.0", "VV2.1.0", "V2.1.0"); avail {
t.Fatal("VV display, same latest (V raw) must not report an update")
}
// Unparseable display AND unparseable raw base → no verdict, false.
if _, avail := displayUpdateFieldsFor("V2.2.0", "VV2.1.0", "3.7.0-agent.1"); avail {
t.Fatal("unparseable raw base must yield no update verdict")
}
// Parseable display: segment rule decides, raw base is ignored
// even when it disagrees.
if disp, avail := displayUpdateFieldsFor("V2.2.0", "V2.1.0", "9.9.9"); disp != "V2.2.0" || !avail {
t.Fatalf("parseable display, newer = %q, %v; want V2.2.0, true", disp, avail)
}
if _, avail := displayUpdateFieldsFor("V2.1.0", "V2.1.0", "1.0.0"); avail {
t.Fatal("parseable display, equal versions must not report an update (raw base must not leak in)")
}
// Unparseable latest tag still yields "" so the UI uses raw fields.
if disp, avail := displayUpdateFieldsFor("3.7.0-agent.1", "VV2.1.0", "2.1.0"); disp != "" || avail {
t.Fatalf("legacy tag = %q, %v; want empty, false", disp, avail)
}
}
func TestServiceUnitFromCgroup(t *testing.T) { func TestServiceUnitFromCgroup(t *testing.T) {
content := "0::/system.slice/x-ui.service\n" content := "0::/system.slice/x-ui.service\n"
if got := serviceUnitFromCgroup(content); got != "x-ui" { if got := serviceUnitFromCgroup(content); got != "x-ui" {
+27
View File
@@ -34,6 +34,9 @@ type Meta struct {
// corrections in GB (V2.0.1), either sign: added to the counted // corrections in GB (V2.0.1), either sign: added to the counted
// uplink (tx) / downlink (rx) figures wherever the ledger is // uplink (tx) / downlink (rx) figures wherever the ledger is
// read — panel statistics, the public page and traffic alerts. // read — panel statistics, the public page and traffic alerts.
// The stored values are the STANDING accumulated correction;
// the edit form submits one-shot deltas that MergeCalDelta folds
// into them (V2.2.1 ⑥b).
TrafficCalUpGB float64 TrafficCalUpGB float64
TrafficCalDownGB float64 TrafficCalDownGB float64
// StatIface pins the one interface whose counters the agent // StatIface pins the one interface whose counters the agent
@@ -138,6 +141,30 @@ func (s *Store) UpsertMeta(m Meta) error {
return err return err
} }
// MergeCalDelta folds a probe-meta form submission into the stored
// row under the one-shot calibration semantics (V2.2.1 ⑥b): the
// form's calibration fields are a correction TO APPLY NOW, not the
// new standing value. A non-zero submitted value adds onto the stored
// standing correction; a zero field (an absent field is the same
// zero — the wire type cannot distinguish them, and both mean
// "nothing to apply") keeps the stored value untouched. Every other
// field takes the submitted value, as UpsertMeta's full replace
// does. Paired with the form clearing its calibration inputs after a
// successful save, a later unrelated save can never re-apply an old
// correction.
func MergeCalDelta(existing, submitted Meta) Meta {
out := submitted
out.TrafficCalUpGB = existing.TrafficCalUpGB
if submitted.TrafficCalUpGB != 0 {
out.TrafficCalUpGB += submitted.TrafficCalUpGB
}
out.TrafficCalDownGB = existing.TrafficCalDownGB
if submitted.TrafficCalDownGB != 0 {
out.TrafficCalDownGB += submitted.TrafficCalDownGB
}
return out
}
func boolToInt(b bool) int64 { func boolToInt(b bool) int64 {
if b { if b {
return 1 return 1
@@ -0,0 +1,143 @@
package probe
import (
"path/filepath"
"testing"
"time"
)
// V2.2.1 ⑥: GB entries convert on the binary convention (1024^3)
// every formatter uses, stored decimal-era limits are rescaled once,
// and calibration submissions are one-shot deltas folded into the
// standing correction — never re-applied by a later unrelated save.
func TestCalBytesBinaryConvention(t *testing.T) {
if got := calBytes(3000); got != 3000*(1<<30) {
t.Fatalf("calBytes(3000) = %d, want %d (3000 GiB)", got, 3000*(1<<30))
}
if got := calBytes(0); got != 0 {
t.Fatalf("calBytes(0) = %d, want 0", got)
}
if got := calBytes(-1.5); got != -1610612736 {
t.Fatalf("calBytes(-1.5) = %d, want -1610612736", got)
}
}
func TestGBConventionMigrationRescalesLimitsOnce(t *testing.T) {
path := filepath.Join(t.TempDir(), "probe.db")
st, err := Open(path)
if err != nil {
t.Fatalf("open: %v", err)
}
// Seed a legacy row as a pre-V2.2.1 database holds it — 3000 GB
// typed at the decimal convention = 3.0e12 stored bytes — and
// strike the migration mark so the next open sees an unmigrated
// database.
if _, err := st.db.Exec(`INSERT INTO probe_meta (agent_id, traffic_limit) VALUES (9, 3000000000000)`); err != nil {
t.Fatalf("seed legacy limit: %v", err)
}
if _, err := st.db.Exec(`DELETE FROM probe_setting WHERE key = ?`, gbConventionKey); err != nil {
t.Fatalf("strike mark: %v", err)
}
if err := st.Close(); err != nil {
t.Fatalf("close: %v", err)
}
st2, err := Open(path)
if err != nil {
t.Fatalf("reopen (migration run): %v", err)
}
m, found, err := st2.Meta(9)
if err != nil || !found {
t.Fatalf("meta after migration: found=%v err=%v", found, err)
}
// 3.0e12 x 1024^3/1e9 = 3000 x 2^30 exactly (~3.221e12).
if want := int64(3000) * (1 << 30); m.TrafficLimit != want {
t.Fatalf("migrated limit = %d, want %d", m.TrafficLimit, want)
}
if err := st2.Close(); err != nil {
t.Fatalf("close 2: %v", err)
}
st3, err := Open(path)
if err != nil {
t.Fatalf("reopen 2: %v", err)
}
defer st3.Close()
m, found, err = st3.Meta(9)
if err != nil || !found {
t.Fatalf("meta after second open: found=%v err=%v", found, err)
}
if want := int64(3000) * (1 << 30); m.TrafficLimit != want {
t.Fatalf("limit after second open = %d, want %d (no double rescale)", m.TrafficLimit, want)
}
}
func TestOneShotCalibrationSaveFlow(t *testing.T) {
st := newTestStore(t)
now := time.Now()
book(t, st, 5, "boot-a", 1000, 1000, now, nil, 0) // baseline: books 0
book(t, st, 5, "boot-a", 3000, 2000, now, nil, 0) // books rx 2000, tx 1000
// First save carries a +1 GB uplink correction delta.
submitted := DefaultMeta(5)
submitted.TrafficCalUpGB = 1
merged := MergeCalDelta(DefaultMeta(5), submitted)
if merged.TrafficCalUpGB != 1 {
t.Fatalf("standing after first fold = %v, want 1", merged.TrafficCalUpGB)
}
if err := st.UpsertMeta(merged); err != nil {
t.Fatalf("upsert 1: %v", err)
}
after1, ok, err := st.TrafficFor(5)
if err != nil || !ok {
t.Fatalf("traffic after save 1: ok=%v err=%v", ok, err)
}
if want := int64(1000) + (1 << 30); after1.TotalTx != want {
t.Fatalf("TotalTx after save 1 = %d, want %d", after1.TotalTx, want)
}
if after1.TotalRx != 2000 {
t.Fatalf("TotalRx after save 1 = %d, want 2000 (uncalibrated)", after1.TotalRx)
}
// Second save changes another field; the (cleared) form submits
// zero calibration — the calibrated figure must not move again.
stored, found, err := st.Meta(5)
if err != nil || !found {
t.Fatalf("stored meta: found=%v err=%v", found, err)
}
sub2 := DefaultMeta(5)
sub2.GroupName = "g2"
merged2 := MergeCalDelta(stored, sub2)
if merged2.TrafficCalUpGB != 1 {
t.Fatalf("standing after zero-delta save = %v, want 1 (unchanged)", merged2.TrafficCalUpGB)
}
if err := st.UpsertMeta(merged2); err != nil {
t.Fatalf("upsert 2: %v", err)
}
after2, ok, err := st.TrafficFor(5)
if err != nil || !ok {
t.Fatalf("traffic after save 2: ok=%v err=%v", ok, err)
}
if after2.TotalTx != after1.TotalTx {
t.Fatalf("TotalTx moved on an unrelated save: %d -> %d (double-applied calibration)", after1.TotalTx, after2.TotalTx)
}
// A further non-zero delta accumulates exactly once more.
sub3 := DefaultMeta(5)
sub3.TrafficCalUpGB = 0.5
merged3 := MergeCalDelta(merged2, sub3)
if merged3.TrafficCalUpGB != 1.5 {
t.Fatalf("standing after second delta = %v, want 1.5", merged3.TrafficCalUpGB)
}
if err := st.UpsertMeta(merged3); err != nil {
t.Fatalf("upsert 3: %v", err)
}
after3, _, err := st.TrafficFor(5)
if err != nil {
t.Fatalf("traffic after save 3: %v", err)
}
if want := int64(1000) + calBytes(1.5); after3.TotalTx != want {
t.Fatalf("TotalTx after save 3 = %d, want %d", after3.TotalTx, want)
}
}
+4 -2
View File
@@ -91,8 +91,10 @@ func TestTrafficCalibrationAppliedAndFloored(t *testing.T) {
t.Fatalf("AllTraffic: %v", err) t.Fatalf("AllTraffic: %v", err)
} }
tr := all[5] tr := all[5]
if tr.TotalRx != 3_500_000_000 || tr.MonthRx != 3_500_000_000 || tr.DayRx != 3_500_000_000 { // V2.2.1 ⑥: calibration converts on the binary convention —
t.Errorf("rx figures after +0.5 GB calibration = %d/%d/%d, want 3500000000 each", tr.TotalRx, tr.MonthRx, tr.DayRx) // +0.5 GB = 0.5 x 2^30 = 536870912 B (was 5e8 at the old 1e9).
if tr.TotalRx != 3_536_870_912 || tr.MonthRx != 3_536_870_912 || tr.DayRx != 3_536_870_912 {
t.Errorf("rx figures after +0.5 GB calibration = %d/%d/%d, want 3536870912 each", tr.TotalRx, tr.MonthRx, tr.DayRx)
} }
if tr.TotalTx != 0 || tr.MonthTx != 0 || tr.DayTx != 0 { if tr.TotalTx != 0 || tr.MonthTx != 0 || tr.DayTx != 0 {
t.Errorf("tx figures after -2 GB calibration = %d/%d/%d, want 0 (floored)", tr.TotalTx, tr.MonthTx, tr.DayTx) t.Errorf("tx figures after -2 GB calibration = %d/%d/%d, want 0 (floored)", tr.TotalTx, tr.MonthTx, tr.DayTx)
+43
View File
@@ -213,9 +213,52 @@ func Open(path string) (*Store, error) {
db.Close() db.Close()
return nil, fmt.Errorf("probe schema migration: %w", err) return nil, fmt.Errorf("probe schema migration: %w", err)
} }
if err := migrateGBConvention(db); err != nil {
db.Close()
return nil, fmt.Errorf("probe gb migration: %w", err)
}
return &Store{db: db, nodes: make(map[int64]*nodeState)}, nil return &Store{db: db, nodes: make(map[int64]*nodeState)}, nil
} }
// gbConventionKey is the probe_setting mark recording that the
// V2.2.1 ⑥ traffic-limit rescale has been applied to this database.
const gbConventionKey = "gb_1024_migrated_v1"
// migrateGBConvention rescales stored probe_meta traffic limits from
// the old decimal entry convention to the unified binary one, once.
// Limits are typed in GB and were stored as GB × 1e9 bytes, while
// every byte formatter (panel, public page) divides by 1024^3 — a
// 3000 GB limit displayed as 2.73 TB instead of 2.93 TB. The GB the
// operator typed is the intent, so stored bytes are scaled by
// 1024^3/1e9 = 1.073741824 and now read back correctly under the
// binary conversion. Only traffic_limit is byte-denominated:
// calibration values are stored in GB (their byte conversion,
// calBytes, carries the fix) and price/sort/reset-day are unitless —
// neither is touched here. The probe_setting mark makes the rescale
// run exactly once per database.
func migrateGBConvention(db *sql.DB) error {
var v string
err := db.QueryRow(`SELECT value FROM probe_setting WHERE key = ?`, gbConventionKey).Scan(&v)
if err == nil {
return nil // already applied
}
if err != sql.ErrNoRows {
return err
}
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(`UPDATE probe_meta SET traffic_limit = CAST(ROUND(traffic_limit * 1.073741824) AS INTEGER) WHERE traffic_limit > 0`); err != nil {
return err
}
if _, err := tx.Exec(`INSERT OR REPLACE INTO probe_setting (key, value) VALUES (?, '1')`, gbConventionKey); err != nil {
return err
}
return tx.Commit()
}
// metaColumnMigrations are the probe_meta columns added after the // metaColumnMigrations are the probe_meta columns added after the
// table's first release; CREATE TABLE IF NOT EXISTS never retrofits // table's first release; CREATE TABLE IF NOT EXISTS never retrofits
// them onto an existing database, so they are added here, once, by // them onto an existing database, so they are added here, once, by
+6 -2
View File
@@ -332,9 +332,13 @@ func (s *Store) TrafficFor(agentID int64) (Traffic, bool, error) {
// AllTraffic returns every agent's ledger in one query, gated as in // AllTraffic returns every agent's ledger in one query, gated as in
// TrafficFor — the bulk read port for B3's node list. // TrafficFor — the bulk read port for B3's node list.
// calBytes converts a GB calibration value to bytes on the panel's // calBytes converts a GB calibration value to bytes on the panel's
// 1 GB = 1e9 B convention (the same one the traffic limit uses). // binary convention, 1 GB = 1024^3 B — the convention every byte
// formatter in the panel and on the public page uses, and the one
// the traffic limit's GB entry converts with (V2.2.1 ⑥; the decimal
// 1e9 stood here before, which made a 3000 GB limit read as 2.73 TB
// instead of 2.93 TB wherever bytes were formatted).
func calBytes(gb float64) int64 { func calBytes(gb float64) int64 {
return int64(math.Round(gb * 1e9)) return int64(math.Round(gb * (1 << 30)))
} }
// applyCalibration shifts a ledger row by the operator's per-node // applyCalibration shifts a ledger row by the operator's per-node
+14 -1
View File
@@ -268,6 +268,19 @@ func expiresInDays(expires *string, now time.Time) any {
return days return days
} }
// probeDisplayName is the name a node carries in the public views
// (V2.2.1 ⑦): the operator's public remark when one is set — it is
// the label written for public consumption — else the node name. The
// contract field stays "name"; themes and the public API keep their
// shape, only the value's source changes. The panel's internal node
// list is a different surface and keeps the plain node name.
func probeDisplayName(name, publicRemark string) string {
if r := strings.TrimSpace(publicRemark); r != "" {
return r
}
return name
}
// buildProbeNodeView renders one node for one audience. The public // buildProbeNodeView renders one node for one audience. The public
// shape is built by whitelist (hub node_view): address, hostname and // shape is built by whitelist (hub node_view): address, hostname and
// the private remark exist only in the admin (full) rendering. // the private remark exist only in the admin (full) rendering.
@@ -298,7 +311,7 @@ func buildProbeNodeView(src probeNodeSource, full bool) map[string]any {
view := map[string]any{ view := map[string]any{
"id": a.Id, "id": a.Id,
"name": src.name, "name": probeDisplayName(src.name, m.PublicRemark),
"country": m.CountryPin, "country": m.CountryPin,
"group": m.GroupName, "group": m.GroupName,
"public_remark": m.PublicRemark, "public_remark": m.PublicRemark,
@@ -0,0 +1,40 @@
package service
import (
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/web/service/probe"
)
// V2.2.1 ⑦: the public views' node name is the operator's public
// remark when one is set, and the node name otherwise — the panel's
// own list (a different surface) is untouched.
func TestProbeDisplayNamePrefersPublicRemark(t *testing.T) {
if got := probeDisplayName("node-name", "东京 01"); got != "东京 01" {
t.Fatalf("with remark = %q, want the remark", got)
}
if got := probeDisplayName("node-name", ""); got != "node-name" {
t.Fatalf("empty remark = %q, want node name", got)
}
if got := probeDisplayName("node-name", " "); got != "node-name" {
t.Fatalf("whitespace-only remark = %q, want node name", got)
}
}
func TestBuildProbeNodeViewNameSource(t *testing.T) {
withRemark := probeNodeSource{
name: "internal-name",
meta: probe.Meta{PublicRemark: "公开名", Public: true},
}
if got := buildProbeNodeView(withRemark, false)["name"]; got != "公开名" {
t.Fatalf("anonymous view name = %v, want the public remark", got)
}
if got := buildProbeNodeView(withRemark, true)["name"]; got != "公开名" {
t.Fatalf("admin view name = %v, want the public remark", got)
}
without := probeNodeSource{name: "internal-name", meta: probe.Meta{Public: true}}
if got := buildProbeNodeView(without, false)["name"]; got != "internal-name" {
t.Fatalf("view name without remark = %v, want the node name", got)
}
}