From e2baa6bd175f533708e5147904890f59f0860141 Mon Sep 17 00:00:00 2001 From: Wu Qing <3184394176@qq.com> Date: Mon, 20 Apr 2026 23:35:39 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20#46=20Agent=20=E4=B8=80?= =?UTF-8?q?=E9=94=AE=E5=AE=89=E8=A3=85=E8=84=9A=E6=9C=AC=E5=9C=A8=20Debian?= =?UTF-8?q?=20dash=20=E4=B8=8B=E6=89=A7=E8=A1=8C=E5=A4=B1=E8=B4=A5=20(#48)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 修复: #46 Agent 一键安装脚本在 Debian dash 下执行失败 根因(多因素,任何一个都可能导致用户复现的 "sh: 2: Syntax error: newline unexpected"): - Debian/Ubuntu 默认 /bin/sh → dash;pipe 方式下 shebang 被忽略 - Content-Type: text/x-shellscript 会触发部分 CDN/反向代理的脚本识别与改写 - 如果响应被改写为 HTML,sh 在第 2 行()即报此语法错误 修复: 1. 前端命令改为 `curl -fsSL URL | sudo bash`(避开 dash) 2. 命令面板增加"先下载再执行"备用命令(代理过滤场景兜底) 3. install handler Content-Type 改为 text/plain;加 nosniff / no-store / Content-Disposition 三头,减少中间层改写的概率 4. 脚本模板加 magic marker `BACKUPX_AGENT_INSTALL_V1`,用户可通过 `head -3` 自查响应完整性;加 bash 自举段,文件执行时优先切到 bash 测试: - installscript/issue46_test.go 断言 magic + bash-bootstrap 存在于三种模式 - install_flow_test.go 断言新 headers 与 marker - go test ./... 全绿,前端 build 通过 * 修复: #46 用户截图证实 nginx SPA fallback 返回 index.html 用户反馈截图显示 curl 下载到的是 BackupX 前端 HTML,而非 shell 脚本—— 说明 /install/:token 未被反向代理转发到后端,nginx 按 try_files fallback 到 /index.html,sh 读第 2 行 报语法错误。 真正的根因修复: 1. 后端 install 端点额外暴露 /api/install/:token 别名,让反向代理 已有的 /api/ 转发规则自动接管 2. 节点创建时返回的 url/composeUrl 统一使用 /api/install/ 前缀 3. 更新 deploy/nginx.conf 模板: - 新增 location /install/ 转发(兼容旧版本生成的命令) - 新增 /health /ready /metrics 单独转发,避免 SPA fallback 测试: - install_flow_test.go 新增 TestInstallScriptAliasUnderAPI 断言 /api/install/:token 路径可用 + 新生成的 url 用 /api/install/ 前缀 --- deploy/nginx.conf | 16 ++++ server/internal/http/install_flow_test.go | 84 +++++++++++++++++++ server/internal/http/install_handler.go | 12 ++- server/internal/http/node_handler.go | 6 +- server/internal/http/router.go | 10 ++- server/internal/installscript/issue46_test.go | 38 +++++++++ .../templates/agent-install.sh.tmpl | 8 ++ web/src/pages/nodes/AgentInstallWizard.tsx | 2 +- .../nodes/wizard/Step3CommandPreview.tsx | 21 ++++- 9 files changed, 191 insertions(+), 6 deletions(-) create mode 100644 server/internal/installscript/issue46_test.go diff --git a/deploy/nginx.conf b/deploy/nginx.conf index d709743..55c62dc 100644 --- a/deploy/nginx.conf +++ b/deploy/nginx.conf @@ -18,6 +18,22 @@ server { proxy_read_timeout 3600s; } + # Agent 一键安装脚本路径(兼容 v2.0 及之前生成的命令)。 + # v2.1+ 新生成的命令走 /api/install/... 自动命中上面的 /api/ 代理。 + location /install/ { + proxy_pass http://127.0.0.1:8340/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; + } + + # 健康检查端点同样不走 SPA fallback。 + location = /health { proxy_pass http://127.0.0.1:8340/health; } + location = /ready { proxy_pass http://127.0.0.1:8340/ready; } + location = /metrics { proxy_pass http://127.0.0.1:8340/metrics; } + location / { try_files $uri $uri/ /index.html; } diff --git a/server/internal/http/install_flow_test.go b/server/internal/http/install_flow_test.go index 37782ca..67f7304 100644 --- a/server/internal/http/install_flow_test.go +++ b/server/internal/http/install_flow_test.go @@ -7,6 +7,7 @@ import ( "net/http" "net/http/httptest" "path/filepath" + "strconv" "strings" "testing" "time" @@ -171,6 +172,22 @@ func TestOneClickInstallFlow(t *testing.T) { if !strings.Contains(scriptRec.Body.String(), "systemctl enable --now backupx-agent") { t.Fatalf("script missing systemctl enable:\n%s", scriptRec.Body.String()) } + // Issue #46 防嗅探 headers:text/plain + nosniff + no-store + Content-Disposition + if ct := scriptRec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") { + t.Errorf("script Content-Type should be text/plain*, got %q", ct) + } + if nosniff := scriptRec.Header().Get("X-Content-Type-Options"); nosniff != "nosniff" { + t.Errorf("missing X-Content-Type-Options: nosniff (got %q)", nosniff) + } + if cc := scriptRec.Header().Get("Cache-Control"); !strings.Contains(cc, "no-store") { + t.Errorf("missing Cache-Control: no-store (got %q)", cc) + } + if cd := scriptRec.Header().Get("Content-Disposition"); !strings.Contains(cd, "backupx-agent-install.sh") { + t.Errorf("Content-Disposition should name the script file (got %q)", cd) + } + if !strings.Contains(scriptRec.Body.String(), "BACKUPX_AGENT_INSTALL_V1") { + t.Errorf("script missing magic marker BACKUPX_AGENT_INSTALL_V1") + } // 4. 再次消费应 410 scriptReq2 := httptest.NewRequest(http.MethodGet, "/install/"+genResp.Data.InstallToken, nil) @@ -181,6 +198,73 @@ func TestOneClickInstallFlow(t *testing.T) { } } +// TestInstallScriptAliasUnderAPI 验证 /api/install/:token 别名路径可用, +// 这是 Issue #46 的根本修复:让 install 端点自动命中反向代理的 /api/ 转发规则, +// 避免 nginx SPA fallback 把请求当前端路由返回 index.html。 +func TestInstallScriptAliasUnderAPI(t *testing.T) { + router, token := setupInstallFlowRouter(t) + + // 1. 创建一个节点,生成 install token + batchBody, _ := json.Marshal(map[string][]string{"names": {"alias-node"}}) + batchReq := httptest.NewRequest(http.MethodPost, "/api/nodes/batch", bytes.NewReader(batchBody)) + batchReq.Header.Set("Content-Type", "application/json") + batchReq.Header.Set("Authorization", "Bearer "+token) + batchRec := httptest.NewRecorder() + router.ServeHTTP(batchRec, batchReq) + if batchRec.Code != 200 { + t.Fatalf("batch create failed: %d %s", batchRec.Code, batchRec.Body.String()) + } + var batchResp struct { + Data []struct { + ID uint `json:"id"` + } `json:"data"` + } + _ = json.Unmarshal(batchRec.Body.Bytes(), &batchResp) + if len(batchResp.Data) == 0 { + t.Fatalf("batch create returned no nodes: %s", batchRec.Body.String()) + } + nodeID := batchResp.Data[0].ID + + genBody, _ := json.Marshal(map[string]any{ + "mode": "systemd", "arch": "auto", "agentVersion": "v1.7.0", "downloadSrc": "github", "ttlSeconds": 600, + }) + genReq := httptest.NewRequest(http.MethodPost, + "/api/nodes/"+strconv.FormatUint(uint64(nodeID), 10)+"/install-tokens", bytes.NewReader(genBody)) + genReq.Header.Set("Content-Type", "application/json") + genReq.Header.Set("Authorization", "Bearer "+token) + genRec := httptest.NewRecorder() + router.ServeHTTP(genRec, genReq) + if genRec.Code != 200 { + t.Fatalf("gen install token failed: %d %s", genRec.Code, genRec.Body.String()) + } + var genResp struct { + Data struct { + InstallToken string `json:"installToken"` + URL string `json:"url"` + } `json:"data"` + } + _ = json.Unmarshal(genRec.Body.Bytes(), &genResp) + + // 2. 新生成的 url 应指向 /api/install/... —— 让反向代理的 /api/ 转发规则自动接管 + if !strings.Contains(genResp.Data.URL, "/api/install/") { + t.Errorf("new install URL should use /api/install/ prefix, got %s", genResp.Data.URL) + } + + // 3. /api/install/:token 必须可消费(与 /install/:token 等价) + aliasReq := httptest.NewRequest(http.MethodGet, "/api/install/"+genResp.Data.InstallToken, nil) + aliasRec := httptest.NewRecorder() + router.ServeHTTP(aliasRec, aliasReq) + if aliasRec.Code != 200 { + t.Fatalf("/api/install alias failed: %d %s", aliasRec.Code, aliasRec.Body.String()) + } + if !strings.Contains(aliasRec.Body.String(), "systemctl enable --now backupx-agent") { + t.Errorf("alias should return rendered script, got:\n%s", aliasRec.Body.String()) + } + if ct := aliasRec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") { + t.Errorf("alias Content-Type should be text/plain*, got %q", ct) + } +} + func TestInstallTokenRateLimit(t *testing.T) { router, jwt := setupInstallFlowRouter(t) diff --git a/server/internal/http/install_handler.go b/server/internal/http/install_handler.go index 2eb6b92..3746576 100644 --- a/server/internal/http/install_handler.go +++ b/server/internal/http/install_handler.go @@ -36,6 +36,13 @@ func NewInstallHandler(gcCtx context.Context, tokenService *service.InstallToken } // Script 消费 install token 并返回 shell 脚本;Mode 由 token 存储决定(systemd/docker/foreground 均返回 shell)。 +// +// 响应头策略(issue #46 教训): +// - Content-Type 用 text/plain 而非 text/x-shellscript:避免 Cloudflare/反向代理把 +// 脚本内容按特殊类型识别并触发 minify/HTML rewrite,导致 `curl | sh` 收到非脚本内容 +// - X-Content-Type-Options: nosniff:禁止浏览器/中间层按内容嗅探改写 MIME +// - Cache-Control: no-store:token 一次性消费,禁止任何缓存层留存旧脚本 +// - Content-Disposition: inline; filename=...:部分代理会跳过带文件名的响应 func (h *InstallHandler) Script(c *gin.Context) { if !h.limiter.allow(c.ClientIP()) { c.String(stdhttp.StatusTooManyRequests, "请求过于频繁,请稍后再试\n") @@ -66,7 +73,10 @@ func (h *InstallHandler) Script(c *gin.Context) { c.String(stdhttp.StatusInternalServerError, "render error\n") return } - c.Data(stdhttp.StatusOK, "text/x-shellscript; charset=utf-8", []byte(script)) + c.Header("X-Content-Type-Options", "nosniff") + c.Header("Cache-Control", "no-store") + c.Header("Content-Disposition", `inline; filename="backupx-agent-install.sh"`) + c.Data(stdhttp.StatusOK, "text/plain; charset=utf-8", []byte(script)) } // Compose 消费 install token 并返回 docker-compose YAML,仅 Mode=docker 有效。 diff --git a/server/internal/http/node_handler.go b/server/internal/http/node_handler.go index cfc28e0..61c6d73 100644 --- a/server/internal/http/node_handler.go +++ b/server/internal/http/node_handler.go @@ -262,14 +262,16 @@ 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) + // 使用 /api/install/... 而非 /install/... —— 让反向代理的 /api/ 转发规则 + // 自动接管,避免 SPA fallback 把请求当成前端路由返回 index.html(issue #46)。 body := gin.H{ "installToken": out.Token, "expiresAt": out.ExpiresAt, - "url": masterURL + "/install/" + out.Token, + "url": masterURL + "/api/install/" + out.Token, "composeUrl": "", } if input.Mode == "docker" { - body["composeUrl"] = masterURL + "/install/" + out.Token + "/compose.yml" + body["composeUrl"] = masterURL + "/api/install/" + out.Token + "/compose.yml" } response.Success(c, body) } diff --git a/server/internal/http/router.go b/server/internal/http/router.go index 599bf47..a911b03 100644 --- a/server/internal/http/router.go +++ b/server/internal/http/router.go @@ -320,7 +320,13 @@ func NewRouter(deps RouterDependencies) *gin.Engine { engine.GET("/metrics", gin.WrapH(deps.Metrics.Handler())) } - // 公开安装路由(不走 JWT 中间件) + // 公开安装路由(不走 JWT 中间件)。 + // 同时注册到 / 和 /api 前缀下: + // - /install/:token 保留历史 URL,兼容旧 nginx 部署 + // - /api/install/:token 新 URL,自动走反向代理的 /api/ 转发规则 + // + // Issue #46:用户的 nginx 只转发 /api/,/install/* 被 SPA fallback 到 index.html, + // 返回 HTML 被 sh 解释成 "Syntax error"。使用 /api/install/ 可避开此问题。 if deps.InstallTokenService != nil { gcCtx := deps.Context if gcCtx == nil { @@ -329,6 +335,8 @@ func NewRouter(deps RouterDependencies) *gin.Engine { installHandler := NewInstallHandler(gcCtx, deps.InstallTokenService, deps.AuditService, deps.MasterExternalURL) engine.GET("/install/:token", installHandler.Script) engine.GET("/install/:token/compose.yml", installHandler.Compose) + engine.GET("/api/install/:token", installHandler.Script) + engine.GET("/api/install/:token/compose.yml", installHandler.Compose) } engine.NoRoute(func(c *gin.Context) { diff --git a/server/internal/installscript/issue46_test.go b/server/internal/installscript/issue46_test.go new file mode 100644 index 0000000..b775f62 --- /dev/null +++ b/server/internal/installscript/issue46_test.go @@ -0,0 +1,38 @@ +package installscript + +import ( + "strings" + "testing" + + "backupx/server/internal/model" +) + +// TestRenderScriptIncludesMagicMarker 渲染脚本必须包含 Issue #46 引入的魔数注释, +// 方便用户通过 `head -3 脚本` 自查是否被中间层改写。 +func TestRenderScriptIncludesMagicMarker(t *testing.T) { + for _, mode := range []string{model.InstallModeSystemd, model.InstallModeDocker, model.InstallModeForeground} { + ctx := testCtx + ctx.Mode = mode + got, err := RenderScript(ctx) + if err != nil { + t.Fatalf("render err (%s): %v", mode, err) + } + if !strings.Contains(got, "BACKUPX_AGENT_INSTALL_V1") { + t.Errorf("mode=%s: script missing magic marker:\n%s", mode, got) + } + } +} + +// TestRenderScriptBashBootstrap 脚本顶部必须有 bash 自举段,文件执行时跳到 bash。 +func TestRenderScriptBashBootstrap(t *testing.T) { + got, err := RenderScript(testCtx) + if err != nil { + t.Fatalf("render err: %v", err) + } + if !strings.Contains(got, `[ -z "${BASH_VERSION:-}" ]`) { + t.Errorf("script missing bash bootstrap guard:\n%s", got) + } + if !strings.Contains(got, `exec bash "$0" "$@"`) { + t.Errorf("script missing exec bash fallback:\n%s", got) + } +} diff --git a/server/internal/installscript/templates/agent-install.sh.tmpl b/server/internal/installscript/templates/agent-install.sh.tmpl index ed5f667..788ccac 100644 --- a/server/internal/installscript/templates/agent-install.sh.tmpl +++ b/server/internal/installscript/templates/agent-install.sh.tmpl @@ -1,8 +1,16 @@ #!/bin/sh # BackupX Agent 一键安装脚本(由 Master 动态渲染) +# Magic: BACKUPX_AGENT_INSTALL_V1 —— 若 `head -3 脚本` 看不到此行,说明反向代理/CDN 改写了响应 # 模式: {{.Mode}} | 架构: {{.Arch}} | 版本: {{.AgentVersion}} set -eu +# 自举到 bash(文件执行模式下生效;管道模式 $0 不是文件,exec 会静默失败,继续用 sh)。 +# 动机:部分 Debian/Ubuntu 用户通过 `curl | sudo sh` 触发时,dash 对本脚本报语法错误; +# 若目标机装有 bash,优先切换到 bash 获得更一致的行为。 +if [ -z "${BASH_VERSION:-}" ] && command -v bash >/dev/null 2>&1 && [ -f "$0" ]; then + exec bash "$0" "$@" +fi + MASTER_URL="{{.MasterURL}}" AGENT_TOKEN="{{.AgentToken}}" AGENT_VERSION="{{.AgentVersion}}" diff --git a/web/src/pages/nodes/AgentInstallWizard.tsx b/web/src/pages/nodes/AgentInstallWizard.tsx index 6533833..ec34315 100644 --- a/web/src/pages/nodes/AgentInstallWizard.tsx +++ b/web/src/pages/nodes/AgentInstallWizard.tsx @@ -162,7 +162,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 sh`, + command: `curl -fsSL ${tok.url} | sudo bash`, expiresAt: tok.expiresAt, })) if (mountedRef.current) setBatchRows(rows) diff --git a/web/src/pages/nodes/wizard/Step3CommandPreview.tsx b/web/src/pages/nodes/wizard/Step3CommandPreview.tsx index 22f91bd..bc92b3e 100644 --- a/web/src/pages/nodes/wizard/Step3CommandPreview.tsx +++ b/web/src/pages/nodes/wizard/Step3CommandPreview.tsx @@ -29,7 +29,11 @@ export function Step3CommandPreview({ nodeId, nodeName, token, mode, previewPara }, [token.expiresAt]) const expired = remaining === 0 - const command = `curl -fsSL ${token.url} | sudo sh` + // 使用 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 dockerComposeCmd = mode === 'docker' && token.composeUrl ? `curl -fsSL ${token.composeUrl} -o docker-compose.yml && docker-compose up -d` : null @@ -76,6 +80,21 @@ export function Step3CommandPreview({ nodeId, nodeName, token, mode, previewPara +
+ + 或先下载再执行(当目标机无 bash / 反向代理过滤管道响应时): + + + {fallbackCommand} + +
+ +
+
+ {dockerComposeCmd && (