修复: #46 Agent 一键安装脚本在 Debian dash 下执行失败 (#48)

* 修复: #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:
Wu Qing
2026-04-20 23:35:39 +08:00
committed by GitHub
parent 5021fe665e
commit e2baa6bd17
9 changed files with 191 additions and 6 deletions
+84
View File
@@ -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 防嗅探 headerstext/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)