mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-08-15 17:34:15 +08:00
feat: 优化集群部署与堡垒机接入 (#106)
支持受限网络、正向代理、私有 CA 与 SSH 堡垒机部署 Agent。 加固 Docker、systemd、Nginx、安装器、Release 校验与可信代理边界,并完善命令队列索引、前端安装向导及中英文运维文档。
This commit is contained in:
@@ -24,7 +24,10 @@ func runAgent(args []string) {
|
||||
configPath := fs.String("config", "", "path to agent config YAML (optional)")
|
||||
master := fs.String("master", "", "master URL, e.g. http://master.example.com:8340")
|
||||
token := fs.String("token", "", "agent authentication token")
|
||||
tokenFile := fs.String("token-file", "", "read the agent authentication token from a file")
|
||||
tempDir := fs.String("temp-dir", "", "local temp directory for backup artifacts")
|
||||
proxyURL := fs.String("proxy-url", "", "HTTP(S) or SOCKS5 proxy used to reach the master")
|
||||
caCertFile := fs.String("ca-cert", "", "PEM CA certificate used to verify the master")
|
||||
insecureTLS := fs.Bool("insecure-tls", false, "skip TLS verification (testing only)")
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
@@ -36,10 +39,21 @@ func runAgent(args []string) {
|
||||
fmt.Fprintf(os.Stderr, "agent: load config: %v\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
cfg.MergeWithFlags(*master, *token, *tempDir)
|
||||
cfg.ApplyOverrides(agent.Overrides{
|
||||
Master: *master,
|
||||
Token: *token,
|
||||
TokenFile: *tokenFile,
|
||||
TempDir: *tempDir,
|
||||
ProxyURL: *proxyURL,
|
||||
CACertFile: *caCertFile,
|
||||
})
|
||||
if *insecureTLS {
|
||||
cfg.InsecureSkipTLSVerify = true
|
||||
}
|
||||
if err := cfg.ResolveToken(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "agent: %v\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "agent: %v\n", err)
|
||||
os.Exit(2)
|
||||
|
||||
@@ -4,6 +4,9 @@ server:
|
||||
port: 8340
|
||||
mode: "release" # debug | release
|
||||
external_url: "" # 可选:Master 对 Agent 可达的 URL,例如 https://backup.example.com
|
||||
trusted_proxies: # 仅这些代理可提供 X-Forwarded-For;跨容器代理需加入其网段
|
||||
- "127.0.0.1"
|
||||
- "::1"
|
||||
web_root: "" # 前端静态目录;留空自动探测(./web、/opt/backupx/web 等)。
|
||||
# 命中后后端直接托管 Web 控制台,无需额外 nginx 反向代理。
|
||||
|
||||
|
||||
@@ -28,10 +28,16 @@ type Agent struct {
|
||||
|
||||
// New 构造 Agent。
|
||||
func New(cfg *Config, version string) (*Agent, error) {
|
||||
if err := cfg.ResolveToken(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client := NewMasterClient(cfg.Master, cfg.Token, cfg.InsecureSkipTLSVerify)
|
||||
if err := client.ConfigureTransport(cfg.ProxyURL, cfg.CACertFile); err != nil {
|
||||
return nil, fmt.Errorf("configure master connection: %w", err)
|
||||
}
|
||||
executor := NewExecutor(client, cfg.TempDir)
|
||||
return &Agent{
|
||||
cfg: cfg,
|
||||
@@ -93,7 +99,6 @@ func (a *Agent) heartbeatLoop(ctx context.Context, interval time.Duration) {
|
||||
func (a *Agent) heartbeatOnce(ctx context.Context) error {
|
||||
hostname, _ := os.Hostname()
|
||||
req := HeartbeatRequest{
|
||||
Token: a.cfg.Token,
|
||||
Hostname: hostname,
|
||||
IPAddress: detectLocalIP(),
|
||||
AgentVersion: a.version,
|
||||
|
||||
@@ -4,11 +4,14 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -22,23 +25,64 @@ type MasterClient struct {
|
||||
|
||||
// NewMasterClient 构造 Master 客户端。
|
||||
func NewMasterClient(baseURL, token string, insecureTLS bool) *MasterClient {
|
||||
transport := &http.Transport{}
|
||||
if insecureTLS {
|
||||
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12}
|
||||
if transport.TLSClientConfig != nil {
|
||||
tlsConfig = transport.TLSClientConfig.Clone()
|
||||
tlsConfig.MinVersion = tls.VersionTLS12
|
||||
}
|
||||
// 仅用于用户显式开启的测试模式。生产环境应配置受信 CA。
|
||||
tlsConfig.InsecureSkipVerify = insecureTLS // #nosec G402
|
||||
transport.TLSClientConfig = tlsConfig
|
||||
return &MasterClient{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
token: token,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 120 * time.Second,
|
||||
Transport: transport,
|
||||
// Agent Token 是自定义认证头。禁止自动重定向,避免代理或错误
|
||||
// 配置把它转发到另一个主机;Master URL 必须直接指向 API。
|
||||
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ConfigureTransport 应用显式代理和私有 CA。默认 Transport 已保留
|
||||
// ProxyFromEnvironment,因此 ProxyURL 留空时 HTTP_PROXY/HTTPS_PROXY/NO_PROXY 生效。
|
||||
func (c *MasterClient) ConfigureTransport(proxyURL, caCertFile string) error {
|
||||
transport, ok := c.httpClient.Transport.(*http.Transport)
|
||||
if !ok {
|
||||
return errors.New("agent http transport has unexpected type")
|
||||
}
|
||||
if strings.TrimSpace(proxyURL) != "" {
|
||||
parsedProxy, err := url.Parse(strings.TrimSpace(proxyURL))
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse proxy URL: %w", err)
|
||||
}
|
||||
transport.Proxy = http.ProxyURL(parsedProxy)
|
||||
}
|
||||
if strings.TrimSpace(caCertFile) == "" {
|
||||
return nil
|
||||
}
|
||||
pemData, err := os.ReadFile(strings.TrimSpace(caCertFile))
|
||||
if err != nil {
|
||||
return fmt.Errorf("read CA certificate: %w", err)
|
||||
}
|
||||
roots, err := x509.SystemCertPool()
|
||||
if err != nil || roots == nil {
|
||||
roots = x509.NewCertPool()
|
||||
}
|
||||
if !roots.AppendCertsFromPEM(pemData) {
|
||||
return errors.New("CA certificate file does not contain a valid PEM certificate")
|
||||
}
|
||||
transport.TLSClientConfig.RootCAs = roots
|
||||
return nil
|
||||
}
|
||||
|
||||
// HeartbeatRequest Agent 上报心跳的请求
|
||||
type HeartbeatRequest struct {
|
||||
Token string `json:"token"`
|
||||
Hostname string `json:"hostname,omitempty"`
|
||||
IPAddress string `json:"ipAddress,omitempty"`
|
||||
AgentVersion string `json:"agentVersion,omitempty"`
|
||||
|
||||
93
server/internal/agent/client_test.go
Normal file
93
server/internal/agent/client_test.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMasterClientKeepsEnvironmentProxySupport(t *testing.T) {
|
||||
client := NewMasterClient("https://master.example.com", "token", false)
|
||||
transport := client.httpClient.Transport.(*http.Transport)
|
||||
if transport.Proxy == nil {
|
||||
t.Fatal("default transport proxy function must be preserved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMasterClientConfiguresExplicitProxy(t *testing.T) {
|
||||
client := NewMasterClient("https://master.example.com", "token", false)
|
||||
if err := client.ConfigureTransport("socks5h://127.0.0.1:1080", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
transport := client.httpClient.Transport.(*http.Transport)
|
||||
requestURL, _ := url.Parse("https://master.example.com")
|
||||
proxyURL, err := transport.Proxy(&http.Request{URL: requestURL})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if proxyURL == nil || proxyURL.String() != "socks5h://127.0.0.1:1080" {
|
||||
t.Fatalf("proxy URL = %v", proxyURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMasterClientRejectsInvalidCACertificate(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "invalid.pem")
|
||||
if err := os.WriteFile(path, []byte("not a certificate"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client := NewMasterClient("https://master.example.com", "token", false)
|
||||
if err := client.ConfigureTransport("", path); err == nil {
|
||||
t.Fatal("expected invalid CA certificate error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMasterClientDoesNotForwardTokenThroughRedirects(t *testing.T) {
|
||||
receivedToken := ""
|
||||
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
receivedToken = r.Header.Get("X-Agent-Token")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer target.Close()
|
||||
redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, target.URL, http.StatusTemporaryRedirect)
|
||||
}))
|
||||
defer redirector.Close()
|
||||
|
||||
client := NewMasterClient(redirector.URL, "secret-agent-token", false)
|
||||
if _, err := client.Heartbeat(context.Background(), HeartbeatRequest{}); err == nil {
|
||||
t.Fatal("redirect response should not be accepted as a Master API response")
|
||||
}
|
||||
if receivedToken != "" {
|
||||
t.Fatalf("Agent token leaked through redirect: %q", receivedToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatSendsTokenOnlyInAuthenticationHeader(t *testing.T) {
|
||||
requestBody := ""
|
||||
receivedHeader := ""
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
requestBody = string(body)
|
||||
receivedHeader = r.Header.Get("X-Agent-Token")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"data":{"status":"ok","nodeId":1,"name":"node"}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewMasterClient(server.URL, "secret-agent-token", false)
|
||||
if _, err := client.Heartbeat(context.Background(), HeartbeatRequest{Hostname: "node"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if receivedHeader != "secret-agent-token" {
|
||||
t.Fatalf("authentication header = %q", receivedHeader)
|
||||
}
|
||||
if strings.Contains(requestBody, "secret-agent-token") || strings.Contains(requestBody, `"token"`) {
|
||||
t.Fatalf("heartbeat body exposed the Agent token: %s", requestBody)
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,10 @@ package agent
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
@@ -22,16 +24,34 @@ type Config struct {
|
||||
Master string `yaml:"master"`
|
||||
// Token 节点认证令牌(在 Master 创建节点时生成)
|
||||
Token string `yaml:"token"`
|
||||
// TokenFile 从文件读取节点认证令牌;适合 systemd 凭据和容器 secret。
|
||||
// Token 与 TokenFile 同时设置时优先使用 Token。
|
||||
TokenFile string `yaml:"tokenFile"`
|
||||
// HeartbeatInterval 心跳间隔,默认 15s
|
||||
HeartbeatInterval string `yaml:"heartbeatInterval"`
|
||||
// PollInterval 命令轮询间隔,默认 5s
|
||||
PollInterval string `yaml:"pollInterval"`
|
||||
// TempDir 备份临时目录,默认 /var/lib/backupx-agent/tmp
|
||||
TempDir string `yaml:"tempDir"`
|
||||
// ProxyURL Agent 访问 Master 使用的显式代理。留空时遵循
|
||||
// HTTP_PROXY、HTTPS_PROXY 与 NO_PROXY;支持 http(s) 和 socks5(h)。
|
||||
ProxyURL string `yaml:"proxyUrl"`
|
||||
// CACertFile 私有 CA 的 PEM 文件路径,用于安全连接内网 HTTPS Master。
|
||||
CACertFile string `yaml:"caCertFile"`
|
||||
// InsecureSkipTLSVerify 测试环境允许跳过 TLS 证书校验
|
||||
InsecureSkipTLSVerify bool `yaml:"insecureSkipTlsVerify"`
|
||||
}
|
||||
|
||||
// Overrides 表示命令行显式提供的 Agent 配置覆盖项。
|
||||
type Overrides struct {
|
||||
Master string
|
||||
Token string
|
||||
TokenFile string
|
||||
TempDir string
|
||||
ProxyURL string
|
||||
CACertFile string
|
||||
}
|
||||
|
||||
// LoadConfigFile 从 YAML 文件加载 Agent 配置。
|
||||
func LoadConfigFile(path string) (*Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
@@ -50,42 +70,109 @@ func LoadConfigFile(path string) (*Config, error) {
|
||||
// 支持的环境变量:
|
||||
// - BACKUPX_AGENT_MASTER Master URL
|
||||
// - BACKUPX_AGENT_TOKEN 节点认证令牌
|
||||
// - BACKUPX_AGENT_TOKEN_FILE 节点认证令牌文件
|
||||
// - BACKUPX_AGENT_HEARTBEAT 心跳间隔(如 15s)
|
||||
// - BACKUPX_AGENT_POLL 命令轮询间隔(如 5s)
|
||||
// - BACKUPX_AGENT_TEMP_DIR 临时目录
|
||||
// - BACKUPX_AGENT_PROXY_URL 显式 HTTP(S)/SOCKS5 代理
|
||||
// - BACKUPX_AGENT_CA_CERT_FILE 私有 CA PEM 文件
|
||||
// - BACKUPX_AGENT_INSECURE_TLS true / 1 跳过 TLS 校验
|
||||
func LoadConfigFromEnv() (*Config, error) {
|
||||
cfg := &Config{
|
||||
Master: strings.TrimSpace(os.Getenv("BACKUPX_AGENT_MASTER")),
|
||||
Token: strings.TrimSpace(os.Getenv("BACKUPX_AGENT_TOKEN")),
|
||||
TokenFile: strings.TrimSpace(os.Getenv("BACKUPX_AGENT_TOKEN_FILE")),
|
||||
HeartbeatInterval: strings.TrimSpace(os.Getenv("BACKUPX_AGENT_HEARTBEAT")),
|
||||
PollInterval: strings.TrimSpace(os.Getenv("BACKUPX_AGENT_POLL")),
|
||||
TempDir: strings.TrimSpace(os.Getenv("BACKUPX_AGENT_TEMP_DIR")),
|
||||
ProxyURL: strings.TrimSpace(os.Getenv("BACKUPX_AGENT_PROXY_URL")),
|
||||
CACertFile: strings.TrimSpace(os.Getenv("BACKUPX_AGENT_CA_CERT_FILE")),
|
||||
InsecureSkipTLSVerify: strings.EqualFold(os.Getenv("BACKUPX_AGENT_INSECURE_TLS"), "true") || os.Getenv("BACKUPX_AGENT_INSECURE_TLS") == "1",
|
||||
}
|
||||
return applyConfigDefaults(cfg)
|
||||
}
|
||||
|
||||
// MergeWithFlags 把命令行覆盖值合并入配置(非空覆盖)。
|
||||
func (c *Config) MergeWithFlags(master, token, tempDir string) {
|
||||
if strings.TrimSpace(master) != "" {
|
||||
c.Master = master
|
||||
// ApplyOverrides 把命令行覆盖值合并入配置(非空覆盖)。
|
||||
func (c *Config) ApplyOverrides(overrides Overrides) {
|
||||
if strings.TrimSpace(overrides.Master) != "" {
|
||||
c.Master = strings.TrimSpace(overrides.Master)
|
||||
}
|
||||
if strings.TrimSpace(token) != "" {
|
||||
c.Token = token
|
||||
tokenProvided := strings.TrimSpace(overrides.Token) != ""
|
||||
if strings.TrimSpace(overrides.TokenFile) != "" {
|
||||
c.TokenFile = strings.TrimSpace(overrides.TokenFile)
|
||||
if !tokenProvided {
|
||||
// An explicit --token-file must override a token inherited from YAML.
|
||||
c.Token = ""
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(tempDir) != "" {
|
||||
c.TempDir = tempDir
|
||||
if tokenProvided {
|
||||
c.Token = strings.TrimSpace(overrides.Token)
|
||||
}
|
||||
if strings.TrimSpace(overrides.TempDir) != "" {
|
||||
c.TempDir = strings.TrimSpace(overrides.TempDir)
|
||||
}
|
||||
if strings.TrimSpace(overrides.ProxyURL) != "" {
|
||||
c.ProxyURL = strings.TrimSpace(overrides.ProxyURL)
|
||||
}
|
||||
if strings.TrimSpace(overrides.CACertFile) != "" {
|
||||
c.CACertFile = strings.TrimSpace(overrides.CACertFile)
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveToken 在所有配置源合并完成后读取 token 文件。
|
||||
func (c *Config) ResolveToken() error {
|
||||
if strings.TrimSpace(c.Token) != "" || strings.TrimSpace(c.TokenFile) == "" {
|
||||
c.Token = strings.TrimSpace(c.Token)
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(strings.TrimSpace(c.TokenFile))
|
||||
if err != nil {
|
||||
return fmt.Errorf("read agent token file: %w", err)
|
||||
}
|
||||
c.Token = strings.TrimSpace(string(data))
|
||||
if c.Token == "" {
|
||||
return errors.New("agent token file is empty")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate 校验必填字段。
|
||||
func (c *Config) Validate() error {
|
||||
masterURL, err := url.Parse(strings.TrimSpace(c.Master))
|
||||
if strings.TrimSpace(c.Master) == "" {
|
||||
return errors.New("master url is required (set via --master, BACKUPX_AGENT_MASTER or config file)")
|
||||
}
|
||||
if err != nil || (masterURL.Scheme != "http" && masterURL.Scheme != "https") || masterURL.Host == "" || masterURL.User != nil || masterURL.RawQuery != "" || masterURL.Fragment != "" {
|
||||
return errors.New("master url must be an absolute http(s) URL without credentials, query or fragment")
|
||||
}
|
||||
if strings.TrimSpace(c.Token) == "" {
|
||||
return errors.New("token is required (set via --token, BACKUPX_AGENT_TOKEN or config file)")
|
||||
return errors.New("token is required (set via --token, --token-file, environment or config file)")
|
||||
}
|
||||
if c.ProxyURL != "" {
|
||||
proxyURL, proxyErr := url.Parse(c.ProxyURL)
|
||||
if proxyErr != nil || proxyURL.Host == "" {
|
||||
return errors.New("proxy url must be an absolute URL")
|
||||
}
|
||||
switch proxyURL.Scheme {
|
||||
case "http", "https", "socks5", "socks5h":
|
||||
default:
|
||||
return errors.New("proxy url scheme must be http, https, socks5 or socks5h")
|
||||
}
|
||||
if proxyURL.RawQuery != "" || proxyURL.Fragment != "" || (proxyURL.Path != "" && proxyURL.Path != "/") {
|
||||
return errors.New("proxy url must not contain a path, query or fragment")
|
||||
}
|
||||
}
|
||||
if c.CACertFile != "" && c.InsecureSkipTLSVerify {
|
||||
return errors.New("ca cert file and insecure TLS cannot be enabled together")
|
||||
}
|
||||
for name, value := range map[string]string{
|
||||
"heartbeat interval": c.HeartbeatInterval,
|
||||
"poll interval": c.PollInterval,
|
||||
} {
|
||||
duration, durationErr := time.ParseDuration(value)
|
||||
if durationErr != nil || duration <= 0 {
|
||||
return fmt.Errorf("%s must be a positive duration", name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -101,5 +188,9 @@ func applyConfigDefaults(cfg *Config) (*Config, error) {
|
||||
cfg.TempDir = "/var/lib/backupx-agent/tmp"
|
||||
}
|
||||
cfg.Master = strings.TrimRight(strings.TrimSpace(cfg.Master), "/")
|
||||
cfg.Token = strings.TrimSpace(cfg.Token)
|
||||
cfg.TokenFile = strings.TrimSpace(cfg.TokenFile)
|
||||
cfg.ProxyURL = strings.TrimSpace(cfg.ProxyURL)
|
||||
cfg.CACertFile = strings.TrimSpace(cfg.CACertFile)
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
@@ -11,9 +11,12 @@ func TestLoadConfigFile(t *testing.T) {
|
||||
path := filepath.Join(dir, "agent.yaml")
|
||||
content := `master: http://master.example.com:8340/
|
||||
token: abc123
|
||||
tokenFile: /run/secrets/backupx_agent_token
|
||||
heartbeatInterval: 20s
|
||||
pollInterval: 3s
|
||||
tempDir: /var/backupx-agent
|
||||
proxyUrl: socks5h://127.0.0.1:1080
|
||||
caCertFile: /etc/backupx-agent/ca.pem
|
||||
insecureSkipTlsVerify: true
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
@@ -35,6 +38,9 @@ insecureSkipTlsVerify: true
|
||||
if !cfg.InsecureSkipTLSVerify {
|
||||
t.Errorf("insecure should be true")
|
||||
}
|
||||
if cfg.ProxyURL != "socks5h://127.0.0.1:1080" || cfg.CACertFile != "/etc/backupx-agent/ca.pem" {
|
||||
t.Errorf("connection options not loaded: %+v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigDefaults(t *testing.T) {
|
||||
@@ -64,8 +70,15 @@ func TestConfigValidate(t *testing.T) {
|
||||
{"valid", Config{Master: "http://m", Token: "t"}, false},
|
||||
{"missing master", Config{Token: "t"}, true},
|
||||
{"missing token", Config{Master: "http://m"}, true},
|
||||
{"invalid master scheme", Config{Master: "ssh://m", Token: "t"}, true},
|
||||
{"master credentials rejected", Config{Master: "https://user:pass@m", Token: "t"}, true},
|
||||
{"valid socks proxy", Config{Master: "https://m", Token: "t", ProxyURL: "socks5h://127.0.0.1:1080"}, false},
|
||||
{"invalid proxy", Config{Master: "https://m", Token: "t", ProxyURL: "ftp://proxy"}, true},
|
||||
{"proxy path rejected", Config{Master: "https://m", Token: "t", ProxyURL: "http://proxy/connect"}, true},
|
||||
{"invalid heartbeat", Config{Master: "https://m", Token: "t", HeartbeatInterval: "never"}, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
_, _ = applyConfigDefaults(&c.cfg)
|
||||
err := c.cfg.Validate()
|
||||
if (err != nil) != c.wantErr {
|
||||
t.Errorf("%s: err=%v wantErr=%v", c.name, err, c.wantErr)
|
||||
@@ -75,7 +88,7 @@ func TestConfigValidate(t *testing.T) {
|
||||
|
||||
func TestMergeWithFlags(t *testing.T) {
|
||||
cfg := &Config{Master: "http://old", Token: "old"}
|
||||
cfg.MergeWithFlags("http://new", "", "/tmp/x")
|
||||
cfg.ApplyOverrides(Overrides{Master: "http://new", TempDir: "/tmp/x", ProxyURL: "http://proxy:3128"})
|
||||
if cfg.Master != "http://new" {
|
||||
t.Errorf("master not overridden: %q", cfg.Master)
|
||||
}
|
||||
@@ -85,17 +98,50 @@ func TestMergeWithFlags(t *testing.T) {
|
||||
if cfg.TempDir != "/tmp/x" {
|
||||
t.Errorf("tempDir: %q", cfg.TempDir)
|
||||
}
|
||||
if cfg.ProxyURL != "http://proxy:3128" {
|
||||
t.Errorf("proxyUrl: %q", cfg.ProxyURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenFileOverrideReplacesConfiguredToken(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "agent.token")
|
||||
if err := os.WriteFile(path, []byte("file-token\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := &Config{Token: "yaml-token", TokenFile: "/old/token"}
|
||||
cfg.ApplyOverrides(Overrides{TokenFile: " " + path + " "})
|
||||
if err := cfg.ResolveToken(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Token != "file-token" || cfg.TokenFile != path {
|
||||
t.Fatalf("token file override was not applied: %+v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigFromEnv(t *testing.T) {
|
||||
t.Setenv("BACKUPX_AGENT_MASTER", "http://env-master")
|
||||
t.Setenv("BACKUPX_AGENT_TOKEN", "env-token")
|
||||
t.Setenv("BACKUPX_AGENT_PROXY_URL", "http://env-proxy:8080")
|
||||
t.Setenv("BACKUPX_AGENT_INSECURE_TLS", "true")
|
||||
cfg, err := LoadConfigFromEnv()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Master != "http://env-master" || cfg.Token != "env-token" || !cfg.InsecureSkipTLSVerify {
|
||||
if cfg.Master != "http://env-master" || cfg.Token != "env-token" || cfg.ProxyURL != "http://env-proxy:8080" || !cfg.InsecureSkipTLSVerify {
|
||||
t.Errorf("env not picked up: %+v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTokenFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "agent.token")
|
||||
if err := os.WriteFile(path, []byte(" file-token\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := &Config{TokenFile: path}
|
||||
if err := cfg.ResolveToken(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Token != "file-token" {
|
||||
t.Fatalf("token = %q", cfg.Token)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ func (r *FileRunner) Run(_ context.Context, task TaskSpec, writer LogWriter) (*R
|
||||
|
||||
walkErr := filepath.Walk(sourcePath, func(currentPath string, currentInfo os.FileInfo, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
writer.WriteLine(fmt.Sprintf("⚠ 无法访问 %s: %v", currentPath, walkErr))
|
||||
writer.WriteLine(fmt.Sprintf("[WARN] 无法访问 %s: %v", currentPath, walkErr))
|
||||
return nil
|
||||
}
|
||||
relPath, err := filepath.Rel(baseParent, currentPath)
|
||||
@@ -115,7 +115,7 @@ func (r *FileRunner) Run(_ context.Context, task TaskSpec, writer LogWriter) (*R
|
||||
|
||||
if currentInfo.IsDir() {
|
||||
dirCount++
|
||||
writer.WriteLine(fmt.Sprintf("📁 进入目录 %s", archiveName))
|
||||
writer.WriteLine(fmt.Sprintf("[DIR] 进入目录 %s", archiveName))
|
||||
}
|
||||
|
||||
header, err := tar.FileInfoHeader(currentInfo, "")
|
||||
|
||||
@@ -2,6 +2,8 @@ package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -21,6 +23,9 @@ type ServerConfig struct {
|
||||
Port int `mapstructure:"port"`
|
||||
Mode string `mapstructure:"mode"`
|
||||
ExternalURL string `mapstructure:"external_url"`
|
||||
// TrustedProxies 限定可提供 X-Forwarded-For 等头部的反向代理地址。
|
||||
// 默认仅信任本机代理;空列表表示不信任任何代理头。
|
||||
TrustedProxies []string `mapstructure:"trusted_proxies"`
|
||||
// WebRoot 指向前端构建产物目录。留空时后端会按部署惯例自动探测
|
||||
// (./web、./web/dist、/opt/backupx/web 等)。探测命中后后端直接托管
|
||||
// 前端 SPA,无需额外的 nginx 反向代理即可访问 Web 控制台。
|
||||
@@ -91,6 +96,25 @@ func Load(configPath string) (Config, error) {
|
||||
if cfg.Server.Mode == "" {
|
||||
cfg.Server.Mode = "release"
|
||||
}
|
||||
cfg.Server.ExternalURL = strings.TrimRight(strings.TrimSpace(cfg.Server.ExternalURL), "/")
|
||||
if cfg.Server.ExternalURL != "" {
|
||||
externalURL, parseErr := url.Parse(cfg.Server.ExternalURL)
|
||||
if parseErr != nil || (externalURL.Scheme != "http" && externalURL.Scheme != "https") || externalURL.Host == "" || externalURL.User != nil || externalURL.RawQuery != "" || externalURL.Fragment != "" {
|
||||
return Config{}, fmt.Errorf("server.external_url must be an absolute http(s) URL without credentials, query or fragment")
|
||||
}
|
||||
}
|
||||
if len(cfg.Server.TrustedProxies) == 1 && strings.Contains(cfg.Server.TrustedProxies[0], ",") {
|
||||
cfg.Server.TrustedProxies = strings.Split(cfg.Server.TrustedProxies[0], ",")
|
||||
}
|
||||
for index := range cfg.Server.TrustedProxies {
|
||||
proxy := strings.TrimSpace(cfg.Server.TrustedProxies[index])
|
||||
cfg.Server.TrustedProxies[index] = proxy
|
||||
if net.ParseIP(proxy) == nil {
|
||||
if _, _, parseErr := net.ParseCIDR(proxy); parseErr != nil {
|
||||
return Config{}, fmt.Errorf("server.trusted_proxies contains invalid IP or CIDR %q", proxy)
|
||||
}
|
||||
}
|
||||
}
|
||||
if cfg.Database.Path == "" {
|
||||
cfg.Database.Path = "./data/backupx.db"
|
||||
}
|
||||
@@ -142,6 +166,7 @@ func applyDefaults(v *viper.Viper) {
|
||||
v.SetDefault("server.port", 8340)
|
||||
v.SetDefault("server.mode", "release")
|
||||
v.SetDefault("server.external_url", "")
|
||||
v.SetDefault("server.trusted_proxies", []string{"127.0.0.1", "::1"})
|
||||
v.SetDefault("server.web_root", "")
|
||||
v.SetDefault("database.path", "./data/backupx.db")
|
||||
v.SetDefault("security.jwt_expire", "24h")
|
||||
|
||||
@@ -21,6 +21,25 @@ func TestLoadUsesDefaultsWithoutConfigFile(t *testing.T) {
|
||||
if cfg.Database.Path != "./data/backupx.db" {
|
||||
t.Fatalf("expected default database path, got %s", cfg.Database.Path)
|
||||
}
|
||||
if len(cfg.Server.TrustedProxies) != 2 {
|
||||
t.Fatalf("expected loopback trusted proxies, got %#v", cfg.Server.TrustedProxies)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsInvalidExternalURLAndTrustedProxy(t *testing.T) {
|
||||
tests := []string{
|
||||
"server:\n external_url: \"ssh://master.example.com\"\n",
|
||||
"server:\n trusted_proxies: [\"not-an-ip\"]\n",
|
||||
}
|
||||
for _, content := range tests {
|
||||
configPath := filepath.Join(t.TempDir(), "config.yaml")
|
||||
if err := os.WriteFile(configPath, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Load(configPath); err == nil {
|
||||
t.Fatalf("expected invalid configuration to fail: %s", content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadReadsServerExternalURLFromFile(t *testing.T) {
|
||||
@@ -52,3 +71,28 @@ func TestLoadReadsServerExternalURLFromEnv(t *testing.T) {
|
||||
t.Fatalf("expected external URL from env, got %q", cfg.Server.ExternalURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadReadsTrustedProxiesFromEnv(t *testing.T) {
|
||||
t.Setenv("BACKUPX_SERVER_TRUSTED_PROXIES", "127.0.0.1,172.18.0.0/16")
|
||||
cfg, err := Load("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cfg.Server.TrustedProxies) != 2 || cfg.Server.TrustedProxies[1] != "172.18.0.0/16" {
|
||||
t.Fatalf("trusted proxies = %#v", cfg.Server.TrustedProxies)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAllowsTrustedProxiesToBeDisabled(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.yaml")
|
||||
if err := os.WriteFile(configPath, []byte("server:\n trusted_proxies: []\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := Load(configPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cfg.Server.TrustedProxies) != 0 {
|
||||
t.Fatalf("trusted proxies should be disabled, got %#v", cfg.Server.TrustedProxies)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"backupx/server/internal/config"
|
||||
"backupx/server/internal/model"
|
||||
@@ -18,7 +19,14 @@ func Open(cfg config.DatabaseConfig, logger *zap.Logger) (*gorm.DB, error) {
|
||||
return nil, fmt.Errorf("create database dir: %w", err)
|
||||
}
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(cfg.Path), &gorm.Config{Logger: gormlogger.Default.LogMode(gormlogger.Silent)})
|
||||
separator := "?"
|
||||
if strings.Contains(cfg.Path, "?") {
|
||||
separator = "&"
|
||||
}
|
||||
// busy_timeout 减少 Agent 轮询、心跳和任务写入同时发生时的瞬时锁错误。
|
||||
// 维持默认回滚日志模式,保证当前嵌入式 SQLite 依赖的数据完整性。
|
||||
dsn := cfg.Path + separator + "_pragma=busy_timeout(5000)"
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{Logger: gormlogger.Default.LogMode(gormlogger.Silent)})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite: %w", err)
|
||||
}
|
||||
|
||||
40
server/internal/database/database_test.go
Normal file
40
server/internal/database/database_test.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"backupx/server/internal/config"
|
||||
"backupx/server/internal/logger"
|
||||
)
|
||||
|
||||
func TestOpenConfiguresSQLiteForSingleMasterConcurrency(t *testing.T) {
|
||||
log, err := logger.New(config.LogConfig{Level: "error"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db, err := Open(config.DatabaseConfig{Path: filepath.Join(t.TempDir(), "backupx.db")}, log)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
|
||||
var journalMode string
|
||||
if err := db.Raw("PRAGMA journal_mode").Scan(&journalMode).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if journalMode != "delete" {
|
||||
t.Fatalf("journal_mode = %q, want delete", journalMode)
|
||||
}
|
||||
var busyTimeout int
|
||||
if err := db.Raw("PRAGMA busy_timeout").Scan(&busyTimeout).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if busyTimeout != 5000 {
|
||||
t.Fatalf("busy_timeout = %d, want 5000", busyTimeout)
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ func NewAgentHandler(agentService *service.AgentService, nodeService *service.No
|
||||
return &AgentHandler{agentService: agentService, nodeService: nodeService, restoreService: restoreService}
|
||||
}
|
||||
|
||||
// extractToken 从请求头或 JSON body 中提取 Agent Token。
|
||||
// extractToken 从认证请求头中提取 Agent Token。
|
||||
func extractToken(c *gin.Context) string {
|
||||
if t := strings.TrimSpace(c.GetHeader("X-Agent-Token")); t != "" {
|
||||
return t
|
||||
@@ -46,10 +46,10 @@ func (h *AgentHandler) Heartbeat(c *gin.Context) {
|
||||
Arch string `json:"arch"`
|
||||
}
|
||||
_ = c.ShouldBindJSON(&input)
|
||||
// token 优先走 body(向后兼容),否则从 header 读
|
||||
token := input.Token
|
||||
// 新版 Agent 只通过请求头发送 Token;JSON body 仅保留旧版本兼容。
|
||||
token := extractToken(c)
|
||||
if token == "" {
|
||||
token = extractToken(c)
|
||||
token = input.Token
|
||||
}
|
||||
if token == "" {
|
||||
c.JSON(stdhttp.StatusBadRequest, gin.H{"code": "INVALID_INPUT", "message": "missing token"})
|
||||
@@ -72,7 +72,7 @@ func (h *AgentHandler) Heartbeat(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// Poll Agent 长轮询获取下一条待执行命令。
|
||||
// Poll Agent 获取下一条待执行命令;Agent 按配置间隔主动轮询。
|
||||
// 无命令时返回 {command: null}。
|
||||
func (h *AgentHandler) Poll(c *gin.Context) {
|
||||
node, err := h.agentService.AuthenticatedNode(c.Request.Context(), extractToken(c))
|
||||
|
||||
49
server/internal/http/forwarded_headers_test.go
Normal file
49
server/internal/http/forwarded_headers_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
stdhttp "net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestForwardedHeadersMiddlewareRejectsUntrustedHeaders(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
engine.Use(ForwardedHeadersMiddleware([]string{"127.0.0.1", "10.0.0.0/8"}))
|
||||
engine.GET("/master-url", func(c *gin.Context) {
|
||||
c.String(stdhttp.StatusOK, resolveMasterURL(c, ""))
|
||||
})
|
||||
|
||||
request := httptest.NewRequest(stdhttp.MethodGet, "http://master.example.com/master-url", nil)
|
||||
request.RemoteAddr = "203.0.113.10:54321"
|
||||
request.Header.Set("X-Forwarded-Host", "attacker.example.com")
|
||||
request.Header.Set("X-Forwarded-Proto", "https")
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Body.String() != "http://master.example.com" {
|
||||
t.Fatalf("untrusted forwarding headers changed URL: %q", recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardedHeadersMiddlewareAcceptsTrustedProxy(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
engine.Use(ForwardedHeadersMiddleware([]string{"10.0.0.0/8"}))
|
||||
engine.GET("/master-url", func(c *gin.Context) {
|
||||
c.String(stdhttp.StatusOK, resolveMasterURL(c, ""))
|
||||
})
|
||||
|
||||
request := httptest.NewRequest(stdhttp.MethodGet, "http://backupx:8340/master-url", nil)
|
||||
request.RemoteAddr = "10.10.0.5:43210"
|
||||
request.Header.Set("X-Forwarded-Host", "backup.example.com")
|
||||
request.Header.Set("X-Forwarded-Proto", "https")
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Body.String() != "https://backup.example.com" {
|
||||
t.Fatalf("trusted forwarding headers were ignored: %q", recorder.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -115,7 +115,7 @@ func setupInstallFlowRouterWithExternalURL(t *testing.T, externalURL string) (ht
|
||||
return router, setupResp.Data.Token
|
||||
}
|
||||
|
||||
func TestInstallTokenUsesConfiguredExternalURL(t *testing.T) {
|
||||
func TestInstallTokenUsesAgentSpecificURLAndConnectionSettings(t *testing.T) {
|
||||
const externalURL = "https://public.example.com/base"
|
||||
router, jwt := setupInstallFlowRouterWithExternalURL(t, externalURL)
|
||||
|
||||
@@ -141,11 +141,14 @@ func TestInstallTokenUsesConfiguredExternalURL(t *testing.T) {
|
||||
}
|
||||
|
||||
genBody, _ := json.Marshal(map[string]any{
|
||||
"mode": "systemd",
|
||||
"arch": "auto",
|
||||
"agentVersion": "v1.7.0",
|
||||
"downloadSrc": "github",
|
||||
"ttlSeconds": 900,
|
||||
"mode": "systemd",
|
||||
"arch": "auto",
|
||||
"agentVersion": "v1.7.0",
|
||||
"downloadSrc": "github",
|
||||
"ttlSeconds": 900,
|
||||
"agentMasterUrl": "http://127.0.0.1:18340",
|
||||
"proxyUrl": "socks5h://127.0.0.1:1080",
|
||||
"caCertFile": "/etc/pki/internal-ca.pem",
|
||||
})
|
||||
genReq := httptest.NewRequest(http.MethodPost,
|
||||
"/api/nodes/"+formatUint(batchResp.Data[0].ID)+"/install-tokens", bytes.NewBuffer(genBody))
|
||||
@@ -156,6 +159,9 @@ func TestInstallTokenUsesConfiguredExternalURL(t *testing.T) {
|
||||
if genRec.Code != 200 {
|
||||
t.Fatalf("install-tokens failed: %d %s", genRec.Code, genRec.Body.String())
|
||||
}
|
||||
if genRec.Header().Get("Cache-Control") != "no-store" {
|
||||
t.Fatalf("install-token response must not be cached: %#v", genRec.Header())
|
||||
}
|
||||
var genResp struct {
|
||||
Data struct {
|
||||
InstallToken string `json:"installToken"`
|
||||
@@ -167,18 +173,21 @@ func TestInstallTokenUsesConfiguredExternalURL(t *testing.T) {
|
||||
if err := json.Unmarshal(genRec.Body.Bytes(), &genResp); err != nil {
|
||||
t.Fatalf("unmarshal gen: %v", err)
|
||||
}
|
||||
if genResp.Data.URL != externalURL+"/api/install/"+genResp.Data.InstallToken {
|
||||
t.Fatalf("url should use external URL, got %q", genResp.Data.URL)
|
||||
if genResp.Data.URL != "http://127.0.0.1:18340/api/install/"+genResp.Data.InstallToken {
|
||||
t.Fatalf("url should use Agent-specific URL, got %q", genResp.Data.URL)
|
||||
}
|
||||
if genResp.Data.FallbackURL != externalURL+"/install/"+genResp.Data.InstallToken {
|
||||
t.Fatalf("fallbackUrl should use external URL, got %q", genResp.Data.FallbackURL)
|
||||
if genResp.Data.FallbackURL != "http://127.0.0.1:18340/install/"+genResp.Data.InstallToken {
|
||||
t.Fatalf("fallbackUrl should use Agent-specific URL, got %q", genResp.Data.FallbackURL)
|
||||
}
|
||||
decodedScript, err := base64.StdEncoding.DecodeString(genResp.Data.ScriptBase64)
|
||||
if err != nil {
|
||||
t.Fatalf("scriptBase64 should be valid base64: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(decodedScript), `MASTER_URL="`+externalURL+`"`) {
|
||||
t.Fatalf("script should use external MASTER_URL:\n%s", string(decodedScript))
|
||||
if !strings.Contains(string(decodedScript), `MASTER_URL="http://127.0.0.1:18340"`) {
|
||||
t.Fatalf("script should use the Agent-specific Master URL:\n%s", string(decodedScript))
|
||||
}
|
||||
if !strings.Contains(string(decodedScript), `PROXY_URL="socks5h://127.0.0.1:1080"`) || !strings.Contains(string(decodedScript), `CA_CERT_FILE="/etc/pki/internal-ca.pem"`) {
|
||||
t.Fatalf("script should include restricted-network settings:\n%s", string(decodedScript))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,8 +267,9 @@ func TestOneClickInstallFlow(t *testing.T) {
|
||||
if scriptRec.Code != 200 {
|
||||
t.Fatalf("script fetch failed: %d %s", scriptRec.Code, scriptRec.Body.String())
|
||||
}
|
||||
if !strings.Contains(scriptRec.Body.String(), "systemctl enable --now backupx-agent") {
|
||||
t.Fatalf("script missing systemctl enable:\n%s", scriptRec.Body.String())
|
||||
if !strings.Contains(scriptRec.Body.String(), "systemctl enable backupx-agent") ||
|
||||
!strings.Contains(scriptRec.Body.String(), "systemctl restart backupx-agent") {
|
||||
t.Fatalf("script missing systemctl enable/restart:\n%s", scriptRec.Body.String())
|
||||
}
|
||||
// Issue #46 防嗅探 headers:text/plain + nosniff + no-store + Content-Disposition
|
||||
if ct := scriptRec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") {
|
||||
@@ -354,7 +364,8 @@ func TestInstallScriptAliasUnderAPI(t *testing.T) {
|
||||
if aliasRec.Code != 200 {
|
||||
t.Fatalf("/api/install alias failed: %d %s", aliasRec.Code, aliasRec.Body.String())
|
||||
}
|
||||
if !strings.Contains(aliasRec.Body.String(), "systemctl enable --now backupx-agent") {
|
||||
if !strings.Contains(aliasRec.Body.String(), "systemctl enable backupx-agent") ||
|
||||
!strings.Contains(aliasRec.Body.String(), "systemctl restart backupx-agent") {
|
||||
t.Errorf("alias should return rendered script, got:\n%s", aliasRec.Body.String())
|
||||
}
|
||||
if ct := aliasRec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") {
|
||||
@@ -557,6 +568,9 @@ func TestInstallFlowComposeSuccessConsumesToken(t *testing.T) {
|
||||
if !strings.Contains(composeRec.Body.String(), "BACKUPX_AGENT_TOKEN") {
|
||||
t.Fatalf("compose missing token env:\n%s", composeRec.Body.String())
|
||||
}
|
||||
if composeRec.Header().Get("Cache-Control") != "no-store" || composeRec.Header().Get("X-Content-Type-Options") != "nosniff" {
|
||||
t.Fatalf("compose response missing secret-safe headers: %#v", composeRec.Header())
|
||||
}
|
||||
|
||||
scriptReq := httptest.NewRequest(http.MethodGet, "/api/install/"+genResp.Data.InstallToken, nil)
|
||||
scriptRec := httptest.NewRecorder()
|
||||
|
||||
@@ -104,16 +104,22 @@ func (h *InstallHandler) Compose(c *gin.Context) {
|
||||
}
|
||||
h.recordConsumeAudit(c, consumed, "compose")
|
||||
yaml, err := installscript.RenderComposeYaml(installscript.Context{
|
||||
MasterURL: resolveMasterURL(c, h.externalURL),
|
||||
MasterURL: installMasterURL(resolveMasterURL(c, h.externalURL), consumed.Record),
|
||||
AgentToken: consumed.Node.Token,
|
||||
AgentVersion: consumed.Record.AgentVer,
|
||||
Mode: model.InstallModeDocker,
|
||||
Arch: consumed.Record.Arch,
|
||||
NodeID: consumed.Node.ID,
|
||||
ProxyURL: consumed.Record.ProxyURL,
|
||||
CACertFile: consumed.Record.CACertFile,
|
||||
})
|
||||
if err != nil {
|
||||
c.String(stdhttp.StatusInternalServerError, "render error\n")
|
||||
return
|
||||
}
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.Header("Content-Disposition", `attachment; filename="backupx-agent-compose.yml"`)
|
||||
c.Data(stdhttp.StatusOK, "text/yaml; charset=utf-8", []byte(yaml))
|
||||
}
|
||||
|
||||
@@ -134,7 +140,7 @@ func (h *InstallHandler) recordConsumeAudit(c *gin.Context, consumed *service.Co
|
||||
|
||||
func renderInstallScript(masterURL string, node *model.Node, record *model.AgentInstallToken) (string, error) {
|
||||
return installscript.RenderScript(installscript.Context{
|
||||
MasterURL: masterURL,
|
||||
MasterURL: installMasterURL(masterURL, record),
|
||||
AgentToken: node.Token,
|
||||
AgentVersion: record.AgentVer,
|
||||
Mode: record.Mode,
|
||||
@@ -142,9 +148,18 @@ func renderInstallScript(masterURL string, node *model.Node, record *model.Agent
|
||||
DownloadBase: installscript.DownloadBaseFor(record.DownloadSrc),
|
||||
InstallPrefix: "/opt/backupx-agent",
|
||||
NodeID: node.ID,
|
||||
ProxyURL: record.ProxyURL,
|
||||
CACertFile: record.CACertFile,
|
||||
})
|
||||
}
|
||||
|
||||
func installMasterURL(fallback string, record *model.AgentInstallToken) string {
|
||||
if record != nil && strings.TrimSpace(record.AgentMasterURL) != "" {
|
||||
return strings.TrimRight(strings.TrimSpace(record.AgentMasterURL), "/")
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// resolveMasterURL 按优先级推导 Master URL:外部配置 > X-Forwarded-* > Request.Host。
|
||||
// 此为包级 helper,供 install_handler 和 node_handler 共用。
|
||||
func resolveMasterURL(c *gin.Context, externalURL string) string {
|
||||
|
||||
@@ -3,6 +3,7 @@ package http
|
||||
import (
|
||||
"context"
|
||||
stdhttp "net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
|
||||
"backupx/server/internal/apperror"
|
||||
@@ -11,6 +12,46 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ForwardedHeadersMiddleware 只允许配置中的反向代理提供转发头。
|
||||
// Gin 的 trusted_proxies 保护 ClientIP;这里同步保护安装命令使用的
|
||||
// X-Forwarded-Host 与 X-Forwarded-Proto,避免直连请求伪造 Agent 地址。
|
||||
func ForwardedHeadersMiddleware(trustedProxies []string) gin.HandlerFunc {
|
||||
trustedPrefixes := make([]netip.Prefix, 0, len(trustedProxies))
|
||||
for _, raw := range trustedProxies {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if prefix, err := netip.ParsePrefix(raw); err == nil {
|
||||
trustedPrefixes = append(trustedPrefixes, prefix)
|
||||
continue
|
||||
}
|
||||
if addr, err := netip.ParseAddr(raw); err == nil {
|
||||
trustedPrefixes = append(trustedPrefixes, netip.PrefixFrom(addr, addr.BitLen()))
|
||||
}
|
||||
}
|
||||
|
||||
return func(c *gin.Context) {
|
||||
remote, err := netip.ParseAddrPort(c.Request.RemoteAddr)
|
||||
trusted := false
|
||||
if err == nil {
|
||||
remoteAddr := remote.Addr().Unmap()
|
||||
for _, prefix := range trustedPrefixes {
|
||||
if prefix.Contains(remoteAddr) {
|
||||
trusted = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !trusted {
|
||||
for _, header := range []string{
|
||||
"Forwarded", "X-Forwarded-For", "X-Forwarded-Host",
|
||||
"X-Forwarded-Port", "X-Forwarded-Proto", "X-Real-IP",
|
||||
} {
|
||||
c.Request.Header.Del(header)
|
||||
}
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// CORSMiddleware handles Cross-Origin Resource Sharing for the API.
|
||||
func CORSMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
|
||||
@@ -204,6 +204,7 @@ func (h *NodeHandler) RotateToken(c *gin.Context) {
|
||||
recordAudit(c, h.auditService, "node", "rotate_token", "node",
|
||||
fmt.Sprintf("%d", id), "",
|
||||
fmt.Sprintf("轮换节点 Token (ID: %d)", id))
|
||||
c.Header("Cache-Control", "no-store")
|
||||
response.Success(c, gin.H{"newToken": tok})
|
||||
}
|
||||
|
||||
@@ -220,11 +221,14 @@ func (h *NodeHandler) CreateInstallToken(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
Mode string `json:"mode"`
|
||||
Arch string `json:"arch"`
|
||||
AgentVersion string `json:"agentVersion"`
|
||||
DownloadSrc string `json:"downloadSrc"`
|
||||
TTLSeconds int `json:"ttlSeconds"`
|
||||
Mode string `json:"mode"`
|
||||
Arch string `json:"arch"`
|
||||
AgentVersion string `json:"agentVersion"`
|
||||
DownloadSrc string `json:"downloadSrc"`
|
||||
TTLSeconds int `json:"ttlSeconds"`
|
||||
AgentMasterURL string `json:"agentMasterUrl"`
|
||||
ProxyURL string `json:"proxyUrl"`
|
||||
CACertFile string `json:"caCertFile"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(stdhttp.StatusBadRequest, gin.H{"code": "INVALID_INPUT", "message": err.Error()})
|
||||
@@ -246,13 +250,16 @@ func (h *NodeHandler) CreateInstallToken(c *gin.Context) {
|
||||
|
||||
out, err := h.installTokenSvc.CreateCommand(c.Request.Context(), service.InstallCommandInput{
|
||||
InstallTokenInput: service.InstallTokenInput{
|
||||
NodeID: uint(id),
|
||||
Mode: input.Mode,
|
||||
Arch: input.Arch,
|
||||
AgentVersion: input.AgentVersion,
|
||||
DownloadSrc: input.DownloadSrc,
|
||||
TTLSeconds: input.TTLSeconds,
|
||||
CreatedByID: h.resolveCurrentUserID(c),
|
||||
NodeID: uint(id),
|
||||
Mode: input.Mode,
|
||||
Arch: input.Arch,
|
||||
AgentVersion: input.AgentVersion,
|
||||
DownloadSrc: input.DownloadSrc,
|
||||
TTLSeconds: input.TTLSeconds,
|
||||
CreatedByID: h.resolveCurrentUserID(c),
|
||||
AgentMasterURL: input.AgentMasterURL,
|
||||
ProxyURL: input.ProxyURL,
|
||||
CACertFile: input.CACertFile,
|
||||
},
|
||||
MasterURL: resolveMasterURL(c, h.externalURL),
|
||||
})
|
||||
@@ -278,6 +285,7 @@ func (h *NodeHandler) CreateInstallToken(c *gin.Context) {
|
||||
"composeUrl": out.ComposeURL,
|
||||
"fallbackComposeUrl": out.FallbackComposeURL,
|
||||
}
|
||||
c.Header("Cache-Control", "no-store")
|
||||
response.Success(c, body)
|
||||
}
|
||||
|
||||
@@ -292,18 +300,24 @@ func (h *NodeHandler) PreviewScript(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
src := c.DefaultQuery("downloadSrc", "github")
|
||||
agentMasterURL := c.Query("agentMasterUrl")
|
||||
if agentMasterURL == "" {
|
||||
agentMasterURL = resolveMasterURL(c, h.externalURL)
|
||||
}
|
||||
ctx := installscript.Context{
|
||||
MasterURL: resolveMasterURL(c, h.externalURL),
|
||||
MasterURL: agentMasterURL,
|
||||
AgentToken: "<AGENT_TOKEN>",
|
||||
AgentVersion: ver,
|
||||
Mode: mode,
|
||||
Arch: arch,
|
||||
DownloadBase: installscript.DownloadBaseFor(src),
|
||||
InstallPrefix: "/opt/backupx-agent",
|
||||
ProxyURL: c.Query("proxyUrl"),
|
||||
CACertFile: c.Query("caCertFile"),
|
||||
}
|
||||
script, err := installscript.RenderScript(ctx)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
response.Error(c, apperror.BadRequest("INSTALL_TOKEN_INVALID", "Agent 连接配置无效", err))
|
||||
return
|
||||
}
|
||||
c.Data(stdhttp.StatusOK, "text/x-shellscript; charset=utf-8", []byte(script))
|
||||
|
||||
@@ -61,7 +61,11 @@ type RouterDependencies struct {
|
||||
func NewRouter(deps RouterDependencies) *gin.Engine {
|
||||
gin.SetMode(deps.Config.Server.Mode)
|
||||
engine := gin.New()
|
||||
if err := engine.SetTrustedProxies(deps.Config.Server.TrustedProxies); err != nil {
|
||||
panic("invalid trusted proxy configuration: " + err.Error())
|
||||
}
|
||||
engine.Use(gin.Recovery())
|
||||
engine.Use(ForwardedHeadersMiddleware(deps.Config.Server.TrustedProxies))
|
||||
engine.Use(CORSMiddleware())
|
||||
engine.Use(requestLogger(deps.Logger))
|
||||
|
||||
|
||||
@@ -6,11 +6,17 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func TestDeployInstallScriptSyntax(t *testing.T) {
|
||||
scriptPath := filepath.Join("..", "..", "..", "deploy", "install.sh")
|
||||
cmd := exec.Command("sh", "-n", scriptPath)
|
||||
sh, err := exec.LookPath("sh")
|
||||
if err != nil {
|
||||
t.Skip("POSIX sh is not available on this platform")
|
||||
}
|
||||
cmd := exec.Command(sh, "-n", scriptPath)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("install.sh syntax invalid: %v\n%s", err, output)
|
||||
@@ -30,9 +36,13 @@ func TestDeployInstallScriptSupportsReleasePackageLayout(t *testing.T) {
|
||||
`BIN_SOURCE="${BIN_SOURCE:-$SCRIPT_DIR/backupx}"`,
|
||||
`WEB_SOURCE="${WEB_SOURCE:-$SCRIPT_DIR/web}"`,
|
||||
`CONFIG_TEMPLATE="${CONFIG_TEMPLATE:-$SCRIPT_DIR/config.example.yaml}"`,
|
||||
`SERVICE_SOURCE_DEFAULT="$SCRIPT_DIR/backupx.service"`,
|
||||
`发布包安装请确认当前目录包含 ./backupx、./web 和 ./install.sh。`,
|
||||
`cat > "/etc/systemd/system/$SERVICE_NAME.service" <<UNIT`,
|
||||
`if [ -d "/etc/nginx/conf.d" ] && [ -f "$NGINX_SOURCE" ]; then`,
|
||||
`if [ "$INSTALL_NGINX" = "1" ]; then`,
|
||||
`[ "$PREFIX" = "/opt/backupx" ] && [ "$ETC_DIR" = "/etc/backupx" ]`,
|
||||
`validate_install_path PREFIX "$PREFIX"`,
|
||||
`拒绝通过符号链接写入受管目录`,
|
||||
} {
|
||||
if !strings.Contains(script, want) {
|
||||
t.Fatalf("install.sh missing %q", want)
|
||||
@@ -53,9 +63,72 @@ func TestDeployInstallScriptSupportsSourceBuildAndVerifiesFirstSetup(t *testing.
|
||||
`HEALTH_URL="${HEALTH_URL:-http://127.0.0.1:8340/api/auth/setup/status}"`,
|
||||
`systemctl is-active --quiet "$SERVICE_NAME"`,
|
||||
`System setup`,
|
||||
`chown -R root:root "$PREFIX/bin" "$PREFIX/web"`,
|
||||
`find "$PREFIX/web" -type f -exec chmod 0644`,
|
||||
`chown root:"$APP_GROUP" "$ETC_DIR/config.yaml"`,
|
||||
`systemctl restart "$SERVICE_NAME"`,
|
||||
} {
|
||||
if !strings.Contains(script, want) {
|
||||
t.Fatalf("install.sh missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerDeploymentUsesSingleUnprivilegedProcess(t *testing.T) {
|
||||
root := filepath.Join("..", "..", "..")
|
||||
dockerfileData, err := os.ReadFile(filepath.Join(root, "Dockerfile"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dockerfile := string(dockerfileData)
|
||||
for _, want := range []string{"su-exec", "BACKUPX_SERVER_WEB_ROOT=/app/web", "HEALTHCHECK"} {
|
||||
if !strings.Contains(dockerfile, want) {
|
||||
t.Fatalf("Dockerfile missing %q", want)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"docker-cli", "COPY deploy/docker/nginx.conf"} {
|
||||
if strings.Contains(dockerfile, forbidden) {
|
||||
t.Fatalf("Dockerfile still contains %q", forbidden)
|
||||
}
|
||||
}
|
||||
|
||||
composeData, err := os.ReadFile(filepath.Join(root, "docker-compose.yml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var compose map[string]any
|
||||
if err := yaml.Unmarshal(composeData, &compose); err != nil {
|
||||
t.Fatalf("docker-compose.yml is not valid YAML: %v", err)
|
||||
}
|
||||
composeText := string(composeData)
|
||||
for _, want := range []string{"no-new-privileges:true", "cap_drop:", "cap_add:", "DAC_OVERRIDE", "SETGID", "SETUID", "/ready"} {
|
||||
if !strings.Contains(composeText, want) {
|
||||
t.Fatalf("docker-compose.yml missing %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(composeText, "docker.sock") {
|
||||
t.Fatal("docker-compose.yml must not expose the Docker socket")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseWorkflowPublishesChecksums(t *testing.T) {
|
||||
workflowPath := filepath.Join("..", "..", "..", ".github", "workflows", "release.yml")
|
||||
workflowData, err := os.ReadFile(workflowPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var workflow map[string]any
|
||||
if err := yaml.Unmarshal(workflowData, &workflow); err != nil {
|
||||
t.Fatalf("release workflow is not valid YAML: %v", err)
|
||||
}
|
||||
workflowText := string(workflowData)
|
||||
for _, want := range []string{
|
||||
`cp deploy/backupx.service "${ARCHIVE_NAME}/"`,
|
||||
`sha256sum "${ARCHIVE_NAME}.tar.gz"`,
|
||||
`backupx-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz.sha256`,
|
||||
} {
|
||||
if !strings.Contains(workflowText, want) {
|
||||
t.Fatalf("release workflow missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ func TestRenderScriptUsesRootForBareMetalBackups(t *testing.T) {
|
||||
}
|
||||
for _, want := range []string{
|
||||
"/var/lib/backupx-agent/tmp",
|
||||
"install -d -m 0700 /var/lib/backupx-agent /var/lib/backupx-agent/tmp",
|
||||
"install -d -m 0700 \"$CONFIG_DIR\" /var/lib/backupx-agent /var/lib/backupx-agent/tmp",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("script missing %q:\n%s", want, got)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
@@ -30,6 +31,8 @@ type Context struct {
|
||||
DownloadBase string
|
||||
InstallPrefix string
|
||||
NodeID uint
|
||||
ProxyURL string
|
||||
CACertFile string
|
||||
}
|
||||
|
||||
// DownloadBaseFor 将下载源枚举转换为具体 URL 前缀。
|
||||
@@ -84,6 +87,12 @@ func RenderComposeYaml(ctx Context) (string, error) {
|
||||
// 这些字段被直接写入 shell 双引号字符串和 YAML 双引号值;不做校验会带来
|
||||
// 注入风险(如 MasterURL 含 `"\nCOMMAND:` 可逃逸 YAML 结构)。
|
||||
func validateContext(ctx Context) error {
|
||||
if ctx.Mode != model.InstallModeSystemd && ctx.Mode != model.InstallModeDocker && ctx.Mode != model.InstallModeForeground {
|
||||
return fmt.Errorf("unsupported install mode %q", ctx.Mode)
|
||||
}
|
||||
if ctx.Arch != model.InstallArchAmd64 && ctx.Arch != model.InstallArchArm64 && ctx.Arch != model.InstallArchAuto {
|
||||
return fmt.Errorf("unsupported install architecture %q", ctx.Arch)
|
||||
}
|
||||
if err := validateMasterURL(ctx.MasterURL); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -93,6 +102,42 @@ func validateContext(ctx Context) error {
|
||||
if err := validateAgentVersion(ctx.AgentVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateProxyURL(ctx.ProxyURL); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateCACertFile(ctx.CACertFile); err != nil {
|
||||
return err
|
||||
}
|
||||
if !path.IsAbs(ctx.InstallPrefix) || path.Clean(ctx.InstallPrefix) != ctx.InstallPrefix {
|
||||
return fmt.Errorf("install prefix must be a clean absolute path without shell metacharacters")
|
||||
}
|
||||
for _, c := range ctx.InstallPrefix {
|
||||
switch {
|
||||
case c >= '0' && c <= '9':
|
||||
case c >= 'a' && c <= 'z':
|
||||
case c >= 'A' && c <= 'Z':
|
||||
case c == '/' || c == '.' || c == '_' || c == '-' || c == '+':
|
||||
default:
|
||||
return fmt.Errorf("install prefix contains illegal character %q", c)
|
||||
}
|
||||
}
|
||||
if err := validateDownloadBase(ctx.DownloadBase); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDownloadBase(raw string) error {
|
||||
if strings.ContainsAny(raw, " \t\r\n\"'`$\\") {
|
||||
return fmt.Errorf("download base contains illegal characters")
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||
return fmt.Errorf("download base must be an absolute http(s) URL")
|
||||
}
|
||||
if u.User != nil || u.RawQuery != "" || u.Fragment != "" {
|
||||
return fmt.Errorf("download base must not contain credentials, query or fragment")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -114,6 +159,43 @@ func validateMasterURL(raw string) error {
|
||||
if u.Host == "" {
|
||||
return fmt.Errorf("master URL missing host")
|
||||
}
|
||||
if u.User != nil || u.RawQuery != "" || u.Fragment != "" {
|
||||
return fmt.Errorf("master URL must not contain credentials, query or fragment")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateProxyURL(raw string) error {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
if strings.ContainsAny(raw, " \t\r\n\"'`$\\") {
|
||||
return fmt.Errorf("proxy URL contains illegal characters")
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Host == "" {
|
||||
return fmt.Errorf("invalid proxy URL")
|
||||
}
|
||||
switch u.Scheme {
|
||||
case "http", "https", "socks5", "socks5h":
|
||||
default:
|
||||
return fmt.Errorf("proxy URL scheme must be http, https, socks5 or socks5h")
|
||||
}
|
||||
if u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Path != "" && u.Path != "/") {
|
||||
return fmt.Errorf("proxy URL must not contain credentials, path, query or fragment")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCACertFile(file string) error {
|
||||
file = strings.TrimSpace(file)
|
||||
if file == "" {
|
||||
return nil
|
||||
}
|
||||
if !path.IsAbs(file) || path.Clean(file) != file || strings.ContainsAny(file, " \t\r\n\"'`$\\") {
|
||||
return fmt.Errorf("CA certificate path must be a clean absolute path without shell metacharacters")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -160,6 +242,9 @@ func validateAgentVersion(v string) error {
|
||||
}
|
||||
|
||||
func withDefaults(ctx Context) Context {
|
||||
if ctx.Arch == "" {
|
||||
ctx.Arch = model.InstallArchAuto
|
||||
}
|
||||
if ctx.InstallPrefix == "" {
|
||||
ctx.InstallPrefix = "/opt/backupx-agent"
|
||||
}
|
||||
|
||||
@@ -2,11 +2,13 @@ package installscript
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"backupx/server/internal/model"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// 使用合法 hex token(32 字节 = 64 字符)以通过 validateAgentToken 校验
|
||||
@@ -27,11 +29,13 @@ func TestRenderScriptSystemd(t *testing.T) {
|
||||
t.Fatalf("render err: %v", err)
|
||||
}
|
||||
mustContain := []string{
|
||||
"BACKUPX_AGENT_MASTER=${MASTER_URL}",
|
||||
`Environment="BACKUPX_AGENT_TOKEN=${AGENT_TOKEN}"`,
|
||||
`master: "${MASTER_URL}"`,
|
||||
`tokenFile: "${TOKEN_FILE}"`,
|
||||
`ExecStart=${INSTALL_PREFIX}/backupx agent --config ${CONFIG_FILE}`,
|
||||
"/var/lib/backupx-agent/tmp",
|
||||
"systemctl daemon-reload",
|
||||
"systemctl enable --now backupx-agent",
|
||||
"systemctl enable backupx-agent",
|
||||
"systemctl restart backupx-agent",
|
||||
"systemctl status backupx-agent",
|
||||
"X-Agent-Token: ${AGENT_TOKEN}",
|
||||
"MASTER_URL=\"https://master.example.com\"",
|
||||
@@ -48,6 +52,29 @@ func TestRenderScriptSystemd(t *testing.T) {
|
||||
t.Errorf("systemd script unexpectedly contains %q", s)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, `Environment="BACKUPX_AGENT_TOKEN=`) {
|
||||
t.Errorf("systemd unit must not expose the agent token in its environment:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderedInstallScriptSyntax(t *testing.T) {
|
||||
sh, err := exec.LookPath("sh")
|
||||
if err != nil {
|
||||
t.Skip("POSIX sh is not available on this platform")
|
||||
}
|
||||
for _, mode := range []string{model.InstallModeSystemd, model.InstallModeDocker, model.InstallModeForeground} {
|
||||
ctx := testCtx
|
||||
ctx.Mode = mode
|
||||
script, renderErr := RenderScript(ctx)
|
||||
if renderErr != nil {
|
||||
t.Fatal(renderErr)
|
||||
}
|
||||
cmd := exec.Command(sh, "-n")
|
||||
cmd.Stdin = strings.NewReader(script)
|
||||
if output, syntaxErr := cmd.CombinedOutput(); syntaxErr != nil {
|
||||
t.Fatalf("%s installer syntax invalid: %v\n%s", mode, syntaxErr, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderScriptForeground(t *testing.T) {
|
||||
@@ -90,8 +117,11 @@ func TestRenderScriptDocker(t *testing.T) {
|
||||
if !strings.Contains(got, `"awuqing/backupx:${AGENT_VERSION}" agent`) {
|
||||
t.Errorf("docker script must start image in agent mode:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, `-e "BACKUPX_AGENT_TEMP_DIR=/var/lib/backupx-agent/tmp"`) {
|
||||
t.Errorf("docker script missing temp dir env:\n%s", got)
|
||||
if !strings.Contains(got, `-v /etc/backupx-agent:/etc/backupx-agent:ro`) {
|
||||
t.Errorf("docker script missing protected config mount:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, `agent --config /etc/backupx-agent/config.yaml`) {
|
||||
t.Errorf("docker script must load the protected config file:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, `docker logs --tail=100 backupx-agent`) {
|
||||
t.Errorf("docker script missing diagnostic log command:\n%s", got)
|
||||
@@ -102,6 +132,9 @@ func TestRenderScriptDocker(t *testing.T) {
|
||||
if strings.Contains(got, "systemctl daemon-reload") {
|
||||
t.Errorf("docker script should not reference systemctl:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, `-e "BACKUPX_AGENT_TOKEN=`) {
|
||||
t.Errorf("docker inspect must not expose the agent token:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerEntrypointForwardsAgentSubcommand(t *testing.T) {
|
||||
@@ -111,12 +144,18 @@ func TestDockerEntrypointForwardsAgentSubcommand(t *testing.T) {
|
||||
t.Fatalf("read docker entrypoint: %v", err)
|
||||
}
|
||||
script := string(got)
|
||||
if !strings.Contains(script, `"${1:-}" = "agent"`) {
|
||||
t.Fatalf("entrypoint must detect the agent subcommand before starting server:\n%s", script)
|
||||
}
|
||||
if !strings.Contains(script, `exec /app/bin/backupx "$@"`) {
|
||||
t.Fatalf("entrypoint must exec backupx with forwarded args:\n%s", script)
|
||||
}
|
||||
if !strings.Contains(script, `exec su-exec backupx:backupx /app/bin/backupx "$@"`) {
|
||||
t.Fatalf("master entrypoint must drop privileges after data migration:\n%s", script)
|
||||
}
|
||||
if !strings.Contains(script, `export HOME=/app`) {
|
||||
t.Fatalf("master entrypoint must set the service user's home directory:\n%s", script)
|
||||
}
|
||||
if strings.Contains(script, "nginx") || strings.Contains(script, "wait -n") {
|
||||
t.Fatalf("entrypoint should run a single foreground process:\n%s", script)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderComposeYaml(t *testing.T) {
|
||||
@@ -138,9 +177,40 @@ func TestRenderComposeYaml(t *testing.T) {
|
||||
if !strings.Contains(got, `BACKUPX_AGENT_TEMP_DIR: "/var/lib/backupx-agent/tmp"`) {
|
||||
t.Errorf("compose missing temp dir env:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, `user: "0:0"`) || !strings.Contains(got, "no-new-privileges:true") {
|
||||
t.Errorf("compose missing root execution declaration or security option:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "/var/lib/backupx-agent:/var/lib/backupx-agent") {
|
||||
t.Errorf("compose missing agent data volume:\n%s", got)
|
||||
}
|
||||
var document map[string]any
|
||||
if err := yaml.Unmarshal([]byte(got), &document); err != nil {
|
||||
t.Fatalf("compose is not valid YAML: %v\n%s", err, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderComposeYamlIncludesRestrictedNetworkSettings(t *testing.T) {
|
||||
ctx := testCtx
|
||||
ctx.Mode = model.InstallModeDocker
|
||||
ctx.ProxyURL = "socks5h://127.0.0.1:1080"
|
||||
ctx.CACertFile = "/etc/backupx-agent/ca.pem"
|
||||
got, err := RenderComposeYaml(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
`BACKUPX_AGENT_PROXY_URL: "socks5h://127.0.0.1:1080"`,
|
||||
`BACKUPX_AGENT_CA_CERT_FILE: "/etc/backupx-agent/ca.pem"`,
|
||||
`- /etc/backupx-agent/ca.pem:/etc/backupx-agent/ca.pem:ro`,
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("compose missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
var document map[string]any
|
||||
if err := yaml.Unmarshal([]byte(got), &document); err != nil {
|
||||
t.Fatalf("compose is not valid YAML: %v\n%s", err, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderScriptRejectsInjectedMasterURL(t *testing.T) {
|
||||
@@ -159,6 +229,73 @@ func TestRenderScriptRejectsInjectedMasterURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderScriptIncludesRestrictedNetworkSettings(t *testing.T) {
|
||||
ctx := testCtx
|
||||
ctx.MasterURL = "http://127.0.0.1:18340"
|
||||
ctx.ProxyURL = "socks5h://127.0.0.1:1080"
|
||||
ctx.CACertFile = "/etc/pki/internal-ca.pem"
|
||||
got, err := RenderScript(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
`PROXY_URL="socks5h://127.0.0.1:1080"`,
|
||||
`CA_CERT_FILE="/etc/pki/internal-ca.pem"`,
|
||||
`proxyUrl: "${PROXY_URL}"`,
|
||||
`caCertFile: "${CA_CERT_FILE}"`,
|
||||
`curl -fsSL --retry 3 --retry-delay 2 --connect-timeout 15 --proxy "$PROXY_URL"`,
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("restricted network script missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderScriptRejectsUnsafeRestrictedNetworkSettings(t *testing.T) {
|
||||
for _, mutate := range []func(*Context){
|
||||
func(ctx *Context) { ctx.ProxyURL = "ftp://proxy.example.com" },
|
||||
func(ctx *Context) { ctx.ProxyURL = "http://user:pass@proxy.example.com" },
|
||||
func(ctx *Context) { ctx.ProxyURL = "http://proxy.example.com/connect" },
|
||||
func(ctx *Context) { ctx.CACertFile = "relative-ca.pem" },
|
||||
} {
|
||||
ctx := testCtx
|
||||
mutate(&ctx)
|
||||
if _, err := RenderScript(ctx); err == nil {
|
||||
t.Fatalf("expected restricted network settings to be rejected: %+v", ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderScriptRejectsUnsafeDeploymentSettings(t *testing.T) {
|
||||
for _, mutate := range []func(*Context){
|
||||
func(ctx *Context) { ctx.Mode = "unknown" },
|
||||
func(ctx *Context) { ctx.Arch = "386" },
|
||||
func(ctx *Context) { ctx.InstallPrefix = "/opt/backupx;touch/tmp/pwned" },
|
||||
func(ctx *Context) { ctx.DownloadBase = "https://user:pass@example.com/releases" },
|
||||
} {
|
||||
ctx := testCtx
|
||||
mutate(&ctx)
|
||||
if _, err := RenderScript(ctx); err == nil {
|
||||
t.Fatalf("expected unsafe deployment settings to be rejected: %+v", ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderScriptVerifiesReleaseChecksumWithoutEmoji(t *testing.T) {
|
||||
got, err := RenderScript(testCtx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{"sha256sum -c", `download_file "${URL}.sha256"`, "[OK] 节点已上线", "[WARN]"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("script missing %q", want)
|
||||
}
|
||||
}
|
||||
if strings.ContainsAny(got, "\u2713\u26a0") {
|
||||
t.Fatal("installer output must not use emoji symbols")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderComposeYamlRejectsInjectedMasterURL(t *testing.T) {
|
||||
ctx := testCtx
|
||||
ctx.Mode = model.InstallModeDocker
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
# BackupX Agent docker-compose 片段
|
||||
# BackupX Agent Compose 片段
|
||||
# 生成于 {{.MasterURL}} · 节点 ID {{.NodeID}}
|
||||
version: "3.8"
|
||||
# 文件包含长期节点 Token,请保存为 0600 权限并在部署完成后限制访问。
|
||||
services:
|
||||
backupx-agent:
|
||||
image: awuqing/backupx:{{.AgentVersion}}
|
||||
command: ["agent"]
|
||||
user: "0:0"
|
||||
restart: unless-stopped
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
environment:
|
||||
BACKUPX_AGENT_MASTER: "{{.MasterURL}}"
|
||||
BACKUPX_AGENT_TOKEN: "{{.AgentToken}}"
|
||||
BACKUPX_AGENT_TEMP_DIR: "/var/lib/backupx-agent/tmp"
|
||||
volumes:
|
||||
{{if .ProxyURL}} BACKUPX_AGENT_PROXY_URL: "{{.ProxyURL}}"
|
||||
{{end}}{{if .CACertFile}} BACKUPX_AGENT_CA_CERT_FILE: "{{.CACertFile}}"
|
||||
{{end}} volumes:
|
||||
- /var/lib/backupx-agent:/var/lib/backupx-agent
|
||||
{{if .CACertFile}} - {{.CACertFile}}:{{.CACertFile}}:ro
|
||||
{{end}} # 备份宿主机文件时,必须按需添加只读源目录挂载:
|
||||
# - /srv/data:/srv/data:ro
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
#!/bin/sh
|
||||
# BackupX Agent 一键安装脚本(由 Master 动态渲染)
|
||||
# Magic: BACKUPX_AGENT_INSTALL_V1 —— 若 `head -3 脚本` 看不到此行,说明反向代理/CDN 改写了响应
|
||||
# Magic: BACKUPX_AGENT_INSTALL_V1 —— 若 `head -3 脚本` 看不到此行,说明反向代理或 CDN 改写了响应
|
||||
# 模式: {{.Mode}} | 架构: {{.Arch}} | 版本: {{.AgentVersion}}
|
||||
set -eu
|
||||
umask 077
|
||||
|
||||
# 自举到 bash(文件执行模式下生效;管道模式 $0 不是文件,exec 会静默失败,继续用 sh)。
|
||||
# 动机:部分 Debian/Ubuntu 用户通过 `curl | sudo sh` 触发时,dash 对本脚本报语法错误;
|
||||
# 若目标机装有 bash,优先切换到 bash 获得更一致的行为。
|
||||
# 文件执行模式下优先使用 bash;管道模式继续使用 POSIX sh。
|
||||
if [ -z "${BASH_VERSION:-}" ] && command -v bash >/dev/null 2>&1 && [ -f "$0" ]; then
|
||||
exec bash "$0" "$@"
|
||||
fi
|
||||
@@ -17,14 +16,56 @@ AGENT_VERSION="{{.AgentVersion}}"
|
||||
DOWNLOAD_BASE="{{.DownloadBase}}"
|
||||
INSTALL_PREFIX="{{.InstallPrefix}}"
|
||||
ARCH="{{.Arch}}"
|
||||
PROXY_URL="{{.ProxyURL}}"
|
||||
CA_CERT_FILE="{{.CACertFile}}"
|
||||
CONFIG_DIR="/etc/backupx-agent"
|
||||
CONFIG_FILE="${CONFIG_DIR}/config.yaml"
|
||||
TOKEN_FILE="${CONFIG_DIR}/agent.token"
|
||||
|
||||
download_file() {
|
||||
source_url="$1"
|
||||
destination="$2"
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
if [ -n "$PROXY_URL" ]; then
|
||||
curl -fsSL --retry 3 --retry-delay 2 --connect-timeout 15 --proxy "$PROXY_URL" "$source_url" -o "$destination"
|
||||
else
|
||||
curl -fsSL --retry 3 --retry-delay 2 --connect-timeout 15 "$source_url" -o "$destination"
|
||||
fi
|
||||
else
|
||||
wget -q -T 30 -O "$destination" "$source_url"
|
||||
fi
|
||||
}
|
||||
|
||||
agent_is_online() {
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
if [ -n "$PROXY_URL" ] && [ -n "$CA_CERT_FILE" ]; then
|
||||
response=$(curl -fsS --max-time 5 --proxy "$PROXY_URL" --cacert "$CA_CERT_FILE" -H "X-Agent-Token: ${AGENT_TOKEN}" "${MASTER_URL}/api/v1/agent/self" 2>/dev/null) || return 1
|
||||
elif [ -n "$PROXY_URL" ]; then
|
||||
response=$(curl -fsS --max-time 5 --proxy "$PROXY_URL" -H "X-Agent-Token: ${AGENT_TOKEN}" "${MASTER_URL}/api/v1/agent/self" 2>/dev/null) || return 1
|
||||
elif [ -n "$CA_CERT_FILE" ]; then
|
||||
response=$(curl -fsS --max-time 5 --cacert "$CA_CERT_FILE" -H "X-Agent-Token: ${AGENT_TOKEN}" "${MASTER_URL}/api/v1/agent/self" 2>/dev/null) || return 1
|
||||
else
|
||||
response=$(curl -fsS --max-time 5 -H "X-Agent-Token: ${AGENT_TOKEN}" "${MASTER_URL}/api/v1/agent/self" 2>/dev/null) || return 1
|
||||
fi
|
||||
else
|
||||
response=$(wget -q -T 5 --max-redirect=0 -O - --header="X-Agent-Token: ${AGENT_TOKEN}" "${MASTER_URL}/api/v1/agent/self" 2>/dev/null) || return 1
|
||||
fi
|
||||
printf '%s' "$response" | grep -q '"status":"online"'
|
||||
}
|
||||
|
||||
# 1. 前置检查
|
||||
[ "$(id -u)" -eq 0 ] || { echo "请使用 root 或 sudo 执行" >&2; exit 1; }
|
||||
command -v curl >/dev/null || command -v wget >/dev/null \
|
||||
command -v curl >/dev/null 2>&1 || command -v wget >/dev/null 2>&1 \
|
||||
|| { echo "需要 curl 或 wget" >&2; exit 1; }
|
||||
{{if eq .Mode "systemd"}}command -v systemctl >/dev/null || { echo "不支持非 systemd 系统" >&2; exit 1; }
|
||||
{{end}}{{if eq .Mode "docker"}}command -v docker >/dev/null || { echo "需要先安装 docker" >&2; exit 1; }
|
||||
command -v grep >/dev/null 2>&1 || { echo "需要 grep" >&2; exit 1; }
|
||||
if { [ -n "$PROXY_URL" ] || [ -n "$CA_CERT_FILE" ]; } && ! command -v curl >/dev/null 2>&1; then
|
||||
echo "显式代理或自定义 CA 场景需要 curl" >&2
|
||||
exit 1
|
||||
fi
|
||||
{{if eq .Mode "systemd"}}command -v systemctl >/dev/null 2>&1 || { echo "当前系统不支持 systemd" >&2; exit 1; }
|
||||
{{end}}{{if eq .Mode "docker"}}command -v docker >/dev/null 2>&1 || { echo "需要先安装 Docker" >&2; exit 1; }
|
||||
{{end}}
|
||||
|
||||
# 2. 架构检测
|
||||
if [ "$ARCH" = "auto" ]; then
|
||||
case "$(uname -m)" in
|
||||
@@ -34,24 +75,54 @@ if [ "$ARCH" = "auto" ]; then
|
||||
esac
|
||||
fi
|
||||
|
||||
# 3. 安全写入 Agent 配置。systemd unit 与容器元数据中不保存节点 Token。
|
||||
install -d -m 0700 "$CONFIG_DIR" /var/lib/backupx-agent /var/lib/backupx-agent/tmp
|
||||
printf '%s\n' "$AGENT_TOKEN" > "$TOKEN_FILE"
|
||||
chmod 0600 "$TOKEN_FILE"
|
||||
if [ -n "$CA_CERT_FILE" ]; then
|
||||
[ -r "$CA_CERT_FILE" ] || { echo "无法读取 CA 证书: $CA_CERT_FILE" >&2; exit 1; }
|
||||
if [ "$CA_CERT_FILE" != "$CONFIG_DIR/ca.pem" ]; then
|
||||
install -m 0644 "$CA_CERT_FILE" "$CONFIG_DIR/ca.pem"
|
||||
fi
|
||||
CA_CERT_FILE="$CONFIG_DIR/ca.pem"
|
||||
fi
|
||||
cat > "$CONFIG_FILE" <<CONFIG
|
||||
master: "${MASTER_URL}"
|
||||
tokenFile: "${TOKEN_FILE}"
|
||||
heartbeatInterval: "15s"
|
||||
pollInterval: "5s"
|
||||
tempDir: "/var/lib/backupx-agent/tmp"
|
||||
proxyUrl: "${PROXY_URL}"
|
||||
caCertFile: "${CA_CERT_FILE}"
|
||||
CONFIG
|
||||
chmod 0600 "$CONFIG_FILE"
|
||||
|
||||
{{if ne .Mode "docker"}}
|
||||
# 3. 下载二进制(systemd / foreground 模式)
|
||||
# 4. 下载并安装二进制(systemd / foreground 模式)
|
||||
ARCHIVE="backupx-${AGENT_VERSION}-linux-${ARCH}.tar.gz"
|
||||
URL="${DOWNLOAD_BASE}/${AGENT_VERSION}/${ARCHIVE}"
|
||||
TMPDIR="$(mktemp -d)"; trap 'rm -rf "$TMPDIR"' EXIT
|
||||
TMPDIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMPDIR"' EXIT HUP INT TERM
|
||||
echo "[1/4] 下载 ${URL}"
|
||||
if command -v curl >/dev/null; then
|
||||
curl -fsSL "$URL" -o "$TMPDIR/pkg.tar.gz"
|
||||
download_file "$URL" "$TMPDIR/$ARCHIVE"
|
||||
if download_file "${URL}.sha256" "$TMPDIR/$ARCHIVE.sha256" 2>/dev/null; then
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
(cd "$TMPDIR" && sha256sum -c "$ARCHIVE.sha256")
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
(cd "$TMPDIR" && shasum -a 256 -c "$ARCHIVE.sha256")
|
||||
else
|
||||
echo "已下载校验文件,但系统缺少 sha256sum 或 shasum,拒绝未校验安装" >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
wget -qO "$TMPDIR/pkg.tar.gz" "$URL"
|
||||
echo "[WARN] 当前版本未提供 SHA-256 校验文件,继续兼容安装" >&2
|
||||
fi
|
||||
tar xzf "$TMPDIR/pkg.tar.gz" -C "$TMPDIR"
|
||||
tar xzf "$TMPDIR/$ARCHIVE" -C "$TMPDIR"
|
||||
|
||||
# 4. 安装二进制 + 数据目录
|
||||
echo "[2/4] 安装到 ${INSTALL_PREFIX}"
|
||||
install -d -m 0755 "$INSTALL_PREFIX"
|
||||
install -d -m 0700 /var/lib/backupx-agent /var/lib/backupx-agent/tmp
|
||||
install -m 0755 "$TMPDIR/backupx-${AGENT_VERSION}-linux-${ARCH}/backupx" "$INSTALL_PREFIX/backupx"
|
||||
install -m 0755 "$TMPDIR/backupx-${AGENT_VERSION}-linux-${ARCH}/backupx" "$INSTALL_PREFIX/backupx.new"
|
||||
mv -f "$INSTALL_PREFIX/backupx.new" "$INSTALL_PREFIX/backupx"
|
||||
{{end}}
|
||||
|
||||
{{if eq .Mode "systemd"}}
|
||||
@@ -62,69 +133,78 @@ cat > /etc/systemd/system/backupx-agent.service <<UNIT
|
||||
Description=BackupX Agent
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
StartLimitIntervalSec=300
|
||||
StartLimitBurst=10
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
Environment="BACKUPX_AGENT_MASTER=${MASTER_URL}"
|
||||
Environment="BACKUPX_AGENT_TOKEN=${AGENT_TOKEN}"
|
||||
ExecStart=${INSTALL_PREFIX}/backupx agent --temp-dir /var/lib/backupx-agent/tmp
|
||||
ExecStart=${INSTALL_PREFIX}/backupx agent --config ${CONFIG_FILE}
|
||||
Restart=on-failure
|
||||
RestartSec=10s
|
||||
# Agent 需以 root 运行以读取任意源数据;与单机服务端保持一致的资源/句柄上限。
|
||||
TimeoutStopSec=30s
|
||||
UMask=0077
|
||||
# Agent 以 root 运行,以便读取备份源及执行恢复;节点 Token 仅保存在 0600 文件中。
|
||||
LimitNOFILE=65535
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
UNIT
|
||||
chmod 0644 /etc/systemd/system/backupx-agent.service
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now backupx-agent
|
||||
if ! systemctl enable backupx-agent || ! systemctl restart backupx-agent; then
|
||||
echo "BackupX Agent 服务启动失败" >&2
|
||||
systemctl status backupx-agent --no-pager >&2 || true
|
||||
journalctl -u backupx-agent -n 100 --no-pager >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 6. 等待上线
|
||||
echo "[4/4] 等待节点上线"
|
||||
for i in $(seq 1 15); do
|
||||
i=0
|
||||
while [ "$i" -lt 15 ]; do
|
||||
sleep 2
|
||||
if curl -fsSL -H "X-Agent-Token: ${AGENT_TOKEN}" "${MASTER_URL}/api/v1/agent/self" 2>/dev/null \
|
||||
| grep -q '"status":"online"'; then
|
||||
echo "✓ 节点已上线"
|
||||
if agent_is_online; then
|
||||
echo "[OK] 节点已上线"
|
||||
exit 0
|
||||
fi
|
||||
i=$((i + 1))
|
||||
done
|
||||
echo "⚠ 30s 内未收到上线心跳,请检查防火墙或 journalctl -u backupx-agent"
|
||||
echo "提示:systemd 服务名是 backupx-agent,可执行 systemctl status backupx-agent 查看状态。"
|
||||
echo "[WARN] 30 秒内未收到上线心跳,请检查网络、代理或 SSH 隧道" >&2
|
||||
echo "排查命令: systemctl status backupx-agent" >&2
|
||||
echo "排查命令: journalctl -u backupx-agent -n 100 --no-pager" >&2
|
||||
exit 2
|
||||
{{end}}
|
||||
|
||||
{{if eq .Mode "foreground"}}
|
||||
# 5. 前台运行
|
||||
echo "[3/3] 前台启动 agent(Ctrl+C 退出)"
|
||||
export BACKUPX_AGENT_MASTER="${MASTER_URL}"
|
||||
export BACKUPX_AGENT_TOKEN="${AGENT_TOKEN}"
|
||||
exec "${INSTALL_PREFIX}/backupx" agent --temp-dir /var/lib/backupx-agent/tmp
|
||||
echo "[3/3] 前台启动 Agent(Ctrl+C 退出)"
|
||||
exec "${INSTALL_PREFIX}/backupx" agent --config "$CONFIG_FILE"
|
||||
{{end}}
|
||||
|
||||
{{if eq .Mode "docker"}}
|
||||
# Docker 模式:直接用镜像启动容器
|
||||
# Docker 模式:配置文件只读挂载,避免 Token 出现在 docker inspect 环境变量中。
|
||||
echo "[1/2] 拉取镜像 awuqing/backupx:${AGENT_VERSION}"
|
||||
docker pull "awuqing/backupx:${AGENT_VERSION}"
|
||||
echo "[2/2] 启动容器 backupx-agent"
|
||||
docker rm -f backupx-agent >/dev/null 2>&1 || true
|
||||
docker run -d --name backupx-agent --restart=unless-stopped \
|
||||
-e "BACKUPX_AGENT_MASTER=${MASTER_URL}" \
|
||||
-e "BACKUPX_AGENT_TOKEN=${AGENT_TOKEN}" \
|
||||
-e "BACKUPX_AGENT_TEMP_DIR=/var/lib/backupx-agent/tmp" \
|
||||
docker run -d --name backupx-agent --restart=unless-stopped --user 0:0 \
|
||||
--security-opt no-new-privileges:true \
|
||||
-v /etc/backupx-agent:/etc/backupx-agent:ro \
|
||||
-v /var/lib/backupx-agent:/var/lib/backupx-agent \
|
||||
"awuqing/backupx:${AGENT_VERSION}" agent
|
||||
echo "✓ 容器已启动,等待节点上线"
|
||||
for i in $(seq 1 15); do
|
||||
"awuqing/backupx:${AGENT_VERSION}" agent --config /etc/backupx-agent/config.yaml
|
||||
echo "提示: Docker Agent 只能访问显式挂载的目录;备份宿主机路径前请按文档添加只读 -v 挂载。"
|
||||
echo "容器已启动,等待节点上线"
|
||||
i=0
|
||||
while [ "$i" -lt 15 ]; do
|
||||
sleep 2
|
||||
if curl -fsSL -H "X-Agent-Token: ${AGENT_TOKEN}" "${MASTER_URL}/api/v1/agent/self" 2>/dev/null \
|
||||
| grep -q '"status":"online"'; then
|
||||
echo "✓ 节点已上线"
|
||||
if agent_is_online; then
|
||||
echo "[OK] 节点已上线"
|
||||
exit 0
|
||||
fi
|
||||
i=$((i + 1))
|
||||
done
|
||||
echo "⚠ 30s 内未收到上线心跳,请检查容器状态、网络与 Master URL。"
|
||||
echo "排查命令:docker ps -a --filter name=backupx-agent"
|
||||
echo "排查命令:docker logs --tail=100 backupx-agent"
|
||||
echo "[WARN] 30 秒内未收到上线心跳,请检查容器、代理、隧道与 Master 地址" >&2
|
||||
echo "排查命令: docker ps -a --filter name=backupx-agent" >&2
|
||||
echo "排查命令: docker logs --tail=100 backupx-agent" >&2
|
||||
exit 2
|
||||
{{end}}
|
||||
|
||||
@@ -4,11 +4,11 @@ import "time"
|
||||
|
||||
// AgentCommand 状态常量
|
||||
const (
|
||||
AgentCommandStatusPending = "pending" // 待 Agent 拉取
|
||||
AgentCommandStatusPending = "pending" // 待 Agent 拉取
|
||||
AgentCommandStatusDispatched = "dispatched" // Agent 已领取,正在执行
|
||||
AgentCommandStatusSucceeded = "succeeded" // 执行成功
|
||||
AgentCommandStatusFailed = "failed" // 执行失败
|
||||
AgentCommandStatusTimeout = "timeout" // 超时未完成
|
||||
AgentCommandStatusSucceeded = "succeeded" // 执行成功
|
||||
AgentCommandStatusFailed = "failed" // 执行失败
|
||||
AgentCommandStatusTimeout = "timeout" // 超时未完成
|
||||
)
|
||||
|
||||
// AgentCommand 类型常量
|
||||
@@ -36,20 +36,20 @@ const (
|
||||
)
|
||||
|
||||
// AgentCommand 代表 Master 发给某个 Agent 节点的待执行命令。
|
||||
// 使用简单的数据库队列实现:Agent 通过 token 长轮询拉取本节点 pending 命令,
|
||||
// 使用简单的数据库队列实现:Agent 通过 token 定期轮询本节点 pending 命令,
|
||||
// 执行后回写状态与结果。Master 侧通过定时检查把超时的命令标记为 timeout。
|
||||
type AgentCommand struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
NodeID uint `gorm:"column:node_id;index;not null" json:"nodeId"`
|
||||
Type string `gorm:"size:32;index;not null" json:"type"`
|
||||
Status string `gorm:"size:20;index;not null;default:'pending'" json:"status"`
|
||||
Payload string `gorm:"type:text" json:"payload"` // JSON
|
||||
Result string `gorm:"type:text" json:"result"` // JSON(成功结果)
|
||||
ErrorMessage string `gorm:"column:error_message;type:text" json:"errorMessage"`
|
||||
DispatchedAt *time.Time `gorm:"column:dispatched_at" json:"dispatchedAt,omitempty"`
|
||||
CompletedAt *time.Time `gorm:"column:completed_at" json:"completedAt,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
NodeID uint `gorm:"column:node_id;not null;index:idx_agent_commands_node_status,priority:1" json:"nodeId"`
|
||||
Type string `gorm:"size:32;index;not null" json:"type"`
|
||||
Status string `gorm:"size:20;not null;default:'pending';index:idx_agent_commands_node_status,priority:2;index:idx_agent_commands_status_dispatched,priority:1;index:idx_agent_commands_status_created,priority:1" json:"status"`
|
||||
Payload string `gorm:"type:text" json:"payload"` // JSON
|
||||
Result string `gorm:"type:text" json:"result"` // JSON(成功结果)
|
||||
ErrorMessage string `gorm:"column:error_message;type:text" json:"errorMessage"`
|
||||
DispatchedAt *time.Time `gorm:"column:dispatched_at;index:idx_agent_commands_status_dispatched,priority:2" json:"dispatchedAt,omitempty"`
|
||||
CompletedAt *time.Time `gorm:"column:completed_at" json:"completedAt,omitempty"`
|
||||
CreatedAt time.Time `gorm:"index:idx_agent_commands_status_created,priority:2" json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (AgentCommand) TableName() string {
|
||||
|
||||
@@ -6,17 +6,21 @@ import "time"
|
||||
//
|
||||
// 生命周期:创建 → 消费(ConsumedAt 非空即作废)→ 超过 ExpiresAt 后被 GC 硬删除。
|
||||
type AgentInstallToken struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Token string `gorm:"size:64;uniqueIndex;not null" json:"token"`
|
||||
NodeID uint `gorm:"not null;index" json:"nodeId"`
|
||||
Mode string `gorm:"size:16;not null" json:"mode"` // systemd|docker|foreground
|
||||
Arch string `gorm:"size:16;not null" json:"arch"` // amd64|arm64|auto
|
||||
AgentVer string `gorm:"size:32;not null" json:"agentVersion"`
|
||||
DownloadSrc string `gorm:"size:16;not null;default:'github'" json:"downloadSrc"`
|
||||
ExpiresAt time.Time `gorm:"not null;index" json:"expiresAt"`
|
||||
ConsumedAt *time.Time `json:"consumedAt,omitempty"`
|
||||
CreatedByID uint `gorm:"not null" json:"createdById"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Token string `gorm:"size:64;uniqueIndex;not null" json:"token"`
|
||||
NodeID uint `gorm:"not null;index" json:"nodeId"`
|
||||
Mode string `gorm:"size:16;not null" json:"mode"` // systemd|docker|foreground
|
||||
Arch string `gorm:"size:16;not null" json:"arch"` // amd64|arm64|auto
|
||||
AgentVer string `gorm:"size:32;not null" json:"agentVersion"`
|
||||
DownloadSrc string `gorm:"size:16;not null;default:'github'" json:"downloadSrc"`
|
||||
// AgentMasterURL 可覆盖公开安装地址,支持代理或 SSH 隧道后的节点专用入口。
|
||||
AgentMasterURL string `gorm:"size:2048" json:"agentMasterUrl,omitempty"`
|
||||
ProxyURL string `gorm:"size:2048" json:"proxyUrl,omitempty"`
|
||||
CACertFile string `gorm:"size:512" json:"caCertFile,omitempty"`
|
||||
ExpiresAt time.Time `gorm:"not null;index" json:"expiresAt"`
|
||||
ConsumedAt *time.Time `json:"consumedAt,omitempty"`
|
||||
CreatedByID uint `gorm:"not null" json:"createdById"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
func (AgentInstallToken) TableName() string { return "agent_install_tokens" }
|
||||
|
||||
@@ -17,12 +17,30 @@ func newTestDB(t *testing.T) *gorm.DB {
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatalf("get sql database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
if err := db.AutoMigrate(&model.AgentCommand{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestAgentCommandQueueIndexes(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
for _, name := range []string{
|
||||
"idx_agent_commands_node_status",
|
||||
"idx_agent_commands_status_dispatched",
|
||||
"idx_agent_commands_status_created",
|
||||
} {
|
||||
if !db.Migrator().HasIndex(&model.AgentCommand{}, name) {
|
||||
t.Fatalf("missing Agent command queue index %s", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentCommandRepository_ClaimPending(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
repo := NewAgentCommandRepository(db)
|
||||
|
||||
@@ -20,6 +20,11 @@ func openTestInstallTokenDB(t *testing.T) *gorm.DB {
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatalf("get sql database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
if err := db.AutoMigrate(&model.AgentInstallToken{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
@@ -22,6 +22,11 @@ func newBackupRecordTestRepository(t *testing.T) *GormBackupRecordRepository {
|
||||
if err != nil {
|
||||
t.Fatalf("database.Open returned error: %v", err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatalf("db.DB returned error: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
storageTarget := &model.StorageTarget{Name: "local", Type: "local_disk", Enabled: true, ConfigCiphertext: "{}", ConfigVersion: 1, LastTestStatus: "unknown"}
|
||||
if err := db.Create(storageTarget).Error; err != nil {
|
||||
t.Fatalf("seed storage target error: %v", err)
|
||||
|
||||
@@ -21,6 +21,11 @@ func newBackupTaskTestRepository(t *testing.T) *GormBackupTaskRepository {
|
||||
if err != nil {
|
||||
t.Fatalf("database.Open returned error: %v", err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatalf("db.DB returned error: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
if err := db.Create(&model.StorageTarget{Name: "local", Type: "local_disk", Enabled: true, ConfigCiphertext: "{}", ConfigVersion: 1, LastTestStatus: "unknown"}).Error; err != nil {
|
||||
t.Fatalf("seed storage target error: %v", err)
|
||||
}
|
||||
|
||||
@@ -19,6 +19,11 @@ func openTestNodeDB(t *testing.T) *gorm.DB {
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %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.Node{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
@@ -21,6 +21,11 @@ func newNotificationTestRepository(t *testing.T) *GormNotificationRepository {
|
||||
if err != nil {
|
||||
t.Fatalf("database.Open returned error: %v", err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatalf("db.DB returned error: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
return NewNotificationRepository(db)
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,11 @@ func newOAuthSessionTestRepository(t *testing.T) *GormOAuthSessionRepository {
|
||||
if err != nil {
|
||||
t.Fatalf("database.Open returned error: %v", err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatalf("db.DB returned error: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
return NewOAuthSessionRepository(db)
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,11 @@ func newRestoreRecordTestRepository(t *testing.T) (*GormRestoreRecordRepository,
|
||||
if err != nil {
|
||||
t.Fatalf("database.Open returned error: %v", err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatalf("db.DB returned error: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
storageTarget := &model.StorageTarget{Name: "local", Type: "local_disk", Enabled: true, ConfigCiphertext: "{}", ConfigVersion: 1, LastTestStatus: "unknown"}
|
||||
if err := db.Create(storageTarget).Error; err != nil {
|
||||
t.Fatalf("seed storage target error: %v", err)
|
||||
|
||||
@@ -26,6 +26,11 @@ func newStorageTestRepository(t *testing.T) *GormStorageTargetRepository {
|
||||
if err != nil {
|
||||
t.Fatalf("database.Open returned error: %v", err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatalf("db.DB returned error: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
return NewStorageTargetRepository(db)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -27,13 +28,16 @@ func NewInstallTokenService(repo repository.AgentInstallTokenRepository, nodeRep
|
||||
|
||||
// InstallTokenInput 生成一次性安装令牌的输入。
|
||||
type InstallTokenInput struct {
|
||||
NodeID uint
|
||||
Mode string
|
||||
Arch string
|
||||
AgentVersion string
|
||||
DownloadSrc string
|
||||
TTLSeconds int
|
||||
CreatedByID uint
|
||||
NodeID uint
|
||||
Mode string
|
||||
Arch string
|
||||
AgentVersion string
|
||||
DownloadSrc string
|
||||
TTLSeconds int
|
||||
CreatedByID uint
|
||||
AgentMasterURL string
|
||||
ProxyURL string
|
||||
CACertFile string
|
||||
}
|
||||
|
||||
// InstallTokenOutput 生成结果。
|
||||
@@ -112,14 +116,17 @@ func (s *InstallTokenService) Create(ctx context.Context, in InstallTokenInput)
|
||||
}
|
||||
expiresAt := time.Now().UTC().Add(time.Duration(in.TTLSeconds) * time.Second)
|
||||
record := &model.AgentInstallToken{
|
||||
Token: token,
|
||||
NodeID: in.NodeID,
|
||||
Mode: in.Mode,
|
||||
Arch: in.Arch,
|
||||
AgentVer: in.AgentVersion,
|
||||
DownloadSrc: in.DownloadSrc,
|
||||
ExpiresAt: expiresAt,
|
||||
CreatedByID: in.CreatedByID,
|
||||
Token: token,
|
||||
NodeID: in.NodeID,
|
||||
Mode: in.Mode,
|
||||
Arch: in.Arch,
|
||||
AgentVer: in.AgentVersion,
|
||||
DownloadSrc: in.DownloadSrc,
|
||||
AgentMasterURL: strings.TrimRight(strings.TrimSpace(in.AgentMasterURL), "/"),
|
||||
ProxyURL: strings.TrimSpace(in.ProxyURL),
|
||||
CACertFile: strings.TrimSpace(in.CACertFile),
|
||||
ExpiresAt: expiresAt,
|
||||
CreatedByID: in.CreatedByID,
|
||||
}
|
||||
if err := s.repo.Create(ctx, record); err != nil {
|
||||
return nil, err
|
||||
@@ -130,9 +137,15 @@ func (s *InstallTokenService) Create(ctx context.Context, in InstallTokenInput)
|
||||
// CreateCommand 创建 install token,并返回 UI 展示安装命令所需的 URL 与嵌入式脚本。
|
||||
func (s *InstallTokenService) CreateCommand(ctx context.Context, in InstallCommandInput) (*InstallCommandOutput, error) {
|
||||
masterURL := strings.TrimRight(strings.TrimSpace(in.MasterURL), "/")
|
||||
if masterURL == "" {
|
||||
return nil, apperror.BadRequest("INSTALL_TOKEN_INVALID", "masterURL 必填", nil)
|
||||
deliveryURL, parseErr := url.Parse(masterURL)
|
||||
if masterURL == "" || parseErr != nil || (deliveryURL.Scheme != "http" && deliveryURL.Scheme != "https") || deliveryURL.Host == "" || deliveryURL.User != nil || deliveryURL.RawQuery != "" || deliveryURL.Fragment != "" || strings.ContainsAny(masterURL, " \t\r\n\"'`$\\") {
|
||||
return nil, apperror.BadRequest("INSTALL_TOKEN_INVALID", "masterURL 必须是安全的完整 HTTP(S) 地址", parseErr)
|
||||
}
|
||||
agentMasterURL := strings.TrimRight(strings.TrimSpace(in.AgentMasterURL), "/")
|
||||
if agentMasterURL == "" {
|
||||
agentMasterURL = masterURL
|
||||
}
|
||||
in.AgentMasterURL = agentMasterURL
|
||||
if err := s.validate(in.InstallTokenInput); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -144,12 +157,15 @@ func (s *InstallTokenService) CreateCommand(ctx context.Context, in InstallComma
|
||||
return nil, apperror.New(404, "NODE_NOT_FOUND", "节点不存在", nil)
|
||||
}
|
||||
if _, err := renderInstallCommandScript(masterURL, node, &model.AgentInstallToken{
|
||||
Mode: in.Mode,
|
||||
Arch: in.Arch,
|
||||
AgentVer: in.AgentVersion,
|
||||
DownloadSrc: in.DownloadSrc,
|
||||
Mode: in.Mode,
|
||||
Arch: in.Arch,
|
||||
AgentVer: in.AgentVersion,
|
||||
DownloadSrc: in.DownloadSrc,
|
||||
AgentMasterURL: in.AgentMasterURL,
|
||||
ProxyURL: in.ProxyURL,
|
||||
CACertFile: in.CACertFile,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
return nil, apperror.BadRequest("INSTALL_TOKEN_INVALID", "Agent 连接配置无效", err)
|
||||
}
|
||||
out, err := s.Create(ctx, in.InstallTokenInput)
|
||||
if err != nil {
|
||||
@@ -164,20 +180,24 @@ func (s *InstallTokenService) CreateCommand(ctx context.Context, in InstallComma
|
||||
ExpiresAt: out.ExpiresAt,
|
||||
Node: out.Node,
|
||||
Record: out.Record,
|
||||
URL: masterURL + "/api/install/" + out.Token,
|
||||
FallbackURL: masterURL + "/install/" + out.Token,
|
||||
URL: agentMasterURL + "/api/install/" + out.Token,
|
||||
FallbackURL: agentMasterURL + "/install/" + out.Token,
|
||||
ScriptBase64: base64.StdEncoding.EncodeToString([]byte(script)),
|
||||
}
|
||||
if out.Record.Mode == model.InstallModeDocker {
|
||||
result.ComposeURL = masterURL + "/api/install/" + out.Token + "/compose.yml"
|
||||
result.FallbackComposeURL = masterURL + "/install/" + out.Token + "/compose.yml"
|
||||
result.ComposeURL = agentMasterURL + "/api/install/" + out.Token + "/compose.yml"
|
||||
result.FallbackComposeURL = agentMasterURL + "/install/" + out.Token + "/compose.yml"
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func renderInstallCommandScript(masterURL string, node *model.Node, record *model.AgentInstallToken) (string, error) {
|
||||
agentMasterURL := strings.TrimRight(strings.TrimSpace(record.AgentMasterURL), "/")
|
||||
if agentMasterURL == "" {
|
||||
agentMasterURL = masterURL
|
||||
}
|
||||
return installscript.RenderScript(installscript.Context{
|
||||
MasterURL: masterURL,
|
||||
MasterURL: agentMasterURL,
|
||||
AgentToken: node.Token,
|
||||
AgentVersion: record.AgentVer,
|
||||
Mode: record.Mode,
|
||||
@@ -185,6 +205,8 @@ func renderInstallCommandScript(masterURL string, node *model.Node, record *mode
|
||||
DownloadBase: installscript.DownloadBaseFor(record.DownloadSrc),
|
||||
InstallPrefix: "/opt/backupx-agent",
|
||||
NodeID: node.ID,
|
||||
ProxyURL: record.ProxyURL,
|
||||
CACertFile: record.CACertFile,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -259,6 +281,9 @@ func (s *InstallTokenService) validate(in InstallTokenInput) error {
|
||||
return apperror.BadRequest("INSTALL_TOKEN_INVALID",
|
||||
fmt.Sprintf("ttlSeconds 需在 %d-%d", InstallTokenMinTTL, InstallTokenMaxTTL), nil)
|
||||
}
|
||||
if len(in.AgentMasterURL) > 2048 || len(in.ProxyURL) > 2048 || len(in.CACertFile) > 512 {
|
||||
return apperror.BadRequest("INSTALL_TOKEN_INVALID", "连接配置过长", nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -177,13 +179,16 @@ func TestInstallTokenServiceCreateCommandBuildsURLsAndScript(t *testing.T) {
|
||||
|
||||
out, err := svc.CreateCommand(context.Background(), InstallCommandInput{
|
||||
InstallTokenInput: InstallTokenInput{
|
||||
NodeID: node.ID,
|
||||
Mode: model.InstallModeDocker,
|
||||
Arch: model.InstallArchAuto,
|
||||
AgentVersion: "v1.7.0",
|
||||
DownloadSrc: model.InstallSourceGitHub,
|
||||
TTLSeconds: 900,
|
||||
CreatedByID: 1,
|
||||
NodeID: node.ID,
|
||||
Mode: model.InstallModeDocker,
|
||||
Arch: model.InstallArchAuto,
|
||||
AgentVersion: "v1.7.0",
|
||||
DownloadSrc: model.InstallSourceGitHub,
|
||||
TTLSeconds: 900,
|
||||
CreatedByID: 1,
|
||||
AgentMasterURL: "http://127.0.0.1:18340",
|
||||
ProxyURL: "socks5h://127.0.0.1:1080",
|
||||
CACertFile: "/etc/pki/internal-ca.pem",
|
||||
},
|
||||
MasterURL: "https://public.example.com/base",
|
||||
})
|
||||
@@ -193,15 +198,66 @@ func TestInstallTokenServiceCreateCommandBuildsURLsAndScript(t *testing.T) {
|
||||
if out.Token == "" || out.ScriptBase64 == "" {
|
||||
t.Fatalf("missing token or script: %+v", out)
|
||||
}
|
||||
if out.URL != "https://public.example.com/base/api/install/"+out.Token {
|
||||
if out.URL != "http://127.0.0.1:18340/api/install/"+out.Token {
|
||||
t.Fatalf("bad url: %s", out.URL)
|
||||
}
|
||||
if out.FallbackURL != "https://public.example.com/base/install/"+out.Token {
|
||||
if out.FallbackURL != "http://127.0.0.1:18340/install/"+out.Token {
|
||||
t.Fatalf("bad fallback url: %s", out.FallbackURL)
|
||||
}
|
||||
if out.ComposeURL != "https://public.example.com/base/api/install/"+out.Token+"/compose.yml" {
|
||||
if out.ComposeURL != "http://127.0.0.1:18340/api/install/"+out.Token+"/compose.yml" {
|
||||
t.Fatalf("bad compose url: %s", out.ComposeURL)
|
||||
}
|
||||
script, err := base64.StdEncoding.DecodeString(out.ScriptBase64)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
`MASTER_URL="http://127.0.0.1:18340"`,
|
||||
`PROXY_URL="socks5h://127.0.0.1:1080"`,
|
||||
`CA_CERT_FILE="/etc/pki/internal-ca.pem"`,
|
||||
} {
|
||||
if !strings.Contains(string(script), want) {
|
||||
t.Fatalf("script missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallTokenServiceRejectsUnsafeConnectionBeforeCreate(t *testing.T) {
|
||||
db := openInstallTokenTestDB(t)
|
||||
nodeRepo := repository.NewNodeRepository(db)
|
||||
node := &model.Node{Name: "restricted", Token: "deadbeefcafebabe0123456789abcdef0123456789abcdef0123456789abcdef"}
|
||||
if err := nodeRepo.Create(context.Background(), node); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tokenRepo := repository.NewAgentInstallTokenRepository(db)
|
||||
svc := NewInstallTokenService(tokenRepo, nodeRepo)
|
||||
_, err := svc.CreateCommand(context.Background(), InstallCommandInput{
|
||||
InstallTokenInput: InstallTokenInput{
|
||||
NodeID: node.ID, Mode: model.InstallModeSystemd, Arch: model.InstallArchAuto,
|
||||
AgentVersion: "v2.4.0", DownloadSrc: model.InstallSourceGitHub, TTLSeconds: 900,
|
||||
ProxyURL: "http://user:pass@proxy.example.com",
|
||||
},
|
||||
MasterURL: "https://public.example.com",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected proxy credentials to be rejected")
|
||||
}
|
||||
count, countErr := tokenRepo.CountCreatedSince(context.Background(), node.ID, time.Now().UTC().Add(-time.Hour))
|
||||
if countErr != nil || count != 0 {
|
||||
t.Fatalf("invalid connection created token records: count=%d err=%v", count, countErr)
|
||||
}
|
||||
|
||||
_, err = svc.CreateCommand(context.Background(), InstallCommandInput{
|
||||
InstallTokenInput: InstallTokenInput{
|
||||
NodeID: node.ID, Mode: model.InstallModeSystemd, Arch: model.InstallArchAuto,
|
||||
AgentVersion: "v2.4.0", DownloadSrc: model.InstallSourceGitHub, TTLSeconds: 900,
|
||||
AgentMasterURL: "https://master.internal",
|
||||
},
|
||||
MasterURL: "http://public.example.com/$unsafe",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected unsafe public delivery URL to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallTokenServiceRateLimit(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user