From 875a07850724c916301a4b0cdac90be603df5efd Mon Sep 17 00:00:00 2001 From: AzurCrystal Date: Tue, 2 Jun 2026 22:27:18 +0800 Subject: [PATCH 1/2] Add Unix socket listening support for the server --- backend/cmd/server/main.go | 45 +++++++- backend/internal/config/config.go | 154 +++++++++++++++++++++++++ backend/internal/config/config_test.go | 141 ++++++++++++++++++++++ backend/internal/setup/handler.go | 11 +- deploy/install.sh | 2 + deploy/sub2api.service | 2 + 6 files changed, 346 insertions(+), 9 deletions(-) diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index a581b9263..6eb85e3b1 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -7,11 +7,14 @@ import ( _ "embed" "errors" "flag" + "fmt" "log" + "net" "net/http" _ "net/http/pprof" //nolint:gosec // Admin/debug profiling is intentionally exposed only when the server is started with that route mounted. "os" "os/signal" + "path/filepath" "strconv" "strings" "syscall" @@ -125,7 +128,10 @@ func runSetupServer() { IdleTimeout: 120 * time.Second, } - if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + if err := serveServer(server, config.ServerListenSpec{ + Network: config.ServerListenNetworkTCP, + Address: addr, + }); err != nil && !errors.Is(err, http.ErrServerClosed) { log.Fatalf("Failed to start setup server: %v", err) } } @@ -154,15 +160,19 @@ func runMainServer() { defer app.Cleanup() pprofServer := startPprofServer() + listenSpec, err := cfg.Server.ListenSpec() + if err != nil { + log.Fatalf("Invalid server listen configuration: %v", err) + } // 启动服务器 go func() { - if err := app.Server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + if err := serveServer(app.Server, listenSpec); err != nil && !errors.Is(err, http.ErrServerClosed) { log.Fatalf("Failed to start server: %v", err) } }() - log.Printf("Server started on %s", app.Server.Addr) + log.Printf("Server started on %s", listenSpec.DisplayAddress()) // 等待中断信号 quit := make(chan os.Signal, 1) @@ -187,6 +197,35 @@ func runMainServer() { log.Println("Server exited") } +func serveServer(server *http.Server, spec config.ServerListenSpec) error { + switch spec.Network { + case config.ServerListenNetworkUnix: + if err := os.MkdirAll(filepath.Dir(spec.Address), 0o755); err != nil { + return fmt.Errorf("create unix socket directory: %w", err) + } + if err := config.RemoveUnixSocketIfExists(spec.Address); err != nil { + return err + } + listener, err := net.Listen(string(spec.Network), spec.Address) + if err != nil { + return fmt.Errorf("listen on %s: %w", spec.DisplayAddress(), err) + } + if err := os.Chmod(spec.Address, spec.Mode); err != nil { + _ = listener.Close() + _ = os.Remove(spec.Address) + return fmt.Errorf("chmod unix socket %s: %w", spec.Address, err) + } + defer func() { + _ = listener.Close() + _ = os.Remove(spec.Address) + }() + return server.Serve(listener) + default: + server.Addr = spec.Address + return server.ListenAndServe() + } +} + func startPprofServer() *http.Server { enabledValue := strings.TrimSpace(os.Getenv("PPROF_ENABLED")) if enabledValue == "" { diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 02b3a9132..a3039791d 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -4,10 +4,13 @@ package config import ( "crypto/rand" "encoding/hex" + "errors" "fmt" "log/slog" "net/url" "os" + pathpkg "path" + "strconv" "strings" "time" @@ -530,6 +533,139 @@ type ServerConfig struct { H2C H2CConfig `mapstructure:"h2c"` // HTTP/2 Cleartext 配置 } +type ServerListenNetwork string + +const ( + ServerListenNetworkTCP ServerListenNetwork = "tcp" + ServerListenNetworkUnix ServerListenNetwork = "unix" +) + +const ( + defaultUnixSocketFileMode os.FileMode = 0o660 +) + +type ServerListenSpec struct { + Network ServerListenNetwork + Address string + Mode os.FileMode +} + +func (s ServerListenSpec) DisplayAddress() string { + switch s.Network { + case ServerListenNetworkUnix: + return fmt.Sprintf("unix://%s", s.Address) + default: + return s.Address + } +} + +func ParseServerListenSpec(host string, port int) (ServerListenSpec, error) { + rawHost := strings.TrimSpace(host) + if rawHost == "" { + rawHost = "0.0.0.0" + } + + socketMode := defaultUnixSocketFileMode + switch { + case strings.HasPrefix(rawHost, "unix:"): + return parseUnixSocketListenSpec(strings.TrimSpace(strings.TrimPrefix(rawHost, "unix:")), socketMode) + case strings.HasPrefix(rawHost, "/"): + return parseUnixSocketListenSpec(rawHost, socketMode) + default: + if port <= 0 || port > 65535 { + return ServerListenSpec{}, fmt.Errorf("tcp listen port must be between 1-65535") + } + return ServerListenSpec{ + Network: ServerListenNetworkTCP, + Address: fmt.Sprintf("%s:%d", rawHost, port), + }, nil + } +} + +func parseUnixSocketListenSpec(raw string, defaultMode os.FileMode) (ServerListenSpec, error) { + socketPath := strings.TrimSpace(raw) + if socketPath == "" { + return ServerListenSpec{}, fmt.Errorf("unix socket path is required") + } + + mode := defaultMode + if idx := strings.LastIndex(socketPath, ","); idx >= 0 { + maybeMode := strings.TrimSpace(socketPath[idx+1:]) + if maybeMode != "" { + parsedMode, err := parseUnixSocketFileMode(maybeMode) + if err != nil { + return ServerListenSpec{}, err + } + mode = parsedMode + socketPath = strings.TrimSpace(socketPath[:idx]) + } + } + + if socketPath == "" { + return ServerListenSpec{}, fmt.Errorf("unix socket path is required") + } + if !strings.HasPrefix(socketPath, "/") { + return ServerListenSpec{}, fmt.Errorf("unix socket path must be absolute") + } + cleanPath := pathpkg.Clean(socketPath) + if cleanPath == "/" { + return ServerListenSpec{}, fmt.Errorf("unix socket path cannot be root directory") + } + + return ServerListenSpec{ + Network: ServerListenNetworkUnix, + Address: cleanPath, + Mode: mode, + }, nil +} + +func parseUnixSocketFileMode(raw string) (os.FileMode, error) { + value := strings.TrimSpace(raw) + if value == "" { + return 0, fmt.Errorf("unix socket file mode cannot be empty") + } + if strings.HasPrefix(value, "0o") || strings.HasPrefix(value, "0O") { + value = value[2:] + } + if strings.HasPrefix(value, "0") && len(value) > 1 { + value = value[1:] + } + if len(value) < 3 || len(value) > 4 { + return 0, fmt.Errorf("unix socket file mode must be 3-4 octal digits") + } + for _, r := range value { + if r < '0' || r > '7' { + return 0, fmt.Errorf("unix socket file mode must be octal") + } + } + parsed, err := strconv.ParseUint(value, 8, 32) + if err != nil { + return 0, fmt.Errorf("parse unix socket file mode: %w", err) + } + mode := os.FileMode(parsed) + if mode&os.ModeType != 0 { + return 0, fmt.Errorf("unix socket file mode must not include file type bits") + } + return mode, nil +} + +func RemoveUnixSocketIfExists(path string) error { + info, err := os.Lstat(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return fmt.Errorf("stat unix socket %q: %w", path, err) + } + if info.Mode()&os.ModeSocket == 0 { + return fmt.Errorf("refusing to remove non-socket file at %q", path) + } + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("remove stale unix socket %q: %w", path, err) + } + return nil +} + // H2CConfig HTTP/2 Cleartext 配置 type H2CConfig struct { Enabled bool `mapstructure:"enabled"` // 是否启用 H2C @@ -967,6 +1103,21 @@ func (s *ServerConfig) Address() string { return fmt.Sprintf("%s:%d", s.Host, s.Port) } +func (s *ServerConfig) ListenSpec() (ServerListenSpec, error) { + if s == nil { + return ParseServerListenSpec("", 8080) + } + return ParseServerListenSpec(s.Host, s.Port) +} + +func (s *ServerConfig) DisplayAddress() string { + spec, err := s.ListenSpec() + if err != nil { + return s.Address() + } + return spec.DisplayAddress() +} + // DatabaseConfig 数据库连接配置 // 性能优化:新增连接池参数,避免频繁创建/销毁连接 type DatabaseConfig struct { @@ -1949,6 +2100,9 @@ func (c *Config) Validate() error { } warnIfInsecureURL("server.frontend_url", c.Server.FrontendURL) } + if _, err := c.Server.ListenSpec(); err != nil { + return fmt.Errorf("server listen config invalid: %w", err) + } if c.JWT.ExpireHour <= 0 { return fmt.Errorf("jwt.expire_hour must be positive") } diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 40d73605c..89fb59cc8 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -1,8 +1,11 @@ package config import ( + "net" "os" + pathpkg "path" "path/filepath" + "runtime" "strings" "testing" "time" @@ -210,6 +213,144 @@ func TestLoadDefaultIdempotencyConfig(t *testing.T) { } } +func TestServerListenSpecTCP(t *testing.T) { + server := &ServerConfig{Host: "127.0.0.1", Port: 9000} + + spec, err := server.ListenSpec() + if err != nil { + t.Fatalf("ListenSpec() error: %v", err) + } + if spec.Network != ServerListenNetworkTCP { + t.Fatalf("ListenSpec().Network = %q, want %q", spec.Network, ServerListenNetworkTCP) + } + if spec.Address != "127.0.0.1:9000" { + t.Fatalf("ListenSpec().Address = %q", spec.Address) + } + if spec.DisplayAddress() != "127.0.0.1:9000" { + t.Fatalf("ListenSpec().DisplayAddress() = %q", spec.DisplayAddress()) + } +} + +func TestServerListenSpecUnixCompat(t *testing.T) { + spec, err := ParseServerListenSpec("/var/run/pixel.sock,0660", 0) + if err != nil { + t.Fatalf("ParseServerListenSpec() error: %v", err) + } + if spec.Network != ServerListenNetworkUnix { + t.Fatalf("Network = %q, want %q", spec.Network, ServerListenNetworkUnix) + } + if spec.Address != pathpkg.Clean("/var/run/pixel.sock") { + t.Fatalf("Address = %q", spec.Address) + } + if spec.Mode != 0o660 { + t.Fatalf("Mode = %#o, want 0660", spec.Mode) + } + if spec.DisplayAddress() != "unix:///var/run/pixel.sock" { + t.Fatalf("DisplayAddress() = %q", spec.DisplayAddress()) + } +} + +func TestServerListenSpecUnixExplicitPrefix(t *testing.T) { + spec, err := ParseServerListenSpec("unix:/run/pixel/pixel.sock,0600", 8080) + if err != nil { + t.Fatalf("ParseServerListenSpec() error: %v", err) + } + if spec.Network != ServerListenNetworkUnix { + t.Fatalf("Network = %q, want %q", spec.Network, ServerListenNetworkUnix) + } + if spec.Address != pathpkg.Clean("/run/pixel/pixel.sock") { + t.Fatalf("Address = %q", spec.Address) + } + if spec.Mode != 0o600 { + t.Fatalf("Mode = %#o, want 0600", spec.Mode) + } +} + +func TestServerListenSpecUnixDefaultMode(t *testing.T) { + spec, err := ParseServerListenSpec("unix:/run/pixel/pixel.sock", 8080) + if err != nil { + t.Fatalf("ParseServerListenSpec() error: %v", err) + } + if spec.Mode != defaultUnixSocketFileMode { + t.Fatalf("Mode = %#o, want %#o", spec.Mode, defaultUnixSocketFileMode) + } +} + +func TestServerListenSpecRejectsInvalidValues(t *testing.T) { + tests := []struct { + name string + host string + port int + }{ + {name: "tcp_missing_port", host: "127.0.0.1", port: 0}, + {name: "unix_relative", host: "unix:pixel.sock", port: 8080}, + {name: "unix_bad_mode", host: "unix:/run/pixel.sock,09", port: 8080}, + {name: "unix_empty_path", host: "unix:", port: 8080}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if _, err := ParseServerListenSpec(tt.host, tt.port); err == nil { + t.Fatalf("ParseServerListenSpec(%q, %d) expected error", tt.host, tt.port) + } + }) + } +} + +func TestRemoveUnixSocketIfExists(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("unix domain sockets are not available in this Windows test environment") + } + + dir := t.TempDir() + socketPath := filepath.Join(dir, "pixel.sock") + + ln, err := net.Listen("unix", socketPath) + if err != nil { + t.Fatalf("net.Listen(unix) error: %v", err) + } + if err := ln.Close(); err != nil { + t.Fatalf("close unix listener: %v", err) + } + + if err := RemoveUnixSocketIfExists(socketPath); err != nil { + t.Fatalf("RemoveUnixSocketIfExists() error: %v", err) + } + if _, err := os.Stat(socketPath); !os.IsNotExist(err) { + t.Fatalf("socket path still exists after removal, stat err=%v", err) + } +} + +func TestRemoveUnixSocketIfExistsRejectsNonSocket(t *testing.T) { + dir := t.TempDir() + plainFile := filepath.Join(dir, "pixel.sock") + if err := os.WriteFile(plainFile, []byte("not-a-socket"), 0o600); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + if err := RemoveUnixSocketIfExists(plainFile); err == nil { + t.Fatal("RemoveUnixSocketIfExists() expected error for non-socket file") + } +} + +func TestValidateAllowsUnixSocketServerHost(t *testing.T) { + resetViperWithJWTSecret(t) + t.Setenv("SERVER_HOST", "unix:/run/pixel/pixel.sock,0660") + t.Setenv("SERVER_PORT", "0") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error: %v", err) + } + spec, err := cfg.Server.ListenSpec() + if err != nil { + t.Fatalf("ListenSpec() error: %v", err) + } + if spec.Network != ServerListenNetworkUnix { + t.Fatalf("ListenSpec().Network = %q, want unix", spec.Network) + } +} + func TestLoadIdempotencyConfigFromEnv(t *testing.T) { resetViperWithJWTSecret(t) t.Setenv("IDEMPOTENCY_OBSERVE_ONLY", "false") diff --git a/backend/internal/setup/handler.go b/backend/internal/setup/handler.go index c2944cedf..edb3c7182 100644 --- a/backend/internal/setup/handler.go +++ b/backend/internal/setup/handler.go @@ -9,6 +9,7 @@ import ( "sync" "time" + "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/pkg/sysutil" @@ -296,12 +297,6 @@ func install(c *gin.Context) { return } - // Server validation - if req.Server.Port != 0 && !validatePort(req.Server.Port) { - response.Error(c, http.StatusBadRequest, "Invalid server port") - return - } - // ========== SET DEFAULTS ========== if req.Database.SSLMode == "" { req.Database.SSLMode = "disable" @@ -324,6 +319,10 @@ func install(c *gin.Context) { response.Error(c, http.StatusBadRequest, "Invalid server mode (must be 'release' or 'debug')") return } + if _, err := config.ParseServerListenSpec(req.Server.Host, req.Server.Port); err != nil { + response.Error(c, http.StatusBadRequest, "Invalid server listen config: "+err.Error()) + return + } cfg := &SetupConfig{ Database: req.Database, diff --git a/deploy/install.sh b/deploy/install.sh index 6dcf41238..e159194cd 100644 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -677,6 +677,8 @@ ProtectSystem=strict ProtectHome=true PrivateTmp=true ReadWritePaths=/opt/sub2api +RuntimeDirectory=sub2api +RuntimeDirectoryMode=0755 # Environment - Server configuration Environment=GIN_MODE=release diff --git a/deploy/sub2api.service b/deploy/sub2api.service index 1a59ad032..5564d4a27 100644 --- a/deploy/sub2api.service +++ b/deploy/sub2api.service @@ -22,6 +22,8 @@ ProtectSystem=strict ProtectHome=true PrivateTmp=true ReadWritePaths=/opt/sub2api +RuntimeDirectory=sub2api +RuntimeDirectoryMode=0755 # Environment - Server configuration # Modify these values to change listen address and port From 685496fbf7bb25d21b11f090910d39f1c0ddfa4c Mon Sep 17 00:00:00 2001 From: AzurCrystal Date: Wed, 3 Jun 2026 02:04:39 +0800 Subject: [PATCH 2/2] Add Redis Unix socket connection support --- backend/internal/config/config.go | 69 +++++++++++++++++++++++ backend/internal/config/config_test.go | 68 ++++++++++++++++++++++ backend/internal/repository/redis.go | 13 ++++- backend/internal/repository/redis_test.go | 23 ++++++++ backend/internal/setup/cli.go | 41 +++++++++++--- backend/internal/setup/handler.go | 37 +++++++----- backend/internal/setup/setup.go | 11 +++- deploy/config.example.yaml | 6 +- 8 files changed, 241 insertions(+), 27 deletions(-) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index a3039791d..9ed78dd3e 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -1192,10 +1192,76 @@ type RedisConfig struct { EnableTLS bool `mapstructure:"enable_tls"` } +type RedisConnectionNetwork string + +const ( + RedisConnectionNetworkTCP RedisConnectionNetwork = "tcp" + RedisConnectionNetworkUnix RedisConnectionNetwork = "unix" +) + +type RedisConnectionSpec struct { + Network RedisConnectionNetwork + Address string +} + +func ParseRedisConnectionSpec(host string, port int) (RedisConnectionSpec, error) { + rawHost := strings.TrimSpace(host) + if rawHost == "" { + rawHost = "localhost" + } + + switch { + case strings.HasPrefix(rawHost, "unix:"): + return parseRedisUnixSocketSpec(strings.TrimSpace(strings.TrimPrefix(rawHost, "unix:"))) + case strings.HasPrefix(rawHost, "/"): + return parseRedisUnixSocketSpec(rawHost) + default: + if port <= 0 || port > 65535 { + return RedisConnectionSpec{}, fmt.Errorf("tcp redis port must be between 1-65535") + } + return RedisConnectionSpec{ + Network: RedisConnectionNetworkTCP, + Address: fmt.Sprintf("%s:%d", rawHost, port), + }, nil + } +} + +func parseRedisUnixSocketSpec(raw string) (RedisConnectionSpec, error) { + socketPath := strings.TrimSpace(raw) + if socketPath == "" { + return RedisConnectionSpec{}, fmt.Errorf("redis unix socket path is required") + } + if !strings.HasPrefix(socketPath, "/") { + return RedisConnectionSpec{}, fmt.Errorf("redis unix socket path must be absolute") + } + cleanPath := pathpkg.Clean(socketPath) + if cleanPath == "/" { + return RedisConnectionSpec{}, fmt.Errorf("redis unix socket path cannot be root directory") + } + return RedisConnectionSpec{ + Network: RedisConnectionNetworkUnix, + Address: cleanPath, + }, nil +} + func (r *RedisConfig) Address() string { return fmt.Sprintf("%s:%d", r.Host, r.Port) } +func (r *RedisConfig) ConnectionSpec() (RedisConnectionSpec, error) { + if r == nil { + return ParseRedisConnectionSpec("", 6379) + } + spec, err := ParseRedisConnectionSpec(r.Host, r.Port) + if err != nil { + return RedisConnectionSpec{}, err + } + if spec.Network == RedisConnectionNetworkUnix && r.EnableTLS { + return RedisConnectionSpec{}, fmt.Errorf("redis.enable_tls is not supported with unix socket connections") + } + return spec, nil +} + type OpsConfig struct { // Enabled controls whether ops features should run. // @@ -2344,6 +2410,9 @@ func (c *Config) Validate() error { if c.Redis.MinIdleConns > c.Redis.PoolSize { return fmt.Errorf("redis.min_idle_conns cannot exceed redis.pool_size") } + if _, err := c.Redis.ConnectionSpec(); err != nil { + return fmt.Errorf("redis connection config invalid: %w", err) + } if c.Dashboard.Enabled { if c.Dashboard.StatsFreshTTLSeconds <= 0 { return fmt.Errorf("dashboard_cache.stats_fresh_ttl_seconds must be positive") diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 89fb59cc8..ba19aa23f 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -903,6 +903,74 @@ func TestConfigAddressHelpers(t *testing.T) { } } +func TestRedisConnectionSpecTCP(t *testing.T) { + redis := RedisConfig{Host: "redis", Port: 6379} + + spec, err := redis.ConnectionSpec() + if err != nil { + t.Fatalf("ConnectionSpec() error: %v", err) + } + if spec.Network != RedisConnectionNetworkTCP { + t.Fatalf("ConnectionSpec().Network = %q, want %q", spec.Network, RedisConnectionNetworkTCP) + } + if spec.Address != "redis:6379" { + t.Fatalf("ConnectionSpec().Address = %q", spec.Address) + } +} + +func TestRedisConnectionSpecUnix(t *testing.T) { + spec, err := ParseRedisConnectionSpec("unix:/run/redis/redis.sock", 0) + if err != nil { + t.Fatalf("ParseRedisConnectionSpec() error: %v", err) + } + if spec.Network != RedisConnectionNetworkUnix { + t.Fatalf("Network = %q, want %q", spec.Network, RedisConnectionNetworkUnix) + } + if spec.Address != pathpkg.Clean("/run/redis/redis.sock") { + t.Fatalf("Address = %q", spec.Address) + } +} + +func TestRedisConnectionSpecUnixCompat(t *testing.T) { + spec, err := ParseRedisConnectionSpec("/var/run/redis.sock", 6379) + if err != nil { + t.Fatalf("ParseRedisConnectionSpec() error: %v", err) + } + if spec.Network != RedisConnectionNetworkUnix { + t.Fatalf("Network = %q, want %q", spec.Network, RedisConnectionNetworkUnix) + } + if spec.Address != pathpkg.Clean("/var/run/redis.sock") { + t.Fatalf("Address = %q", spec.Address) + } +} + +func TestRedisConnectionSpecRejectsInvalidValues(t *testing.T) { + tests := []struct { + name string + host string + port int + }{ + {name: "tcp_missing_port", host: "redis", port: 0}, + {name: "unix_relative", host: "unix:redis.sock", port: 0}, + {name: "unix_empty_path", host: "unix:", port: 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if _, err := ParseRedisConnectionSpec(tt.host, tt.port); err == nil { + t.Fatalf("ParseRedisConnectionSpec(%q, %d) expected error", tt.host, tt.port) + } + }) + } +} + +func TestRedisConnectionSpecRejectsTLSWithUnixSocket(t *testing.T) { + redis := RedisConfig{Host: "unix:/run/redis/redis.sock", EnableTLS: true} + if _, err := redis.ConnectionSpec(); err == nil { + t.Fatal("ConnectionSpec() expected error for TLS over unix socket") + } +} + func TestNormalizeStringSlice(t *testing.T) { values := normalizeStringSlice([]string{" a ", "", "b", " ", "c"}) if len(values) != 3 || values[0] != "a" || values[1] != "b" || values[2] != "c" { diff --git a/backend/internal/repository/redis.go b/backend/internal/repository/redis.go index 2b4ee4e63..ffb17424b 100644 --- a/backend/internal/repository/redis.go +++ b/backend/internal/repository/redis.go @@ -27,8 +27,19 @@ func InitRedis(cfg *config.Config) *redis.Client { // buildRedisOptions 构建 Redis 连接选项 // 从配置文件读取连接池和超时参数,支持生产环境调优 func buildRedisOptions(cfg *config.Config) *redis.Options { + spec, err := cfg.Redis.ConnectionSpec() + if err != nil { + // Config validation should catch this before repository wiring; keep a + // defensive fallback so tests and partial configs do not panic. + spec = config.RedisConnectionSpec{ + Network: config.RedisConnectionNetworkTCP, + Address: cfg.Redis.Address(), + } + } + opts := &redis.Options{ - Addr: cfg.Redis.Address(), + Network: string(spec.Network), + Addr: spec.Address, Password: cfg.Redis.Password, DB: cfg.Redis.DB, DialTimeout: time.Duration(cfg.Redis.DialTimeoutSeconds) * time.Second, // 建连超时 diff --git a/backend/internal/repository/redis_test.go b/backend/internal/repository/redis_test.go index 7cb31002b..a8fd79a6d 100644 --- a/backend/internal/repository/redis_test.go +++ b/backend/internal/repository/redis_test.go @@ -24,6 +24,7 @@ func TestBuildRedisOptions(t *testing.T) { } opts := buildRedisOptions(cfg) + require.Equal(t, "tcp", opts.Network) require.Equal(t, "localhost:6379", opts.Addr) require.Equal(t, "secret", opts.Password) require.Equal(t, 2, opts.DB) @@ -45,3 +46,25 @@ func TestBuildRedisOptions(t *testing.T) { require.NotNil(t, optsTLS.TLSConfig) require.Equal(t, "localhost", optsTLS.TLSConfig.ServerName) } + +func TestBuildRedisOptionsUnixSocket(t *testing.T) { + cfg := &config.Config{ + Redis: config.RedisConfig{ + Host: "unix:/run/redis/redis.sock", + Password: "secret", + DB: 1, + DialTimeoutSeconds: 5, + ReadTimeoutSeconds: 3, + WriteTimeoutSeconds: 4, + PoolSize: 100, + MinIdleConns: 10, + }, + } + + opts := buildRedisOptions(cfg) + require.Equal(t, "unix", opts.Network) + require.Equal(t, "/run/redis/redis.sock", opts.Addr) + require.Equal(t, "secret", opts.Password) + require.Equal(t, 1, opts.DB) + require.Nil(t, opts.TLSConfig) +} diff --git a/backend/internal/setup/cli.go b/backend/internal/setup/cli.go index 2b323acf4..87290550d 100644 --- a/backend/internal/setup/cli.go +++ b/backend/internal/setup/cli.go @@ -10,6 +10,8 @@ import ( "strconv" "strings" + "github.com/Wei-Shaw/sub2api/internal/config" + "golang.org/x/term" ) @@ -125,18 +127,26 @@ func RunCLI() error { for { cfg.Redis.Host = promptString(reader, "Redis Host", "localhost") - if cliValidateHostname(cfg.Redis.Host) { + if isRedisUnixSocketHost(cfg.Redis.Host) { + if _, err := config.ParseRedisConnectionSpec(cfg.Redis.Host, 0); err == nil { + break + } + } else if cliValidateHostname(cfg.Redis.Host) { break } - fmt.Println(" Invalid hostname format. Use alphanumeric, dots, hyphens only.") + fmt.Println(" Invalid Redis host. Use a hostname/IP or unix:/absolute/path.sock.") } - for { - cfg.Redis.Port = promptInt(reader, "Redis Port", 6379) - if cliValidatePort(cfg.Redis.Port) { - break + if isRedisUnixSocketHost(cfg.Redis.Host) { + cfg.Redis.Port = 0 + } else { + for { + cfg.Redis.Port = promptInt(reader, "Redis Port", 6379) + if cliValidatePort(cfg.Redis.Port) { + break + } + fmt.Println(" Invalid port. Must be between 1 and 65535.") } - fmt.Println(" Invalid port. Must be between 1 and 65535.") } cfg.Redis.Password = promptPassword("Redis Password (optional)") @@ -149,7 +159,11 @@ func RunCLI() error { fmt.Println(" Invalid Redis DB. Must be between 0 and 15.") } - cfg.Redis.EnableTLS = promptConfirm(reader, "Enable Redis TLS?") + if isRedisUnixSocketHost(cfg.Redis.Host) { + cfg.Redis.EnableTLS = false + } else { + cfg.Redis.EnableTLS = promptConfirm(reader, "Enable Redis TLS?") + } fmt.Println() fmt.Print("Testing Redis connection... ") @@ -206,7 +220,11 @@ func RunCLI() error { fmt.Println() fmt.Println("── Configuration Summary ──") fmt.Printf("Database: %s@%s:%d/%s\n", cfg.Database.User, cfg.Database.Host, cfg.Database.Port, cfg.Database.DBName) - fmt.Printf("Redis: %s:%d\n", cfg.Redis.Host, cfg.Redis.Port) + if spec, err := config.ParseRedisConnectionSpec(cfg.Redis.Host, cfg.Redis.Port); err == nil && spec.Network == config.RedisConnectionNetworkUnix { + fmt.Printf("Redis: unix://%s\n", spec.Address) + } else { + fmt.Printf("Redis: %s:%d\n", cfg.Redis.Host, cfg.Redis.Port) + } fmt.Printf("Redis TLS: %s\n", map[bool]string{true: "enabled", false: "disabled"}[cfg.Redis.EnableTLS]) fmt.Printf("Admin: %s\n", cfg.Admin.Email) fmt.Printf("Server: :%d\n", cfg.Server.Port) @@ -239,6 +257,11 @@ func RunCLI() error { return nil } +func isRedisUnixSocketHost(host string) bool { + trimmed := strings.TrimSpace(host) + return strings.HasPrefix(trimmed, "unix:") || strings.HasPrefix(trimmed, "/") +} + func promptString(reader *bufio.Reader, prompt, defaultVal string) string { if defaultVal != "" { fmt.Printf(" %s [%s]: ", prompt, defaultVal) diff --git a/backend/internal/setup/handler.go b/backend/internal/setup/handler.go index edb3c7182..fbf010c0c 100644 --- a/backend/internal/setup/handler.go +++ b/backend/internal/setup/handler.go @@ -178,7 +178,7 @@ func testDatabase(c *gin.Context) { // TestRedisRequest represents Redis test request type TestRedisRequest struct { Host string `json:"host" binding:"required"` - Port int `json:"port" binding:"required"` + Port int `json:"port"` Password string `json:"password"` DB int `json:"db"` EnableTLS bool `json:"enable_tls"` @@ -192,19 +192,23 @@ func testRedis(c *gin.Context) { return } - // Security: Validate inputs - if !validateHostname(req.Host) { - response.Error(c, http.StatusBadRequest, "Invalid hostname format") - return - } - if !validatePort(req.Port) { - response.Error(c, http.StatusBadRequest, "Invalid port number") + spec, err := config.ParseRedisConnectionSpec(req.Host, req.Port) + if err != nil { + response.Error(c, http.StatusBadRequest, "Invalid Redis connection config: "+err.Error()) return } if req.DB < 0 || req.DB > 15 { response.Error(c, http.StatusBadRequest, "Invalid Redis database number (0-15)") return } + if spec.Network == config.RedisConnectionNetworkTCP && !validateHostname(req.Host) { + response.Error(c, http.StatusBadRequest, "Invalid hostname format") + return + } + if spec.Network == config.RedisConnectionNetworkUnix && req.EnableTLS { + response.Error(c, http.StatusBadRequest, "Redis TLS is not supported with unix socket connections") + return + } cfg := &RedisConfig{ Host: req.Host, @@ -274,18 +278,23 @@ func install(c *gin.Context) { } // Redis validation - if !validateHostname(req.Redis.Host) { - response.Error(c, http.StatusBadRequest, "Invalid Redis hostname") - return - } - if !validatePort(req.Redis.Port) { - response.Error(c, http.StatusBadRequest, "Invalid Redis port") + redisSpec, err := config.ParseRedisConnectionSpec(req.Redis.Host, req.Redis.Port) + if err != nil { + response.Error(c, http.StatusBadRequest, "Invalid Redis connection config: "+err.Error()) return } if req.Redis.DB < 0 || req.Redis.DB > 15 { response.Error(c, http.StatusBadRequest, "Invalid Redis database number") return } + if redisSpec.Network == config.RedisConnectionNetworkTCP && !validateHostname(req.Redis.Host) { + response.Error(c, http.StatusBadRequest, "Invalid Redis hostname") + return + } + if redisSpec.Network == config.RedisConnectionNetworkUnix && req.Redis.EnableTLS { + response.Error(c, http.StatusBadRequest, "Redis TLS is not supported with unix socket connections") + return + } // Admin validation if !validateEmail(req.Admin.Email) { diff --git a/backend/internal/setup/setup.go b/backend/internal/setup/setup.go index 2d8239c79..6cec48c6e 100644 --- a/backend/internal/setup/setup.go +++ b/backend/internal/setup/setup.go @@ -247,8 +247,17 @@ func TestDatabaseConnection(cfg *DatabaseConfig) error { // TestRedisConnection tests the Redis connection func TestRedisConnection(cfg *RedisConfig) error { + spec, err := config.ParseRedisConnectionSpec(cfg.Host, cfg.Port) + if err != nil { + return err + } + if spec.Network == config.RedisConnectionNetworkUnix && cfg.EnableTLS { + return fmt.Errorf("redis TLS is not supported with unix socket connections") + } + opts := &redis.Options{ - Addr: fmt.Sprintf("%s:%d", cfg.Host, cfg.Port), + Network: string(spec.Network), + Addr: spec.Address, Password: cfg.Password, DB: cfg.DB, } diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index 440032be8..12256f9d7 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -778,9 +778,11 @@ database: redis: # Redis host address # Redis 主机地址 + # For unix socket, use "unix:/run/redis/redis.sock" or "/run/redis/redis.sock" + # 如需通过 Unix Socket 连接,可填写 "unix:/run/redis/redis.sock" 或 "/run/redis/redis.sock" host: "localhost" # Redis port - # Redis 端口 + # Redis 端口(Unix Socket 模式下忽略) port: 6379 # Redis password (leave empty if no password is set) # Redis 密码(如果未设置密码则留空) @@ -795,7 +797,7 @@ redis: # 最小空闲连接数 min_idle_conns: 128 # Enable TLS/SSL connection - # 是否启用 TLS/SSL 连接 + # 是否启用 TLS/SSL 连接(Unix Socket 模式不支持 TLS) enable_tls: false # =============================================================================