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
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:
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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": "افتراضي",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "پیشفرض",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "デフォルト",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "По умолчанию",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "Типово",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -943,7 +943,8 @@
|
|||||||
"agentTokenCopied": "安装命令已复制",
|
"agentTokenCopied": "安装命令已复制",
|
||||||
"agentTokenCopyFailed": "复制失败,请手动复制",
|
"agentTokenCopyFailed": "复制失败,请手动复制",
|
||||||
"copyToken": "复制安装命令",
|
"copyToken": "复制安装命令",
|
||||||
"agentMasterUrlHint": "主控地址(agent 用来连接主控,如有域名或反代请改成对外地址):"
|
"agentMasterUrlHint": "主控地址(agent 用来连接主控,如有域名或反代请改成对外地址):",
|
||||||
|
"agentMasterUrlRequired": "主控地址为空——请先填入主控地址,之后才会生成安装命令。"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"defaultTag": "默认",
|
"defaultTag": "默认",
|
||||||
|
|||||||
@@ -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": "預設",
|
||||||
|
|||||||
Reference in New Issue
Block a user