mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-05-15 20:39:52 +08:00
* 修复: #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 行(<html>)即报此语法错误 修复: 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 行 <html> 报语法错误。 真正的根因修复: 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/ 前缀
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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 有效。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
38
server/internal/installscript/issue46_test.go
Normal file
38
server/internal/installscript/issue46_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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}}"
|
||||
|
||||
Reference in New Issue
Block a user