From 7d03749246a1db05613c8191c50c8e6775becea5 Mon Sep 17 00:00:00 2001 From: Muse Date: Wed, 21 Oct 2026 22:05:09 +0000 Subject: [PATCH 1/3] =?UTF-8?q?feat(agent):=20=E6=B3=A8=E5=86=8C=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E9=80=80=E9=81=BF=E9=87=8D=E8=AF=95=E4=B8=8D=E5=81=9C?= =?UTF-8?q?=E6=9C=BA,404=20=E6=97=B6=E6=97=A5=E5=BF=97=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E6=A3=80=E6=9F=A5=20master=5Furl=20=E5=9F=BA=E7=A1=80=E8=B7=AF?= =?UTF-8?q?=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/agent/agent.go | 83 +++++++++++++++- internal/agent/agent_register_test.go | 135 ++++++++++++++++++++++++++ 2 files changed, 214 insertions(+), 4 deletions(-) create mode 100644 internal/agent/agent_register_test.go diff --git a/internal/agent/agent.go b/internal/agent/agent.go index f8333e3..e296620 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -8,6 +8,7 @@ import ( "bytes" "crypto/tls" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -80,6 +81,18 @@ func New(cfg *Config) *Agent { } } +// statusError is a non-200 response from the master. It keeps the status +// code addressable so the registration retry loop can tell a wrong-path +// 404 (master_url missing the panel base path) apart from other failures. +type statusError struct { + code int + body string +} + +func (e *statusError) Error() string { + return fmt.Sprintf("register failed with status %d: %s", e.code, e.body) +} + // Register registers the agent with the master func (a *Agent) Register() error { url := fmt.Sprintf("%s/panel/api/agent/register", a.config.MasterURL) @@ -103,21 +116,83 @@ func (a *Agent) Register() error { if resp.StatusCode != http.StatusOK { data, _ := io.ReadAll(resp.Body) - return fmt.Errorf("register failed with status %d: %s", resp.StatusCode, string(data)) + return &statusError{code: resp.StatusCode, body: string(data)} } logger.Info("Agent registered successfully with master") return nil } +// registrationBackoff returns the delay before the next registration +// attempt: 5s, 15s, 30s, then capped at 60s. +func registrationBackoff(failures int) time.Duration { + switch { + case failures <= 1: + return 5 * time.Second + case failures == 2: + return 15 * time.Second + case failures == 3: + return 30 * time.Second + default: + return 60 * time.Second + } +} + +// registerWait sleeps between registration attempts, returning false when +// the agent is stopping. A package var so tests can drive the retry loop +// without real delays. +var registerWait = func(stopCh chan struct{}, d time.Duration) bool { + select { + case <-stopCh: + return false + case <-time.After(d): + return true + } +} + +// errRegistrationAborted marks a registration loop cut short by Stop. +var errRegistrationAborted = errors.New("registration aborted: agent stopping") + +// registerWithRetry keeps trying to register until the master accepts or +// the agent is stopped. A one-shot failure used to kill the agent +// outright — a typo'd master_url (classically: the panel base path +// missing, so the master answers 404) left the node dead and "unknown" +// on the panel with nothing retrying. Registration is idempotent on the +// master, so retrying with backoff is safe. +func (a *Agent) registerWithRetry() error { + failures := 0 + for { + err := a.Register() + if err == nil { + return nil + } + failures++ + var st *statusError + if errors.As(err, &st) && st.code == http.StatusNotFound { + logger.Warningf("主控返回 404:检查 master_url 是否包含面板基础路径(如 https://host/rogue),当前 master_url=%s", a.config.MasterURL) + } + delay := registrationBackoff(failures) + logger.Warningf("Registration attempt %d failed: %v; retrying in %s", failures, err, delay) + if !registerWait(a.stopCh, delay) { + return errRegistrationAborted + } + } +} + // Run starts the agent main loop func (a *Agent) Run() error { logger.Info("Starting 3x-ui agent...") logger.Infof("Master: %s, Node: %s", a.config.MasterURL, a.config.NodeName) - // Initial registration - if err := a.Register(); err != nil { - return fmt.Errorf("initial registration failed: %w", err) + // Initial registration, retried with backoff: a node that cannot + // register yet (master down, URL just fixed, network still coming up) + // must not die on the first failure. + if err := a.registerWithRetry(); err != nil { + if errors.Is(err, errRegistrationAborted) { + logger.Info("Agent stopping...") + return nil + } + return err } // Start heartbeat ticker diff --git a/internal/agent/agent_register_test.go b/internal/agent/agent_register_test.go new file mode 100644 index 0000000..84a6e8d --- /dev/null +++ b/internal/agent/agent_register_test.go @@ -0,0 +1,135 @@ +package agent + +import ( + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/mhsanaei/3x-ui/v3/internal/logger" +) + +// stubRegisterWait replaces the retry sleep with a recorder that never +// actually sleeps. allowWaits is how many waits return true (keep +// retrying) before returning false (agent stopping). +func stubRegisterWait(t *testing.T, allowWaits int) *[]time.Duration { + t.Helper() + delays := &[]time.Duration{} + orig := registerWait + registerWait = func(_ chan struct{}, d time.Duration) bool { + *delays = append(*delays, d) + return len(*delays) <= allowWaits + } + t.Cleanup(func() { registerWait = orig }) + return delays +} + +func captureLogs(t *testing.T) *[]string { + t.Helper() + var mu sync.Mutex + lines := &[]string{} + prev := logger.SetLogHook(func(_, msg string) { + mu.Lock() + *lines = append(*lines, msg) + mu.Unlock() + }) + t.Cleanup(func() { logger.SetLogHook(prev) }) + return lines +} + +// scriptedMaster answers register requests from a status-code script; +// once the script is exhausted it answers 200. +func scriptedMaster(t *testing.T, script []int, hits *int) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasSuffix(r.URL.Path, "/panel/api/agent/register") { + w.WriteHeader(http.StatusNotFound) + return + } + *hits++ + code := http.StatusOK + if *hits <= len(script) { + code = script[*hits-1] + } + w.WriteHeader(code) + fmt.Fprint(w, `{}`) + })) + t.Cleanup(srv.Close) + return srv +} + +func TestRegistrationBackoffSequence(t *testing.T) { + want := []time.Duration{5, 15, 30, 60, 60, 60} + for i, w := range want { + if got := registrationBackoff(i + 1); got != w*time.Second { + t.Errorf("registrationBackoff(%d) = %s, want %ss", i+1, got, w) + } + } +} + +func TestRegisterWithRetrySucceedsAfter404s(t *testing.T) { + hits := 0 + srv := scriptedMaster(t, []int{404, 404}, &hits) + delays := stubRegisterWait(t, 10) + logs := captureLogs(t) + + ag := New(&Config{MasterURL: srv.URL, Token: "tok", NodeName: "n1"}) + if err := ag.registerWithRetry(); err != nil { + t.Fatalf("registerWithRetry: %v", err) + } + if hits != 3 { + t.Fatalf("register hits = %d, want 3 (404, 404, 200)", hits) + } + if len(*delays) != 2 || (*delays)[0] != 5*time.Second || (*delays)[1] != 15*time.Second { + t.Fatalf("retry delays = %v, want [5s 15s]", *delays) + } + joined := strings.Join(*logs, "\n") + if !strings.Contains(joined, "检查 master_url 是否包含面板基础路径") { + t.Errorf("404 hint missing from logs:\n%s", joined) + } + if !strings.Contains(joined, "retrying in 5s") { + t.Errorf("retry log line missing:\n%s", joined) + } +} + +func TestRegisterWithRetryAbortsOnStop(t *testing.T) { + hits := 0 + srv := scriptedMaster(t, []int{500, 500, 500, 500, 500, 500, 500, 500}, &hits) + // First wait says "stopping": the loop must give up immediately + // instead of burning through the script. + delays := stubRegisterWait(t, 0) + + ag := New(&Config{MasterURL: srv.URL, Token: "tok", NodeName: "n1"}) + err := ag.registerWithRetry() + if !errors.Is(err, errRegistrationAborted) { + t.Fatalf("registerWithRetry err = %v, want errRegistrationAborted", err) + } + if hits != 1 { + t.Fatalf("register hits = %d, want 1 (no retry after stop)", hits) + } + if len(*delays) != 1 { + t.Fatalf("waits = %d, want 1", len(*delays)) + } +} + +func TestRegisterStatusErrorCarriesCode(t *testing.T) { + hits := 0 + srv := scriptedMaster(t, []int{404}, &hits) + ag := New(&Config{MasterURL: srv.URL, Token: "tok", NodeName: "n1"}) + err := ag.Register() + var st *statusError + if !errors.As(err, &st) { + t.Fatalf("Register err = %v (%T), want *statusError", err, err) + } + if st.code != http.StatusNotFound { + t.Fatalf("statusError.code = %d, want 404", st.code) + } + // Error text must stay the historical one — scripts may grep for it. + if !strings.Contains(err.Error(), "register failed with status 404") { + t.Fatalf("error text changed: %q", err.Error()) + } +} From dcfaf6ace6f283005fadc849e8c225a5449534a7 Mon Sep 17 00:00:00 2001 From: Muse Date: Wed, 21 Oct 2026 22:05:09 +0000 Subject: [PATCH 2/3] =?UTF-8?q?feat(install-agent):=20=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=E5=89=8D=E6=8E=A2=E6=B5=8B=E4=B8=BB=E6=8E=A7=E6=B3=A8=E5=86=8C?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3(404/=E8=BF=9E=E4=B8=8D=E4=B8=8A=E5=8D=B3?= =?UTF-8?q?=E4=B8=AD=E6=AD=A2),=E6=94=B9=E4=B8=BA=20systemd/OpenRC=20?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E5=8C=96=E5=AE=89=E8=A3=85=E5=B8=A6=E8=87=AA?= =?UTF-8?q?=E5=90=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- install-agent.sh | 205 ++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 184 insertions(+), 21 deletions(-) diff --git a/install-agent.sh b/install-agent.sh index cf834bb..e93dfd4 100755 --- a/install-agent.sh +++ b/install-agent.sh @@ -2,11 +2,17 @@ # 3x-ui-agent 节点一键安装脚本 # 用法: bash install-agent.sh --master http://MASTER_IP:2053 --token YOUR_AGENT_TOKEN [--name my-node] # 要求: Debian/Ubuntu/Alpine/CentOS (amd64/arm64), root 运行 +# +# 测试用环境变量(一般无需设置): +# XUI_ROOT 安装路径前缀(假根目录验证用),默认空 = 真实路径 +# XUI_INIT_SYSTEM 强制 init 系统: systemd | openrc | none,默认 auto 探测 +# XUI_URL agent 二进制下载地址覆盖 set -e -RED='\033[0;31m'; GREEN='\033[0;32m'; NC='\033[0m' +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[0;33m'; NC='\033[0m' info() { echo -e "${GREEN}[INFO]${NC} $1"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; } [[ $EUID -ne 0 ]] && error "请用 root 运行" @@ -35,15 +41,20 @@ esac XUI_URL="${XUI_URL:-https://git.881218.xyz/rogue/3x-ui-agent/releases/download/v3.7.0-agent.1/x-ui-linux-${BIN_ARCH}}" XRAY_VER="26.6.27" +ROOT="${XUI_ROOT:-}" +XUI_HOME="${ROOT}/usr/local/x-ui" +CONF_DIR="${ROOT}/etc/x-ui" +AGENT_BIN="${XUI_HOME}/x-ui" + info "创建目录..." -mkdir -p /usr/local/x-ui/bin /etc/x-ui +mkdir -p "${XUI_HOME}/bin" "${CONF_DIR}" info "下载 agent 二进制..." -curl -fL -o /usr/local/x-ui/x-ui "$XUI_URL" || error "agent 下载失败" -chmod +x /usr/local/x-ui/x-ui +curl -fL -o "${AGENT_BIN}" "$XUI_URL" || error "agent 下载失败" +chmod +x "${AGENT_BIN}" info "下载 xray v${XRAY_VER}..." -cd /usr/local/x-ui/bin +cd "${XUI_HOME}/bin" case "$BIN_ARCH" in amd64) XRAY_ZIP="Xray-linux-64.zip";; arm64) XRAY_ZIP="Xray-linux-arm64-v8a.zip";; @@ -55,29 +66,181 @@ rm -f "$XRAY_ZIP" info "下载 geo 文件..." curl -fL -o geoip.dat "https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat" || error "geoip 下载失败" curl -fL -o geosite.dat "https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat" || error "geosite 下载失败" +cd - >/dev/null info "写入 agent 配置..." -cat > /etc/x-ui/agent.json < "${CONF_DIR}/agent.json" </dev/null) || code="000" + case "$code" in + 404) + echo "" + echo -e "${RED}============================================================${NC}" + echo -e "${RED} 主控返回 404:注册接口不存在,安装中止。${NC}" + echo -e "${RED} 主控地址大概率少了面板基础路径(如 /rogue)。${NC}" + echo -e "${RED} 当前地址: ${MASTER}${NC}" + echo -e "${RED} 若面板实际地址带基础路径,请改用 --master ${MASTER%/}/rogue${NC}" + echo -e "${RED} 之类的完整地址(以面板真实基础路径为准)改对后重跑本脚本。${NC}" + echo -e "${RED}============================================================${NC}" + exit 1 + ;; + 000) + echo "" + echo -e "${RED}============================================================${NC}" + echo -e "${RED} 连不上主控,安装中止。${NC}" + echo -e "${RED} 请检查:网络是否通、域名解析是否正常、主控地址与端口是否写对、${NC}" + echo -e "${RED} 以及主控 HTTPS 证书是否有效(agent 不接受自签证书)。${NC}" + echo -e "${RED} 目标: $url${NC}" + echo -e "${RED}============================================================${NC}" + exit 1 + ;; + *) + info "主控探测通过 (HTTP $code,注册路由存在)" + ;; + esac +} + +probe_master + +detect_init_system() { + case "${XUI_INIT_SYSTEM:-auto}" in + systemd|openrc|none) echo "${XUI_INIT_SYSTEM}"; return;; + esac + if [ -d /run/systemd/system ] && command -v systemctl >/dev/null 2>&1; then + echo systemd; return + fi + if command -v rc-update >/dev/null 2>&1 && command -v rc-service >/dev/null 2>&1; then + echo openrc; return + fi + echo none +} + +# 以 systemd 服务方式安装并启动(开机自启) +start_via_systemd() { + local unit_dir="${ROOT}/etc/systemd/system" + mkdir -p "$unit_dir" + cat > "${unit_dir}/x-ui-agent.service" <<'EOF' +[Unit] +Description=3x-ui agent +After=network.target + +[Service] +ExecStart=/usr/local/x-ui/x-ui agent +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target +EOF + info "已生成 systemd 服务文件: ${unit_dir}/x-ui-agent.service" + if [ -n "$ROOT" ]; then + info "(XUI_ROOT 模式:跳过 systemctl 实际操作)" + return 0 + fi + systemctl stop x-ui-agent 2>/dev/null || true + pkill -f "${AGENT_BIN} agent" 2>/dev/null || true; sleep 1 + systemctl daemon-reload + systemctl enable x-ui-agent + systemctl restart x-ui-agent + sleep 2 + if systemctl is-active --quiet x-ui-agent; then + info "agent 已由 systemd 托管启动(开机自启)" + echo "" + echo " 主控: $MASTER" + echo " 节点名: $NAME" + echo " 日志: journalctl -u x-ui-agent -f" + echo " agent 会在 30 秒内从主控拉取配置" + else + error "agent 服务启动失败,查看: systemctl status x-ui-agent" + fi +} + +# 以 OpenRC 服务方式安装并启动(开机自启,Alpine 等) +start_via_openrc() { + local init_dir="${ROOT}/etc/init.d" + mkdir -p "$init_dir" + cat > "${init_dir}/x-ui-agent" <<'EOF' +#!/sbin/openrc-run + +name="x-ui-agent" +description="3x-ui lightweight node agent" +command="/usr/local/x-ui/x-ui" +command_args="agent" +command_background="yes" +pidfile="/run/x-ui-agent.pid" +output_log="/var/log/x-ui-agent.log" +error_log="/var/log/x-ui-agent.log" + +depend() { + need net +} +EOF + chmod +x "${init_dir}/x-ui-agent" + info "已生成 OpenRC 服务文件: ${init_dir}/x-ui-agent" + if [ -n "$ROOT" ]; then + info "(XUI_ROOT 模式:跳过 rc-update/rc-service 实际操作)" + return 0 + fi + rc-service x-ui-agent stop 2>/dev/null || true + pkill -f "${AGENT_BIN} agent" 2>/dev/null || true; sleep 1 + rc-update add x-ui-agent default + rc-service x-ui-agent restart + sleep 2 + if rc-service x-ui-agent status >/dev/null 2>&1; then + info "agent 已由 OpenRC 托管启动(开机自启)" + echo "" + echo " 主控: $MASTER" + echo " 节点名: $NAME" + echo " 日志: tail -f /var/log/x-ui-agent.log" + echo " agent 会在 30 秒内从主控拉取配置" + else + error "agent 服务启动失败,查看: rc-service x-ui-agent status" + fi +} + +# 无服务管理器时回落 nohup(无开机自启,明确警告) +start_via_nohup() { + warn "未检测到 systemd / OpenRC,退回 nohup 方式启动" + warn "注意:nohup 方式没有开机自启,机器重启后 agent 不会自动恢复,需手动重跑本脚本!" + pkill -f "${AGENT_BIN} agent" 2>/dev/null || true; sleep 1 + # -config 显式给出(生产环境即其默认路径 /etc/x-ui/agent.json,行为不变), + # 这样 XUI_ROOT 假根验证时 agent 也能找到刚写下的配置。 + nohup "${AGENT_BIN}" agent -config "${CONF_DIR}/agent.json" > /tmp/agent.log 2>&1 & + sleep 8 + if grep -q "Starting 3x-ui agent" /tmp/agent.log 2>/dev/null; then + info "agent 启动成功!" + echo "" + echo " 主控: $MASTER" + echo " 节点名: $NAME" + echo " 日志: tail -f /tmp/agent.log" + echo " agent 会在 30 秒内从主控拉取配置" + else + error "启动失败,查看日志: cat /tmp/agent.log" + fi +} info "启动 agent..." -pkill -f "/usr/local/x-ui/x-ui agent" 2>/dev/null || true; sleep 1 -nohup /usr/local/x-ui/x-ui agent > /tmp/agent.log 2>&1 & -sleep 8 - -if grep -q "Starting 3x-ui agent" /tmp/agent.log 2>/dev/null; then - info "agent 启动成功!" - echo "" - echo " 主控: $MASTER" - echo " 节点名: $NAME" - echo " 日志: tail -f /tmp/agent.log" - echo " agent 会在 30 秒内从主控拉取配置" -else - error "启动失败,查看日志: cat /tmp/agent.log" -fi +case "$(detect_init_system)" in + systemd) start_via_systemd;; + openrc) start_via_openrc;; + *) start_via_nohup;; +esac From 0ed6d8a1cf5eaa0d4bee0d627f99f69f9e4c3ea4 Mon Sep 17 00:00:00 2001 From: Muse Date: Wed, 21 Oct 2026 22:05:09 +0000 Subject: [PATCH 3/3] =?UTF-8?q?fix(nodes):=20agent=20=E4=B8=BB=E6=8E=A7?= =?UTF-8?q?=E5=9C=B0=E5=9D=80=E9=A2=84=E5=A1=AB=20origin+=E5=9F=BA?= =?UTF-8?q?=E7=A1=80=E8=B7=AF=E5=BE=84,=E5=9C=B0=E5=9D=80=E4=B8=BA?= =?UTF-8?q?=E7=A9=BA=E6=97=B6=E4=B8=8D=E7=94=9F=E6=88=90=E5=AE=89=E8=A3=85?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=E5=B9=B6=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/nodes/NodeFormModal.tsx | 29 +++++++++++++++---- frontend/src/pages/nodes/agentMasterUrl.ts | 20 +++++++++++++ frontend/src/test/agent-master-url.test.ts | 33 ++++++++++++++++++++++ internal/web/translation/ar-EG.json | 3 +- internal/web/translation/en-US.json | 3 +- internal/web/translation/es-ES.json | 3 +- internal/web/translation/fa-IR.json | 3 +- internal/web/translation/id-ID.json | 3 +- internal/web/translation/ja-JP.json | 3 +- internal/web/translation/pt-BR.json | 3 +- internal/web/translation/ru-RU.json | 3 +- internal/web/translation/tr-TR.json | 3 +- internal/web/translation/uk-UA.json | 3 +- internal/web/translation/vi-VN.json | 3 +- internal/web/translation/zh-CN.json | 3 +- internal/web/translation/zh-TW.json | 3 +- 16 files changed, 103 insertions(+), 18 deletions(-) create mode 100644 frontend/src/pages/nodes/agentMasterUrl.ts create mode 100644 frontend/src/test/agent-master-url.test.ts diff --git a/frontend/src/pages/nodes/NodeFormModal.tsx b/frontend/src/pages/nodes/NodeFormModal.tsx index 0bb9d73..5aefab0 100644 --- a/frontend/src/pages/nodes/NodeFormModal.tsx +++ b/frontend/src/pages/nodes/NodeFormModal.tsx @@ -20,6 +20,7 @@ import type { Msg } from '@/utils'; import { NodeFormSchema, type NodeFormValues, type ProbeResult } from '@/schemas/node'; import { FormField, rhfZodValidate } from '@/components/form/rhf'; import { useOutboundTagGroups } from '@/api/queries/useOutboundTags'; +import { currentPanelMasterUrl } from './agentMasterUrl'; import './NodeFormModal.css'; type Mode = 'add' | 'edit'; @@ -248,8 +249,10 @@ export default function NodeFormModal({ if (agentMode && mode === 'add' && obj?.agentToken) { setAgentToken(obj.agentToken); setAgentNodeName(result.data.name.trim()); - // Default master URL to the current panel origin; user can edit it. - setAgentMasterUrl(window.location.origin); + // 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. + setAgentMasterUrl(currentPanelMasterUrl()); // Keep the modal open state; the token dialog shows on top. } else { onOpenChange(false); @@ -264,6 +267,13 @@ export default function NodeFormModal({ if (!submitting) onOpenChange(false); } + // The install command is only meaningful with a master URL; an empty + // 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}` + : ''; + return ( <> {messageContextHolder} @@ -560,9 +570,9 @@ export default function NodeFormModal({