Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a3c7c0dc9 | ||
|
|
388e26c292 | ||
|
|
1d1258a10d | ||
|
|
81299db6ff | ||
|
|
89b65f1606 | ||
|
|
d16b888146 | ||
|
|
c94136c8a1 | ||
|
|
e7f4bfed24 | ||
|
|
6b9832cdcc | ||
|
|
e78cb8ccc5 | ||
|
|
ef662c9fdb | ||
|
|
a81eff8c0d | ||
|
|
1d7eb16784 | ||
|
|
a13179ed9b | ||
|
|
93c1620c15 | ||
|
|
ba57fb3c00 | ||
|
|
151b07b73f | ||
|
|
cec5905483 | ||
|
|
1df78dd874 | ||
|
|
92dfdc542c |
@@ -0,0 +1,113 @@
|
||||
// Command xui-agent is the standalone Linux build of the 3x-ui node
|
||||
// agent (agent/panel split, phase 1). Until the split, nodes ran the
|
||||
// panel binary under the "agent" subcommand, shipping the whole web
|
||||
// panel and the embedded frontend to every node. This entry links only
|
||||
// the agent's own dependency closure (internal/agent plus config,
|
||||
// logger, selfupdate, xray-api types and two util leaves), so the node
|
||||
// artifact is a fraction of the panel binary while remaining a drop-in
|
||||
// replacement for it:
|
||||
//
|
||||
// - invocation is unchanged: service units run
|
||||
// "xui-agent agent [-config /etc/x-ui/agent.json]" (the bare
|
||||
// "-config" form is also accepted for manual runs);
|
||||
// - agent.json, the traffic baseline and the other state files keep
|
||||
// their names, locations and formats;
|
||||
// - the wire protocol with the master (register / heartbeat /
|
||||
// config / traffic / probe-report / update intent) is byte-for-
|
||||
// byte the internal/agent implementation the dual-mode binary ran.
|
||||
//
|
||||
// The agent lifecycle wiring below mirrors main.go's agent branch
|
||||
// exactly — the same internal/agent package does the work either way,
|
||||
// and cmd/xui-agent-windows is the Windows sibling of this entry.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/agent"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
|
||||
"github.com/op/go-logging"
|
||||
)
|
||||
|
||||
func main() {
|
||||
args := os.Args[1:]
|
||||
|
||||
// Version self-report: the agent's own display version (A-form),
|
||||
// from the same build-time displayVersion injection the panel
|
||||
// uses. Kept ahead of the subcommand so it works however the
|
||||
// binary was invoked.
|
||||
if len(args) > 0 {
|
||||
switch args[0] {
|
||||
case "-v", "--version", "version":
|
||||
fmt.Println(config.GetAgentDisplayVersion())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Service units (systemd / OpenRC / procd / nohup fallback in
|
||||
// install-agent.sh) invoke the binary with the historical "agent"
|
||||
// subcommand; consume it so those units keep working unchanged.
|
||||
if len(args) > 0 && args[0] == "agent" {
|
||||
args = args[1:]
|
||||
} else if len(args) > 0 && args[0][0] != '-' {
|
||||
fmt.Fprintf(os.Stderr, "xui-agent: unknown subcommand %q (this binary only runs the node agent)\n", args[0])
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
runAgent(args)
|
||||
}
|
||||
|
||||
// runAgent is main.go's agent branch, verbatim in behaviour: same
|
||||
// flags, same defaults, same shutdown wiring.
|
||||
func runAgent(args []string) {
|
||||
agentCmd := flag.NewFlagSet("agent", flag.ExitOnError)
|
||||
var configPath string
|
||||
agentCmd.StringVar(&configPath, "config", "/etc/x-ui/agent.json", "Path to agent config file")
|
||||
agentCmd.Parse(args)
|
||||
|
||||
// Agent mode always uses the panel's directory layout for the
|
||||
// xray binary, geo files and config.json. Scoped to agent mode
|
||||
// only so the panel's own relative "bin" default is untouched.
|
||||
if os.Getenv("XUI_BIN_FOLDER") == "" {
|
||||
_ = os.Setenv("XUI_BIN_FOLDER", agent.DefaultBinFolder)
|
||||
}
|
||||
|
||||
cfg, err := agent.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to load agent config from %s: %v", configPath, err)
|
||||
}
|
||||
|
||||
// The acknowledged traffic baseline persists next to the agent
|
||||
// config (its state home, e.g. /etc/x-ui), unless the config
|
||||
// names a path.
|
||||
if cfg.BaselinePath == "" {
|
||||
cfg.BaselinePath = filepath.Join(filepath.Dir(configPath), "agent-traffic-baseline.json")
|
||||
}
|
||||
|
||||
// Initialize logger
|
||||
logger.InitLogger(logging.INFO)
|
||||
log.Printf("Starting 3x-ui agent (node: %s)", cfg.NodeName)
|
||||
|
||||
ag := agent.New(cfg)
|
||||
|
||||
// Handle shutdown signals
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
go func() {
|
||||
<-sigCh
|
||||
log.Println("Shutting down agent...")
|
||||
ag.Stop()
|
||||
}()
|
||||
|
||||
if err := ag.Run(); err != nil {
|
||||
log.Fatalf("Agent error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -84,9 +84,6 @@ export default function NodeFormModal({
|
||||
const [testResult, setTestResult] = useState<ProbeResult | null>(null);
|
||||
const [agentToken, setAgentToken] = useState<string | null>(null);
|
||||
const [agentNodeName, setAgentNodeName] = useState('');
|
||||
// Whether the just-created agent node was flagged probe-only, so the
|
||||
// install command below can carry the matching --probe-only flag.
|
||||
const [agentProbeOnly, setAgentProbeOnly] = useState(false);
|
||||
const [agentMasterUrl, setAgentMasterUrl] = useState('');
|
||||
// Edit mode (item 19): the stored install material for an existing agent
|
||||
// node, fetched from the master; null until loaded / for non-agent nodes.
|
||||
@@ -104,7 +101,6 @@ export default function NodeFormModal({
|
||||
const inboundSyncMode = useWatch({ control: methods.control, name: 'inboundSyncMode' }) ?? 'all';
|
||||
const nodeType = useWatch({ control: methods.control, name: 'nodeType' }) ?? 'traditional';
|
||||
const isAgent = nodeType === 'agent';
|
||||
const probeOnly = useWatch({ control: methods.control, name: 'probeOnly' }) ?? false;
|
||||
const { data: outboundGroups } = useOutboundTagGroups({ excludeBlackhole: true });
|
||||
|
||||
// Outbounds and balancers share one picker (like the panel-outbound selector);
|
||||
@@ -186,10 +182,13 @@ export default function NodeFormModal({
|
||||
const cmd = shownInstall.hasToken
|
||||
? `${base} --token ${shownInstall.token} --name ${shownInstall.name}`
|
||||
: `${base} --name ${shownInstall.name}`;
|
||||
// The toggle is the source of truth for the re-displayed command:
|
||||
// flipping it rewrites the command the operator would re-run.
|
||||
return probeOnly ? `${cmd} --probe-only` : cmd;
|
||||
}, [shownInstall, editMasterUrl, probeOnly]);
|
||||
// The command never carries --probe-only: an agent.json preset would
|
||||
// pin probe-only on the machine (dual-source OR gate) and the panel
|
||||
// toggle alone could no longer turn it off. Probe-only is governed
|
||||
// solely by the master-side flag; the script flag stays for manual
|
||||
// installs that deliberately want the preset.
|
||||
return cmd;
|
||||
}, [shownInstall, editMasterUrl]);
|
||||
|
||||
const title = useMemo(
|
||||
() => (mode === 'edit' ? t('pages.nodes.editNode') : t('pages.nodes.addNode')),
|
||||
@@ -307,7 +306,6 @@ export default function NodeFormModal({
|
||||
if (agentMode && mode === 'add' && obj?.agentToken) {
|
||||
setAgentToken(obj.agentToken);
|
||||
setAgentNodeName(result.data.name.trim());
|
||||
setAgentProbeOnly(result.data.probeOnly ?? false);
|
||||
// Default the master URL to the address this panel is being
|
||||
// viewed through — origin AND base path. Dropping the base
|
||||
// path made the agent's registration 404. User can edit it.
|
||||
@@ -330,7 +328,7 @@ export default function NodeFormModal({
|
||||
// one would produce a command that can never register, so withhold it
|
||||
// (and say why) instead of letting the user copy a dead command.
|
||||
const installCmd = agentMasterUrl
|
||||
? `bash <(curl -fL https://git.881218.xyz/rogue/3x-ui-agent/raw/branch/main/install-agent.sh) --master ${agentMasterUrl} --token ${agentToken ?? ''} --name ${agentNodeName}${agentProbeOnly ? ' --probe-only' : ''}`
|
||||
? `bash <(curl -fL https://git.881218.xyz/rogue/3x-ui-agent/raw/branch/main/install-agent.sh) --master ${agentMasterUrl} --token ${agentToken ?? ''} --name ${agentNodeName}`
|
||||
: '';
|
||||
|
||||
return (
|
||||
|
||||
@@ -307,13 +307,13 @@ export default function NodeList({
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Space>
|
||||
<Tooltip title={t('pages.nodes.probe')}>
|
||||
<Tooltip title={t('pages.nodes.testConnection')}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
style={{ fontSize: 16 }}
|
||||
icon={<ThunderboltOutlined />}
|
||||
aria-label={t('pages.nodes.probe')}
|
||||
aria-label={t('pages.nodes.testConnection')}
|
||||
onClick={() => onProbe(record)}
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -716,7 +716,7 @@ export default function NodeList({
|
||||
key: 'probe',
|
||||
label: (
|
||||
<>
|
||||
<ThunderboltOutlined /> {t('pages.nodes.probe')}
|
||||
<ThunderboltOutlined /> {t('pages.nodes.testConnection')}
|
||||
</>
|
||||
),
|
||||
onClick: () => onProbe(record),
|
||||
|
||||
@@ -24,12 +24,20 @@ interface ProbeMetaWire {
|
||||
ipv4Pin: string;
|
||||
ipv6Pin: string;
|
||||
// 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;
|
||||
trafficCalDownGb: number;
|
||||
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 NAMED_CYCLES = [
|
||||
@@ -59,6 +67,9 @@ export default function ProbeMetaSection({ nodeId }: { nodeId: number }) {
|
||||
// in NodeFormModal); visibility derives during render.
|
||||
const [loaded, setLoaded] = useState<{ nodeId: number; meta: ProbeMetaWire } | null>(null);
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -68,7 +79,7 @@ export default function ProbeMetaSection({ nodeId }: { nodeId: number }) {
|
||||
}).then((msg) => {
|
||||
if (!cancelled && msg?.success && msg.obj) {
|
||||
setLoaded({ nodeId, meta: msg.obj });
|
||||
setTrafficGb(msg.obj.trafficLimit / 1e9);
|
||||
setTrafficGb(msg.obj.trafficLimit / BYTES_PER_GB);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
@@ -116,14 +127,23 @@ export default function ProbeMetaSection({ nodeId }: { nodeId: number }) {
|
||||
try {
|
||||
const payload: ProbeMetaWire = {
|
||||
...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, {
|
||||
silent: true,
|
||||
...JSON_HEADERS,
|
||||
});
|
||||
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'));
|
||||
} else {
|
||||
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}>
|
||||
<Field label={t('pages.nodes.probe.calUp')}>
|
||||
<InputNumber
|
||||
value={meta.trafficCalUpGb}
|
||||
value={calUpGb}
|
||||
step={0.1}
|
||||
onChange={(v) => set('trafficCalUpGb', v ?? 0)}
|
||||
placeholder="0"
|
||||
onChange={(v) => setCalUpGb(v)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Field>
|
||||
@@ -259,9 +280,10 @@ export default function ProbeMetaSection({ nodeId }: { nodeId: number }) {
|
||||
<Col xs={24} md={8}>
|
||||
<Field label={t('pages.nodes.probe.calDown')}>
|
||||
<InputNumber
|
||||
value={meta.trafficCalDownGb}
|
||||
value={calDownGb}
|
||||
step={0.1}
|
||||
onChange={(v) => set('trafficCalDownGb', v ?? 0)}
|
||||
placeholder="0"
|
||||
onChange={(v) => setCalDownGb(v)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
@@ -68,6 +68,7 @@ require (
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/oschwald/maxminddb-golang v1.13.1 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.4.3 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
|
||||
@@ -108,6 +108,8 @@ github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd
|
||||
github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
|
||||
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 h1:lDH9UUVJtmYCjyT0CI4q8xvlXPxeZ0gYCVvWbmPlp88=
|
||||
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk=
|
||||
github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE=
|
||||
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
|
||||
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
|
||||
github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ $ErrorActionPreference = "Stop"
|
||||
|
||||
# Release download base, same shape as install-agent.sh's
|
||||
# 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.3.0"
|
||||
if (-not $AgentUrl) {
|
||||
$AgentUrl = "$ReleaseBase/x-ui-agent-windows-amd64.exe"
|
||||
}
|
||||
|
||||
+68
-6
@@ -11,7 +11,7 @@
|
||||
#
|
||||
# 测试用环境变量(一般无需设置):
|
||||
# XUI_ROOT 安装路径前缀(假根目录验证用),默认空 = 真实路径
|
||||
# XUI_INIT_SYSTEM 强制 init 系统: systemd | openrc | none,默认 auto 探测
|
||||
# XUI_INIT_SYSTEM 强制 init 系统: systemd | openrc | procd | none,默认 auto 探测
|
||||
# XUI_URL agent 二进制下载地址覆盖
|
||||
#
|
||||
# 二进制命名(P12 改名):agent 独立装为 /usr/local/x-ui/xui-agent,不再与主控共用
|
||||
@@ -81,8 +81,13 @@ esac
|
||||
# sing-box 核心在 NexusGate 仓的专用发行里(见下方 SINGBOX_BASE)。
|
||||
# 注意:本脚本是 V2 版——只装 sing-box 运行时;主控未升 V2 前不要用它
|
||||
# 重装节点(节点升级走面板一键更新,不需要本脚本)。
|
||||
RELEASE_BASE="https://git.881218.xyz/rogue/3x-ui-agent/releases/download/V2.2.0"
|
||||
XUI_URL="${XUI_URL:-${RELEASE_BASE}/x-ui-linux-${BIN_ARCH}}"
|
||||
#
|
||||
# agent/主控拆分(期一):下载的是 agent 专用产物 x-ui-agent-linux-<arch>
|
||||
# (独立入口 cmd/xui-agent,不再是主控双模式二进制)。该资产自拆分版
|
||||
# 发行起才有——RELEASE_BASE 的升版由发版步完成,指向拆分版即可用;
|
||||
# 若需装拆分前的旧版节点,显式 XUI_URL 指回 x-ui-linux-<arch> 即可。
|
||||
RELEASE_BASE="https://git.881218.xyz/rogue/3x-ui-agent/releases/download/V2.3.0"
|
||||
XUI_URL="${XUI_URL:-${RELEASE_BASE}/x-ui-agent-linux-${BIN_ARCH}}"
|
||||
|
||||
ROOT="${XUI_ROOT:-}"
|
||||
XUI_HOME="${ROOT}/usr/local/x-ui"
|
||||
@@ -220,7 +225,7 @@ stop_legacy_agent() {
|
||||
|
||||
detect_init_system() {
|
||||
case "${XUI_INIT_SYSTEM:-auto}" in
|
||||
systemd|openrc|none) echo "${XUI_INIT_SYSTEM}"; return;;
|
||||
systemd|openrc|procd|none) echo "${XUI_INIT_SYSTEM}"; return;;
|
||||
esac
|
||||
if [ -d /run/systemd/system ] && command -v systemctl >/dev/null 2>&1; then
|
||||
echo systemd; return
|
||||
@@ -228,6 +233,9 @@ detect_init_system() {
|
||||
if command -v rc-update >/dev/null 2>&1 && command -v rc-service >/dev/null 2>&1; then
|
||||
echo openrc; return
|
||||
fi
|
||||
if [ -f /etc/rc.common ] && command -v procd >/dev/null 2>&1; then
|
||||
echo procd; return
|
||||
fi
|
||||
echo none
|
||||
}
|
||||
|
||||
@@ -286,10 +294,14 @@ command_args="agent"
|
||||
# supervise-daemon 守护(第 23 条):进程崩溃后按 respawn 参数自动拉起,
|
||||
# 对齐 systemd 支的 Restart=on-failure + RestartSec=5;旧版 command_background
|
||||
# 只负责后台化、崩溃不重启。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"
|
||||
respawn_delay=5
|
||||
respawn_max=0
|
||||
respawn_period=0
|
||||
respawn_period=1800
|
||||
pidfile="/run/x-ui-agent.pid"
|
||||
output_log="/var/log/x-ui-agent.log"
|
||||
error_log="/var/log/x-ui-agent.log"
|
||||
@@ -322,9 +334,58 @@ EOF
|
||||
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(无开机自启,明确警告)
|
||||
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 不会自动恢复,需手动重跑本脚本!"
|
||||
stop_legacy_agent
|
||||
pkill -f "${AGENT_BIN} agent" 2>/dev/null || true; sleep 1
|
||||
@@ -348,5 +409,6 @@ info "启动 agent..."
|
||||
case "$(detect_init_system)" in
|
||||
systemd) start_via_systemd;;
|
||||
openrc) start_via_openrc;;
|
||||
procd) start_via_procd;;
|
||||
*) start_via_nohup;;
|
||||
esac
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,14 @@ package agent
|
||||
// degradation discipline Load stays zero and TCP/UDP stay 0
|
||||
// until a wrapper exists;
|
||||
// - 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 (
|
||||
"net"
|
||||
@@ -50,15 +58,35 @@ import (
|
||||
"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 (
|
||||
kernel32DLL = windows.NewLazySystemDLL("kernel32.dll")
|
||||
procGetSystemTimes = kernel32DLL.NewProc("GetSystemTimes")
|
||||
procGlobalMemoryStatus = kernel32DLL.NewProc("GlobalMemoryStatusEx")
|
||||
iphlpapiDLL = windows.NewLazySystemDLL("iphlpapi.dll")
|
||||
procGetIfTable2Ex = iphlpapiDLL.NewProc("GetIfTable2Ex")
|
||||
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 {
|
||||
memTotal, _, swapTotal, _ := winMemory()
|
||||
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 {
|
||||
probeColl.mu.Lock()
|
||||
defer probeColl.mu.Unlock()
|
||||
memTotal, memUsed, swapTotal, swapUsed := winMemory()
|
||||
diskTotal, diskUsed := winDiskUsage()
|
||||
rows := winIfaceRows()
|
||||
// Every source runs guarded: a panic in one (a Win32 surface the
|
||||
// gates did not foresee) zeroes that source alone instead of
|
||||
// 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))
|
||||
counted := make([]netCounters, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
@@ -107,23 +149,25 @@ func collectMetrics() *ProbeMetrics {
|
||||
}
|
||||
rxRate, txRate := probeColl.netRate(counted, time.Now())
|
||||
return &ProbeMetrics{
|
||||
BootID: bootEpoch(winMachineGUID(), names),
|
||||
Uptime: uint64(windows.DurationSinceBoot().Seconds()),
|
||||
CPU: probeColl.cpuPercent(winCPUSample()),
|
||||
BootID: bootEpoch(winMachineGUID(), names),
|
||||
Uptime: guardedCall("uptime", func() uint64 {
|
||||
return uint64(windows.DurationSinceBoot().Seconds())
|
||||
}),
|
||||
CPU: probeColl.cpuPercent(guardedCall("cpu", winCPUSample)),
|
||||
Load: [3]float32{},
|
||||
MemTotal: memTotal,
|
||||
MemUsed: memUsed,
|
||||
SwapTotal: swapTotal,
|
||||
SwapUsed: swapUsed,
|
||||
DiskTotal: diskTotal,
|
||||
DiskUsed: diskUsed,
|
||||
MemTotal: mem.total,
|
||||
MemUsed: mem.used,
|
||||
SwapTotal: mem.swapTotal,
|
||||
SwapUsed: mem.swapUsed,
|
||||
DiskTotal: disk.total,
|
||||
DiskUsed: disk.used,
|
||||
NetRxTotal: rxTotal,
|
||||
NetTxTotal: txTotal,
|
||||
NetRx: rxRate,
|
||||
NetTx: txRate,
|
||||
TCP: 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:
|
||||
// total = kernel + user (kernel already includes idle, like jiffies).
|
||||
func winCPUSample() *cpuSample {
|
||||
if !gateSystemTimes.enabled("GetSystemTimes", procProbe(procGetSystemTimes)) {
|
||||
return nil
|
||||
}
|
||||
var idle, kernel, user windows.Filetime
|
||||
r, _, _ := procGetSystemTimes.Call(
|
||||
uintptr(unsafe.Pointer(&idle)),
|
||||
@@ -248,6 +295,9 @@ func usedFromTotalAvail(total, avail uint64) uint64 {
|
||||
}
|
||||
|
||||
func winMemory() (total, used, swapTotal, swapUsed uint64) {
|
||||
if !gateMemoryStatus.enabled("GlobalMemoryStatusEx", procProbe(procGlobalMemoryStatus)) {
|
||||
return 0, 0, 0, 0
|
||||
}
|
||||
var ms memoryStatusEx
|
||||
ms.Length = uint32(unsafe.Sizeof(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
|
||||
// counted set from it.
|
||||
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
|
||||
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
|
||||
}
|
||||
defer procFreeMibIfTable2.Call(uintptr(unsafe.Pointer(table)))
|
||||
|
||||
@@ -151,7 +151,12 @@ func (a *Agent) runUpdate(intent updateIntent) {
|
||||
fail(fmt.Errorf("locate own binary: %w", err))
|
||||
return
|
||||
}
|
||||
asset, err := selfupdate.AssetName(runtime.GOARCH)
|
||||
// Agent/panel split (phase 1): the agent downloads the standalone
|
||||
// agent asset, not the panel binary. An agent build predating the
|
||||
// split still asks for the panel asset (AssetName) — the two are
|
||||
// interchangeable at the same path, so each generation converges
|
||||
// on the form its own build requests.
|
||||
asset, err := selfupdate.AgentAssetName(runtime.GOARCH)
|
||||
if err != nil {
|
||||
fail(err)
|
||||
return
|
||||
|
||||
@@ -22,7 +22,9 @@ import (
|
||||
|
||||
func fakeReleaseServer(t *testing.T, payload []byte, goodSum bool, hits *atomic.Int32) *httptest.Server {
|
||||
t.Helper()
|
||||
asset, err := selfupdate.AssetName(runtime.GOARCH)
|
||||
// The agent requests the standalone agent asset (agent/panel
|
||||
// split); the fake release only serves that name.
|
||||
asset, err := selfupdate.AgentAssetName(runtime.GOARCH)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -20,8 +20,10 @@ import (
|
||||
)
|
||||
|
||||
// DefaultBaseURL is the release download base of the fork's own Gitea.
|
||||
// A release tag directory hangs directly below it:
|
||||
// <base><tag>/x-ui-linux-<arch> plus a "<asset>.sha256" sidecar.
|
||||
// A release tag directory hangs directly below it, holding the panel
|
||||
// assets (<base><tag>/x-ui-linux-<arch>) and — since the agent/panel
|
||||
// split — the standalone agent assets (x-ui-agent-linux-<arch>),
|
||||
// each with a "<asset>.sha256" sidecar.
|
||||
const DefaultBaseURL = "https://git.881218.xyz/rogue/3x-ui-agent/releases/download/"
|
||||
|
||||
// LatestReleaseAPI is the Gitea API endpoint for the newest release of the
|
||||
@@ -41,7 +43,9 @@ func BaseURL() string {
|
||||
return DefaultBaseURL
|
||||
}
|
||||
|
||||
// AssetName maps a GOARCH to the release asset name published for it.
|
||||
// AssetName maps a GOARCH to the release asset name published for it:
|
||||
// the panel (dual-mode) binary. The panel's own native self-update
|
||||
// downloads this asset; pre-split agents consumed it too.
|
||||
func AssetName(goarch string) (string, error) {
|
||||
switch goarch {
|
||||
case "amd64":
|
||||
@@ -53,6 +57,24 @@ func AssetName(goarch string) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// AgentAssetName maps a GOARCH to the standalone agent asset published
|
||||
// since the agent/panel split (phase 1). A split agent's one-click
|
||||
// update downloads this asset — never the panel binary — so a node
|
||||
// keeps its slim form across updates. Releases predating the split do
|
||||
// not carry these assets; an intent pointing at such a tag fails the
|
||||
// download and the agent keeps running its old binary (the designed
|
||||
// failure mode of runUpdate).
|
||||
func AgentAssetName(goarch string) (string, error) {
|
||||
switch goarch {
|
||||
case "amd64":
|
||||
return "x-ui-agent-linux-amd64", nil
|
||||
case "arm64":
|
||||
return "x-ui-agent-linux-arm64", nil
|
||||
default:
|
||||
return "", fmt.Errorf("no agent release asset for architecture %q", goarch)
|
||||
}
|
||||
}
|
||||
|
||||
// LatestReleaseURLEnv overrides the release-lookup endpoint as a whole
|
||||
// (mirrors, tests), analogous to BaseURLEnv.
|
||||
const LatestReleaseURLEnv = "XUI_RELEASE_LATEST_URL"
|
||||
|
||||
@@ -47,6 +47,18 @@ func TestAssetName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentAssetName(t *testing.T) {
|
||||
if name, err := AgentAssetName("amd64"); err != nil || name != "x-ui-agent-linux-amd64" {
|
||||
t.Fatalf("amd64: %q, %v", name, err)
|
||||
}
|
||||
if name, err := AgentAssetName("arm64"); err != nil || name != "x-ui-agent-linux-arm64" {
|
||||
t.Fatalf("arm64: %q, %v", name, err)
|
||||
}
|
||||
if _, err := AgentAssetName("riscv64"); err == nil {
|
||||
t.Fatal("unknown arch must error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwapExecutableKeepsBackup(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
exe := filepath.Join(dir, "xui-agent")
|
||||
|
||||
@@ -52,7 +52,10 @@ type probeMetaForm struct {
|
||||
IPv4Pin string `json:"ipv4Pin"`
|
||||
IPv6Pin string `json:"ipv6Pin"`
|
||||
// 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"`
|
||||
TrafficCalDownGB float64 `json:"trafficCalDownGb"`
|
||||
StatIface string `json:"statIface"`
|
||||
@@ -75,8 +78,11 @@ func metaToForm(m probe.Meta) probeMetaForm {
|
||||
CountryPin: m.CountryPin,
|
||||
IPv4Pin: m.IPv4Pin,
|
||||
IPv6Pin: m.IPv6Pin,
|
||||
TrafficCalUpGB: m.TrafficCalUpGB,
|
||||
TrafficCalDownGB: m.TrafficCalDownGB,
|
||||
// One-shot delta inputs (V2.2.1 ⑥b): the form's calibration
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
@@ -150,7 +156,16 @@ func (pc *ProbeMetaController) putMeta(c *gin.Context) {
|
||||
jsonMsg(c, msg, errRequest)
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -323,7 +323,9 @@ func TestProbePublicContract(t *testing.T) {
|
||||
assertKeys(t, keySet(t, nodeBody), anonNodeKeys)
|
||||
var node map[string]any
|
||||
_ = 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["total_tx"] != float64(222) || node["price"] != float64(10) {
|
||||
t.Fatalf("anon node values wrong: %v", node)
|
||||
@@ -599,3 +601,61 @@ func TestProbeWebSocket(t *testing.T) {
|
||||
func itoa(v int64) string {
|
||||
return strconv.FormatInt(v, 10)
|
||||
}
|
||||
|
||||
// Q7: the resolved country reaches both frames. The seed's held
|
||||
// address is a documentation range (unlisted in any country DB), so
|
||||
// repoint it at a real public address whose country the embedded
|
||||
// DB-IP snapshot knows — 8.8.8.8 is US there.
|
||||
func TestProbePublicCountryFlag(t *testing.T) {
|
||||
rig := newProbeTestRig(t)
|
||||
pubID, _ := rig.seedAgent(t, "FlagNode", true)
|
||||
|
||||
db := database.GetDB()
|
||||
var agent model.AgentNode
|
||||
if err := db.First(&agent, pubID).Error; err != nil {
|
||||
t.Fatalf("load seeded agent: %v", err)
|
||||
}
|
||||
agent.IPv4 = "8.8.8.8"
|
||||
if err := db.Save(&agent).Error; err != nil {
|
||||
t.Fatalf("repoint agent address: %v", err)
|
||||
}
|
||||
|
||||
rig.setSetting(t, "public_page", "on")
|
||||
service.InvalidateProbeSnapshot()
|
||||
|
||||
find := func(rec *httptest.ResponseRecorder) map[string]any {
|
||||
t.Helper()
|
||||
var frame struct {
|
||||
Nodes []map[string]any `json:"nodes"`
|
||||
Admin bool `json:"admin"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &frame); err != nil {
|
||||
t.Fatalf("frame decode: %v", err)
|
||||
}
|
||||
for _, n := range frame.Nodes {
|
||||
if n["id"] == float64(pubID) {
|
||||
return n
|
||||
}
|
||||
}
|
||||
t.Fatalf("seeded node missing from frame: %s", rec.Body.String())
|
||||
return nil
|
||||
}
|
||||
|
||||
rec := rig.do(http.MethodGet, "/api/nodes", nil, false)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("nodes anon: %d", rec.Code)
|
||||
}
|
||||
if got := find(rec)["country"]; got != "US" {
|
||||
t.Fatalf("anonymous country = %v, want US", got)
|
||||
}
|
||||
|
||||
rec = rig.do(http.MethodGet, "/api/nodes", nil, true)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("nodes admin: %d", rec.Code)
|
||||
}
|
||||
admin := find(rec)
|
||||
if admin["country"] != "US" || admin["country_auto"] != "US" || admin["country_pin"] != "" {
|
||||
t.Fatalf("admin country triple = (%v, %v, %v), want (US, US, empty)",
|
||||
admin["country"], admin["country_auto"], admin["country_pin"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
)
|
||||
|
||||
// GeoIPCountryJob is the weekly refresh of the local country
|
||||
// database behind the public pages' node flags (Q7: a local MMDB on
|
||||
// the master, never an external per-node lookup). The service does
|
||||
// the conditional download, validation and hot swap; with no update
|
||||
// URL configured the pass is a no-op, so scheduling it is harmless
|
||||
// on installs that never opted in.
|
||||
type GeoIPCountryJob struct{}
|
||||
|
||||
// NewGeoIPCountryJob creates a geoip country job instance.
|
||||
func NewGeoIPCountryJob() *GeoIPCountryJob {
|
||||
return new(GeoIPCountryJob)
|
||||
}
|
||||
|
||||
// Run executes one update pass.
|
||||
func (j *GeoIPCountryJob) Run() {
|
||||
service.UpdateGeoIPCountry()
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/oschwald/maxminddb-golang"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
)
|
||||
|
||||
// Node country flags (item 7 Q7, resolved 2026-10-24): the public
|
||||
// views carry an ISO country code per node, which the themes render
|
||||
// as a flag. The upstream hub resolves it by calling ipinfo.io per
|
||||
// node; this build never sends a node address to a third party — the
|
||||
// lookup is a local MMDB read on the master, from the node's own
|
||||
// reported addresses. The agent is untouched: it already reports its
|
||||
// interface addresses in its facts, and the master already observes
|
||||
// the connection's source addresses (SeenIPv4/SeenIPv6).
|
||||
//
|
||||
// The data is the DB-IP Lite country database (CC BY 4.0 by DB-IP,
|
||||
// https://db-ip.com; attribution and snapshot provenance live in
|
||||
// geoipdata/NOTICE), in the MMDB conversion published by the
|
||||
// sapics/ip-location-db project. Three layers, in winning order:
|
||||
//
|
||||
// 1. a local file in the data folder (geoip-country.mmdb beside the
|
||||
// panel database) — written by the weekly updater below, or by
|
||||
// the operator by hand;
|
||||
// 2. the factory snapshot embedded in the binary (geoipdata/), so a
|
||||
// fresh install shows flags with no network at all;
|
||||
// 3. no database: lookups answer "", the views render exactly the
|
||||
// pre-flag shape.
|
||||
//
|
||||
// Reads are mmap-backed (maxminddb.Open) or run against the embedded
|
||||
// bytes in place; either way a lookup is a pure in-memory tree walk
|
||||
// on the snapshot path, and answers are cached per address for the
|
||||
// life of the loaded database.
|
||||
|
||||
//go:embed geoipdata/country.mmdb
|
||||
var geoipCountryEmbedded []byte
|
||||
|
||||
// geoipCountryFileName is the local copy's name inside the data
|
||||
// folder (config.GetDBFolderPath, where x-ui.db and probe.db live).
|
||||
const geoipCountryFileName = "geoip-country.mmdb"
|
||||
|
||||
// geoipCountryDefaultURL is the weekly updater's fallback source,
|
||||
// used when the operator has not set the geoip_country_url setting.
|
||||
//
|
||||
// RELEASE-TIME (main session): point this at the self-hosted Gitea
|
||||
// mirror asset for geoip-country.mmdb. Empty disables auto-update;
|
||||
// the embedded snapshot and any hand-placed local file still apply.
|
||||
// Set at V2.3.0 release time to the standing `geoip-country` release
|
||||
// asset on the self-hosted Gitea (asset swapped in place, URL stable).
|
||||
const geoipCountryDefaultURL = "https://git.881218.xyz/rogue/3x-ui-agent/releases/download/geoip-country/geoip-country.mmdb"
|
||||
|
||||
// Settings keys (main settings KV, alongside the other probe_* keys).
|
||||
const (
|
||||
geoipCountryURLSetting = "geoip_country_url"
|
||||
geoipCountryETagSetting = "geoip_country_etag"
|
||||
)
|
||||
|
||||
// geoipCountryMaxBytes caps one update download; the country
|
||||
// database is ~8 MB today, the cap only stops a runaway response.
|
||||
const geoipCountryMaxBytes = 64 << 20
|
||||
|
||||
// geoipCountryProbeIP / geoipCountryProbeIPv6 are the well-known
|
||||
// addresses a candidate database must answer to be trusted (Google
|
||||
// DNS v4, AliDNS v6 — both resolve in the DB-IP data).
|
||||
const (
|
||||
geoipCountryProbeIP = "8.8.8.8"
|
||||
geoipCountryProbeIPv6 = "2400:3200::1"
|
||||
)
|
||||
|
||||
// GeoIPCountryLocalPath is where the local country database lives.
|
||||
func GeoIPCountryLocalPath() string {
|
||||
return filepath.Join(config.GetDBFolderPath(), geoipCountryFileName)
|
||||
}
|
||||
|
||||
// geoipCountryUpdateURL resolves the updater's source: the operator
|
||||
// setting wins, else the compiled-in default (possibly empty).
|
||||
func geoipCountryUpdateURL() string {
|
||||
if v, ok := probeSetting(geoipCountryURLSetting); ok {
|
||||
if v = strings.TrimSpace(v); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return geoipCountryDefaultURL
|
||||
}
|
||||
|
||||
// ---- the loaded database ----
|
||||
|
||||
// countryRecord is the DB-IP Lite record shape: a flat country_code.
|
||||
type countryRecord struct {
|
||||
CountryCode string `maxminddb:"country_code"`
|
||||
}
|
||||
|
||||
// geoipDB is one loaded database plus its per-address answer cache
|
||||
// ("" answers are cached too — an unlisted address stays unlisted
|
||||
// until the database is swapped).
|
||||
type geoipDB struct {
|
||||
reader *maxminddb.Reader
|
||||
origin string // "local" | "embedded"
|
||||
|
||||
mu sync.Mutex
|
||||
cache map[string]string
|
||||
}
|
||||
|
||||
func (db *geoipDB) lookup(ipStr string) string {
|
||||
db.mu.Lock()
|
||||
if cc, ok := db.cache[ipStr]; ok {
|
||||
db.mu.Unlock()
|
||||
return cc
|
||||
}
|
||||
db.mu.Unlock()
|
||||
|
||||
cc := ""
|
||||
var rec countryRecord
|
||||
if err := db.reader.Lookup(net.ParseIP(ipStr), &rec); err == nil {
|
||||
cc = rec.CountryCode
|
||||
}
|
||||
|
||||
db.mu.Lock()
|
||||
db.cache[ipStr] = cc
|
||||
db.mu.Unlock()
|
||||
return cc
|
||||
}
|
||||
|
||||
var (
|
||||
geoipMu sync.RWMutex
|
||||
geoipCur *geoipDB
|
||||
geoipOnce sync.Once
|
||||
)
|
||||
|
||||
// currentGeoIP returns the live database, loading it on first use.
|
||||
func currentGeoIP() *geoipDB {
|
||||
geoipOnce.Do(func() {
|
||||
if db := loadGeoIP(); db != nil {
|
||||
swapGeoIP(db)
|
||||
}
|
||||
})
|
||||
geoipMu.RLock()
|
||||
defer geoipMu.RUnlock()
|
||||
return geoipCur
|
||||
}
|
||||
|
||||
// swapGeoIP makes db the live database. The replaced reader is
|
||||
// deliberately NOT closed: it is mmap-backed (or backed by the
|
||||
// binary's own image), a lookup racing the swap could still be
|
||||
// walking it, and an mmap cannot be safely unmapped under a reader.
|
||||
// The mapping is one file per week at most and is reclaimed when the
|
||||
// process exits.
|
||||
func swapGeoIP(db *geoipDB) {
|
||||
geoipMu.Lock()
|
||||
geoipCur = db
|
||||
geoipMu.Unlock()
|
||||
}
|
||||
|
||||
// loadGeoIP applies the layer order: a valid local file wins, else
|
||||
// the embedded snapshot. A local file that will not open or will not
|
||||
// answer falls through to the snapshot with one warning — a corrupt
|
||||
// download must never take the flags (or the panel) down.
|
||||
func loadGeoIP() *geoipDB {
|
||||
if path := GeoIPCountryLocalPath(); path != "" {
|
||||
if r, err := openGeoIPFile(path); err == nil {
|
||||
return &geoipDB{reader: r, origin: "local", cache: map[string]string{}}
|
||||
} else {
|
||||
logger.Warningf("geoip: local country database %s unusable (%v); using the embedded snapshot", path, err)
|
||||
}
|
||||
}
|
||||
if r, err := maxminddb.FromBytes(geoipCountryEmbedded); err == nil {
|
||||
if err := probeGeoIPReader(r); err == nil {
|
||||
return &geoipDB{reader: r, origin: "embedded", cache: map[string]string{}}
|
||||
} else {
|
||||
logger.Warningf("geoip: embedded country snapshot fails validation: %v", err)
|
||||
}
|
||||
} else {
|
||||
logger.Warningf("geoip: embedded country snapshot will not open: %v", err)
|
||||
}
|
||||
return &geoipDB{origin: "none", cache: map[string]string{}}
|
||||
}
|
||||
|
||||
// openGeoIPFile opens a database file and checks it answers.
|
||||
func openGeoIPFile(path string) (*maxminddb.Reader, error) {
|
||||
r, err := maxminddb.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := probeGeoIPReader(r); err != nil {
|
||||
_ = r.Close()
|
||||
return nil, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// probeGeoIPReader is the functional validation: the well-known
|
||||
// probe address must resolve to a country code, and a v6 probe must
|
||||
// at least answer without an error. (maxminddb's Verify is NOT used:
|
||||
// it also audits metadata niceties — the DB-IP/sapics files carry an
|
||||
// empty description map and fail it despite being fully readable,
|
||||
// verified against the factory snapshot on 2026-10-24. A candidate
|
||||
// that opens and answers both probes is a working country DB; a
|
||||
// truncated or foreign file fails to open or errors a lookup.)
|
||||
func probeGeoIPReader(r *maxminddb.Reader) error {
|
||||
var rec countryRecord
|
||||
if err := r.Lookup(net.ParseIP(geoipCountryProbeIP), &rec); err != nil {
|
||||
return err
|
||||
}
|
||||
if rec.CountryCode == "" {
|
||||
return fmt.Errorf("probe address %s resolves to no country", geoipCountryProbeIP)
|
||||
}
|
||||
return r.Lookup(net.ParseIP(geoipCountryProbeIPv6), &rec)
|
||||
}
|
||||
|
||||
// verifyGeoIPFile validates a fresh download in place before it may
|
||||
// replace the live database.
|
||||
func verifyGeoIPFile(path string) error {
|
||||
r, err := maxminddb.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = r.Close() }()
|
||||
return probeGeoIPReader(r)
|
||||
}
|
||||
|
||||
// ---- lookups used by the public views ----
|
||||
|
||||
// countryCodeForIP resolves one public IP literal to its ISO country
|
||||
// code, "" for anything non-public, unknown, or unanswerable. The
|
||||
// public gate runs first so private/held addresses never reach the
|
||||
// database (they have no country, exactly as on the hub).
|
||||
func countryCodeForIP(ipStr string) string {
|
||||
ipStr = strings.TrimSpace(ipStr)
|
||||
if !IsPublicIPLiteral(ipStr) {
|
||||
return ""
|
||||
}
|
||||
db := currentGeoIP()
|
||||
if db == nil || db.reader == nil {
|
||||
return ""
|
||||
}
|
||||
return db.lookup(ipStr)
|
||||
}
|
||||
|
||||
// probeCountryAuto is a node's auto-detected country: the first
|
||||
// public address in the hub's order — the held interface v4, then
|
||||
// held v6, then the master-observed exit v4, then exit v6 — resolved
|
||||
// locally. "" when the node shows no public address or the database
|
||||
// has no answer for it.
|
||||
func probeCountryAuto(a *model.AgentNode) string {
|
||||
for _, s := range []string{a.IPv4, a.IPv6, a.SeenIPv4, a.SeenIPv6} {
|
||||
if IsPublicIPLiteral(s) {
|
||||
return countryCodeForIP(s)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// probeCountry resolves the pair the views publish (hub api.rs): the
|
||||
// effective country is the operator's pin when one is set, else the
|
||||
// auto-detected code; country_auto always carries the detected code.
|
||||
func probeCountry(pin string, a *model.AgentNode) (country, auto string) {
|
||||
auto = probeCountryAuto(a)
|
||||
if pin != "" {
|
||||
return pin, auto
|
||||
}
|
||||
return auto, auto
|
||||
}
|
||||
|
||||
// ---- weekly auto-update ----
|
||||
|
||||
type geoipFetchResult int
|
||||
|
||||
const (
|
||||
geoipFetchUnchanged geoipFetchResult = iota
|
||||
geoipFetchUpdated
|
||||
)
|
||||
|
||||
// downloadGeoIPCountry performs one conditional download of url into
|
||||
// destPath. The candidate lands in a temp file in the destination
|
||||
// directory, is fully validated there, and only then renames over
|
||||
// destPath (atomic on the same filesystem); any failure leaves
|
||||
// destPath and the live database untouched.
|
||||
func downloadGeoIPCountry(client *http.Client, url, destPath, etag string, ifModifiedSince time.Time) (geoipFetchResult, string, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return geoipFetchUnchanged, etag, err
|
||||
}
|
||||
if etag != "" {
|
||||
req.Header.Set("If-None-Match", etag)
|
||||
}
|
||||
if !ifModifiedSince.IsZero() {
|
||||
req.Header.Set("If-Modified-Since", ifModifiedSince.UTC().Format(http.TimeFormat))
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return geoipFetchUnchanged, etag, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode == http.StatusNotModified {
|
||||
return geoipFetchUnchanged, etag, nil
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return geoipFetchUnchanged, etag, fmt.Errorf("update server answered %s", resp.Status)
|
||||
}
|
||||
|
||||
dir := filepath.Dir(destPath)
|
||||
tmp, err := os.CreateTemp(dir, ".geoip-country-*.tmp")
|
||||
if err != nil {
|
||||
return geoipFetchUnchanged, etag, err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer func() { _ = os.Remove(tmpName) }()
|
||||
n, err := io.Copy(tmp, io.LimitReader(resp.Body, geoipCountryMaxBytes))
|
||||
if err := tmp.Close(); err != nil {
|
||||
return geoipFetchUnchanged, etag, err
|
||||
}
|
||||
if err != nil {
|
||||
return geoipFetchUnchanged, etag, err
|
||||
}
|
||||
if n == 0 {
|
||||
return geoipFetchUnchanged, etag, fmt.Errorf("update download was empty")
|
||||
}
|
||||
if err := verifyGeoIPFile(tmpName); err != nil {
|
||||
return geoipFetchUnchanged, etag, fmt.Errorf("downloaded database rejected: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpName, destPath); err != nil {
|
||||
return geoipFetchUnchanged, etag, err
|
||||
}
|
||||
return geoipFetchUpdated, resp.Header.Get("ETag"), nil
|
||||
}
|
||||
|
||||
// UpdateGeoIPCountry runs one update pass: a conditional download
|
||||
// from the configured URL, a validated atomic replace of the local
|
||||
// copy, and a hot swap of the live reader. No configured URL or any
|
||||
// failure keeps the current database; a failure logs a single
|
||||
// warning (the hub discipline: a broken mirror must not spam).
|
||||
func UpdateGeoIPCountry() {
|
||||
url := geoipCountryUpdateURL()
|
||||
if url == "" {
|
||||
return
|
||||
}
|
||||
dest := GeoIPCountryLocalPath()
|
||||
var lastMod time.Time
|
||||
if fi, err := os.Stat(dest); err == nil {
|
||||
lastMod = fi.ModTime()
|
||||
}
|
||||
etag, _ := probeSetting(geoipCountryETagSetting)
|
||||
client := &http.Client{Timeout: 2 * time.Minute}
|
||||
res, newETag, err := downloadGeoIPCountry(client, url, dest, etag, lastMod)
|
||||
if err != nil {
|
||||
logger.Warningf("geoip: country database update from %s failed: %v (keeping the current database)", url, err)
|
||||
return
|
||||
}
|
||||
if res != geoipFetchUpdated {
|
||||
return
|
||||
}
|
||||
if newETag != "" && newETag != etag {
|
||||
_ = setProbeSetting(geoipCountryETagSetting, newETag)
|
||||
}
|
||||
r, err := openGeoIPFile(dest)
|
||||
if err != nil {
|
||||
logger.Warningf("geoip: updated country database %s will not reopen: %v", dest, err)
|
||||
return
|
||||
}
|
||||
swapGeoIP(&geoipDB{reader: r, origin: "local", cache: map[string]string{}})
|
||||
logger.Infof("geoip: country database updated from %s", url)
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/oschwald/maxminddb-golang"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service/probe"
|
||||
)
|
||||
|
||||
// Q7 node country flags: local MMDB resolution on the master. The
|
||||
// known-address expectations below are calibrated against the
|
||||
// embedded factory snapshot (DB-IP Lite country database, retrieved
|
||||
// 2026-10-24; provenance in geoipdata/NOTICE): these are long-lived
|
||||
// anycast/ISP addresses whose country attribution is stable across
|
||||
// editions — 8.8.8.8 (Google DNS) US, 223.5.5.5 (AliDNS) CN,
|
||||
// 1.1.1.1 (Cloudflare/APNIC) AU, 2400:3200::1 (AliDNS v6) CN.
|
||||
|
||||
// useEmbeddedGeoIP swaps the embedded snapshot in as the live
|
||||
// database for one test, restoring whatever was live before.
|
||||
func useEmbeddedGeoIP(t *testing.T) {
|
||||
t.Helper()
|
||||
prev := currentGeoIP() // fire the lazy load so the Once is spent
|
||||
r, err := maxminddb.FromBytes(geoipCountryEmbedded)
|
||||
if err != nil {
|
||||
t.Fatalf("embedded snapshot will not open: %v", err)
|
||||
}
|
||||
swapGeoIP(&geoipDB{reader: r, origin: "embedded", cache: map[string]string{}})
|
||||
t.Cleanup(func() { swapGeoIP(prev) })
|
||||
}
|
||||
|
||||
func TestCountryCodeForIPKnownAddresses(t *testing.T) {
|
||||
useEmbeddedGeoIP(t)
|
||||
for ip, want := range map[string]string{
|
||||
"8.8.8.8": "US",
|
||||
"223.5.5.5": "CN",
|
||||
"1.1.1.1": "AU",
|
||||
"2400:3200::1": "CN",
|
||||
" 8.8.8.8 ": "US", // surrounding space is trimmed
|
||||
} {
|
||||
if got := countryCodeForIP(ip); got != want {
|
||||
t.Errorf("countryCodeForIP(%q) = %q, want %q", ip, got, want)
|
||||
}
|
||||
}
|
||||
// Non-public and unlisted addresses have no country.
|
||||
for _, ip := range []string{
|
||||
"192.168.0.1", // RFC1918
|
||||
"10.0.0.5", // RFC1918
|
||||
"127.0.0.1", // loopback
|
||||
"100.64.0.1", // CGNAT shared range
|
||||
"203.0.113.9", // public documentation range: unlisted in the DB
|
||||
"", // empty
|
||||
"example.com", // not a literal
|
||||
"999.1.1.1", // not an address
|
||||
} {
|
||||
if got := countryCodeForIP(ip); got != "" {
|
||||
t.Errorf("countryCodeForIP(%q) = %q, want empty", ip, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeCountryAutoSourceOrder(t *testing.T) {
|
||||
useEmbeddedGeoIP(t)
|
||||
// Held v4 leads over held v6 (hub order).
|
||||
a := &model.AgentNode{IPv4: "8.8.8.8", IPv6: "2400:3200::1", SeenIPv4: "223.5.5.5"}
|
||||
if got := probeCountryAuto(a); got != "US" {
|
||||
t.Errorf("held v4 first: got %q, want US", got)
|
||||
}
|
||||
// A private held v4 is skipped; the held public v6 beats the
|
||||
// observed exit v4.
|
||||
a = &model.AgentNode{IPv4: "10.0.0.5", IPv6: "2400:3200::1", SeenIPv4: "8.8.8.8"}
|
||||
if got := probeCountryAuto(a); got != "CN" {
|
||||
t.Errorf("held v6 over exit v4: got %q, want CN", got)
|
||||
}
|
||||
// Exit addresses are the fallback when the node holds none.
|
||||
a = &model.AgentNode{SeenIPv4: "223.5.5.5"}
|
||||
if got := probeCountryAuto(a); got != "CN" {
|
||||
t.Errorf("exit v4 fallback: got %q, want CN", got)
|
||||
}
|
||||
a = &model.AgentNode{SeenIPv6: "2400:3200::1"}
|
||||
if got := probeCountryAuto(a); got != "CN" {
|
||||
t.Errorf("exit v6 fallback: got %q, want CN", got)
|
||||
}
|
||||
// No public address anywhere: no country.
|
||||
a = &model.AgentNode{IPv4: "192.168.1.2", SeenIPv4: "10.1.1.1"}
|
||||
if got := probeCountryAuto(a); got != "" {
|
||||
t.Errorf("private only: got %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeCountryPinWins(t *testing.T) {
|
||||
useEmbeddedGeoIP(t)
|
||||
a := &model.AgentNode{IPv4: "223.5.5.5"}
|
||||
country, auto := probeCountry("JP", a)
|
||||
if country != "JP" || auto != "CN" {
|
||||
t.Errorf("pin set: got (%q, %q), want (JP, CN)", country, auto)
|
||||
}
|
||||
country, auto = probeCountry("", a)
|
||||
if country != "CN" || auto != "CN" {
|
||||
t.Errorf("no pin: got (%q, %q), want (CN, CN)", country, auto)
|
||||
}
|
||||
country, auto = probeCountry("JP", &model.AgentNode{})
|
||||
if country != "JP" || auto != "" {
|
||||
t.Errorf("pin without address: got (%q, %q), want (JP, empty)", country, auto)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildProbeNodeViewCountry(t *testing.T) {
|
||||
useEmbeddedGeoIP(t)
|
||||
src := probeNodeSource{
|
||||
name: "flagged",
|
||||
agent: model.AgentNode{IPv4: "8.8.8.8"},
|
||||
meta: probe.Meta{Public: true},
|
||||
}
|
||||
anon := buildProbeNodeView(src, false)
|
||||
if anon["country"] != "US" {
|
||||
t.Errorf("anonymous country = %v, want US", anon["country"])
|
||||
}
|
||||
admin := buildProbeNodeView(src, true)
|
||||
if admin["country"] != "US" || admin["country_auto"] != "US" || admin["country_pin"] != "" {
|
||||
t.Errorf("admin pair = (%v, %v, pin %v), want (US, US, empty)",
|
||||
admin["country"], admin["country_auto"], admin["country_pin"])
|
||||
}
|
||||
src.meta.CountryPin = "DE"
|
||||
if got := buildProbeNodeView(src, false)["country"]; got != "DE" {
|
||||
t.Errorf("pinned country = %v, want DE", got)
|
||||
}
|
||||
if got := buildProbeNodeView(src, true)["country_auto"]; got != "US" {
|
||||
t.Errorf("country_auto under a pin = %v, want US", got)
|
||||
}
|
||||
// A node with no public address keeps the pre-flag empty country.
|
||||
src = probeNodeSource{name: "dark", agent: model.AgentNode{IPv4: "192.168.0.9"}, meta: probe.Meta{Public: true}}
|
||||
if got := buildProbeNodeView(src, false)["country"]; got != "" {
|
||||
t.Errorf("private node country = %v, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenGeoIPFileValidation(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
bad := filepath.Join(dir, "bad.mmdb")
|
||||
if err := os.WriteFile(bad, []byte("this is not an mmdb"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := openGeoIPFile(bad); err == nil {
|
||||
t.Fatal("garbage file opened as a country database")
|
||||
}
|
||||
good := filepath.Join(dir, "good.mmdb")
|
||||
if err := os.WriteFile(good, geoipCountryEmbedded, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := openGeoIPFile(good)
|
||||
if err != nil {
|
||||
t.Fatalf("embedded bytes as a file rejected: %v", err)
|
||||
}
|
||||
defer func() { _ = r.Close() }()
|
||||
db := &geoipDB{reader: r, cache: map[string]string{}}
|
||||
if got := db.lookup("223.5.5.5"); got != "CN" {
|
||||
t.Errorf("file-backed lookup = %q, want CN", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the weekly updater's download/validate/replace core ----
|
||||
|
||||
func TestDownloadGeoIPCountryUpdates(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("ETag", `"db-v1"`)
|
||||
_, _ = w.Write(geoipCountryEmbedded)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
dest := filepath.Join(t.TempDir(), "geoip-country.mmdb")
|
||||
res, etag, err := downloadGeoIPCountry(srv.Client(), srv.URL, dest, "", time.Time{})
|
||||
if err != nil {
|
||||
t.Fatalf("download: %v", err)
|
||||
}
|
||||
if res != geoipFetchUpdated || etag != `"db-v1"` {
|
||||
t.Fatalf("got (res %v, etag %q), want (updated, \"db-v1\")", res, etag)
|
||||
}
|
||||
fi, err := os.Stat(dest)
|
||||
if err != nil || fi.Size() != int64(len(geoipCountryEmbedded)) {
|
||||
t.Fatalf("dest stat = %v (err %v), want %d bytes", fi, err, len(geoipCountryEmbedded))
|
||||
}
|
||||
// The replaced file is a working database.
|
||||
r, err := openGeoIPFile(dest)
|
||||
if err != nil {
|
||||
t.Fatalf("replaced file will not open: %v", err)
|
||||
}
|
||||
_ = r.Close()
|
||||
}
|
||||
|
||||
func TestDownloadGeoIPCountryNotModified(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("If-None-Match") == `"db-v1"` || r.Header.Get("If-Modified-Since") != "" {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(geoipCountryEmbedded)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
dir := t.TempDir()
|
||||
dest := filepath.Join(dir, "geoip-country.mmdb")
|
||||
if err := os.WriteFile(dest, []byte("ORIGINAL"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// ETag path.
|
||||
res, _, err := downloadGeoIPCountry(srv.Client(), srv.URL, dest, `"db-v1"`, time.Time{})
|
||||
if err != nil || res != geoipFetchUnchanged {
|
||||
t.Fatalf("etag path: got (res %v, err %v), want unchanged", res, err)
|
||||
}
|
||||
// If-Modified-Since path.
|
||||
res, _, err = downloadGeoIPCountry(srv.Client(), srv.URL, dest, "", time.Now())
|
||||
if err != nil || res != geoipFetchUnchanged {
|
||||
t.Fatalf("modified-since path: got (res %v, err %v), want unchanged", res, err)
|
||||
}
|
||||
body, _ := os.ReadFile(dest)
|
||||
if string(body) != "ORIGINAL" {
|
||||
t.Fatalf("dest rewritten on a 304: %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadGeoIPCountryRejectsBad(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dest := filepath.Join(dir, "geoip-country.mmdb")
|
||||
if err := os.WriteFile(dest, []byte("ORIGINAL"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertKept := func(name string) {
|
||||
t.Helper()
|
||||
body, _ := os.ReadFile(dest)
|
||||
if string(body) != "ORIGINAL" {
|
||||
t.Fatalf("%s: dest replaced despite the failure: %q", name, body)
|
||||
}
|
||||
}
|
||||
|
||||
garbage := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("definitely not an mmdb, just bytes"))
|
||||
}))
|
||||
defer garbage.Close()
|
||||
if _, _, err := downloadGeoIPCountry(garbage.Client(), garbage.URL, dest, "", time.Time{}); err == nil {
|
||||
t.Fatal("garbage download accepted")
|
||||
}
|
||||
assertKept("garbage")
|
||||
|
||||
broken := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer broken.Close()
|
||||
if _, _, err := downloadGeoIPCountry(broken.Client(), broken.URL, dest, "", time.Time{}); err == nil {
|
||||
t.Fatal("500 download accepted")
|
||||
}
|
||||
assertKept("500")
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
Country database attribution
|
||||
============================
|
||||
|
||||
The embedded country.mmdb is the DB-IP Lite country database
|
||||
(dbip-country, IPv4+IPv6 combined), converted to MMDB format by the
|
||||
sapics/ip-location-db project:
|
||||
|
||||
https://github.com/sapics/ip-location-db
|
||||
|
||||
The underlying data is made available by DB-IP (https://db-ip.com)
|
||||
under the Creative Commons Attribution 4.0 International License
|
||||
(CC BY 4.0):
|
||||
|
||||
https://creativecommons.org/licenses/by/4.0/
|
||||
|
||||
Attribution: IP geolocation data by DB-IP, https://db-ip.com
|
||||
|
||||
Snapshot provenance (factory copy embedded in the binary):
|
||||
source file : dbip-country.mmdb (sapics/ip-location-db "latest" release)
|
||||
retrieved : 2026-10-24
|
||||
size : 8300093 bytes
|
||||
sha256 : fcecf33b565fcda088bbcb7a959b5b35c8e5195ed64fcd67daa3cd9384f6272b
|
||||
|
||||
Locally updated copies (downloaded by the master's weekly updater
|
||||
into the data folder as geoip-country.mmdb) supersede this snapshot
|
||||
at runtime; they are the same database from the same source.
|
||||
Binary file not shown.
@@ -1234,7 +1234,74 @@ func (s *NodeService) AggregateNodeMetric(id int, metric string, bucketSeconds i
|
||||
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) {
|
||||
// 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 := ""
|
||||
if n.OutboundTag != "" {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -164,13 +164,38 @@ func (s *PanelService) GetUpdateInfo() (*PanelUpdateInfo, error) {
|
||||
// display version. An unparseable tag (not a V-series release) yields ""
|
||||
// so the UI falls back to the raw fields.
|
||||
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)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
disp := fmt.Sprintf("V%d.%d.%d", parts[0], parts[1], parts[2])
|
||||
cmp, ok := config.CompareDisplayVersion(disp, config.GetMasterDisplayVersion())
|
||||
return disp, ok && cmp > 0
|
||||
cmp, ok := config.CompareDisplayVersion(disp, currentDisplay)
|
||||
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
|
||||
|
||||
@@ -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) {
|
||||
content := "0::/system.slice/x-ui.service\n"
|
||||
if got := serviceUnitFromCgroup(content); got != "x-ui" {
|
||||
|
||||
@@ -34,6 +34,9 @@ type Meta struct {
|
||||
// corrections in GB (V2.0.1), either sign: added to the counted
|
||||
// uplink (tx) / downlink (rx) figures wherever the ledger is
|
||||
// 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
|
||||
TrafficCalDownGB float64
|
||||
// StatIface pins the one interface whose counters the agent
|
||||
@@ -138,6 +141,30 @@ func (s *Store) UpsertMeta(m Meta) error {
|
||||
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 {
|
||||
if b {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -91,8 +91,10 @@ func TestTrafficCalibrationAppliedAndFloored(t *testing.T) {
|
||||
t.Fatalf("AllTraffic: %v", err)
|
||||
}
|
||||
tr := all[5]
|
||||
if tr.TotalRx != 3_500_000_000 || tr.MonthRx != 3_500_000_000 || tr.DayRx != 3_500_000_000 {
|
||||
t.Errorf("rx figures after +0.5 GB calibration = %d/%d/%d, want 3500000000 each", tr.TotalRx, tr.MonthRx, tr.DayRx)
|
||||
// V2.2.1 ⑥: calibration converts on the binary convention —
|
||||
// +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 {
|
||||
t.Errorf("tx figures after -2 GB calibration = %d/%d/%d, want 0 (floored)", tr.TotalTx, tr.MonthTx, tr.DayTx)
|
||||
|
||||
@@ -213,9 +213,52 @@ func Open(path string) (*Store, error) {
|
||||
db.Close()
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
// table's first release; CREATE TABLE IF NOT EXISTS never retrofits
|
||||
// them onto an existing database, so they are added here, once, by
|
||||
|
||||
@@ -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
|
||||
// TrafficFor — the bulk read port for B3's node list.
|
||||
// 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 {
|
||||
return int64(math.Round(gb * 1e9))
|
||||
return int64(math.Round(gb * (1 << 30)))
|
||||
}
|
||||
|
||||
// applyCalibration shifts a ledger row by the operator's per-node
|
||||
|
||||
@@ -268,6 +268,19 @@ func expiresInDays(expires *string, now time.Time) any {
|
||||
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
|
||||
// shape is built by whitelist (hub node_view): address, hostname and
|
||||
// the private remark exist only in the admin (full) rendering.
|
||||
@@ -275,6 +288,11 @@ func buildProbeNodeView(src probeNodeSource, full bool) map[string]any {
|
||||
a := src.agent
|
||||
m := src.meta
|
||||
t := src.traffic
|
||||
// The flag pair (hub api.rs): the effective country is the pin
|
||||
// when the operator set one, else the locally resolved code for
|
||||
// the node's public address (geoip_country.go, Q7 — no external
|
||||
// lookup ever leaves the master).
|
||||
country, countryAuto := probeCountry(m.CountryPin, &a)
|
||||
|
||||
var lastSeenAgo any
|
||||
if src.lastSeen > 0 {
|
||||
@@ -298,8 +316,8 @@ func buildProbeNodeView(src probeNodeSource, full bool) map[string]any {
|
||||
|
||||
view := map[string]any{
|
||||
"id": a.Id,
|
||||
"name": src.name,
|
||||
"country": m.CountryPin,
|
||||
"name": probeDisplayName(src.name, m.PublicRemark),
|
||||
"country": country,
|
||||
"group": m.GroupName,
|
||||
"public_remark": m.PublicRemark,
|
||||
"sort": m.Sort,
|
||||
@@ -370,7 +388,7 @@ func buildProbeNodeView(src probeNodeSource, full bool) map[string]any {
|
||||
view["ipv6_auto"] = probeAutoAddress(exits, held4, held6, m.IPv4Pin, "", true)
|
||||
view["interval"] = interval
|
||||
view["country_pin"] = m.CountryPin
|
||||
view["country_auto"] = ""
|
||||
view["country_auto"] = countryAuto
|
||||
view["remark"] = src.remark
|
||||
view["notify"] = m.Notify
|
||||
return view
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+16
-3
@@ -320,9 +320,10 @@ const (
|
||||
cadenceXrayLogPrune = "@every 10m"
|
||||
// cpu.Percent samples over a full minute (blocking), so a finer cadence just
|
||||
// stacks overlapping samplers; subscribers rate-limit alerts to 1/min anyway.
|
||||
cadenceCheckHash = "@every 2m"
|
||||
cadenceCPUAlarm = "@every 1m"
|
||||
cadenceMemoryAlarm = "@every 1m"
|
||||
cadenceCheckHash = "@every 2m"
|
||||
cadenceCPUAlarm = "@every 1m"
|
||||
cadenceMemoryAlarm = "@every 1m"
|
||||
cadenceGeoIPCountry = "@weekly"
|
||||
)
|
||||
|
||||
// startTask schedules background jobs (Xray checks, traffic jobs, cron
|
||||
@@ -378,6 +379,18 @@ func (s *Server) startTask(restartXray bool, loc *time.Location) {
|
||||
job.NewProbeNotifyHousekeepingJob().Run()
|
||||
}()
|
||||
|
||||
// Node country flags (Q7): refresh the local country database
|
||||
// weekly. The pass is a conditional download — a no-op while no
|
||||
// update URL is configured, cheap (304) while the mirror copy is
|
||||
// current — and the same one-minute startup kick as the probe
|
||||
// jobs above lets a long-parked install catch up without waiting
|
||||
// out the week.
|
||||
_, _ = s.cron.AddJob(cadenceGeoIPCountry, job.NewGeoIPCountryJob())
|
||||
go func() {
|
||||
time.Sleep(time.Minute)
|
||||
job.NewGeoIPCountryJob().Run()
|
||||
}()
|
||||
|
||||
// check client ips from log file every day
|
||||
_, _ = s.cron.AddJob("@daily", job.NewClearLogsJob())
|
||||
_, _ = s.cron.AddJob(cadenceXrayLogPrune, job.NewPruneXrayLogsJob())
|
||||
|
||||
Reference in New Issue
Block a user