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.
This commit is contained in:
Rogue
2026-10-23 11:54:35 +00:00
parent 2d37a6de18
commit 5ca22ec340
26 changed files with 655 additions and 29 deletions
+26 -4
View File
@@ -50,6 +50,7 @@ function defaultValues(): NodeFormValues {
basePath: '/',
apiToken: '',
hasStoredToken: false,
probeOnly: false,
enable: true,
allowPrivateAddress: false,
tlsVerifyMode: 'verify',
@@ -83,6 +84,9 @@ 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.
@@ -100,6 +104,7 @@ 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);
@@ -178,10 +183,13 @@ export default function NodeFormModal({
const base =
`bash <(curl -fL https://git.881218.xyz/rogue/3x-ui-agent/raw/branch/main/install-agent.sh)` +
` --master ${editMasterUrl}`;
return shownInstall.hasToken
const cmd = shownInstall.hasToken
? `${base} --token ${shownInstall.token} --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(
() => (mode === 'edit' ? t('pages.nodes.editNode') : t('pages.nodes.addNode')),
@@ -211,6 +219,9 @@ export default function NodeFormModal({
inboundTags: values.inboundSyncMode === 'selected' ? values.inboundTags : [],
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;
return payload;
}
@@ -296,6 +307,7 @@ 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.
@@ -318,7 +330,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}`
? `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 (
@@ -430,11 +442,21 @@ export default function NodeFormModal({
)}
{isAgent && (
<Row gutter={16}>
<Col xs={24}>
<Col xs={24} md={12}>
<FormField label={t('pages.nodes.enable')} name="enable" valueProp="checked">
<Switch />
</FormField>
</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>
)}
+9 -1
View File
@@ -495,12 +495,20 @@ export default function NodeList({
{
// Core version (V2.0.1 ②): agent rows run sing-box and show
// 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'),
dataIndex: 'xrayVersion',
align: 'center',
render: (_value, record) =>
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 || '-',
},
{
+5
View File
@@ -56,6 +56,9 @@ export const NodeRecordSchema = z
// P7: node kind, annotated by the backend for nodes registered by an
// agent. Absent/traditional for classic panel-to-panel nodes.
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.
// Rendered as plain text in the address column for agent rows.
ipv4: z.string().optional(),
@@ -101,6 +104,8 @@ export const NodeFormSchema = z
// Agent nodes use an agent token instead of a panel API token.
apiToken: z.string().trim(),
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(),
allowPrivateAddress: z.boolean(),
tlsVerifyMode: z.enum(['verify', 'skip', 'pin', 'mtls']),
+29 -9
View File
@@ -1,6 +1,8 @@
#!/bin/bash
# 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 运行
#
# 无参沿用(第 16 条):已装过 agent 的机器重跑本脚本升级时,可省略全部参数——
@@ -28,12 +30,13 @@ error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; }
[[ $EUID -ne 0 ]] && error "请用 root 运行"
command -v curl >/dev/null 2>&1 || error "缺少 curl,请先安装 curl 后重跑"
MASTER=""; TOKEN=""; NAME=""
MASTER=""; TOKEN=""; NAME=""; PROBE_ONLY=""
while [[ $# -gt 0 ]]; do
case $1 in
--master) MASTER="$2"; shift 2;;
--token) TOKEN="$2"; shift 2;;
--name) NAME="$2"; shift 2;;
--probe-only) PROBE_ONLY=1; shift;;
*) error "未知参数: $1";;
esac
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 "$TOKEN" && -n "$f_token" ]]; then TOKEN="$f_token"; INHERITED+=("token"); 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
info "沿用本机已有配置 ${AGENT_JSON_PATH}(${INHERITED[*]}):节点名=${NAME},主控=${MASTER}(token 已沿用,不显示)"
elif [[ -z "$MASTER" || -z "$TOKEN" ]]; then
@@ -107,18 +112,30 @@ mv -f "${AGENT_BIN}.new" "${AGENT_BIN}"
SINGBOX_VER="1.13.21"
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}}"
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"
[[ -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"
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"
if [[ "$PROBE_ONLY" == "1" ]]; then
# 纯探针节点不运行核心(V2.1.0):不下载、不落地 sing-box;
# 本机已有的核心文件不删(关掉纯探针后可直接复用)。
info "纯探针模式:跳过 sing-box 核心下载"
else
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"
[[ -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"
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"
fi
# 安装收尾自检(第 17 条):文件在不等于能用——sing-box 本体须存在、
# 可执行、version 能真实跑通;若已有生效配置,须过 sing-box check;
# agent 本体须可执行。任一不过即报错退出并指出缺什么,不许带病进入
# 写配置/起服务阶段。
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}"
[[ -x "$sb_bin" ]] || error "自检失败:sing-box 本体缺失或不可执行 ${sb_bin}"
local sb_out
@@ -137,11 +154,14 @@ self_check() {
self_check
info "写入 agent 配置..."
PROBE_ONLY_JSON=""
if [[ "$PROBE_ONLY" == "1" ]]; then PROBE_ONLY_JSON=',
"probeOnly": true'; fi
cat > "${CONF_DIR}/agent.json" <<EOF
{
"master_url": "$MASTER",
"token": "$TOKEN",
"node_name": "$NAME"
"node_name": "$NAME"${PROBE_ONLY_JSON}
}
EOF
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
// falls back to the bin folder.
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
@@ -124,6 +129,11 @@ type Agent struct {
fastIntervalSec atomic.Int64
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
// on the config poll, samples returned on the heartbeat.
probes *probeRunner
@@ -505,6 +515,10 @@ func (a *Agent) fetchAndApplyConfig() error {
// report, automatic interface selection).
ProbeFastInterval *int64 `json:"probe_fast_interval"`
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 {
return err
@@ -523,6 +537,9 @@ func (a *Agent) fetchAndApplyConfig() error {
if result.ProbeStatIface != nil {
SetStatIfacePin(*result.ProbeStatIface)
}
if result.ProbeOnly != nil {
a.masterProbeOnly.Store(*result.ProbeOnly)
}
// One-click update intents ride this poll (item 20); handle them even
// when the config itself is unchanged.
@@ -534,6 +551,16 @@ func (a *Agent) fetchAndApplyConfig() error {
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
// has made sing-box the node's formal runtime. Apply the config
// through the switch sequence (which retires the legacy xray
@@ -598,6 +625,40 @@ func (a *Agent) fetchAndApplyConfig() error {
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
// currently alive.
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
// clients and baselines; a failure in one never blocks the other.
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
if err := a.collectAndReportXrayTraffic(); err != nil {
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"`
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;
// the agent picks it up from its config poll, downloads + verifies +
// swaps its binary, restarts itself and reports the outcome back on
+29 -14
View File
@@ -671,22 +671,37 @@ func (a *AgentController) getConfig(c *gin.Context) {
c.JSON(409, gin.H{"message": "agent token is not bound to a node"})
return
}
sbCfg, sbErr := a.singboxService.GetSingboxConfigForAgent(*agent.NodeID, agent.SingboxOutbounds, agent.SingboxRoutingRules)
if sbErr != nil {
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"})
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)
if sbErr != nil {
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"})
return
}
raw, mErr := json.Marshal(sbCfg)
if mErr != nil {
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"})
return
}
sbRaw = raw
sbSum := sha256.Sum256(sbRaw)
sbHash = hex.EncodeToString(sbSum[:])
}
sbRaw, mErr := json.Marshal(sbCfg)
if mErr != nil {
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"})
return
}
sbSum := sha256.Sum256(sbRaw)
payload := gin.H{
"singbox_config": json.RawMessage(sbRaw),
"singbox_config_hash": hex.EncodeToString(sbSum[:]),
"singbox_config": sbRaw,
"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
// config poll (no new channel). Always present from this
// master version — an empty list means "run no probes", while
@@ -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"
"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/service"
@@ -167,11 +170,25 @@ func (a *NodeController) add(c *gin.Context) {
if isAgent {
view.NodeType = "agent"
nodeID := view.Id
token, _, err := issueAgentToken(&nodeID, n.Name)
token, agentID, err := issueAgentToken(&nodeID, n.Name)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
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{
"node": view,
"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 {
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)
}); err != nil {
return err
+14
View File
@@ -40,6 +40,11 @@ type NodeView struct {
// traditional nodes and for agents with no known address yet.
Ipv4 string `json:"ipv4,omitempty" example:"203.0.113.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:
// the last intent's state/target and, on failure, its error.
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
// already has a master-observed source address.
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 {
continue
}
@@ -278,6 +287,11 @@ type NodeMutationRequest struct {
// agent nodes. Agent nodes skip the panel reachability probe and the API
// token requirement; an agent token is issued on creation instead.
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 {
@@ -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")
}
}
+2
View File
@@ -933,6 +933,8 @@
"nodeTypeTraditional": "Traditional Panel 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.",
"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",
"agentTokenCreated": "Node {{name}} created successfully",
"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",
"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.",
"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",
"agentTokenCreated": "Node {{name}} created successfully",
"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",
"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.",
"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",
"agentTokenCreated": "Node {{name}} created successfully",
"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",
"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.",
"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",
"agentTokenCreated": "Node {{name}} created successfully",
"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",
"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.",
"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",
"agentTokenCreated": "Node {{name}} created successfully",
"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",
"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.",
"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",
"agentTokenCreated": "Node {{name}} created successfully",
"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",
"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.",
"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",
"agentTokenCreated": "Node {{name}} created successfully",
"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",
"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.",
"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",
"agentTokenCreated": "Node {{name}} created successfully",
"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",
"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.",
"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",
"agentTokenCreated": "Node {{name}} created successfully",
"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",
"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.",
"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",
"agentTokenCreated": "Node {{name}} created successfully",
"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",
"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.",
"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",
"agentTokenCreated": "Node {{name}} created successfully",
"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": "传统面板节点",
"nodeTypeAgent": "Agent 轻量节点",
"agentHint": "Agent 节点由节点机主动连接主控,无需填写地址、端口和 API 令牌。创建后会生成安装命令。",
"probeOnly": "纯探针",
"probeOnlyHint": "纯探针模式:该节点不运行任何代理核心,只保留探针功能(心跳、指标与延迟探测)。",
"agentTokenTitle": "Agent 节点安装",
"agentTokenCreated": "节点 {{name}} 创建成功",
"agentTokenHint": "复制下面的命令,在节点机上运行即可完成安装。此 token 只显示一次!",
+2
View File
@@ -933,6 +933,8 @@
"nodeTypeTraditional": "Traditional Panel 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.",
"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",
"agentTokenCreated": "Node {{name}} created successfully",
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!",