mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-09-08 17:08:14 +08:00
feat(deploy): harden restricted-network agent installs
This commit is contained in:
@@ -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 {
|
walkErr := filepath.Walk(sourcePath, func(currentPath string, currentInfo os.FileInfo, walkErr error) error {
|
||||||
if walkErr != nil {
|
if walkErr != nil {
|
||||||
writer.WriteLine(fmt.Sprintf("⚠ 无法访问 %s: %v", currentPath, walkErr))
|
writer.WriteLine(fmt.Sprintf("[WARN] 无法访问 %s: %v", currentPath, walkErr))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
relPath, err := filepath.Rel(baseParent, currentPath)
|
relPath, err := filepath.Rel(baseParent, currentPath)
|
||||||
@@ -115,7 +115,7 @@ func (r *FileRunner) Run(_ context.Context, task TaskSpec, writer LogWriter) (*R
|
|||||||
|
|
||||||
if currentInfo.IsDir() {
|
if currentInfo.IsDir() {
|
||||||
dirCount++
|
dirCount++
|
||||||
writer.WriteLine(fmt.Sprintf("📁 进入目录 %s", archiveName))
|
writer.WriteLine(fmt.Sprintf("[DIR] 进入目录 %s", archiveName))
|
||||||
}
|
}
|
||||||
|
|
||||||
header, err := tar.FileInfoHeader(currentInfo, "")
|
header, err := tar.FileInfoHeader(currentInfo, "")
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ func setupInstallFlowRouterWithExternalURL(t *testing.T, externalURL string) (ht
|
|||||||
return router, setupResp.Data.Token
|
return router, setupResp.Data.Token
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestInstallTokenUsesConfiguredExternalURL(t *testing.T) {
|
func TestInstallTokenUsesAgentSpecificURLAndConnectionSettings(t *testing.T) {
|
||||||
const externalURL = "https://public.example.com/base"
|
const externalURL = "https://public.example.com/base"
|
||||||
router, jwt := setupInstallFlowRouterWithExternalURL(t, externalURL)
|
router, jwt := setupInstallFlowRouterWithExternalURL(t, externalURL)
|
||||||
|
|
||||||
@@ -146,6 +146,9 @@ func TestInstallTokenUsesConfiguredExternalURL(t *testing.T) {
|
|||||||
"agentVersion": "v1.7.0",
|
"agentVersion": "v1.7.0",
|
||||||
"downloadSrc": "github",
|
"downloadSrc": "github",
|
||||||
"ttlSeconds": 900,
|
"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,
|
genReq := httptest.NewRequest(http.MethodPost,
|
||||||
"/api/nodes/"+formatUint(batchResp.Data[0].ID)+"/install-tokens", bytes.NewBuffer(genBody))
|
"/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 {
|
if genRec.Code != 200 {
|
||||||
t.Fatalf("install-tokens failed: %d %s", genRec.Code, genRec.Body.String())
|
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 {
|
var genResp struct {
|
||||||
Data struct {
|
Data struct {
|
||||||
InstallToken string `json:"installToken"`
|
InstallToken string `json:"installToken"`
|
||||||
@@ -167,18 +173,21 @@ func TestInstallTokenUsesConfiguredExternalURL(t *testing.T) {
|
|||||||
if err := json.Unmarshal(genRec.Body.Bytes(), &genResp); err != nil {
|
if err := json.Unmarshal(genRec.Body.Bytes(), &genResp); err != nil {
|
||||||
t.Fatalf("unmarshal gen: %v", err)
|
t.Fatalf("unmarshal gen: %v", err)
|
||||||
}
|
}
|
||||||
if genResp.Data.URL != externalURL+"/api/install/"+genResp.Data.InstallToken {
|
if genResp.Data.URL != "http://127.0.0.1:18340/api/install/"+genResp.Data.InstallToken {
|
||||||
t.Fatalf("url should use external URL, got %q", genResp.Data.URL)
|
t.Fatalf("url should use Agent-specific URL, got %q", genResp.Data.URL)
|
||||||
}
|
}
|
||||||
if genResp.Data.FallbackURL != externalURL+"/install/"+genResp.Data.InstallToken {
|
if genResp.Data.FallbackURL != "http://127.0.0.1:18340/install/"+genResp.Data.InstallToken {
|
||||||
t.Fatalf("fallbackUrl should use external URL, got %q", genResp.Data.FallbackURL)
|
t.Fatalf("fallbackUrl should use Agent-specific URL, got %q", genResp.Data.FallbackURL)
|
||||||
}
|
}
|
||||||
decodedScript, err := base64.StdEncoding.DecodeString(genResp.Data.ScriptBase64)
|
decodedScript, err := base64.StdEncoding.DecodeString(genResp.Data.ScriptBase64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("scriptBase64 should be valid base64: %v", err)
|
t.Fatalf("scriptBase64 should be valid base64: %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(string(decodedScript), `MASTER_URL="`+externalURL+`"`) {
|
if !strings.Contains(string(decodedScript), `MASTER_URL="http://127.0.0.1:18340"`) {
|
||||||
t.Fatalf("script should use external MASTER_URL:\n%s", string(decodedScript))
|
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 {
|
if scriptRec.Code != 200 {
|
||||||
t.Fatalf("script fetch failed: %d %s", scriptRec.Code, scriptRec.Body.String())
|
t.Fatalf("script fetch failed: %d %s", scriptRec.Code, scriptRec.Body.String())
|
||||||
}
|
}
|
||||||
if !strings.Contains(scriptRec.Body.String(), "systemctl enable --now backupx-agent") {
|
if !strings.Contains(scriptRec.Body.String(), "systemctl enable backupx-agent") ||
|
||||||
t.Fatalf("script missing systemctl enable:\n%s", scriptRec.Body.String())
|
!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
|
// Issue #46 防嗅探 headers:text/plain + nosniff + no-store + Content-Disposition
|
||||||
if ct := scriptRec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") {
|
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 {
|
if aliasRec.Code != 200 {
|
||||||
t.Fatalf("/api/install alias failed: %d %s", aliasRec.Code, aliasRec.Body.String())
|
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())
|
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") {
|
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") {
|
if !strings.Contains(composeRec.Body.String(), "BACKUPX_AGENT_TOKEN") {
|
||||||
t.Fatalf("compose missing token env:\n%s", composeRec.Body.String())
|
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)
|
scriptReq := httptest.NewRequest(http.MethodGet, "/api/install/"+genResp.Data.InstallToken, nil)
|
||||||
scriptRec := httptest.NewRecorder()
|
scriptRec := httptest.NewRecorder()
|
||||||
|
|||||||
@@ -104,16 +104,22 @@ func (h *InstallHandler) Compose(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
h.recordConsumeAudit(c, consumed, "compose")
|
h.recordConsumeAudit(c, consumed, "compose")
|
||||||
yaml, err := installscript.RenderComposeYaml(installscript.Context{
|
yaml, err := installscript.RenderComposeYaml(installscript.Context{
|
||||||
MasterURL: resolveMasterURL(c, h.externalURL),
|
MasterURL: installMasterURL(resolveMasterURL(c, h.externalURL), consumed.Record),
|
||||||
AgentToken: consumed.Node.Token,
|
AgentToken: consumed.Node.Token,
|
||||||
AgentVersion: consumed.Record.AgentVer,
|
AgentVersion: consumed.Record.AgentVer,
|
||||||
Mode: model.InstallModeDocker,
|
Mode: model.InstallModeDocker,
|
||||||
|
Arch: consumed.Record.Arch,
|
||||||
NodeID: consumed.Node.ID,
|
NodeID: consumed.Node.ID,
|
||||||
|
ProxyURL: consumed.Record.ProxyURL,
|
||||||
|
CACertFile: consumed.Record.CACertFile,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.String(stdhttp.StatusInternalServerError, "render error\n")
|
c.String(stdhttp.StatusInternalServerError, "render error\n")
|
||||||
return
|
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))
|
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) {
|
func renderInstallScript(masterURL string, node *model.Node, record *model.AgentInstallToken) (string, error) {
|
||||||
return installscript.RenderScript(installscript.Context{
|
return installscript.RenderScript(installscript.Context{
|
||||||
MasterURL: masterURL,
|
MasterURL: installMasterURL(masterURL, record),
|
||||||
AgentToken: node.Token,
|
AgentToken: node.Token,
|
||||||
AgentVersion: record.AgentVer,
|
AgentVersion: record.AgentVer,
|
||||||
Mode: record.Mode,
|
Mode: record.Mode,
|
||||||
@@ -142,9 +148,18 @@ func renderInstallScript(masterURL string, node *model.Node, record *model.Agent
|
|||||||
DownloadBase: installscript.DownloadBaseFor(record.DownloadSrc),
|
DownloadBase: installscript.DownloadBaseFor(record.DownloadSrc),
|
||||||
InstallPrefix: "/opt/backupx-agent",
|
InstallPrefix: "/opt/backupx-agent",
|
||||||
NodeID: node.ID,
|
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。
|
// resolveMasterURL 按优先级推导 Master URL:外部配置 > X-Forwarded-* > Request.Host。
|
||||||
// 此为包级 helper,供 install_handler 和 node_handler 共用。
|
// 此为包级 helper,供 install_handler 和 node_handler 共用。
|
||||||
func resolveMasterURL(c *gin.Context, externalURL string) string {
|
func resolveMasterURL(c *gin.Context, externalURL string) string {
|
||||||
|
|||||||
@@ -204,6 +204,7 @@ func (h *NodeHandler) RotateToken(c *gin.Context) {
|
|||||||
recordAudit(c, h.auditService, "node", "rotate_token", "node",
|
recordAudit(c, h.auditService, "node", "rotate_token", "node",
|
||||||
fmt.Sprintf("%d", id), "",
|
fmt.Sprintf("%d", id), "",
|
||||||
fmt.Sprintf("轮换节点 Token (ID: %d)", id))
|
fmt.Sprintf("轮换节点 Token (ID: %d)", id))
|
||||||
|
c.Header("Cache-Control", "no-store")
|
||||||
response.Success(c, gin.H{"newToken": tok})
|
response.Success(c, gin.H{"newToken": tok})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,6 +226,9 @@ func (h *NodeHandler) CreateInstallToken(c *gin.Context) {
|
|||||||
AgentVersion string `json:"agentVersion"`
|
AgentVersion string `json:"agentVersion"`
|
||||||
DownloadSrc string `json:"downloadSrc"`
|
DownloadSrc string `json:"downloadSrc"`
|
||||||
TTLSeconds int `json:"ttlSeconds"`
|
TTLSeconds int `json:"ttlSeconds"`
|
||||||
|
AgentMasterURL string `json:"agentMasterUrl"`
|
||||||
|
ProxyURL string `json:"proxyUrl"`
|
||||||
|
CACertFile string `json:"caCertFile"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
c.JSON(stdhttp.StatusBadRequest, gin.H{"code": "INVALID_INPUT", "message": err.Error()})
|
c.JSON(stdhttp.StatusBadRequest, gin.H{"code": "INVALID_INPUT", "message": err.Error()})
|
||||||
@@ -253,6 +257,9 @@ func (h *NodeHandler) CreateInstallToken(c *gin.Context) {
|
|||||||
DownloadSrc: input.DownloadSrc,
|
DownloadSrc: input.DownloadSrc,
|
||||||
TTLSeconds: input.TTLSeconds,
|
TTLSeconds: input.TTLSeconds,
|
||||||
CreatedByID: h.resolveCurrentUserID(c),
|
CreatedByID: h.resolveCurrentUserID(c),
|
||||||
|
AgentMasterURL: input.AgentMasterURL,
|
||||||
|
ProxyURL: input.ProxyURL,
|
||||||
|
CACertFile: input.CACertFile,
|
||||||
},
|
},
|
||||||
MasterURL: resolveMasterURL(c, h.externalURL),
|
MasterURL: resolveMasterURL(c, h.externalURL),
|
||||||
})
|
})
|
||||||
@@ -278,6 +285,7 @@ func (h *NodeHandler) CreateInstallToken(c *gin.Context) {
|
|||||||
"composeUrl": out.ComposeURL,
|
"composeUrl": out.ComposeURL,
|
||||||
"fallbackComposeUrl": out.FallbackComposeURL,
|
"fallbackComposeUrl": out.FallbackComposeURL,
|
||||||
}
|
}
|
||||||
|
c.Header("Cache-Control", "no-store")
|
||||||
response.Success(c, body)
|
response.Success(c, body)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,18 +300,24 @@ func (h *NodeHandler) PreviewScript(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
src := c.DefaultQuery("downloadSrc", "github")
|
src := c.DefaultQuery("downloadSrc", "github")
|
||||||
|
agentMasterURL := c.Query("agentMasterUrl")
|
||||||
|
if agentMasterURL == "" {
|
||||||
|
agentMasterURL = resolveMasterURL(c, h.externalURL)
|
||||||
|
}
|
||||||
ctx := installscript.Context{
|
ctx := installscript.Context{
|
||||||
MasterURL: resolveMasterURL(c, h.externalURL),
|
MasterURL: agentMasterURL,
|
||||||
AgentToken: "<AGENT_TOKEN>",
|
AgentToken: "<AGENT_TOKEN>",
|
||||||
AgentVersion: ver,
|
AgentVersion: ver,
|
||||||
Mode: mode,
|
Mode: mode,
|
||||||
Arch: arch,
|
Arch: arch,
|
||||||
DownloadBase: installscript.DownloadBaseFor(src),
|
DownloadBase: installscript.DownloadBaseFor(src),
|
||||||
InstallPrefix: "/opt/backupx-agent",
|
InstallPrefix: "/opt/backupx-agent",
|
||||||
|
ProxyURL: c.Query("proxyUrl"),
|
||||||
|
CACertFile: c.Query("caCertFile"),
|
||||||
}
|
}
|
||||||
script, err := installscript.RenderScript(ctx)
|
script, err := installscript.RenderScript(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Error(c, err)
|
response.Error(c, apperror.BadRequest("INSTALL_TOKEN_INVALID", "Agent 连接配置无效", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Data(stdhttp.StatusOK, "text/x-shellscript; charset=utf-8", []byte(script))
|
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{
|
for _, want := range []string{
|
||||||
"/var/lib/backupx-agent/tmp",
|
"/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) {
|
if !strings.Contains(got, want) {
|
||||||
t.Errorf("script missing %q:\n%s", want, got)
|
t.Errorf("script missing %q:\n%s", want, got)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
_ "embed"
|
_ "embed"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
"text/template"
|
"text/template"
|
||||||
|
|
||||||
@@ -30,6 +31,8 @@ type Context struct {
|
|||||||
DownloadBase string
|
DownloadBase string
|
||||||
InstallPrefix string
|
InstallPrefix string
|
||||||
NodeID uint
|
NodeID uint
|
||||||
|
ProxyURL string
|
||||||
|
CACertFile string
|
||||||
}
|
}
|
||||||
|
|
||||||
// DownloadBaseFor 将下载源枚举转换为具体 URL 前缀。
|
// DownloadBaseFor 将下载源枚举转换为具体 URL 前缀。
|
||||||
@@ -84,6 +87,12 @@ func RenderComposeYaml(ctx Context) (string, error) {
|
|||||||
// 这些字段被直接写入 shell 双引号字符串和 YAML 双引号值;不做校验会带来
|
// 这些字段被直接写入 shell 双引号字符串和 YAML 双引号值;不做校验会带来
|
||||||
// 注入风险(如 MasterURL 含 `"\nCOMMAND:` 可逃逸 YAML 结构)。
|
// 注入风险(如 MasterURL 含 `"\nCOMMAND:` 可逃逸 YAML 结构)。
|
||||||
func validateContext(ctx Context) error {
|
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 {
|
if err := validateMasterURL(ctx.MasterURL); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -93,6 +102,42 @@ func validateContext(ctx Context) error {
|
|||||||
if err := validateAgentVersion(ctx.AgentVersion); err != nil {
|
if err := validateAgentVersion(ctx.AgentVersion); err != nil {
|
||||||
return err
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,6 +159,43 @@ func validateMasterURL(raw string) error {
|
|||||||
if u.Host == "" {
|
if u.Host == "" {
|
||||||
return fmt.Errorf("master URL missing 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,6 +242,9 @@ func validateAgentVersion(v string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func withDefaults(ctx Context) Context {
|
func withDefaults(ctx Context) Context {
|
||||||
|
if ctx.Arch == "" {
|
||||||
|
ctx.Arch = model.InstallArchAuto
|
||||||
|
}
|
||||||
if ctx.InstallPrefix == "" {
|
if ctx.InstallPrefix == "" {
|
||||||
ctx.InstallPrefix = "/opt/backupx-agent"
|
ctx.InstallPrefix = "/opt/backupx-agent"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ package installscript
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"backupx/server/internal/model"
|
"backupx/server/internal/model"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 使用合法 hex token(32 字节 = 64 字符)以通过 validateAgentToken 校验
|
// 使用合法 hex token(32 字节 = 64 字符)以通过 validateAgentToken 校验
|
||||||
@@ -27,11 +29,13 @@ func TestRenderScriptSystemd(t *testing.T) {
|
|||||||
t.Fatalf("render err: %v", err)
|
t.Fatalf("render err: %v", err)
|
||||||
}
|
}
|
||||||
mustContain := []string{
|
mustContain := []string{
|
||||||
"BACKUPX_AGENT_MASTER=${MASTER_URL}",
|
`master: "${MASTER_URL}"`,
|
||||||
`Environment="BACKUPX_AGENT_TOKEN=${AGENT_TOKEN}"`,
|
`tokenFile: "${TOKEN_FILE}"`,
|
||||||
|
`ExecStart=${INSTALL_PREFIX}/backupx agent --config ${CONFIG_FILE}`,
|
||||||
"/var/lib/backupx-agent/tmp",
|
"/var/lib/backupx-agent/tmp",
|
||||||
"systemctl daemon-reload",
|
"systemctl daemon-reload",
|
||||||
"systemctl enable --now backupx-agent",
|
"systemctl enable backupx-agent",
|
||||||
|
"systemctl restart backupx-agent",
|
||||||
"systemctl status backupx-agent",
|
"systemctl status backupx-agent",
|
||||||
"X-Agent-Token: ${AGENT_TOKEN}",
|
"X-Agent-Token: ${AGENT_TOKEN}",
|
||||||
"MASTER_URL=\"https://master.example.com\"",
|
"MASTER_URL=\"https://master.example.com\"",
|
||||||
@@ -48,6 +52,29 @@ func TestRenderScriptSystemd(t *testing.T) {
|
|||||||
t.Errorf("systemd script unexpectedly contains %q", s)
|
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) {
|
func TestRenderScriptForeground(t *testing.T) {
|
||||||
@@ -90,8 +117,11 @@ func TestRenderScriptDocker(t *testing.T) {
|
|||||||
if !strings.Contains(got, `"awuqing/backupx:${AGENT_VERSION}" agent`) {
|
if !strings.Contains(got, `"awuqing/backupx:${AGENT_VERSION}" agent`) {
|
||||||
t.Errorf("docker script must start image in agent mode:\n%s", got)
|
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"`) {
|
if !strings.Contains(got, `-v /etc/backupx-agent:/etc/backupx-agent:ro`) {
|
||||||
t.Errorf("docker script missing temp dir env:\n%s", got)
|
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`) {
|
if !strings.Contains(got, `docker logs --tail=100 backupx-agent`) {
|
||||||
t.Errorf("docker script missing diagnostic log command:\n%s", got)
|
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") {
|
if strings.Contains(got, "systemctl daemon-reload") {
|
||||||
t.Errorf("docker script should not reference systemctl:\n%s", got)
|
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) {
|
func TestDockerEntrypointForwardsAgentSubcommand(t *testing.T) {
|
||||||
@@ -111,12 +144,18 @@ func TestDockerEntrypointForwardsAgentSubcommand(t *testing.T) {
|
|||||||
t.Fatalf("read docker entrypoint: %v", err)
|
t.Fatalf("read docker entrypoint: %v", err)
|
||||||
}
|
}
|
||||||
script := string(got)
|
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 "$@"`) {
|
if !strings.Contains(script, `exec /app/bin/backupx "$@"`) {
|
||||||
t.Fatalf("entrypoint must exec backupx with forwarded args:\n%s", script)
|
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) {
|
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"`) {
|
if !strings.Contains(got, `BACKUPX_AGENT_TEMP_DIR: "/var/lib/backupx-agent/tmp"`) {
|
||||||
t.Errorf("compose missing temp dir env:\n%s", got)
|
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") {
|
if !strings.Contains(got, "/var/lib/backupx-agent:/var/lib/backupx-agent") {
|
||||||
t.Errorf("compose missing agent data volume:\n%s", got)
|
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) {
|
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) {
|
func TestRenderComposeYamlRejectsInjectedMasterURL(t *testing.T) {
|
||||||
ctx := testCtx
|
ctx := testCtx
|
||||||
ctx.Mode = model.InstallModeDocker
|
ctx.Mode = model.InstallModeDocker
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
# BackupX Agent docker-compose 片段
|
# BackupX Agent Compose 片段
|
||||||
# 生成于 {{.MasterURL}} · 节点 ID {{.NodeID}}
|
# 生成于 {{.MasterURL}} · 节点 ID {{.NodeID}}
|
||||||
version: "3.8"
|
# 文件包含长期节点 Token,请保存为 0600 权限并在部署完成后限制访问。
|
||||||
services:
|
services:
|
||||||
backupx-agent:
|
backupx-agent:
|
||||||
image: awuqing/backupx:{{.AgentVersion}}
|
image: awuqing/backupx:{{.AgentVersion}}
|
||||||
command: ["agent"]
|
command: ["agent"]
|
||||||
|
user: "0:0"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
environment:
|
environment:
|
||||||
BACKUPX_AGENT_MASTER: "{{.MasterURL}}"
|
BACKUPX_AGENT_MASTER: "{{.MasterURL}}"
|
||||||
BACKUPX_AGENT_TOKEN: "{{.AgentToken}}"
|
BACKUPX_AGENT_TOKEN: "{{.AgentToken}}"
|
||||||
BACKUPX_AGENT_TEMP_DIR: "/var/lib/backupx-agent/tmp"
|
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
|
- /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
|
#!/bin/sh
|
||||||
# BackupX Agent 一键安装脚本(由 Master 动态渲染)
|
# BackupX Agent 一键安装脚本(由 Master 动态渲染)
|
||||||
# Magic: BACKUPX_AGENT_INSTALL_V1 —— 若 `head -3 脚本` 看不到此行,说明反向代理/CDN 改写了响应
|
# Magic: BACKUPX_AGENT_INSTALL_V1 —— 若 `head -3 脚本` 看不到此行,说明反向代理或 CDN 改写了响应
|
||||||
# 模式: {{.Mode}} | 架构: {{.Arch}} | 版本: {{.AgentVersion}}
|
# 模式: {{.Mode}} | 架构: {{.Arch}} | 版本: {{.AgentVersion}}
|
||||||
set -eu
|
set -eu
|
||||||
|
umask 077
|
||||||
|
|
||||||
# 自举到 bash(文件执行模式下生效;管道模式 $0 不是文件,exec 会静默失败,继续用 sh)。
|
# 文件执行模式下优先使用 bash;管道模式继续使用 POSIX sh。
|
||||||
# 动机:部分 Debian/Ubuntu 用户通过 `curl | sudo sh` 触发时,dash 对本脚本报语法错误;
|
|
||||||
# 若目标机装有 bash,优先切换到 bash 获得更一致的行为。
|
|
||||||
if [ -z "${BASH_VERSION:-}" ] && command -v bash >/dev/null 2>&1 && [ -f "$0" ]; then
|
if [ -z "${BASH_VERSION:-}" ] && command -v bash >/dev/null 2>&1 && [ -f "$0" ]; then
|
||||||
exec bash "$0" "$@"
|
exec bash "$0" "$@"
|
||||||
fi
|
fi
|
||||||
@@ -17,14 +16,56 @@ AGENT_VERSION="{{.AgentVersion}}"
|
|||||||
DOWNLOAD_BASE="{{.DownloadBase}}"
|
DOWNLOAD_BASE="{{.DownloadBase}}"
|
||||||
INSTALL_PREFIX="{{.InstallPrefix}}"
|
INSTALL_PREFIX="{{.InstallPrefix}}"
|
||||||
ARCH="{{.Arch}}"
|
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. 前置检查
|
# 1. 前置检查
|
||||||
[ "$(id -u)" -eq 0 ] || { echo "请使用 root 或 sudo 执行" >&2; exit 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; }
|
|| { echo "需要 curl 或 wget" >&2; exit 1; }
|
||||||
{{if eq .Mode "systemd"}}command -v systemctl >/dev/null || { echo "不支持非 systemd 系统" >&2; exit 1; }
|
command -v grep >/dev/null 2>&1 || { echo "需要 grep" >&2; exit 1; }
|
||||||
{{end}}{{if eq .Mode "docker"}}command -v docker >/dev/null || { echo "需要先安装 docker" >&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}}
|
{{end}}
|
||||||
|
|
||||||
# 2. 架构检测
|
# 2. 架构检测
|
||||||
if [ "$ARCH" = "auto" ]; then
|
if [ "$ARCH" = "auto" ]; then
|
||||||
case "$(uname -m)" in
|
case "$(uname -m)" in
|
||||||
@@ -34,24 +75,54 @@ if [ "$ARCH" = "auto" ]; then
|
|||||||
esac
|
esac
|
||||||
fi
|
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"}}
|
{{if ne .Mode "docker"}}
|
||||||
# 3. 下载二进制(systemd / foreground 模式)
|
# 4. 下载并安装二进制(systemd / foreground 模式)
|
||||||
ARCHIVE="backupx-${AGENT_VERSION}-linux-${ARCH}.tar.gz"
|
ARCHIVE="backupx-${AGENT_VERSION}-linux-${ARCH}.tar.gz"
|
||||||
URL="${DOWNLOAD_BASE}/${AGENT_VERSION}/${ARCHIVE}"
|
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}"
|
echo "[1/4] 下载 ${URL}"
|
||||||
if command -v curl >/dev/null; then
|
download_file "$URL" "$TMPDIR/$ARCHIVE"
|
||||||
curl -fsSL "$URL" -o "$TMPDIR/pkg.tar.gz"
|
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
|
else
|
||||||
wget -qO "$TMPDIR/pkg.tar.gz" "$URL"
|
echo "已下载校验文件,但系统缺少 sha256sum 或 shasum,拒绝未校验安装" >&2
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
tar xzf "$TMPDIR/pkg.tar.gz" -C "$TMPDIR"
|
else
|
||||||
|
echo "[WARN] 当前版本未提供 SHA-256 校验文件,继续兼容安装" >&2
|
||||||
|
fi
|
||||||
|
tar xzf "$TMPDIR/$ARCHIVE" -C "$TMPDIR"
|
||||||
|
|
||||||
# 4. 安装二进制 + 数据目录
|
|
||||||
echo "[2/4] 安装到 ${INSTALL_PREFIX}"
|
echo "[2/4] 安装到 ${INSTALL_PREFIX}"
|
||||||
install -d -m 0755 "$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.new"
|
||||||
install -m 0755 "$TMPDIR/backupx-${AGENT_VERSION}-linux-${ARCH}/backupx" "$INSTALL_PREFIX/backupx"
|
mv -f "$INSTALL_PREFIX/backupx.new" "$INSTALL_PREFIX/backupx"
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{if eq .Mode "systemd"}}
|
{{if eq .Mode "systemd"}}
|
||||||
@@ -62,69 +133,78 @@ cat > /etc/systemd/system/backupx-agent.service <<UNIT
|
|||||||
Description=BackupX Agent
|
Description=BackupX Agent
|
||||||
After=network-online.target
|
After=network-online.target
|
||||||
Wants=network-online.target
|
Wants=network-online.target
|
||||||
|
StartLimitIntervalSec=300
|
||||||
|
StartLimitBurst=10
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
Environment="BACKUPX_AGENT_MASTER=${MASTER_URL}"
|
ExecStart=${INSTALL_PREFIX}/backupx agent --config ${CONFIG_FILE}
|
||||||
Environment="BACKUPX_AGENT_TOKEN=${AGENT_TOKEN}"
|
|
||||||
ExecStart=${INSTALL_PREFIX}/backupx agent --temp-dir /var/lib/backupx-agent/tmp
|
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=10s
|
RestartSec=10s
|
||||||
# Agent 需以 root 运行以读取任意源数据;与单机服务端保持一致的资源/句柄上限。
|
TimeoutStopSec=30s
|
||||||
|
UMask=0077
|
||||||
|
# Agent 以 root 运行,以便读取备份源及执行恢复;节点 Token 仅保存在 0600 文件中。
|
||||||
LimitNOFILE=65535
|
LimitNOFILE=65535
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
UNIT
|
UNIT
|
||||||
|
chmod 0644 /etc/systemd/system/backupx-agent.service
|
||||||
systemctl daemon-reload
|
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. 等待上线
|
# 6. 等待上线
|
||||||
echo "[4/4] 等待节点上线"
|
echo "[4/4] 等待节点上线"
|
||||||
for i in $(seq 1 15); do
|
i=0
|
||||||
|
while [ "$i" -lt 15 ]; do
|
||||||
sleep 2
|
sleep 2
|
||||||
if curl -fsSL -H "X-Agent-Token: ${AGENT_TOKEN}" "${MASTER_URL}/api/v1/agent/self" 2>/dev/null \
|
if agent_is_online; then
|
||||||
| grep -q '"status":"online"'; then
|
echo "[OK] 节点已上线"
|
||||||
echo "✓ 节点已上线"
|
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
i=$((i + 1))
|
||||||
done
|
done
|
||||||
echo "⚠ 30s 内未收到上线心跳,请检查防火墙或 journalctl -u backupx-agent"
|
echo "[WARN] 30 秒内未收到上线心跳,请检查网络、代理或 SSH 隧道" >&2
|
||||||
echo "提示:systemd 服务名是 backupx-agent,可执行 systemctl status backupx-agent 查看状态。"
|
echo "排查命令: systemctl status backupx-agent" >&2
|
||||||
|
echo "排查命令: journalctl -u backupx-agent -n 100 --no-pager" >&2
|
||||||
exit 2
|
exit 2
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{if eq .Mode "foreground"}}
|
{{if eq .Mode "foreground"}}
|
||||||
# 5. 前台运行
|
# 5. 前台运行
|
||||||
echo "[3/3] 前台启动 agent(Ctrl+C 退出)"
|
echo "[3/3] 前台启动 Agent(Ctrl+C 退出)"
|
||||||
export BACKUPX_AGENT_MASTER="${MASTER_URL}"
|
exec "${INSTALL_PREFIX}/backupx" agent --config "$CONFIG_FILE"
|
||||||
export BACKUPX_AGENT_TOKEN="${AGENT_TOKEN}"
|
|
||||||
exec "${INSTALL_PREFIX}/backupx" agent --temp-dir /var/lib/backupx-agent/tmp
|
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{if eq .Mode "docker"}}
|
{{if eq .Mode "docker"}}
|
||||||
# Docker 模式:直接用镜像启动容器
|
# Docker 模式:配置文件只读挂载,避免 Token 出现在 docker inspect 环境变量中。
|
||||||
echo "[1/2] 拉取镜像 awuqing/backupx:${AGENT_VERSION}"
|
echo "[1/2] 拉取镜像 awuqing/backupx:${AGENT_VERSION}"
|
||||||
docker pull "awuqing/backupx:${AGENT_VERSION}"
|
docker pull "awuqing/backupx:${AGENT_VERSION}"
|
||||||
echo "[2/2] 启动容器 backupx-agent"
|
echo "[2/2] 启动容器 backupx-agent"
|
||||||
docker rm -f backupx-agent >/dev/null 2>&1 || true
|
docker rm -f backupx-agent >/dev/null 2>&1 || true
|
||||||
docker run -d --name backupx-agent --restart=unless-stopped \
|
docker run -d --name backupx-agent --restart=unless-stopped --user 0:0 \
|
||||||
-e "BACKUPX_AGENT_MASTER=${MASTER_URL}" \
|
--security-opt no-new-privileges:true \
|
||||||
-e "BACKUPX_AGENT_TOKEN=${AGENT_TOKEN}" \
|
-v /etc/backupx-agent:/etc/backupx-agent:ro \
|
||||||
-e "BACKUPX_AGENT_TEMP_DIR=/var/lib/backupx-agent/tmp" \
|
|
||||||
-v /var/lib/backupx-agent:/var/lib/backupx-agent \
|
-v /var/lib/backupx-agent:/var/lib/backupx-agent \
|
||||||
"awuqing/backupx:${AGENT_VERSION}" agent
|
"awuqing/backupx:${AGENT_VERSION}" agent --config /etc/backupx-agent/config.yaml
|
||||||
echo "✓ 容器已启动,等待节点上线"
|
echo "提示: Docker Agent 只能访问显式挂载的目录;备份宿主机路径前请按文档添加只读 -v 挂载。"
|
||||||
for i in $(seq 1 15); do
|
echo "容器已启动,等待节点上线"
|
||||||
|
i=0
|
||||||
|
while [ "$i" -lt 15 ]; do
|
||||||
sleep 2
|
sleep 2
|
||||||
if curl -fsSL -H "X-Agent-Token: ${AGENT_TOKEN}" "${MASTER_URL}/api/v1/agent/self" 2>/dev/null \
|
if agent_is_online; then
|
||||||
| grep -q '"status":"online"'; then
|
echo "[OK] 节点已上线"
|
||||||
echo "✓ 节点已上线"
|
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
i=$((i + 1))
|
||||||
done
|
done
|
||||||
echo "⚠ 30s 内未收到上线心跳,请检查容器状态、网络与 Master URL。"
|
echo "[WARN] 30 秒内未收到上线心跳,请检查容器、代理、隧道与 Master 地址" >&2
|
||||||
echo "排查命令:docker ps -a --filter name=backupx-agent"
|
echo "排查命令: docker ps -a --filter name=backupx-agent" >&2
|
||||||
echo "排查命令:docker logs --tail=100 backupx-agent"
|
echo "排查命令: docker logs --tail=100 backupx-agent" >&2
|
||||||
exit 2
|
exit 2
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ type AgentInstallToken struct {
|
|||||||
Arch string `gorm:"size:16;not null" json:"arch"` // amd64|arm64|auto
|
Arch string `gorm:"size:16;not null" json:"arch"` // amd64|arm64|auto
|
||||||
AgentVer string `gorm:"size:32;not null" json:"agentVersion"`
|
AgentVer string `gorm:"size:32;not null" json:"agentVersion"`
|
||||||
DownloadSrc string `gorm:"size:16;not null;default:'github'" json:"downloadSrc"`
|
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"`
|
ExpiresAt time.Time `gorm:"not null;index" json:"expiresAt"`
|
||||||
ConsumedAt *time.Time `json:"consumedAt,omitempty"`
|
ConsumedAt *time.Time `json:"consumedAt,omitempty"`
|
||||||
CreatedByID uint `gorm:"not null" json:"createdById"`
|
CreatedByID uint `gorm:"not null" json:"createdById"`
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ func openTestInstallTokenDB(t *testing.T) *gorm.DB {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("open: %v", err)
|
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 {
|
if err := db.AutoMigrate(&model.AgentInstallToken{}); err != nil {
|
||||||
t.Fatalf("migrate: %v", err)
|
t.Fatalf("migrate: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -34,6 +35,9 @@ type InstallTokenInput struct {
|
|||||||
DownloadSrc string
|
DownloadSrc string
|
||||||
TTLSeconds int
|
TTLSeconds int
|
||||||
CreatedByID uint
|
CreatedByID uint
|
||||||
|
AgentMasterURL string
|
||||||
|
ProxyURL string
|
||||||
|
CACertFile string
|
||||||
}
|
}
|
||||||
|
|
||||||
// InstallTokenOutput 生成结果。
|
// InstallTokenOutput 生成结果。
|
||||||
@@ -118,6 +122,9 @@ func (s *InstallTokenService) Create(ctx context.Context, in InstallTokenInput)
|
|||||||
Arch: in.Arch,
|
Arch: in.Arch,
|
||||||
AgentVer: in.AgentVersion,
|
AgentVer: in.AgentVersion,
|
||||||
DownloadSrc: in.DownloadSrc,
|
DownloadSrc: in.DownloadSrc,
|
||||||
|
AgentMasterURL: strings.TrimRight(strings.TrimSpace(in.AgentMasterURL), "/"),
|
||||||
|
ProxyURL: strings.TrimSpace(in.ProxyURL),
|
||||||
|
CACertFile: strings.TrimSpace(in.CACertFile),
|
||||||
ExpiresAt: expiresAt,
|
ExpiresAt: expiresAt,
|
||||||
CreatedByID: in.CreatedByID,
|
CreatedByID: in.CreatedByID,
|
||||||
}
|
}
|
||||||
@@ -130,9 +137,15 @@ func (s *InstallTokenService) Create(ctx context.Context, in InstallTokenInput)
|
|||||||
// CreateCommand 创建 install token,并返回 UI 展示安装命令所需的 URL 与嵌入式脚本。
|
// CreateCommand 创建 install token,并返回 UI 展示安装命令所需的 URL 与嵌入式脚本。
|
||||||
func (s *InstallTokenService) CreateCommand(ctx context.Context, in InstallCommandInput) (*InstallCommandOutput, error) {
|
func (s *InstallTokenService) CreateCommand(ctx context.Context, in InstallCommandInput) (*InstallCommandOutput, error) {
|
||||||
masterURL := strings.TrimRight(strings.TrimSpace(in.MasterURL), "/")
|
masterURL := strings.TrimRight(strings.TrimSpace(in.MasterURL), "/")
|
||||||
if masterURL == "" {
|
deliveryURL, parseErr := url.Parse(masterURL)
|
||||||
return nil, apperror.BadRequest("INSTALL_TOKEN_INVALID", "masterURL 必填", nil)
|
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 {
|
if err := s.validate(in.InstallTokenInput); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -148,8 +161,11 @@ func (s *InstallTokenService) CreateCommand(ctx context.Context, in InstallComma
|
|||||||
Arch: in.Arch,
|
Arch: in.Arch,
|
||||||
AgentVer: in.AgentVersion,
|
AgentVer: in.AgentVersion,
|
||||||
DownloadSrc: in.DownloadSrc,
|
DownloadSrc: in.DownloadSrc,
|
||||||
|
AgentMasterURL: in.AgentMasterURL,
|
||||||
|
ProxyURL: in.ProxyURL,
|
||||||
|
CACertFile: in.CACertFile,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return nil, err
|
return nil, apperror.BadRequest("INSTALL_TOKEN_INVALID", "Agent 连接配置无效", err)
|
||||||
}
|
}
|
||||||
out, err := s.Create(ctx, in.InstallTokenInput)
|
out, err := s.Create(ctx, in.InstallTokenInput)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -164,20 +180,24 @@ func (s *InstallTokenService) CreateCommand(ctx context.Context, in InstallComma
|
|||||||
ExpiresAt: out.ExpiresAt,
|
ExpiresAt: out.ExpiresAt,
|
||||||
Node: out.Node,
|
Node: out.Node,
|
||||||
Record: out.Record,
|
Record: out.Record,
|
||||||
URL: masterURL + "/api/install/" + out.Token,
|
URL: agentMasterURL + "/api/install/" + out.Token,
|
||||||
FallbackURL: masterURL + "/install/" + out.Token,
|
FallbackURL: agentMasterURL + "/install/" + out.Token,
|
||||||
ScriptBase64: base64.StdEncoding.EncodeToString([]byte(script)),
|
ScriptBase64: base64.StdEncoding.EncodeToString([]byte(script)),
|
||||||
}
|
}
|
||||||
if out.Record.Mode == model.InstallModeDocker {
|
if out.Record.Mode == model.InstallModeDocker {
|
||||||
result.ComposeURL = masterURL + "/api/install/" + out.Token + "/compose.yml"
|
result.ComposeURL = agentMasterURL + "/api/install/" + out.Token + "/compose.yml"
|
||||||
result.FallbackComposeURL = masterURL + "/install/" + out.Token + "/compose.yml"
|
result.FallbackComposeURL = agentMasterURL + "/install/" + out.Token + "/compose.yml"
|
||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderInstallCommandScript(masterURL string, node *model.Node, record *model.AgentInstallToken) (string, error) {
|
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{
|
return installscript.RenderScript(installscript.Context{
|
||||||
MasterURL: masterURL,
|
MasterURL: agentMasterURL,
|
||||||
AgentToken: node.Token,
|
AgentToken: node.Token,
|
||||||
AgentVersion: record.AgentVer,
|
AgentVersion: record.AgentVer,
|
||||||
Mode: record.Mode,
|
Mode: record.Mode,
|
||||||
@@ -185,6 +205,8 @@ func renderInstallCommandScript(masterURL string, node *model.Node, record *mode
|
|||||||
DownloadBase: installscript.DownloadBaseFor(record.DownloadSrc),
|
DownloadBase: installscript.DownloadBaseFor(record.DownloadSrc),
|
||||||
InstallPrefix: "/opt/backupx-agent",
|
InstallPrefix: "/opt/backupx-agent",
|
||||||
NodeID: node.ID,
|
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",
|
return apperror.BadRequest("INSTALL_TOKEN_INVALID",
|
||||||
fmt.Sprintf("ttlSeconds 需在 %d-%d", InstallTokenMinTTL, InstallTokenMaxTTL), nil)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -184,6 +186,9 @@ func TestInstallTokenServiceCreateCommandBuildsURLsAndScript(t *testing.T) {
|
|||||||
DownloadSrc: model.InstallSourceGitHub,
|
DownloadSrc: model.InstallSourceGitHub,
|
||||||
TTLSeconds: 900,
|
TTLSeconds: 900,
|
||||||
CreatedByID: 1,
|
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",
|
MasterURL: "https://public.example.com/base",
|
||||||
})
|
})
|
||||||
@@ -193,15 +198,66 @@ func TestInstallTokenServiceCreateCommandBuildsURLsAndScript(t *testing.T) {
|
|||||||
if out.Token == "" || out.ScriptBase64 == "" {
|
if out.Token == "" || out.ScriptBase64 == "" {
|
||||||
t.Fatalf("missing token or script: %+v", out)
|
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)
|
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)
|
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)
|
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) {
|
func TestInstallTokenServiceRateLimit(t *testing.T) {
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react'
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
import { Modal, Steps, Button, Space, Message, Spin } from '@arco-design/web-react'
|
import { Modal, Steps, Button, Space, Message, Spin } from '@arco-design/web-react'
|
||||||
import { Step1NodeName, type Mode } from './wizard/Step1NodeName'
|
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 { Step3CommandPreview } from './wizard/Step3CommandPreview'
|
||||||
import { BatchCommandTable, type BatchCommandRow } from './BatchCommandTable'
|
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 { useAgentDeployFlow, type AgentDeployRow } from './useAgentDeployFlow'
|
||||||
|
import { validateAgentConnection } from './wizard/AgentConnectionOptions'
|
||||||
|
|
||||||
const Step = Steps.Step
|
const Step = Steps.Step
|
||||||
|
|
||||||
@@ -29,15 +30,19 @@ export function AgentInstallWizard({ visible, onClose, onSuccess, masterVersion,
|
|||||||
const [deploy, setDeploy] = useState<DeployOptions>({
|
const [deploy, setDeploy] = useState<DeployOptions>({
|
||||||
mode: 'systemd',
|
mode: 'systemd',
|
||||||
arch: 'auto',
|
arch: 'auto',
|
||||||
agentVersion: masterVersion || '',
|
agentVersion: isReleaseVersion(masterVersion) ? masterVersion || '' : '',
|
||||||
downloadSrc: 'github',
|
downloadSrc: 'github',
|
||||||
ttlSeconds: 900,
|
ttlSeconds: 900,
|
||||||
|
connectionMode: 'direct',
|
||||||
|
agentMasterUrl: '',
|
||||||
|
proxyUrl: '',
|
||||||
|
caCertFile: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
// 当父组件异步拿到 masterVersion 后,同步到 deploy.agentVersion(仅初始为空时)
|
// 当父组件异步拿到 masterVersion 后,同步到 deploy.agentVersion(仅初始为空时)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (masterVersion && !deploy.agentVersion) {
|
if (isReleaseVersion(masterVersion) && !deploy.agentVersion) {
|
||||||
setDeploy((prev) => ({ ...prev, agentVersion: masterVersion }))
|
setDeploy((prev) => ({ ...prev, agentVersion: masterVersion as string }))
|
||||||
}
|
}
|
||||||
}, [masterVersion]) // eslint-disable-line react-hooks/exhaustive-deps
|
}, [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)')
|
Message.warning('请填写 Agent 版本号(形如 v1.7.0)')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const connectionError = validateAgentConnection(deploy)
|
||||||
|
if (connectionError) {
|
||||||
|
Message.warning(connectionError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const installInput = toInstallTokenInput(deploy)
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
if (fixedNode) {
|
if (fixedNode) {
|
||||||
const result = await deployFlow.submitExistingNode(fixedNode, deploy)
|
const result = await deployFlow.submitExistingNode(fixedNode, installInput)
|
||||||
applySingleOrTableResult(result.rows, fixedNode)
|
applySingleOrTableResult(result.rows, fixedNode)
|
||||||
} else if (mode === 'single') {
|
} else if (mode === 'single') {
|
||||||
const result = await deployFlow.submitNewNodes([singleName.trim()], deploy)
|
const result = await deployFlow.submitNewNodes([singleName.trim()], installInput)
|
||||||
applySingleOrTableResult(result.rows)
|
applySingleOrTableResult(result.rows)
|
||||||
} else {
|
} else {
|
||||||
const names = parseBatchNames()
|
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 (mountedRef.current) setBatchRows(toBatchRows(result.rows))
|
||||||
if (result.status === 'partialFailed') {
|
if (result.status === 'partialFailed') {
|
||||||
Message.warning('部分节点安装命令生成失败,可在结果表中查看')
|
Message.warning('部分节点安装命令生成失败,可在结果表中查看')
|
||||||
@@ -127,7 +138,7 @@ export function AgentInstallWizard({ visible, onClose, onSuccess, masterVersion,
|
|||||||
if (!singleNodeInfo) return
|
if (!singleNodeInfo) return
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
const row = await deployFlow.regenerateNode(singleNodeInfo, deploy)
|
const row = await deployFlow.regenerateNode(singleNodeInfo, toInstallTokenInput(deploy))
|
||||||
if (row.status === 'ready' && row.installToken) {
|
if (row.status === 'ready' && row.installToken) {
|
||||||
setSingleToken(row.installToken)
|
setSingleToken(row.installToken)
|
||||||
} else {
|
} else {
|
||||||
@@ -143,7 +154,7 @@ export function AgentInstallWizard({ visible, onClose, onSuccess, masterVersion,
|
|||||||
const retryBatchNode = async (row: BatchCommandRow) => {
|
const retryBatchNode = async (row: BatchCommandRow) => {
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
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) => (
|
setBatchRows((rows) => rows.map((item) => (
|
||||||
item.nodeId === row.nodeId ? toBatchRows([next])[0] : item
|
item.nodeId === row.nodeId ? toBatchRows([next])[0] : item
|
||||||
)))
|
)))
|
||||||
@@ -164,6 +175,9 @@ export function AgentInstallWizard({ visible, onClose, onSuccess, masterVersion,
|
|||||||
arch: deploy.arch,
|
arch: deploy.arch,
|
||||||
agentVersion: deploy.agentVersion,
|
agentVersion: deploy.agentVersion,
|
||||||
downloadSrc: deploy.downloadSrc,
|
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 开始,
|
// fixedNode 路径下步骤只有 2 步(部署参数 + 安装命令),step 值从 1 开始,
|
||||||
@@ -236,7 +250,6 @@ export function AgentInstallWizard({ visible, onClose, onSuccess, masterVersion,
|
|||||||
nodeId={singleNodeInfo.id}
|
nodeId={singleNodeInfo.id}
|
||||||
nodeName={singleNodeInfo.name}
|
nodeName={singleNodeInfo.name}
|
||||||
token={singleToken}
|
token={singleToken}
|
||||||
mode={deploy.mode}
|
|
||||||
previewParams={previewParams}
|
previewParams={previewParams}
|
||||||
onRegenerate={regenerateSingle}
|
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[] {
|
function toBatchRows(rows: AgentDeployRow[]): BatchCommandRow[] {
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
nodeId: row.nodeId,
|
nodeId: row.nodeId,
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export function BatchCommandTable({ rows, onRetryNode }: Props) {
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Text style={{
|
<Text style={{
|
||||||
fontFamily: 'monospace', fontSize: 12, wordBreak: 'break-all',
|
fontSize: 12, wordBreak: 'break-all',
|
||||||
opacity: left === 0 ? 0.4 : 1,
|
opacity: left === 0 ? 0.4 : 1,
|
||||||
}}>
|
}}>
|
||||||
{cmd as string}
|
{cmd as string}
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ export default function NodesPage() {
|
|||||||
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
||||||
新 Token(24 小时内新旧 Token 均可认证,便于滚动替换):
|
新 Token(24 小时内新旧 Token 均可认证,便于滚动替换):
|
||||||
</Text>
|
</Text>
|
||||||
<Text copyable style={{ fontFamily: 'monospace', fontSize: 12, wordBreak: 'break-all' }}>
|
<Text copyable style={{ fontSize: 12, wordBreak: 'break-all' }}>
|
||||||
{newToken}
|
{newToken}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
@@ -138,7 +138,7 @@ export default function NodesPage() {
|
|||||||
render: (name: string, record: NodeSummary) => (
|
render: (name: string, record: NodeSummary) => (
|
||||||
<Space>
|
<Space>
|
||||||
{record.isLocal ? <IconDesktop style={{ color: 'var(--color-primary-6)' }} /> : <IconCloudDownload />}
|
{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>}
|
{record.isLocal && <Tag color="arcoblue" size="small" bordered>本机</Tag>}
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -17,21 +17,32 @@ describe('install command builders', () => {
|
|||||||
'https://master.example.com/install/abc',
|
'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("'https://master.example.com/install/abc'")
|
||||||
expect(cmd).toContain('non-script content')
|
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(
|
const cmd = buildAgentInstallCommand(
|
||||||
'https://master.example.com/api/install/abc',
|
'https://master.example.com/api/install/abc',
|
||||||
'https://master.example.com/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/api/install/abc')
|
||||||
expect(cmd).toContain('https://master.example.com/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', () => {
|
it('builds embedded fallback command explicitly', () => {
|
||||||
|
|||||||
@@ -12,16 +12,34 @@ function runScriptCommand(path: string) {
|
|||||||
return `if [ "$(id -u)" -eq 0 ]; then sh ${path}; else sudo sh ${path}; fi`
|
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 primary = url.trim()
|
||||||
const fallback = (fallbackUrl || legacyInstallUrl(primary)).trim()
|
const fallback = (fallbackUrl || legacyInstallUrl(primary)).trim()
|
||||||
const urls = fallback && fallback !== primary ? [primary, fallback] : [primary]
|
const urls = fallback && fallback !== primary ? [primary, fallback] : [primary]
|
||||||
const marker = shellQuote(INSTALL_MAGIC_MARKER)
|
const marker = shellQuote(INSTALL_MAGIC_MARKER)
|
||||||
const fetchScript = urls.length > 1
|
const fetchScript = urls.length > 1
|
||||||
? `(curl -fsSL ${shellQuote(urls[0])} -o "$tmp" && grep -q ${marker} "$tmp" || curl -fsSL ${shellQuote(urls[1])} -o "$tmp")`
|
? `(${curlFetch(urls[0], '"$tmp"', options)} && grep -q ${marker} "$tmp" || ${curlFetch(urls[1], '"$tmp"', options)})`
|
||||||
: `(curl -fsSL ${shellQuote(urls[0])} -o "$tmp" && grep -q ${marker} "$tmp")`
|
: `(${curlFetch(urls[0], '"$tmp"', options)} && grep -q ${marker} "$tmp")`
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
'umask 077',
|
||||||
'tmp=$(mktemp)',
|
'tmp=$(mktemp)',
|
||||||
fetchScript,
|
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; }; }`,
|
`{ 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'
|
].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 primary = url.trim()
|
||||||
const fallback = (fallbackUrl || legacyInstallUrl(primary)).trim()
|
const fallback = (fallbackUrl || legacyInstallUrl(primary)).trim()
|
||||||
const marker = shellQuote(INSTALL_MAGIC_MARKER)
|
const marker = shellQuote(INSTALL_MAGIC_MARKER)
|
||||||
const fetchScript = fallback && fallback !== primary
|
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)`
|
? `(${curlFetch(primary, '"$tmp"', options)} && grep -q ${marker} "$tmp" || ${curlFetch(fallback, '"$tmp"', options)})`
|
||||||
: `(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")`
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
'umask 077',
|
||||||
|
'tmp=$(mktemp /tmp/bx-agent-install.XXXXXX)',
|
||||||
fetchScript,
|
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; }; }`,
|
`{ 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/bx-agent-install.sh'),
|
runScriptCommand('"$tmp"'),
|
||||||
].join(' && ')
|
].join(' && ') + '; rc=$?; rm -f "$tmp"; test $rc -eq 0'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildEmbeddedAgentInstallCommand(scriptBase64: string) {
|
export function buildEmbeddedAgentInstallCommand(scriptBase64: string) {
|
||||||
const marker = shellQuote(INSTALL_MAGIC_MARKER)
|
const marker = shellQuote(INSTALL_MAGIC_MARKER)
|
||||||
return [
|
return [
|
||||||
|
'umask 077',
|
||||||
'enc=$(mktemp)',
|
'enc=$(mktemp)',
|
||||||
'tmp=$(mktemp)',
|
'tmp=$(mktemp)',
|
||||||
`printf %s ${shellQuote(scriptBase64.trim())} > "$enc"`,
|
`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 () => {
|
it('rejects duplicate names before creating nodes', async () => {
|
||||||
const flow = createAgentDeployFlow({
|
const flow = createAgentDeployFlow({
|
||||||
batchCreateNodes: async () => {
|
batchCreateNodes: async () => {
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export function createAgentDeployFlow(deps: AgentDeployFlowDeps) {
|
|||||||
const issueTokenForNode = async (node: AgentDeployNode, input: InstallTokenInput): Promise<AgentDeployRow> => {
|
const issueTokenForNode = async (node: AgentDeployNode, input: InstallTokenInput): Promise<AgentDeployRow> => {
|
||||||
try {
|
try {
|
||||||
const token = await deps.createInstallToken(node.id, input)
|
const token = await deps.createInstallToken(node.id, input)
|
||||||
return readyRow(node, token)
|
return readyRow(node, token, input)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
nodeId: node.id,
|
nodeId: node.id,
|
||||||
@@ -77,12 +77,15 @@ export function useAgentDeployFlow() {
|
|||||||
return useMemo(() => createAgentDeployFlow({ batchCreateNodes, createInstallToken }), [])
|
return useMemo(() => createAgentDeployFlow({ batchCreateNodes, createInstallToken }), [])
|
||||||
}
|
}
|
||||||
|
|
||||||
function readyRow(node: AgentDeployNode, token: InstallTokenResult): AgentDeployRow {
|
function readyRow(node: AgentDeployNode, token: InstallTokenResult, input: InstallTokenInput): AgentDeployRow {
|
||||||
return {
|
return {
|
||||||
nodeId: node.id,
|
nodeId: node.id,
|
||||||
nodeName: node.name,
|
nodeName: node.name,
|
||||||
status: 'ready',
|
status: 'ready',
|
||||||
command: buildAgentInstallCommand(token.url, token.fallbackUrl),
|
command: buildAgentInstallCommand(token.url, token.fallbackUrl, {
|
||||||
|
proxyUrl: input.proxyUrl,
|
||||||
|
caCertFile: input.caCertFile,
|
||||||
|
}),
|
||||||
expiresAt: token.expiresAt,
|
expiresAt: token.expiresAt,
|
||||||
installToken: token,
|
installToken: token,
|
||||||
embeddedCommand: token.scriptBase64
|
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">支持 http、https、socks5、socks5h;SSH 动态转发可使用 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -32,7 +32,7 @@ export function Step1NodeName({
|
|||||||
</div>
|
</div>
|
||||||
{mode === 'single' ? (
|
{mode === 'single' ? (
|
||||||
<div>
|
<div>
|
||||||
<Text bold style={{ marginBottom: 6, display: 'block' }}>节点名称</Text>
|
<Text style={{ marginBottom: 6, display: 'block' }}>节点名称</Text>
|
||||||
<Input
|
<Input
|
||||||
placeholder="如:prod-db-01"
|
placeholder="如:prod-db-01"
|
||||||
value={singleName}
|
value={singleName}
|
||||||
@@ -42,13 +42,13 @@ export function Step1NodeName({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
<Text bold style={{ marginBottom: 6, display: 'block' }}>节点名称(每行一个,最多 50 个)</Text>
|
<Text style={{ marginBottom: 6, display: 'block' }}>节点名称(每行一个,最多 50 个)</Text>
|
||||||
<TextArea
|
<TextArea
|
||||||
rows={8}
|
rows={8}
|
||||||
placeholder={'prod-db-01\nprod-db-02\nprod-web-01'}
|
placeholder={'prod-db-01\nprod-db-02\nprod-web-01'}
|
||||||
value={batchText}
|
value={batchText}
|
||||||
onChange={onBatchTextChange}
|
onChange={onBatchTextChange}
|
||||||
style={{ fontFamily: 'monospace', fontSize: 13 }}
|
style={{ fontSize: 13 }}
|
||||||
/>
|
/>
|
||||||
<Text type="secondary" style={{ fontSize: 12, marginTop: 4, display: 'block' }}>
|
<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 React from 'react'
|
||||||
import { Form, Radio, Select, Input, Typography } from '@arco-design/web-react'
|
import { Form, Radio, Select, Input, Typography } from '@arco-design/web-react'
|
||||||
import type { InstallMode, InstallArch, InstallSource } from '../../../types/nodes'
|
import type { InstallMode, InstallArch, InstallSource } from '../../../types/nodes'
|
||||||
|
import { AgentConnectionOptions, type AgentConnectionValue } from './AgentConnectionOptions'
|
||||||
|
|
||||||
const { Text } = Typography
|
const { Text } = Typography
|
||||||
|
|
||||||
export interface DeployOptions {
|
export interface DeployOptions extends AgentConnectionValue {
|
||||||
mode: InstallMode
|
mode: InstallMode
|
||||||
arch: InstallArch
|
arch: InstallArch
|
||||||
agentVersion: string
|
agentVersion: string
|
||||||
@@ -21,12 +22,17 @@ interface Props {
|
|||||||
|
|
||||||
export function Step2DeployOptions({ masterVersion, value, onChange }: Props) {
|
export function Step2DeployOptions({ masterVersion, value, onChange }: Props) {
|
||||||
const update = (patch: Partial<DeployOptions>) => onChange({ ...value, ...patch })
|
const update = (patch: Partial<DeployOptions>) => onChange({ ...value, ...patch })
|
||||||
const versionKnown = !!masterVersion
|
const versionKnown = isReleaseVersion(masterVersion)
|
||||||
const versionLoading = masterVersion === null
|
const versionLoading = masterVersion === null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form layout="vertical" size="default">
|
<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
|
<Radio.Group
|
||||||
type="button"
|
type="button"
|
||||||
value={value.mode}
|
value={value.mode}
|
||||||
@@ -56,7 +62,9 @@ export function Step2DeployOptions({ masterVersion, value, onChange }: Props) {
|
|||||||
extra={
|
extra={
|
||||||
!versionKnown && !versionLoading ? (
|
!versionKnown && !versionLoading ? (
|
||||||
<Text type="warning" style={{ fontSize: 12 }}>
|
<Text type="warning" style={{ fontSize: 12 }}>
|
||||||
未能自动获取 Master 版本,请手动输入(形如 v1.7.0)
|
{masterVersion
|
||||||
|
? `当前 Master 版本 ${masterVersion} 不是可下载的 Release,请手动输入 Agent Release 标签`
|
||||||
|
: '未能自动获取 Master 版本,请手动输入 Agent Release 标签(形如 v1.7.0)'}
|
||||||
</Text>
|
</Text>
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
@@ -106,6 +114,12 @@ export function Step2DeployOptions({ masterVersion, value, onChange }: Props) {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
<AgentConnectionOptions value={value} onChange={(connection) => update(connection)} />
|
||||||
</Form>
|
</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 React, { useEffect, useState } from 'react'
|
||||||
import { Typography, Button, Space, Collapse, Spin, Message, Tag } from '@arco-design/web-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 { fetchScriptPreview } from '../../../services/nodes'
|
||||||
import type { InstallTokenResult, InstallMode } from '../../../types/nodes'
|
import type { InstallTokenResult } from '../../../types/nodes'
|
||||||
import { buildAgentDownloadCommand, buildAgentInstallCommand, buildEmbeddedAgentInstallCommand } from '../installCommands'
|
import { buildAgentDownloadCommand, buildAgentInstallCommand, buildEmbeddedAgentInstallCommand } from '../installCommands'
|
||||||
|
import { InstallCommandBlock } from './InstallCommandBlock'
|
||||||
|
|
||||||
const { Text } = Typography
|
const { Text } = Typography
|
||||||
|
|
||||||
@@ -11,12 +12,19 @@ interface Props {
|
|||||||
nodeId: number
|
nodeId: number
|
||||||
nodeName: string
|
nodeName: string
|
||||||
token: InstallTokenResult
|
token: InstallTokenResult
|
||||||
mode: InstallMode
|
previewParams: {
|
||||||
previewParams: { mode: string; arch: string; agentVersion: string; downloadSrc: string }
|
mode: string
|
||||||
|
arch: string
|
||||||
|
agentVersion: string
|
||||||
|
downloadSrc: string
|
||||||
|
agentMasterUrl?: string
|
||||||
|
proxyUrl?: string
|
||||||
|
caCertFile?: string
|
||||||
|
}
|
||||||
onRegenerate: () => void
|
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 [remaining, setRemaining] = useState(0)
|
||||||
const [preview, setPreview] = useState<string>('')
|
const [preview, setPreview] = useState<string>('')
|
||||||
const [loadingPreview, setLoadingPreview] = useState(false)
|
const [loadingPreview, setLoadingPreview] = useState(false)
|
||||||
@@ -30,12 +38,10 @@ export function Step3CommandPreview({ nodeId, nodeName, token, mode, previewPara
|
|||||||
}, [token.expiresAt])
|
}, [token.expiresAt])
|
||||||
|
|
||||||
const expired = remaining === 0
|
const expired = remaining === 0
|
||||||
const command = buildAgentInstallCommand(token.url, token.fallbackUrl)
|
const fetchOptions = { proxyUrl: previewParams.proxyUrl, caCertFile: previewParams.caCertFile }
|
||||||
const fallbackCommand = buildAgentDownloadCommand(token.url, token.fallbackUrl)
|
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 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) => {
|
const copy = async (s: string) => {
|
||||||
await navigator.clipboard.writeText(s)
|
await navigator.clipboard.writeText(s)
|
||||||
@@ -57,73 +63,28 @@ export function Step3CommandPreview({ nodeId, nodeName, token, mode, previewPara
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Space style={{ marginBottom: 12 }}>
|
<Space style={{ marginBottom: 12 }}>
|
||||||
<Text bold>节点:</Text>
|
<Text>节点:</Text>
|
||||||
<Tag>{nodeName}</Tag>
|
<Tag>{nodeName}</Tag>
|
||||||
<Tag color={expired ? 'gray' : 'green'}>
|
<Tag color={expired ? 'gray' : 'green'}>
|
||||||
{expired ? '已过期' : `有效期 ${Math.floor(remaining / 60)}:${String(remaining % 60).padStart(2, '0')}`}
|
{expired ? '已过期' : `有效期 ${Math.floor(remaining / 60)}:${String(remaining % 60).padStart(2, '0')}`}
|
||||||
</Tag>
|
</Tag>
|
||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
<div style={{ background: 'var(--color-fill-2)', padding: '12px 14px', borderRadius: 6, marginBottom: 12 }}>
|
<InstallCommandBlock
|
||||||
<Text style={{
|
command={command}
|
||||||
fontFamily: 'monospace', fontSize: 13, wordBreak: 'break-all',
|
disabled={expired}
|
||||||
opacity: expired ? 0.4 : 1, userSelect: 'all',
|
onCopy={copy}
|
||||||
}}>
|
action={expired ? <Button size="small" type="primary" icon={<IconRefresh />} onClick={onRegenerate}>重新生成</Button> : undefined}
|
||||||
{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>
|
|
||||||
|
|
||||||
<div style={{ background: 'var(--color-fill-2)', padding: '12px 14px', borderRadius: 6, marginBottom: 12 }}>
|
<InstallCommandBlock label="或先下载到 /tmp 后执行:" command={fallbackCommand} disabled={expired} onCopy={copy} />
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{embeddedCommand && (
|
{embeddedCommand && (
|
||||||
<div style={{ background: 'var(--color-fill-2)', padding: '12px 14px', borderRadius: 6, marginBottom: 12 }}>
|
<InstallCommandBlock label="安装入口不可达时使用嵌入式备用命令:" command={embeddedCommand} onCopy={copy} />
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 8 }}>
|
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 8 }}>
|
||||||
主安装命令包含公开 install token,会在 TTL 到期或首次消费后作废;嵌入式备用命令包含完整节点 token,不依赖公开链接消费状态,请仅在目标机执行并妥善保存。
|
主安装命令包含一次性 install token,会在 TTL 到期或首次消费后作废;嵌入式备用命令包含完整节点 Token,不依赖公开入口,请仅在目标机执行并妥善保存。
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Collapse bordered={false} onChange={(_key, keys) => {
|
<Collapse bordered={false} onChange={(_key, keys) => {
|
||||||
|
|||||||
@@ -59,7 +59,15 @@ export async function rotateNodeToken(nodeId: number) {
|
|||||||
|
|
||||||
export async function fetchScriptPreview(
|
export async function fetchScriptPreview(
|
||||||
nodeId: number,
|
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`, {
|
const response = await http.get<string>(`/nodes/${nodeId}/install-script-preview`, {
|
||||||
params,
|
params,
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ export interface InstallTokenInput {
|
|||||||
agentVersion: string
|
agentVersion: string
|
||||||
downloadSrc: InstallSource
|
downloadSrc: InstallSource
|
||||||
ttlSeconds: number
|
ttlSeconds: number
|
||||||
|
agentMasterUrl?: string
|
||||||
|
proxyUrl?: string
|
||||||
|
caCertFile?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InstallTokenResult {
|
export interface InstallTokenResult {
|
||||||
|
|||||||
Reference in New Issue
Block a user