diff --git a/config/config.go b/config/config.go index 49771950..a921a457 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(op, 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..d240b333 --- /dev/null +++ b/config/unix_socket_test.go @@ -0,0 +1,84 @@ +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) { + 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 + wantErr string + }{ + {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"}} + if field == "http.unix_socket" { + cfg.Address, cfg.UnixSocket = tt.address, tt.options + } 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 wantErr != "" { + require.ErrorContains(t, err, op) + require.ErrorContains(t, err, wantErr) + } else { + require.NoError(t, err) + } + }) + } + } +} + +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) { + 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/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= diff --git a/plugin_test.go b/plugin_test.go index 9aa5f641..cf57b1fd 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 +} + func (c *stubConfigurer) Experimental() bool { return c.experimental } func (c *stubConfigurer) UnmarshalKey(name string, out any) error { 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..2220a6e7 100644 --- a/servers/fcgi/fcgi.go +++ b/servers/fcgi/fcgi.go @@ -4,6 +4,7 @@ import ( stderr "errors" "log" "log/slog" + "net" "net/http" "net/http/fcgi" "slices" @@ -20,6 +21,8 @@ type Server struct { cfg *FCGI log *slog.Logger fcgi *http.Server + + listener net.Listener } func NewFCGIServer(handler http.Handler, cfg *FCGI, log *slog.Logger, errLog *log.Logger) servers.InternalServer[any] { @@ -41,13 +44,15 @@ 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) + l, err := tcplisten.CreateListenerWithOptions(s.cfg.Address, s.cfg.UnixSocket) if err != nil { return errors.E(op, err) } + s.listener = l + defer s.Stop() err = fcgi.Serve(l, s.fcgi.Handler) - if err != nil && !stderr.Is(err, http.ErrServerClosed) { + if err != nil && !stderr.Is(err, net.ErrClosed) { return errors.E(op, err) } @@ -59,9 +64,10 @@ 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) + 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/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/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..18b14a3d --- /dev/null +++ b/tests/unix_socket_test.go @@ -0,0 +1,234 @@ +//go:build linux || darwin || freebsd + +package tests + +import ( + "context" + "crypto/tls" + "fmt" + "log/slog" + "net" + "net/http" + "os" + "path/filepath" + "slices" + "strconv" + "sync" + "syscall" + "testing" + "time" + + "tests/helpers" + 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/server/v6" + "github.com/stretchr/testify/require" + "golang.org/x/net/http2" +) + +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 tt.options != "" { + yaml += indent + "unix_socket: " + tt.options + "\n" + } + 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, "http_plugin_init") + require.ErrorContains(t, err, tt.wantErr) + return + } + require.NoError(t, err) + }) + } + } +} + +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") + 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: "${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", 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) + } + 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.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} { + _, err := os.Stat(path) + require.ErrorIs(t, err, os.ErrNotExist) + } + }) + } +} + +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()) + }) + } + } +}