diff --git a/server/internal/backup/file_runner.go b/server/internal/backup/file_runner.go index b86e20f..726060d 100644 --- a/server/internal/backup/file_runner.go +++ b/server/internal/backup/file_runner.go @@ -84,7 +84,7 @@ func (r *FileRunner) Run(_ context.Context, task TaskSpec, writer LogWriter) (*R walkErr := filepath.Walk(sourcePath, func(currentPath string, currentInfo os.FileInfo, walkErr error) error { if walkErr != nil { - writer.WriteLine(fmt.Sprintf("⚠ 无法访问 %s: %v", currentPath, walkErr)) + writer.WriteLine(fmt.Sprintf("[WARN] 无法访问 %s: %v", currentPath, walkErr)) return nil } relPath, err := filepath.Rel(baseParent, currentPath) @@ -115,7 +115,7 @@ func (r *FileRunner) Run(_ context.Context, task TaskSpec, writer LogWriter) (*R if currentInfo.IsDir() { dirCount++ - writer.WriteLine(fmt.Sprintf("📁 进入目录 %s", archiveName)) + writer.WriteLine(fmt.Sprintf("[DIR] 进入目录 %s", archiveName)) } header, err := tar.FileInfoHeader(currentInfo, "") diff --git a/server/internal/http/install_flow_test.go b/server/internal/http/install_flow_test.go index 005a670..a14310a 100644 --- a/server/internal/http/install_flow_test.go +++ b/server/internal/http/install_flow_test.go @@ -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 防嗅探 headers:text/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() diff --git a/server/internal/http/install_handler.go b/server/internal/http/install_handler.go index 9924d17..7371bf8 100644 --- a/server/internal/http/install_handler.go +++ b/server/internal/http/install_handler.go @@ -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 { diff --git a/server/internal/http/node_handler.go b/server/internal/http/node_handler.go index 249694b..a833b18 100644 --- a/server/internal/http/node_handler.go +++ b/server/internal/http/node_handler.go @@ -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: "", 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)) diff --git a/server/internal/installscript/issue46_test.go b/server/internal/installscript/issue46_test.go index 5c0e17a..de10c19 100644 --- a/server/internal/installscript/issue46_test.go +++ b/server/internal/installscript/issue46_test.go @@ -44,7 +44,7 @@ func TestRenderScriptUsesRootForBareMetalBackups(t *testing.T) { } for _, want := range []string{ "/var/lib/backupx-agent/tmp", - "install -d -m 0700 /var/lib/backupx-agent /var/lib/backupx-agent/tmp", + "install -d -m 0700 \"$CONFIG_DIR\" /var/lib/backupx-agent /var/lib/backupx-agent/tmp", } { if !strings.Contains(got, want) { t.Errorf("script missing %q:\n%s", want, got) diff --git a/server/internal/installscript/renderer.go b/server/internal/installscript/renderer.go index 2b6f466..d61ee9b 100644 --- a/server/internal/installscript/renderer.go +++ b/server/internal/installscript/renderer.go @@ -8,6 +8,7 @@ import ( _ "embed" "fmt" "net/url" + "path" "strings" "text/template" @@ -30,6 +31,8 @@ type Context struct { DownloadBase string InstallPrefix string NodeID uint + ProxyURL string + CACertFile string } // DownloadBaseFor 将下载源枚举转换为具体 URL 前缀。 @@ -84,6 +87,12 @@ func RenderComposeYaml(ctx Context) (string, error) { // 这些字段被直接写入 shell 双引号字符串和 YAML 双引号值;不做校验会带来 // 注入风险(如 MasterURL 含 `"\nCOMMAND:` 可逃逸 YAML 结构)。 func validateContext(ctx Context) error { + if ctx.Mode != model.InstallModeSystemd && ctx.Mode != model.InstallModeDocker && ctx.Mode != model.InstallModeForeground { + return fmt.Errorf("unsupported install mode %q", ctx.Mode) + } + if ctx.Arch != model.InstallArchAmd64 && ctx.Arch != model.InstallArchArm64 && ctx.Arch != model.InstallArchAuto { + return fmt.Errorf("unsupported install architecture %q", ctx.Arch) + } if err := validateMasterURL(ctx.MasterURL); err != nil { return err } @@ -93,6 +102,42 @@ func validateContext(ctx Context) error { if err := validateAgentVersion(ctx.AgentVersion); err != nil { return err } + if err := validateProxyURL(ctx.ProxyURL); err != nil { + return err + } + if err := validateCACertFile(ctx.CACertFile); err != nil { + return err + } + if !path.IsAbs(ctx.InstallPrefix) || path.Clean(ctx.InstallPrefix) != ctx.InstallPrefix { + return fmt.Errorf("install prefix must be a clean absolute path without shell metacharacters") + } + for _, c := range ctx.InstallPrefix { + switch { + case c >= '0' && c <= '9': + case c >= 'a' && c <= 'z': + case c >= 'A' && c <= 'Z': + case c == '/' || c == '.' || c == '_' || c == '-' || c == '+': + default: + return fmt.Errorf("install prefix contains illegal character %q", c) + } + } + if err := validateDownloadBase(ctx.DownloadBase); err != nil { + return err + } + return nil +} + +func validateDownloadBase(raw string) error { + if strings.ContainsAny(raw, " \t\r\n\"'`$\\") { + return fmt.Errorf("download base contains illegal characters") + } + u, err := url.Parse(raw) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + return fmt.Errorf("download base must be an absolute http(s) URL") + } + if u.User != nil || u.RawQuery != "" || u.Fragment != "" { + return fmt.Errorf("download base must not contain credentials, query or fragment") + } return nil } @@ -114,6 +159,43 @@ func validateMasterURL(raw string) error { if u.Host == "" { return fmt.Errorf("master URL missing host") } + if u.User != nil || u.RawQuery != "" || u.Fragment != "" { + return fmt.Errorf("master URL must not contain credentials, query or fragment") + } + return nil +} + +func validateProxyURL(raw string) error { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + if strings.ContainsAny(raw, " \t\r\n\"'`$\\") { + return fmt.Errorf("proxy URL contains illegal characters") + } + u, err := url.Parse(raw) + if err != nil || u.Host == "" { + return fmt.Errorf("invalid proxy URL") + } + switch u.Scheme { + case "http", "https", "socks5", "socks5h": + default: + return fmt.Errorf("proxy URL scheme must be http, https, socks5 or socks5h") + } + if u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Path != "" && u.Path != "/") { + return fmt.Errorf("proxy URL must not contain credentials, path, query or fragment") + } + return nil +} + +func validateCACertFile(file string) error { + file = strings.TrimSpace(file) + if file == "" { + return nil + } + if !path.IsAbs(file) || path.Clean(file) != file || strings.ContainsAny(file, " \t\r\n\"'`$\\") { + return fmt.Errorf("CA certificate path must be a clean absolute path without shell metacharacters") + } return nil } @@ -160,6 +242,9 @@ func validateAgentVersion(v string) error { } func withDefaults(ctx Context) Context { + if ctx.Arch == "" { + ctx.Arch = model.InstallArchAuto + } if ctx.InstallPrefix == "" { ctx.InstallPrefix = "/opt/backupx-agent" } diff --git a/server/internal/installscript/renderer_test.go b/server/internal/installscript/renderer_test.go index 91f0601..209448f 100644 --- a/server/internal/installscript/renderer_test.go +++ b/server/internal/installscript/renderer_test.go @@ -2,11 +2,13 @@ package installscript import ( "os" + "os/exec" "path/filepath" "strings" "testing" "backupx/server/internal/model" + "gopkg.in/yaml.v3" ) // 使用合法 hex token(32 字节 = 64 字符)以通过 validateAgentToken 校验 @@ -27,11 +29,13 @@ func TestRenderScriptSystemd(t *testing.T) { t.Fatalf("render err: %v", err) } mustContain := []string{ - "BACKUPX_AGENT_MASTER=${MASTER_URL}", - `Environment="BACKUPX_AGENT_TOKEN=${AGENT_TOKEN}"`, + `master: "${MASTER_URL}"`, + `tokenFile: "${TOKEN_FILE}"`, + `ExecStart=${INSTALL_PREFIX}/backupx agent --config ${CONFIG_FILE}`, "/var/lib/backupx-agent/tmp", "systemctl daemon-reload", - "systemctl enable --now backupx-agent", + "systemctl enable backupx-agent", + "systemctl restart backupx-agent", "systemctl status backupx-agent", "X-Agent-Token: ${AGENT_TOKEN}", "MASTER_URL=\"https://master.example.com\"", @@ -48,6 +52,29 @@ func TestRenderScriptSystemd(t *testing.T) { t.Errorf("systemd script unexpectedly contains %q", s) } } + if strings.Contains(got, `Environment="BACKUPX_AGENT_TOKEN=`) { + t.Errorf("systemd unit must not expose the agent token in its environment:\n%s", got) + } +} + +func TestRenderedInstallScriptSyntax(t *testing.T) { + sh, err := exec.LookPath("sh") + if err != nil { + t.Skip("POSIX sh is not available on this platform") + } + for _, mode := range []string{model.InstallModeSystemd, model.InstallModeDocker, model.InstallModeForeground} { + ctx := testCtx + ctx.Mode = mode + script, renderErr := RenderScript(ctx) + if renderErr != nil { + t.Fatal(renderErr) + } + cmd := exec.Command(sh, "-n") + cmd.Stdin = strings.NewReader(script) + if output, syntaxErr := cmd.CombinedOutput(); syntaxErr != nil { + t.Fatalf("%s installer syntax invalid: %v\n%s", mode, syntaxErr, output) + } + } } func TestRenderScriptForeground(t *testing.T) { @@ -90,8 +117,11 @@ func TestRenderScriptDocker(t *testing.T) { if !strings.Contains(got, `"awuqing/backupx:${AGENT_VERSION}" agent`) { t.Errorf("docker script must start image in agent mode:\n%s", got) } - if !strings.Contains(got, `-e "BACKUPX_AGENT_TEMP_DIR=/var/lib/backupx-agent/tmp"`) { - t.Errorf("docker script missing temp dir env:\n%s", got) + if !strings.Contains(got, `-v /etc/backupx-agent:/etc/backupx-agent:ro`) { + t.Errorf("docker script missing protected config mount:\n%s", got) + } + if !strings.Contains(got, `agent --config /etc/backupx-agent/config.yaml`) { + t.Errorf("docker script must load the protected config file:\n%s", got) } if !strings.Contains(got, `docker logs --tail=100 backupx-agent`) { t.Errorf("docker script missing diagnostic log command:\n%s", got) @@ -102,6 +132,9 @@ func TestRenderScriptDocker(t *testing.T) { if strings.Contains(got, "systemctl daemon-reload") { t.Errorf("docker script should not reference systemctl:\n%s", got) } + if strings.Contains(got, `-e "BACKUPX_AGENT_TOKEN=`) { + t.Errorf("docker inspect must not expose the agent token:\n%s", got) + } } func TestDockerEntrypointForwardsAgentSubcommand(t *testing.T) { @@ -111,12 +144,18 @@ func TestDockerEntrypointForwardsAgentSubcommand(t *testing.T) { t.Fatalf("read docker entrypoint: %v", err) } script := string(got) - if !strings.Contains(script, `"${1:-}" = "agent"`) { - t.Fatalf("entrypoint must detect the agent subcommand before starting server:\n%s", script) - } if !strings.Contains(script, `exec /app/bin/backupx "$@"`) { t.Fatalf("entrypoint must exec backupx with forwarded args:\n%s", script) } + if !strings.Contains(script, `exec su-exec backupx:backupx /app/bin/backupx "$@"`) { + t.Fatalf("master entrypoint must drop privileges after data migration:\n%s", script) + } + if !strings.Contains(script, `export HOME=/app`) { + t.Fatalf("master entrypoint must set the service user's home directory:\n%s", script) + } + if strings.Contains(script, "nginx") || strings.Contains(script, "wait -n") { + t.Fatalf("entrypoint should run a single foreground process:\n%s", script) + } } func TestRenderComposeYaml(t *testing.T) { @@ -138,9 +177,40 @@ func TestRenderComposeYaml(t *testing.T) { if !strings.Contains(got, `BACKUPX_AGENT_TEMP_DIR: "/var/lib/backupx-agent/tmp"`) { t.Errorf("compose missing temp dir env:\n%s", got) } + if !strings.Contains(got, `user: "0:0"`) || !strings.Contains(got, "no-new-privileges:true") { + t.Errorf("compose missing root execution declaration or security option:\n%s", got) + } if !strings.Contains(got, "/var/lib/backupx-agent:/var/lib/backupx-agent") { t.Errorf("compose missing agent data volume:\n%s", got) } + var document map[string]any + if err := yaml.Unmarshal([]byte(got), &document); err != nil { + t.Fatalf("compose is not valid YAML: %v\n%s", err, got) + } +} + +func TestRenderComposeYamlIncludesRestrictedNetworkSettings(t *testing.T) { + ctx := testCtx + ctx.Mode = model.InstallModeDocker + ctx.ProxyURL = "socks5h://127.0.0.1:1080" + ctx.CACertFile = "/etc/backupx-agent/ca.pem" + got, err := RenderComposeYaml(ctx) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + `BACKUPX_AGENT_PROXY_URL: "socks5h://127.0.0.1:1080"`, + `BACKUPX_AGENT_CA_CERT_FILE: "/etc/backupx-agent/ca.pem"`, + `- /etc/backupx-agent/ca.pem:/etc/backupx-agent/ca.pem:ro`, + } { + if !strings.Contains(got, want) { + t.Errorf("compose missing %q:\n%s", want, got) + } + } + var document map[string]any + if err := yaml.Unmarshal([]byte(got), &document); err != nil { + t.Fatalf("compose is not valid YAML: %v\n%s", err, got) + } } func TestRenderScriptRejectsInjectedMasterURL(t *testing.T) { @@ -159,6 +229,73 @@ func TestRenderScriptRejectsInjectedMasterURL(t *testing.T) { } } +func TestRenderScriptIncludesRestrictedNetworkSettings(t *testing.T) { + ctx := testCtx + ctx.MasterURL = "http://127.0.0.1:18340" + ctx.ProxyURL = "socks5h://127.0.0.1:1080" + ctx.CACertFile = "/etc/pki/internal-ca.pem" + got, err := RenderScript(ctx) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + `PROXY_URL="socks5h://127.0.0.1:1080"`, + `CA_CERT_FILE="/etc/pki/internal-ca.pem"`, + `proxyUrl: "${PROXY_URL}"`, + `caCertFile: "${CA_CERT_FILE}"`, + `curl -fsSL --retry 3 --retry-delay 2 --connect-timeout 15 --proxy "$PROXY_URL"`, + } { + if !strings.Contains(got, want) { + t.Errorf("restricted network script missing %q", want) + } + } +} + +func TestRenderScriptRejectsUnsafeRestrictedNetworkSettings(t *testing.T) { + for _, mutate := range []func(*Context){ + func(ctx *Context) { ctx.ProxyURL = "ftp://proxy.example.com" }, + func(ctx *Context) { ctx.ProxyURL = "http://user:pass@proxy.example.com" }, + func(ctx *Context) { ctx.ProxyURL = "http://proxy.example.com/connect" }, + func(ctx *Context) { ctx.CACertFile = "relative-ca.pem" }, + } { + ctx := testCtx + mutate(&ctx) + if _, err := RenderScript(ctx); err == nil { + t.Fatalf("expected restricted network settings to be rejected: %+v", ctx) + } + } +} + +func TestRenderScriptRejectsUnsafeDeploymentSettings(t *testing.T) { + for _, mutate := range []func(*Context){ + func(ctx *Context) { ctx.Mode = "unknown" }, + func(ctx *Context) { ctx.Arch = "386" }, + func(ctx *Context) { ctx.InstallPrefix = "/opt/backupx;touch/tmp/pwned" }, + func(ctx *Context) { ctx.DownloadBase = "https://user:pass@example.com/releases" }, + } { + ctx := testCtx + mutate(&ctx) + if _, err := RenderScript(ctx); err == nil { + t.Fatalf("expected unsafe deployment settings to be rejected: %+v", ctx) + } + } +} + +func TestRenderScriptVerifiesReleaseChecksumWithoutEmoji(t *testing.T) { + got, err := RenderScript(testCtx) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"sha256sum -c", `download_file "${URL}.sha256"`, "[OK] 节点已上线", "[WARN]"} { + if !strings.Contains(got, want) { + t.Errorf("script missing %q", want) + } + } + if strings.ContainsAny(got, "\u2713\u26a0") { + t.Fatal("installer output must not use emoji symbols") + } +} + func TestRenderComposeYamlRejectsInjectedMasterURL(t *testing.T) { ctx := testCtx ctx.Mode = model.InstallModeDocker diff --git a/server/internal/installscript/templates/agent-compose.yml.tmpl b/server/internal/installscript/templates/agent-compose.yml.tmpl index a49b072..6ded671 100644 --- a/server/internal/installscript/templates/agent-compose.yml.tmpl +++ b/server/internal/installscript/templates/agent-compose.yml.tmpl @@ -1,14 +1,22 @@ -# BackupX Agent docker-compose 片段 +# BackupX Agent Compose 片段 # 生成于 {{.MasterURL}} · 节点 ID {{.NodeID}} -version: "3.8" +# 文件包含长期节点 Token,请保存为 0600 权限并在部署完成后限制访问。 services: backupx-agent: image: awuqing/backupx:{{.AgentVersion}} command: ["agent"] + user: "0:0" restart: unless-stopped + security_opt: + - no-new-privileges:true environment: BACKUPX_AGENT_MASTER: "{{.MasterURL}}" BACKUPX_AGENT_TOKEN: "{{.AgentToken}}" BACKUPX_AGENT_TEMP_DIR: "/var/lib/backupx-agent/tmp" - volumes: +{{if .ProxyURL}} BACKUPX_AGENT_PROXY_URL: "{{.ProxyURL}}" +{{end}}{{if .CACertFile}} BACKUPX_AGENT_CA_CERT_FILE: "{{.CACertFile}}" +{{end}} volumes: - /var/lib/backupx-agent:/var/lib/backupx-agent +{{if .CACertFile}} - {{.CACertFile}}:{{.CACertFile}}:ro +{{end}} # 备份宿主机文件时,必须按需添加只读源目录挂载: + # - /srv/data:/srv/data:ro diff --git a/server/internal/installscript/templates/agent-install.sh.tmpl b/server/internal/installscript/templates/agent-install.sh.tmpl index 5d4adaf..6aac492 100644 --- a/server/internal/installscript/templates/agent-install.sh.tmpl +++ b/server/internal/installscript/templates/agent-install.sh.tmpl @@ -1,12 +1,11 @@ #!/bin/sh # BackupX Agent 一键安装脚本(由 Master 动态渲染) -# Magic: BACKUPX_AGENT_INSTALL_V1 —— 若 `head -3 脚本` 看不到此行,说明反向代理/CDN 改写了响应 +# Magic: BACKUPX_AGENT_INSTALL_V1 —— 若 `head -3 脚本` 看不到此行,说明反向代理或 CDN 改写了响应 # 模式: {{.Mode}} | 架构: {{.Arch}} | 版本: {{.AgentVersion}} set -eu +umask 077 -# 自举到 bash(文件执行模式下生效;管道模式 $0 不是文件,exec 会静默失败,继续用 sh)。 -# 动机:部分 Debian/Ubuntu 用户通过 `curl | sudo sh` 触发时,dash 对本脚本报语法错误; -# 若目标机装有 bash,优先切换到 bash 获得更一致的行为。 +# 文件执行模式下优先使用 bash;管道模式继续使用 POSIX sh。 if [ -z "${BASH_VERSION:-}" ] && command -v bash >/dev/null 2>&1 && [ -f "$0" ]; then exec bash "$0" "$@" fi @@ -17,14 +16,56 @@ AGENT_VERSION="{{.AgentVersion}}" DOWNLOAD_BASE="{{.DownloadBase}}" INSTALL_PREFIX="{{.InstallPrefix}}" ARCH="{{.Arch}}" +PROXY_URL="{{.ProxyURL}}" +CA_CERT_FILE="{{.CACertFile}}" +CONFIG_DIR="/etc/backupx-agent" +CONFIG_FILE="${CONFIG_DIR}/config.yaml" +TOKEN_FILE="${CONFIG_DIR}/agent.token" + +download_file() { + source_url="$1" + destination="$2" + if command -v curl >/dev/null 2>&1; then + if [ -n "$PROXY_URL" ]; then + curl -fsSL --retry 3 --retry-delay 2 --connect-timeout 15 --proxy "$PROXY_URL" "$source_url" -o "$destination" + else + curl -fsSL --retry 3 --retry-delay 2 --connect-timeout 15 "$source_url" -o "$destination" + fi + else + wget -q -T 30 -O "$destination" "$source_url" + fi +} + +agent_is_online() { + if command -v curl >/dev/null 2>&1; then + if [ -n "$PROXY_URL" ] && [ -n "$CA_CERT_FILE" ]; then + response=$(curl -fsS --max-time 5 --proxy "$PROXY_URL" --cacert "$CA_CERT_FILE" -H "X-Agent-Token: ${AGENT_TOKEN}" "${MASTER_URL}/api/v1/agent/self" 2>/dev/null) || return 1 + elif [ -n "$PROXY_URL" ]; then + response=$(curl -fsS --max-time 5 --proxy "$PROXY_URL" -H "X-Agent-Token: ${AGENT_TOKEN}" "${MASTER_URL}/api/v1/agent/self" 2>/dev/null) || return 1 + elif [ -n "$CA_CERT_FILE" ]; then + response=$(curl -fsS --max-time 5 --cacert "$CA_CERT_FILE" -H "X-Agent-Token: ${AGENT_TOKEN}" "${MASTER_URL}/api/v1/agent/self" 2>/dev/null) || return 1 + else + response=$(curl -fsS --max-time 5 -H "X-Agent-Token: ${AGENT_TOKEN}" "${MASTER_URL}/api/v1/agent/self" 2>/dev/null) || return 1 + fi + else + response=$(wget -q -T 5 --max-redirect=0 -O - --header="X-Agent-Token: ${AGENT_TOKEN}" "${MASTER_URL}/api/v1/agent/self" 2>/dev/null) || return 1 + fi + printf '%s' "$response" | grep -q '"status":"online"' +} # 1. 前置检查 [ "$(id -u)" -eq 0 ] || { echo "请使用 root 或 sudo 执行" >&2; exit 1; } -command -v curl >/dev/null || command -v wget >/dev/null \ +command -v curl >/dev/null 2>&1 || command -v wget >/dev/null 2>&1 \ || { echo "需要 curl 或 wget" >&2; exit 1; } -{{if eq .Mode "systemd"}}command -v systemctl >/dev/null || { echo "不支持非 systemd 系统" >&2; exit 1; } -{{end}}{{if eq .Mode "docker"}}command -v docker >/dev/null || { echo "需要先安装 docker" >&2; exit 1; } +command -v grep >/dev/null 2>&1 || { echo "需要 grep" >&2; exit 1; } +if { [ -n "$PROXY_URL" ] || [ -n "$CA_CERT_FILE" ]; } && ! command -v curl >/dev/null 2>&1; then + echo "显式代理或自定义 CA 场景需要 curl" >&2 + exit 1 +fi +{{if eq .Mode "systemd"}}command -v systemctl >/dev/null 2>&1 || { echo "当前系统不支持 systemd" >&2; exit 1; } +{{end}}{{if eq .Mode "docker"}}command -v docker >/dev/null 2>&1 || { echo "需要先安装 Docker" >&2; exit 1; } {{end}} + # 2. 架构检测 if [ "$ARCH" = "auto" ]; then case "$(uname -m)" in @@ -34,24 +75,54 @@ if [ "$ARCH" = "auto" ]; then esac fi +# 3. 安全写入 Agent 配置。systemd unit 与容器元数据中不保存节点 Token。 +install -d -m 0700 "$CONFIG_DIR" /var/lib/backupx-agent /var/lib/backupx-agent/tmp +printf '%s\n' "$AGENT_TOKEN" > "$TOKEN_FILE" +chmod 0600 "$TOKEN_FILE" +if [ -n "$CA_CERT_FILE" ]; then + [ -r "$CA_CERT_FILE" ] || { echo "无法读取 CA 证书: $CA_CERT_FILE" >&2; exit 1; } + if [ "$CA_CERT_FILE" != "$CONFIG_DIR/ca.pem" ]; then + install -m 0644 "$CA_CERT_FILE" "$CONFIG_DIR/ca.pem" + fi + CA_CERT_FILE="$CONFIG_DIR/ca.pem" +fi +cat > "$CONFIG_FILE" </dev/null; then - curl -fsSL "$URL" -o "$TMPDIR/pkg.tar.gz" +download_file "$URL" "$TMPDIR/$ARCHIVE" +if download_file "${URL}.sha256" "$TMPDIR/$ARCHIVE.sha256" 2>/dev/null; then + if command -v sha256sum >/dev/null 2>&1; then + (cd "$TMPDIR" && sha256sum -c "$ARCHIVE.sha256") + elif command -v shasum >/dev/null 2>&1; then + (cd "$TMPDIR" && shasum -a 256 -c "$ARCHIVE.sha256") + else + echo "已下载校验文件,但系统缺少 sha256sum 或 shasum,拒绝未校验安装" >&2 + exit 1 + fi else - wget -qO "$TMPDIR/pkg.tar.gz" "$URL" + echo "[WARN] 当前版本未提供 SHA-256 校验文件,继续兼容安装" >&2 fi -tar xzf "$TMPDIR/pkg.tar.gz" -C "$TMPDIR" +tar xzf "$TMPDIR/$ARCHIVE" -C "$TMPDIR" -# 4. 安装二进制 + 数据目录 echo "[2/4] 安装到 ${INSTALL_PREFIX}" install -d -m 0755 "$INSTALL_PREFIX" -install -d -m 0700 /var/lib/backupx-agent /var/lib/backupx-agent/tmp -install -m 0755 "$TMPDIR/backupx-${AGENT_VERSION}-linux-${ARCH}/backupx" "$INSTALL_PREFIX/backupx" +install -m 0755 "$TMPDIR/backupx-${AGENT_VERSION}-linux-${ARCH}/backupx" "$INSTALL_PREFIX/backupx.new" +mv -f "$INSTALL_PREFIX/backupx.new" "$INSTALL_PREFIX/backupx" {{end}} {{if eq .Mode "systemd"}} @@ -62,69 +133,78 @@ cat > /etc/systemd/system/backupx-agent.service <&2 + systemctl status backupx-agent --no-pager >&2 || true + journalctl -u backupx-agent -n 100 --no-pager >&2 || true + exit 1 +fi # 6. 等待上线 echo "[4/4] 等待节点上线" -for i in $(seq 1 15); do +i=0 +while [ "$i" -lt 15 ]; do sleep 2 - if curl -fsSL -H "X-Agent-Token: ${AGENT_TOKEN}" "${MASTER_URL}/api/v1/agent/self" 2>/dev/null \ - | grep -q '"status":"online"'; then - echo "✓ 节点已上线" + if agent_is_online; then + echo "[OK] 节点已上线" exit 0 fi + i=$((i + 1)) done -echo "⚠ 30s 内未收到上线心跳,请检查防火墙或 journalctl -u backupx-agent" -echo "提示:systemd 服务名是 backupx-agent,可执行 systemctl status backupx-agent 查看状态。" +echo "[WARN] 30 秒内未收到上线心跳,请检查网络、代理或 SSH 隧道" >&2 +echo "排查命令: systemctl status backupx-agent" >&2 +echo "排查命令: journalctl -u backupx-agent -n 100 --no-pager" >&2 exit 2 {{end}} {{if eq .Mode "foreground"}} # 5. 前台运行 -echo "[3/3] 前台启动 agent(Ctrl+C 退出)" -export BACKUPX_AGENT_MASTER="${MASTER_URL}" -export BACKUPX_AGENT_TOKEN="${AGENT_TOKEN}" -exec "${INSTALL_PREFIX}/backupx" agent --temp-dir /var/lib/backupx-agent/tmp +echo "[3/3] 前台启动 Agent(Ctrl+C 退出)" +exec "${INSTALL_PREFIX}/backupx" agent --config "$CONFIG_FILE" {{end}} {{if eq .Mode "docker"}} -# Docker 模式:直接用镜像启动容器 +# Docker 模式:配置文件只读挂载,避免 Token 出现在 docker inspect 环境变量中。 echo "[1/2] 拉取镜像 awuqing/backupx:${AGENT_VERSION}" docker pull "awuqing/backupx:${AGENT_VERSION}" echo "[2/2] 启动容器 backupx-agent" docker rm -f backupx-agent >/dev/null 2>&1 || true -docker run -d --name backupx-agent --restart=unless-stopped \ - -e "BACKUPX_AGENT_MASTER=${MASTER_URL}" \ - -e "BACKUPX_AGENT_TOKEN=${AGENT_TOKEN}" \ - -e "BACKUPX_AGENT_TEMP_DIR=/var/lib/backupx-agent/tmp" \ +docker run -d --name backupx-agent --restart=unless-stopped --user 0:0 \ + --security-opt no-new-privileges:true \ + -v /etc/backupx-agent:/etc/backupx-agent:ro \ -v /var/lib/backupx-agent:/var/lib/backupx-agent \ - "awuqing/backupx:${AGENT_VERSION}" agent -echo "✓ 容器已启动,等待节点上线" -for i in $(seq 1 15); do + "awuqing/backupx:${AGENT_VERSION}" agent --config /etc/backupx-agent/config.yaml +echo "提示: Docker Agent 只能访问显式挂载的目录;备份宿主机路径前请按文档添加只读 -v 挂载。" +echo "容器已启动,等待节点上线" +i=0 +while [ "$i" -lt 15 ]; do sleep 2 - if curl -fsSL -H "X-Agent-Token: ${AGENT_TOKEN}" "${MASTER_URL}/api/v1/agent/self" 2>/dev/null \ - | grep -q '"status":"online"'; then - echo "✓ 节点已上线" + if agent_is_online; then + echo "[OK] 节点已上线" exit 0 fi + i=$((i + 1)) done -echo "⚠ 30s 内未收到上线心跳,请检查容器状态、网络与 Master URL。" -echo "排查命令:docker ps -a --filter name=backupx-agent" -echo "排查命令:docker logs --tail=100 backupx-agent" +echo "[WARN] 30 秒内未收到上线心跳,请检查容器、代理、隧道与 Master 地址" >&2 +echo "排查命令: docker ps -a --filter name=backupx-agent" >&2 +echo "排查命令: docker logs --tail=100 backupx-agent" >&2 exit 2 {{end}} diff --git a/server/internal/model/agent_install_token.go b/server/internal/model/agent_install_token.go index f211d7e..ed6b36b 100644 --- a/server/internal/model/agent_install_token.go +++ b/server/internal/model/agent_install_token.go @@ -6,17 +6,21 @@ import "time" // // 生命周期:创建 → 消费(ConsumedAt 非空即作废)→ 超过 ExpiresAt 后被 GC 硬删除。 type AgentInstallToken struct { - ID uint `gorm:"primaryKey" json:"id"` - Token string `gorm:"size:64;uniqueIndex;not null" json:"token"` - NodeID uint `gorm:"not null;index" json:"nodeId"` - Mode string `gorm:"size:16;not null" json:"mode"` // systemd|docker|foreground - Arch string `gorm:"size:16;not null" json:"arch"` // amd64|arm64|auto - AgentVer string `gorm:"size:32;not null" json:"agentVersion"` - DownloadSrc string `gorm:"size:16;not null;default:'github'" json:"downloadSrc"` - ExpiresAt time.Time `gorm:"not null;index" json:"expiresAt"` - ConsumedAt *time.Time `json:"consumedAt,omitempty"` - CreatedByID uint `gorm:"not null" json:"createdById"` - CreatedAt time.Time `json:"createdAt"` + ID uint `gorm:"primaryKey" json:"id"` + Token string `gorm:"size:64;uniqueIndex;not null" json:"token"` + NodeID uint `gorm:"not null;index" json:"nodeId"` + Mode string `gorm:"size:16;not null" json:"mode"` // systemd|docker|foreground + Arch string `gorm:"size:16;not null" json:"arch"` // amd64|arm64|auto + AgentVer string `gorm:"size:32;not null" json:"agentVersion"` + DownloadSrc string `gorm:"size:16;not null;default:'github'" json:"downloadSrc"` + // AgentMasterURL 可覆盖公开安装地址,支持代理或 SSH 隧道后的节点专用入口。 + AgentMasterURL string `gorm:"size:2048" json:"agentMasterUrl,omitempty"` + ProxyURL string `gorm:"size:2048" json:"proxyUrl,omitempty"` + CACertFile string `gorm:"size:512" json:"caCertFile,omitempty"` + ExpiresAt time.Time `gorm:"not null;index" json:"expiresAt"` + ConsumedAt *time.Time `json:"consumedAt,omitempty"` + CreatedByID uint `gorm:"not null" json:"createdById"` + CreatedAt time.Time `json:"createdAt"` } func (AgentInstallToken) TableName() string { return "agent_install_tokens" } diff --git a/server/internal/repository/agent_install_token_repository_test.go b/server/internal/repository/agent_install_token_repository_test.go index b7c6b26..fef32ef 100644 --- a/server/internal/repository/agent_install_token_repository_test.go +++ b/server/internal/repository/agent_install_token_repository_test.go @@ -20,6 +20,11 @@ func openTestInstallTokenDB(t *testing.T) *gorm.DB { if err != nil { t.Fatalf("open: %v", err) } + sqlDB, err := db.DB() + if err != nil { + t.Fatalf("get sql database: %v", err) + } + t.Cleanup(func() { _ = sqlDB.Close() }) if err := db.AutoMigrate(&model.AgentInstallToken{}); err != nil { t.Fatalf("migrate: %v", err) } diff --git a/server/internal/service/install_token_service.go b/server/internal/service/install_token_service.go index e2e97d9..27a5481 100644 --- a/server/internal/service/install_token_service.go +++ b/server/internal/service/install_token_service.go @@ -6,6 +6,7 @@ import ( "encoding/base64" "encoding/hex" "fmt" + "net/url" "strings" "time" @@ -27,13 +28,16 @@ func NewInstallTokenService(repo repository.AgentInstallTokenRepository, nodeRep // InstallTokenInput 生成一次性安装令牌的输入。 type InstallTokenInput struct { - NodeID uint - Mode string - Arch string - AgentVersion string - DownloadSrc string - TTLSeconds int - CreatedByID uint + NodeID uint + Mode string + Arch string + AgentVersion string + DownloadSrc string + TTLSeconds int + CreatedByID uint + AgentMasterURL string + ProxyURL string + CACertFile string } // InstallTokenOutput 生成结果。 @@ -112,14 +116,17 @@ func (s *InstallTokenService) Create(ctx context.Context, in InstallTokenInput) } expiresAt := time.Now().UTC().Add(time.Duration(in.TTLSeconds) * time.Second) record := &model.AgentInstallToken{ - Token: token, - NodeID: in.NodeID, - Mode: in.Mode, - Arch: in.Arch, - AgentVer: in.AgentVersion, - DownloadSrc: in.DownloadSrc, - ExpiresAt: expiresAt, - CreatedByID: in.CreatedByID, + Token: token, + NodeID: in.NodeID, + Mode: in.Mode, + Arch: in.Arch, + AgentVer: in.AgentVersion, + DownloadSrc: in.DownloadSrc, + AgentMasterURL: strings.TrimRight(strings.TrimSpace(in.AgentMasterURL), "/"), + ProxyURL: strings.TrimSpace(in.ProxyURL), + CACertFile: strings.TrimSpace(in.CACertFile), + ExpiresAt: expiresAt, + CreatedByID: in.CreatedByID, } if err := s.repo.Create(ctx, record); err != nil { return nil, err @@ -130,9 +137,15 @@ func (s *InstallTokenService) Create(ctx context.Context, in InstallTokenInput) // CreateCommand 创建 install token,并返回 UI 展示安装命令所需的 URL 与嵌入式脚本。 func (s *InstallTokenService) CreateCommand(ctx context.Context, in InstallCommandInput) (*InstallCommandOutput, error) { masterURL := strings.TrimRight(strings.TrimSpace(in.MasterURL), "/") - if masterURL == "" { - return nil, apperror.BadRequest("INSTALL_TOKEN_INVALID", "masterURL 必填", nil) + deliveryURL, parseErr := url.Parse(masterURL) + if masterURL == "" || parseErr != nil || (deliveryURL.Scheme != "http" && deliveryURL.Scheme != "https") || deliveryURL.Host == "" || deliveryURL.User != nil || deliveryURL.RawQuery != "" || deliveryURL.Fragment != "" || strings.ContainsAny(masterURL, " \t\r\n\"'`$\\") { + return nil, apperror.BadRequest("INSTALL_TOKEN_INVALID", "masterURL 必须是安全的完整 HTTP(S) 地址", parseErr) } + agentMasterURL := strings.TrimRight(strings.TrimSpace(in.AgentMasterURL), "/") + if agentMasterURL == "" { + agentMasterURL = masterURL + } + in.AgentMasterURL = agentMasterURL if err := s.validate(in.InstallTokenInput); err != nil { return nil, err } @@ -144,12 +157,15 @@ func (s *InstallTokenService) CreateCommand(ctx context.Context, in InstallComma return nil, apperror.New(404, "NODE_NOT_FOUND", "节点不存在", nil) } if _, err := renderInstallCommandScript(masterURL, node, &model.AgentInstallToken{ - Mode: in.Mode, - Arch: in.Arch, - AgentVer: in.AgentVersion, - DownloadSrc: in.DownloadSrc, + Mode: in.Mode, + Arch: in.Arch, + AgentVer: in.AgentVersion, + DownloadSrc: in.DownloadSrc, + AgentMasterURL: in.AgentMasterURL, + ProxyURL: in.ProxyURL, + CACertFile: in.CACertFile, }); err != nil { - return nil, err + return nil, apperror.BadRequest("INSTALL_TOKEN_INVALID", "Agent 连接配置无效", err) } out, err := s.Create(ctx, in.InstallTokenInput) if err != nil { @@ -164,20 +180,24 @@ func (s *InstallTokenService) CreateCommand(ctx context.Context, in InstallComma ExpiresAt: out.ExpiresAt, Node: out.Node, Record: out.Record, - URL: masterURL + "/api/install/" + out.Token, - FallbackURL: masterURL + "/install/" + out.Token, + URL: agentMasterURL + "/api/install/" + out.Token, + FallbackURL: agentMasterURL + "/install/" + out.Token, ScriptBase64: base64.StdEncoding.EncodeToString([]byte(script)), } if out.Record.Mode == model.InstallModeDocker { - result.ComposeURL = masterURL + "/api/install/" + out.Token + "/compose.yml" - result.FallbackComposeURL = masterURL + "/install/" + out.Token + "/compose.yml" + result.ComposeURL = agentMasterURL + "/api/install/" + out.Token + "/compose.yml" + result.FallbackComposeURL = agentMasterURL + "/install/" + out.Token + "/compose.yml" } return result, nil } func renderInstallCommandScript(masterURL string, node *model.Node, record *model.AgentInstallToken) (string, error) { + agentMasterURL := strings.TrimRight(strings.TrimSpace(record.AgentMasterURL), "/") + if agentMasterURL == "" { + agentMasterURL = masterURL + } return installscript.RenderScript(installscript.Context{ - MasterURL: masterURL, + MasterURL: agentMasterURL, AgentToken: node.Token, AgentVersion: record.AgentVer, Mode: record.Mode, @@ -185,6 +205,8 @@ func renderInstallCommandScript(masterURL string, node *model.Node, record *mode DownloadBase: installscript.DownloadBaseFor(record.DownloadSrc), InstallPrefix: "/opt/backupx-agent", NodeID: node.ID, + ProxyURL: record.ProxyURL, + CACertFile: record.CACertFile, }) } @@ -259,6 +281,9 @@ func (s *InstallTokenService) validate(in InstallTokenInput) error { return apperror.BadRequest("INSTALL_TOKEN_INVALID", fmt.Sprintf("ttlSeconds 需在 %d-%d", InstallTokenMinTTL, InstallTokenMaxTTL), nil) } + if len(in.AgentMasterURL) > 2048 || len(in.ProxyURL) > 2048 || len(in.CACertFile) > 512 { + return apperror.BadRequest("INSTALL_TOKEN_INVALID", "连接配置过长", nil) + } return nil } diff --git a/server/internal/service/install_token_service_test.go b/server/internal/service/install_token_service_test.go index 5ac0789..e9a7bf0 100644 --- a/server/internal/service/install_token_service_test.go +++ b/server/internal/service/install_token_service_test.go @@ -2,7 +2,9 @@ package service import ( "context" + "encoding/base64" "path/filepath" + "strings" "testing" "time" @@ -177,13 +179,16 @@ func TestInstallTokenServiceCreateCommandBuildsURLsAndScript(t *testing.T) { out, err := svc.CreateCommand(context.Background(), InstallCommandInput{ InstallTokenInput: InstallTokenInput{ - NodeID: node.ID, - Mode: model.InstallModeDocker, - Arch: model.InstallArchAuto, - AgentVersion: "v1.7.0", - DownloadSrc: model.InstallSourceGitHub, - TTLSeconds: 900, - CreatedByID: 1, + NodeID: node.ID, + Mode: model.InstallModeDocker, + Arch: model.InstallArchAuto, + AgentVersion: "v1.7.0", + DownloadSrc: model.InstallSourceGitHub, + TTLSeconds: 900, + CreatedByID: 1, + AgentMasterURL: "http://127.0.0.1:18340", + ProxyURL: "socks5h://127.0.0.1:1080", + CACertFile: "/etc/pki/internal-ca.pem", }, MasterURL: "https://public.example.com/base", }) @@ -193,15 +198,66 @@ func TestInstallTokenServiceCreateCommandBuildsURLsAndScript(t *testing.T) { if out.Token == "" || out.ScriptBase64 == "" { t.Fatalf("missing token or script: %+v", out) } - if out.URL != "https://public.example.com/base/api/install/"+out.Token { + if out.URL != "http://127.0.0.1:18340/api/install/"+out.Token { t.Fatalf("bad url: %s", out.URL) } - if out.FallbackURL != "https://public.example.com/base/install/"+out.Token { + if out.FallbackURL != "http://127.0.0.1:18340/install/"+out.Token { t.Fatalf("bad fallback url: %s", out.FallbackURL) } - if out.ComposeURL != "https://public.example.com/base/api/install/"+out.Token+"/compose.yml" { + if out.ComposeURL != "http://127.0.0.1:18340/api/install/"+out.Token+"/compose.yml" { t.Fatalf("bad compose url: %s", out.ComposeURL) } + script, err := base64.StdEncoding.DecodeString(out.ScriptBase64) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + `MASTER_URL="http://127.0.0.1:18340"`, + `PROXY_URL="socks5h://127.0.0.1:1080"`, + `CA_CERT_FILE="/etc/pki/internal-ca.pem"`, + } { + if !strings.Contains(string(script), want) { + t.Fatalf("script missing %q", want) + } + } +} + +func TestInstallTokenServiceRejectsUnsafeConnectionBeforeCreate(t *testing.T) { + db := openInstallTokenTestDB(t) + nodeRepo := repository.NewNodeRepository(db) + node := &model.Node{Name: "restricted", Token: "deadbeefcafebabe0123456789abcdef0123456789abcdef0123456789abcdef"} + if err := nodeRepo.Create(context.Background(), node); err != nil { + t.Fatal(err) + } + tokenRepo := repository.NewAgentInstallTokenRepository(db) + svc := NewInstallTokenService(tokenRepo, nodeRepo) + _, err := svc.CreateCommand(context.Background(), InstallCommandInput{ + InstallTokenInput: InstallTokenInput{ + NodeID: node.ID, Mode: model.InstallModeSystemd, Arch: model.InstallArchAuto, + AgentVersion: "v2.4.0", DownloadSrc: model.InstallSourceGitHub, TTLSeconds: 900, + ProxyURL: "http://user:pass@proxy.example.com", + }, + MasterURL: "https://public.example.com", + }) + if err == nil { + t.Fatal("expected proxy credentials to be rejected") + } + count, countErr := tokenRepo.CountCreatedSince(context.Background(), node.ID, time.Now().UTC().Add(-time.Hour)) + if countErr != nil || count != 0 { + t.Fatalf("invalid connection created token records: count=%d err=%v", count, countErr) + } + + _, err = svc.CreateCommand(context.Background(), InstallCommandInput{ + InstallTokenInput: InstallTokenInput{ + NodeID: node.ID, Mode: model.InstallModeSystemd, Arch: model.InstallArchAuto, + AgentVersion: "v2.4.0", DownloadSrc: model.InstallSourceGitHub, TTLSeconds: 900, + AgentMasterURL: "https://master.internal", + }, + MasterURL: "http://public.example.com/$unsafe", + }) + if err == nil { + t.Fatal("expected unsafe public delivery URL to be rejected") + } } func TestInstallTokenServiceRateLimit(t *testing.T) { diff --git a/web/src/pages/nodes/AgentInstallWizard.tsx b/web/src/pages/nodes/AgentInstallWizard.tsx index c237200..a14abfd 100644 --- a/web/src/pages/nodes/AgentInstallWizard.tsx +++ b/web/src/pages/nodes/AgentInstallWizard.tsx @@ -1,11 +1,12 @@ import React, { useEffect, useRef, useState } from 'react' import { Modal, Steps, Button, Space, Message, Spin } from '@arco-design/web-react' import { Step1NodeName, type Mode } from './wizard/Step1NodeName' -import { Step2DeployOptions, type DeployOptions } from './wizard/Step2DeployOptions' +import { Step2DeployOptions, isReleaseVersion, type DeployOptions } from './wizard/Step2DeployOptions' import { Step3CommandPreview } from './wizard/Step3CommandPreview' import { BatchCommandTable, type BatchCommandRow } from './BatchCommandTable' -import type { InstallTokenResult } from '../../types/nodes' +import type { InstallTokenInput, InstallTokenResult } from '../../types/nodes' import { useAgentDeployFlow, type AgentDeployRow } from './useAgentDeployFlow' +import { validateAgentConnection } from './wizard/AgentConnectionOptions' const Step = Steps.Step @@ -29,15 +30,19 @@ export function AgentInstallWizard({ visible, onClose, onSuccess, masterVersion, const [deploy, setDeploy] = useState({ mode: 'systemd', arch: 'auto', - agentVersion: masterVersion || '', + agentVersion: isReleaseVersion(masterVersion) ? masterVersion || '' : '', downloadSrc: 'github', ttlSeconds: 900, + connectionMode: 'direct', + agentMasterUrl: '', + proxyUrl: '', + caCertFile: '', }) // 当父组件异步拿到 masterVersion 后,同步到 deploy.agentVersion(仅初始为空时) useEffect(() => { - if (masterVersion && !deploy.agentVersion) { - setDeploy((prev) => ({ ...prev, agentVersion: masterVersion })) + if (isReleaseVersion(masterVersion) && !deploy.agentVersion) { + setDeploy((prev) => ({ ...prev, agentVersion: masterVersion as string })) } }, [masterVersion]) // eslint-disable-line react-hooks/exhaustive-deps @@ -98,17 +103,23 @@ export function AgentInstallWizard({ visible, onClose, onSuccess, masterVersion, Message.warning('请填写 Agent 版本号(形如 v1.7.0)') return } + const connectionError = validateAgentConnection(deploy) + if (connectionError) { + Message.warning(connectionError) + return + } + const installInput = toInstallTokenInput(deploy) setSubmitting(true) try { if (fixedNode) { - const result = await deployFlow.submitExistingNode(fixedNode, deploy) + const result = await deployFlow.submitExistingNode(fixedNode, installInput) applySingleOrTableResult(result.rows, fixedNode) } else if (mode === 'single') { - const result = await deployFlow.submitNewNodes([singleName.trim()], deploy) + const result = await deployFlow.submitNewNodes([singleName.trim()], installInput) applySingleOrTableResult(result.rows) } else { const names = parseBatchNames() - const result = await deployFlow.submitNewNodes(names, deploy) + const result = await deployFlow.submitNewNodes(names, installInput) if (mountedRef.current) setBatchRows(toBatchRows(result.rows)) if (result.status === 'partialFailed') { Message.warning('部分节点安装命令生成失败,可在结果表中查看') @@ -127,7 +138,7 @@ export function AgentInstallWizard({ visible, onClose, onSuccess, masterVersion, if (!singleNodeInfo) return setSubmitting(true) try { - const row = await deployFlow.regenerateNode(singleNodeInfo, deploy) + const row = await deployFlow.regenerateNode(singleNodeInfo, toInstallTokenInput(deploy)) if (row.status === 'ready' && row.installToken) { setSingleToken(row.installToken) } else { @@ -143,7 +154,7 @@ export function AgentInstallWizard({ visible, onClose, onSuccess, masterVersion, const retryBatchNode = async (row: BatchCommandRow) => { setSubmitting(true) try { - const next = await deployFlow.regenerateNode({ id: row.nodeId, name: row.nodeName }, deploy) + const next = await deployFlow.regenerateNode({ id: row.nodeId, name: row.nodeName }, toInstallTokenInput(deploy)) setBatchRows((rows) => rows.map((item) => ( item.nodeId === row.nodeId ? toBatchRows([next])[0] : item ))) @@ -164,6 +175,9 @@ export function AgentInstallWizard({ visible, onClose, onSuccess, masterVersion, arch: deploy.arch, agentVersion: deploy.agentVersion, downloadSrc: deploy.downloadSrc, + agentMasterUrl: deploy.connectionMode === 'restricted' ? deploy.agentMasterUrl.trim() : '', + proxyUrl: deploy.connectionMode === 'restricted' ? deploy.proxyUrl.trim() : '', + caCertFile: deploy.connectionMode === 'restricted' ? deploy.caCertFile.trim() : '', } // fixedNode 路径下步骤只有 2 步(部署参数 + 安装命令),step 值从 1 开始, @@ -236,7 +250,6 @@ export function AgentInstallWizard({ visible, onClose, onSuccess, masterVersion, nodeId={singleNodeInfo.id} nodeName={singleNodeInfo.name} token={singleToken} - mode={deploy.mode} previewParams={previewParams} onRegenerate={regenerateSingle} /> @@ -268,6 +281,19 @@ export function AgentInstallWizard({ visible, onClose, onSuccess, masterVersion, } } +function toInstallTokenInput(deploy: DeployOptions): InstallTokenInput { + return { + mode: deploy.mode, + arch: deploy.arch, + agentVersion: deploy.agentVersion.trim(), + downloadSrc: deploy.downloadSrc, + ttlSeconds: deploy.ttlSeconds, + agentMasterUrl: deploy.connectionMode === 'restricted' ? deploy.agentMasterUrl.trim() : undefined, + proxyUrl: deploy.connectionMode === 'restricted' ? deploy.proxyUrl.trim() : undefined, + caCertFile: deploy.connectionMode === 'restricted' ? deploy.caCertFile.trim() : undefined, + } +} + function toBatchRows(rows: AgentDeployRow[]): BatchCommandRow[] { return rows.map((row) => ({ nodeId: row.nodeId, diff --git a/web/src/pages/nodes/BatchCommandTable.tsx b/web/src/pages/nodes/BatchCommandTable.tsx index c724e45..badd4fc 100644 --- a/web/src/pages/nodes/BatchCommandTable.tsx +++ b/web/src/pages/nodes/BatchCommandTable.tsx @@ -81,7 +81,7 @@ export function BatchCommandTable({ rows, onRetryNode }: Props) { } return ( {cmd as string} diff --git a/web/src/pages/nodes/NodesPage.tsx b/web/src/pages/nodes/NodesPage.tsx index bdcd079..79c4dd5 100644 --- a/web/src/pages/nodes/NodesPage.tsx +++ b/web/src/pages/nodes/NodesPage.tsx @@ -121,7 +121,7 @@ export default function NodesPage() { 新 Token(24 小时内新旧 Token 均可认证,便于滚动替换): - + {newToken} @@ -138,7 +138,7 @@ export default function NodesPage() { render: (name: string, record: NodeSummary) => ( {record.isLocal ? : } - {name} + {name} {record.isLocal && 本机} ), diff --git a/web/src/pages/nodes/installCommands.test.ts b/web/src/pages/nodes/installCommands.test.ts index 27a0895..4b569d1 100644 --- a/web/src/pages/nodes/installCommands.test.ts +++ b/web/src/pages/nodes/installCommands.test.ts @@ -17,21 +17,32 @@ describe('install command builders', () => { 'https://master.example.com/install/abc', ) - expect(cmd).toContain('/tmp/bx-agent-install.sh') + expect(cmd).toContain('mktemp /tmp/bx-agent-install.XXXXXX') expect(cmd).toContain("'https://master.example.com/install/abc'") expect(cmd).toContain('non-script content') + expect(cmd).toContain('umask 077') + expect(cmd).toContain('rm -f "$tmp"') }) - it('keeps URL install command as primary even when embedded script is available', () => { + it('keeps the one-time URL as the primary install command', () => { const cmd = buildAgentInstallCommand( 'https://master.example.com/api/install/abc', 'https://master.example.com/install/abc', - 'IyEvYmluL3NoCg==', ) expect(cmd).toContain('https://master.example.com/api/install/abc') expect(cmd).toContain('https://master.example.com/install/abc') - expect(cmd).not.toContain('IyEvYmluL3NoCg==') + }) + + it('binds proxy and private CA settings to installer downloads', () => { + const cmd = buildAgentInstallCommand( + 'https://master.internal/api/install/abc', + undefined, + { proxyUrl: 'socks5h://127.0.0.1:1080', caCertFile: '/etc/backupx-agent/ca.pem' }, + ) + + expect(cmd).toContain("--proxy 'socks5h://127.0.0.1:1080'") + expect(cmd).toContain("--cacert '/etc/backupx-agent/ca.pem'") }) it('builds embedded fallback command explicitly', () => { diff --git a/web/src/pages/nodes/installCommands.ts b/web/src/pages/nodes/installCommands.ts index 5203c7c..1646dcb 100644 --- a/web/src/pages/nodes/installCommands.ts +++ b/web/src/pages/nodes/installCommands.ts @@ -12,16 +12,34 @@ 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) { +export interface InstallFetchOptions { + proxyUrl?: string + caCertFile?: string +} + +function curlFetch(url: string, destination: string, options: InstallFetchOptions) { + const args = ['curl', '-fsS'] + if (options.proxyUrl?.trim()) { + args.push('--proxy', shellQuote(options.proxyUrl.trim())) + } + if (options.caCertFile?.trim()) { + args.push('--cacert', shellQuote(options.caCertFile.trim())) + } + args.push(shellQuote(url), '-o', destination) + return args.join(' ') +} + +export function buildAgentInstallCommand(url: string, fallbackUrl?: string, options: InstallFetchOptions = {}) { 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")` + ? `(${curlFetch(urls[0], '"$tmp"', options)} && grep -q ${marker} "$tmp" || ${curlFetch(urls[1], '"$tmp"', options)})` + : `(${curlFetch(urls[0], '"$tmp"', options)} && grep -q ${marker} "$tmp")` return [ + 'umask 077', '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; }; }`, @@ -29,24 +47,27 @@ export function buildAgentInstallCommand(url: string, fallbackUrl?: string, _scr ].join(' && ') + '; rc=$?; rm -f "$tmp"; test $rc -eq 0' } -export function buildAgentDownloadCommand(url: string, fallbackUrl?: string, _scriptBase64?: string) { +export function buildAgentDownloadCommand(url: string, fallbackUrl?: string, options: InstallFetchOptions = {}) { 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)` + ? `(${curlFetch(primary, '"$tmp"', options)} && grep -q ${marker} "$tmp" || ${curlFetch(fallback, '"$tmp"', options)})` + : `(${curlFetch(primary, '"$tmp"', options)} && grep -q ${marker} "$tmp")` return [ + 'umask 077', + 'tmp=$(mktemp /tmp/bx-agent-install.XXXXXX)', 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(' && ') + `{ 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 buildEmbeddedAgentInstallCommand(scriptBase64: string) { const marker = shellQuote(INSTALL_MAGIC_MARKER) return [ + 'umask 077', 'enc=$(mktemp)', 'tmp=$(mktemp)', `printf %s ${shellQuote(scriptBase64.trim())} > "$enc"`, diff --git a/web/src/pages/nodes/useAgentDeployFlow.test.ts b/web/src/pages/nodes/useAgentDeployFlow.test.ts index 93b0c69..5938e63 100644 --- a/web/src/pages/nodes/useAgentDeployFlow.test.ts +++ b/web/src/pages/nodes/useAgentDeployFlow.test.ts @@ -76,6 +76,24 @@ describe('createAgentDeployFlow', () => { }) }) + it('uses restricted-network options in batch install commands', async () => { + const flow = createAgentDeployFlow({ + batchCreateNodes: async () => [{ id: 1, name: 'restricted' }], + createInstallToken: async () => tokenResult({ + url: 'https://master.internal/api/install/install-token', + fallbackUrl: 'https://master.internal/install/install-token', + }), + }) + const result = await flow.submitNewNodes(['restricted'], { + ...deployOptions(), + proxyUrl: 'socks5h://127.0.0.1:1080', + caCertFile: '/etc/backupx-agent/ca.pem', + }) + + expect(result.rows[0].command).toContain("--proxy 'socks5h://127.0.0.1:1080'") + expect(result.rows[0].command).toContain("--cacert '/etc/backupx-agent/ca.pem'") + }) + it('rejects duplicate names before creating nodes', async () => { const flow = createAgentDeployFlow({ batchCreateNodes: async () => { diff --git a/web/src/pages/nodes/useAgentDeployFlow.ts b/web/src/pages/nodes/useAgentDeployFlow.ts index 8c73c3c..bfcc70a 100644 --- a/web/src/pages/nodes/useAgentDeployFlow.ts +++ b/web/src/pages/nodes/useAgentDeployFlow.ts @@ -41,7 +41,7 @@ export function createAgentDeployFlow(deps: AgentDeployFlowDeps) { const issueTokenForNode = async (node: AgentDeployNode, input: InstallTokenInput): Promise => { try { const token = await deps.createInstallToken(node.id, input) - return readyRow(node, token) + return readyRow(node, token, input) } catch (error) { return { nodeId: node.id, @@ -77,12 +77,15 @@ export function useAgentDeployFlow() { return useMemo(() => createAgentDeployFlow({ batchCreateNodes, createInstallToken }), []) } -function readyRow(node: AgentDeployNode, token: InstallTokenResult): AgentDeployRow { +function readyRow(node: AgentDeployNode, token: InstallTokenResult, input: InstallTokenInput): AgentDeployRow { return { nodeId: node.id, nodeName: node.name, status: 'ready', - command: buildAgentInstallCommand(token.url, token.fallbackUrl), + command: buildAgentInstallCommand(token.url, token.fallbackUrl, { + proxyUrl: input.proxyUrl, + caCertFile: input.caCertFile, + }), expiresAt: token.expiresAt, installToken: token, embeddedCommand: token.scriptBase64 diff --git a/web/src/pages/nodes/wizard/AgentConnectionOptions.test.ts b/web/src/pages/nodes/wizard/AgentConnectionOptions.test.ts new file mode 100644 index 0000000..780571c --- /dev/null +++ b/web/src/pages/nodes/wizard/AgentConnectionOptions.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { validateAgentConnection, type AgentConnectionValue } from './AgentConnectionOptions' + +function connection(patch: Partial = {}): AgentConnectionValue { + return { + connectionMode: 'restricted', + agentMasterUrl: '', + proxyUrl: '', + caCertFile: '', + ...patch, + } +} + +describe('validateAgentConnection', () => { + it('accepts direct connectivity without overrides', () => { + expect(validateAgentConnection(connection({ connectionMode: 'direct' }))).toBe('') + }) + + it('accepts an SSH local-forward URL and SOCKS5 proxy', () => { + expect(validateAgentConnection(connection({ agentMasterUrl: 'http://127.0.0.1:18340' }))).toBe('') + expect(validateAgentConnection(connection({ proxyUrl: 'socks5h://127.0.0.1:1080' }))).toBe('') + }) + + it('rejects empty restricted settings and relative CA paths', () => { + expect(validateAgentConnection(connection())).not.toBe('') + expect(validateAgentConnection(connection({ caCertFile: 'internal-ca.pem' }))).not.toBe('') + }) + + it('rejects credentials and shell-unsafe values before submission', () => { + expect(validateAgentConnection(connection({ agentMasterUrl: 'https://user:pass@master.example.com' }))).not.toBe('') + expect(validateAgentConnection(connection({ proxyUrl: 'http://user:pass@proxy.example.com' }))).not.toBe('') + expect(validateAgentConnection(connection({ caCertFile: '/etc/pki/internal ca.pem' }))).not.toBe('') + }) +}) diff --git a/web/src/pages/nodes/wizard/AgentConnectionOptions.tsx b/web/src/pages/nodes/wizard/AgentConnectionOptions.tsx new file mode 100644 index 0000000..d549487 --- /dev/null +++ b/web/src/pages/nodes/wizard/AgentConnectionOptions.tsx @@ -0,0 +1,110 @@ +import React from 'react' +import { Form, Input, Radio, Typography } from '@arco-design/web-react' + +const { Text } = Typography + +export type ConnectionMode = 'direct' | 'restricted' + +export interface AgentConnectionValue { + connectionMode: ConnectionMode + agentMasterUrl: string + proxyUrl: string + caCertFile: string +} + +interface Props { + value: AgentConnectionValue + onChange: (value: AgentConnectionValue) => void +} + +export function AgentConnectionOptions({ value, onChange }: Props) { + const update = (patch: Partial) => onChange({ ...value, ...patch }) + + return ( + <> + Agent 只需主动访问 Master,不需要从 Master 反向开放节点端口。} + > + update({ connectionMode: mode as ConnectionMode })} + options={[ + { label: '直连', value: 'direct' }, + { label: '代理或堡垒机', value: 'restricted' }, + ]} + /> + + + {value.connectionMode === 'restricted' && ( + <> + 可填写经 SSH 本地转发后的地址;留空则继续使用 Master 对外地址。} + > + update({ agentMasterUrl })} + /> + + 支持 http、https、socks5、socks5h;SSH 动态转发可使用 socks5h://127.0.0.1:1080。} + > + update({ proxyUrl })} + /> + + 目标节点上已存在的 PEM 文件绝对路径;安装器会复制到受保护的 Agent 配置目录。} + > + update({ caCertFile })} + /> + + + )} + + ) +} + +export function validateAgentConnection(value: AgentConnectionValue) { + if (value.connectionMode === 'direct') return '' + const agentMasterUrl = value.agentMasterUrl.trim() + const proxyUrl = value.proxyUrl.trim() + const caCertFile = value.caCertFile.trim() + if (!agentMasterUrl && !proxyUrl && !caCertFile) { + return '请至少填写 Agent 连接地址、代理 URL 或私有 CA 路径' + } + if (agentMasterUrl) { + try { + const parsed = new URL(agentMasterUrl) + if (!['http:', 'https:'].includes(parsed.protocol) || !parsed.host || parsed.username || parsed.password || parsed.search || parsed.hash || /\s/.test(agentMasterUrl)) { + return 'Agent 连接地址必须是不含凭据、查询参数和片段的完整 HTTP(S) URL' + } + } catch { + return 'Agent 连接地址必须是完整的 HTTP 或 HTTPS URL' + } + } + if (proxyUrl) { + try { + const parsed = new URL(proxyUrl) + if (!['http:', 'https:', 'socks5:', 'socks5h:'].includes(parsed.protocol) || !parsed.host || parsed.username || parsed.password || (parsed.pathname !== '' && parsed.pathname !== '/') || parsed.search || parsed.hash || /\s/.test(proxyUrl)) { + return '代理 URL 仅支持无凭据、无路径的 http、https、socks5 或 socks5h 地址' + } + } catch { + return '代理 URL 仅支持 http、https、socks5 或 socks5h' + } + } + if (caCertFile && (!/^\/(?:[A-Za-z0-9._-]+\/)*[A-Za-z0-9._-]+$/.test(caCertFile) || caCertFile.split('/').some((part) => part === '..'))) { + return '私有 CA 证书必须使用不含空格或特殊字符的绝对路径' + } + return '' +} diff --git a/web/src/pages/nodes/wizard/InstallCommandBlock.tsx b/web/src/pages/nodes/wizard/InstallCommandBlock.tsx new file mode 100644 index 0000000..0dbc0f7 --- /dev/null +++ b/web/src/pages/nodes/wizard/InstallCommandBlock.tsx @@ -0,0 +1,30 @@ +import React, { type ReactNode } from 'react' +import { Button, Space, Typography } from '@arco-design/web-react' +import { IconCopy } from '../../../components/icons' + +const { Text } = Typography + +interface Props { + label?: string + command: string + disabled?: boolean + action?: ReactNode + onCopy: (command: string) => void +} + +export function InstallCommandBlock({ label, command, disabled, action, onCopy }: Props) { + return ( +
+ {label && {label}} + + {command} + +
+ + + {action} + +
+
+ ) +} diff --git a/web/src/pages/nodes/wizard/Step1NodeName.tsx b/web/src/pages/nodes/wizard/Step1NodeName.tsx index 67c592c..13bb788 100644 --- a/web/src/pages/nodes/wizard/Step1NodeName.tsx +++ b/web/src/pages/nodes/wizard/Step1NodeName.tsx @@ -32,7 +32,7 @@ export function Step1NodeName({ {mode === 'single' ? (
- 节点名称 + 节点名称 ) : (
- 节点名称(每行一个,最多 50 个) + 节点名称(每行一个,最多 50 个)