diff --git a/Dockerfile b/Dockerfile index 31c62bd..112e3ef 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,10 +7,6 @@ FROM docker.io/library/golang:1.22-bookworm AS develop ENV GOPATH="/var/tmp/go" RUN set -x \ - # renovate: source=github-releases name=abice/go-enum - && ABICE_GOENUM_VERSION="0.6.0" \ - && GOBIN=/bin go install "github.com/abice/go-enum@v${ABICE_GOENUM_VERSION}" \ - && GOBIN=/bin go install golang.org/x/tools/cmd/goimports@latest \ && GOBIN=/bin go install gotest.tools/gotestsum@latest \ && go clean -cache -modcache \ # renovate: source=github-releases name=golangci/golangci-lint diff --git a/go.mod b/go.mod index 7d1f0ca..208e3d4 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module gh.tarampamp.am/error-pages -go 1.21 +go 1.22 require ( github.com/a8m/envsubst v1.4.2 diff --git a/internal/cli/serve/command.go b/internal/cli/serve/command.go index 35800cf..8d172a3 100644 --- a/internal/cli/serve/command.go +++ b/internal/cli/serve/command.go @@ -2,15 +2,18 @@ package serve import ( "context" + "errors" "fmt" "net/http" "strings" + "time" "github.com/urfave/cli/v3" "go.uber.org/zap" "gh.tarampamp.am/error-pages/internal/cli/shared" "gh.tarampamp.am/error-pages/internal/config" + appHttp "gh.tarampamp.am/error-pages/internal/http" ) type command struct { @@ -18,9 +21,9 @@ type command struct { opt struct { http struct { // our HTTP server - addr string - port uint16 - readBufferSize uint + addr string + port uint16 + // readBufferSize uint } } } @@ -28,8 +31,9 @@ type command struct { // NewCommand creates `serve` command. func NewCommand(log *zap.Logger) *cli.Command { //nolint:funlen,gocognit,gocyclo var ( - cmd command - cfg = config.New() + cmd command + cfg = config.New() + env, trim = cli.EnvVars, cli.StringConfig{TrimSpace: true} ) var ( @@ -40,38 +44,38 @@ func NewCommand(log *zap.Logger) *cli.Command { //nolint:funlen,gocognit,gocyclo jsonFormatFlag = cli.StringFlag{ Name: "json-format", Usage: "override the default error page response in JSON format (Go templates are supported)", - Sources: cli.EnvVars("RESPONSE_JSON_FORMAT"), + Sources: env("RESPONSE_JSON_FORMAT"), OnlyOnce: true, - Config: cli.StringConfig{TrimSpace: true}, + Config: trim, } xmlFormatFlag = cli.StringFlag{ Name: "xml-format", Usage: "override the default error page response in XML format (Go templates are supported)", - Sources: cli.EnvVars("RESPONSE_XML_FORMAT"), + Sources: env("RESPONSE_XML_FORMAT"), OnlyOnce: true, - Config: cli.StringConfig{TrimSpace: true}, + Config: trim, } templateNameFlag = cli.StringFlag{ Name: "template-name", Aliases: []string{"t"}, Value: cfg.TemplateName, Usage: "name of the template to use for rendering error pages", - Sources: cli.EnvVars("TEMPLATE_NAME"), + Sources: env("TEMPLATE_NAME"), OnlyOnce: true, - Config: cli.StringConfig{TrimSpace: true}, + Config: trim, } disableL10nFlag = cli.BoolFlag{ Name: "disable-l10n", Usage: "disable localization of error pages (if the template supports localization)", Value: cfg.L10n.Disable, - Sources: cli.EnvVars("DISABLE_L10N"), + Sources: env("DISABLE_L10N"), OnlyOnce: true, } defaultCodeToRenderFlag = cli.UintFlag{ Name: "default-error-page", Usage: "the code of the default (index page, when a code is not specified) error page to render", Value: uint64(cfg.Default.CodeToRender), - Sources: cli.EnvVars("DEFAULT_ERROR_PAGE"), + Sources: env("DEFAULT_ERROR_PAGE"), Validator: func(code uint64) error { if code > 999 { //nolint:mnd return fmt.Errorf("wrong HTTP code [%d] for the default error page", code) @@ -85,7 +89,7 @@ func NewCommand(log *zap.Logger) *cli.Command { //nolint:funlen,gocognit,gocyclo Name: "default-http-code", Usage: "the default (index page, when a code is not specified) HTTP response code", Value: uint64(cfg.Default.HttpCode), - Sources: cli.EnvVars("DEFAULT_HTTP_CODE"), + Sources: env("DEFAULT_HTTP_CODE"), Validator: defaultCodeToRenderFlag.Validator, OnlyOnce: true, } @@ -93,7 +97,7 @@ func NewCommand(log *zap.Logger) *cli.Command { //nolint:funlen,gocognit,gocyclo Name: "show-details", Usage: "show request details in the error page response (if supported by the template)", Value: cfg.ShowDetails, - Sources: cli.EnvVars("SHOW_DETAILS"), + Sources: env("SHOW_DETAILS"), OnlyOnce: true, } proxyHeadersListFlag = cli.StringFlag{ @@ -101,7 +105,7 @@ func NewCommand(log *zap.Logger) *cli.Command { //nolint:funlen,gocognit,gocyclo Usage: "listed here HTTP headers will be proxied from the original request to the error page response " + "(comma-separated list)", Value: strings.Join(cfg.ProxyHeaders, ","), - Sources: cli.EnvVars("PROXY_HTTP_HEADERS"), + Sources: env("PROXY_HTTP_HEADERS"), Validator: func(s string) error { for _, raw := range strings.Split(s, ",") { if clean := strings.TrimSpace(raw); strings.ContainsRune(clean, ' ') { @@ -112,17 +116,33 @@ func NewCommand(log *zap.Logger) *cli.Command { //nolint:funlen,gocognit,gocyclo return nil }, OnlyOnce: true, - Config: cli.StringConfig{TrimSpace: true}, + Config: trim, } - readBufferSizeFlag = cli.UintFlag{ - Name: "read-buffer-size", - Usage: "customize the HTTP read buffer size (set per connection for reading requests, also limits the " + - "maximum header size; consider increasing it if your clients send multi-KB request URIs or multi-KB " + - "headers, such as large cookies)", - DefaultText: "not set", - Sources: cli.EnvVars("READ_BUFFER_SIZE"), - OnlyOnce: true, + rotationModeFlag = cli.StringFlag{ + Name: "rotation-mode", + Value: config.RotationModeDisabled.String(), + Usage: "templates automatic rotation mode (" + strings.Join(config.RotationModeStrings(), "/") + ")", + Sources: env("TEMPLATES_ROTATION_MODE"), + OnlyOnce: true, + Config: trim, + Validator: func(s string) error { + if _, err := config.ParseRotationMode(s); err != nil { + return err + } + + return nil + }, } + + // readBufferSizeFlag = cli.UintFlag{ + // Name: "read-buffer-size", + // Usage: "customize the HTTP read buffer size (set per connection for reading requests, also limits the " + + // "maximum header size; consider increasing it if your clients send multi-KB request URIs or multi-KB " + + // "headers, such as large cookies)", + // DefaultText: "not set", + // Sources: cli.EnvVars("READ_BUFFER_SIZE"), + // OnlyOnce: true, + // } ) cmd.c = &cli.Command{ @@ -133,12 +153,13 @@ func NewCommand(log *zap.Logger) *cli.Command { //nolint:funlen,gocognit,gocyclo Action: func(ctx context.Context, c *cli.Command) error { cmd.opt.http.addr = c.String(addrFlag.Name) cmd.opt.http.port = uint16(c.Uint(portFlag.Name)) - cmd.opt.http.readBufferSize = uint(c.Uint(readBufferSizeFlag.Name)) + // cmd.opt.http.readBufferSize = uint(c.Uint(readBufferSizeFlag.Name)) cfg.TemplateName = c.String(templateNameFlag.Name) cfg.L10n.Disable = c.Bool(disableL10nFlag.Name) cfg.Default.CodeToRender = uint16(c.Uint(defaultCodeToRenderFlag.Name)) cfg.Default.HttpCode = uint16(c.Uint(defaultHTTPCodeFlag.Name)) + cfg.RotationMode, _ = config.ParseRotationMode(c.String(rotationModeFlag.Name)) cfg.ShowDetails = c.Bool(showDetailsFlag.Name) if c.IsSet(proxyHeadersListFlag.Name) { @@ -231,7 +252,8 @@ func NewCommand(log *zap.Logger) *cli.Command { //nolint:funlen,gocognit,gocyclo &defaultHTTPCodeFlag, &showDetailsFlag, &proxyHeadersListFlag, - &readBufferSizeFlag, + &rotationModeFlag, + // &readBufferSizeFlag, }, } @@ -240,5 +262,47 @@ func NewCommand(log *zap.Logger) *cli.Command { //nolint:funlen,gocognit,gocyclo // Run current command. func (cmd *command) Run(ctx context.Context, log *zap.Logger, cfg *config.Config) error { - return nil // TODO: implement + var srv = appHttp.NewServer(ctx, log) + + if err := srv.Register(cfg); err != nil { + return err + } + + var startingErrCh = make(chan error, 1) // channel for server starting error + defer close(startingErrCh) + + // start HTTP server in separate goroutine + go func(errCh chan<- error) { + var now = time.Now() + + defer func() { + log.Info("HTTP server stopped", zap.Duration("uptime", time.Since(now).Round(time.Millisecond))) + }() + + log.Info("HTTP server starting", + zap.String("addr", cmd.opt.http.addr), + zap.Uint16("port", cmd.opt.http.port), + ) + + if err := srv.Start(cmd.opt.http.addr, cmd.opt.http.port); err != nil && !errors.Is(err, http.ErrServerClosed) { + errCh <- err + } + }(startingErrCh) + + // and wait for... + select { + case err := <-startingErrCh: // ..server starting error + return err + + case <-ctx.Done(): // ..or context cancellation + const shutdownTimeout = 5 * time.Second + + log.Info("HTTP server stopping", zap.Duration("with timeout", shutdownTimeout)) + + if err := srv.Stop(shutdownTimeout); err != nil { //nolint:contextcheck + return err + } + } + + return nil } diff --git a/internal/config/config.go b/internal/config/config.go index eb0b340..6effb1b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -47,6 +47,10 @@ type Config struct { HttpCode uint16 } + // RotationMode allows to set the rotation mode for templates to switch between them automatically on startup, + // on each request, daily, hourly and so on. + RotationMode RotationMode + // ShowDetails determines whether to show additional details in the error response, extracted from the // incoming request (if supported by the template). ShowDetails bool diff --git a/internal/config/rotation_mode.go b/internal/config/rotation_mode.go new file mode 100644 index 0000000..f4bff1c --- /dev/null +++ b/internal/config/rotation_mode.go @@ -0,0 +1,87 @@ +package config + +import ( + "fmt" + "strings" +) + +// RotationMode represents the rotation mode for templates. +type RotationMode byte + +const ( + RotationModeDisabled RotationMode = iota // do not rotate templates, default + RotationModeRandomOnStartup // pick a random template on startup + RotationModeRandomOnEachRequest // pick a random template on each request + RotationModeRandomDaily // once a day switch to a random template + RotationModeRandomHourly // once an hour switch to a random template +) + +// String returns a human-readable representation of the rotation mode. +func (rm RotationMode) String() string { + switch rm { + case RotationModeDisabled: + return "disabled" + case RotationModeRandomOnStartup: + return "random-on-startup" + case RotationModeRandomOnEachRequest: + return "random-on-each-request" + case RotationModeRandomDaily: + return "random-daily" + case RotationModeRandomHourly: + return "random-hourly" + } + + return fmt.Sprintf("RotationMode(%d)", rm) +} + +// RotationModes returns a slice of all rotation modes. +func RotationModes() []RotationMode { + return []RotationMode{ + RotationModeDisabled, + RotationModeRandomOnStartup, + RotationModeRandomOnEachRequest, + RotationModeRandomDaily, + RotationModeRandomHourly, + } +} + +// RotationModeStrings returns a slice of all rotation modes as strings. +func RotationModeStrings() []string { + var ( + modes = RotationModes() + result = make([]string, len(modes)) + ) + + for i := range modes { + result[i] = modes[i].String() + } + + return result +} + +// ParseRotationMode parses a rotation mode (case is ignored) based on the ASCII representation of the rotation mode. +// If the provided ASCII representation is invalid an error is returned. +func ParseRotationMode[T string | []byte](text T) (RotationMode, error) { + var mode string + + if s, ok := any(text).(string); ok { + mode = s + } else { + mode = string(any(text).([]byte)) + } + + switch strings.ToLower(mode) { + case RotationModeDisabled.String(), "": + return RotationModeDisabled, nil // the empty string makes sense + case RotationModeRandomOnStartup.String(): + return RotationModeRandomOnStartup, nil + case RotationModeRandomOnEachRequest.String(): + return RotationModeRandomOnEachRequest, nil + case RotationModeRandomDaily.String(): + return RotationModeRandomDaily, nil + case RotationModeRandomHourly.String(): + return RotationModeRandomHourly, nil + } + + return RotationMode(0), fmt.Errorf("unrecognized rotation mode: %q", mode) +} diff --git a/internal/config/rotation_mode_test.go b/internal/config/rotation_mode_test.go new file mode 100644 index 0000000..f359b7c --- /dev/null +++ b/internal/config/rotation_mode_test.go @@ -0,0 +1,90 @@ +package config_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "gh.tarampamp.am/error-pages/internal/config" +) + +func TestRotationMode_String(t *testing.T) { + t.Parallel() + + assert.Equal(t, "disabled", config.RotationModeDisabled.String()) + assert.Equal(t, "random-on-startup", config.RotationModeRandomOnStartup.String()) + assert.Equal(t, "random-on-each-request", config.RotationModeRandomOnEachRequest.String()) + assert.Equal(t, "random-daily", config.RotationModeRandomDaily.String()) + assert.Equal(t, "random-hourly", config.RotationModeRandomHourly.String()) + + assert.Equal(t, "RotationMode(255)", config.RotationMode(255).String()) +} + +func TestRotationModes(t *testing.T) { + t.Parallel() + + assert.Equal(t, []config.RotationMode{ + config.RotationModeDisabled, + config.RotationModeRandomOnStartup, + config.RotationModeRandomOnEachRequest, + config.RotationModeRandomDaily, + config.RotationModeRandomHourly, + }, config.RotationModes()) +} + +func TestRotationModeStrings(t *testing.T) { + t.Parallel() + + assert.Equal(t, []string{ + "disabled", + "random-on-startup", + "random-on-each-request", + "random-daily", + "random-hourly", + }, config.RotationModeStrings()) +} + +func TestParseRotationMode(t *testing.T) { + t.Parallel() + + for name, _tt := range map[string]struct { + giveBytes []byte + giveString string + wantMode config.RotationMode + wantErrorMsg string + }{ + "": {giveString: "", wantMode: config.RotationModeDisabled}, + "": {giveBytes: []byte(""), wantMode: config.RotationModeDisabled}, + "disabled": {giveString: "disabled", wantMode: config.RotationModeDisabled}, + "disabled (bytes)": {giveBytes: []byte("disabled"), wantMode: config.RotationModeDisabled}, + "random-on-startup": {giveString: "random-on-startup", wantMode: config.RotationModeRandomOnStartup}, + "random-on-startup (bytes)": {giveBytes: []byte("random-on-startup"), wantMode: config.RotationModeRandomOnStartup}, + "on-each-request": {giveString: "random-on-each-request", wantMode: config.RotationModeRandomOnEachRequest}, + "daily": {giveString: "random-daily", wantMode: config.RotationModeRandomDaily}, + "hourly": {giveString: "random-hourly", wantMode: config.RotationModeRandomHourly}, + + "foobar": {giveString: "foobar", wantErrorMsg: "unrecognized rotation mode: \"foobar\""}, + } { + tt := _tt + + t.Run(name, func(t *testing.T) { + var ( + mode config.RotationMode + err error + ) + + if tt.giveString != "" || tt.giveBytes == nil { + mode, err = config.ParseRotationMode(tt.giveString) + } else { + mode, err = config.ParseRotationMode(tt.giveBytes) + } + + if tt.wantErrorMsg == "" { + assert.NoError(t, err) + assert.Equal(t, tt.wantMode, mode) + } else { + assert.ErrorContains(t, err, tt.wantErrorMsg) + } + }) + } +} diff --git a/internal/http/handlers/error_page/handler.go b/internal/http/handlers/error_page/handler.go new file mode 100644 index 0000000..6a475de --- /dev/null +++ b/internal/http/handlers/error_page/handler.go @@ -0,0 +1,12 @@ +package error_page + +import "net/http" + +func New() http.Handler { + var body = []byte("error page") + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write(body) + }) +} diff --git a/internal/http/handlers/live/handler.go b/internal/http/handlers/live/handler.go new file mode 100644 index 0000000..14852ce --- /dev/null +++ b/internal/http/handlers/live/handler.go @@ -0,0 +1,16 @@ +package live + +import ( + "net/http" +) + +// New creates a new handler that always returns "OK" with status code 200. +func New() http.Handler { + var body = []byte("OK") + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(body) + }) +} diff --git a/internal/http/handlers/live/handler_test.go b/internal/http/handlers/live/handler_test.go new file mode 100644 index 0000000..55e64f4 --- /dev/null +++ b/internal/http/handlers/live/handler_test.go @@ -0,0 +1,26 @@ +package live_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + + "gh.tarampamp.am/error-pages/internal/http/handlers/live" +) + +func TestServeHTTP(t *testing.T) { + t.Parallel() + + var ( + req = httptest.NewRequest(http.MethodGet, "http://testing", http.NoBody) + rr = httptest.NewRecorder() + ) + + live.New().ServeHTTP(rr, req) + + assert.Equal(t, rr.Header().Get("Content-Type"), "text/plain; charset=utf-8") + assert.Equal(t, rr.Code, http.StatusOK) + assert.Equal(t, rr.Body.String(), "OK") +} diff --git a/internal/http/handlers/version/handler.go b/internal/http/handlers/version/handler.go new file mode 100644 index 0000000..1a685ed --- /dev/null +++ b/internal/http/handlers/version/handler.go @@ -0,0 +1,22 @@ +package version + +import ( + "encoding/json" + "net/http" + "strings" +) + +// New creates a handler that returns the version of the service in JSON format. +func New(ver string) http.Handler { + var body, _ = json.Marshal(struct { //nolint:errchkjson + Version string `json:"version"` + }{ + Version: strings.TrimSpace(ver), + }) + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(body) + }) +} diff --git a/internal/http/handlers/version/handler_test.go b/internal/http/handlers/version/handler_test.go new file mode 100644 index 0000000..6fc8420 --- /dev/null +++ b/internal/http/handlers/version/handler_test.go @@ -0,0 +1,25 @@ +package version_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + + "gh.tarampamp.am/error-pages/internal/http/handlers/version" +) + +func TestServeHTTP(t *testing.T) { + t.Parallel() + + var ( + req = httptest.NewRequest(http.MethodGet, "http://testing", http.NoBody) + rr = httptest.NewRecorder() + ) + + version.New("\t\n foo@bar ").ServeHTTP(rr, req) + + assert.Equal(t, rr.Code, http.StatusOK) + assert.Equal(t, rr.Body.String(), `{"version":"foo@bar"}`) +} diff --git a/internal/http/middleware/logreq/middleware.go b/internal/http/middleware/logreq/middleware.go new file mode 100644 index 0000000..bd61819 --- /dev/null +++ b/internal/http/middleware/logreq/middleware.go @@ -0,0 +1,49 @@ +package logreq + +import ( + "net/http" + "time" + + "go.uber.org/zap" +) + +// New creates a middleware for [http.ServeMux] that logs every incoming request. +// +// The skipper function should return true if the request should be skipped. It's ok to pass nil. +func New(log *zap.Logger, skipper func(*http.Request) bool) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if skipper != nil && skipper(r) { + next.ServeHTTP(w, r) + + return + } + + var now = time.Now() + + defer func() { + var fields = []zap.Field{ + zap.String("useragent", r.UserAgent()), + zap.String("method", r.Method), + zap.String("url", r.URL.String()), + zap.String("referer", r.Referer()), + zap.String("content type", w.Header().Get("Content-Type")), + zap.String("remote addr", r.RemoteAddr), + zap.String("method", r.Method), + zap.Duration("duration", time.Since(now).Round(time.Microsecond)), + } + + if log.Level() <= zap.DebugLevel { + fields = append(fields, + zap.Any("request headers", r.Header.Clone()), + zap.Any("response headers", w.Header().Clone()), + ) + } + + log.Info("HTTP request processed", fields...) + }() + + next.ServeHTTP(w, r) + }) + } +} diff --git a/internal/http/server.go b/internal/http/server.go new file mode 100644 index 0000000..c1e4173 --- /dev/null +++ b/internal/http/server.go @@ -0,0 +1,91 @@ +package http + +import ( + "context" + "net" + "net/http" + "strconv" + "strings" + "time" + + "go.uber.org/zap" + + "gh.tarampamp.am/error-pages/internal/appmeta" + "gh.tarampamp.am/error-pages/internal/config" + "gh.tarampamp.am/error-pages/internal/http/handlers/error_page" + "gh.tarampamp.am/error-pages/internal/http/handlers/live" + "gh.tarampamp.am/error-pages/internal/http/handlers/version" + "gh.tarampamp.am/error-pages/internal/http/middleware/logreq" +) + +// Server is an HTTP server for serving error pages. +type Server struct { + log *zap.Logger + server *http.Server + mux *http.ServeMux +} + +// NewServer creates a new HTTP server. +func NewServer(baseCtx context.Context, log *zap.Logger) Server { + const ( + readTimeout = 30 * time.Second + writeTimeout = readTimeout + 10*time.Second // should be bigger than the read timeout + maxHeaderBytes = (1 << 20) * 5 //nolint:mnd // 5 MB + ) + + var ( + mux = http.NewServeMux() + srv = &http.Server{ + Handler: mux, + ReadTimeout: readTimeout, + WriteTimeout: writeTimeout, + ReadHeaderTimeout: readTimeout, + MaxHeaderBytes: maxHeaderBytes, + ErrorLog: zap.NewStdLog(log), + BaseContext: func(net.Listener) context.Context { return baseCtx }, + } + ) + + return Server{log: log, server: srv, mux: mux} +} + +// Register server handlers, middlewares, etc. +func (s *Server) Register(cfg *config.Config) error { + // register middleware + s.server.Handler = logreq.New(s.log, func(r *http.Request) bool { + // skip logging healthcheck requests + return strings.Contains(strings.ToLower(r.UserAgent()), "healthcheck") + })(s.server.Handler) + + { // register handlers (https://go.dev/blog/routing-enhancements) + var errorPageHandler = error_page.New() + + s.mux.Handle("/", errorPageHandler) + s.mux.Handle("/{any}", errorPageHandler) + + var liveHandler = live.New() + + s.mux.Handle("GET /health/live", liveHandler) + s.mux.Handle("GET /healthz", liveHandler) + s.mux.Handle("GET /live", liveHandler) + + s.mux.Handle("GET /version", version.New(appmeta.Version())) + } + + return nil +} + +// Start server. +func (s *Server) Start(ip string, port uint16) error { + s.server.Addr = ip + ":" + strconv.Itoa(int(port)) + + return s.server.ListenAndServe() +} + +// Stop server gracefully. +func (s *Server) Stop(timeout time.Duration) error { + var ctx, cancel = context.WithTimeout(context.Background(), timeout) + defer cancel() + + return s.server.Shutdown(ctx) +}