diff --git a/cmd/serverNameExample_httpExample/initial/createService.go b/cmd/serverNameExample_httpExample/initial/createService.go index 36b554ce..610532d8 100644 --- a/cmd/serverNameExample_httpExample/initial/createService.go +++ b/cmd/serverNameExample_httpExample/initial/createService.go @@ -1,8 +1,6 @@ package initial import ( - "strconv" - "github.com/go-dev-frame/sponge/internal/config" "github.com/go-dev-frame/sponge/internal/server" @@ -15,8 +13,7 @@ func CreateServices() []app.IServer { var servers []app.IServer // create a http service - httpAddr := ":" + strconv.Itoa(cfg.HTTP.Port) - httpServer := server.NewHTTPServer(httpAddr, + httpServer := server.NewHTTPServer(cfg.HTTP, server.WithHTTPIsProd(cfg.App.Env == "prod"), ) servers = append(servers, httpServer) diff --git a/cmd/sponge/commands/generate/common.go b/cmd/sponge/commands/generate/common.go index 790b78d1..377e9ea5 100644 --- a/cmd/sponge/commands/generate/common.go +++ b/cmd/sponge/commands/generate/common.go @@ -1023,6 +1023,10 @@ func getHTTPServiceFields() []replacer.Field { Old: appConfigFileMark3, New: "", }, + { + Old: "http_test.go.noregistry", + New: "http_test.go", + }, { Old: "http.go.noregistry", New: "http.go", diff --git a/cmd/sponge/commands/generate/http-pb.go b/cmd/sponge/commands/generate/http-pb.go index 1b55bc29..ed7fb512 100644 --- a/cmd/sponge/commands/generate/http-pb.go +++ b/cmd/sponge/commands/generate/http-pb.go @@ -137,7 +137,7 @@ func (g *httpPbGenerator) generateCode() (string, error) { "routers_pbExample.go", }, "internal/server": { - "http.go.noregistry", "http_option.go.noregistry", + "http.go.noregistry", "http_option.go.noregistry", "http_test.go.noregistry", }, } diff --git a/cmd/sponge/commands/generate/http.go b/cmd/sponge/commands/generate/http.go index c398c81e..538a314c 100644 --- a/cmd/sponge/commands/generate/http.go +++ b/cmd/sponge/commands/generate/http.go @@ -232,7 +232,7 @@ func (g *httpGenerator) generateCode() (string, error) { "routers.go", "userExample.go", }, "internal/server": { - "http.go.noregistry", "http_option.go.noregistry", + "http.go.noregistry", "http_option.go.noregistry", "http_test.go.noregistry", }, "internal/types": { "swagger_types.go", "userExample_types.go", diff --git a/cmd/sponge/commands/generate/template.go b/cmd/sponge/commands/generate/template.go index dd29bdd5..74293083 100644 --- a/cmd/sponge/commands/generate/template.go +++ b/cmd/sponge/commands/generate/template.go @@ -370,7 +370,19 @@ func NewCenter(configFile string) (*Center, error) { httpServerConfigCode = `# http server settings http: port: 8080 # listen port - timeout: 0 # request timeout, unit(second), if 0 means not set, if greater than 0 means set timeout, if enableHTTPProfile is true, it needs to set 0 or greater than 60s` + httpsPort: 8443 # https listen port when tls is enabled + timeout: 0 # request timeout, unit(second), if 0 means not set, if greater than 0 means set timeout, if enableHTTPProfile is true, it needs to set 0 or greater than 60s + idleTimeout: 60 # http idle timeout, unit(second) + readTimeout: 30 # http read timeout, unit(second) + writeTimeout: 30 # http write timeout, unit(second) + tls: + domains: + - "" # list of domains for automatic tls certificates, empty disables tls + acmeDirectory: "https://acme-v02.api.letsencrypt.org/directory" # acme directory url + storagePath: "./storage/autocert" # directory to cache certificates + eab: + kid: "" # external account binding key identifier + hmacKey: "" # base64url encoded external account binding hmac key` rpcServerConfigCode = `# grpc server settings grpc: diff --git a/configs/serverNameExample.yml b/configs/serverNameExample.yml index 23b42950..d530fb48 100644 --- a/configs/serverNameExample.yml +++ b/configs/serverNameExample.yml @@ -22,7 +22,19 @@ app: # http server settings http: port: 8080 # listen port - timeout: 0 # request timeout, unit(second), if 0 means not set, if greater than 0 means set timeout, if enableHTTPProfile is true, it needs to set 0 or greater than 60s + httpsPort: 8443 # https listen port when tls is enabled + timeout: 0 # request timeout, unit(second), if 0 means not set, if greater than 0 means set timeout, if enableHTTPProfile is true, it needs to set 0 or greater than 60s + idleTimeout: 60 # http idle timeout, unit(second) + readTimeout: 30 # http read timeout, unit(second) + writeTimeout: 30 # http write timeout, unit(second) + tls: + domains: + - "" # list of domains for automatic tls certificates, empty disables tls + acmeDirectory: "https://acme-v02.api.letsencrypt.org/directory" # acme directory url + storagePath: "./storage/autocert" # directory to cache certificates + eab: + kid: "" # external account binding key identifier + hmacKey: "" # base64url encoded external account binding hmac key # grpc server settings diff --git a/internal/server/http.go.noregistry b/internal/server/http.go.noregistry index 6c94b9f8..8e24fa16 100644 --- a/internal/server/http.go.noregistry +++ b/internal/server/http.go.noregistry @@ -2,45 +2,102 @@ package server import ( "context" + "encoding/base64" + "errors" "fmt" + "net" "net/http" + "strings" "time" "github.com/gin-gonic/gin" + "golang.org/x/crypto/acme" + "golang.org/x/crypto/acme/autocert" "github.com/go-dev-frame/sponge/pkg/app" + "github.com/go-dev-frame/sponge/pkg/logger" + "github.com/go-dev-frame/sponge/internal/config" "github.com/go-dev-frame/sponge/internal/routers" ) var _ app.IServer = (*httpServer)(nil) type httpServer struct { - addr string - server *http.Server + httpAddr string + httpsAddr string + httpServer *http.Server + httpsServer *http.Server + tlsEnabled bool } -// Start http service +// Start http/https service func (s *httpServer) Start() error { - if err := s.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - return fmt.Errorf("listen server error: %v", err) + if s.tlsEnabled { + errCh := make(chan error, 2) + + go func() { + errCh <- listenAndServe(s.httpServer) + }() + + go func() { + errCh <- listenAndServeTLS(s.httpsServer) + }() + + var firstErr error + for i := 0; i < 2; i++ { + if err := <-errCh; err != nil { + if firstErr == nil { + firstErr = err + } else { + logger.Error("http server encountered multiple errors", logger.Err(err)) + } + } + } + + return firstErr } + + if err := listenAndServe(s.httpServer); err != nil { + return err + } + return nil } -// Stop http service +// Stop http/https service func (s *httpServer) Stop() error { - ctx, _ := context.WithTimeout(context.Background(), 3*time.Second) //nolint - return s.server.Shutdown(ctx) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + var firstErr error + if err := s.httpServer.Shutdown(ctx); err != nil { + firstErr = err + } + + if s.tlsEnabled && s.httpsServer != nil { + if err := s.httpsServer.Shutdown(ctx); err != nil && !errors.Is(err, context.Canceled) { + if firstErr == nil { + firstErr = err + } else { + logger.Error("https server shutdown reported additional error", logger.Err(err)) + } + } + } + + return firstErr } -// String comment +// String provides a human readable description of listener addresses. func (s *httpServer) String() string { - return "http service address " + s.addr + if s.tlsEnabled { + return fmt.Sprintf("http service redirecting on %s and https service address %s", s.httpAddr, s.httpsAddr) + } + return "http service address " + s.httpAddr } -// NewHTTPServer creates a new http server -func NewHTTPServer(addr string, opts ...HTTPOption) app.IServer { +// NewHTTPServer creates an HTTP server with optional automatic TLS. +func NewHTTPServer(cfg config.HTTP, opts ...HTTPOption) app.IServer { o := defaultHTTPOptions() o.apply(opts...) @@ -50,16 +107,57 @@ func NewHTTPServer(addr string, opts ...HTTPOption) app.IServer { gin.SetMode(gin.DebugMode) } - router := routers.NewRouter() - server := &http.Server{ - Addr: addr, - Handler: router, + appHandler := o.handler + if appHandler == nil { + appHandler = routers.NewRouter() + } + + readTimeout := secondsToDuration(cfg.ReadTimeout) + writeTimeout := secondsToDuration(cfg.WriteTimeout) + idleTimeout := secondsToDuration(cfg.IdleTimeout) + + httpSrv := &http.Server{ + Addr: fmt.Sprintf(":%d", cfg.Port), + Handler: appHandler, + ReadTimeout: readTimeout, + WriteTimeout: writeTimeout, + IdleTimeout: idleTimeout, MaxHeaderBytes: 1 << 20, } + domains := filterDomains(cfg.TLS.Domains) + tlsEnabled := len(domains) > 0 + + var ( + httpsSrv *http.Server + httpsAddr string + ) + if tlsEnabled { + manager := buildAutocertManager(cfg, domains) + httpSrv.Handler = manager.HTTPHandler(http.HandlerFunc(httpRedirectHandler)) + + httpsSrv = &http.Server{ + Addr: fmt.Sprintf(":%d", cfg.HTTPSPort), + Handler: appHandler, + ReadTimeout: readTimeout, + WriteTimeout: writeTimeout, + IdleTimeout: idleTimeout, + MaxHeaderBytes: 1 << 20, + TLSConfig: manager.TLSConfig(), + } + httpsAddr = httpsSrv.Addr + + logger.Info("automatic TLS enabled", logger.String("http_addr", httpSrv.Addr), logger.String("https_addr", httpsSrv.Addr), logger.Any("domains", domains)) + } else { + logger.Info("automatic TLS disabled", logger.String("http_addr", httpSrv.Addr)) + } + return &httpServer{ - addr: addr, - server: server, + httpAddr: httpSrv.Addr, + httpsAddr: httpsAddr, + httpServer: httpSrv, + httpsServer: httpsSrv, + tlsEnabled: tlsEnabled, } } diff --git a/internal/server/http_option.go.noregistry b/internal/server/http_option.go.noregistry index faa2b055..85e37674 100644 --- a/internal/server/http_option.go.noregistry +++ b/internal/server/http_option.go.noregistry @@ -1,15 +1,19 @@ package server +import "net/http" + // HTTPOption setting up http type HTTPOption func(*httpOptions) type httpOptions struct { - isProd bool + isProd bool + handler http.Handler } func defaultHTTPOptions() *httpOptions { return &httpOptions{ - isProd: false, + isProd: false, + handler: nil, } } @@ -25,3 +29,10 @@ func WithHTTPIsProd(isProd bool) HTTPOption { o.isProd = isProd } } + +// WithHTTPHandler allows injecting a custom http handler (primarily for testing) +func WithHTTPHandler(handler http.Handler) HTTPOption { + return func(o *httpOptions) { + o.handler = handler + } +} diff --git a/internal/server/http_test.go.noregistry b/internal/server/http_test.go.noregistry new file mode 100644 index 00000000..b97f8732 --- /dev/null +++ b/internal/server/http_test.go.noregistry @@ -0,0 +1,87 @@ +package server + +import ( + "encoding/base64" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/require" + "golang.org/x/crypto/acme" + + "github.com/go-dev-frame/sponge/internal/config" +) + +func setTestConfig(t *testing.T, httpCfg config.HTTP) { + t.Helper() + config.Set(&config.Config{ + App: config.App{ + Env: "dev", + }, + HTTP: httpCfg, + }) + t.Cleanup(func() { + config.Set(nil) + }) +} + +func TestSecondsToDuration(t *testing.T) { + require.Equal(t, time.Duration(0), secondsToDuration(0)) + require.Equal(t, time.Duration(0), secondsToDuration(-1)) + require.Equal(t, 30*time.Second, secondsToDuration(30)) +} + +func TestFilterDomains(t *testing.T) { + input := []string{"example.com", "", " other.example.com "} + result := filterDomains(input) + require.Equal(t, []string{"example.com", "other.example.com"}, result) +} + +func TestExternalAccountBinding(t *testing.T) { + // returns nil when kid or key missing + require.Nil(t, externalAccountBinding(config.Eab{})) + + key := []byte("secret") + encoded := base64.RawURLEncoding.EncodeToString(key) + binding := externalAccountBinding(config.Eab{Kid: "kid", HmacKey: encoded}) + require.NotNil(t, binding) + require.Equal(t, "kid", binding.KID) + require.Equal(t, key, binding.Key) +} + +func TestNewHTTPServer_TLSEnabled(t *testing.T) { + httpCfg := config.HTTP{ + Port: 8080, + HTTPSPort: 8443, + IdleTimeout: 10, + ReadTimeout: 5, + WriteTimeout: 5, + TLS: config.TLS{ + Domains: []string{"example.com"}, + AcmeDirectory: acme.LetsEncryptURL, + StoragePath: t.TempDir(), + }, + } + + setTestConfig(t, httpCfg) + server := NewHTTPServer(httpCfg, WithHTTPHandler(http.NewServeMux())).(*httpServer) + require.True(t, server.tlsEnabled) + require.NotNil(t, server.httpsServer) + require.Equal(t, ":8080", server.httpAddr) + require.Equal(t, ":8443", server.httpsServer.Addr) +} + +func TestNewHTTPServer_TLSDisabledWhenNoDomains(t *testing.T) { + httpCfg := config.HTTP{ + Port: 8080, + HTTPSPort: 8443, + TLS: config.TLS{ + Domains: []string{"", " "}, + }, + } + + setTestConfig(t, httpCfg) + server := NewHTTPServer(httpCfg, WithHTTPHandler(http.NewServeMux())).(*httpServer) + require.False(t, server.tlsEnabled) + require.Nil(t, server.httpsServer) +}