feat(cluster): support restricted-network agent connectivity

This commit is contained in:
Awuqing
2026-08-09 02:30:42 +08:00
parent 00151e466c
commit ea46a30f11
13 changed files with 481 additions and 22 deletions
+25
View File
@@ -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")
+44
View File
@@ -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)
}
}