Author SHA1 Message Date
Muse 7df3a338ef install-agent: RELEASE_BASE -> V2.1.0
CI / go-test (push) Waiting to run
CI / postgres-durable-first (push) Waiting to run
CI / codegen (push) Waiting to run
CI / govulncheck (push) Waiting to run
CI / race (push) Waiting to run
CI / fuzz-smoke (push) Waiting to run
CI / golangci (push) Waiting to run
CI / frontend (push) Waiting to run
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (amd64) (push) Waiting to run
Release 3X-UI / Publish rolling dev release (push) Blocked by required conditions
CodeQL Advanced / Analyze (go) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
2026-10-23 19:57:56 +08:00
Rogue 5ca22ec340 probe-only node mode: master flag + agent gate + panel switch (V2.1.0 A)
agent_nodes gains probe_only; getConfig always serves the flag and withholds the sing-box payload (null config, empty hash) for flagged nodes while probe tasks, fast interval, stat iface and update intents ride as usual. The agent gates both core branches when the local agent.json preset or the master flag is on, retires a running core once (best-effort tail flush, switchover bookkeeping untouched so turning the flag off self-heals through the existing hash-current-but-not-running path), and makes the traffic cycle an explicit no-op. Panel: node create/update accept probeOnly, the node view overlays it from the binding, the node form gains a switch (carried into the generated install command), the list shows a probe-only tag in the core-version cell, and install-agent.sh --probe-only writes the preset, skips the core download/self-check and inherits the flag on no-arg reruns.
2026-10-23 11:54:35 +00:00
Muse 2d37a6de18 install-agent: RELEASE_BASE -> V2.0.3
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (amd64) (push) Waiting to run
Release 3X-UI / Publish rolling dev release (push) Blocked by required conditions
2026-10-23 18:45:22 +08:00
Muse db6f666ff7 probe: fast report stamps last-seen; snapshot TTL 900ms (V2.0.3)
CI / go-test (push) Waiting to run
CI / postgres-durable-first (push) Waiting to run
CI / codegen (push) Waiting to run
CI / govulncheck (push) Waiting to run
CI / race (push) Waiting to run
CI / fuzz-smoke (push) Waiting to run
CI / golangci (push) Waiting to run
CI / frontend (push) Waiting to run
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (amd64) (push) Waiting to run
Release 3X-UI / Publish rolling dev release (push) Blocked by required conditions
CodeQL Advanced / Analyze (go) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
2026-10-23 18:38:56 +08:00
28 changed files with 688 additions and 44 deletions
+26 -4
View File
@@ -50,6 +50,7 @@ function defaultValues(): NodeFormValues {
basePath: '/', basePath: '/',
apiToken: '', apiToken: '',
hasStoredToken: false, hasStoredToken: false,
probeOnly: false,
enable: true, enable: true,
allowPrivateAddress: false, allowPrivateAddress: false,
tlsVerifyMode: 'verify', tlsVerifyMode: 'verify',
@@ -83,6 +84,9 @@ export default function NodeFormModal({
const [testResult, setTestResult] = useState<ProbeResult | null>(null); const [testResult, setTestResult] = useState<ProbeResult | null>(null);
const [agentToken, setAgentToken] = useState<string | null>(null); const [agentToken, setAgentToken] = useState<string | null>(null);
const [agentNodeName, setAgentNodeName] = useState(''); 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(''); const [agentMasterUrl, setAgentMasterUrl] = useState('');
// Edit mode (item 19): the stored install material for an existing agent // Edit mode (item 19): the stored install material for an existing agent
// node, fetched from the master; null until loaded / for non-agent nodes. // node, fetched from the master; null until loaded / for non-agent nodes.
@@ -100,6 +104,7 @@ export default function NodeFormModal({
const inboundSyncMode = useWatch({ control: methods.control, name: 'inboundSyncMode' }) ?? 'all'; const inboundSyncMode = useWatch({ control: methods.control, name: 'inboundSyncMode' }) ?? 'all';
const nodeType = useWatch({ control: methods.control, name: 'nodeType' }) ?? 'traditional'; const nodeType = useWatch({ control: methods.control, name: 'nodeType' }) ?? 'traditional';
const isAgent = nodeType === 'agent'; const isAgent = nodeType === 'agent';
const probeOnly = useWatch({ control: methods.control, name: 'probeOnly' }) ?? false;
const { data: outboundGroups } = useOutboundTagGroups({ excludeBlackhole: true }); const { data: outboundGroups } = useOutboundTagGroups({ excludeBlackhole: true });
// Outbounds and balancers share one picker (like the panel-outbound selector); // Outbounds and balancers share one picker (like the panel-outbound selector);
@@ -178,10 +183,13 @@ export default function NodeFormModal({
const base = const base =
`bash <(curl -fL https://git.881218.xyz/rogue/3x-ui-agent/raw/branch/main/install-agent.sh)` + `bash <(curl -fL https://git.881218.xyz/rogue/3x-ui-agent/raw/branch/main/install-agent.sh)` +
` --master ${editMasterUrl}`; ` --master ${editMasterUrl}`;
return shownInstall.hasToken const cmd = shownInstall.hasToken
? `${base} --token ${shownInstall.token} --name ${shownInstall.name}` ? `${base} --token ${shownInstall.token} --name ${shownInstall.name}`
: `${base} --name ${shownInstall.name}`; : `${base} --name ${shownInstall.name}`;
}, [shownInstall, editMasterUrl]); // 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]);
const title = useMemo( const title = useMemo(
() => (mode === 'edit' ? t('pages.nodes.editNode') : t('pages.nodes.addNode')), () => (mode === 'edit' ? t('pages.nodes.editNode') : t('pages.nodes.addNode')),
@@ -211,6 +219,9 @@ export default function NodeFormModal({
inboundTags: values.inboundSyncMode === 'selected' ? values.inboundTags : [], inboundTags: values.inboundSyncMode === 'selected' ? values.inboundTags : [],
outboundTag: values.outboundTag || '', outboundTag: values.outboundTag || '',
}; };
// Probe-only (V2.1.0) is an agent-node concept; traditional
// payloads stay byte-identical to before.
if (agentMode) payload.probeOnly = values.probeOnly ?? false;
if (token) payload.apiToken = token; if (token) payload.apiToken = token;
return payload; return payload;
} }
@@ -296,6 +307,7 @@ export default function NodeFormModal({
if (agentMode && mode === 'add' && obj?.agentToken) { if (agentMode && mode === 'add' && obj?.agentToken) {
setAgentToken(obj.agentToken); setAgentToken(obj.agentToken);
setAgentNodeName(result.data.name.trim()); setAgentNodeName(result.data.name.trim());
setAgentProbeOnly(result.data.probeOnly ?? false);
// Default the master URL to the address this panel is being // Default the master URL to the address this panel is being
// viewed through — origin AND base path. Dropping the base // viewed through — origin AND base path. Dropping the base
// path made the agent's registration 404. User can edit it. // path made the agent's registration 404. User can edit it.
@@ -318,7 +330,7 @@ export default function NodeFormModal({
// one would produce a command that can never register, so withhold it // one would produce a command that can never register, so withhold it
// (and say why) instead of letting the user copy a dead command. // (and say why) instead of letting the user copy a dead command.
const installCmd = agentMasterUrl 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}` ? `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' : ''}`
: ''; : '';
return ( return (
@@ -430,11 +442,21 @@ export default function NodeFormModal({
)} )}
{isAgent && ( {isAgent && (
<Row gutter={16}> <Row gutter={16}>
<Col xs={24}> <Col xs={24} md={12}>
<FormField label={t('pages.nodes.enable')} name="enable" valueProp="checked"> <FormField label={t('pages.nodes.enable')} name="enable" valueProp="checked">
<Switch /> <Switch />
</FormField> </FormField>
</Col> </Col>
<Col xs={24} md={12}>
<FormField
label={t('pages.nodes.probeOnly')}
name="probeOnly"
valueProp="checked"
tooltip={t('pages.nodes.probeOnlyHint')}
>
<Switch />
</FormField>
</Col>
</Row> </Row>
)} )}
+9 -1
View File
@@ -495,12 +495,20 @@ export default function NodeList({
{ {
// Core version (V2.0.1 ②): agent rows run sing-box and show // Core version (V2.0.1 ②): agent rows run sing-box and show
// its version; traditional rows keep showing the xray one. // its version; traditional rows keep showing the xray one.
// Probe-only agent rows (V2.1.0) run no core by design: the
// cell says so instead of a bare '-', which reads as a fault.
title: t('pages.nodes.coreVersion'), title: t('pages.nodes.coreVersion'),
dataIndex: 'xrayVersion', dataIndex: 'xrayVersion',
align: 'center', align: 'center',
render: (_value, record) => render: (_value, record) =>
record.nodeType === 'agent' record.nodeType === 'agent'
? record.singboxVersion || record.xrayVersion || '-' ? record.probeOnly
? (
<Tag color="cyan" style={{ margin: 0 }}>
{t('pages.nodes.probeOnly')}
</Tag>
)
: record.singboxVersion || record.xrayVersion || '-'
: record.xrayVersion || '-', : record.xrayVersion || '-',
}, },
{ {
+5
View File
@@ -56,6 +56,9 @@ export const NodeRecordSchema = z
// P7: node kind, annotated by the backend for nodes registered by an // P7: node kind, annotated by the backend for nodes registered by an
// agent. Absent/traditional for classic panel-to-panel nodes. // agent. Absent/traditional for classic panel-to-panel nodes.
nodeType: z.enum(['traditional', 'agent']).optional(), nodeType: z.enum(['traditional', 'agent']).optional(),
// V2.1.0: probe-only agent nodes run no core, only the probe
// channels. Annotated by the backend from the bound agent row.
probeOnly: z.boolean().optional(),
// P13: host IPv4/IPv6 an agent reports with its heartbeat telemetry. // P13: host IPv4/IPv6 an agent reports with its heartbeat telemetry.
// Rendered as plain text in the address column for agent rows. // Rendered as plain text in the address column for agent rows.
ipv4: z.string().optional(), ipv4: z.string().optional(),
@@ -101,6 +104,8 @@ export const NodeFormSchema = z
// Agent nodes use an agent token instead of a panel API token. // Agent nodes use an agent token instead of a panel API token.
apiToken: z.string().trim(), apiToken: z.string().trim(),
hasStoredToken: z.boolean().optional().default(false), hasStoredToken: z.boolean().optional().default(false),
// V2.1.0: probe-only toggle for agent nodes (see NodeRecordSchema).
probeOnly: z.boolean().optional().default(false),
enable: z.boolean(), enable: z.boolean(),
allowPrivateAddress: z.boolean(), allowPrivateAddress: z.boolean(),
tlsVerifyMode: z.enum(['verify', 'skip', 'pin', 'mtls']), tlsVerifyMode: z.enum(['verify', 'skip', 'pin', 'mtls']),
+24 -4
View File
@@ -1,6 +1,8 @@
#!/bin/bash #!/bin/bash
# 3x-ui-agent 节点一键安装脚本 # 3x-ui-agent 节点一键安装脚本
# 用法: bash install-agent.sh --master http://MASTER_IP:2053 --token YOUR_AGENT_TOKEN [--name my-node] # 用法: bash install-agent.sh --master http://MASTER_IP:2053 --token YOUR_AGENT_TOKEN [--name my-node] [--probe-only]
# --probe-only(V2.1.0):纯探针安装——跳过 sing-box 核心下载与自检,
# agent.json 写入 probeOnly=true;agent 只跑探针通道、不运行任何核心。
# 要求: Debian/Ubuntu/Alpine/CentOS (amd64/arm64), root 运行 # 要求: Debian/Ubuntu/Alpine/CentOS (amd64/arm64), root 运行
# #
# 无参沿用(第 16 条):已装过 agent 的机器重跑本脚本升级时,可省略全部参数—— # 无参沿用(第 16 条):已装过 agent 的机器重跑本脚本升级时,可省略全部参数——
@@ -28,12 +30,13 @@ error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; }
[[ $EUID -ne 0 ]] && error "请用 root 运行" [[ $EUID -ne 0 ]] && error "请用 root 运行"
command -v curl >/dev/null 2>&1 || error "缺少 curl,请先安装 curl 后重跑" command -v curl >/dev/null 2>&1 || error "缺少 curl,请先安装 curl 后重跑"
MASTER=""; TOKEN=""; NAME="" MASTER=""; TOKEN=""; NAME=""; PROBE_ONLY=""
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case $1 in case $1 in
--master) MASTER="$2"; shift 2;; --master) MASTER="$2"; shift 2;;
--token) TOKEN="$2"; shift 2;; --token) TOKEN="$2"; shift 2;;
--name) NAME="$2"; shift 2;; --name) NAME="$2"; shift 2;;
--probe-only) PROBE_ONLY=1; shift;;
*) error "未知参数: $1";; *) error "未知参数: $1";;
esac esac
done done
@@ -53,6 +56,8 @@ if [[ -f "$AGENT_JSON_PATH" ]]; then
if [[ -z "$MASTER" && -n "$f_master" ]]; then MASTER="$f_master"; INHERITED+=("主控地址"); fi if [[ -z "$MASTER" && -n "$f_master" ]]; then MASTER="$f_master"; INHERITED+=("主控地址"); fi
if [[ -z "$TOKEN" && -n "$f_token" ]]; then TOKEN="$f_token"; INHERITED+=("token"); fi if [[ -z "$TOKEN" && -n "$f_token" ]]; then TOKEN="$f_token"; INHERITED+=("token"); fi
if [[ -z "$NAME" && -n "$f_name" ]]; then NAME="$f_name"; INHERITED+=("节点名"); fi if [[ -z "$NAME" && -n "$f_name" ]]; then NAME="$f_name"; INHERITED+=("节点名"); fi
# probeOnly 是布尔字段,json_get 只取字符串值,这里单独按 true 沿用。
if [[ -z "$PROBE_ONLY" ]] && grep -q '"probeOnly"[[:space:]]*:[[:space:]]*true' "$AGENT_JSON_PATH"; then PROBE_ONLY=1; INHERITED+=("纯探针"); fi
if [[ ${#INHERITED[@]} -gt 0 ]]; then if [[ ${#INHERITED[@]} -gt 0 ]]; then
info "沿用本机已有配置 ${AGENT_JSON_PATH}(${INHERITED[*]}):节点名=${NAME},主控=${MASTER}(token 已沿用,不显示)" info "沿用本机已有配置 ${AGENT_JSON_PATH}(${INHERITED[*]}):节点名=${NAME},主控=${MASTER}(token 已沿用,不显示)"
elif [[ -z "$MASTER" || -z "$TOKEN" ]]; then elif [[ -z "$MASTER" || -z "$TOKEN" ]]; then
@@ -76,7 +81,7 @@ esac
# sing-box 核心在 NexusGate 仓的专用发行里(见下方 SINGBOX_BASE)。 # sing-box 核心在 NexusGate 仓的专用发行里(见下方 SINGBOX_BASE)。
# 注意:本脚本是 V2 版——只装 sing-box 运行时;主控未升 V2 前不要用它 # 注意:本脚本是 V2 版——只装 sing-box 运行时;主控未升 V2 前不要用它
# 重装节点(节点升级走面板一键更新,不需要本脚本)。 # 重装节点(节点升级走面板一键更新,不需要本脚本)。
RELEASE_BASE="https://git.881218.xyz/rogue/3x-ui-agent/releases/download/V2.0.1" RELEASE_BASE="https://git.881218.xyz/rogue/3x-ui-agent/releases/download/V2.1.0"
XUI_URL="${XUI_URL:-${RELEASE_BASE}/x-ui-linux-${BIN_ARCH}}" XUI_URL="${XUI_URL:-${RELEASE_BASE}/x-ui-linux-${BIN_ARCH}}"
ROOT="${XUI_ROOT:-}" ROOT="${XUI_ROOT:-}"
@@ -107,18 +112,30 @@ mv -f "${AGENT_BIN}.new" "${AGENT_BIN}"
SINGBOX_VER="1.13.21" SINGBOX_VER="1.13.21"
SINGBOX_BASE="https://git.881218.xyz/rogue/NexusGate/releases/download/sing-box-v${SINGBOX_VER}" SINGBOX_BASE="https://git.881218.xyz/rogue/NexusGate/releases/download/sing-box-v${SINGBOX_VER}"
SINGBOX_URL="${SINGBOX_URL:-${SINGBOX_BASE}/nexusgate-sing-box-linux-${BIN_ARCH}}" SINGBOX_URL="${SINGBOX_URL:-${SINGBOX_BASE}/nexusgate-sing-box-linux-${BIN_ARCH}}"
if [[ "$PROBE_ONLY" == "1" ]]; then
# 纯探针节点不运行核心(V2.1.0):不下载、不落地 sing-box;
# 本机已有的核心文件不删(关掉纯探针后可直接复用)。
info "纯探针模式:跳过 sing-box 核心下载"
else
info "下载 sing-box v${SINGBOX_VER}..." info "下载 sing-box v${SINGBOX_VER}..."
curl -fL -o "${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}.new" "$SINGBOX_URL" || error "sing-box 下载失败: $SINGBOX_URL" curl -fL -o "${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}.new" "$SINGBOX_URL" || error "sing-box 下载失败: $SINGBOX_URL"
[[ -s "${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}.new" ]] || error "sing-box 下载得到空文件: $SINGBOX_URL" [[ -s "${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}.new" ]] || error "sing-box 下载得到空文件: $SINGBOX_URL"
chmod +x "${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}.new" chmod +x "${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}.new"
mv -f "${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}.new" "${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}" mv -f "${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}.new" "${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}"
printf '%s\n' "$SINGBOX_VER" > "${XUI_HOME}/bin/sing-box.version" printf '%s\n' "$SINGBOX_VER" > "${XUI_HOME}/bin/sing-box.version"
fi
# 安装收尾自检(第 17 条):文件在不等于能用——sing-box 本体须存在、 # 安装收尾自检(第 17 条):文件在不等于能用——sing-box 本体须存在、
# 可执行、version 能真实跑通;若已有生效配置,须过 sing-box check; # 可执行、version 能真实跑通;若已有生效配置,须过 sing-box check;
# agent 本体须可执行。任一不过即报错退出并指出缺什么,不许带病进入 # agent 本体须可执行。任一不过即报错退出并指出缺什么,不许带病进入
# 写配置/起服务阶段。 # 写配置/起服务阶段。
self_check() { self_check() {
if [[ "$PROBE_ONLY" == "1" ]]; then
# 纯探针节点没有核心可检(V2.1.0),只验 agent 本体。
[[ -x "$AGENT_BIN" ]] || error "自检失败:agent 二进制不可执行 ${AGENT_BIN}"
info "纯探针模式:跳过 sing-box 自检"
return 0
fi
local sb_bin="${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}" local sb_bin="${XUI_HOME}/bin/sing-box-linux-${BIN_ARCH}"
[[ -x "$sb_bin" ]] || error "自检失败:sing-box 本体缺失或不可执行 ${sb_bin}" [[ -x "$sb_bin" ]] || error "自检失败:sing-box 本体缺失或不可执行 ${sb_bin}"
local sb_out local sb_out
@@ -137,11 +154,14 @@ self_check() {
self_check self_check
info "写入 agent 配置..." info "写入 agent 配置..."
PROBE_ONLY_JSON=""
if [[ "$PROBE_ONLY" == "1" ]]; then PROBE_ONLY_JSON=',
"probeOnly": true'; fi
cat > "${CONF_DIR}/agent.json" <<EOF cat > "${CONF_DIR}/agent.json" <<EOF
{ {
"master_url": "$MASTER", "master_url": "$MASTER",
"token": "$TOKEN", "token": "$TOKEN",
"node_name": "$NAME" "node_name": "$NAME"${PROBE_ONLY_JSON}
} }
EOF EOF
chmod 600 "${CONF_DIR}/agent.json" chmod 600 "${CONF_DIR}/agent.json"
+67
View File
@@ -55,6 +55,11 @@ type Config struct {
// Empty: main derives it next to the agent config file; the agent // Empty: main derives it next to the agent config file; the agent
// falls back to the bin folder. // falls back to the bin folder.
BaselinePath string `json:"baseline_path,omitempty"` BaselinePath string `json:"baseline_path,omitempty"`
// ProbeOnly presets probe-only mode locally (V2.1.0): no core is
// fetched, started or stopped for the node's inbounds — only the
// probe channels run. The master's per-node flag (delivered on the
// config poll) enables the same mode; either source turns it on.
ProbeOnly bool `json:"probeOnly,omitempty"`
} }
// Agent represents the node agent // Agent represents the node agent
@@ -124,6 +129,11 @@ type Agent struct {
fastIntervalSec atomic.Int64 fastIntervalSec atomic.Int64
fastUnsupported atomic.Bool fastUnsupported atomic.Bool
// Probe-only mode (V2.1.0): the master's per-node flag rides the
// config poll; the local config preset is the other source. While
// either is on, the poll skips both core branches entirely.
masterProbeOnly atomic.Bool
// Latency probe runner (item 7 B4, see ping.go): task set pushed // Latency probe runner (item 7 B4, see ping.go): task set pushed
// on the config poll, samples returned on the heartbeat. // on the config poll, samples returned on the heartbeat.
probes *probeRunner probes *probeRunner
@@ -505,6 +515,10 @@ func (a *Agent) fetchAndApplyConfig() error {
// report, automatic interface selection). // report, automatic interface selection).
ProbeFastInterval *int64 `json:"probe_fast_interval"` ProbeFastInterval *int64 `json:"probe_fast_interval"`
ProbeStatIface *string `json:"probe_stat_iface"` ProbeStatIface *string `json:"probe_stat_iface"`
// ProbeOnly is a pointer on purpose (V2.1.0), same as the other
// probe keys: a master too old to know the mode omits the field
// and the agent keeps its current state.
ProbeOnly *bool `json:"probe_only"`
} }
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return err return err
@@ -523,6 +537,9 @@ func (a *Agent) fetchAndApplyConfig() error {
if result.ProbeStatIface != nil { if result.ProbeStatIface != nil {
SetStatIfacePin(*result.ProbeStatIface) SetStatIfacePin(*result.ProbeStatIface)
} }
if result.ProbeOnly != nil {
a.masterProbeOnly.Store(*result.ProbeOnly)
}
// One-click update intents ride this poll (item 20); handle them even // One-click update intents ride this poll (item 20); handle them even
// when the config itself is unchanged. // when the config itself is unchanged.
@@ -534,6 +551,16 @@ func (a *Agent) fetchAndApplyConfig() error {
a.probes.applyTasks(*result.ProbeTasks) a.probes.applyTasks(*result.ProbeTasks)
} }
// Probe-only mode (V2.1.0): no core config is applied at all. The
// master already withholds the payload for flagged nodes, but the
// local preset must gate even against a master that still serves
// one. A core left running from before the mode was turned on is
// retired by the convergence step.
if a.probeOnlyActive() {
a.retireCoresForProbeOnly()
return nil
}
// Switchover (ITEM6 B5): a master that serves a sing-box config // Switchover (ITEM6 B5): a master that serves a sing-box config
// has made sing-box the node's formal runtime. Apply the config // has made sing-box the node's formal runtime. Apply the config
// through the switch sequence (which retires the legacy xray // through the switch sequence (which retires the legacy xray
@@ -598,6 +625,40 @@ func (a *Agent) fetchAndApplyConfig() error {
return nil return nil
} }
// probeOnlyActive reports whether probe-only mode is in force from
// either source: the local agent.json preset or the master's
// per-node flag on the config poll.
func (a *Agent) probeOnlyActive() bool {
return a.config.ProbeOnly || a.masterProbeOnly.Load()
}
// retireCoresForProbeOnly converges a node that entered probe-only
// mode while a core was still running: the core's tail traffic is
// flushed best-effort (an explicit operator mode change outranks the
// unreported tail, unlike the switchover retire path, which keeps
// the core alive on a failed flush) and the core is stopped. Later
// polls find nothing running and skip, so the retirement happens
// exactly once per entry. The switchover bookkeeping (sbSwitched,
// lastSingboxHash) is left untouched on purpose: leaving probe-only
// re-serves the same config hash, and the existing "hash current but
// core not running" poll path reactivates the core on its own.
func (a *Agent) retireCoresForProbeOnly() {
if a.xrayProc != nil {
if err := a.collectAndReportXrayTraffic(); err != nil {
logger.Warningf("probe-only: flush xray traffic before retiring its core failed: %v", err)
}
a.stopXray()
logger.Info("probe-only mode: xray core retired")
}
if a.singboxRunning() {
if err := a.collectAndReportSingboxTraffic(); err != nil {
logger.Warningf("probe-only: flush sing-box traffic before retiring its core failed: %v", err)
}
a.stopSingbox()
logger.Info("probe-only mode: sing-box core retired")
}
}
// singboxRunning reports whether the managed sing-box process is // singboxRunning reports whether the managed sing-box process is
// currently alive. // currently alive.
func (a *Agent) singboxRunning() bool { func (a *Agent) singboxRunning() bool {
@@ -914,6 +975,12 @@ func (a *Agent) reportTrafficWith(sc statsClient, post func(trafficReport) error
// (xray and, since ITEM6 B3, sing-box). The two cores keep separate // (xray and, since ITEM6 B3, sing-box). The two cores keep separate
// clients and baselines; a failure in one never blocks the other. // clients and baselines; a failure in one never blocks the other.
func (a *Agent) collectAndReportTraffic() error { func (a *Agent) collectAndReportTraffic() error {
// Probe-only nodes run no core (V2.1.0): the cycle is an explicit
// no-op, so a core retired by the mode can never be mistaken for
// a live one by a later tick.
if a.probeOnlyActive() {
return nil
}
var firstErr error var firstErr error
if err := a.collectAndReportXrayTraffic(); err != nil { if err := a.collectAndReportXrayTraffic(); err != nil {
firstErr = err firstErr = err
+209
View File
@@ -0,0 +1,209 @@
package agent
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"sync/atomic"
"testing"
)
// V2.1.0 probe-only tests: the mode gates both core branches of the
// config poll (no fetch, no start, no switch latch), retires a core
// that was running when the mode turns on, and self-heals back to
// the core when the mode turns off again.
// probeMaster is a fake master whose config response is assembled
// from a switchable probe_only flag plus the usual sing-box payload.
type probeMaster struct {
probeOnly atomic.Bool
trafficHit atomic.Int32
srv *httptest.Server
}
func newProbeMaster(t *testing.T) *probeMaster {
t.Helper()
m := &probeMaster{}
sum := sha256.Sum256([]byte(sbCfgA))
mux := http.NewServeMux()
mux.HandleFunc("/panel/api/agent/config", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
body := map[string]any{
"singbox_config": json.RawMessage(sbCfgA),
"singbox_config_hash": hex.EncodeToString(sum[:]),
"probe_tasks": []any{},
// The real master always sends the flag (only a pre-V2.1.0
// master omits it); the fixture mirrors that.
"probe_only": m.probeOnly.Load(),
}
_ = json.NewEncoder(w).Encode(body)
})
mux.HandleFunc("/panel/api/agent/traffic", func(w http.ResponseWriter, r *http.Request) {
m.trafficHit.Add(1)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true}`))
})
m.srv = httptest.NewServer(mux)
t.Cleanup(m.srv.Close)
return m
}
func (m *probeMaster) agent(cfg *Config) *Agent {
if cfg == nil {
cfg = &Config{}
}
cfg.MasterURL = m.srv.URL
cfg.Token = "tok"
cfg.NodeName = "n"
return New(cfg)
}
// The master's flag alone (no local preset) must gate the poll: the
// served sing-box payload is ignored entirely.
func TestProbeOnlyMasterFlagGatesApply(t *testing.T) {
setupFakeSingbox(t)
m := newProbeMaster(t)
m.probeOnly.Store(true)
a := m.agent(nil)
t.Cleanup(func() { a.stopXray(); a.stopSingbox() })
if err := a.fetchAndApplyConfig(); err != nil {
t.Fatalf("fetchAndApplyConfig: %v", err)
}
if !a.probeOnlyActive() {
t.Fatal("probe-only must be active from the master flag")
}
if a.sbSwitched {
t.Fatal("switchover latched under probe-only")
}
if a.singboxRunning() {
t.Fatal("sing-box started under probe-only")
}
if a.lastSingboxHash != "" {
t.Fatalf("lastSingboxHash = %q, want untouched (empty)", a.lastSingboxHash)
}
if _, err := os.Stat(singboxConfigPath()); !os.IsNotExist(err) {
t.Fatalf("applied config file exists under probe-only (stat err = %v)", err)
}
}
// The local agent.json preset gates even against a master that
// still serves a payload (and predates the flag): with no core
// binary on disk, not even a core fetch may be attempted.
func TestProbeOnlyLocalPresetSkipsCoreFetch(t *testing.T) {
binDir := t.TempDir() // deliberately no fake sing-box binary
t.Setenv("XUI_BIN_FOLDER", binDir)
var hits atomic.Int32
coreSrv := fakeCoreServer(t, &hits, http.StatusOK)
defer coreSrv.Close()
useCoreBase(t, coreSrv.URL)
m := newProbeMaster(t) // probe_only absent from its responses
a := m.agent(&Config{ProbeOnly: true})
t.Cleanup(func() { a.stopXray(); a.stopSingbox() })
if err := a.fetchAndApplyConfig(); err != nil {
t.Fatalf("fetchAndApplyConfig: %v", err)
}
if hits.Load() != 0 {
t.Fatalf("core fetch attempted %d times under local probe-only preset", hits.Load())
}
if a.sbSwitched || a.singboxRunning() {
t.Fatal("core engaged under local probe-only preset")
}
}
// Turning the flag on while sing-box is running retires the core
// exactly once; turning it off again reactivates the core from the
// unchanged hash (the poll's not-running path), without the
// bookkeeping having been dirtied in between.
func TestProbeOnlyRetiresRunningSingboxAndHealsBack(t *testing.T) {
setupFakeSingbox(t)
m := newProbeMaster(t)
a := m.agent(nil)
t.Cleanup(func() { a.stopXray(); a.stopSingbox() })
if err := a.fetchAndApplyConfig(); err != nil {
t.Fatalf("initial fetch: %v", err)
}
if !a.singboxRunning() || !a.sbSwitched {
t.Fatal("precondition: sing-box must be running after the normal poll")
}
hashBefore := a.lastSingboxHash
m.probeOnly.Store(true)
if err := a.fetchAndApplyConfig(); err != nil {
t.Fatalf("probe-only fetch: %v", err)
}
if a.singboxRunning() {
t.Fatal("sing-box must be retired when probe-only turns on")
}
if !a.sbSwitched {
t.Fatal("sbSwitched must stay latched (bookkeeping untouched by probe-only)")
}
if a.lastSingboxHash != hashBefore {
t.Fatalf("lastSingboxHash dirtied: %q -> %q", hashBefore, a.lastSingboxHash)
}
// A second probe-only poll is a no-op, not an error.
if err := a.fetchAndApplyConfig(); err != nil {
t.Fatalf("second probe-only fetch: %v", err)
}
m.probeOnly.Store(false)
if err := a.fetchAndApplyConfig(); err != nil {
t.Fatalf("heal-back fetch: %v", err)
}
if !a.singboxRunning() {
t.Fatal("sing-box must restart once probe-only turns off (hash unchanged)")
}
}
// Same retirement for a legacy xray core that predates the
// switchover: probe-only stops it and does not start sing-box.
func TestProbeOnlyRetiresLegacyXray(t *testing.T) {
binDir := setupFakeSingbox(t)
setupFakeXray(t, binDir)
m := newProbeMaster(t)
m.probeOnly.Store(true)
a := m.agent(nil)
t.Cleanup(func() { a.stopXray(); a.stopSingbox() })
startLegacyXray(t, a)
if err := a.fetchAndApplyConfig(); err != nil {
t.Fatalf("fetchAndApplyConfig: %v", err)
}
if a.xrayProc != nil {
t.Fatal("xray core must be retired under probe-only")
}
if a.singboxRunning() || a.sbSwitched {
t.Fatal("sing-box must not engage under probe-only")
}
}
// The traffic cycle is an explicit no-op under probe-only: even
// with a core process somehow alive, nothing is collected or posted.
func TestProbeOnlyTrafficCycleIsNoop(t *testing.T) {
setupFakeSingbox(t)
m := newProbeMaster(t)
a := m.agent(&Config{ProbeOnly: true})
t.Cleanup(func() { a.stopXray(); a.stopSingbox() })
// Put a core under the agent directly (bypassing the poll gate)
// to prove the traffic cycle itself refuses to touch it.
if err := a.applySingboxConfig([]byte(sbCfgA)); err != nil {
t.Fatalf("applySingboxConfig: %v", err)
}
if !a.singboxRunning() {
t.Fatal("precondition: fake sing-box must be running")
}
if err := a.collectAndReportTraffic(); err != nil {
t.Fatalf("collectAndReportTraffic under probe-only: %v", err)
}
if n := m.trafficHit.Load(); n != 0 {
t.Fatalf("traffic posted %d times under probe-only", n)
}
}
+7
View File
@@ -108,6 +108,13 @@ type AgentNode struct {
SeenIPv6 string `json:"seenIpv6"` SeenIPv6 string `json:"seenIpv6"`
SeenIPAt int64 `json:"seenIpAt"` SeenIPAt int64 `json:"seenIpAt"`
// ProbeOnly marks the node as probe-only (V2.1.0): the master serves
// no core config for it and the agent runs no core at all — only the
// probe channels (heartbeat, fast report, latency tasks) stay live.
// Set from the panel's node form; the agent can also preset it in
// its local agent.json, either source turns the mode on.
ProbeOnly bool `json:"probeOnly"`
// One-click update (item 20): the master records an update intent here; // One-click update (item 20): the master records an update intent here;
// the agent picks it up from its config poll, downloads + verifies + // the agent picks it up from its config poll, downloads + verifies +
// swaps its binary, restarts itself and reports the outcome back on // swaps its binary, restarts itself and reports the outcome back on
+33 -9
View File
@@ -626,11 +626,16 @@ func probeStatIface(agentID int64) string {
} }
// probeReport ingests one fast report (V2.0.1): a probe metrics // probeReport ingests one fast report (V2.0.1): a probe metrics
// block and nothing else. It deliberately does NOT touch the // block and nothing else. Since V2.0.3 it also touches the agent's
// agent's last-seen stamp — liveness stays the 60 s heartbeat's // last-seen stamp, like every other report channel: the hub's
// signal, and the traffic ledger's unseen-boot rule keeps measuring // themes gate each batch on the node's last_seen being newer than
// reboots against heartbeat sightings exactly as before. Agents on // the last one they applied, so a metrics-only channel that never
// older masters never see this endpoint (404) and simply keep // advanced the stamp froze the public cards between the 10 s
// traffic reports. Liveness now means "any report from the agent"
// — all channels come from the same process — and the traffic
// ledger's unseen-boot rule is unaffected: Record still receives
// the pre-touch stamp, so gaps measure exactly as before. Agents
// on older masters never see this endpoint (404) and simply keep
// reporting metrics on the heartbeat; masters accept metrics on // reporting metrics on the heartbeat; masters accept metrics on
// either channel. // either channel.
func (a *AgentController) probeReport(c *gin.Context) { func (a *AgentController) probeReport(c *gin.Context) {
@@ -644,9 +649,13 @@ func (a *AgentController) probeReport(c *gin.Context) {
ProbeMetrics *heartbeatProbeMetrics `json:"probeMetrics"` ProbeMetrics *heartbeatProbeMetrics `json:"probeMetrics"`
} }
_ = c.ShouldBindJSON(&req) _ = c.ShouldBindJSON(&req)
// The last-seen stamp as it stood before this report (see the
// heartbeat handler): captured before touchAgent overwrites it.
prevLastSeen := agent.LastSeen
a.touchAgent(uint(agent.Id), "")
if req.ProbeMetrics != nil { if req.ProbeMetrics != nil {
a.updateAgentProbe(uint(agent.Id), nil, req.ProbeMetrics) a.updateAgentProbe(uint(agent.Id), nil, req.ProbeMetrics)
probe.Record(int64(agent.Id), probeSample(req.ProbeMetrics), agent.LastSeen) probe.Record(int64(agent.Id), probeSample(req.ProbeMetrics), prevLastSeen)
} }
jsonObj(c, gin.H{"message": "ok"}, nil) jsonObj(c, gin.H{"message": "ok"}, nil)
} }
@@ -662,22 +671,37 @@ func (a *AgentController) getConfig(c *gin.Context) {
c.JSON(409, gin.H{"message": "agent token is not bound to a node"}) c.JSON(409, gin.H{"message": "agent token is not bound to a node"})
return return
} }
// Probe-only nodes (V2.1.0) get no core payload at all: the build
// is skipped entirely, so a broken data model on such a node can
// never 500 its poll, and the probe keys + update intent below
// ride along exactly as for normal nodes. singbox_config stays
// null and the hash empty, which agents read as "no payload".
var sbRaw json.RawMessage
sbHash := ""
if !agent.ProbeOnly {
sbCfg, sbErr := a.singboxService.GetSingboxConfigForAgent(*agent.NodeID, agent.SingboxOutbounds, agent.SingboxRoutingRules) sbCfg, sbErr := a.singboxService.GetSingboxConfigForAgent(*agent.NodeID, agent.SingboxOutbounds, agent.SingboxRoutingRules)
if sbErr != nil { if sbErr != nil {
logger.Warningf("agent config: singbox config build failed for node %d: %v", *agent.NodeID, sbErr) logger.Warningf("agent config: singbox config build failed for node %d: %v", *agent.NodeID, sbErr)
c.JSON(500, gin.H{"message": "failed to build sing-box config"}) c.JSON(500, gin.H{"message": "failed to build sing-box config"})
return return
} }
sbRaw, mErr := json.Marshal(sbCfg) raw, mErr := json.Marshal(sbCfg)
if mErr != nil { if mErr != nil {
logger.Warningf("agent config: singbox config encode failed for node %d: %v", *agent.NodeID, mErr) logger.Warningf("agent config: singbox config encode failed for node %d: %v", *agent.NodeID, mErr)
c.JSON(500, gin.H{"message": "failed to encode sing-box config"}) c.JSON(500, gin.H{"message": "failed to encode sing-box config"})
return return
} }
sbRaw = raw
sbSum := sha256.Sum256(sbRaw) sbSum := sha256.Sum256(sbRaw)
sbHash = hex.EncodeToString(sbSum[:])
}
payload := gin.H{ payload := gin.H{
"singbox_config": json.RawMessage(sbRaw), "singbox_config": sbRaw,
"singbox_config_hash": hex.EncodeToString(sbSum[:]), "singbox_config_hash": sbHash,
// Probe-only (V2.1.0): the per-node flag rides every poll so
// toggling it in the panel takes effect without touching the
// node. Always present from this master version.
"probe_only": agent.ProbeOnly,
// Item 7 B4: the agent's probe task set rides the existing // Item 7 B4: the agent's probe task set rides the existing
// config poll (no new channel). Always present from this // config poll (no new channel). Always present from this
// master version — an empty list means "run no probes", while // master version — an empty list means "run no probes", while
@@ -18,9 +18,10 @@ import (
) )
// V2.0.1 probe batch, master side: the fast-report endpoint ingests // V2.0.1 probe batch, master side: the fast-report endpoint ingests
// metrics without touching liveness (last-seen stays the // metrics, and the config poll carries the cadence + the node's
// heartbeat's), and the config poll carries the cadence + the // counted-interface pin. Since V2.0.3 the fast report also stamps
// node's counted-interface pin. // last-seen (the hub's themes gate batches on its freshness); it
// originally did not — see the probeReport handler comment.
func TestAgentFastReportAndConfigKeys(t *testing.T) { func TestAgentFastReportAndConfigKeys(t *testing.T) {
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
dbDir := t.TempDir() dbDir := t.TempDir()
@@ -71,6 +72,12 @@ func TestAgentFastReportAndConfigKeys(t *testing.T) {
metrics := `{"probeMetrics":{"bootId":"boot-f/0123456789abcdef","uptime":60,` + metrics := `{"probeMetrics":{"bootId":"boot-f/0123456789abcdef","uptime":60,` +
`"memTotal":1000,"memUsed":400,"netRxTotal":10,"netTxTotal":20}}` `"memTotal":1000,"memUsed":400,"netRxTotal":10,"netTxTotal":20}}`
// Age the stamp first so the fast report's touch is observable
// even within the same millisecond as the heartbeat's.
stale := seenAfterHeartbeat - 120_000
if err := db.Model(&model.AgentNode{}).Where("id = ?", agent.Id).Update("last_seen", stale).Error; err != nil {
t.Fatalf("age last-seen: %v", err)
}
if code, body := call(http.MethodPost, "/panel/api/agent/probe-report", metrics); code != http.StatusOK { if code, body := call(http.MethodPost, "/panel/api/agent/probe-report", metrics); code != http.StatusOK {
t.Fatalf("probe-report: %d (%s)", code, body) t.Fatalf("probe-report: %d (%s)", code, body)
} }
@@ -78,8 +85,8 @@ func TestAgentFastReportAndConfigKeys(t *testing.T) {
if row.ProbeMemUsed != 400 || row.ProbeAt == 0 { if row.ProbeMemUsed != 400 || row.ProbeAt == 0 {
t.Errorf("fast report metrics not stored: %+v", row) t.Errorf("fast report metrics not stored: %+v", row)
} }
if row.LastSeen != seenAfterHeartbeat { if row.LastSeen <= stale {
t.Errorf("fast report moved last-seen: %d -> %d (liveness is the heartbeat's signal)", seenAfterHeartbeat, row.LastSeen) t.Errorf("fast report did not stamp last-seen: %d -> %d (V2.0.3: themes gate on it)", stale, row.LastSeen)
} }
// Unauthorized callers are refused. // Unauthorized callers are refused.
@@ -0,0 +1,125 @@
package controller
import (
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// V2.1.0: a probe-only agent's config poll carries the flag and NO
// core payload (singbox_config null, hash empty), while the probe
// keys and a pending update intent still ride along — a probe-only
// node must stay reachable for probes and one-click agent updates.
// The control agent on a normal node keeps receiving its payload.
func TestConfigProbeOnlyWithholdsCorePayload(t *testing.T) {
gin.SetMode(gin.TestMode)
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
db := database.GetDB()
seed := func(name, token string, probeOnly bool) {
node := &model.Node{Name: name, Address: "10.0.9.4"}
if err := db.Create(node).Error; err != nil {
t.Fatalf("seed node: %v", err)
}
agent := &model.AgentNode{
TokenHash: hashAgentToken(token),
TokenPrefix: token[:12],
Name: name,
NodeID: &node.Id,
AgentVersion: "A2.0.3",
ProbeOnly: probeOnly,
}
if probeOnly {
agent.UpdateRequestedAt = time.Now().Unix()
agent.UpdateState = "pending"
agent.UpdateTarget = "A2.1.0"
}
if err := db.Create(agent).Error; err != nil {
t.Fatalf("seed agent: %v", err)
}
inbound := &model.Inbound{
Enable: true,
Port: 443,
Protocol: model.VLESS,
Tag: "inbound-" + name,
NodeID: &node.Id,
Settings: `{"clients":[],"decryption":"none","encryption":"none","fallbacks":[]}`,
StreamSettings: `{"network":"tcp","security":"reality","realitySettings":` +
`{"serverNames":["www.amd.com"],"shortIds":["ab12cd34"],"privateKey":"FAKEPRIV",` +
`"settings":{"publicKey":"FAKEPUB","fingerprint":"chrome","spiderX":"/"}}}`,
SingboxConfig: `{"type":"vless","tag":"inbound-` + name + `","listen_port":443,` +
`"tls":{"enabled":true,"server_name":"www.amd.com","reality":{"enabled":true,` +
`"handshake":{"server":"www.amd.com","server_port":443},"private_key":"FAKEPRIV",` +
`"short_id":["ab12cd34"]}},"users":[]}`,
}
if err := db.Create(inbound).Error; err != nil {
t.Fatalf("seed inbound: %v", err)
}
}
const probeToken = "probe-only-token-0000000000000000000001"
const normalToken = "probe-normal-token-00000000000000000001"
seed("probe-node", probeToken, true)
seed("normal-node", normalToken, false)
engine := gin.New()
NewAgentController(engine.Group("/panel/api/agent"))
get := func(token string) map[string]any {
req := httptest.NewRequest(http.MethodGet, "/panel/api/agent/config", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
engine.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("config: status %d body %s", rec.Code, rec.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("config response not JSON: %v", err)
}
return resp
}
resp := get(probeToken)
if resp["probe_only"] != true {
t.Fatalf("probe_only = %v, want true", resp["probe_only"])
}
if cfg, present := resp["singbox_config"]; !present || cfg != nil {
t.Fatalf("singbox_config = %v (present=%v), want JSON null", cfg, present)
}
if h := resp["singbox_config_hash"]; h != "" {
t.Fatalf("singbox_config_hash = %v, want empty", h)
}
if _, present := resp["probe_tasks"]; !present {
t.Fatal("probe_tasks missing: probe-only nodes still run latency probes")
}
if _, present := resp["probe_fast_interval"]; !present {
t.Fatal("probe_fast_interval missing on probe-only config")
}
upd, _ := resp["agent_update"].(map[string]any)
if upd == nil || upd["target"] != "A2.1.0" {
t.Fatalf("agent_update = %v, want the pending intent (probe-only nodes still update)", resp["agent_update"])
}
resp = get(normalToken)
if resp["probe_only"] != false {
t.Fatalf("probe_only = %v, want false for a normal node", resp["probe_only"])
}
if cfg, _ := resp["singbox_config"].(map[string]any); cfg == nil {
t.Fatalf("normal node lost its singbox payload: %v", resp["singbox_config"])
}
if h, _ := resp["singbox_config_hash"].(string); h == "" {
t.Fatal("normal node singbox_config_hash empty")
}
}
+18 -1
View File
@@ -8,6 +8,9 @@ import (
"strconv" "strconv"
"time" "time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/web/middleware" "github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
"github.com/mhsanaei/3x-ui/v3/internal/web/service" "github.com/mhsanaei/3x-ui/v3/internal/web/service"
@@ -167,11 +170,25 @@ func (a *NodeController) add(c *gin.Context) {
if isAgent { if isAgent {
view.NodeType = "agent" view.NodeType = "agent"
nodeID := view.Id nodeID := view.Id
token, _, err := issueAgentToken(&nodeID, n.Name) token, agentID, err := issueAgentToken(&nodeID, n.Name)
if err != nil { if err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err) jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
return return
} }
// Probe-only (V2.1.0): preset the flag on the freshly issued
// agent row when the create form asked for it, so the very
// first config poll already serves no core payload. A failed
// preset only logs — the node and token are usable, and the
// flag can still be flipped from the edit form.
if n.ProbeOnly != nil && *n.ProbeOnly {
if err := database.GetDB().Model(&model.AgentNode{}).
Where("id = ?", agentID).
Update("probe_only", true).Error; err != nil {
logger.Warningf("node add: preset probe_only for agent %d failed: %v", agentID, err)
} else {
view.ProbeOnly = true
}
}
jsonMsgObj(c, I18nWeb(c, "pages.nodes.toasts.add"), gin.H{ jsonMsgObj(c, I18nWeb(c, "pages.nodes.toasts.add"), gin.H{
"node": view, "node": view,
"agentToken": token, "agentToken": token,
+11
View File
@@ -680,6 +680,17 @@ func (s *NodeService) UpdateFromRequest(id int, req *NodeMutationRequest) error
if err := tx.Model(model.Node{}).Where("id = ?", id).Updates(updates).Error; err != nil { if err := tx.Model(model.Node{}).Where("id = ?", id).Updates(updates).Error; err != nil {
return err return err
} }
// Probe-only (V2.1.0) lives on the bound agent rows, not the
// node row: flip every binding of this node in the same
// transaction, so the flag and the node edit land together.
// Nil means the form didn't touch the switch.
if isAgent && req.ProbeOnly != nil {
if err := tx.Model(&model.AgentNode{}).
Where("node_id = ?", id).
Update("probe_only", *req.ProbeOnly).Error; err != nil {
return err
}
}
return s.MarkNodeDirtyTx(tx, id) return s.MarkNodeDirtyTx(tx, id)
}); err != nil { }); err != nil {
return err return err
+14
View File
@@ -40,6 +40,11 @@ type NodeView struct {
// traditional nodes and for agents with no known address yet. // traditional nodes and for agents with no known address yet.
Ipv4 string `json:"ipv4,omitempty" example:"203.0.113.7"` Ipv4 string `json:"ipv4,omitempty" example:"203.0.113.7"`
Ipv6 string `json:"ipv6,omitempty" example:"2001:db8::7"` Ipv6 string `json:"ipv6,omitempty" example:"2001:db8::7"`
// ProbeOnly marks an agent node as probe-only (V2.1.0): no core
// runs on it, only the probe channels. Overlaid from the bound
// agent row like the update bookkeeping below; false (and absent)
// for traditional nodes and unflagged agents.
ProbeOnly bool `json:"probeOnly,omitempty" example:"false"`
// Agent one-click update bookkeeping (item 20), agent nodes only: // Agent one-click update bookkeeping (item 20), agent nodes only:
// the last intent's state/target and, on failure, its error. // the last intent's state/target and, on failure, its error.
AgentUpdateState string `json:"agentUpdateState,omitempty" example:"failed"` AgentUpdateState string `json:"agentUpdateState,omitempty" example:"failed"`
@@ -225,6 +230,10 @@ func annotateAgentTelemetry(views []*NodeView) {
// its first telemetry report: an agent that has only registered // its first telemetry report: an agent that has only registered
// already has a master-observed source address. // already has a master-observed source address.
v.Ipv4, v.Ipv6 = ResolveAgentAddresses(ag) v.Ipv4, v.Ipv6 = ResolveAgentAddresses(ag)
// Probe-only (V2.1.0) is a stored flag, not telemetry: it must
// show even before the agent's first report, so it is overlaid
// here, ahead of the telemetry early-exit below.
v.ProbeOnly = ag.ProbeOnly
if ag.TelemetryAt == 0 { if ag.TelemetryAt == 0 {
continue continue
} }
@@ -278,6 +287,11 @@ type NodeMutationRequest struct {
// agent nodes. Agent nodes skip the panel reachability probe and the API // agent nodes. Agent nodes skip the panel reachability probe and the API
// token requirement; an agent token is issued on creation instead. // token requirement; an agent token is issued on creation instead.
NodeType string `json:"nodeType,omitempty" form:"nodeType" validate:"omitempty,oneof=traditional agent"` NodeType string `json:"nodeType,omitempty" form:"nodeType" validate:"omitempty,oneof=traditional agent"`
// ProbeOnly (V2.1.0) flips an agent node into probe-only mode. A
// pointer so updates can tell "leave the flag alone" (nil) from an
// explicit on/off; applied to the bound agent rows, agent nodes
// only. On create it presets the flag on the freshly issued agent.
ProbeOnly *bool `json:"probeOnly,omitempty" form:"probeOnly"`
} }
func (r *NodeMutationRequest) validateCredentials(create bool) error { func (r *NodeMutationRequest) validateCredentials(create bool) error {
@@ -0,0 +1,80 @@
package service
import (
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// V2.1.0: the node form's probeOnly switch persists onto the bound
// agent rows via UpdateFromRequest (nil leaves the flag untouched),
// and the list view overlays it from the binding.
func TestNodeProbeOnlyUpdateAndView(t *testing.T) {
setupConflictDB(t)
db := database.GetDB()
node := &model.Node{Id: 7601, Name: "probe-svc", Enable: true, Status: "online"}
if err := db.Create(node).Error; err != nil {
t.Fatalf("create node: %v", err)
}
ag := &model.AgentNode{TokenHash: "hash-probe-svc", Name: "probe-svc", NodeID: &node.Id}
if err := db.Create(ag).Error; err != nil {
t.Fatalf("create agent: %v", err)
}
svc := &NodeService{}
truth := true
lies := false
base := func() *NodeMutationRequest {
return &NodeMutationRequest{
Name: "probe-svc", NodeType: "agent", Enable: true,
Scheme: "https", BasePath: "/",
}
}
// Nil: flag untouched (still false).
if err := svc.UpdateFromRequest(node.Id, base()); err != nil {
t.Fatalf("update (nil): %v", err)
}
var row model.AgentNode
if err := db.First(&row, ag.Id).Error; err != nil {
t.Fatal(err)
}
if row.ProbeOnly {
t.Fatal("nil probeOnly must not set the flag")
}
// True: flag set on the agent row, and the annotated view shows it.
req := base()
req.ProbeOnly = &truth
if err := svc.UpdateFromRequest(node.Id, req); err != nil {
t.Fatalf("update (true): %v", err)
}
if err := db.First(&row, ag.Id).Error; err != nil {
t.Fatal(err)
}
if !row.ProbeOnly {
t.Fatal("probeOnly=true not persisted on the agent row")
}
view, err := svc.GetViewById(node.Id)
if err != nil {
t.Fatalf("GetViewById: %v", err)
}
if !view.ProbeOnly {
t.Fatal("view probeOnly = false, want true from the binding")
}
// False: explicit off wins over the stored value.
req = base()
req.ProbeOnly = &lies
if err := svc.UpdateFromRequest(node.Id, req); err != nil {
t.Fatalf("update (false): %v", err)
}
if err := db.First(&row, ag.Id).Error; err != nil {
t.Fatal(err)
}
if row.ProbeOnly {
t.Fatal("probeOnly=false not persisted on the agent row")
}
}
+5 -3
View File
@@ -29,9 +29,11 @@ import (
// 150 s absorbs one lost beat plus jitter. // 150 s absorbs one lost beat plus jitter.
const ProbeOnlineThresholdMs = 150_000 const ProbeOnlineThresholdMs = 150_000
// ProbeSnapshotTTL mirrors the hub's 1900 ms frame reuse: just under // ProbeSnapshotTTL mirrors the hub's frame reuse, shortened in
// the 2 s push interval, so a frame is built at most once per tick. // V2.0.3 from the hub's 1900 ms: just under the fastest (1 s) push
const ProbeSnapshotTTL = 1900 * time.Millisecond // cadence, so a 1 s stream still gets a fresh frame every tick
// instead of repeating the previous one half the time.
const ProbeSnapshotTTL = 900 * time.Millisecond
// ---- public-surface settings (main settings KV, hub key names) ---- // ---- public-surface settings (main settings KV, hub key names) ----
+2
View File
@@ -933,6 +933,8 @@
"nodeTypeTraditional": "Traditional Panel Node", "nodeTypeTraditional": "Traditional Panel Node",
"nodeTypeAgent": "Agent Lightweight Node", "nodeTypeAgent": "Agent Lightweight Node",
"agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.", "agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.",
"probeOnly": "Probe only",
"probeOnlyHint": "Probe-only mode: no proxy core runs on this node; only monitoring (heartbeat, metrics and latency probes) is active.",
"agentTokenTitle": "Agent Node Setup", "agentTokenTitle": "Agent Node Setup",
"agentTokenCreated": "Node {{name}} created successfully", "agentTokenCreated": "Node {{name}} created successfully",
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!", "agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!",
+2
View File
@@ -1051,6 +1051,8 @@
"nodeTypeTraditional": "Traditional Panel Node", "nodeTypeTraditional": "Traditional Panel Node",
"nodeTypeAgent": "Agent Lightweight Node", "nodeTypeAgent": "Agent Lightweight Node",
"agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.", "agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.",
"probeOnly": "Probe only",
"probeOnlyHint": "Probe-only mode: no proxy core runs on this node; only monitoring (heartbeat, metrics and latency probes) is active.",
"agentTokenTitle": "Agent Node Setup", "agentTokenTitle": "Agent Node Setup",
"agentTokenCreated": "Node {{name}} created successfully", "agentTokenCreated": "Node {{name}} created successfully",
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!", "agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!",
+2
View File
@@ -933,6 +933,8 @@
"nodeTypeTraditional": "Traditional Panel Node", "nodeTypeTraditional": "Traditional Panel Node",
"nodeTypeAgent": "Agent Lightweight Node", "nodeTypeAgent": "Agent Lightweight Node",
"agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.", "agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.",
"probeOnly": "Probe only",
"probeOnlyHint": "Probe-only mode: no proxy core runs on this node; only monitoring (heartbeat, metrics and latency probes) is active.",
"agentTokenTitle": "Agent Node Setup", "agentTokenTitle": "Agent Node Setup",
"agentTokenCreated": "Node {{name}} created successfully", "agentTokenCreated": "Node {{name}} created successfully",
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!", "agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!",
+2
View File
@@ -933,6 +933,8 @@
"nodeTypeTraditional": "Traditional Panel Node", "nodeTypeTraditional": "Traditional Panel Node",
"nodeTypeAgent": "Agent Lightweight Node", "nodeTypeAgent": "Agent Lightweight Node",
"agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.", "agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.",
"probeOnly": "Probe only",
"probeOnlyHint": "Probe-only mode: no proxy core runs on this node; only monitoring (heartbeat, metrics and latency probes) is active.",
"agentTokenTitle": "Agent Node Setup", "agentTokenTitle": "Agent Node Setup",
"agentTokenCreated": "Node {{name}} created successfully", "agentTokenCreated": "Node {{name}} created successfully",
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!", "agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!",
+2
View File
@@ -933,6 +933,8 @@
"nodeTypeTraditional": "Traditional Panel Node", "nodeTypeTraditional": "Traditional Panel Node",
"nodeTypeAgent": "Agent Lightweight Node", "nodeTypeAgent": "Agent Lightweight Node",
"agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.", "agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.",
"probeOnly": "Probe only",
"probeOnlyHint": "Probe-only mode: no proxy core runs on this node; only monitoring (heartbeat, metrics and latency probes) is active.",
"agentTokenTitle": "Agent Node Setup", "agentTokenTitle": "Agent Node Setup",
"agentTokenCreated": "Node {{name}} created successfully", "agentTokenCreated": "Node {{name}} created successfully",
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!", "agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!",
+2
View File
@@ -933,6 +933,8 @@
"nodeTypeTraditional": "Traditional Panel Node", "nodeTypeTraditional": "Traditional Panel Node",
"nodeTypeAgent": "Agent Lightweight Node", "nodeTypeAgent": "Agent Lightweight Node",
"agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.", "agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.",
"probeOnly": "Probe only",
"probeOnlyHint": "Probe-only mode: no proxy core runs on this node; only monitoring (heartbeat, metrics and latency probes) is active.",
"agentTokenTitle": "Agent Node Setup", "agentTokenTitle": "Agent Node Setup",
"agentTokenCreated": "Node {{name}} created successfully", "agentTokenCreated": "Node {{name}} created successfully",
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!", "agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!",
+2
View File
@@ -933,6 +933,8 @@
"nodeTypeTraditional": "Traditional Panel Node", "nodeTypeTraditional": "Traditional Panel Node",
"nodeTypeAgent": "Agent Lightweight Node", "nodeTypeAgent": "Agent Lightweight Node",
"agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.", "agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.",
"probeOnly": "Probe only",
"probeOnlyHint": "Probe-only mode: no proxy core runs on this node; only monitoring (heartbeat, metrics and latency probes) is active.",
"agentTokenTitle": "Agent Node Setup", "agentTokenTitle": "Agent Node Setup",
"agentTokenCreated": "Node {{name}} created successfully", "agentTokenCreated": "Node {{name}} created successfully",
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!", "agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!",
+2
View File
@@ -933,6 +933,8 @@
"nodeTypeTraditional": "Traditional Panel Node", "nodeTypeTraditional": "Traditional Panel Node",
"nodeTypeAgent": "Agent Lightweight Node", "nodeTypeAgent": "Agent Lightweight Node",
"agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.", "agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.",
"probeOnly": "Probe only",
"probeOnlyHint": "Probe-only mode: no proxy core runs on this node; only monitoring (heartbeat, metrics and latency probes) is active.",
"agentTokenTitle": "Agent Node Setup", "agentTokenTitle": "Agent Node Setup",
"agentTokenCreated": "Node {{name}} created successfully", "agentTokenCreated": "Node {{name}} created successfully",
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!", "agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!",
+2
View File
@@ -933,6 +933,8 @@
"nodeTypeTraditional": "Traditional Panel Node", "nodeTypeTraditional": "Traditional Panel Node",
"nodeTypeAgent": "Agent Lightweight Node", "nodeTypeAgent": "Agent Lightweight Node",
"agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.", "agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.",
"probeOnly": "Probe only",
"probeOnlyHint": "Probe-only mode: no proxy core runs on this node; only monitoring (heartbeat, metrics and latency probes) is active.",
"agentTokenTitle": "Agent Node Setup", "agentTokenTitle": "Agent Node Setup",
"agentTokenCreated": "Node {{name}} created successfully", "agentTokenCreated": "Node {{name}} created successfully",
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!", "agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!",
+2
View File
@@ -933,6 +933,8 @@
"nodeTypeTraditional": "Traditional Panel Node", "nodeTypeTraditional": "Traditional Panel Node",
"nodeTypeAgent": "Agent Lightweight Node", "nodeTypeAgent": "Agent Lightweight Node",
"agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.", "agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.",
"probeOnly": "Probe only",
"probeOnlyHint": "Probe-only mode: no proxy core runs on this node; only monitoring (heartbeat, metrics and latency probes) is active.",
"agentTokenTitle": "Agent Node Setup", "agentTokenTitle": "Agent Node Setup",
"agentTokenCreated": "Node {{name}} created successfully", "agentTokenCreated": "Node {{name}} created successfully",
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!", "agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!",
+2
View File
@@ -933,6 +933,8 @@
"nodeTypeTraditional": "Traditional Panel Node", "nodeTypeTraditional": "Traditional Panel Node",
"nodeTypeAgent": "Agent Lightweight Node", "nodeTypeAgent": "Agent Lightweight Node",
"agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.", "agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.",
"probeOnly": "Probe only",
"probeOnlyHint": "Probe-only mode: no proxy core runs on this node; only monitoring (heartbeat, metrics and latency probes) is active.",
"agentTokenTitle": "Agent Node Setup", "agentTokenTitle": "Agent Node Setup",
"agentTokenCreated": "Node {{name}} created successfully", "agentTokenCreated": "Node {{name}} created successfully",
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!", "agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!",
+2
View File
@@ -933,6 +933,8 @@
"nodeTypeTraditional": "传统面板节点", "nodeTypeTraditional": "传统面板节点",
"nodeTypeAgent": "Agent 轻量节点", "nodeTypeAgent": "Agent 轻量节点",
"agentHint": "Agent 节点由节点机主动连接主控,无需填写地址、端口和 API 令牌。创建后会生成安装命令。", "agentHint": "Agent 节点由节点机主动连接主控,无需填写地址、端口和 API 令牌。创建后会生成安装命令。",
"probeOnly": "纯探针",
"probeOnlyHint": "纯探针模式:该节点不运行任何代理核心,只保留探针功能(心跳、指标与延迟探测)。",
"agentTokenTitle": "Agent 节点安装", "agentTokenTitle": "Agent 节点安装",
"agentTokenCreated": "节点 {{name}} 创建成功", "agentTokenCreated": "节点 {{name}} 创建成功",
"agentTokenHint": "复制下面的命令,在节点机上运行即可完成安装。此 token 只显示一次!", "agentTokenHint": "复制下面的命令,在节点机上运行即可完成安装。此 token 只显示一次!",
+2
View File
@@ -933,6 +933,8 @@
"nodeTypeTraditional": "Traditional Panel Node", "nodeTypeTraditional": "Traditional Panel Node",
"nodeTypeAgent": "Agent Lightweight Node", "nodeTypeAgent": "Agent Lightweight Node",
"agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.", "agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.",
"probeOnly": "Probe only",
"probeOnlyHint": "Probe-only mode: no proxy core runs on this node; only monitoring (heartbeat, metrics and latency probes) is active.",
"agentTokenTitle": "Agent Node Setup", "agentTokenTitle": "Agent Node Setup",
"agentTokenCreated": "Node {{name}} created successfully", "agentTokenCreated": "Node {{name}} created successfully",
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!", "agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!",