feat(deploy): harden restricted-network agent installs

This commit is contained in:
Awuqing
2026-08-09 02:31:06 +08:00
parent ea46a30f11
commit ab181b3197
29 changed files with 935 additions and 241 deletions
+29 -15
View File
@@ -115,7 +115,7 @@ func setupInstallFlowRouterWithExternalURL(t *testing.T, externalURL string) (ht
return router, setupResp.Data.Token
}
func TestInstallTokenUsesConfiguredExternalURL(t *testing.T) {
func TestInstallTokenUsesAgentSpecificURLAndConnectionSettings(t *testing.T) {
const externalURL = "https://public.example.com/base"
router, jwt := setupInstallFlowRouterWithExternalURL(t, externalURL)
@@ -141,11 +141,14 @@ func TestInstallTokenUsesConfiguredExternalURL(t *testing.T) {
}
genBody, _ := json.Marshal(map[string]any{
"mode": "systemd",
"arch": "auto",
"agentVersion": "v1.7.0",
"downloadSrc": "github",
"ttlSeconds": 900,
"mode": "systemd",
"arch": "auto",
"agentVersion": "v1.7.0",
"downloadSrc": "github",
"ttlSeconds": 900,
"agentMasterUrl": "http://127.0.0.1:18340",
"proxyUrl": "socks5h://127.0.0.1:1080",
"caCertFile": "/etc/pki/internal-ca.pem",
})
genReq := httptest.NewRequest(http.MethodPost,
"/api/nodes/"+formatUint(batchResp.Data[0].ID)+"/install-tokens", bytes.NewBuffer(genBody))
@@ -156,6 +159,9 @@ func TestInstallTokenUsesConfiguredExternalURL(t *testing.T) {
if genRec.Code != 200 {
t.Fatalf("install-tokens failed: %d %s", genRec.Code, genRec.Body.String())
}
if genRec.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("install-token response must not be cached: %#v", genRec.Header())
}
var genResp struct {
Data struct {
InstallToken string `json:"installToken"`
@@ -167,18 +173,21 @@ func TestInstallTokenUsesConfiguredExternalURL(t *testing.T) {
if err := json.Unmarshal(genRec.Body.Bytes(), &genResp); err != nil {
t.Fatalf("unmarshal gen: %v", err)
}
if genResp.Data.URL != externalURL+"/api/install/"+genResp.Data.InstallToken {
t.Fatalf("url should use external URL, got %q", genResp.Data.URL)
if genResp.Data.URL != "http://127.0.0.1:18340/api/install/"+genResp.Data.InstallToken {
t.Fatalf("url should use Agent-specific URL, got %q", genResp.Data.URL)
}
if genResp.Data.FallbackURL != externalURL+"/install/"+genResp.Data.InstallToken {
t.Fatalf("fallbackUrl should use external URL, got %q", genResp.Data.FallbackURL)
if genResp.Data.FallbackURL != "http://127.0.0.1:18340/install/"+genResp.Data.InstallToken {
t.Fatalf("fallbackUrl should use Agent-specific 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), `MASTER_URL="`+externalURL+`"`) {
t.Fatalf("script should use external MASTER_URL:\n%s", string(decodedScript))
if !strings.Contains(string(decodedScript), `MASTER_URL="http://127.0.0.1:18340"`) {
t.Fatalf("script should use the Agent-specific Master URL:\n%s", string(decodedScript))
}
if !strings.Contains(string(decodedScript), `PROXY_URL="socks5h://127.0.0.1:1080"`) || !strings.Contains(string(decodedScript), `CA_CERT_FILE="/etc/pki/internal-ca.pem"`) {
t.Fatalf("script should include restricted-network settings:\n%s", string(decodedScript))
}
}
@@ -258,8 +267,9 @@ func TestOneClickInstallFlow(t *testing.T) {
if scriptRec.Code != 200 {
t.Fatalf("script fetch failed: %d %s", scriptRec.Code, scriptRec.Body.String())
}
if !strings.Contains(scriptRec.Body.String(), "systemctl enable --now backupx-agent") {
t.Fatalf("script missing systemctl enable:\n%s", scriptRec.Body.String())
if !strings.Contains(scriptRec.Body.String(), "systemctl enable backupx-agent") ||
!strings.Contains(scriptRec.Body.String(), "systemctl restart backupx-agent") {
t.Fatalf("script missing systemctl enable/restart:\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") {
@@ -354,7 +364,8 @@ func TestInstallScriptAliasUnderAPI(t *testing.T) {
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") {
if !strings.Contains(aliasRec.Body.String(), "systemctl enable backupx-agent") ||
!strings.Contains(aliasRec.Body.String(), "systemctl restart 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") {
@@ -557,6 +568,9 @@ func TestInstallFlowComposeSuccessConsumesToken(t *testing.T) {
if !strings.Contains(composeRec.Body.String(), "BACKUPX_AGENT_TOKEN") {
t.Fatalf("compose missing token env:\n%s", composeRec.Body.String())
}
if composeRec.Header().Get("Cache-Control") != "no-store" || composeRec.Header().Get("X-Content-Type-Options") != "nosniff" {
t.Fatalf("compose response missing secret-safe headers: %#v", composeRec.Header())
}
scriptReq := httptest.NewRequest(http.MethodGet, "/api/install/"+genResp.Data.InstallToken, nil)
scriptRec := httptest.NewRecorder()
+17 -2
View File
@@ -104,16 +104,22 @@ func (h *InstallHandler) Compose(c *gin.Context) {
}
h.recordConsumeAudit(c, consumed, "compose")
yaml, err := installscript.RenderComposeYaml(installscript.Context{
MasterURL: resolveMasterURL(c, h.externalURL),
MasterURL: installMasterURL(resolveMasterURL(c, h.externalURL), consumed.Record),
AgentToken: consumed.Node.Token,
AgentVersion: consumed.Record.AgentVer,
Mode: model.InstallModeDocker,
Arch: consumed.Record.Arch,
NodeID: consumed.Node.ID,
ProxyURL: consumed.Record.ProxyURL,
CACertFile: consumed.Record.CACertFile,
})
if err != nil {
c.String(stdhttp.StatusInternalServerError, "render error\n")
return
}
c.Header("X-Content-Type-Options", "nosniff")
c.Header("Cache-Control", "no-store")
c.Header("Content-Disposition", `attachment; filename="backupx-agent-compose.yml"`)
c.Data(stdhttp.StatusOK, "text/yaml; charset=utf-8", []byte(yaml))
}
@@ -134,7 +140,7 @@ 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,
MasterURL: installMasterURL(masterURL, record),
AgentToken: node.Token,
AgentVersion: record.AgentVer,
Mode: record.Mode,
@@ -142,9 +148,18 @@ func renderInstallScript(masterURL string, node *model.Node, record *model.Agent
DownloadBase: installscript.DownloadBaseFor(record.DownloadSrc),
InstallPrefix: "/opt/backupx-agent",
NodeID: node.ID,
ProxyURL: record.ProxyURL,
CACertFile: record.CACertFile,
})
}
func installMasterURL(fallback string, record *model.AgentInstallToken) string {
if record != nil && strings.TrimSpace(record.AgentMasterURL) != "" {
return strings.TrimRight(strings.TrimSpace(record.AgentMasterURL), "/")
}
return fallback
}
// resolveMasterURL 按优先级推导 Master URL:外部配置 > X-Forwarded-* > Request.Host。
// 此为包级 helper,供 install_handler 和 node_handler 共用。
func resolveMasterURL(c *gin.Context, externalURL string) string {
+28 -14
View File
@@ -204,6 +204,7 @@ func (h *NodeHandler) RotateToken(c *gin.Context) {
recordAudit(c, h.auditService, "node", "rotate_token", "node",
fmt.Sprintf("%d", id), "",
fmt.Sprintf("轮换节点 Token (ID: %d)", id))
c.Header("Cache-Control", "no-store")
response.Success(c, gin.H{"newToken": tok})
}
@@ -220,11 +221,14 @@ func (h *NodeHandler) CreateInstallToken(c *gin.Context) {
return
}
var input struct {
Mode string `json:"mode"`
Arch string `json:"arch"`
AgentVersion string `json:"agentVersion"`
DownloadSrc string `json:"downloadSrc"`
TTLSeconds int `json:"ttlSeconds"`
Mode string `json:"mode"`
Arch string `json:"arch"`
AgentVersion string `json:"agentVersion"`
DownloadSrc string `json:"downloadSrc"`
TTLSeconds int `json:"ttlSeconds"`
AgentMasterURL string `json:"agentMasterUrl"`
ProxyURL string `json:"proxyUrl"`
CACertFile string `json:"caCertFile"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(stdhttp.StatusBadRequest, gin.H{"code": "INVALID_INPUT", "message": err.Error()})
@@ -246,13 +250,16 @@ func (h *NodeHandler) CreateInstallToken(c *gin.Context) {
out, err := h.installTokenSvc.CreateCommand(c.Request.Context(), service.InstallCommandInput{
InstallTokenInput: service.InstallTokenInput{
NodeID: uint(id),
Mode: input.Mode,
Arch: input.Arch,
AgentVersion: input.AgentVersion,
DownloadSrc: input.DownloadSrc,
TTLSeconds: input.TTLSeconds,
CreatedByID: h.resolveCurrentUserID(c),
NodeID: uint(id),
Mode: input.Mode,
Arch: input.Arch,
AgentVersion: input.AgentVersion,
DownloadSrc: input.DownloadSrc,
TTLSeconds: input.TTLSeconds,
CreatedByID: h.resolveCurrentUserID(c),
AgentMasterURL: input.AgentMasterURL,
ProxyURL: input.ProxyURL,
CACertFile: input.CACertFile,
},
MasterURL: resolveMasterURL(c, h.externalURL),
})
@@ -278,6 +285,7 @@ func (h *NodeHandler) CreateInstallToken(c *gin.Context) {
"composeUrl": out.ComposeURL,
"fallbackComposeUrl": out.FallbackComposeURL,
}
c.Header("Cache-Control", "no-store")
response.Success(c, body)
}
@@ -292,18 +300,24 @@ func (h *NodeHandler) PreviewScript(c *gin.Context) {
return
}
src := c.DefaultQuery("downloadSrc", "github")
agentMasterURL := c.Query("agentMasterUrl")
if agentMasterURL == "" {
agentMasterURL = resolveMasterURL(c, h.externalURL)
}
ctx := installscript.Context{
MasterURL: resolveMasterURL(c, h.externalURL),
MasterURL: agentMasterURL,
AgentToken: "<AGENT_TOKEN>",
AgentVersion: ver,
Mode: mode,
Arch: arch,
DownloadBase: installscript.DownloadBaseFor(src),
InstallPrefix: "/opt/backupx-agent",
ProxyURL: c.Query("proxyUrl"),
CACertFile: c.Query("caCertFile"),
}
script, err := installscript.RenderScript(ctx)
if err != nil {
response.Error(c, err)
response.Error(c, apperror.BadRequest("INSTALL_TOKEN_INVALID", "Agent 连接配置无效", err))
return
}
c.Data(stdhttp.StatusOK, "text/x-shellscript; charset=utf-8", []byte(script))