merge: p93-agent-register (注册退避重试+脚本探测服务化+面板地址预填)
CI / go-test (push) Waiting to run
CI / postgres-durable-first (push) Waiting to run
CI / codegen (push) Waiting to run
CI / govulncheck (push) Waiting to run
CI / race (push) Waiting to run
CI / fuzz-smoke (push) Waiting to run
CI / golangci (push) Waiting to run
CI / frontend (push) Waiting to run
CodeQL Advanced / Analyze (go) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (amd64) (push) Waiting to run
Release 3X-UI / Publish rolling dev release (push) Blocked by required conditions

This commit is contained in:
Muse
2026-10-22 04:55:19 +00:00
19 changed files with 501 additions and 43 deletions
+24 -5
View File
@@ -20,6 +20,7 @@ import type { Msg } from '@/utils';
import { NodeFormSchema, type NodeFormValues, type ProbeResult } from '@/schemas/node'; import { NodeFormSchema, type NodeFormValues, type ProbeResult } from '@/schemas/node';
import { FormField, rhfZodValidate } from '@/components/form/rhf'; import { FormField, rhfZodValidate } from '@/components/form/rhf';
import { useOutboundTagGroups } from '@/api/queries/useOutboundTags'; import { useOutboundTagGroups } from '@/api/queries/useOutboundTags';
import { currentPanelMasterUrl } from './agentMasterUrl';
import './NodeFormModal.css'; import './NodeFormModal.css';
type Mode = 'add' | 'edit'; type Mode = 'add' | 'edit';
@@ -248,8 +249,10 @@ export default function NodeFormModal({
if (agentMode && mode === 'add' && obj?.agentToken) { if (agentMode && mode === 'add' && obj?.agentToken) {
setAgentToken(obj.agentToken); setAgentToken(obj.agentToken);
setAgentNodeName(result.data.name.trim()); setAgentNodeName(result.data.name.trim());
// Default master URL to the current panel origin; user can edit it. // Default the master URL to the address this panel is being
setAgentMasterUrl(window.location.origin); // 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. // Keep the modal open state; the token dialog shows on top.
} else { } else {
onOpenChange(false); onOpenChange(false);
@@ -264,6 +267,13 @@ export default function NodeFormModal({
if (!submitting) onOpenChange(false); 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 ( return (
<> <>
{messageContextHolder} {messageContextHolder}
@@ -560,9 +570,9 @@ export default function NodeFormModal({
<Button <Button
key="copy" key="copy"
type="primary" type="primary"
disabled={!installCmd}
onClick={() => { onClick={() => {
const cmd = `bash <(curl -fL https://git.881218.xyz/rogue/3x-ui-agent/raw/branch/main/install-agent.sh) --master ${agentMasterUrl} --token ${agentToken} --name ${agentNodeName}`; navigator.clipboard.writeText(installCmd).then(
navigator.clipboard.writeText(cmd).then(
() => messageApi.success(t('pages.nodes.agentTokenCopied')), () => messageApi.success(t('pages.nodes.agentTokenCopied')),
() => messageApi.error(t('pages.nodes.agentTokenCopyFailed')), () => messageApi.error(t('pages.nodes.agentTokenCopyFailed')),
); );
@@ -597,9 +607,18 @@ export default function NodeFormModal({
onChange={(e) => setAgentMasterUrl(e.target.value.trim())} onChange={(e) => setAgentMasterUrl(e.target.value.trim())}
style={{ marginBottom: 12, fontFamily: 'monospace' }} style={{ marginBottom: 12, fontFamily: 'monospace' }}
/> />
{!agentMasterUrl && (
<Alert
type="warning"
showIcon
style={{ marginBottom: 12 }}
title={t('pages.nodes.agentMasterUrlRequired')}
/>
)}
<p>{t('pages.nodes.agentTokenHint')}</p> <p>{t('pages.nodes.agentTokenHint')}</p>
<Input.TextArea <Input.TextArea
value={`bash <(curl -fL https://git.881218.xyz/rogue/3x-ui-agent/raw/branch/main/install-agent.sh) --master ${agentMasterUrl} --token ${agentToken} --name ${agentNodeName}`} value={installCmd}
placeholder={t('pages.nodes.agentMasterUrlRequired')}
readOnly readOnly
rows={4} rows={4}
style={{ fontFamily: 'monospace' }} style={{ fontFamily: 'monospace' }}
@@ -0,0 +1,20 @@
// Default master URL offered in the agent install command.
//
// The agent registers against {master_url}/panel/api/agent/register, and
// the panel itself is usually served under a base path (webBasePath, e.g.
// "/rogue"). Prefilling the bare origin — as the form used to — produced
// an install command whose registration 404'd, leaving the node stuck at
// "unknown" on the panel. The address the operator is viewing the panel
// through already contains the base path, so derive the default from it:
// origin + window.X_UI_BASE_PATH (injected by the panel server). The
// operator can still override it (e.g. a public address instead of a LAN
// or tunnel one).
export function computeDefaultMasterUrl(origin: string, basePath: string | undefined): string {
const trimmed = (basePath || '/').replace(/\/+$/, '');
return origin + trimmed;
}
export function currentPanelMasterUrl(): string {
if (typeof window === 'undefined') return '';
return computeDefaultMasterUrl(window.location.origin, window.X_UI_BASE_PATH);
}
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import { computeDefaultMasterUrl } from '@/pages/nodes/agentMasterUrl';
// The install command's --master must be the address agents can register
// against: origin + panel base path. Dropping the base path was the
// production bug — registration 404'd and the node stayed "unknown".
describe('computeDefaultMasterUrl', () => {
it('keeps a base path with trailing slash', () => {
expect(computeDefaultMasterUrl('https://amb.example.com', '/rogue/')).toBe(
'https://amb.example.com/rogue',
);
});
it('keeps a base path without trailing slash', () => {
expect(computeDefaultMasterUrl('https://amb.example.com', '/rogue')).toBe(
'https://amb.example.com/rogue',
);
});
it('root base path yields the bare origin', () => {
expect(computeDefaultMasterUrl('http://192.168.0.1:55889', '/')).toBe('http://192.168.0.1:55889');
});
it('undefined or empty base path yields the bare origin', () => {
expect(computeDefaultMasterUrl('http://10.0.0.2:2053', undefined)).toBe('http://10.0.0.2:2053');
expect(computeDefaultMasterUrl('http://10.0.0.2:2053', '')).toBe('http://10.0.0.2:2053');
});
it('nested base paths survive', () => {
expect(computeDefaultMasterUrl('https://example.com', '/a/b/')).toBe('https://example.com/a/b');
});
});
+184 -21
View File
@@ -2,11 +2,17 @@
# 3x-ui-agent 节点一键安装脚本 # 3x-ui-agent 节点一键安装脚本
# 用法: bash install-agent.sh --master http://MASTER_IP:2053 --token YOUR_AGENT_TOKEN [--name my-node] # 用法: bash install-agent.sh --master http://MASTER_IP:2053 --token YOUR_AGENT_TOKEN [--name my-node]
# 要求: Debian/Ubuntu/Alpine/CentOS (amd64/arm64), root 运行 # 要求: Debian/Ubuntu/Alpine/CentOS (amd64/arm64), root 运行
#
# 测试用环境变量(一般无需设置):
# XUI_ROOT 安装路径前缀(假根目录验证用),默认空 = 真实路径
# XUI_INIT_SYSTEM 强制 init 系统: systemd | openrc | none,默认 auto 探测
# XUI_URL agent 二进制下载地址覆盖
set -e 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"; } info() { echo -e "${GREEN}[INFO]${NC} $1"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; } error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; }
[[ $EUID -ne 0 ]] && error "请用 root 运行" [[ $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}}" 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" 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 "创建目录..." info "创建目录..."
mkdir -p /usr/local/x-ui/bin /etc/x-ui mkdir -p "${XUI_HOME}/bin" "${CONF_DIR}"
info "下载 agent 二进制..." info "下载 agent 二进制..."
curl -fL -o /usr/local/x-ui/x-ui "$XUI_URL" || error "agent 下载失败" curl -fL -o "${AGENT_BIN}" "$XUI_URL" || error "agent 下载失败"
chmod +x /usr/local/x-ui/x-ui chmod +x "${AGENT_BIN}"
info "下载 xray v${XRAY_VER}..." info "下载 xray v${XRAY_VER}..."
cd /usr/local/x-ui/bin cd "${XUI_HOME}/bin"
case "$BIN_ARCH" in case "$BIN_ARCH" in
amd64) XRAY_ZIP="Xray-linux-64.zip";; amd64) XRAY_ZIP="Xray-linux-64.zip";;
arm64) XRAY_ZIP="Xray-linux-arm64-v8a.zip";; arm64) XRAY_ZIP="Xray-linux-arm64-v8a.zip";;
@@ -55,29 +66,181 @@ rm -f "$XRAY_ZIP"
info "下载 geo 文件..." info "下载 geo 文件..."
curl -fL -o geoip.dat "https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat" || error "geoip 下载失败" 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 下载失败" curl -fL -o geosite.dat "https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat" || error "geosite 下载失败"
cd - >/dev/null
info "写入 agent 配置..." info "写入 agent 配置..."
cat > /etc/x-ui/agent.json <<EOF cat > "${CONF_DIR}/agent.json" <<EOF
{ {
"master_url": "$MASTER", "master_url": "$MASTER",
"token": "$TOKEN", "token": "$TOKEN",
"node_name": "$NAME" "node_name": "$NAME"
} }
EOF EOF
chmod 600 /etc/x-ui/agent.json chmod 600 "${CONF_DIR}/agent.json"
# 探测主控可达性:拿假 token 打一次注册接口,在启动前把地址问题拦下来,
# 别让 agent 带着必死的配置装完再无声无息地死掉(主控上一直显示"未知")。
# 404 → 注册路由不存在,主控地址大概率少了面板基础路径
# 连不上 → 网络/域名/TLS 问题
# 其他(401等) → 路由存在(假 token 被拒正是预期),路径正确
probe_master() {
local url="${MASTER%/}/panel/api/agent/register"
info "探测主控注册接口: $url"
local code
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 -X POST "$url" \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer install-probe-invalid-token' \
-d '{"token":"install-probe-invalid-token","name":"install-probe"}' 2>/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..." info "启动 agent..."
pkill -f "/usr/local/x-ui/x-ui agent" 2>/dev/null || true; sleep 1 case "$(detect_init_system)" in
nohup /usr/local/x-ui/x-ui agent > /tmp/agent.log 2>&1 & systemd) start_via_systemd;;
sleep 8 openrc) start_via_openrc;;
*) start_via_nohup;;
if grep -q "Starting 3x-ui agent" /tmp/agent.log 2>/dev/null; then esac
info "agent 启动成功!"
echo ""
echo " 主控: $MASTER"
echo " 节点名: $NAME"
echo " 日志: tail -f /tmp/agent.log"
echo " agent 会在 30 秒内从主控拉取配置"
else
error "启动失败,查看日志: cat /tmp/agent.log"
fi
+79 -4
View File
@@ -8,6 +8,7 @@ import (
"bytes" "bytes"
"crypto/tls" "crypto/tls"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "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 // Register registers the agent with the master
func (a *Agent) Register() error { func (a *Agent) Register() error {
url := fmt.Sprintf("%s/panel/api/agent/register", a.config.MasterURL) 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 { if resp.StatusCode != http.StatusOK {
data, _ := io.ReadAll(resp.Body) 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") logger.Info("Agent registered successfully with master")
return nil 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 // Run starts the agent main loop
func (a *Agent) Run() error { func (a *Agent) Run() error {
logger.Info("Starting 3x-ui agent...") logger.Info("Starting 3x-ui agent...")
logger.Infof("Master: %s, Node: %s", a.config.MasterURL, a.config.NodeName) logger.Infof("Master: %s, Node: %s", a.config.MasterURL, a.config.NodeName)
// Initial registration // Initial registration, retried with backoff: a node that cannot
if err := a.Register(); err != nil { // register yet (master down, URL just fixed, network still coming up)
return fmt.Errorf("initial registration failed: %w", err) // 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 // Start heartbeat ticker
+135
View File
@@ -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())
}
}
+2 -1
View File
@@ -921,7 +921,8 @@
"agentTokenCopied": "Install command copied", "agentTokenCopied": "Install command copied",
"agentTokenCopyFailed": "Copy failed, please copy manually", "agentTokenCopyFailed": "Copy failed, please copy manually",
"copyToken": "Copy Install Command", "copyToken": "Copy Install Command",
"agentMasterUrlHint": "Master URL (used by agent to reach the master; change to public address if behind proxy):" "agentMasterUrlHint": "Master URL (used by agent to reach the master; change to public address if behind proxy):",
"agentMasterUrlRequired": "Master URL is empty — enter the master address first; the install command is generated only then."
}, },
"settings": { "settings": {
"defaultTag": "افتراضي", "defaultTag": "افتراضي",
+2 -1
View File
@@ -1061,7 +1061,8 @@
"agentTokenCopied": "Install command copied", "agentTokenCopied": "Install command copied",
"agentTokenCopyFailed": "Copy failed, please copy manually", "agentTokenCopyFailed": "Copy failed, please copy manually",
"copyToken": "Copy Install Command", "copyToken": "Copy Install Command",
"agentMasterUrlHint": "Master URL (used by agent to reach the master; change to public address if behind proxy):" "agentMasterUrlHint": "Master URL (used by agent to reach the master; change to public address if behind proxy):",
"agentMasterUrlRequired": "Master URL is empty — enter the master address first; the install command is generated only then."
}, },
"settings": { "settings": {
"defaultTag": "Default", "defaultTag": "Default",
+2 -1
View File
@@ -921,7 +921,8 @@
"agentTokenCopied": "Install command copied", "agentTokenCopied": "Install command copied",
"agentTokenCopyFailed": "Copy failed, please copy manually", "agentTokenCopyFailed": "Copy failed, please copy manually",
"copyToken": "Copy Install Command", "copyToken": "Copy Install Command",
"agentMasterUrlHint": "Master URL (used by agent to reach the master; change to public address if behind proxy):" "agentMasterUrlHint": "Master URL (used by agent to reach the master; change to public address if behind proxy):",
"agentMasterUrlRequired": "Master URL is empty — enter the master address first; the install command is generated only then."
}, },
"settings": { "settings": {
"defaultTag": "Predeterminado", "defaultTag": "Predeterminado",
+2 -1
View File
@@ -921,7 +921,8 @@
"agentTokenCopied": "Install command copied", "agentTokenCopied": "Install command copied",
"agentTokenCopyFailed": "Copy failed, please copy manually", "agentTokenCopyFailed": "Copy failed, please copy manually",
"copyToken": "Copy Install Command", "copyToken": "Copy Install Command",
"agentMasterUrlHint": "Master URL (used by agent to reach the master; change to public address if behind proxy):" "agentMasterUrlHint": "Master URL (used by agent to reach the master; change to public address if behind proxy):",
"agentMasterUrlRequired": "Master URL is empty — enter the master address first; the install command is generated only then."
}, },
"settings": { "settings": {
"defaultTag": "پیش‌فرض", "defaultTag": "پیش‌فرض",
+2 -1
View File
@@ -921,7 +921,8 @@
"agentTokenCopied": "Install command copied", "agentTokenCopied": "Install command copied",
"agentTokenCopyFailed": "Copy failed, please copy manually", "agentTokenCopyFailed": "Copy failed, please copy manually",
"copyToken": "Copy Install Command", "copyToken": "Copy Install Command",
"agentMasterUrlHint": "Master URL (used by agent to reach the master; change to public address if behind proxy):" "agentMasterUrlHint": "Master URL (used by agent to reach the master; change to public address if behind proxy):",
"agentMasterUrlRequired": "Master URL is empty — enter the master address first; the install command is generated only then."
}, },
"settings": { "settings": {
"defaultTag": "Bawaan", "defaultTag": "Bawaan",
+2 -1
View File
@@ -921,7 +921,8 @@
"agentTokenCopied": "Install command copied", "agentTokenCopied": "Install command copied",
"agentTokenCopyFailed": "Copy failed, please copy manually", "agentTokenCopyFailed": "Copy failed, please copy manually",
"copyToken": "Copy Install Command", "copyToken": "Copy Install Command",
"agentMasterUrlHint": "Master URL (used by agent to reach the master; change to public address if behind proxy):" "agentMasterUrlHint": "Master URL (used by agent to reach the master; change to public address if behind proxy):",
"agentMasterUrlRequired": "Master URL is empty — enter the master address first; the install command is generated only then."
}, },
"settings": { "settings": {
"defaultTag": "デフォルト", "defaultTag": "デフォルト",
+2 -1
View File
@@ -921,7 +921,8 @@
"agentTokenCopied": "Install command copied", "agentTokenCopied": "Install command copied",
"agentTokenCopyFailed": "Copy failed, please copy manually", "agentTokenCopyFailed": "Copy failed, please copy manually",
"copyToken": "Copy Install Command", "copyToken": "Copy Install Command",
"agentMasterUrlHint": "Master URL (used by agent to reach the master; change to public address if behind proxy):" "agentMasterUrlHint": "Master URL (used by agent to reach the master; change to public address if behind proxy):",
"agentMasterUrlRequired": "Master URL is empty — enter the master address first; the install command is generated only then."
}, },
"settings": { "settings": {
"defaultTag": "Padrão", "defaultTag": "Padrão",
+2 -1
View File
@@ -921,7 +921,8 @@
"agentTokenCopied": "Install command copied", "agentTokenCopied": "Install command copied",
"agentTokenCopyFailed": "Copy failed, please copy manually", "agentTokenCopyFailed": "Copy failed, please copy manually",
"copyToken": "Copy Install Command", "copyToken": "Copy Install Command",
"agentMasterUrlHint": "Master URL (used by agent to reach the master; change to public address if behind proxy):" "agentMasterUrlHint": "Master URL (used by agent to reach the master; change to public address if behind proxy):",
"agentMasterUrlRequired": "Master URL is empty — enter the master address first; the install command is generated only then."
}, },
"settings": { "settings": {
"defaultTag": "По умолчанию", "defaultTag": "По умолчанию",
+2 -1
View File
@@ -921,7 +921,8 @@
"agentTokenCopied": "Install command copied", "agentTokenCopied": "Install command copied",
"agentTokenCopyFailed": "Copy failed, please copy manually", "agentTokenCopyFailed": "Copy failed, please copy manually",
"copyToken": "Copy Install Command", "copyToken": "Copy Install Command",
"agentMasterUrlHint": "Master URL (used by agent to reach the master; change to public address if behind proxy):" "agentMasterUrlHint": "Master URL (used by agent to reach the master; change to public address if behind proxy):",
"agentMasterUrlRequired": "Master URL is empty — enter the master address first; the install command is generated only then."
}, },
"settings": { "settings": {
"defaultTag": "Varsayılan", "defaultTag": "Varsayılan",
+2 -1
View File
@@ -921,7 +921,8 @@
"agentTokenCopied": "Install command copied", "agentTokenCopied": "Install command copied",
"agentTokenCopyFailed": "Copy failed, please copy manually", "agentTokenCopyFailed": "Copy failed, please copy manually",
"copyToken": "Copy Install Command", "copyToken": "Copy Install Command",
"agentMasterUrlHint": "Master URL (used by agent to reach the master; change to public address if behind proxy):" "agentMasterUrlHint": "Master URL (used by agent to reach the master; change to public address if behind proxy):",
"agentMasterUrlRequired": "Master URL is empty — enter the master address first; the install command is generated only then."
}, },
"settings": { "settings": {
"defaultTag": "Типово", "defaultTag": "Типово",
+2 -1
View File
@@ -921,7 +921,8 @@
"agentTokenCopied": "Install command copied", "agentTokenCopied": "Install command copied",
"agentTokenCopyFailed": "Copy failed, please copy manually", "agentTokenCopyFailed": "Copy failed, please copy manually",
"copyToken": "Copy Install Command", "copyToken": "Copy Install Command",
"agentMasterUrlHint": "Master URL (used by agent to reach the master; change to public address if behind proxy):" "agentMasterUrlHint": "Master URL (used by agent to reach the master; change to public address if behind proxy):",
"agentMasterUrlRequired": "Master URL is empty — enter the master address first; the install command is generated only then."
}, },
"settings": { "settings": {
"defaultTag": "Mặc định", "defaultTag": "Mặc định",
+2 -1
View File
@@ -943,7 +943,8 @@
"agentTokenCopied": "安装命令已复制", "agentTokenCopied": "安装命令已复制",
"agentTokenCopyFailed": "复制失败,请手动复制", "agentTokenCopyFailed": "复制失败,请手动复制",
"copyToken": "复制安装命令", "copyToken": "复制安装命令",
"agentMasterUrlHint": "主控地址(agent 用来连接主控,如有域名或反代请改成对外地址):" "agentMasterUrlHint": "主控地址(agent 用来连接主控,如有域名或反代请改成对外地址):",
"agentMasterUrlRequired": "主控地址为空——请先填入主控地址,之后才会生成安装命令。"
}, },
"settings": { "settings": {
"defaultTag": "默认", "defaultTag": "默认",
+2 -1
View File
@@ -921,7 +921,8 @@
"agentTokenCopied": "Install command copied", "agentTokenCopied": "Install command copied",
"agentTokenCopyFailed": "Copy failed, please copy manually", "agentTokenCopyFailed": "Copy failed, please copy manually",
"copyToken": "Copy Install Command", "copyToken": "Copy Install Command",
"agentMasterUrlHint": "Master URL (used by agent to reach the master; change to public address if behind proxy):" "agentMasterUrlHint": "Master URL (used by agent to reach the master; change to public address if behind proxy):",
"agentMasterUrlRequired": "Master URL is empty — enter the master address first; the install command is generated only then."
}, },
"settings": { "settings": {
"defaultTag": "預設", "defaultTag": "預設",