diff options
Diffstat (limited to 'service/http')
-rw-r--r-- | service/http/config.go | 51 | ||||
-rw-r--r-- | service/http/config_test.go | 80 | ||||
-rw-r--r-- | service/http/fixtures/server.crt | 15 | ||||
-rw-r--r-- | service/http/fixtures/server.key | 9 | ||||
-rw-r--r-- | service/http/handler.go | 2 | ||||
-rw-r--r-- | service/http/response.go | 14 | ||||
-rw-r--r-- | service/http/rpc.go | 4 | ||||
-rw-r--r-- | service/http/service.go | 91 | ||||
-rw-r--r-- | service/http/ssl_test.go | 215 |
9 files changed, 453 insertions, 28 deletions
diff --git a/service/http/config.go b/service/http/config.go index b11d807c..14738d2e 100644 --- a/service/http/config.go +++ b/service/http/config.go @@ -2,16 +2,21 @@ package http import ( "errors" + "fmt" "github.com/spiral/roadrunner" "github.com/spiral/roadrunner/service" + "os" "strings" ) // Config configures RoadRunner HTTP server. type Config struct { - // Address and port to handle as http server. + // Port and port to handle as http server. Address string + // SSL defines https server options. + SSL SSLConfig + // MaxRequest specified max size for payload body in megabytes, set 0 to unlimited. MaxRequest int64 @@ -22,6 +27,26 @@ type Config struct { Workers *roadrunner.ServerConfig } +// SSLConfig defines https server configuration. +type SSLConfig struct { + // Port to listen as HTTPS server, defaults to 443. + Port int + + // Redirect when enabled forces all http connections to switch to https. + Redirect bool + + // Key defined private server key. + Key string + + // Cert is https certificate. + Cert string +} + +// EnableTLS returns true if rr must listen TLS connections. +func (c *Config) EnableTLS() bool { + return c.SSL.Key != "" || c.SSL.Cert != "" +} + // Hydrate must populate Config values using given Config source. Must return error if Config is not valid. func (c *Config) Hydrate(cfg service.Config) error { if c.Workers == nil { @@ -32,6 +57,10 @@ func (c *Config) Hydrate(cfg service.Config) error { c.Uploads = &UploadsConfig{} } + if c.SSL.Port == 0 { + c.SSL.Port = 443 + } + c.Uploads.InitDefaults() c.Workers.InitDefaults() @@ -67,7 +96,25 @@ func (c *Config) Valid() error { } if !strings.Contains(c.Address, ":") { - return errors.New("mailformed server address") + return errors.New("mailformed http server address") + } + + if c.EnableTLS() { + if _, err := os.Stat(c.SSL.Key); err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("key file '%s' does not exists", c.SSL.Key) + } + + return err + } + + if _, err := os.Stat(c.SSL.Cert); err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("cert file '%s' does not exists", c.SSL.Cert) + } + + return err + } } return nil diff --git a/service/http/config_test.go b/service/http/config_test.go index 823efb32..07901cb6 100644 --- a/service/http/config_test.go +++ b/service/http/config_test.go @@ -51,6 +51,86 @@ func Test_Config_Valid(t *testing.T) { assert.NoError(t, cfg.Valid()) } +func Test_Config_Valid_SSL(t *testing.T) { + cfg := &Config{ + Address: ":8080", + SSL: SSLConfig{ + Cert: "fixtures/server.crt", + Key: "fixtures/server.key", + }, + MaxRequest: 1024, + Uploads: &UploadsConfig{ + Dir: os.TempDir(), + Forbid: []string{".go"}, + }, + Workers: &roadrunner.ServerConfig{ + Command: "php tests/client.php echo pipes", + Relay: "pipes", + Pool: &roadrunner.Config{ + NumWorkers: 1, + AllocateTimeout: time.Second, + DestroyTimeout: time.Second, + }, + }, + } + + assert.Error(t, cfg.Hydrate(&testCfg{httpCfg: "{}"})) + + assert.NoError(t, cfg.Valid()) + assert.True(t, cfg.EnableTLS()) + assert.Equal(t, 443, cfg.SSL.Port) +} + +func Test_Config_SSL_No_key(t *testing.T) { + cfg := &Config{ + Address: ":8080", + SSL: SSLConfig{ + Cert: "fixtures/server.crt", + }, + MaxRequest: 1024, + Uploads: &UploadsConfig{ + Dir: os.TempDir(), + Forbid: []string{".go"}, + }, + Workers: &roadrunner.ServerConfig{ + Command: "php tests/client.php echo pipes", + Relay: "pipes", + Pool: &roadrunner.Config{ + NumWorkers: 1, + AllocateTimeout: time.Second, + DestroyTimeout: time.Second, + }, + }, + } + + assert.Error(t, cfg.Valid()) +} + +func Test_Config_SSL_No_Cert(t *testing.T) { + cfg := &Config{ + Address: ":8080", + SSL: SSLConfig{ + Key: "fixtures/server.key", + }, + MaxRequest: 1024, + Uploads: &UploadsConfig{ + Dir: os.TempDir(), + Forbid: []string{".go"}, + }, + Workers: &roadrunner.ServerConfig{ + Command: "php tests/client.php echo pipes", + Relay: "pipes", + Pool: &roadrunner.Config{ + NumWorkers: 1, + AllocateTimeout: time.Second, + DestroyTimeout: time.Second, + }, + }, + } + + assert.Error(t, cfg.Valid()) +} + func Test_Config_NoUploads(t *testing.T) { cfg := &Config{ Address: ":8080", diff --git a/service/http/fixtures/server.crt b/service/http/fixtures/server.crt new file mode 100644 index 00000000..24d67fd7 --- /dev/null +++ b/service/http/fixtures/server.crt @@ -0,0 +1,15 @@ +-----BEGIN CERTIFICATE----- +MIICTTCCAdOgAwIBAgIJAOKyUd+llTRKMAoGCCqGSM49BAMCMGMxCzAJBgNVBAYT +AlVTMRMwEQYDVQQIDApDYWxpZm9ybmlhMRYwFAYDVQQHDA1TYW4gRnJhbmNpc2Nv +MRMwEQYDVQQKDApSb2FkUnVubmVyMRIwEAYDVQQDDAlsb2NhbGhvc3QwHhcNMTgw +OTMwMTMzNDUzWhcNMjgwOTI3MTMzNDUzWjBjMQswCQYDVQQGEwJVUzETMBEGA1UE +CAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5jaXNjbzETMBEGA1UECgwK +Um9hZFJ1bm5lcjESMBAGA1UEAwwJbG9jYWxob3N0MHYwEAYHKoZIzj0CAQYFK4EE +ACIDYgAEVnbShsM+l5RR3wfWWmGhzuFGwNzKCk7i9xyobDIyBUxG/UUSfj7KKlUX +puDnDEtF5xXcepl744CyIAYFLOXHb5WqI4jCOzG0o9f/00QQ4bQudJOdbqV910QF +C2vb7Fxro1MwUTAdBgNVHQ4EFgQU9xUexnbB6ORKayA7Pfjzs33otsAwHwYDVR0j +BBgwFoAU9xUexnbB6ORKayA7Pfjzs33otsAwDwYDVR0TAQH/BAUwAwEB/zAKBggq +hkjOPQQDAgNoADBlAjEAue3HhR/MUhxoa9tSDBtOJT3FYbDQswrsdqBTz97CGKst +e7XeZ3HMEvEXy0hGGEMhAjAqcD/4k9vViVppgWFtkk6+NFbm+Kw/QeeAiH5FgFSj +8xQcb+b7nPwNLp3JOkXkVd4= +-----END CERTIFICATE----- diff --git a/service/http/fixtures/server.key b/service/http/fixtures/server.key new file mode 100644 index 00000000..7501dd46 --- /dev/null +++ b/service/http/fixtures/server.key @@ -0,0 +1,9 @@ +-----BEGIN EC PARAMETERS----- +BgUrgQQAIg== +-----END EC PARAMETERS----- +-----BEGIN EC PRIVATE KEY----- +MIGkAgEBBDCQP8utxNbHR6xZOLAJgUhn88r6IrPqmN0MsgGJM/jePB+T9UhkmIU8 +PMm2HeScbcugBwYFK4EEACKhZANiAARWdtKGwz6XlFHfB9ZaYaHO4UbA3MoKTuL3 +HKhsMjIFTEb9RRJ+PsoqVRem4OcMS0XnFdx6mXvjgLIgBgUs5cdvlaojiMI7MbSj +1//TRBDhtC50k51upX3XRAULa9vsXGs= +-----END EC PRIVATE KEY----- diff --git a/service/http/handler.go b/service/http/handler.go index f719c751..d7521959 100644 --- a/service/http/handler.go +++ b/service/http/handler.go @@ -110,7 +110,7 @@ func (h *Handler) handleResponse(req *Request, resp *Response) { h.throw(EventResponse, &ResponseEvent{Request: req, Response: resp}) } -// throw invokes event srv if any. +// throw invokes event handler if any. func (h *Handler) throw(event int, ctx interface{}) { h.mul.Lock() defer h.mul.Unlock() diff --git a/service/http/response.go b/service/http/response.go index d43f514d..eb8ce32b 100644 --- a/service/http/response.go +++ b/service/http/response.go @@ -31,9 +31,17 @@ func NewResponse(p *roadrunner.Payload) (*Response, error) { // Write writes response headers, status and body into ResponseWriter. func (r *Response) Write(w http.ResponseWriter) error { - for k, v := range r.Headers { - for _, h := range v { - w.Header().Add(k, h) + for n, h := range r.Headers { + for _, v := range h { + if n == "http2-push" { + if pusher, ok := w.(http.Pusher); ok { + pusher.Push(v, nil) + } + + continue + } + + w.Header().Add(n, v) } } diff --git a/service/http/rpc.go b/service/http/rpc.go index 08b3f262..3390a93d 100644 --- a/service/http/rpc.go +++ b/service/http/rpc.go @@ -15,7 +15,7 @@ type WorkerList struct { // Reset resets underlying RR worker pool and restarts all of it's workers. func (rpc *rpcServer) Reset(reset bool, r *string) error { - if rpc.svc == nil || rpc.svc.srv == nil { + if rpc.svc == nil || rpc.svc.handler == nil { return errors.New("http server is not running") } @@ -25,7 +25,7 @@ func (rpc *rpcServer) Reset(reset bool, r *string) error { // Workers returns list of active workers and their stats. func (rpc *rpcServer) Workers(list bool, r *WorkerList) (err error) { - if rpc.svc == nil || rpc.svc.srv == nil { + if rpc.svc == nil || rpc.svc.handler == nil { return errors.New("http server is not running") } diff --git a/service/http/service.go b/service/http/service.go index bb75a2c0..1f999b8b 100644 --- a/service/http/service.go +++ b/service/http/service.go @@ -2,11 +2,15 @@ package http import ( "context" + "fmt" "github.com/spiral/roadrunner" "github.com/spiral/roadrunner/service/env" "github.com/spiral/roadrunner/service/http/attributes" "github.com/spiral/roadrunner/service/rpc" + "golang.org/x/net/http2" "net/http" + "net/url" + "strings" "sync" "sync/atomic" ) @@ -15,8 +19,8 @@ const ( // ID contains default svc name. ID = "http" - // httpKey indicates to php process that it's running under http service - httpKey = "rr_http" + // EventInitSSL thrown at moment of https initialization. SSL server passed as context. + EventInitSSL = 750 ) // http middleware type. @@ -31,8 +35,9 @@ type Service struct { mu sync.Mutex rr *roadrunner.Server stopping int32 - srv *Handler + handler *Handler http *http.Server + https *http.Server } // AddMiddleware adds new net/http mdwr. @@ -71,28 +76,35 @@ func (s *Service) Serve() error { s.cfg.Workers.SetEnv(k, v) } - s.cfg.Workers.SetEnv(httpKey, "true") + s.cfg.Workers.SetEnv("RR_HTTP", "true") } - rr := roadrunner.NewServer(s.cfg.Workers) + s.rr = roadrunner.NewServer(s.cfg.Workers) + s.rr.Listen(s.throw) - s.rr = rr - s.srv = &Handler{cfg: s.cfg, rr: s.rr} - s.http = &http.Server{Addr: s.cfg.Address} + s.handler = &Handler{cfg: s.cfg, rr: s.rr} + s.handler.Listen(s.throw) - s.rr.Listen(s.listener) - s.srv.Listen(s.listener) + s.http = &http.Server{Addr: s.cfg.Address, Handler: s} - s.http.Handler = s + if s.cfg.EnableTLS() { + s.https = s.initSSL() + } s.mu.Unlock() - if err := rr.Start(); err != nil { + if err := s.rr.Start(); err != nil { return err } - defer rr.Stop() + defer s.rr.Stop() + + err := make(chan error, 2) + go func() { err <- s.http.ListenAndServe() }() + if s.https != nil { + go func() { err <- s.https.ListenAndServeTLS(s.cfg.SSL.Cert, s.cfg.SSL.Key) }() + } - return s.http.ListenAndServe() + return <-err } // Stop stops the svc. @@ -108,23 +120,50 @@ func (s *Service) Stop() { return } - s.http.Shutdown(context.Background()) + if s.https != nil { + go s.https.Shutdown(context.Background()) + } + + go s.http.Shutdown(context.Background()) } -// mdwr handles connection using set of mdwr and rr PSR-7 server. +// ServeHTTP handles connection using set of middleware and rr PSR-7 server. func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if s.https != nil && r.TLS == nil && s.cfg.SSL.Redirect { + target := &url.URL{ + Scheme: "https", + Host: s.tlsAddr(r.Host, false), + Path: r.URL.Path, + RawQuery: r.URL.RawQuery, + } + + http.Redirect(w, r, target.String(), http.StatusTemporaryRedirect) + return + } + r = attributes.Init(r) - // chaining mdwr - f := s.srv.ServeHTTP + // chaining middleware + f := s.handler.ServeHTTP for _, m := range s.mdwr { f = m(f) } f(w, r) } -// listener handles service, server and pool events. -func (s *Service) listener(event int, ctx interface{}) { +// Init https server. +func (s *Service) initSSL() *http.Server { + server := &http.Server{Addr: s.tlsAddr(s.cfg.Address, true), Handler: s} + s.throw(EventInitSSL, server) + + // Enable HTTP/2 support by default + http2.ConfigureServer(server, &http2.Server{}) + + return server +} + +// throw handles service, server and pool events. +func (s *Service) throw(event int, ctx interface{}) { for _, l := range s.lsns { l(event, ctx) } @@ -134,3 +173,15 @@ func (s *Service) listener(event int, ctx interface{}) { s.Stop() } } + +// tlsAddr replaces listen or host port with port configured by SSL config. +func (s *Service) tlsAddr(host string, forcePort bool) string { + // remove current forcePort first + host = strings.Split(host, ":")[0] + + if forcePort || s.cfg.SSL.Port != 443 { + host = fmt.Sprintf("%s:%v", host, s.cfg.SSL.Port) + } + + return host +} diff --git a/service/http/ssl_test.go b/service/http/ssl_test.go new file mode 100644 index 00000000..63eb90b1 --- /dev/null +++ b/service/http/ssl_test.go @@ -0,0 +1,215 @@ +package http + +import ( + "crypto/tls" + "github.com/sirupsen/logrus" + "github.com/sirupsen/logrus/hooks/test" + "github.com/spiral/roadrunner/service" + "github.com/stretchr/testify/assert" + "io/ioutil" + "net/http" + "testing" + "time" +) + +var sslClient = &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + }, +} + +func Test_SSL_Service_Echo(t *testing.T) { + logger, _ := test.NewNullLogger() + logger.SetLevel(logrus.DebugLevel) + + c := service.NewContainer(logger) + c.Register(ID, &Service{}) + + assert.NoError(t, c.Init(&testCfg{httpCfg: `{ + "address": ":6029", + "ssl": { + "port": 6900, + "key": "fixtures/server.key", + "cert": "fixtures/server.crt" + }, + "workers":{ + "command": "php ../../tests/http/client.php echo pipes", + "pool": {"numWorkers": 1} + } + }`})) + + s, st := c.Get(ID) + assert.NotNil(t, s) + assert.Equal(t, service.StatusOK, st) + + // should do nothing + s.(*Service).Stop() + + go func() { c.Serve() }() + time.Sleep(time.Millisecond * 100) + defer c.Stop() + + req, err := http.NewRequest("GET", "https://localhost:6900?hello=world", nil) + assert.NoError(t, err) + + r, err := sslClient.Do(req) + assert.NoError(t, err) + defer r.Body.Close() + + b, err := ioutil.ReadAll(r.Body) + assert.NoError(t, err) + + assert.NoError(t, err) + assert.Equal(t, 201, r.StatusCode) + assert.Equal(t, "WORLD", string(b)) +} + +func Test_SSL_Service_NoRedirect(t *testing.T) { + logger, _ := test.NewNullLogger() + logger.SetLevel(logrus.DebugLevel) + + c := service.NewContainer(logger) + c.Register(ID, &Service{}) + + assert.NoError(t, c.Init(&testCfg{httpCfg: `{ + "address": ":6029", + "ssl": { + "port": 6900, + "key": "fixtures/server.key", + "cert": "fixtures/server.crt" + }, + "workers":{ + "command": "php ../../tests/http/client.php echo pipes", + "pool": {"numWorkers": 1} + } + }`})) + + s, st := c.Get(ID) + assert.NotNil(t, s) + assert.Equal(t, service.StatusOK, st) + + // should do nothing + s.(*Service).Stop() + + go func() { c.Serve() }() + time.Sleep(time.Millisecond * 100) + defer c.Stop() + + req, err := http.NewRequest("GET", "http://localhost:6029?hello=world", nil) + assert.NoError(t, err) + + r, err := sslClient.Do(req) + assert.NoError(t, err) + defer r.Body.Close() + + assert.Nil(t, r.TLS) + + b, err := ioutil.ReadAll(r.Body) + assert.NoError(t, err) + + assert.NoError(t, err) + assert.Equal(t, 201, r.StatusCode) + assert.Equal(t, "WORLD", string(b)) +} + +func Test_SSL_Service_Redirect(t *testing.T) { + logger, _ := test.NewNullLogger() + logger.SetLevel(logrus.DebugLevel) + + c := service.NewContainer(logger) + c.Register(ID, &Service{}) + + assert.NoError(t, c.Init(&testCfg{httpCfg: `{ + "address": ":6029", + "ssl": { + "port": 6900, + "redirect": true, + "key": "fixtures/server.key", + "cert": "fixtures/server.crt" + }, + "workers":{ + "command": "php ../../tests/http/client.php echo pipes", + "pool": {"numWorkers": 1} + } + }`})) + + s, st := c.Get(ID) + assert.NotNil(t, s) + assert.Equal(t, service.StatusOK, st) + + // should do nothing + s.(*Service).Stop() + + go func() { c.Serve() }() + time.Sleep(time.Millisecond * 100) + defer c.Stop() + + req, err := http.NewRequest("GET", "http://localhost:6029?hello=world", nil) + assert.NoError(t, err) + + r, err := sslClient.Do(req) + assert.NoError(t, err) + defer r.Body.Close() + + assert.NotNil(t, r.TLS) + + b, err := ioutil.ReadAll(r.Body) + assert.NoError(t, err) + + assert.NoError(t, err) + assert.Equal(t, 201, r.StatusCode) + assert.Equal(t, "WORLD", string(b)) +} + +func Test_SSL_Service_Push(t *testing.T) { + logger, _ := test.NewNullLogger() + logger.SetLevel(logrus.DebugLevel) + + c := service.NewContainer(logger) + c.Register(ID, &Service{}) + + assert.NoError(t, c.Init(&testCfg{httpCfg: `{ + "address": ":6029", + "ssl": { + "port": 6900, + "redirect": true, + "key": "fixtures/server.key", + "cert": "fixtures/server.crt" + }, + "workers":{ + "command": "php ../../tests/http/client.php push pipes", + "pool": {"numWorkers": 1} + } + }`})) + + s, st := c.Get(ID) + assert.NotNil(t, s) + assert.Equal(t, service.StatusOK, st) + + // should do nothing + s.(*Service).Stop() + + go func() { c.Serve() }() + time.Sleep(time.Millisecond * 100) + defer c.Stop() + + req, err := http.NewRequest("GET", "https://localhost:6900?hello=world", nil) + assert.NoError(t, err) + + r, err := sslClient.Do(req) + assert.NoError(t, err) + defer r.Body.Close() + + assert.NotNil(t, r.TLS) + + b, err := ioutil.ReadAll(r.Body) + assert.NoError(t, err) + + assert.Equal(t, "", r.Header.Get("http2-push")) + + assert.NoError(t, err) + assert.Equal(t, 201, r.StatusCode) + assert.Equal(t, "WORLD", string(b)) +} |