Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7df3a338ef | ||
|
|
5ca22ec340 | ||
|
|
2d37a6de18 | ||
|
|
db6f666ff7 |
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 || '-',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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']),
|
||||
|
||||
+24
-4
@@ -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
|
||||
@@ -76,7 +81,7 @@ esac
|
||||
# sing-box 核心在 NexusGate 仓的专用发行里(见下方 SINGBOX_BASE)。
|
||||
# 注意:本脚本是 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}}"
|
||||
|
||||
ROOT="${XUI_ROOT:-}"
|
||||
@@ -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}}"
|
||||
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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -626,11 +626,16 @@ func probeStatIface(agentID int64) string {
|
||||
}
|
||||
|
||||
// probeReport ingests one fast report (V2.0.1): a probe metrics
|
||||
// block and nothing else. It deliberately does NOT touch the
|
||||
// agent's last-seen stamp — liveness stays the 60 s heartbeat's
|
||||
// signal, and the traffic ledger's unseen-boot rule keeps measuring
|
||||
// reboots against heartbeat sightings exactly as before. Agents on
|
||||
// older masters never see this endpoint (404) and simply keep
|
||||
// block and nothing else. Since V2.0.3 it also touches the agent's
|
||||
// last-seen stamp, like every other report channel: the hub's
|
||||
// themes gate each batch on the node's last_seen being newer than
|
||||
// the last one they applied, so a metrics-only channel that never
|
||||
// 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
|
||||
// either channel.
|
||||
func (a *AgentController) probeReport(c *gin.Context) {
|
||||
@@ -644,9 +649,13 @@ func (a *AgentController) probeReport(c *gin.Context) {
|
||||
ProbeMetrics *heartbeatProbeMetrics `json:"probeMetrics"`
|
||||
}
|
||||
_ = 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 {
|
||||
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)
|
||||
}
|
||||
@@ -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"})
|
||||
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
|
||||
}
|
||||
sbRaw, mErr := json.Marshal(sbCfg)
|
||||
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[:])
|
||||
}
|
||||
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
|
||||
|
||||
@@ -18,9 +18,10 @@ import (
|
||||
)
|
||||
|
||||
// V2.0.1 probe batch, master side: the fast-report endpoint ingests
|
||||
// metrics without touching liveness (last-seen stays the
|
||||
// heartbeat's), and the config poll carries the cadence + the
|
||||
// node's counted-interface pin.
|
||||
// metrics, and the config poll carries the cadence + the node's
|
||||
// counted-interface pin. Since V2.0.3 the fast report also stamps
|
||||
// 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) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
dbDir := t.TempDir()
|
||||
@@ -71,6 +72,12 @@ func TestAgentFastReportAndConfigKeys(t *testing.T) {
|
||||
|
||||
metrics := `{"probeMetrics":{"bootId":"boot-f/0123456789abcdef","uptime":60,` +
|
||||
`"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 {
|
||||
t.Fatalf("probe-report: %d (%s)", code, body)
|
||||
}
|
||||
@@ -78,8 +85,8 @@ func TestAgentFastReportAndConfigKeys(t *testing.T) {
|
||||
if row.ProbeMemUsed != 400 || row.ProbeAt == 0 {
|
||||
t.Errorf("fast report metrics not stored: %+v", row)
|
||||
}
|
||||
if row.LastSeen != seenAfterHeartbeat {
|
||||
t.Errorf("fast report moved last-seen: %d -> %d (liveness is the heartbeat's signal)", seenAfterHeartbeat, row.LastSeen)
|
||||
if row.LastSeen <= stale {
|
||||
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.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -29,9 +29,11 @@ import (
|
||||
// 150 s absorbs one lost beat plus jitter.
|
||||
const ProbeOnlineThresholdMs = 150_000
|
||||
|
||||
// ProbeSnapshotTTL mirrors the hub's 1900 ms frame reuse: just under
|
||||
// the 2 s push interval, so a frame is built at most once per tick.
|
||||
const ProbeSnapshotTTL = 1900 * time.Millisecond
|
||||
// ProbeSnapshotTTL mirrors the hub's frame reuse, shortened in
|
||||
// V2.0.3 from the hub's 1900 ms: just under the fastest (1 s) push
|
||||
// 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) ----
|
||||
|
||||
|
||||
@@ -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!",
|
||||
|
||||
@@ -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!",
|
||||
|
||||
@@ -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!",
|
||||
|
||||
@@ -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!",
|
||||
|
||||
@@ -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!",
|
||||
|
||||
@@ -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!",
|
||||
|
||||
@@ -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!",
|
||||
|
||||
@@ -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!",
|
||||
|
||||
@@ -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!",
|
||||
|
||||
@@ -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!",
|
||||
|
||||
@@ -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!",
|
||||
|
||||
@@ -933,6 +933,8 @@
|
||||
"nodeTypeTraditional": "传统面板节点",
|
||||
"nodeTypeAgent": "Agent 轻量节点",
|
||||
"agentHint": "Agent 节点由节点机主动连接主控,无需填写地址、端口和 API 令牌。创建后会生成安装命令。",
|
||||
"probeOnly": "纯探针",
|
||||
"probeOnlyHint": "纯探针模式:该节点不运行任何代理核心,只保留探针功能(心跳、指标与延迟探测)。",
|
||||
"agentTokenTitle": "Agent 节点安装",
|
||||
"agentTokenCreated": "节点 {{name}} 创建成功",
|
||||
"agentTokenHint": "复制下面的命令,在节点机上运行即可完成安装。此 token 只显示一次!",
|
||||
|
||||
@@ -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!",
|
||||
|
||||
Reference in New Issue
Block a user