mirror of
https://github.com/tarampampam/error-pages.git
synced 2024-08-30 18:22:40 +00:00
392 lines
13 KiB
Go
392 lines
13 KiB
Go
package serve
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/urfave/cli/v3"
|
|
|
|
"gh.tarampamp.am/error-pages/internal/cli/shared"
|
|
"gh.tarampamp.am/error-pages/internal/config"
|
|
appHttp "gh.tarampamp.am/error-pages/internal/http"
|
|
"gh.tarampamp.am/error-pages/internal/logger"
|
|
)
|
|
|
|
type command struct {
|
|
c *cli.Command
|
|
|
|
opt struct {
|
|
http struct { // our HTTP server
|
|
addr string
|
|
port uint16
|
|
readBufferSize uint
|
|
}
|
|
}
|
|
}
|
|
|
|
// NewCommand creates `serve` command.
|
|
func NewCommand(log *logger.Logger) *cli.Command { //nolint:funlen,gocognit,gocyclo
|
|
var (
|
|
cmd command
|
|
cfg = config.New()
|
|
env, trim = cli.EnvVars, cli.StringConfig{TrimSpace: true}
|
|
)
|
|
|
|
var (
|
|
addrFlag = shared.ListenAddrFlag
|
|
portFlag = shared.ListenPortFlag
|
|
addTplFlag = shared.AddTemplatesFlag
|
|
disableTplFlag = shared.DisableTemplateNamesFlag
|
|
addCodeFlag = shared.AddHTTPCodesFlag
|
|
disableL10nFlag = shared.DisableL10nFlag
|
|
jsonFormatFlag = cli.StringFlag{
|
|
Name: "json-format",
|
|
Usage: "Override the default error page response in JSON format (Go templates are supported; the error " +
|
|
"page will use this template if the client requests JSON content type)",
|
|
Sources: env("RESPONSE_JSON_FORMAT"),
|
|
Category: shared.CategoryFormats,
|
|
OnlyOnce: true,
|
|
Config: trim,
|
|
}
|
|
xmlFormatFlag = cli.StringFlag{
|
|
Name: "xml-format",
|
|
Usage: "Override the default error page response in XML format (Go templates are supported; the error " +
|
|
"page will use this template if the client requests XML content type)",
|
|
Sources: env("RESPONSE_XML_FORMAT"),
|
|
Category: shared.CategoryFormats,
|
|
OnlyOnce: true,
|
|
Config: trim,
|
|
}
|
|
plainTextFormatFlag = cli.StringFlag{
|
|
Name: "plaintext-format",
|
|
Usage: "Override the default error page response in plain text format (Go templates are supported; the " +
|
|
"error page will use this template if the client requests plain text content type or does not specify any)",
|
|
Sources: env("RESPONSE_PLAINTEXT_FORMAT"),
|
|
Category: shared.CategoryFormats,
|
|
OnlyOnce: 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 (built-in templates: " +
|
|
strings.Join(cfg.Templates.Names(), ", ") + ")",
|
|
Sources: env("TEMPLATE_NAME"),
|
|
Category: shared.CategoryTemplates,
|
|
OnlyOnce: true,
|
|
Config: trim,
|
|
}
|
|
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.DefaultCodeToRender),
|
|
Sources: env("DEFAULT_ERROR_PAGE"),
|
|
Category: shared.CategoryCodes,
|
|
Validator: func(code uint64) error {
|
|
if code > 999 { //nolint:mnd
|
|
return fmt.Errorf("wrong HTTP code [%d] for the default error page", code)
|
|
}
|
|
|
|
return nil
|
|
},
|
|
OnlyOnce: true,
|
|
}
|
|
sendSameHTTPCodeFlag = cli.BoolFlag{
|
|
Name: "send-same-http-code",
|
|
Usage: "The HTTP response should have the same status code as the requested error page (by default, " +
|
|
"every response with an error page will have a status code of 200)",
|
|
Value: cfg.RespondWithSameHTTPCode,
|
|
Sources: env("SEND_SAME_HTTP_CODE"),
|
|
Category: shared.CategoryOther,
|
|
OnlyOnce: true,
|
|
}
|
|
showDetailsFlag = cli.BoolFlag{
|
|
Name: "show-details",
|
|
Usage: "Show request details in the error page response (if supported by the template)",
|
|
Value: cfg.ShowDetails,
|
|
Sources: env("SHOW_DETAILS"),
|
|
Category: shared.CategoryOther,
|
|
OnlyOnce: true,
|
|
}
|
|
proxyHeadersListFlag = cli.StringFlag{
|
|
Name: "proxy-headers",
|
|
Usage: "HTTP headers listed here will be proxied from the original request to the error page response " +
|
|
"(comma-separated list)",
|
|
Value: strings.Join(cfg.ProxyHeaders, ","),
|
|
Sources: env("PROXY_HTTP_HEADERS"),
|
|
Validator: func(s string) error {
|
|
for _, raw := range strings.Split(s, ",") {
|
|
if clean := strings.TrimSpace(raw); strings.ContainsRune(clean, ' ') {
|
|
return fmt.Errorf("whitespaces in the HTTP headers are not allowed: %s", clean)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
},
|
|
Category: shared.CategoryOther,
|
|
OnlyOnce: true,
|
|
Config: trim,
|
|
}
|
|
rotationModeFlag = cli.StringFlag{
|
|
Name: "rotation-mode",
|
|
Value: config.RotationModeDisabled.String(),
|
|
Usage: "Templates automatic rotation mode (" + strings.Join(config.RotationModeStrings(), "/") + ")",
|
|
Sources: env("TEMPLATES_ROTATION_MODE"),
|
|
Category: shared.CategoryTemplates,
|
|
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: "Per-connection buffer size in bytes for reading requests, this also limits the maximum header size " +
|
|
"(increase this buffer if your clients send multi-KB Request URIs and/or multi-KB headers (e.g., " +
|
|
"large cookies), note that increasing this value will increase memory consumption)",
|
|
Value: 1024 * 5, //nolint:mnd // 5 KB
|
|
Sources: env("READ_BUFFER_SIZE"),
|
|
Category: shared.CategoryOther,
|
|
OnlyOnce: true,
|
|
}
|
|
)
|
|
|
|
// override some flag usage messages
|
|
addrFlag.Usage = "The HTTP server will listen on this IP (v4 or v6) address (set 127.0.0.1/::1 for localhost, " +
|
|
"0.0.0.0 to listen on all interfaces, or specify a custom IP)"
|
|
portFlag.Usage = "The TCP port number for the HTTP server to listen on (0-65535)"
|
|
|
|
disableL10nFlag.Value = cfg.L10n.Disable // set the default value depending on the configuration
|
|
|
|
cmd.c = &cli.Command{
|
|
Name: "serve",
|
|
Aliases: []string{"s", "server", "http"},
|
|
Usage: "Please start the HTTP server to serve the error pages. You can configure various options - please RTFM :D",
|
|
Suggest: true,
|
|
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))
|
|
cfg.L10n.Disable = c.Bool(disableL10nFlag.Name)
|
|
cfg.DefaultCodeToRender = uint16(c.Uint(defaultCodeToRenderFlag.Name))
|
|
cfg.RespondWithSameHTTPCode = c.Bool(sendSameHTTPCodeFlag.Name)
|
|
cfg.RotationMode, _ = config.ParseRotationMode(c.String(rotationModeFlag.Name))
|
|
cfg.ShowDetails = c.Bool(showDetailsFlag.Name)
|
|
|
|
{ // override default JSON, XML, and PlainText formats
|
|
if c.IsSet(jsonFormatFlag.Name) {
|
|
cfg.Formats.JSON = strings.TrimSpace(c.String(jsonFormatFlag.Name))
|
|
}
|
|
|
|
if c.IsSet(xmlFormatFlag.Name) {
|
|
cfg.Formats.XML = strings.TrimSpace(c.String(xmlFormatFlag.Name))
|
|
}
|
|
|
|
if c.IsSet(plainTextFormatFlag.Name) {
|
|
cfg.Formats.PlainText = strings.TrimSpace(c.String(plainTextFormatFlag.Name))
|
|
}
|
|
}
|
|
|
|
// add templates from files to the configuration
|
|
if add := c.StringSlice(addTplFlag.Name); len(add) > 0 {
|
|
for _, templatePath := range add {
|
|
if addedName, err := cfg.Templates.AddFromFile(templatePath); err != nil {
|
|
return fmt.Errorf("cannot add template from file %s: %w", templatePath, err)
|
|
} else {
|
|
log.Info("Template added",
|
|
logger.String("name", addedName),
|
|
logger.String("path", templatePath),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// set the list of HTTP headers we need to proxy from the incoming request to the error page response
|
|
if c.IsSet(proxyHeadersListFlag.Name) {
|
|
var m = make(map[string]struct{}) // map is used to avoid duplicates
|
|
|
|
for _, header := range strings.Split(c.String(proxyHeadersListFlag.Name), ",") {
|
|
m[http.CanonicalHeaderKey(strings.TrimSpace(header))] = struct{}{}
|
|
}
|
|
|
|
clear(cfg.ProxyHeaders) // clear the list before adding new headers
|
|
|
|
for header := range m {
|
|
cfg.ProxyHeaders = append(cfg.ProxyHeaders, header)
|
|
}
|
|
}
|
|
|
|
// add custom HTTP codes to the configuration
|
|
if add := c.StringMap(addCodeFlag.Name); len(add) > 0 {
|
|
for code, desc := range shared.ParseHTTPCodes(add) {
|
|
cfg.Codes[code] = desc
|
|
|
|
log.Info("HTTP code added",
|
|
logger.String("code", code),
|
|
logger.String("message", desc.Message),
|
|
logger.String("description", desc.Description),
|
|
)
|
|
}
|
|
}
|
|
|
|
// disable templates specified by the user
|
|
if disable := c.StringSlice(disableTplFlag.Name); len(disable) > 0 {
|
|
for _, templateName := range disable {
|
|
if ok := cfg.Templates.Remove(templateName); ok {
|
|
log.Info("Template disabled", logger.String("name", templateName))
|
|
}
|
|
}
|
|
}
|
|
|
|
// check if there are any templates available to render error pages
|
|
if len(cfg.Templates.Names()) == 0 {
|
|
return errors.New("no templates available to render error pages")
|
|
}
|
|
|
|
// if the rotation mode is set to random-on-startup, pick a random template (ignore the user-provided
|
|
// template name)
|
|
if cfg.RotationMode == config.RotationModeRandomOnStartup {
|
|
cfg.TemplateName = cfg.Templates.RandomName()
|
|
} else { // otherwise, use the user-provided template name
|
|
cfg.TemplateName = c.String(templateNameFlag.Name)
|
|
|
|
if !cfg.Templates.Has(cfg.TemplateName) {
|
|
return fmt.Errorf(
|
|
"template '%s' not found and cannot be used (available templates: %s)",
|
|
cfg.TemplateName,
|
|
cfg.Templates.Names(),
|
|
)
|
|
}
|
|
}
|
|
|
|
log.Debug("Configuration",
|
|
logger.Strings("loaded templates", cfg.Templates.Names()...),
|
|
logger.Strings("described HTTP codes", cfg.Codes.Codes()...),
|
|
logger.String("JSON format", cfg.Formats.JSON),
|
|
logger.String("XML format", cfg.Formats.XML),
|
|
logger.String("plain text format", cfg.Formats.PlainText),
|
|
logger.String("template name", cfg.TemplateName),
|
|
logger.Bool("disable localization", cfg.L10n.Disable),
|
|
logger.Uint16("default code to render", cfg.DefaultCodeToRender),
|
|
logger.Bool("respond with the same HTTP code", cfg.RespondWithSameHTTPCode),
|
|
logger.String("rotation mode", cfg.RotationMode.String()),
|
|
logger.Bool("show details", cfg.ShowDetails),
|
|
logger.Strings("proxy HTTP headers", cfg.ProxyHeaders...),
|
|
)
|
|
|
|
return cmd.Run(ctx, log, &cfg)
|
|
},
|
|
Flags: []cli.Flag{
|
|
&addrFlag,
|
|
&portFlag,
|
|
&addTplFlag,
|
|
&disableTplFlag,
|
|
&addCodeFlag,
|
|
&jsonFormatFlag,
|
|
&xmlFormatFlag,
|
|
&plainTextFormatFlag,
|
|
&templateNameFlag,
|
|
&disableL10nFlag,
|
|
&defaultCodeToRenderFlag,
|
|
&sendSameHTTPCodeFlag,
|
|
&showDetailsFlag,
|
|
&proxyHeadersListFlag,
|
|
&rotationModeFlag,
|
|
&readBufferSizeFlag,
|
|
},
|
|
}
|
|
|
|
return cmd.c
|
|
}
|
|
|
|
// Run current command.
|
|
func (cmd *command) Run(ctx context.Context, log *logger.Logger, cfg *config.Config) error { //nolint:funlen
|
|
var srv = appHttp.NewServer(log, cmd.opt.http.readBufferSize)
|
|
|
|
if err := srv.Register(cfg); err != nil {
|
|
return err
|
|
}
|
|
|
|
var startingErrCh = make(chan error, 1) // channel for server starting error
|
|
defer close(startingErrCh)
|
|
|
|
// to track the frequency of each template's use, we send a simple GET request to the GoatCounter API
|
|
// (https://goatcounter.com, https://github.com/arp242/goatcounter) to increment the counter. this service is
|
|
// free and does not require an API key. no private data is sent, as shown in the URL below. this is necessary
|
|
// to render a badge displaying the number of template usages on the error-pages repository README file :D
|
|
//
|
|
// badge code example:
|
|
// ![Used times](https://img.shields.io/badge/dynamic/json?
|
|
// url=https%3A%2F%2Ferror-pages.goatcounter.com%2Fcounter%2F%2Fuse-template%2Flost-in-space.json
|
|
// &query=%24.count&label=Used%20times)
|
|
//
|
|
// if you wish, you may view the collected statistics at any time here - https://error-pages.goatcounter.com/
|
|
go func() {
|
|
var tpl = url.QueryEscape(cfg.TemplateName)
|
|
|
|
req, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf(
|
|
// https://www.goatcounter.com/help/pixel
|
|
"https://error-pages.goatcounter.com/count?p=/use-template/%s&t=%s", tpl, tpl,
|
|
), http.NoBody)
|
|
if reqErr != nil {
|
|
return
|
|
}
|
|
|
|
req.Header.Set("User-Agent", fmt.Sprintf("Mozilla/5.0 (error-pages, rnd:%d)", time.Now().UnixNano()))
|
|
|
|
resp, respErr := (&http.Client{Timeout: 10 * time.Second}).Do(req) //nolint:mnd // don't care about the response
|
|
if respErr != nil {
|
|
log.Debug("Cannot send a request to increment the template usage counter", logger.Error(respErr))
|
|
|
|
return
|
|
} else if resp != nil {
|
|
_ = resp.Body.Close()
|
|
}
|
|
}()
|
|
|
|
// start HTTP server in separate goroutine
|
|
go func(errCh chan<- error) {
|
|
var now = time.Now()
|
|
|
|
defer func() {
|
|
log.Info("HTTP server stopped", logger.Duration("uptime", time.Since(now).Round(time.Millisecond)))
|
|
}()
|
|
|
|
log.Info("HTTP server starting",
|
|
logger.String("addr", cmd.opt.http.addr),
|
|
logger.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", logger.Duration("with timeout", shutdownTimeout))
|
|
|
|
if err := srv.Stop(shutdownTimeout); err != nil { //nolint:contextcheck
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|