From 67a42b09baf66bec2a73c56545d2e329dbf333a1 Mon Sep 17 00:00:00 2001 From: Wu Qing <3184394176@qq.com> Date: Sat, 25 Apr 2026 13:43:30 +0800 Subject: [PATCH] fix: make agent install command proxy independent (#50) --- deploy/docker/nginx.conf | 19 ++++++ docs-site/docs/features/multi-node.md | 10 ++- .../current/features/multi-node.md | 10 ++- server/internal/http/install_flow_test.go | 21 ++++++ server/internal/http/install_handler.go | 24 ++++--- server/internal/http/node_handler.go | 21 ++++-- server/internal/installscript/issue46_test.go | 17 +++++ .../templates/agent-install.sh.tmpl | 23 ++++++- web/src/pages/nodes/AgentInstallWizard.tsx | 3 +- web/src/pages/nodes/installCommands.test.ts | 37 ++++++++++ web/src/pages/nodes/installCommands.ts | 67 +++++++++++++++++++ .../nodes/wizard/Step3CommandPreview.tsx | 12 ++-- web/src/types/nodes.ts | 3 + 13 files changed, 232 insertions(+), 35 deletions(-) create mode 100644 web/src/pages/nodes/installCommands.test.ts create mode 100644 web/src/pages/nodes/installCommands.ts diff --git a/deploy/docker/nginx.conf b/deploy/docker/nginx.conf index e13c2aa..ff4fb65 100644 --- a/deploy/docker/nginx.conf +++ b/deploy/docker/nginx.conf @@ -19,6 +19,25 @@ server { proxy_read_timeout 3600s; } + # Agent one-click install endpoints. + # Some external reverse proxies strip the /api prefix before reaching this + # container, so /install/ must be proxied here instead of falling through to + # the SPA index.html. + location /install/ { + proxy_pass http://127.0.0.1:8341/install/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_buffering off; + proxy_cache off; + } + + location = /health { proxy_pass http://127.0.0.1:8341/health; } + location = /ready { proxy_pass http://127.0.0.1:8341/ready; } + location = /metrics { proxy_pass http://127.0.0.1:8341/metrics; } + # SPA fallback location / { try_files $uri $uri/ /index.html; diff --git a/docs-site/docs/features/multi-node.md b/docs-site/docs/features/multi-node.md index 9d0acd3..32187a0 100644 --- a/docs-site/docs/features/multi-node.md +++ b/docs-site/docs/features/multi-node.md @@ -34,15 +34,11 @@ In the Web Console → **Node Management** → **Add Node**. You'll see a three- - **Step 1 — Node info.** Give the node a name, or switch to batch mode and paste multiple names (one per line, max 50). - **Step 2 — Deploy options.** Pick install mode (`systemd` recommended, `docker`, or `foreground` for debugging), architecture (auto-detect by default), agent version (defaults to the master's version), TTL for the install link (5 min / 15 min / 1 h / 24 h), and download source (`github` direct, or the `ghproxy` mirror for mainland China). -- **Step 3 — Copy the command.** A single `curl ... | sudo sh` line is shown with a live countdown. Click copy, paste into the target machine, and run with root privileges. +- **Step 3 — Copy the command.** A one-line install command is shown with a live countdown. Click copy, paste into the target machine, and run with root privileges. The default command embeds the rendered installer, so the target host does not need to fetch `/api/install/:token` through your reverse proxy. The public install URL is still available as a fallback. ### 2. One-line install on the target host -Example (systemd mode): - -```bash -curl -fsSL https://master.example.com/install/Xk3p9...vM | sudo sh -``` +Use the command generated by the Web Console. It writes the installer to a temporary file, validates the `BACKUPX_AGENT_INSTALL_V1` marker, then runs it with root privileges. The script runs automatically and: @@ -53,6 +49,8 @@ The script runs automatically and: 5. Runs `systemctl enable --now backupx-agent` 6. Polls `/api/v1/agent/self` until the master confirms `status: online` (up to 30 s) +If you choose the URL-based fallback command and `curl` prints HTML or the shell reports `Syntax error: newline unexpected`, the install URL is being served by the web console instead of the backend. Ensure either `/api/install/` or `/install/` is forwarded to the BackupX backend, or use the embedded command generated by the console. + Reruns are idempotent — to upgrade or re-provision, simply generate a new install command and run it again. The one-time install link expires after its TTL or after first consumption, whichever is sooner. ### 3. Rotate agent tokens at any time diff --git a/docs-site/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/multi-node.md b/docs-site/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/multi-node.md index 332797b..bfa3902 100644 --- a/docs-site/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/multi-node.md +++ b/docs-site/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/multi-node.md @@ -34,15 +34,11 @@ Web 控制台 → **节点管理** → **添加节点**,打开三步向导: - **第一步 · 节点信息**:填写节点名称;或切换"批量创建"粘贴多行名称(每行一个,最多 50 个) - **第二步 · 部署参数**:选择安装模式(`systemd` 推荐、`Docker`、`前台运行` 调试用)、架构(默认自动检测)、Agent 版本(默认跟随 Master 版本)、有效期(5 分钟 / 15 分钟 / 1 小时 / 24 小时)、下载源(`GitHub` 直连或 `ghproxy` 镜像,国内服务器建议后者) -- **第三步 · 安装命令**:一行 `curl ... | sudo sh` 命令 + 实时倒计时。点击复制,粘贴到目标机以 root 权限执行 +- **第三步 · 安装命令**:一条一键安装命令 + 实时倒计时。点击复制,粘贴到目标机以 root 权限执行。默认命令会嵌入已渲染的安装脚本,目标机无需再通过反向代理访问 `/api/install/:token`;公开安装 URL 仍作为备用路径保留。 ### 2. 目标机一条命令完成 -示例(systemd 模式): - -```bash -curl -fsSL https://master.example.com/install/Xk3p9...vM | sudo sh -``` +请直接使用 Web 控制台生成的命令。该命令会把安装脚本写入临时文件,校验 `BACKUPX_AGENT_INSTALL_V1` 魔数,再以 root 权限执行。 脚本会自动: @@ -53,6 +49,8 @@ curl -fsSL https://master.example.com/install/Xk3p9...vM | sudo sh 5. 执行 `systemctl enable --now backupx-agent` 6. 轮询 `/api/v1/agent/self`,直到 Master 确认 `status: online`(最多 30 秒) +如果使用 URL 备用命令时 `curl` 输出 HTML,或 shell 报 `Syntax error: newline unexpected`,说明安装 URL 被 Web 控制台接管而不是转发到后端。需要确保 `/api/install/` 或 `/install/` 至少一个路径能转发到 BackupX 后端,或改用控制台生成的嵌入式命令。 + 脚本是幂等的:升级或重装只需重新生成一条安装命令再跑一次。一次性安装链接在 TTL 到期或被首次消费后立即作废。 ### 3. 随时轮换 Agent Token diff --git a/server/internal/http/install_flow_test.go b/server/internal/http/install_flow_test.go index 67f7304..c14786a 100644 --- a/server/internal/http/install_flow_test.go +++ b/server/internal/http/install_flow_test.go @@ -3,6 +3,7 @@ package http import ( "bytes" "context" + "encoding/base64" "encoding/json" "net/http" "net/http/httptest" @@ -153,6 +154,8 @@ func TestOneClickInstallFlow(t *testing.T) { Data struct { InstallToken string `json:"installToken"` URL string `json:"url"` + FallbackURL string `json:"fallbackUrl"` + ScriptBase64 string `json:"scriptBase64"` } `json:"data"` } if err := json.Unmarshal(genRec.Body.Bytes(), &genResp); err != nil { @@ -161,6 +164,16 @@ func TestOneClickInstallFlow(t *testing.T) { if genResp.Data.InstallToken == "" { t.Fatalf("missing installToken") } + if !strings.Contains(genResp.Data.FallbackURL, "/install/") { + t.Fatalf("missing fallback install URL, got %q", genResp.Data.FallbackURL) + } + decodedScript, err := base64.StdEncoding.DecodeString(genResp.Data.ScriptBase64) + if err != nil { + t.Fatalf("scriptBase64 should be valid base64: %v", err) + } + if !strings.Contains(string(decodedScript), "BACKUPX_AGENT_INSTALL_V1") { + t.Fatalf("scriptBase64 should contain rendered install script") + } // 3. 公开端点消费 scriptReq := httptest.NewRequest(http.MethodGet, "/install/"+genResp.Data.InstallToken, nil) @@ -241,6 +254,8 @@ func TestInstallScriptAliasUnderAPI(t *testing.T) { Data struct { InstallToken string `json:"installToken"` URL string `json:"url"` + FallbackURL string `json:"fallbackUrl"` + ScriptBase64 string `json:"scriptBase64"` } `json:"data"` } _ = json.Unmarshal(genRec.Body.Bytes(), &genResp) @@ -249,6 +264,12 @@ func TestInstallScriptAliasUnderAPI(t *testing.T) { if !strings.Contains(genResp.Data.URL, "/api/install/") { t.Errorf("new install URL should use /api/install/ prefix, got %s", genResp.Data.URL) } + if !strings.Contains(genResp.Data.FallbackURL, "/install/") { + t.Errorf("fallback install URL should use /install/ prefix, got %s", genResp.Data.FallbackURL) + } + if genResp.Data.ScriptBase64 == "" { + t.Errorf("new install response should include scriptBase64 for proxy-independent commands") + } // 3. /api/install/:token 必须可消费(与 /install/:token 等价) aliasReq := httptest.NewRequest(http.MethodGet, "/api/install/"+genResp.Data.InstallToken, nil) diff --git a/server/internal/http/install_handler.go b/server/internal/http/install_handler.go index 3746576..9924d17 100644 --- a/server/internal/http/install_handler.go +++ b/server/internal/http/install_handler.go @@ -59,16 +59,7 @@ func (h *InstallHandler) Script(c *gin.Context) { return } h.recordConsumeAudit(c, consumed, "script") - script, err := installscript.RenderScript(installscript.Context{ - MasterURL: resolveMasterURL(c, h.externalURL), - AgentToken: consumed.Node.Token, - AgentVersion: consumed.Record.AgentVer, - Mode: consumed.Record.Mode, - Arch: consumed.Record.Arch, - DownloadBase: installscript.DownloadBaseFor(consumed.Record.DownloadSrc), - InstallPrefix: "/opt/backupx-agent", - NodeID: consumed.Node.ID, - }) + script, err := renderInstallScript(resolveMasterURL(c, h.externalURL), consumed.Node, consumed.Record) if err != nil { c.String(stdhttp.StatusInternalServerError, "render error\n") return @@ -141,6 +132,19 @@ func (h *InstallHandler) recordConsumeAudit(c *gin.Context, consumed *service.Co }) } +func renderInstallScript(masterURL string, node *model.Node, record *model.AgentInstallToken) (string, error) { + return installscript.RenderScript(installscript.Context{ + MasterURL: masterURL, + AgentToken: node.Token, + AgentVersion: record.AgentVer, + Mode: record.Mode, + Arch: record.Arch, + DownloadBase: installscript.DownloadBaseFor(record.DownloadSrc), + InstallPrefix: "/opt/backupx-agent", + NodeID: node.ID, + }) +} + // resolveMasterURL 按优先级推导 Master URL:外部配置 > X-Forwarded-* > Request.Host。 // 此为包级 helper,供 install_handler 和 node_handler 共用。 func resolveMasterURL(c *gin.Context, externalURL string) string { diff --git a/server/internal/http/node_handler.go b/server/internal/http/node_handler.go index 61c6d73..44ba878 100644 --- a/server/internal/http/node_handler.go +++ b/server/internal/http/node_handler.go @@ -1,6 +1,7 @@ package http import ( + "encoding/base64" "fmt" stdhttp "net/http" "strconv" @@ -262,16 +263,28 @@ func (h *NodeHandler) CreateInstallToken(c *gin.Context) { fmt.Sprintf("生成 %s/%s install token TTL=%ds", input.Mode, input.Arch, input.TTLSeconds)) masterURL := resolveMasterURL(c, h.externalURL) + script, err := renderInstallScript(masterURL, out.Node, out.Record) + if err != nil { + response.Error(c, err) + return + } // 使用 /api/install/... 而非 /install/... —— 让反向代理的 /api/ 转发规则 // 自动接管,避免 SPA fallback 把请求当成前端路由返回 index.html(issue #46)。 + // 同时返回 /install/... 备用地址,兼容会剥离 /api 前缀的外层反向代理。 + // scriptBase64 让前端可以生成不依赖公开下载路径的嵌入式命令,解决 Lucky 等代理 + // 把 /api/install/* 也 fallback 到 index.html 的场景。 body := gin.H{ - "installToken": out.Token, - "expiresAt": out.ExpiresAt, - "url": masterURL + "/api/install/" + out.Token, - "composeUrl": "", + "installToken": out.Token, + "expiresAt": out.ExpiresAt, + "url": masterURL + "/api/install/" + out.Token, + "fallbackUrl": masterURL + "/install/" + out.Token, + "scriptBase64": base64.StdEncoding.EncodeToString([]byte(script)), + "composeUrl": "", + "fallbackComposeUrl": "", } if input.Mode == "docker" { body["composeUrl"] = masterURL + "/api/install/" + out.Token + "/compose.yml" + body["fallbackComposeUrl"] = masterURL + "/install/" + out.Token + "/compose.yml" } response.Success(c, body) } diff --git a/server/internal/installscript/issue46_test.go b/server/internal/installscript/issue46_test.go index b775f62..14256cf 100644 --- a/server/internal/installscript/issue46_test.go +++ b/server/internal/installscript/issue46_test.go @@ -36,3 +36,20 @@ func TestRenderScriptBashBootstrap(t *testing.T) { t.Errorf("script missing exec bash fallback:\n%s", got) } } + +func TestRenderScriptCreatesBackupXUserAndGroup(t *testing.T) { + got, err := RenderScript(testCtx) + if err != nil { + t.Fatalf("render err: %v", err) + } + for _, want := range []string{ + "getent group backupx", + "groupadd --system backupx", + "useradd --system --gid backupx", + "Group=backupx", + } { + if !strings.Contains(got, want) { + t.Errorf("script missing %q:\n%s", want, got) + } + } +} diff --git a/server/internal/installscript/templates/agent-install.sh.tmpl b/server/internal/installscript/templates/agent-install.sh.tmpl index 788ccac..dcf032f 100644 --- a/server/internal/installscript/templates/agent-install.sh.tmpl +++ b/server/internal/installscript/templates/agent-install.sh.tmpl @@ -49,7 +49,27 @@ tar xzf "$TMPDIR/pkg.tar.gz" -C "$TMPDIR" # 4. 安装二进制 + 用户 echo "[2/4] 安装到 ${INSTALL_PREFIX}" -id backupx >/dev/null 2>&1 || useradd --system --home-dir "$INSTALL_PREFIX" --shell /usr/sbin/nologin backupx +if ! getent group backupx >/dev/null 2>&1; then + if command -v groupadd >/dev/null 2>&1; then + groupadd --system backupx + elif command -v addgroup >/dev/null 2>&1; then + addgroup --system backupx + else + echo "需要 groupadd 或 addgroup 来创建 backupx 组" >&2 + exit 1 + fi +fi +if ! id backupx >/dev/null 2>&1; then + if command -v useradd >/dev/null 2>&1; then + useradd --system --gid backupx --home-dir "$INSTALL_PREFIX" --shell /usr/sbin/nologin backupx + elif command -v adduser >/dev/null 2>&1; then + adduser --system --ingroup backupx --home "$INSTALL_PREFIX" --shell /usr/sbin/nologin backupx + else + echo "需要 useradd 或 adduser 来创建 backupx 用户" >&2 + exit 1 + fi +fi +id backupx >/dev/null 2>&1 || { echo "backupx 用户创建失败" >&2; exit 1; } install -d -o backupx -g backupx "$INSTALL_PREFIX" /var/lib/backupx-agent install -m 0755 "$TMPDIR/backupx-${AGENT_VERSION}-linux-${ARCH}/backupx" "$INSTALL_PREFIX/backupx" {{end}} @@ -66,6 +86,7 @@ Wants=network-online.target [Service] Type=simple User=backupx +Group=backupx Environment="BACKUPX_AGENT_MASTER=${MASTER_URL}" Environment="BACKUPX_AGENT_TOKEN=${AGENT_TOKEN}" ExecStart=${INSTALL_PREFIX}/backupx agent --temp-dir /var/lib/backupx-agent diff --git a/web/src/pages/nodes/AgentInstallWizard.tsx b/web/src/pages/nodes/AgentInstallWizard.tsx index ec34315..b418945 100644 --- a/web/src/pages/nodes/AgentInstallWizard.tsx +++ b/web/src/pages/nodes/AgentInstallWizard.tsx @@ -6,6 +6,7 @@ import { Step3CommandPreview } from './wizard/Step3CommandPreview' import { BatchCommandTable, type BatchCommandRow } from './BatchCommandTable' import { batchCreateNodes, createInstallToken } from '../../services/nodes' import type { InstallTokenResult } from '../../types/nodes' +import { buildAgentInstallCommand } from './installCommands' const Step = Steps.Step @@ -162,7 +163,7 @@ export function AgentInstallWizard({ visible, onClose, onSuccess, masterVersion, const rows: BatchCommandRow[] = tokens.map(({ c, tok }) => ({ nodeId: c.id, nodeName: c.name, - command: `curl -fsSL ${tok.url} | sudo bash`, + command: buildAgentInstallCommand(tok.url, tok.fallbackUrl, tok.scriptBase64), expiresAt: tok.expiresAt, })) if (mountedRef.current) setBatchRows(rows) diff --git a/web/src/pages/nodes/installCommands.test.ts b/web/src/pages/nodes/installCommands.test.ts new file mode 100644 index 0000000..25044af --- /dev/null +++ b/web/src/pages/nodes/installCommands.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { buildAgentDownloadCommand, buildAgentInstallCommand } from './installCommands' + +describe('install command builders', () => { + it('adds script marker validation and fallback install path', () => { + const cmd = buildAgentInstallCommand('https://master.example.com/api/install/abc') + + expect(cmd).toContain('BACKUPX_AGENT_INSTALL_V1') + expect(cmd).toContain("'https://master.example.com/api/install/abc'") + expect(cmd).toContain("'https://master.example.com/install/abc'") + expect(cmd).toContain('sh "$tmp"') + }) + + it('uses explicit fallback URL when provided', () => { + const cmd = buildAgentDownloadCommand( + 'https://master.example.com/api/install/abc', + 'https://master.example.com/install/abc', + ) + + expect(cmd).toContain('/tmp/bx-agent-install.sh') + expect(cmd).toContain("'https://master.example.com/install/abc'") + expect(cmd).toContain('non-script content') + }) + + it('prefers embedded script content when available', () => { + const cmd = buildAgentInstallCommand( + 'https://master.example.com/api/install/abc', + 'https://master.example.com/install/abc', + 'IyEvYmluL3NoCg==', + ) + + expect(cmd).toContain('base64 -d') + expect(cmd).toContain('base64 -D') + expect(cmd).toContain("'IyEvYmluL3NoCg=='") + expect(cmd).not.toContain('https://master.example.com/api/install/abc') + }) +}) diff --git a/web/src/pages/nodes/installCommands.ts b/web/src/pages/nodes/installCommands.ts new file mode 100644 index 0000000..b3a3064 --- /dev/null +++ b/web/src/pages/nodes/installCommands.ts @@ -0,0 +1,67 @@ +const INSTALL_MAGIC_MARKER = 'BACKUPX_AGENT_INSTALL_V1' + +function shellQuote(value: string) { + return `'${value.replace(/'/g, `'\\''`)}'` +} + +function legacyInstallUrl(url: string) { + return url.replace('/api/install/', '/install/') +} + +function runScriptCommand(path: string) { + return `if [ "$(id -u)" -eq 0 ]; then sh ${path}; else sudo sh ${path}; fi` +} + +export function buildAgentInstallCommand(url: string, fallbackUrl?: string, scriptBase64?: string) { + if (scriptBase64?.trim()) { + const marker = shellQuote(INSTALL_MAGIC_MARKER) + return [ + 'enc=$(mktemp)', + 'tmp=$(mktemp)', + `printf %s ${shellQuote(scriptBase64.trim())} > "$enc"`, + '(base64 -d < "$enc" > "$tmp" 2>/dev/null || base64 -D < "$enc" > "$tmp")', + `{ grep -q ${marker} "$tmp" || { echo 'BackupX embedded installer is invalid.' >&2; head -5 "$tmp" >&2; false; }; }`, + runScriptCommand('"$tmp"'), + ].join(' && ') + '; rc=$?; rm -f "$enc" "$tmp"; test $rc -eq 0' + } + + const primary = url.trim() + const fallback = (fallbackUrl || legacyInstallUrl(primary)).trim() + const urls = fallback && fallback !== primary ? [primary, fallback] : [primary] + const marker = shellQuote(INSTALL_MAGIC_MARKER) + const fetchScript = urls.length > 1 + ? `(curl -fsSL ${shellQuote(urls[0])} -o "$tmp" && grep -q ${marker} "$tmp" || curl -fsSL ${shellQuote(urls[1])} -o "$tmp")` + : `(curl -fsSL ${shellQuote(urls[0])} -o "$tmp" && grep -q ${marker} "$tmp")` + + return [ + 'tmp=$(mktemp)', + fetchScript, + `{ grep -q ${marker} "$tmp" || { echo 'BackupX install endpoint returned non-script content; check reverse proxy /api/install or /install forwarding.' >&2; head -5 "$tmp" >&2; false; }; }`, + runScriptCommand('"$tmp"'), + ].join(' && ') + '; rc=$?; rm -f "$tmp"; test $rc -eq 0' +} + +export function buildAgentDownloadCommand(url: string, fallbackUrl?: string, scriptBase64?: string) { + if (scriptBase64?.trim()) { + const marker = shellQuote(INSTALL_MAGIC_MARKER) + return [ + `printf %s ${shellQuote(scriptBase64.trim())} > /tmp/bx-agent-install.b64`, + '(base64 -d < /tmp/bx-agent-install.b64 > /tmp/bx-agent-install.sh 2>/dev/null || base64 -D < /tmp/bx-agent-install.b64 > /tmp/bx-agent-install.sh)', + `{ grep -q ${marker} /tmp/bx-agent-install.sh || { echo 'BackupX embedded installer is invalid.' >&2; head -5 /tmp/bx-agent-install.sh >&2; false; }; }`, + runScriptCommand('/tmp/bx-agent-install.sh'), + ].join(' && ') + } + + const primary = url.trim() + const fallback = (fallbackUrl || legacyInstallUrl(primary)).trim() + const marker = shellQuote(INSTALL_MAGIC_MARKER) + const fetchScript = fallback && fallback !== primary + ? `(curl -fsSL ${shellQuote(primary)} -o /tmp/bx-agent-install.sh && grep -q ${marker} /tmp/bx-agent-install.sh || curl -fsSL ${shellQuote(fallback)} -o /tmp/bx-agent-install.sh)` + : `(curl -fsSL ${shellQuote(primary)} -o /tmp/bx-agent-install.sh && grep -q ${marker} /tmp/bx-agent-install.sh)` + + return [ + fetchScript, + `{ grep -q ${marker} /tmp/bx-agent-install.sh || { echo 'BackupX install endpoint returned non-script content; check reverse proxy /api/install or /install forwarding.' >&2; head -5 /tmp/bx-agent-install.sh >&2; false; }; }`, + runScriptCommand('/tmp/bx-agent-install.sh'), + ].join(' && ') +} diff --git a/web/src/pages/nodes/wizard/Step3CommandPreview.tsx b/web/src/pages/nodes/wizard/Step3CommandPreview.tsx index bc92b3e..c7776e6 100644 --- a/web/src/pages/nodes/wizard/Step3CommandPreview.tsx +++ b/web/src/pages/nodes/wizard/Step3CommandPreview.tsx @@ -3,6 +3,7 @@ import { Typography, Button, Space, Collapse, Spin, Message, Tag } from '@arco-d import { IconCopy, IconRefresh } from '@arco-design/web-react/icon' import { fetchScriptPreview } from '../../../services/nodes' import type { InstallTokenResult, InstallMode } from '../../../types/nodes' +import { buildAgentDownloadCommand, buildAgentInstallCommand } from '../installCommands' const { Text } = Typography @@ -29,11 +30,8 @@ export function Step3CommandPreview({ nodeId, nodeName, token, mode, previewPara }, [token.expiresAt]) const expired = remaining === 0 - // 使用 bash 管道执行:避开 Debian/Ubuntu 默认 /bin/sh=dash 的差异, - // 同时让反向代理 / CDN 不再按 "sh" 的脚本类型做内容识别(issue #46)。 - const command = `curl -fsSL ${token.url} | sudo bash` - // 备用命令:若当前机器无 bash,或中间代理过滤了管道响应,可先落盘再执行。 - const fallbackCommand = `curl -fsSL ${token.url} -o /tmp/bx-agent-install.sh && sudo sh /tmp/bx-agent-install.sh` + const command = buildAgentInstallCommand(token.url, token.fallbackUrl, token.scriptBase64) + const fallbackCommand = buildAgentDownloadCommand(token.url, token.fallbackUrl, token.scriptBase64) const dockerComposeCmd = mode === 'docker' && token.composeUrl ? `curl -fsSL ${token.composeUrl} -o docker-compose.yml && docker-compose up -d` : null @@ -82,7 +80,7 @@ export function Step3CommandPreview({ nodeId, nodeName, token, mode, previewPara