From 8b96b71bd70e3164aba7a52e5cc471cd27f27fa0 Mon Sep 17 00:00:00 2001 From: Valery Piashchynski Date: Sun, 6 Sep 2026 23:45:02 +0200 Subject: [PATCH 1/8] feat: configure HTTP and FastCGI UNIX socket attributes --- config/config.go | 12 ++ config/unix_socket_test.go | 96 ++++++++++++ go.mod | 2 +- go.sum | 4 +- init.go | 63 ++++++++ plugin_test.go | 7 +- schema.json | 18 ++- servers/fcgi/config.go | 15 ++ servers/fcgi/fcgi.go | 36 ++++- servers/fcgi/fcgi_test.go | 61 ++++++++ servers/http11/http.go | 5 +- servers/https/https_test.go | 2 +- servers/unix_socket_test.go | 113 +++++++++++++++ tests/go.mod | 2 +- tests/go.sum | 4 +- tests/unix_socket_test.go | 281 ++++++++++++++++++++++++++++++++++++ 16 files changed, 702 insertions(+), 19 deletions(-) create mode 100644 config/unix_socket_test.go create mode 100644 servers/unix_socket_test.go create mode 100644 tests/unix_socket_test.go diff --git a/config/config.go b/config/config.go index 49771950..4eae030e 100644 --- a/config/config.go +++ b/config/config.go @@ -10,6 +10,7 @@ import ( "github.com/roadrunner-server/errors" "github.com/roadrunner-server/pool/v2/pool" + "github.com/roadrunner-server/tcplisten" ) // Config configures RoadRunner HTTP server. @@ -18,6 +19,8 @@ type Config struct { RawBody bool `mapstructure:"raw_body"` // Host and port to handle as http server. Address string `mapstructure:"address"` + // UnixSocket sets attributes on the plain HTTP UNIX socket only. + UnixSocket *tcplisten.UnixSocketOptions `mapstructure:"unix_socket"` // ProxyProtocol applies only to the plain HTTP listener. ProxyProtocol *proxyprotocol.Config `mapstructure:"proxy_protocol"` // AccessLogs turn on/off, logged at Info log level, default: false @@ -125,6 +128,15 @@ func (c *Config) InitDefaults() error { // Valid validates the configuration. func (c *Config) Valid() error { const op = errors.Op("validation") + if err := c.UnixSocket.Validate(c.Address); err != nil { + return errors.E(errors.Op("http.unix_socket"), err) + } + if c.FCGIConfig != nil { + if err := c.FCGIConfig.Valid(); err != nil { + return err + } + } + if c.Uploads == nil { return errors.E(op, errors.Str("malformed uploads config")) } diff --git a/config/unix_socket_test.go b/config/unix_socket_test.go new file mode 100644 index 00000000..665d5808 --- /dev/null +++ b/config/unix_socket_test.go @@ -0,0 +1,96 @@ +package config + +import ( + "runtime" + "testing" + + "github.com/roadrunner-server/http/v6/servers/fcgi" + "github.com/roadrunner-server/http/v6/servers/proxyprotocol" + "github.com/roadrunner-server/tcplisten" + "github.com/stretchr/testify/require" +) + +func TestUnixSocketValidation(t *testing.T) { + negative := -1 + for _, field := range []string{"http.unix_socket", "http.fcgi.unix_socket"} { + for _, tt := range []struct { + name, address string + options *tcplisten.UnixSocketOptions + invalid bool + }{ + {"nil_tcp", "127.0.0.1:0", nil, false}, + {"nil_unix", "unix://http.sock", nil, false}, + {"nil_disabled", "", nil, false}, + {"empty_options", "unix://http.sock", &tcplisten.UnixSocketOptions{}, false}, + {"mode_and_zero_ids", "unix://http.sock", &tcplisten.UnixSocketOptions{Mode: "0660", UID: new(int), GID: new(int)}, false}, + {"tcp", "tcp://127.0.0.1:0", &tcplisten.UnixSocketOptions{}, true}, + {"disabled", "", &tcplisten.UnixSocketOptions{}, true}, + {"missing_path", "unix://", &tcplisten.UnixSocketOptions{}, true}, + {"short_mode", "unix://http.sock", &tcplisten.UnixSocketOptions{Mode: "660"}, true}, + {"invalid_mode", "unix://http.sock", &tcplisten.UnixSocketOptions{Mode: "0999"}, true}, + {"negative_uid", "unix://http.sock", &tcplisten.UnixSocketOptions{UID: &negative}, true}, + {"negative_gid", "unix://http.sock", &tcplisten.UnixSocketOptions{GID: &negative}, true}, + {"abstract", "unix://@http", &tcplisten.UnixSocketOptions{}, runtime.GOOS == "linux"}, + } { + t.Run(field+"/"+tt.name, func(t *testing.T) { + cfg := &Config{Address: "127.0.0.1:0", FCGIConfig: &fcgi.FCGI{Address: "127.0.0.1:0"}} + if field == "http.unix_socket" { + cfg.Address, cfg.UnixSocket = tt.address, tt.options + } else { + cfg.FCGIConfig.Address, cfg.FCGIConfig.UnixSocket = tt.address, tt.options + } + err := cfg.InitDefaults() + if tt.invalid || (runtime.GOOS == "windows" && tt.options != nil) { + require.ErrorContains(t, err, field) + require.ErrorContains(t, cfg.Valid(), field) + } else { + require.NoError(t, err) + require.NoError(t, cfg.Valid()) + } + }) + } + } +} + +func TestUnixSocketIndependentOptions(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("UNIX socket attributes are not supported on Windows") + } + for _, tt := range []struct { + name string + http, fcgi *tcplisten.UnixSocketOptions + }{ + {"omitted", nil, nil}, + {"http_only", &tcplisten.UnixSocketOptions{Mode: "0600"}, nil}, + {"fcgi_only", nil, &tcplisten.UnixSocketOptions{Mode: "0660"}}, + {"both", &tcplisten.UnixSocketOptions{Mode: "0600"}, &tcplisten.UnixSocketOptions{Mode: "0660"}}, + } { + t.Run(tt.name, func(t *testing.T) { + cfg := &Config{ + Address: "unix://http.sock", UnixSocket: tt.http, + FCGIConfig: &fcgi.FCGI{Address: "unix://fcgi.sock", UnixSocket: tt.fcgi}, + UID: 123, GID: 456, + } + require.NoError(t, cfg.InitDefaults()) + require.Same(t, tt.http, cfg.UnixSocket) + require.Same(t, tt.fcgi, cfg.FCGIConfig.UnixSocket) + for _, options := range []*tcplisten.UnixSocketOptions{cfg.UnixSocket, cfg.FCGIConfig.UnixSocket} { + if options != nil { + require.Nil(t, options.UID) + require.Nil(t, options.GID) + } + } + }) + } +} + +func TestUnixSocketRejectsProxyProtocol(t *testing.T) { + cfg := &Config{ + Address: "unix://http.sock", + UnixSocket: &tcplisten.UnixSocketOptions{Mode: "0660"}, + ProxyProtocol: &proxyprotocol.Config{TrustedProxies: []string{"127.0.0.1"}}, + } + err := cfg.InitDefaults() + require.ErrorContains(t, err, "http.proxy_protocol") + require.ErrorContains(t, err, "TCP listen") +} diff --git a/go.mod b/go.mod index f2d6a525..6f77c76b 100644 --- a/go.mod +++ b/go.mod @@ -18,7 +18,7 @@ require ( github.com/roadrunner-server/errors v1.5.0 github.com/roadrunner-server/goridge/v4 v4.0.0-beta.3 github.com/roadrunner-server/pool/v2 v2.0.0-beta.1 - github.com/roadrunner-server/tcplisten v1.5.2 + github.com/roadrunner-server/tcplisten v1.6.0 github.com/stretchr/testify v1.12.1 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 go.opentelemetry.io/contrib/propagators/jaeger v1.46.0 diff --git a/go.sum b/go.sum index bbd58e55..558ae6c1 100644 --- a/go.sum +++ b/go.sum @@ -74,8 +74,8 @@ github.com/roadrunner-server/goridge/v4 v4.0.0-beta.3 h1:+kUw00/fpqwdMWrPMYW+OZH github.com/roadrunner-server/goridge/v4 v4.0.0-beta.3/go.mod h1:1aHppV68y/VqRED/AsfNg59sft9aQOhqgr5Z5n49jbM= github.com/roadrunner-server/pool/v2 v2.0.0-beta.1 h1:jpYXFtdD6QGAdAGPgMxrNi3j1CegCRpb2y+A+3GnXFA= github.com/roadrunner-server/pool/v2 v2.0.0-beta.1/go.mod h1:Bo1wT7RtL3eyQHXBUohNhtj/yAmRt6Rq8smuBg5pWkY= -github.com/roadrunner-server/tcplisten v1.5.2 h1:nn8yXYrhRDkfQ9AAu4V075uT4fZRmOnpxkawgE+bWPA= -github.com/roadrunner-server/tcplisten v1.5.2/go.mod h1:DufGBz7Dlx2KrNe/4RukEvGMTqZKB0Uve1GztwcyyR8= +github.com/roadrunner-server/tcplisten v1.6.0 h1:xfFeA2PZTmwJdwc/InhJGq200ew/lfTDReF3oa4AyI4= +github.com/roadrunner-server/tcplisten v1.6.0/go.mod h1:M01BcmhsBiek8WfkiRQwVXwVamgZ5YV36Wa0hz937dA= github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI= github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= diff --git a/init.go b/init.go index 947312b6..f6dcc9fc 100644 --- a/init.go +++ b/init.go @@ -1,7 +1,11 @@ package http import ( + "fmt" + "math" "net/http" + "reflect" + "strconv" "github.com/quic-go/quic-go/http3" "github.com/roadrunner-server/http/v6/acme" @@ -12,6 +16,7 @@ import ( httpServer "github.com/roadrunner-server/http/v6/servers/http11" http3Server "github.com/roadrunner-server/http/v6/servers/http3" httpsServer "github.com/roadrunner-server/http/v6/servers/https" + "github.com/roadrunner-server/tcplisten" ) // ------- PRIVATE --------- @@ -71,6 +76,12 @@ func (p *Plugin) applyBundledMiddleware() { } func (p *Plugin) unmarshal(cfg api.Configurer) error { + for _, key := range []string{"http.unix_socket", "http.fcgi.unix_socket"} { + if err := validateUnixSocketIDs(cfg, key); err != nil { + return err + } + } + // unmarshal general section err := cfg.UnmarshalKey(PluginName, &p.cfg) if err != nil { @@ -101,5 +112,57 @@ func (p *Plugin) unmarshal(cfg api.Configurer) error { return err } + // Viper can omit empty maps when it decodes the parent section. + if cfg.Has("http.unix_socket") { + p.cfg.UnixSocket = &tcplisten.UnixSocketOptions{} + if err = cfg.UnmarshalKey("http.unix_socket", p.cfg.UnixSocket); err != nil { + return err + } + } + if cfg.Has("http.fcgi.unix_socket") { + if p.cfg.FCGIConfig == nil { + p.cfg.FCGIConfig = &fcgi.FCGI{} + } + p.cfg.FCGIConfig.UnixSocket = &tcplisten.UnixSocketOptions{} + if err = cfg.UnmarshalKey("http.fcgi.unix_socket", p.cfg.FCGIConfig.UnixSocket); err != nil { + return err + } + } + + return nil +} + +// Check raw IDs before weak decoding can convert booleans or truncate fractions. +func validateUnixSocketIDs(cfg api.Configurer, key string) error { + if !cfg.Has(key) { + return nil + } + var options map[string]any + if err := cfg.UnmarshalKey(key, &options); err != nil { + return fmt.Errorf("%s: %w", key, err) + } + for _, field := range []string{"uid", "gid"} { + if options[field] == nil { + continue + } + value := reflect.ValueOf(options[field]) + var valid bool + switch value.Kind() { //nolint:exhaustive // Other kinds fail validation. + case reflect.String: + id, err := strconv.ParseInt(value.String(), 0, strconv.IntSize) + valid = err == nil && id >= 0 && id < 4294967295 + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + id := value.Int() + valid = id >= 0 && id < 4294967295 + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + valid = value.Uint() < 4294967295 + case reflect.Float32, reflect.Float64: + id := value.Float() + valid = id >= 0 && id < 4294967295 && math.Trunc(id) == id + } + if !valid { + return fmt.Errorf("%s.%s: must be an integer between 0 and 4294967294", key, field) + } + } return nil } diff --git a/plugin_test.go b/plugin_test.go index 9aa5f641..53eb55dd 100644 --- a/plugin_test.go +++ b/plugin_test.go @@ -25,7 +25,10 @@ type stubConfigurer struct { httpCfg *config.Config } -func (c *stubConfigurer) Has(string) bool { return c.has } +func (c *stubConfigurer) Has(section string) bool { + return c.has && (section == PluginName || section == c.errSection) +} + func (c *stubConfigurer) Experimental() bool { return c.experimental } func (c *stubConfigurer) UnmarshalKey(name string, out any) error { @@ -87,7 +90,7 @@ func TestInit_SectionDisabled(t *testing.T) { } func TestInit_UnmarshalErrorPerSection(t *testing.T) { - sections := []string{PluginName, sectionHTTPS, sectionHTTP2, sectionUploads, sectionFCGI} + sections := []string{PluginName, sectionHTTPS, sectionHTTP2, sectionUploads, sectionFCGI, "http.unix_socket", "http.fcgi.unix_socket"} for _, section := range sections { t.Run(section, func(t *testing.T) { diff --git a/schema.json b/schema.json index 7bf89834..fdd44d3d 100644 --- a/schema.json +++ b/schema.json @@ -8,18 +8,25 @@ "dependentRequired": { "proxy_protocol": [ "address" + ], + "unix_socket": [ + "address" ] }, "properties": { "address": { - "description": "Host and/or port to listen on for HTTP traffic. If omitted, RoadRunner will not listen for HTTP requests.", + "description": "TCP address or filesystem UNIX socket for HTTP traffic. If omitted, RoadRunner will not listen for HTTP requests.", "type": "string", "minLength": 1, "examples": [ "127.0.0.1:8080", - ":8080" + ":8080", + "unix:///path/to/http.sock" ] }, + "unix_socket": { + "$ref": "https://raw.githubusercontent.com/roadrunner-server/tcplisten/v1.6.0/schema.json" + }, "internal_error_code": { "description": "HTTP status code to use for internal RoadRunner errors. Defaults to 500 if omitted.", "type": "integer", @@ -438,15 +445,18 @@ "additionalProperties": false, "properties": { "address": { - "description": "Host and/or port to listen on for FCGI requests.", + "description": "TCP address or filesystem UNIX socket for FCGI requests.", "type": "string", "minLength": 1, "examples": [ "0.0.0.0:9000", "127.0.0.1:9000", "localhost:9000", - "unix:/path/to/socket.sock" + "unix:///path/to/socket.sock" ] + }, + "unix_socket": { + "$ref": "https://raw.githubusercontent.com/roadrunner-server/tcplisten/v1.6.0/schema.json" } }, "required": [ diff --git a/servers/fcgi/config.go b/servers/fcgi/config.go index a29b144e..70074205 100644 --- a/servers/fcgi/config.go +++ b/servers/fcgi/config.go @@ -1,7 +1,22 @@ package fcgi +import ( + "github.com/roadrunner-server/errors" + "github.com/roadrunner-server/tcplisten" +) + // FCGI for FastCGI server. type FCGI struct { // Address and port to handle as http server. Address string `mapstructure:"address"` + // UnixSocket sets attributes on the FastCGI UNIX socket only. + UnixSocket *tcplisten.UnixSocketOptions `mapstructure:"unix_socket"` +} + +// Valid validates the FastCGI socket options. +func (c *FCGI) Valid() error { + if err := c.UnixSocket.Validate(c.Address); err != nil { + return errors.E(errors.Op("http.fcgi.unix_socket"), err) + } + return nil } diff --git a/servers/fcgi/fcgi.go b/servers/fcgi/fcgi.go index 1f67798b..fd66830d 100644 --- a/servers/fcgi/fcgi.go +++ b/servers/fcgi/fcgi.go @@ -4,9 +4,11 @@ import ( stderr "errors" "log" "log/slog" + "net" "net/http" "net/http/fcgi" "slices" + "sync" "time" "github.com/roadrunner-server/http/v6/api" @@ -20,6 +22,10 @@ type Server struct { cfg *FCGI log *slog.Logger fcgi *http.Server + + mu sync.Mutex + listener net.Listener + stopped bool } func NewFCGIServer(handler http.Handler, cfg *FCGI, log *slog.Logger, errLog *log.Logger) servers.InternalServer[any] { @@ -41,13 +47,26 @@ func (s *Server) Serve(mdwr map[string]api.Middleware, order []string) error { applyMiddleware(s.fcgi, mdwr, order, s.log) } - l, err := tcplisten.CreateListener(s.cfg.Address) + // Hold the lock through bind so Stop cannot miss a new listener. + s.mu.Lock() + if s.stopped { + s.mu.Unlock() + return nil + } + l, err := tcplisten.CreateListenerWithOptions(s.cfg.Address, s.cfg.UnixSocket) if err != nil { + s.mu.Unlock() return errors.E(op, err) } + s.listener = l + s.mu.Unlock() + defer s.Stop() err = fcgi.Serve(l, s.fcgi.Handler) - if err != nil && !stderr.Is(err, http.ErrServerClosed) { + s.mu.Lock() + stopped := s.stopped + s.mu.Unlock() + if err != nil && (!stopped || !stderr.Is(err, net.ErrClosed)) { return errors.E(op, err) } @@ -59,9 +78,16 @@ func (s *Server) Server() any { } func (s *Server) Stop() { - err := s.fcgi.Close() - if err != nil && !stderr.Is(err, http.ErrServerClosed) { - s.log.Error("fcgi shutdown", "error", err) + s.mu.Lock() + defer s.mu.Unlock() + if s.stopped { + return + } + s.stopped = true + if s.listener != nil { + if err := s.listener.Close(); err != nil && !stderr.Is(err, net.ErrClosed) { + s.log.Error("fcgi shutdown", "error", err) + } } } diff --git a/servers/fcgi/fcgi_test.go b/servers/fcgi/fcgi_test.go index 8ec10ff8..bffcea6a 100644 --- a/servers/fcgi/fcgi_test.go +++ b/servers/fcgi/fcgi_test.go @@ -4,12 +4,16 @@ import ( "io" "log" "log/slog" + "net" "net/http" "net/http/httptest" "strings" + "sync" "testing" + "time" "github.com/roadrunner-server/http/v6/api" + "github.com/stretchr/testify/require" ) // recordingMiddleware appends its name to trace when the wrapped chain runs. @@ -88,4 +92,61 @@ func TestStop_IsIdempotent(t *testing.T) { srv.Stop() srv.Stop() + require.NoError(t, srv.Serve(nil, nil), "Stop before Serve must prevent bind") +} + +func TestStop_ConcurrentServe(t *testing.T) { + for range 30 { + srv := testServer(http.NotFoundHandler()) + srv.cfg.Address = "127.0.0.1:0" + t.Cleanup(srv.Stop) + start := make(chan struct{}) + done := make(chan error, 1) + go func() { + <-start + done <- srv.Serve(nil, nil) + }() + var stops sync.WaitGroup + for range 2 { + stops.Go(func() { + <-start + srv.Stop() + }) + } + close(start) + select { + case err := <-done: + require.NoError(t, err) + case <-time.After(5 * time.Second): + t.Fatal("Serve did not stop") + } + stops.Wait() + if srv.listener != nil { + _, err := srv.listener.Accept() + require.ErrorIs(t, err, net.ErrClosed) + } + } +} + +func TestServe_UnexpectedListenerClose(t *testing.T) { + srv := testServer(http.NotFoundHandler()) + srv.cfg.Address = "127.0.0.1:0" + t.Cleanup(srv.Stop) + done := make(chan error, 1) + go func() { done <- srv.Serve(nil, nil) }() + var listener net.Listener + require.Eventually(t, func() bool { + srv.mu.Lock() + defer srv.mu.Unlock() + listener = srv.listener + return listener != nil + }, 5*time.Second, 10*time.Millisecond) + require.NoError(t, listener.Close()) + select { + case err := <-done: + require.ErrorContains(t, err, "serve_fcgi") + require.ErrorContains(t, err, net.ErrClosed.Error()) + case <-time.After(5 * time.Second): + t.Fatal("Serve did not return the listener error") + } } diff --git a/servers/http11/http.go b/servers/http11/http.go index 9394e3bf..c28d8028 100644 --- a/servers/http11/http.go +++ b/servers/http11/http.go @@ -23,6 +23,7 @@ type Server struct { log *slog.Logger http *http.Server address string + unixSocket *tcplisten.UnixSocketOptions redirect bool redirectPort int proxyProtocol *proxyprotocol.Config @@ -46,6 +47,7 @@ func NewHTTPServer(handler http.Handler, cfg *config.Config, errLog *log.Logger, redirect: redirect, redirectPort: redirectPort, address: cfg.Address, + unixSocket: cfg.UnixSocket, proxyProtocol: cfg.ProxyProtocol, http: &http.Server{ Handler: handler, @@ -64,6 +66,7 @@ func NewHTTPServer(handler http.Handler, cfg *config.Config, errLog *log.Logger, redirect: redirect, redirectPort: redirectPort, address: cfg.Address, + unixSocket: cfg.UnixSocket, proxyProtocol: cfg.ProxyProtocol, http: &http.Server{ ReadTimeout: time.Minute * 5, @@ -89,7 +92,7 @@ func (s *Server) Serve(mdwr map[string]api.Middleware, order []string) error { s.http.Handler = middleware.Redirect(s.http.Handler, s.redirectPort) } - l, err := tcplisten.CreateListener(s.address) + l, err := tcplisten.CreateListenerWithOptions(s.address, s.unixSocket) if err != nil { return errors.E(op, err) } diff --git a/servers/https/https_test.go b/servers/https/https_test.go index 657eb201..f9d40a25 100644 --- a/servers/https/https_test.go +++ b/servers/https/https_test.go @@ -285,7 +285,7 @@ func TestServeBadAddress(t *testing.T) { }}, []string{"known"}) require.Error(t, err) - assert.Contains(t, err.Error(), "invalid Protocol") + assert.Contains(t, err.Error(), "invalid protocol") } func TestServeClosesListenerOnSetupError(t *testing.T) { diff --git a/servers/unix_socket_test.go b/servers/unix_socket_test.go new file mode 100644 index 00000000..d6c8e752 --- /dev/null +++ b/servers/unix_socket_test.go @@ -0,0 +1,113 @@ +//go:build linux || darwin || freebsd + +package servers_test + +import ( + "context" + "crypto/tls" + "io" + "log" + "log/slog" + "net" + "net/http" + "os" + "path/filepath" + "syscall" + "testing" + "time" + + "github.com/roadrunner-server/http/v6/config" + "github.com/roadrunner-server/http/v6/servers" + "github.com/roadrunner-server/http/v6/servers/fcgi" + httpServer "github.com/roadrunner-server/http/v6/servers/http11" + "github.com/roadrunner-server/http/v6/servers/https" + "github.com/roadrunner-server/tcplisten" + "github.com/stretchr/testify/require" + "golang.org/x/net/http2" +) + +func TestServeUnixSocket(t *testing.T) { + // A short path also fits the macOS UNIX socket address limit. + dir, err := os.MkdirTemp("", "rr-http-") + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, os.RemoveAll(dir)) }) + for _, protocol := range []string{"http1", "h2c", "fcgi"} { + for _, attributes := range []string{"default", "explicit"} { + t.Run(protocol+"/"+attributes, func(t *testing.T) { + path := filepath.Join(dir, "http.sock") + uid, gid := os.Geteuid(), os.Getegid() + var options *tcplisten.UnixSocketOptions + if attributes == "explicit" { + if uid == 0 { + uid, gid = 1, 1 + } + options = &tcplisten.UnixSocketOptions{Mode: "0660", UID: &uid, GID: &gid} + } + cfg := &config.Config{ + Address: "unix://" + path, UnixSocket: options, + HTTP2Config: &https.HTTP2{H2C: protocol == "h2c"}, + UID: 123, GID: 456, + } + require.NoError(t, cfg.InitDefaults()) + logger, errLog := slog.New(slog.DiscardHandler), log.New(io.Discard, "", 0) + var srv servers.InternalServer[any] + if protocol == "fcgi" { + srv = fcgi.NewFCGIServer(http.NotFoundHandler(), &fcgi.FCGI{Address: cfg.Address, UnixSocket: options}, logger, errLog) + } else { + srv = httpServer.NewHTTPServer(http.NotFoundHandler(), cfg, errLog, logger) + } + done := make(chan error, 1) + go func() { done <- srv.Serve(nil, nil) }() + t.Cleanup(func() { + srv.Stop() + srv.Stop() + select { + case err := <-done: + require.NoError(t, err) + case <-time.After(5 * time.Second): + t.Fatal("Serve did not stop") + } + _, err := os.Stat(path) + require.ErrorIs(t, err, os.ErrNotExist) + }) + require.Eventually(t, func() bool { + info, err := os.Stat(path) + if err != nil { + return false + } + stat := info.Sys().(*syscall.Stat_t) + return info.Mode()&os.ModeSocket != 0 && int(stat.Uid) == uid && int(stat.Gid) == gid && + (options == nil || info.Mode().Perm() == 0o660) + }, 5*time.Second, 10*time.Millisecond) + dial := func(ctx context.Context, _, _ string) (net.Conn, error) { + return new(net.Dialer).DialContext(ctx, "unix", path) + } + if protocol == "fcgi" { + conn, err := dial(t.Context(), "", "") + require.NoError(t, err) + require.NoError(t, conn.Close()) + return + } + client := &http.Client{Timeout: 5 * time.Second, Transport: &http.Transport{DialContext: dial}} + major := 1 + if protocol == "h2c" { + major = 2 + client.Transport = &http2.Transport{ + AllowHTTP: true, + DialTLSContext: func(ctx context.Context, network, addr string, _ *tls.Config) (net.Conn, error) { + return dial(ctx, network, addr) + }, + } + } + t.Cleanup(client.CloseIdleConnections) + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "http://localhost/", nil) + require.NoError(t, err) + resp, err := client.Do(req) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + require.Equal(t, http.StatusNotFound, resp.StatusCode) + require.Equal(t, major, resp.ProtoMajor) + }) + } + } +} diff --git a/tests/go.mod b/tests/go.mod index 69e24eb0..50fdd57a 100644 --- a/tests/go.mod +++ b/tests/go.mod @@ -68,7 +68,7 @@ require ( github.com/roadrunner-server/api-plugins/v6 v6.0.0-beta.2 // indirect github.com/roadrunner-server/errors v1.5.0 // indirect github.com/roadrunner-server/events v1.0.1 // indirect - github.com/roadrunner-server/tcplisten v1.5.2 // indirect + github.com/roadrunner-server/tcplisten v1.6.0 // indirect github.com/rs/cors v1.11.1 // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect github.com/shirou/gopsutil v3.21.11+incompatible // indirect diff --git a/tests/go.sum b/tests/go.sum index 33d06ea4..7130e2c6 100644 --- a/tests/go.sum +++ b/tests/go.sum @@ -130,8 +130,8 @@ github.com/roadrunner-server/server/v6 v6.0.0-beta.7 h1:EiRKdWFPOYLoYy53xoLbyU88 github.com/roadrunner-server/server/v6 v6.0.0-beta.7/go.mod h1:uq0yIZgp1v80BGIHPZHKHFyYIWTVJJvofThaC8QWf7w= github.com/roadrunner-server/static/v6 v6.0.0-beta.5 h1:FPuqsYoM6BxdHmZHliCSHuaLPeEGey4f5T0mImB84e4= github.com/roadrunner-server/static/v6 v6.0.0-beta.5/go.mod h1:eaKH+Wlxdc9DmZll4JEbiq8/68GZQBSyA7kTjygF1Ac= -github.com/roadrunner-server/tcplisten v1.5.2 h1:nn8yXYrhRDkfQ9AAu4V075uT4fZRmOnpxkawgE+bWPA= -github.com/roadrunner-server/tcplisten v1.5.2/go.mod h1:DufGBz7Dlx2KrNe/4RukEvGMTqZKB0Uve1GztwcyyR8= +github.com/roadrunner-server/tcplisten v1.6.0 h1:xfFeA2PZTmwJdwc/InhJGq200ew/lfTDReF3oa4AyI4= +github.com/roadrunner-server/tcplisten v1.6.0/go.mod h1:M01BcmhsBiek8WfkiRQwVXwVamgZ5YV36Wa0hz937dA= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= diff --git a/tests/unix_socket_test.go b/tests/unix_socket_test.go new file mode 100644 index 00000000..afbf318d --- /dev/null +++ b/tests/unix_socket_test.go @@ -0,0 +1,281 @@ +//go:build linux || darwin || freebsd + +package tests + +import ( + "context" + "crypto/tls" + "fmt" + "io" + "log" + "log/slog" + "net" + "net/http" + "os" + "path/filepath" + "testing" + "time" + + "tests/helpers" + mocklogger "tests/mock" + + rrconfig "github.com/roadrunner-server/config/v6" + httpPlugin "github.com/roadrunner-server/http/v6" + "github.com/roadrunner-server/http/v6/config" + "github.com/roadrunner-server/http/v6/servers/fcgi" + "github.com/roadrunner-server/server/v6" + "github.com/stretchr/testify/require" + "golang.org/x/net/http2" +) + +func TestUnixSocketConfigDecode(t *testing.T) { + for _, tt := range []struct { + name, httpOptions, fcgiOptions, httpMode, fcgiMode, errorField string + flags []string + zeroIDs bool + }{ + {name: "omitted"}, + {name: "http_only", httpOptions: `{mode: "0660"}`, httpMode: "0660"}, + {name: "fcgi_only", fcgiOptions: `{mode: "0600"}`, fcgiMode: "0600"}, + { + name: "numeric_zero_ids", httpOptions: `{mode: "0660", uid: 0, gid: 0}`, fcgiOptions: `{mode: "0600", uid: 0, gid: 0}`, + httpMode: "0660", fcgiMode: "0600", zeroIDs: true, + }, + { + name: "string_cli_overrides", httpOptions: `{mode: "0600"}`, fcgiOptions: `{mode: "0660"}`, + flags: []string{ + "http.unix_socket.mode=0660", "http.unix_socket.uid=0", "http.unix_socket.gid=0", + "http.fcgi.unix_socket.mode=0600", "http.fcgi.unix_socket.uid=0", "http.fcgi.unix_socket.gid=0", + }, + httpMode: "0660", fcgiMode: "0600", zeroIDs: true, + }, + {name: "invalid_http_mode", httpOptions: `{mode: "660"}`, errorField: "http.unix_socket"}, + {name: "invalid_fcgi_mode", fcgiOptions: `{mode: "660"}`, errorField: "http.fcgi.unix_socket"}, + {name: "numeric_mode", httpOptions: `{mode: 0660}`, errorField: "http.unix_socket"}, + {name: "http_empty_options_tcp", httpOptions: `{}`, flags: []string{"http.address=127.0.0.1:0"}, errorField: "http.unix_socket"}, + {name: "fcgi_empty_options_tcp", fcgiOptions: `{}`, flags: []string{"http.fcgi.address=127.0.0.1:0"}, errorField: "http.fcgi.unix_socket"}, + {name: "disabled_http", httpOptions: `{}`, flags: []string{`http.address=""`}, errorField: "http.unix_socket"}, + {name: "disabled_fcgi", fcgiOptions: `{}`, flags: []string{`http.fcgi.address=""`}, errorField: "http.fcgi.unix_socket"}, + } { + t.Run(tt.name, func(t *testing.T) { + yaml := "version: \"3\"\nhttp:\n address: unix://http.sock\n" + if tt.httpOptions != "" { + yaml += " unix_socket: " + tt.httpOptions + "\n" + } + yaml += " fcgi:\n address: unix://fcgi.sock\n" + if tt.fcgiOptions != "" { + yaml += " unix_socket: " + tt.fcgiOptions + "\n" + } + path := filepath.Join(t.TempDir(), ".rr.yaml") + require.NoError(t, os.WriteFile(path, []byte(yaml), 0o600)) + provider := &rrconfig.Plugin{Path: path, Flags: tt.flags} + require.NoError(t, provider.Init()) + var cfg config.Config + require.NoError(t, provider.UnmarshalKey("http", &cfg)) + require.NoError(t, provider.UnmarshalKey("http.fcgi", &cfg.FCGIConfig)) + if tt.errorField != "" { + // Invalid options must fail before logger and worker access. + require.ErrorContains(t, new(httpPlugin.Plugin).Init(provider, nil, nil), tt.errorField) + return + } + require.NoError(t, cfg.InitDefaults()) + for i, mode := range []string{tt.httpMode, tt.fcgiMode} { + options := cfg.UnixSocket + if i == 1 { + options = cfg.FCGIConfig.UnixSocket + } + if mode == "" { + require.Nil(t, options) + continue + } + require.NotNil(t, options) + require.Equal(t, mode, options.Mode) + if tt.zeroIDs { + require.NotNil(t, options.UID) + require.NotNil(t, options.GID) + require.Zero(t, *options.UID) + require.Zero(t, *options.GID) + } else { + require.Nil(t, options.UID) + require.Nil(t, options.GID) + } + } + }) + } +} + +func TestUnixSocketOwnershipValidation(t *testing.T) { + const env = "RR_HTTP_UNIX_SOCKET_TEST_ID" + t.Setenv(env, "33") + id := 33 + for _, key := range []string{"http.unix_socket", "http.fcgi.unix_socket"} { + for _, field := range []string{"uid", "gid"} { + for _, tt := range []struct { + name, value string + want *int + invalid, unsetEnv, json bool + }{ + {name: "false", value: "false", invalid: true}, + {name: "true", value: "true", invalid: true}, + {name: "fraction", value: "1.9", invalid: true}, + {name: "negative_fraction", value: "-0.5", invalid: true}, + {name: "empty_string", value: `""`, invalid: true}, + {name: "unset_env", value: `"${RR_HTTP_UNIX_SOCKET_TEST_ID}"`, invalid: true, unsetEnv: true}, + {name: "over_range", value: "4294967295", invalid: true}, + {name: "unsigned_over_range", value: "18446744073709551615", invalid: true}, + {name: "string_over_range", value: `"4294967295"`, invalid: true}, + {name: "string_overflow", value: `"9223372036854775808"`, invalid: true}, + {name: "nan", value: ".nan", invalid: true}, + {name: "infinity", value: ".inf", invalid: true}, + {name: "sequence", value: "[33]", invalid: true}, + {name: "map", value: "{id: 33}", invalid: true}, + {name: "integer", value: "33", want: &id}, + {name: "populated_env", value: `"${RR_HTTP_UNIX_SOCKET_TEST_ID}"`, want: &id}, + {name: "zero", value: "0", want: new(int)}, + {name: "string_zero", value: `"0"`, want: new(int)}, + {name: "base_zero_string", value: `"0x21"`, want: &id}, + {name: "null", value: "null"}, + {name: "json_integer", value: "33.0", want: &id, json: true}, + } { + t.Run(key+"/"+field+"/"+tt.name, func(t *testing.T) { + if tt.unsetEnv { + t.Setenv(env, "") + require.NoError(t, os.Unsetenv(env)) + } + dir := t.TempDir() + socketPath := filepath.Join(dir, "listener.sock") + contents, indent := "version: \"3\"\nhttp:\n", " " + if key == "http.fcgi.unix_socket" { + contents += " fcgi:\n" + indent = " " + } + contents += fmt.Sprintf("%saddress: %q\n%sunix_socket: {mode: \"0600\", %s: %s}\n", indent, "unix://"+socketPath, indent, field, tt.value) + path := filepath.Join(dir, ".rr.yaml") + if tt.json { + section := fmt.Sprintf(`{"address":%q,"unix_socket":{"mode":"0600",%q:%s}}`, "unix://"+socketPath, field, tt.value) + if key == "http.fcgi.unix_socket" { + section = `{"fcgi":` + section + `}` + } + contents = `{"version":"3","http":` + section + `}` + path = filepath.Join(dir, ".rr.json") + } + require.NoError(t, os.WriteFile(path, []byte(contents), 0o600)) + provider := &rrconfig.Plugin{Path: path} + require.NoError(t, provider.Init()) + if tt.invalid { + require.ErrorContains(t, new(httpPlugin.Plugin).Init(provider, nil, nil), key+"."+field) + } else { + logger := mocklogger.NewLogger(slog.New(slog.DiscardHandler)) + require.NoError(t, new(httpPlugin.Plugin).Init(provider, logger, new(server.Plugin))) + var cfg config.Config + require.NoError(t, provider.UnmarshalKey("http", &cfg)) + options := cfg.UnixSocket + if key == "http.fcgi.unix_socket" { + options = cfg.FCGIConfig.UnixSocket + } + require.NotNil(t, options) + require.Equal(t, "0600", options.Mode) + got, other := options.UID, options.GID + if field == "gid" { + got, other = other, got + } + require.Equal(t, tt.want, got) + require.Nil(t, other) + } + _, err := os.Stat(socketPath) + require.ErrorIs(t, err, os.ErrNotExist) + }) + } + } + } +} + +func TestUnixSocketFCGIRequest(t *testing.T) { + dir, err := os.MkdirTemp("", "rr-fcgi-") + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, os.RemoveAll(dir)) }) + path := filepath.Join(dir, "fcgi.sock") + provider := &rrconfig.Plugin{Type: "yaml", ReadInCfg: []byte("http:\n fcgi:\n address: unix://" + path + "\n unix_socket: {mode: \"0600\"}\n")} + require.NoError(t, provider.Init()) + var cfg config.Config + require.NoError(t, provider.UnmarshalKey("http", &cfg)) + require.NoError(t, cfg.InitDefaults()) + srv := fcgi.NewFCGIServer(http.NotFoundHandler(), cfg.FCGIConfig, slog.New(slog.DiscardHandler), log.New(io.Discard, "", 0)) + done := make(chan error, 1) + go func() { done <- srv.Serve(nil, nil) }() + t.Cleanup(func() { + srv.Stop() + select { + case err := <-done: + require.NoError(t, err) + case <-time.After(5 * time.Second): + t.Fatal("FCGI Serve did not stop") + } + _, err := os.Stat(path) + require.ErrorIs(t, err, os.ErrNotExist) + }) + code, body := fcgiGet(t, "unix", path, "http://localhost/") + require.Equal(t, http.StatusNotFound, code) + require.Equal(t, "404 page not found\n", body) + info, err := os.Stat(path) + require.NoError(t, err) + require.Equal(t, os.FileMode(0o600), info.Mode().Perm()) +} + +func TestUnixSocketPluginServe(t *testing.T) { + dir, err := os.MkdirTemp("", "rr-http-") + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, os.RemoveAll(dir)) }) + for _, protocol := range []string{"http1", "h2c"} { + t.Run(protocol, func(t *testing.T) { + httpPath, fcgiPath := filepath.Join(dir, "http.sock"), filepath.Join(dir, "fcgi.sock") + yaml := fmt.Sprintf(`version: "3" +server: + command: "php php_test_files/http/client.php echo pipes" + relay: pipes +http: + address: unix://%s + unix_socket: {mode: "0660", uid: %d, gid: %d} + http2: {h2c: %t} + pool: {num_workers: 1, allocate_timeout: 5s, destroy_timeout: 1s} + fcgi: + address: unix://%s + unix_socket: {mode: "0600"} +`, httpPath, os.Geteuid(), os.Getegid(), protocol == "h2c", fcgiPath) + _, stop := helpers.Start(t, "", []any{&server.Plugin{}, &httpPlugin.Plugin{}}, helpers.WithInlineConfig(yaml), helpers.WithObservedLogger()) + helpers.WaitListener(t, "unix", httpPath) + dial := func(ctx context.Context, _, _ string) (net.Conn, error) { + return new(net.Dialer).DialContext(ctx, "unix", httpPath) + } + client := &http.Client{Timeout: 5 * time.Second, Transport: &http.Transport{DialContext: dial}} + major := 1 + if protocol == "h2c" { + major = 2 + client.Transport = &http2.Transport{ + AllowHTTP: true, + DialTLSContext: func(ctx context.Context, network, addr string, _ *tls.Config) (net.Conn, error) { + return dial(ctx, network, addr) + }, + } + } + t.Cleanup(client.CloseIdleConnections) + response := clientGet(t, client, "http://localhost/?hello=world") + require.Equal(t, http.StatusCreated, response.StatusCode) + require.Equal(t, "WORLD", response.Body) + require.Equal(t, major, response.ProtoMajor) + code, body := fcgiGet(t, "unix", fcgiPath, "http://localhost/?hello=world") + require.Equal(t, http.StatusCreated, code) + require.Equal(t, "WORLD", body) + for path, mode := range map[string]os.FileMode{httpPath: 0o660, fcgiPath: 0o600} { + info, err := os.Stat(path) + require.NoError(t, err) + require.Equal(t, mode, info.Mode().Perm()) + } + stop() + for _, path := range []string{httpPath, fcgiPath} { + _, err := os.Stat(path) + require.ErrorIs(t, err, os.ErrNotExist) + } + }) + } +} From b1f3f04d94b4db2e3c3d8b3157ddb4e01a56467f Mon Sep 17 00:00:00 2001 From: Valery Piashchynski Date: Mon, 7 Sep 2026 11:50:32 +0200 Subject: [PATCH 2/8] refactor: simplify UNIX socket configuration --- config/unix_socket_test.go | 78 ++++----- init.go | 63 -------- plugin_test.go | 4 +- tests/unix_socket_test.go | 323 ++++++++++++++++++------------------- 4 files changed, 190 insertions(+), 278 deletions(-) diff --git a/config/unix_socket_test.go b/config/unix_socket_test.go index 665d5808..6a5f692c 100644 --- a/config/unix_socket_test.go +++ b/config/unix_socket_test.go @@ -11,26 +11,25 @@ import ( ) func TestUnixSocketValidation(t *testing.T) { - negative := -1 for _, field := range []string{"http.unix_socket", "http.fcgi.unix_socket"} { for _, tt := range []struct { name, address string options *tcplisten.UnixSocketOptions - invalid bool + wantErr string }{ - {"nil_tcp", "127.0.0.1:0", nil, false}, - {"nil_unix", "unix://http.sock", nil, false}, - {"nil_disabled", "", nil, false}, - {"empty_options", "unix://http.sock", &tcplisten.UnixSocketOptions{}, false}, - {"mode_and_zero_ids", "unix://http.sock", &tcplisten.UnixSocketOptions{Mode: "0660", UID: new(int), GID: new(int)}, false}, - {"tcp", "tcp://127.0.0.1:0", &tcplisten.UnixSocketOptions{}, true}, - {"disabled", "", &tcplisten.UnixSocketOptions{}, true}, - {"missing_path", "unix://", &tcplisten.UnixSocketOptions{}, true}, - {"short_mode", "unix://http.sock", &tcplisten.UnixSocketOptions{Mode: "660"}, true}, - {"invalid_mode", "unix://http.sock", &tcplisten.UnixSocketOptions{Mode: "0999"}, true}, - {"negative_uid", "unix://http.sock", &tcplisten.UnixSocketOptions{UID: &negative}, true}, - {"negative_gid", "unix://http.sock", &tcplisten.UnixSocketOptions{GID: &negative}, true}, - {"abstract", "unix://@http", &tcplisten.UnixSocketOptions{}, runtime.GOOS == "linux"}, + {name: "TCP defaults", address: "127.0.0.1:0"}, + {name: "UNIX defaults", address: "unix://http.sock"}, + {name: "disabled defaults"}, + {name: "empty options", address: "unix://http.sock", options: &tcplisten.UnixSocketOptions{}}, + {name: "mode and zero IDs", address: "unix://http.sock", options: &tcplisten.UnixSocketOptions{Mode: "0660", UID: new(int), GID: new(int)}}, + {name: "TCP options", address: "tcp://127.0.0.1:0", options: &tcplisten.UnixSocketOptions{}, wantErr: "filesystem unix:// address"}, + {name: "disabled options", options: &tcplisten.UnixSocketOptions{}, wantErr: "filesystem unix:// address"}, + {name: "empty UNIX path", address: "unix://", options: &tcplisten.UnixSocketOptions{}, wantErr: "filesystem unix:// address"}, + {name: "short mode", address: "unix://http.sock", options: &tcplisten.UnixSocketOptions{Mode: "660"}, wantErr: "invalid unix socket mode"}, + {name: "invalid octal mode", address: "unix://http.sock", options: &tcplisten.UnixSocketOptions{Mode: "0999"}, wantErr: "invalid unix socket mode"}, + {name: "negative UID", address: "unix://http.sock", options: &tcplisten.UnixSocketOptions{UID: new(-1)}, wantErr: "invalid unix socket uid"}, + {name: "negative GID", address: "unix://http.sock", options: &tcplisten.UnixSocketOptions{GID: new(-1)}, wantErr: "invalid unix socket gid"}, + {name: "abstract address", address: "unix://@http", options: &tcplisten.UnixSocketOptions{}}, } { t.Run(field+"/"+tt.name, func(t *testing.T) { cfg := &Config{Address: "127.0.0.1:0", FCGIConfig: &fcgi.FCGI{Address: "127.0.0.1:0"}} @@ -39,49 +38,34 @@ func TestUnixSocketValidation(t *testing.T) { } else { cfg.FCGIConfig.Address, cfg.FCGIConfig.UnixSocket = tt.address, tt.options } + wantErr := tt.wantErr + if runtime.GOOS == "linux" && tt.address == "unix://@http" { + wantErr = "filesystem unix:// address" + } + if runtime.GOOS == "windows" && tt.options != nil { + wantErr = "unix socket attributes are not supported on Windows" + } err := cfg.InitDefaults() - if tt.invalid || (runtime.GOOS == "windows" && tt.options != nil) { + if wantErr != "" { require.ErrorContains(t, err, field) - require.ErrorContains(t, cfg.Valid(), field) + require.ErrorContains(t, err, wantErr) } else { require.NoError(t, err) - require.NoError(t, cfg.Valid()) } }) } } } -func TestUnixSocketIndependentOptions(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("UNIX socket attributes are not supported on Windows") - } - for _, tt := range []struct { - name string - http, fcgi *tcplisten.UnixSocketOptions - }{ - {"omitted", nil, nil}, - {"http_only", &tcplisten.UnixSocketOptions{Mode: "0600"}, nil}, - {"fcgi_only", nil, &tcplisten.UnixSocketOptions{Mode: "0660"}}, - {"both", &tcplisten.UnixSocketOptions{Mode: "0600"}, &tcplisten.UnixSocketOptions{Mode: "0660"}}, - } { - t.Run(tt.name, func(t *testing.T) { - cfg := &Config{ - Address: "unix://http.sock", UnixSocket: tt.http, - FCGIConfig: &fcgi.FCGI{Address: "unix://fcgi.sock", UnixSocket: tt.fcgi}, - UID: 123, GID: 456, - } - require.NoError(t, cfg.InitDefaults()) - require.Same(t, tt.http, cfg.UnixSocket) - require.Same(t, tt.fcgi, cfg.FCGIConfig.UnixSocket) - for _, options := range []*tcplisten.UnixSocketOptions{cfg.UnixSocket, cfg.FCGIConfig.UnixSocket} { - if options != nil { - require.Nil(t, options.UID) - require.Nil(t, options.GID) - } - } - }) +func TestUnixSocketDefaults(t *testing.T) { + cfg := &Config{ + Address: "unix://http.sock", + FCGIConfig: &fcgi.FCGI{Address: "unix://fcgi.sock"}, + UID: 123, GID: 456, } + require.NoError(t, cfg.InitDefaults()) + require.Nil(t, cfg.UnixSocket) + require.Nil(t, cfg.FCGIConfig.UnixSocket) } func TestUnixSocketRejectsProxyProtocol(t *testing.T) { diff --git a/init.go b/init.go index f6dcc9fc..947312b6 100644 --- a/init.go +++ b/init.go @@ -1,11 +1,7 @@ package http import ( - "fmt" - "math" "net/http" - "reflect" - "strconv" "github.com/quic-go/quic-go/http3" "github.com/roadrunner-server/http/v6/acme" @@ -16,7 +12,6 @@ import ( httpServer "github.com/roadrunner-server/http/v6/servers/http11" http3Server "github.com/roadrunner-server/http/v6/servers/http3" httpsServer "github.com/roadrunner-server/http/v6/servers/https" - "github.com/roadrunner-server/tcplisten" ) // ------- PRIVATE --------- @@ -76,12 +71,6 @@ func (p *Plugin) applyBundledMiddleware() { } func (p *Plugin) unmarshal(cfg api.Configurer) error { - for _, key := range []string{"http.unix_socket", "http.fcgi.unix_socket"} { - if err := validateUnixSocketIDs(cfg, key); err != nil { - return err - } - } - // unmarshal general section err := cfg.UnmarshalKey(PluginName, &p.cfg) if err != nil { @@ -112,57 +101,5 @@ func (p *Plugin) unmarshal(cfg api.Configurer) error { return err } - // Viper can omit empty maps when it decodes the parent section. - if cfg.Has("http.unix_socket") { - p.cfg.UnixSocket = &tcplisten.UnixSocketOptions{} - if err = cfg.UnmarshalKey("http.unix_socket", p.cfg.UnixSocket); err != nil { - return err - } - } - if cfg.Has("http.fcgi.unix_socket") { - if p.cfg.FCGIConfig == nil { - p.cfg.FCGIConfig = &fcgi.FCGI{} - } - p.cfg.FCGIConfig.UnixSocket = &tcplisten.UnixSocketOptions{} - if err = cfg.UnmarshalKey("http.fcgi.unix_socket", p.cfg.FCGIConfig.UnixSocket); err != nil { - return err - } - } - - return nil -} - -// Check raw IDs before weak decoding can convert booleans or truncate fractions. -func validateUnixSocketIDs(cfg api.Configurer, key string) error { - if !cfg.Has(key) { - return nil - } - var options map[string]any - if err := cfg.UnmarshalKey(key, &options); err != nil { - return fmt.Errorf("%s: %w", key, err) - } - for _, field := range []string{"uid", "gid"} { - if options[field] == nil { - continue - } - value := reflect.ValueOf(options[field]) - var valid bool - switch value.Kind() { //nolint:exhaustive // Other kinds fail validation. - case reflect.String: - id, err := strconv.ParseInt(value.String(), 0, strconv.IntSize) - valid = err == nil && id >= 0 && id < 4294967295 - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - id := value.Int() - valid = id >= 0 && id < 4294967295 - case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: - valid = value.Uint() < 4294967295 - case reflect.Float32, reflect.Float64: - id := value.Float() - valid = id >= 0 && id < 4294967295 && math.Trunc(id) == id - } - if !valid { - return fmt.Errorf("%s.%s: must be an integer between 0 and 4294967294", key, field) - } - } return nil } diff --git a/plugin_test.go b/plugin_test.go index 53eb55dd..cf57b1fd 100644 --- a/plugin_test.go +++ b/plugin_test.go @@ -26,7 +26,7 @@ type stubConfigurer struct { } func (c *stubConfigurer) Has(section string) bool { - return c.has && (section == PluginName || section == c.errSection) + return c.has && section == PluginName } func (c *stubConfigurer) Experimental() bool { return c.experimental } @@ -90,7 +90,7 @@ func TestInit_SectionDisabled(t *testing.T) { } func TestInit_UnmarshalErrorPerSection(t *testing.T) { - sections := []string{PluginName, sectionHTTPS, sectionHTTP2, sectionUploads, sectionFCGI, "http.unix_socket", "http.fcgi.unix_socket"} + sections := []string{PluginName, sectionHTTPS, sectionHTTP2, sectionUploads, sectionFCGI} for _, section := range sections { t.Run(section, func(t *testing.T) { diff --git a/tests/unix_socket_test.go b/tests/unix_socket_test.go index afbf318d..d7f036c5 100644 --- a/tests/unix_socket_test.go +++ b/tests/unix_socket_test.go @@ -13,6 +13,11 @@ import ( "net/http" "os" "path/filepath" + "slices" + "strconv" + "strings" + "sync" + "syscall" "testing" "time" @@ -20,172 +25,72 @@ import ( mocklogger "tests/mock" rrconfig "github.com/roadrunner-server/config/v6" + "github.com/roadrunner-server/endure/v2" httpPlugin "github.com/roadrunner-server/http/v6" - "github.com/roadrunner-server/http/v6/config" "github.com/roadrunner-server/http/v6/servers/fcgi" "github.com/roadrunner-server/server/v6" + "github.com/roadrunner-server/tcplisten" "github.com/stretchr/testify/require" "golang.org/x/net/http2" ) -func TestUnixSocketConfigDecode(t *testing.T) { - for _, tt := range []struct { - name, httpOptions, fcgiOptions, httpMode, fcgiMode, errorField string - flags []string - zeroIDs bool - }{ - {name: "omitted"}, - {name: "http_only", httpOptions: `{mode: "0660"}`, httpMode: "0660"}, - {name: "fcgi_only", fcgiOptions: `{mode: "0600"}`, fcgiMode: "0600"}, - { - name: "numeric_zero_ids", httpOptions: `{mode: "0660", uid: 0, gid: 0}`, fcgiOptions: `{mode: "0600", uid: 0, gid: 0}`, - httpMode: "0660", fcgiMode: "0600", zeroIDs: true, - }, - { - name: "string_cli_overrides", httpOptions: `{mode: "0600"}`, fcgiOptions: `{mode: "0660"}`, - flags: []string{ - "http.unix_socket.mode=0660", "http.unix_socket.uid=0", "http.unix_socket.gid=0", - "http.fcgi.unix_socket.mode=0600", "http.fcgi.unix_socket.uid=0", "http.fcgi.unix_socket.gid=0", - }, - httpMode: "0660", fcgiMode: "0600", zeroIDs: true, - }, - {name: "invalid_http_mode", httpOptions: `{mode: "660"}`, errorField: "http.unix_socket"}, - {name: "invalid_fcgi_mode", fcgiOptions: `{mode: "660"}`, errorField: "http.fcgi.unix_socket"}, - {name: "numeric_mode", httpOptions: `{mode: 0660}`, errorField: "http.unix_socket"}, - {name: "http_empty_options_tcp", httpOptions: `{}`, flags: []string{"http.address=127.0.0.1:0"}, errorField: "http.unix_socket"}, - {name: "fcgi_empty_options_tcp", fcgiOptions: `{}`, flags: []string{"http.fcgi.address=127.0.0.1:0"}, errorField: "http.fcgi.unix_socket"}, - {name: "disabled_http", httpOptions: `{}`, flags: []string{`http.address=""`}, errorField: "http.unix_socket"}, - {name: "disabled_fcgi", fcgiOptions: `{}`, flags: []string{`http.fcgi.address=""`}, errorField: "http.fcgi.unix_socket"}, - } { - t.Run(tt.name, func(t *testing.T) { - yaml := "version: \"3\"\nhttp:\n address: unix://http.sock\n" - if tt.httpOptions != "" { - yaml += " unix_socket: " + tt.httpOptions + "\n" - } - yaml += " fcgi:\n address: unix://fcgi.sock\n" - if tt.fcgiOptions != "" { - yaml += " unix_socket: " + tt.fcgiOptions + "\n" - } - path := filepath.Join(t.TempDir(), ".rr.yaml") - require.NoError(t, os.WriteFile(path, []byte(yaml), 0o600)) - provider := &rrconfig.Plugin{Path: path, Flags: tt.flags} - require.NoError(t, provider.Init()) - var cfg config.Config - require.NoError(t, provider.UnmarshalKey("http", &cfg)) - require.NoError(t, provider.UnmarshalKey("http.fcgi", &cfg.FCGIConfig)) - if tt.errorField != "" { - // Invalid options must fail before logger and worker access. - require.ErrorContains(t, new(httpPlugin.Plugin).Init(provider, nil, nil), tt.errorField) - return - } - require.NoError(t, cfg.InitDefaults()) - for i, mode := range []string{tt.httpMode, tt.fcgiMode} { - options := cfg.UnixSocket - if i == 1 { - options = cfg.FCGIConfig.UnixSocket +func TestUnixSocketConfig(t *testing.T) { + for _, key := range []string{"http.unix_socket", "http.fcgi.unix_socket"} { + for _, tt := range []struct { + name, address, options, wantErr string + }{ + {name: "TCP defaults", address: "127.0.0.1:0"}, + {name: "UNIX defaults", address: "unix://listener.sock"}, + {name: "disabled defaults"}, + {name: "empty options", address: "unix://listener.sock", options: "{}"}, + {name: "TCP empty options", address: "127.0.0.1:0", options: "{}"}, + {name: "disabled empty options", options: "{}"}, + {name: "mode only", address: "unix://listener.sock", options: `{mode: "0600"}`}, + {name: "explicit zero", address: "unix://listener.sock", options: `{mode: "0000", uid: 0, gid: 0}`}, + {name: "unset mode", address: "unix://listener.sock", options: "{uid: 0, gid: 0}"}, + {name: "TCP options", address: "127.0.0.1:0", options: `{mode: "0600"}`, wantErr: "filesystem unix:// address"}, + {name: "disabled options", options: `{mode: "0600"}`, wantErr: "filesystem unix:// address"}, + {name: "empty UNIX path", address: "unix://", options: `{mode: "0600"}`, wantErr: "filesystem unix:// address"}, + {name: "short mode", address: "unix://listener.sock", options: `{mode: "600"}`, wantErr: "invalid unix socket mode"}, + {name: "unquoted mode", address: "unix://listener.sock", options: "{mode: 0660}", wantErr: "invalid unix socket mode"}, + {name: "scalar options", address: "unix://listener.sock", options: "false", wantErr: "expected a map"}, + {name: "negative UID", address: "unix://listener.sock", options: "{uid: -1}", wantErr: "invalid unix socket uid"}, + {name: "negative GID", address: "unix://listener.sock", options: "{gid: -1}", wantErr: "invalid unix socket gid"}, + {name: "reserved UID", address: "unix://listener.sock", options: "{uid: 4294967295}", wantErr: "invalid unix socket uid"}, + {name: "reserved GID", address: "unix://listener.sock", options: "{gid: 4294967295}", wantErr: "invalid unix socket gid"}, + } { + t.Run(key+"/"+tt.name, func(t *testing.T) { + yaml := fmt.Sprintf(`version: "3" +http: + fcgi: {address: unix://fcgi.sock} + address: %q +`, tt.address) + indent := " " + if key == "http.fcgi.unix_socket" { + yaml = fmt.Sprintf(`version: "3" +http: + address: unix://http.sock + fcgi: + address: %q +`, tt.address) + indent = " " } - if mode == "" { - require.Nil(t, options) - continue + if tt.options != "" { + yaml += indent + "unix_socket: " + tt.options + "\n" } - require.NotNil(t, options) - require.Equal(t, mode, options.Mode) - if tt.zeroIDs { - require.NotNil(t, options.UID) - require.NotNil(t, options.GID) - require.Zero(t, *options.UID) - require.Zero(t, *options.GID) - } else { - require.Nil(t, options.UID) - require.Nil(t, options.GID) + path := filepath.Join(t.TempDir(), ".rr.yaml") + require.NoError(t, os.WriteFile(path, []byte(yaml), 0o600)) + provider := &rrconfig.Plugin{Path: path} + require.NoError(t, provider.Init()) + logger := mocklogger.NewLogger(slog.New(slog.DiscardHandler)) + err := new(httpPlugin.Plugin).Init(provider, logger, new(server.Plugin)) + if tt.wantErr != "" { + require.ErrorContains(t, err, strings.TrimPrefix(key, "http.")) + require.ErrorContains(t, err, tt.wantErr) + return } - } - }) - } -} - -func TestUnixSocketOwnershipValidation(t *testing.T) { - const env = "RR_HTTP_UNIX_SOCKET_TEST_ID" - t.Setenv(env, "33") - id := 33 - for _, key := range []string{"http.unix_socket", "http.fcgi.unix_socket"} { - for _, field := range []string{"uid", "gid"} { - for _, tt := range []struct { - name, value string - want *int - invalid, unsetEnv, json bool - }{ - {name: "false", value: "false", invalid: true}, - {name: "true", value: "true", invalid: true}, - {name: "fraction", value: "1.9", invalid: true}, - {name: "negative_fraction", value: "-0.5", invalid: true}, - {name: "empty_string", value: `""`, invalid: true}, - {name: "unset_env", value: `"${RR_HTTP_UNIX_SOCKET_TEST_ID}"`, invalid: true, unsetEnv: true}, - {name: "over_range", value: "4294967295", invalid: true}, - {name: "unsigned_over_range", value: "18446744073709551615", invalid: true}, - {name: "string_over_range", value: `"4294967295"`, invalid: true}, - {name: "string_overflow", value: `"9223372036854775808"`, invalid: true}, - {name: "nan", value: ".nan", invalid: true}, - {name: "infinity", value: ".inf", invalid: true}, - {name: "sequence", value: "[33]", invalid: true}, - {name: "map", value: "{id: 33}", invalid: true}, - {name: "integer", value: "33", want: &id}, - {name: "populated_env", value: `"${RR_HTTP_UNIX_SOCKET_TEST_ID}"`, want: &id}, - {name: "zero", value: "0", want: new(int)}, - {name: "string_zero", value: `"0"`, want: new(int)}, - {name: "base_zero_string", value: `"0x21"`, want: &id}, - {name: "null", value: "null"}, - {name: "json_integer", value: "33.0", want: &id, json: true}, - } { - t.Run(key+"/"+field+"/"+tt.name, func(t *testing.T) { - if tt.unsetEnv { - t.Setenv(env, "") - require.NoError(t, os.Unsetenv(env)) - } - dir := t.TempDir() - socketPath := filepath.Join(dir, "listener.sock") - contents, indent := "version: \"3\"\nhttp:\n", " " - if key == "http.fcgi.unix_socket" { - contents += " fcgi:\n" - indent = " " - } - contents += fmt.Sprintf("%saddress: %q\n%sunix_socket: {mode: \"0600\", %s: %s}\n", indent, "unix://"+socketPath, indent, field, tt.value) - path := filepath.Join(dir, ".rr.yaml") - if tt.json { - section := fmt.Sprintf(`{"address":%q,"unix_socket":{"mode":"0600",%q:%s}}`, "unix://"+socketPath, field, tt.value) - if key == "http.fcgi.unix_socket" { - section = `{"fcgi":` + section + `}` - } - contents = `{"version":"3","http":` + section + `}` - path = filepath.Join(dir, ".rr.json") - } - require.NoError(t, os.WriteFile(path, []byte(contents), 0o600)) - provider := &rrconfig.Plugin{Path: path} - require.NoError(t, provider.Init()) - if tt.invalid { - require.ErrorContains(t, new(httpPlugin.Plugin).Init(provider, nil, nil), key+"."+field) - } else { - logger := mocklogger.NewLogger(slog.New(slog.DiscardHandler)) - require.NoError(t, new(httpPlugin.Plugin).Init(provider, logger, new(server.Plugin))) - var cfg config.Config - require.NoError(t, provider.UnmarshalKey("http", &cfg)) - options := cfg.UnixSocket - if key == "http.fcgi.unix_socket" { - options = cfg.FCGIConfig.UnixSocket - } - require.NotNil(t, options) - require.Equal(t, "0600", options.Mode) - got, other := options.UID, options.GID - if field == "gid" { - got, other = other, got - } - require.Equal(t, tt.want, got) - require.Nil(t, other) - } - _, err := os.Stat(socketPath) - require.ErrorIs(t, err, os.ErrNotExist) - }) - } + require.NoError(t, err) + }) } } } @@ -195,12 +100,9 @@ func TestUnixSocketFCGIRequest(t *testing.T) { require.NoError(t, err) t.Cleanup(func() { require.NoError(t, os.RemoveAll(dir)) }) path := filepath.Join(dir, "fcgi.sock") - provider := &rrconfig.Plugin{Type: "yaml", ReadInCfg: []byte("http:\n fcgi:\n address: unix://" + path + "\n unix_socket: {mode: \"0600\"}\n")} - require.NoError(t, provider.Init()) - var cfg config.Config - require.NoError(t, provider.UnmarshalKey("http", &cfg)) - require.NoError(t, cfg.InitDefaults()) - srv := fcgi.NewFCGIServer(http.NotFoundHandler(), cfg.FCGIConfig, slog.New(slog.DiscardHandler), log.New(io.Discard, "", 0)) + cfg := &fcgi.FCGI{Address: "unix://" + path, UnixSocket: &tcplisten.UnixSocketOptions{Mode: "0600"}} + require.NoError(t, cfg.Valid()) + srv := fcgi.NewFCGIServer(http.NotFoundHandler(), cfg, slog.New(slog.DiscardHandler), log.New(io.Discard, "", 0)) done := make(chan error, 1) go func() { done <- srv.Serve(nil, nil) }() t.Cleanup(func() { @@ -219,13 +121,32 @@ func TestUnixSocketFCGIRequest(t *testing.T) { require.Equal(t, "404 page not found\n", body) info, err := os.Stat(path) require.NoError(t, err) + require.NotZero(t, info.Mode()&os.ModeSocket) require.Equal(t, os.FileMode(0o600), info.Mode().Perm()) + stat := info.Sys().(*syscall.Stat_t) + require.EqualValues(t, os.Geteuid(), stat.Uid) + require.EqualValues(t, os.Getegid(), stat.Gid) } func TestUnixSocketPluginServe(t *testing.T) { dir, err := os.MkdirTemp("", "rr-http-") require.NoError(t, err) t.Cleanup(func() { require.NoError(t, os.RemoveAll(dir)) }) + uid, gid := os.Geteuid(), os.Getegid() + if uid == 0 { + uid, gid = 1, 1 + } else { + groups, err := os.Getgroups() + require.NoError(t, err) + for _, group := range groups { + if group != gid { + gid = group + break + } + } + } + t.Setenv("RR_HTTP_TEST_SOCKET_UID", strconv.Itoa(uid)) + t.Setenv("RR_HTTP_TEST_SOCKET_GID", strconv.Itoa(gid)) for _, protocol := range []string{"http1", "h2c"} { t.Run(protocol, func(t *testing.T) { httpPath, fcgiPath := filepath.Join(dir, "http.sock"), filepath.Join(dir, "fcgi.sock") @@ -235,14 +156,16 @@ server: relay: pipes http: address: unix://%s - unix_socket: {mode: "0660", uid: %d, gid: %d} + unix_socket: {mode: "0660", uid: "${RR_HTTP_TEST_SOCKET_UID}", gid: "${RR_HTTP_TEST_SOCKET_GID}"} http2: {h2c: %t} pool: {num_workers: 1, allocate_timeout: 5s, destroy_timeout: 1s} fcgi: address: unix://%s - unix_socket: {mode: "0600"} -`, httpPath, os.Geteuid(), os.Getegid(), protocol == "h2c", fcgiPath) - _, stop := helpers.Start(t, "", []any{&server.Plugin{}, &httpPlugin.Plugin{}}, helpers.WithInlineConfig(yaml), helpers.WithObservedLogger()) + unix_socket: {mode: "0600", uid: %d, gid: %d} +`, httpPath, protocol == "h2c", fcgiPath, uid, gid) + configPath := filepath.Join(dir, ".rr.yaml") + require.NoError(t, os.WriteFile(configPath, []byte(yaml), 0o600)) + _, stop := helpers.Start(t, configPath, []any{&server.Plugin{}, &httpPlugin.Plugin{}}, helpers.WithObservedLogger()) helpers.WaitListener(t, "unix", httpPath) dial := func(ctx context.Context, _, _ string) (net.Conn, error) { return new(net.Dialer).DialContext(ctx, "unix", httpPath) @@ -269,7 +192,11 @@ http: for path, mode := range map[string]os.FileMode{httpPath: 0o660, fcgiPath: 0o600} { info, err := os.Stat(path) require.NoError(t, err) + require.NotZero(t, info.Mode()&os.ModeSocket) require.Equal(t, mode, info.Mode().Perm()) + stat := info.Sys().(*syscall.Stat_t) + require.EqualValues(t, uid, stat.Uid) + require.EqualValues(t, gid, stat.Gid) } stop() for _, path := range []string{httpPath, fcgiPath} { @@ -279,3 +206,67 @@ http: }) } } + +func TestUnixSocketOwnershipError(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("Requires an unprivileged process.") + } + groups, err := os.Getgroups() + require.NoError(t, err) + otherGID := 0 + for otherGID == os.Getegid() || slices.Contains(groups, otherGID) { + otherGID++ + } + dir, err := os.MkdirTemp("", "rr-http-") + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, os.RemoveAll(dir)) }) + for _, protocol := range []string{"http", "fcgi"} { + for _, tt := range []struct { + field string + id int + }{ + {field: "uid", id: 0}, + {field: "gid", id: otherGID}, + } { + t.Run(protocol+"/"+tt.field, func(t *testing.T) { + t.Setenv("RR_HTTP_TEST_SOCKET_ID", strconv.Itoa(tt.id)) + path := filepath.Join(dir, "ownership.sock") + yaml := `version: "3" +server: + command: "php php_test_files/http/client.php echo pipes" + relay: pipes +http: + pool: {num_workers: 1, allocate_timeout: 5s, destroy_timeout: 1s} +` + indent := " " + if protocol == "fcgi" { + yaml += " fcgi:\n" + indent = " " + } + yaml += fmt.Sprintf("%saddress: %q\n%sunix_socket: {%s: \"${RR_HTTP_TEST_SOCKET_ID}\"}\n", indent, "unix://"+path, indent, tt.field) + configPath := filepath.Join(dir, ".rr.yaml") + require.NoError(t, os.WriteFile(configPath, []byte(yaml), 0o600)) + provider := &rrconfig.Plugin{Path: configPath} + cont := endure.New(slog.LevelError) + logger, _ := mocklogger.SlogTestLogger(slog.LevelError) + require.NoError(t, cont.RegisterAll(provider, logger, &server.Plugin{}, &httpPlugin.Plugin{})) + require.NoError(t, cont.Init()) + errCh, err := cont.Serve() + require.NoError(t, err) + stop := sync.OnceValue(cont.Stop) + t.Cleanup(func() { require.NoError(t, stop()) }) + select { + case result := <-errCh: + require.NotNil(t, result) + require.ErrorContains(t, result.Error, "chown unix socket") + require.ErrorContains(t, result.Error, path) + case <-time.After(5 * time.Second): + t.Fatal("No socket ownership error.") + } + _, err = os.Stat(path) + require.ErrorIs(t, err, os.ErrNotExist) + require.NoError(t, stop()) + }) + } + } +} From f23a0fcb178bd136c21dbbed7554691be6b455d9 Mon Sep 17 00:00:00 2001 From: Valery Piashchynski Date: Mon, 7 Sep 2026 12:57:53 +0200 Subject: [PATCH 3/8] refactor: simplify FastCGI shutdown error handling --- servers/fcgi/fcgi.go | 5 +---- servers/fcgi/fcgi_test.go | 23 ----------------------- 2 files changed, 1 insertion(+), 27 deletions(-) diff --git a/servers/fcgi/fcgi.go b/servers/fcgi/fcgi.go index fd66830d..049a76ec 100644 --- a/servers/fcgi/fcgi.go +++ b/servers/fcgi/fcgi.go @@ -63,10 +63,7 @@ func (s *Server) Serve(mdwr map[string]api.Middleware, order []string) error { defer s.Stop() err = fcgi.Serve(l, s.fcgi.Handler) - s.mu.Lock() - stopped := s.stopped - s.mu.Unlock() - if err != nil && (!stopped || !stderr.Is(err, net.ErrClosed)) { + if err != nil && !stderr.Is(err, net.ErrClosed) { return errors.E(op, err) } diff --git a/servers/fcgi/fcgi_test.go b/servers/fcgi/fcgi_test.go index bffcea6a..f9fa1a8e 100644 --- a/servers/fcgi/fcgi_test.go +++ b/servers/fcgi/fcgi_test.go @@ -127,26 +127,3 @@ func TestStop_ConcurrentServe(t *testing.T) { } } } - -func TestServe_UnexpectedListenerClose(t *testing.T) { - srv := testServer(http.NotFoundHandler()) - srv.cfg.Address = "127.0.0.1:0" - t.Cleanup(srv.Stop) - done := make(chan error, 1) - go func() { done <- srv.Serve(nil, nil) }() - var listener net.Listener - require.Eventually(t, func() bool { - srv.mu.Lock() - defer srv.mu.Unlock() - listener = srv.listener - return listener != nil - }, 5*time.Second, 10*time.Millisecond) - require.NoError(t, listener.Close()) - select { - case err := <-done: - require.ErrorContains(t, err, "serve_fcgi") - require.ErrorContains(t, err, net.ErrClosed.Error()) - case <-time.After(5 * time.Second): - t.Fatal("Serve did not return the listener error") - } -} From e7db748dd014ec27922c0ebcd89afe0aefe849b4 Mon Sep 17 00:00:00 2001 From: Valery Piashchynski Date: Mon, 7 Sep 2026 13:47:00 +0200 Subject: [PATCH 4/8] refactor: remove FastCGI startup-after-stop guard --- servers/fcgi/fcgi.go | 13 +------------ servers/fcgi/fcgi_test.go | 38 ------------------------------------- servers/unix_socket_test.go | 17 ++++++++++++----- 3 files changed, 13 insertions(+), 55 deletions(-) diff --git a/servers/fcgi/fcgi.go b/servers/fcgi/fcgi.go index 049a76ec..57bb06e8 100644 --- a/servers/fcgi/fcgi.go +++ b/servers/fcgi/fcgi.go @@ -25,7 +25,6 @@ type Server struct { mu sync.Mutex listener net.Listener - stopped bool } func NewFCGIServer(handler http.Handler, cfg *FCGI, log *slog.Logger, errLog *log.Logger) servers.InternalServer[any] { @@ -47,17 +46,11 @@ func (s *Server) Serve(mdwr map[string]api.Middleware, order []string) error { applyMiddleware(s.fcgi, mdwr, order, s.log) } - // Hold the lock through bind so Stop cannot miss a new listener. - s.mu.Lock() - if s.stopped { - s.mu.Unlock() - return nil - } l, err := tcplisten.CreateListenerWithOptions(s.cfg.Address, s.cfg.UnixSocket) if err != nil { - s.mu.Unlock() return errors.E(op, err) } + s.mu.Lock() s.listener = l s.mu.Unlock() defer s.Stop() @@ -77,10 +70,6 @@ func (s *Server) Server() any { func (s *Server) Stop() { s.mu.Lock() defer s.mu.Unlock() - if s.stopped { - return - } - s.stopped = true if s.listener != nil { if err := s.listener.Close(); err != nil && !stderr.Is(err, net.ErrClosed) { s.log.Error("fcgi shutdown", "error", err) diff --git a/servers/fcgi/fcgi_test.go b/servers/fcgi/fcgi_test.go index f9fa1a8e..8ec10ff8 100644 --- a/servers/fcgi/fcgi_test.go +++ b/servers/fcgi/fcgi_test.go @@ -4,16 +4,12 @@ import ( "io" "log" "log/slog" - "net" "net/http" "net/http/httptest" "strings" - "sync" "testing" - "time" "github.com/roadrunner-server/http/v6/api" - "github.com/stretchr/testify/require" ) // recordingMiddleware appends its name to trace when the wrapped chain runs. @@ -92,38 +88,4 @@ func TestStop_IsIdempotent(t *testing.T) { srv.Stop() srv.Stop() - require.NoError(t, srv.Serve(nil, nil), "Stop before Serve must prevent bind") -} - -func TestStop_ConcurrentServe(t *testing.T) { - for range 30 { - srv := testServer(http.NotFoundHandler()) - srv.cfg.Address = "127.0.0.1:0" - t.Cleanup(srv.Stop) - start := make(chan struct{}) - done := make(chan error, 1) - go func() { - <-start - done <- srv.Serve(nil, nil) - }() - var stops sync.WaitGroup - for range 2 { - stops.Go(func() { - <-start - srv.Stop() - }) - } - close(start) - select { - case err := <-done: - require.NoError(t, err) - case <-time.After(5 * time.Second): - t.Fatal("Serve did not stop") - } - stops.Wait() - if srv.listener != nil { - _, err := srv.listener.Accept() - require.ErrorIs(t, err, net.ErrClosed) - } - } } diff --git a/servers/unix_socket_test.go b/servers/unix_socket_test.go index d6c8e752..a2e9e5ec 100644 --- a/servers/unix_socket_test.go +++ b/servers/unix_socket_test.go @@ -102,11 +102,18 @@ func TestServeUnixSocket(t *testing.T) { t.Cleanup(client.CloseIdleConnections) req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "http://localhost/", nil) require.NoError(t, err) - resp, err := client.Do(req) - require.NoError(t, err) - defer func() { _ = resp.Body.Close() }() - require.Equal(t, http.StatusNotFound, resp.StatusCode) - require.Equal(t, major, resp.ProtoMajor) + var status, protoMajor int + require.Eventually(t, func() bool { + resp, errR := client.Do(req) + if errR != nil { + return false + } + _ = resp.Body.Close() + status, protoMajor = resp.StatusCode, resp.ProtoMajor + return true + }, 5*time.Second, 10*time.Millisecond, "HTTP server did not accept a request") + require.Equal(t, http.StatusNotFound, status) + require.Equal(t, major, protoMajor) }) } } From a92c77660a07d63728c116d09d0b734b77886a0e Mon Sep 17 00:00:00 2001 From: Valery Piashchynski Date: Mon, 7 Sep 2026 15:14:55 +0200 Subject: [PATCH 5/8] chore(deps): update workspace checksums --- go.work.sum | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/go.work.sum b/go.work.sum index de177b9f..4c1a7320 100644 --- a/go.work.sum +++ b/go.work.sum @@ -397,6 +397,7 @@ cloud.google.com/go/bigquery v1.76.0/go.mod h1:J4wuqka/1hEpdJxH2oBrUR0vjTD+r7drG cloud.google.com/go/bigquery v1.77.0 h1:L5AW3jhzEKpFVg4i0mVHxKpxogrqT7dczWBSr4m9MKU= cloud.google.com/go/bigquery v1.77.0/go.mod h1:J4wuqka/1hEpdJxH2oBrUR0vjTD+r7drGkpcA3yqERM= cloud.google.com/go/bigquery v1.80.0/go.mod h1:cc0XscySNQNuHBxuZSg5yyxFsg/ZHAfViAG49gJbWew= +cloud.google.com/go/bigquery v1.82.0/go.mod h1:cc0XscySNQNuHBxuZSg5yyxFsg/ZHAfViAG49gJbWew= cloud.google.com/go/bigtable v1.35.0 h1:UEacPwaejN2mNbz67i1Iy3G812rxtgcs6ePj1TAg7dw= cloud.google.com/go/bigtable v1.35.0/go.mod h1:EabtwwmTcOJFXp+oMZAT/jZkyDIjNwrv53TrS4DGrrM= cloud.google.com/go/bigtable v1.37.0/go.mod h1:HXqddP6hduwzrtiTCqZPpj9ij4hGZb4Zy1WF/dT+yaU= @@ -624,6 +625,7 @@ cloud.google.com/go/container v1.46.0/go.mod h1:A7gMqdQduTk46+zssWDTKbGS2z46UsJN cloud.google.com/go/container v1.49.0 h1:K4nmtmJezHOzsIyedAOv1Ok36krw1apFmo4zXBaRL1A= cloud.google.com/go/container v1.49.0/go.mod h1:EvqoT2eXfxLweXXUlhAMGR0sOAB00XPzEjoL01esSDs= cloud.google.com/go/container v1.53.1/go.mod h1:/ZI9J3uuAQh0O3/n9qxkdTVCDB6kuwxEpC9MvEWnf/Q= +cloud.google.com/go/container v1.54.0/go.mod h1:/ZI9J3uuAQh0O3/n9qxkdTVCDB6kuwxEpC9MvEWnf/Q= cloud.google.com/go/containeranalysis v0.11.0 h1:/EsoP+UTIjvl4yqrLA4WgUG83kwQhqZmbXEfqirT2LM= cloud.google.com/go/containeranalysis v0.11.0/go.mod h1:4n2e99ZwpGxpNcz+YsFT1dfOHPQFGcAC8FN2M2/ne/U= cloud.google.com/go/containeranalysis v0.11.1 h1:PHh4KTcMpCjYgxfV+TzvP24wolTGP9lGbqh9sBNHxjs= @@ -1958,6 +1960,7 @@ cloud.google.com/go/spanner v1.88.0/go.mod h1:MzulBwuuYwQUVdkZXBBFapmXee3N+sQrj2 cloud.google.com/go/spanner v1.91.0 h1:XwXfcZ0kc1NT9Uu2IsThFiWtYptB+WgLn/KZEZcyzRg= cloud.google.com/go/spanner v1.91.0/go.mod h1:8NB5a7qgwIhGD19Ly+vkpKffPL78vIG9RcrgsuREha0= cloud.google.com/go/spanner v1.94.0/go.mod h1:Z2+83J5oVDmd1n5ntVMmjEuiNoXOpAyNeG7y1tuEHk0= +cloud.google.com/go/spanner v1.95.0/go.mod h1:Z2+83J5oVDmd1n5ntVMmjEuiNoXOpAyNeG7y1tuEHk0= cloud.google.com/go/speech v1.19.0 h1:MCagaq8ObV2tr1kZJcJYgXYbIn8Ai5rp42tyGYw9rls= cloud.google.com/go/speech v1.19.1 h1:z035FMLs98jpnqcP5xZZ6Es+g6utbeVoUH64BaTzTSU= cloud.google.com/go/speech v1.19.1/go.mod h1:WcuaWz/3hOlzPFOVo9DUsblMIHwxP589y6ZMtaG+iAA= @@ -3869,6 +3872,7 @@ google.golang.org/genproto/googleapis/api v0.0.0-20260504160031-60b97b32f348/go. google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60 h1:3WsB1FAbiRIf2tOxscWKs3pQBD9he1NsrnbhMuWfekc= google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60/go.mod h1:7yoXV7RIh5gblj/xVYoogxAWvA9wUeVbpsK/M694l00= google.golang.org/genproto/googleapis/api v0.0.0-20260818201246-1b0934165a6f/go.mod h1:q/3oV3jAi5vwelxsVAprMBC8BcM2zmNe+IjRGd+9/ks= +google.golang.org/genproto/googleapis/api v0.0.0-20260825221802-da73d73af1c5/go.mod h1:3LhxRw4YYkf+ylAfgaY9JlVLFKhokkCV8duhLLe7+t0= google.golang.org/genproto/googleapis/bytestream v0.0.0-20230530153820-e85fd2cbaebc h1:g3hIDl0jRNd9PPTs2uBzYuaD5mQuwOkZY0vSc0LR32o= google.golang.org/genproto/googleapis/bytestream v0.0.0-20240125205218-1f4bbc51befe h1:weYsP+dNijSQVoLAb5bpUos3ciBpNU/NEVlHFKrk8pg= google.golang.org/genproto/googleapis/bytestream v0.0.0-20240125205218-1f4bbc51befe/go.mod h1:SCz6T5xjNXM4QFPRwxHcfChp7V+9DcXR3ay2TkHR8Tg= @@ -3903,6 +3907,7 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529/go. google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/genproto/googleapis/rpc v0.0.0-20260504160031-60b97b32f348/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/genproto/googleapis/rpc v0.0.0-20260818201246-1b0934165a6f/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA= google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= google.golang.org/grpc v1.16.0/go.mod h1:0JHn/cJsOMiMfNA9+DeHDlAU7KAAB5GDlYFpa9MZMio= google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= @@ -3936,6 +3941,7 @@ google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0 h1:M1YKkFIboKNieVO5DLUEVzQfGwJD30Nv2jfUgzb5UcE= google.golang.org/grpc/examples v0.0.0-20250407062114-b368379ef8f6 h1:ExN12ndbJ608cboPYflpTny6mXSzPrDLh0iTaVrRrds= google.golang.org/grpc/examples v0.0.0-20250407062114-b368379ef8f6/go.mod h1:6ytKWczdvnpnO+m+JiG9NjEDzR1FJfsnmJdG7B8QVZ8= From 20843faca268c4478cc008404a2281fde50b596d Mon Sep 17 00:00:00 2001 From: Valery Piashchynski Date: Mon, 7 Sep 2026 15:50:00 +0200 Subject: [PATCH 6/8] refactor: remove FastCGI listener mutex --- servers/fcgi/fcgi.go | 6 -- servers/unix_socket_test.go | 120 ------------------------------------ tests/unix_socket_test.go | 37 ----------- 3 files changed, 163 deletions(-) delete mode 100644 servers/unix_socket_test.go diff --git a/servers/fcgi/fcgi.go b/servers/fcgi/fcgi.go index 57bb06e8..2220a6e7 100644 --- a/servers/fcgi/fcgi.go +++ b/servers/fcgi/fcgi.go @@ -8,7 +8,6 @@ import ( "net/http" "net/http/fcgi" "slices" - "sync" "time" "github.com/roadrunner-server/http/v6/api" @@ -23,7 +22,6 @@ type Server struct { log *slog.Logger fcgi *http.Server - mu sync.Mutex listener net.Listener } @@ -50,9 +48,7 @@ func (s *Server) Serve(mdwr map[string]api.Middleware, order []string) error { if err != nil { return errors.E(op, err) } - s.mu.Lock() s.listener = l - s.mu.Unlock() defer s.Stop() err = fcgi.Serve(l, s.fcgi.Handler) @@ -68,8 +64,6 @@ func (s *Server) Server() any { } func (s *Server) Stop() { - s.mu.Lock() - defer s.mu.Unlock() if s.listener != nil { if err := s.listener.Close(); err != nil && !stderr.Is(err, net.ErrClosed) { s.log.Error("fcgi shutdown", "error", err) diff --git a/servers/unix_socket_test.go b/servers/unix_socket_test.go deleted file mode 100644 index a2e9e5ec..00000000 --- a/servers/unix_socket_test.go +++ /dev/null @@ -1,120 +0,0 @@ -//go:build linux || darwin || freebsd - -package servers_test - -import ( - "context" - "crypto/tls" - "io" - "log" - "log/slog" - "net" - "net/http" - "os" - "path/filepath" - "syscall" - "testing" - "time" - - "github.com/roadrunner-server/http/v6/config" - "github.com/roadrunner-server/http/v6/servers" - "github.com/roadrunner-server/http/v6/servers/fcgi" - httpServer "github.com/roadrunner-server/http/v6/servers/http11" - "github.com/roadrunner-server/http/v6/servers/https" - "github.com/roadrunner-server/tcplisten" - "github.com/stretchr/testify/require" - "golang.org/x/net/http2" -) - -func TestServeUnixSocket(t *testing.T) { - // A short path also fits the macOS UNIX socket address limit. - dir, err := os.MkdirTemp("", "rr-http-") - require.NoError(t, err) - t.Cleanup(func() { require.NoError(t, os.RemoveAll(dir)) }) - for _, protocol := range []string{"http1", "h2c", "fcgi"} { - for _, attributes := range []string{"default", "explicit"} { - t.Run(protocol+"/"+attributes, func(t *testing.T) { - path := filepath.Join(dir, "http.sock") - uid, gid := os.Geteuid(), os.Getegid() - var options *tcplisten.UnixSocketOptions - if attributes == "explicit" { - if uid == 0 { - uid, gid = 1, 1 - } - options = &tcplisten.UnixSocketOptions{Mode: "0660", UID: &uid, GID: &gid} - } - cfg := &config.Config{ - Address: "unix://" + path, UnixSocket: options, - HTTP2Config: &https.HTTP2{H2C: protocol == "h2c"}, - UID: 123, GID: 456, - } - require.NoError(t, cfg.InitDefaults()) - logger, errLog := slog.New(slog.DiscardHandler), log.New(io.Discard, "", 0) - var srv servers.InternalServer[any] - if protocol == "fcgi" { - srv = fcgi.NewFCGIServer(http.NotFoundHandler(), &fcgi.FCGI{Address: cfg.Address, UnixSocket: options}, logger, errLog) - } else { - srv = httpServer.NewHTTPServer(http.NotFoundHandler(), cfg, errLog, logger) - } - done := make(chan error, 1) - go func() { done <- srv.Serve(nil, nil) }() - t.Cleanup(func() { - srv.Stop() - srv.Stop() - select { - case err := <-done: - require.NoError(t, err) - case <-time.After(5 * time.Second): - t.Fatal("Serve did not stop") - } - _, err := os.Stat(path) - require.ErrorIs(t, err, os.ErrNotExist) - }) - require.Eventually(t, func() bool { - info, err := os.Stat(path) - if err != nil { - return false - } - stat := info.Sys().(*syscall.Stat_t) - return info.Mode()&os.ModeSocket != 0 && int(stat.Uid) == uid && int(stat.Gid) == gid && - (options == nil || info.Mode().Perm() == 0o660) - }, 5*time.Second, 10*time.Millisecond) - dial := func(ctx context.Context, _, _ string) (net.Conn, error) { - return new(net.Dialer).DialContext(ctx, "unix", path) - } - if protocol == "fcgi" { - conn, err := dial(t.Context(), "", "") - require.NoError(t, err) - require.NoError(t, conn.Close()) - return - } - client := &http.Client{Timeout: 5 * time.Second, Transport: &http.Transport{DialContext: dial}} - major := 1 - if protocol == "h2c" { - major = 2 - client.Transport = &http2.Transport{ - AllowHTTP: true, - DialTLSContext: func(ctx context.Context, network, addr string, _ *tls.Config) (net.Conn, error) { - return dial(ctx, network, addr) - }, - } - } - t.Cleanup(client.CloseIdleConnections) - req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "http://localhost/", nil) - require.NoError(t, err) - var status, protoMajor int - require.Eventually(t, func() bool { - resp, errR := client.Do(req) - if errR != nil { - return false - } - _ = resp.Body.Close() - status, protoMajor = resp.StatusCode, resp.ProtoMajor - return true - }, 5*time.Second, 10*time.Millisecond, "HTTP server did not accept a request") - require.Equal(t, http.StatusNotFound, status) - require.Equal(t, major, protoMajor) - }) - } - } -} diff --git a/tests/unix_socket_test.go b/tests/unix_socket_test.go index d7f036c5..e40eb30a 100644 --- a/tests/unix_socket_test.go +++ b/tests/unix_socket_test.go @@ -6,8 +6,6 @@ import ( "context" "crypto/tls" "fmt" - "io" - "log" "log/slog" "net" "net/http" @@ -27,9 +25,7 @@ import ( rrconfig "github.com/roadrunner-server/config/v6" "github.com/roadrunner-server/endure/v2" httpPlugin "github.com/roadrunner-server/http/v6" - "github.com/roadrunner-server/http/v6/servers/fcgi" "github.com/roadrunner-server/server/v6" - "github.com/roadrunner-server/tcplisten" "github.com/stretchr/testify/require" "golang.org/x/net/http2" ) @@ -95,39 +91,6 @@ http: } } -func TestUnixSocketFCGIRequest(t *testing.T) { - dir, err := os.MkdirTemp("", "rr-fcgi-") - require.NoError(t, err) - t.Cleanup(func() { require.NoError(t, os.RemoveAll(dir)) }) - path := filepath.Join(dir, "fcgi.sock") - cfg := &fcgi.FCGI{Address: "unix://" + path, UnixSocket: &tcplisten.UnixSocketOptions{Mode: "0600"}} - require.NoError(t, cfg.Valid()) - srv := fcgi.NewFCGIServer(http.NotFoundHandler(), cfg, slog.New(slog.DiscardHandler), log.New(io.Discard, "", 0)) - done := make(chan error, 1) - go func() { done <- srv.Serve(nil, nil) }() - t.Cleanup(func() { - srv.Stop() - select { - case err := <-done: - require.NoError(t, err) - case <-time.After(5 * time.Second): - t.Fatal("FCGI Serve did not stop") - } - _, err := os.Stat(path) - require.ErrorIs(t, err, os.ErrNotExist) - }) - code, body := fcgiGet(t, "unix", path, "http://localhost/") - require.Equal(t, http.StatusNotFound, code) - require.Equal(t, "404 page not found\n", body) - info, err := os.Stat(path) - require.NoError(t, err) - require.NotZero(t, info.Mode()&os.ModeSocket) - require.Equal(t, os.FileMode(0o600), info.Mode().Perm()) - stat := info.Sys().(*syscall.Stat_t) - require.EqualValues(t, os.Geteuid(), stat.Uid) - require.EqualValues(t, os.Getegid(), stat.Gid) -} - func TestUnixSocketPluginServe(t *testing.T) { dir, err := os.MkdirTemp("", "rr-http-") require.NoError(t, err) From 0ea4cdeefacc3505f3e34be03919925cfaaa15c3 Mon Sep 17 00:00:00 2001 From: Valery Piashchynski Date: Mon, 7 Sep 2026 16:10:01 +0200 Subject: [PATCH 7/8] chore: update error op --- config/config.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/config.go b/config/config.go index 4eae030e..a921a457 100644 --- a/config/config.go +++ b/config/config.go @@ -129,7 +129,7 @@ func (c *Config) InitDefaults() error { func (c *Config) Valid() error { const op = errors.Op("validation") if err := c.UnixSocket.Validate(c.Address); err != nil { - return errors.E(errors.Op("http.unix_socket"), err) + return errors.E(op, err) } if c.FCGIConfig != nil { if err := c.FCGIConfig.Valid(); err != nil { From e5a3b4d8e856b07a73c67ad919e5f21577d198e7 Mon Sep 17 00:00:00 2001 From: Valery Piashchynski Date: Mon, 7 Sep 2026 16:23:17 +0200 Subject: [PATCH 8/8] test: update UNIX socket validation error assertions --- config/unix_socket_test.go | 6 +++++- tests/unix_socket_test.go | 3 +-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/config/unix_socket_test.go b/config/unix_socket_test.go index 6a5f692c..d240b333 100644 --- a/config/unix_socket_test.go +++ b/config/unix_socket_test.go @@ -12,6 +12,10 @@ import ( func TestUnixSocketValidation(t *testing.T) { for _, field := range []string{"http.unix_socket", "http.fcgi.unix_socket"} { + op := "validation" + if field == "http.fcgi.unix_socket" { + op = field + } for _, tt := range []struct { name, address string options *tcplisten.UnixSocketOptions @@ -47,7 +51,7 @@ func TestUnixSocketValidation(t *testing.T) { } err := cfg.InitDefaults() if wantErr != "" { - require.ErrorContains(t, err, field) + require.ErrorContains(t, err, op) require.ErrorContains(t, err, wantErr) } else { require.NoError(t, err) diff --git a/tests/unix_socket_test.go b/tests/unix_socket_test.go index e40eb30a..18b14a3d 100644 --- a/tests/unix_socket_test.go +++ b/tests/unix_socket_test.go @@ -13,7 +13,6 @@ import ( "path/filepath" "slices" "strconv" - "strings" "sync" "syscall" "testing" @@ -81,7 +80,7 @@ http: logger := mocklogger.NewLogger(slog.New(slog.DiscardHandler)) err := new(httpPlugin.Plugin).Init(provider, logger, new(server.Plugin)) if tt.wantErr != "" { - require.ErrorContains(t, err, strings.TrimPrefix(key, "http.")) + require.ErrorContains(t, err, "http_plugin_init") require.ErrorContains(t, err, tt.wantErr) return }