diff --git a/backend/app/cmd/server.go b/backend/app/cmd/server.go index 88acccf5..d150192d 100644 --- a/backend/app/cmd/server.go +++ b/backend/app/cmd/server.go @@ -37,6 +37,7 @@ type ServerCommand struct { Mongo MongoGroup `group:"mongo" namespace:"mongo" env-namespace:"MONGO"` Admin AdminGroup `group:"admin" namespace:"admin" env-namespace:"ADMIN"` Notify NotifyGroup `group:"notify" namespace:"notify" env-namespace:"NOTIFY"` + SSL SSLGroup `group:"ssl" namespace:"ssl" env-namespace:"SSL"` Sites []string `long:"site" env:"SITE" default:"remark" description:"site names" env-delim:","` DevPasswd string `long:"dev-passwd" env:"DEV_PASSWD" default:"" description:"development mode password"` @@ -129,6 +130,14 @@ type NotifyGroup struct { } `group:"telegram" namespace:"telegram" env-namespace:"TELEGRAM"` } +// SSLGroup defines options group for server ssl params +type SSLGroup struct { + Mode string `long:"mode" env:"MODE" description:"ssl (auto)support" choice:"none" choice:"static" choice:"auto" default:"none"` + Port int `long:"port" env:"PORT" description:"port number for https server" default:"8443"` + Cert string `long:"cert" env:"CERT" description:"path to cert.pem file"` + Key string `long:"key" env:"KEY" description:"path to key.pem file"` +} + // serverApp holds all active objects type serverApp struct { *ServerCommand @@ -143,7 +152,7 @@ type serverApp struct { // Execute is the entry point for "server" command, called by flag parser func (s *ServerCommand) Execute(args []string) error { - log.Printf("[INFO] start server on port %d", s.Port) + log.Printf("[INFO] start rest server in '%s' mode", s.SSL.Mode) resetEnv("SECRET", "AUTH_GOOGLE_CSEC", "AUTH_GITHUB_CSEC", "AUTH_FACEBOOK_CSEC", "AUTH_YANDEX_CSEC") ctx, cancel := context.WithCancel(context.Background()) @@ -236,6 +245,11 @@ func (s *ServerCommand) newServerApp() (*serverApp, error) { imgProxy := &proxy.Image{Enabled: s.ImageProxy, RoutePath: "/api/v1/img", RemarkURL: s.RemarkURL} commentFormatter := store.NewCommentFormatter(imgProxy) + sslConfig, err := s.makeSSLConfig() + if err != nil { + return nil, errors.Wrap(err, "failed to make config of ssl server params") + } + srv := &api.Rest{ Version: s.Revision, DataService: dataService, @@ -256,6 +270,7 @@ func (s *ServerCommand) newServerApp() (*serverApp, error) { }, Cache: loadingCache, NotifyService: notifyService, + SSLConfig: sslConfig, } srv.ScoreThresholds.Low, srv.ScoreThresholds.Critical = s.LowScore, s.CriticalScore @@ -478,3 +493,26 @@ func (s *ServerCommand) makeNotify(dataStore *service.DataStore) (*notify.Servic } return nil, errors.Errorf("unsupported notification type %q", s.Notify.Type) } + +func (s *ServerCommand) makeSSLConfig() (group api.SSLConfig, err error) { + switch s.SSL.Mode { + case "none": + group.SSLMode = api.None + case "static": + if s.SSL.Cert == "" { + return group, errors.New("path to cert.pem is required") + } + if s.SSL.Key == "" { + return group, errors.New("path to key.pem is required") + } + group.SSLMode = api.Static + group.Port = s.SSL.Port + group.Cert = s.SSL.Cert + group.Key = s.SSL.Key + case "auto": + group.SSLMode = api.Auto + group.Port = s.SSL.Port + return group, errors.New("not implemented yet") + } + return group, err +} diff --git a/backend/app/main_test.go b/backend/app/main_test.go index f6215c09..2d979b75 100644 --- a/backend/app/main_test.go +++ b/backend/app/main_test.go @@ -1,6 +1,7 @@ package main import ( + "crypto/tls" "io/ioutil" "net/http" "os" @@ -46,3 +47,56 @@ func TestMain(t *testing.T) { wg.Wait() } + +func TestMain_SSLStaticMode(t *testing.T) { + os.Args = []string{"test", "server", "--secret=123456", "--store.bolt.path=/tmp/xyz", "--backup=/tmp", + "--avatar.fs.path=/tmp", "--port=18080", "--url=https://localhost", "--dbg", + "--ssl.mode=static", "--ssl.cert=testdata/cert.pem", "--ssl.key=testdata/key.pem", "--ssl.port=18443"} + + go func() { + time.Sleep(500 * time.Millisecond) + err := syscall.Kill(syscall.Getpid(), syscall.SIGTERM) + require.Nil(t, err) + }() + + wg := sync.WaitGroup{} + wg.Add(1) + go func() { + st := time.Now() + main() + assert.True(t, time.Since(st).Seconds() < 1, "should take about 500msec") + wg.Done() + }() + + time.Sleep(200 * time.Millisecond) // let server start + + client := http.Client{ + // prevent http redirect + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + + // allow self-signed certificate + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + }, + } + + // check http to https redirect response + resp, err := client.Get("http://localhost:18080/blah?param=1") + require.Nil(t, err) + defer resp.Body.Close() + assert.Equal(t, 307, resp.StatusCode) + assert.Equal(t, "https://localhost:18443/blah?param=1", resp.Header.Get("Location")) + + // check https server + resp, err = client.Get("https://localhost:18443/ping") + require.Nil(t, err) + defer resp.Body.Close() + assert.Equal(t, 200, resp.StatusCode) + body, err := ioutil.ReadAll(resp.Body) + assert.Nil(t, err) + assert.Equal(t, "pong", string(body)) + + wg.Wait() +} diff --git a/backend/app/rest/api/rest.go b/backend/app/rest/api/rest.go index c76ccf7c..c47fa7dd 100644 --- a/backend/app/rest/api/rest.go +++ b/backend/app/rest/api/rest.go @@ -31,6 +31,23 @@ import ( "github.com/umputun/remark/backend/app/store/service" ) +// sslMode defines rest server mode (http or https) +type sslMode int8 + +const ( + None sslMode = iota // http server only + Static // both https and http server. Redirect http to https + Auto // both https and http server. Redirect http to https. Https server with autocert support +) + +// SSLConfig holds all params for ssl server mode +type SSLConfig struct { + SSLMode sslMode + Cert string + Key string + Port int +} + // Rest is a rest access server type Rest struct { Version string @@ -53,8 +70,10 @@ type Rest struct { Critical int } - httpServer *http.Server - lock sync.Mutex + SSLConfig SSLConfig + httpsServer *http.Server + httpServer *http.Server + lock sync.Mutex adminService admin } @@ -70,22 +89,37 @@ type commentsWithInfo struct { // Run the lister and request's router, activate rest server func (s *Rest) Run(port int) { - log.Printf("[INFO] activate rest server on port %d", port) + switch s.SSLConfig.SSLMode { + case None: + log.Printf("[INFO] activate http rest server on port %d", port) - router := s.routes() + s.lock.Lock() + s.httpServer = s.makeHTTPServer(port, s.routes()) + s.lock.Unlock() - s.lock.Lock() - s.httpServer = &http.Server{ - Addr: fmt.Sprintf(":%d", port), - Handler: router, - ReadHeaderTimeout: 5 * time.Second, - WriteTimeout: 5 * time.Second, - IdleTimeout: 30 * time.Second, + err := s.httpServer.ListenAndServe() + log.Printf("[WARN] http server terminated, %s", err) + case Static: + log.Printf("[INFO] activate rest https server in 'static' mode on port %d", s.SSLConfig.Port) + + s.lock.Lock() + s.httpsServer = s.makeHTTPServer(s.SSLConfig.Port, s.routes()) + s.httpServer = s.makeHTTPServer(port, s.httpToHttpsRouter()) + s.lock.Unlock() + + go func() { + log.Printf("[INFO] activate http redirect server on port %d", port) + + err := s.httpServer.ListenAndServe() + log.Printf("[WARN] http redirect server terminated, %s", err) + }() + + err := s.httpsServer.ListenAndServeTLS(s.SSLConfig.Cert, s.SSLConfig.Key) + log.Printf("[WARN] https server terminated, %s", err) + case Auto: + log.Printf("[WARN] Autocert mode is not implemented yet") } - s.lock.Unlock() - err := s.httpServer.ListenAndServe() - log.Printf("[WARN] http server terminated, %s", err) } // Shutdown rest http server @@ -96,13 +130,46 @@ func (s *Rest) Shutdown() { s.lock.Lock() if s.httpServer != nil { if err := s.httpServer.Shutdown(ctx); err != nil { - log.Printf("[DEBUG] rest shutdown error, %s", err) + log.Printf("[DEBUG] http shutdown error, %s", err) } + log.Print("[DEBUG] shutdown http server completed") + } + + if s.httpsServer != nil { + log.Print("[WARN] shutdown https server") + if err := s.httpsServer.Shutdown(ctx); err != nil { + log.Printf("[DEBUG] https shutdown error, %s", err) + } + log.Print("[DEBUG] shutdown https server completed") } - log.Print("[DEBUG] shutdown rest server completed") s.lock.Unlock() } +func (s *Rest) makeHTTPServer(port int, router chi.Router) *http.Server { + return &http.Server{ + Addr: fmt.Sprintf(":%d", port), + Handler: router, + ReadHeaderTimeout: 5 * time.Second, + WriteTimeout: 5 * time.Second, + IdleTimeout: 30 * time.Second, + } +} + +func (s *Rest) httpToHttpsRouter() chi.Router { + router := chi.NewRouter() + router.Use(middleware.RealIP, Recoverer) + router.Use(middleware.Throttle(1000), middleware.Timeout(60*time.Second)) + + router.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) { + newURL := fmt.Sprintf("%s:%d", s.RemarkURL, s.SSLConfig.Port) + r.URL.Path + if r.URL.RawQuery != "" { + newURL += "?" + r.URL.RawQuery + } + http.Redirect(w, r, newURL, http.StatusTemporaryRedirect) + }) + return router +} + func (s *Rest) routes() chi.Router { router := chi.NewRouter() router.Use(middleware.RealIP, Recoverer) diff --git a/backend/app/testdata/cert.pem b/backend/app/testdata/cert.pem new file mode 100644 index 00000000..436b1726 --- /dev/null +++ b/backend/app/testdata/cert.pem @@ -0,0 +1,27 @@ +-----BEGIN CERTIFICATE----- +MIIEpDCCAowCCQCDOGCCov0x5zANBgkqhkiG9w0BAQsFADAUMRIwEAYDVQQDDAls +b2NhbGhvc3QwHhcNMTgxMDIwMTIxNjI0WhcNMjgxMDE3MTIxNjI0WjAUMRIwEAYD +VQQDDAlsb2NhbGhvc3QwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDi +PU80EAvPZizxJhwG1X2XBZv8iVgsTIAJhCYSzLScgn46Yln9s1/LJFtQloDrZByk +m+/3VfaYsrwUT8Xn9NHYnde9q/L9sQM9rfLsVjHkLp3gPaWYRburMfOqrdxZ2GYR +uUi9Ni4s7o4WE1CBCoepiFdVY3OYKQdifygCqZlsAgywkqz7q0qJH+Fkw4OTzf+o +fqr6keF8B6HbHf4Kgwv0/h4EKqZ2YeHqn7bCVS/ppr6t5n7+s/EjN6gD5WnBsxNU +ILjnREW4wG6rN0gY9IaZ6vDeJBqe9fhYPevUH9OZInOlYpHYHt1dHo3sxto64KAp +eZv9/LUuO0QSBOzh6L20HLSqUMQVgsVxOjXpza0DTosQfpEKTkuJxMeedweWClRN +gZY6p7xdFtKju43dfxLsUtSLq3TGFVHWVwtPuSdIDwFRx5JPkL5WgK8XEsN9k7Hs +oA+/Ng98hvNj1kSQi8FXTFnlZ7+YNi51UbhveGCKRZdh6Klm1HwYxQcY6kOWRyv+ +QNyeIKlIxhfgsL2iJzZMkM/OL/IXwCROCha/v9tMk7gKmlS7O2MHNILcGP1LsyFJ +EI67ep7LK4q8EZfVPThjS/cTag5zETWoaJ7cxY36WxaeQB+3OgKT0NRiAT88+eKq +Yd5nRJ6LSdSM2rQwqiW8JF4T/Xq10Y0bGoKoqf/ywQIDAQABMA0GCSqGSIb3DQEB +CwUAA4ICAQANcjV3Rzb7sefN7XR77tKpIr0TT2Qk/s4V6hV91vDq+G+7YGi+BRaX +6VWd1OjQVq4YgSmOgQ7L11xEMIZXsNlerwIV0DqLYFYD1Nj6iYdvis4JWDSbFUlI +gJNyvEhC5dtGcHg8mWYGWcQE5Eu2paVrL49madExqvlpIk0cfJVhdviI9t15mJ+X +JBbKaxdoh7CV5oR5cbTJqkszksozMH6krqGZAOgOAn5KzqIZFNyeDUYzPhzjySlh +NNO/sdMyRFfNqZh/bC84z7HB9/KxHnFTBUvUdoX0JRAnkSV5zTuqGFxP/MRdNC1Y +1hre9nnMSzHCoyizFihKBtaTc5OKbV62rl20WSSrDuNzFBGK3jHA3qnL7pdVacXg ++TOt9g0YoKLxQTPzvRGLo3kZbELP2dWzK8ILZeSi2vdJJRGe4Vt3oYF3Ky79ZFW4 +wIm8QTpOKHQlH2mpw+OCQ42AKJdBT4f/stJDxuKk7fBHAEzEnH94pLGpi23BJNGR +wSOk+h6Oi0CHEO+Iww5nWX+y4msK7mZwl3eBdYmuXQJnODyGj3n2rjB0Rj51BNXV +APPpGaCbBEDdnTD7iNtwj7By/kVFmzfjAX45C1aF/3B2RS8AAEyEIP1uolykSQQJ +Cdt0GVjDnKk4nG8//FsPXBU9s7UFYa2E68Oz2JUyOyHVKt0xkc0Seg== +-----END CERTIFICATE----- diff --git a/backend/app/testdata/key.pem b/backend/app/testdata/key.pem new file mode 100644 index 00000000..89619b84 --- /dev/null +++ b/backend/app/testdata/key.pem @@ -0,0 +1,52 @@ +-----BEGIN PRIVATE KEY----- +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 +Qvy0mUIIWxegXy6aVnRb+iikOHgtnZU= +-----END PRIVATE KEY-----