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
+2 -2
View File
@@ -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, "")
+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))
@@ -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)
+85
View File
@@ -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"
}
+145 -8
View File
@@ -2,11 +2,13 @@ package installscript
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"backupx/server/internal/model"
"gopkg.in/yaml.v3"
)
// 使用合法 hex token32 字节 = 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
@@ -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
@@ -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" <<CONFIG
master: "${MASTER_URL}"
tokenFile: "${TOKEN_FILE}"
heartbeatInterval: "15s"
pollInterval: "5s"
tempDir: "/var/lib/backupx-agent/tmp"
proxyUrl: "${PROXY_URL}"
caCertFile: "${CA_CERT_FILE}"
CONFIG
chmod 0600 "$CONFIG_FILE"
{{if ne .Mode "docker"}}
# 3. 下载二进制(systemd / foreground 模式)
# 4. 下载并安装二进制(systemd / foreground 模式)
ARCHIVE="backupx-${AGENT_VERSION}-linux-${ARCH}.tar.gz"
URL="${DOWNLOAD_BASE}/${AGENT_VERSION}/${ARCHIVE}"
TMPDIR="$(mktemp -d)"; trap 'rm -rf "$TMPDIR"' EXIT
TMPDIR="$(mktemp -d)"
trap 'rm -rf "$TMPDIR"' EXIT HUP INT TERM
echo "[1/4] 下载 ${URL}"
if command -v curl >/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 <<UNIT
Description=BackupX Agent
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=10
[Service]
Type=simple
Environment="BACKUPX_AGENT_MASTER=${MASTER_URL}"
Environment="BACKUPX_AGENT_TOKEN=${AGENT_TOKEN}"
ExecStart=${INSTALL_PREFIX}/backupx agent --temp-dir /var/lib/backupx-agent/tmp
ExecStart=${INSTALL_PREFIX}/backupx agent --config ${CONFIG_FILE}
Restart=on-failure
RestartSec=10s
# Agent 需以 root 运行以读取任意源数据;与单机服务端保持一致的资源/句柄上限。
TimeoutStopSec=30s
UMask=0077
# Agent 以 root 运行,以便读取备份源及执行恢复;节点 Token 仅保存在 0600 文件中。
LimitNOFILE=65535
[Install]
WantedBy=multi-user.target
UNIT
chmod 0644 /etc/systemd/system/backupx-agent.service
systemctl daemon-reload
systemctl enable --now backupx-agent
if ! systemctl enable backupx-agent || ! systemctl restart backupx-agent; then
echo "BackupX Agent 服务启动失败" >&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] 前台启动 agentCtrl+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] 前台启动 AgentCtrl+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}}
+15 -11
View File
@@ -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" }
@@ -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)
}
@@ -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
}
@@ -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) {
+37 -11
View File
@@ -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<DeployOptions>({
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,
+1 -1
View File
@@ -81,7 +81,7 @@ export function BatchCommandTable({ rows, onRetryNode }: Props) {
}
return (
<Text style={{
fontFamily: 'monospace', fontSize: 12, wordBreak: 'break-all',
fontSize: 12, wordBreak: 'break-all',
opacity: left === 0 ? 0.4 : 1,
}}>
{cmd as string}
+2 -2
View File
@@ -121,7 +121,7 @@ export default function NodesPage() {
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
Token24 Token 便
</Text>
<Text copyable style={{ fontFamily: 'monospace', fontSize: 12, wordBreak: 'break-all' }}>
<Text copyable style={{ fontSize: 12, wordBreak: 'break-all' }}>
{newToken}
</Text>
</div>
@@ -138,7 +138,7 @@ export default function NodesPage() {
render: (name: string, record: NodeSummary) => (
<Space>
{record.isLocal ? <IconDesktop style={{ color: 'var(--color-primary-6)' }} /> : <IconCloudDownload />}
<Text bold>{name}</Text>
<Text>{name}</Text>
{record.isLocal && <Tag color="arcoblue" size="small" bordered></Tag>}
</Space>
),
+15 -4
View File
@@ -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', () => {
+30 -9
View File
@@ -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"`,
@@ -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 () => {
+6 -3
View File
@@ -41,7 +41,7 @@ export function createAgentDeployFlow(deps: AgentDeployFlowDeps) {
const issueTokenForNode = async (node: AgentDeployNode, input: InstallTokenInput): Promise<AgentDeployRow> => {
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
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest'
import { validateAgentConnection, type AgentConnectionValue } from './AgentConnectionOptions'
function connection(patch: Partial<AgentConnectionValue> = {}): 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('')
})
})
@@ -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<AgentConnectionValue>) => onChange({ ...value, ...patch })
return (
<>
<Form.Item
label="Agent 网络路径"
extra={<Text type="secondary">Agent 访 Master Master </Text>}
>
<Radio.Group
type="button"
value={value.connectionMode}
onChange={(mode) => update({ connectionMode: mode as ConnectionMode })}
options={[
{ label: '直连', value: 'direct' },
{ label: '代理或堡垒机', value: 'restricted' },
]}
/>
</Form.Item>
{value.connectionMode === 'restricted' && (
<>
<Form.Item
label="Agent 连接地址"
extra={<Text type="secondary"> SSH 使 Master </Text>}
>
<Input
value={value.agentMasterUrl}
placeholder="例如 http://127.0.0.1:18340"
onChange={(agentMasterUrl) => update({ agentMasterUrl })}
/>
</Form.Item>
<Form.Item
label="显式代理 URL"
extra={<Text type="secondary"> httphttpssocks5socks5hSSH 使 socks5h://127.0.0.1:1080。</Text>}
>
<Input
value={value.proxyUrl}
placeholder="可选,例如 socks5h://127.0.0.1:1080"
onChange={(proxyUrl) => update({ proxyUrl })}
/>
</Form.Item>
<Form.Item
label="私有 CA 证书路径"
extra={<Text type="secondary"> PEM Agent </Text>}
>
<Input
value={value.caCertFile}
placeholder="可选,例如 /etc/pki/ca-trust/source/anchors/internal-ca.pem"
onChange={(caCertFile) => update({ caCertFile })}
/>
</Form.Item>
</>
)}
</>
)
}
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 ''
}
@@ -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 (
<div style={{ background: 'var(--color-fill-2)', padding: '12px 14px', borderRadius: 4, marginBottom: 12 }}>
{label && <Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>{label}</Text>}
<Text style={{ fontSize: 13, wordBreak: 'break-all', opacity: disabled ? 0.4 : 1, userSelect: 'all' }}>
{command}
</Text>
<div style={{ marginTop: 8 }}>
<Space>
<Button size="small" icon={<IconCopy />} disabled={disabled} onClick={() => onCopy(command)}></Button>
{action}
</Space>
</div>
</div>
)
}
+3 -3
View File
@@ -32,7 +32,7 @@ export function Step1NodeName({
</div>
{mode === 'single' ? (
<div>
<Text bold style={{ marginBottom: 6, display: 'block' }}></Text>
<Text style={{ marginBottom: 6, display: 'block' }}></Text>
<Input
placeholder="如:prod-db-01"
value={singleName}
@@ -42,13 +42,13 @@ export function Step1NodeName({
</div>
) : (
<div>
<Text bold style={{ marginBottom: 6, display: 'block' }}> 50 </Text>
<Text style={{ marginBottom: 6, display: 'block' }}> 50 </Text>
<TextArea
rows={8}
placeholder={'prod-db-01\nprod-db-02\nprod-web-01'}
value={batchText}
onChange={onBatchTextChange}
style={{ fontFamily: 'monospace', fontSize: 13 }}
style={{ fontSize: 13 }}
/>
<Text type="secondary" style={{ fontSize: 12, marginTop: 4, display: 'block' }}>
@@ -0,0 +1,12 @@
import { describe, expect, it } from 'vitest'
import { isReleaseVersion } from './Step2DeployOptions'
describe('isReleaseVersion', () => {
it('accepts release tags and rejects source-build versions', () => {
expect(isReleaseVersion('v2.4.0')).toBe(true)
expect(isReleaseVersion('2.4.0-rc.1')).toBe(true)
expect(isReleaseVersion('dev')).toBe(false)
expect(isReleaseVersion('00151e4')).toBe(false)
expect(isReleaseVersion(null)).toBe(false)
})
})
@@ -1,10 +1,11 @@
import React from 'react'
import { Form, Radio, Select, Input, Typography } from '@arco-design/web-react'
import type { InstallMode, InstallArch, InstallSource } from '../../../types/nodes'
import { AgentConnectionOptions, type AgentConnectionValue } from './AgentConnectionOptions'
const { Text } = Typography
export interface DeployOptions {
export interface DeployOptions extends AgentConnectionValue {
mode: InstallMode
arch: InstallArch
agentVersion: string
@@ -21,12 +22,17 @@ interface Props {
export function Step2DeployOptions({ masterVersion, value, onChange }: Props) {
const update = (patch: Partial<DeployOptions>) => onChange({ ...value, ...patch })
const versionKnown = !!masterVersion
const versionKnown = isReleaseVersion(masterVersion)
const versionLoading = masterVersion === null
return (
<Form layout="vertical" size="default">
<Form.Item label="安装模式">
<Form.Item
label="安装模式"
extra={value.mode === 'docker'
? <Text type="warning">Docker Agent 访使 volume systemd</Text>
: undefined}
>
<Radio.Group
type="button"
value={value.mode}
@@ -56,7 +62,9 @@ export function Step2DeployOptions({ masterVersion, value, onChange }: Props) {
extra={
!versionKnown && !versionLoading ? (
<Text type="warning" style={{ fontSize: 12 }}>
Master v1.7.0
{masterVersion
? `当前 Master 版本 ${masterVersion} 不是可下载的 Release,请手动输入 Agent Release 标签`
: '未能自动获取 Master 版本,请手动输入 Agent Release 标签(形如 v1.7.0'}
</Text>
) : undefined
}
@@ -106,6 +114,12 @@ export function Step2DeployOptions({ masterVersion, value, onChange }: Props) {
]}
/>
</Form.Item>
<AgentConnectionOptions value={value} onChange={(connection) => update(connection)} />
</Form>
)
}
export function isReleaseVersion(version: string | null) {
return !!version && /^v?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(version)
}
@@ -1,9 +1,10 @@
import React, { useEffect, useState } from 'react'
import { Typography, Button, Space, Collapse, Spin, Message, Tag } from '@arco-design/web-react'
import { IconCopy, IconRefresh } from '../../../components/icons'
import { IconRefresh } from '../../../components/icons'
import { fetchScriptPreview } from '../../../services/nodes'
import type { InstallTokenResult, InstallMode } from '../../../types/nodes'
import type { InstallTokenResult } from '../../../types/nodes'
import { buildAgentDownloadCommand, buildAgentInstallCommand, buildEmbeddedAgentInstallCommand } from '../installCommands'
import { InstallCommandBlock } from './InstallCommandBlock'
const { Text } = Typography
@@ -11,12 +12,19 @@ interface Props {
nodeId: number
nodeName: string
token: InstallTokenResult
mode: InstallMode
previewParams: { mode: string; arch: string; agentVersion: string; downloadSrc: string }
previewParams: {
mode: string
arch: string
agentVersion: string
downloadSrc: string
agentMasterUrl?: string
proxyUrl?: string
caCertFile?: string
}
onRegenerate: () => void
}
export function Step3CommandPreview({ nodeId, nodeName, token, mode, previewParams, onRegenerate }: Props) {
export function Step3CommandPreview({ nodeId, nodeName, token, previewParams, onRegenerate }: Props) {
const [remaining, setRemaining] = useState(0)
const [preview, setPreview] = useState<string>('')
const [loadingPreview, setLoadingPreview] = useState(false)
@@ -30,12 +38,10 @@ export function Step3CommandPreview({ nodeId, nodeName, token, mode, previewPara
}, [token.expiresAt])
const expired = remaining === 0
const command = buildAgentInstallCommand(token.url, token.fallbackUrl)
const fallbackCommand = buildAgentDownloadCommand(token.url, token.fallbackUrl)
const fetchOptions = { proxyUrl: previewParams.proxyUrl, caCertFile: previewParams.caCertFile }
const command = buildAgentInstallCommand(token.url, token.fallbackUrl, fetchOptions)
const fallbackCommand = buildAgentDownloadCommand(token.url, token.fallbackUrl, fetchOptions)
const embeddedCommand = token.scriptBase64 ? buildEmbeddedAgentInstallCommand(token.scriptBase64) : null
const dockerComposeCmd = mode === 'docker' && token.composeUrl
? `curl -fsSL ${token.composeUrl} -o docker-compose.yml && docker-compose up -d`
: null
const copy = async (s: string) => {
await navigator.clipboard.writeText(s)
@@ -57,73 +63,28 @@ export function Step3CommandPreview({ nodeId, nodeName, token, mode, previewPara
return (
<div>
<Space style={{ marginBottom: 12 }}>
<Text bold></Text>
<Text></Text>
<Tag>{nodeName}</Tag>
<Tag color={expired ? 'gray' : 'green'}>
{expired ? '已过期' : `有效期 ${Math.floor(remaining / 60)}:${String(remaining % 60).padStart(2, '0')}`}
</Tag>
</Space>
<div style={{ background: 'var(--color-fill-2)', padding: '12px 14px', borderRadius: 6, marginBottom: 12 }}>
<Text style={{
fontFamily: 'monospace', fontSize: 13, wordBreak: 'break-all',
opacity: expired ? 0.4 : 1, userSelect: 'all',
}}>
{command}
</Text>
<div style={{ marginTop: 8 }}>
<Space>
<Button size="small" icon={<IconCopy />} disabled={expired} onClick={() => copy(command)}></Button>
{expired && <Button size="small" type="primary" icon={<IconRefresh />} onClick={onRegenerate}></Button>}
</Space>
</div>
</div>
<InstallCommandBlock
command={command}
disabled={expired}
onCopy={copy}
action={expired ? <Button size="small" type="primary" icon={<IconRefresh />} onClick={onRegenerate}></Button> : undefined}
/>
<div style={{ background: 'var(--color-fill-2)', padding: '12px 14px', borderRadius: 6, marginBottom: 12 }}>
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
/tmp
</Text>
<Text style={{
fontFamily: 'monospace', fontSize: 13, wordBreak: 'break-all',
opacity: expired ? 0.4 : 1, userSelect: 'all',
}}>
{fallbackCommand}
</Text>
<div style={{ marginTop: 8 }}>
<Button size="small" icon={<IconCopy />} disabled={expired} onClick={() => copy(fallbackCommand)}></Button>
</div>
</div>
{dockerComposeCmd && (
<div style={{ background: 'var(--color-fill-2)', padding: '12px 14px', borderRadius: 6, marginBottom: 12 }}>
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
使 docker-compose
</Text>
<Text style={{ fontFamily: 'monospace', fontSize: 13, wordBreak: 'break-all', opacity: expired ? 0.4 : 1 }}>
{dockerComposeCmd}
</Text>
<div style={{ marginTop: 8 }}>
<Button size="small" icon={<IconCopy />} disabled={expired} onClick={() => copy(dockerComposeCmd)}></Button>
</div>
</div>
)}
<InstallCommandBlock label="或先下载到 /tmp 后执行:" command={fallbackCommand} disabled={expired} onCopy={copy} />
{embeddedCommand && (
<div style={{ background: 'var(--color-fill-2)', padding: '12px 14px', borderRadius: 6, marginBottom: 12 }}>
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
使
</Text>
<Text style={{ fontFamily: 'monospace', fontSize: 13, wordBreak: 'break-all', userSelect: 'all' }}>
{embeddedCommand}
</Text>
<div style={{ marginTop: 8 }}>
<Button size="small" icon={<IconCopy />} onClick={() => copy(embeddedCommand)}></Button>
</div>
</div>
<InstallCommandBlock label="安装入口不可达时使用嵌入式备用命令:" command={embeddedCommand} onCopy={copy} />
)}
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 8 }}>
install token TTL token
install token TTL Token
</Text>
<Collapse bordered={false} onChange={(_key, keys) => {
+9 -1
View File
@@ -59,7 +59,15 @@ export async function rotateNodeToken(nodeId: number) {
export async function fetchScriptPreview(
nodeId: number,
params: { mode: string; arch: string; agentVersion: string; downloadSrc: string },
params: {
mode: string
arch: string
agentVersion: string
downloadSrc: string
agentMasterUrl?: string
proxyUrl?: string
caCertFile?: string
},
) {
const response = await http.get<string>(`/nodes/${nodeId}/install-script-preview`, {
params,
+3
View File
@@ -51,6 +51,9 @@ export interface InstallTokenInput {
agentVersion: string
downloadSrc: InstallSource
ttlSeconds: number
agentMasterUrl?: string
proxyUrl?: string
caCertFile?: string
}
export interface InstallTokenResult {