diff --git a/cmd/go-httpbin/main.go b/cmd/go-httpbin/main.go index cb188be4ff7d0be55b2c8619e72fb4a7c209e3ea..6cdcf927fdff62543cf4848f5bbc1202eee20fa5 100644 --- a/cmd/go-httpbin/main.go +++ b/cmd/go-httpbin/main.go @@ -1,188 +1,11 @@ package main import ( - "context" - "flag" - "fmt" - "log" - "net" - "net/http" "os" - "os/signal" - "strconv" - "strings" - "syscall" - "time" - "github.com/mccutchen/go-httpbin/v2/httpbin" -) - -const ( - defaultHost = "0.0.0.0" - defaultPort = 8080 -) - -var ( - allowedRedirectDomains string - host string - httpsCertFile string - httpsKeyFile string - maxBodySize int64 - maxDuration time.Duration - port int - useRealHostname bool + "github.com/mccutchen/go-httpbin/v2/httpbin/cmd" ) func main() { - flag.BoolVar(&useRealHostname, "use-real-hostname", false, "Expose value of os.Hostname() in the /hostname endpoint instead of dummy value") - flag.DurationVar(&maxDuration, "max-duration", httpbin.DefaultMaxDuration, "Maximum duration a response may take") - flag.Int64Var(&maxBodySize, "max-body-size", httpbin.DefaultMaxBodySize, "Maximum size of request or response, in bytes") - flag.IntVar(&port, "port", defaultPort, "Port to listen on") - flag.StringVar(&allowedRedirectDomains, "allowed-redirect-domains", "", "Comma-separated list of domains the /redirect-to endpoint will allow") - flag.StringVar(&host, "host", defaultHost, "Host to listen on") - flag.StringVar(&httpsCertFile, "https-cert-file", "", "HTTPS Server certificate file") - flag.StringVar(&httpsKeyFile, "https-key-file", "", "HTTPS Server private key file") - flag.Parse() - - // Command line flags take precedence over environment vars, so we only - // check for environment vars if we have default values for our command - // line flags. - var err error - if maxBodySize == httpbin.DefaultMaxBodySize && os.Getenv("MAX_BODY_SIZE") != "" { - maxBodySize, err = strconv.ParseInt(os.Getenv("MAX_BODY_SIZE"), 10, 64) - if err != nil { - fmt.Fprintf(os.Stderr, "Error: invalid value %#v for env var MAX_BODY_SIZE: %s\n\n", os.Getenv("MAX_BODY_SIZE"), err) - flag.Usage() - os.Exit(1) - } - } - if maxDuration == httpbin.DefaultMaxDuration && os.Getenv("MAX_DURATION") != "" { - maxDuration, err = time.ParseDuration(os.Getenv("MAX_DURATION")) - if err != nil { - fmt.Fprintf(os.Stderr, "Error: invalid value %#v for env var MAX_DURATION: %s\n\n", os.Getenv("MAX_DURATION"), err) - flag.Usage() - os.Exit(1) - } - } - if host == defaultHost && os.Getenv("HOST") != "" { - host = os.Getenv("HOST") - } - if port == defaultPort && os.Getenv("PORT") != "" { - port, err = strconv.Atoi(os.Getenv("PORT")) - if err != nil { - fmt.Fprintf(os.Stderr, "Error: invalid value %#v for env var PORT: %s\n\n", os.Getenv("PORT"), err) - flag.Usage() - os.Exit(1) - } - } - - if httpsCertFile == "" && os.Getenv("HTTPS_CERT_FILE") != "" { - httpsCertFile = os.Getenv("HTTPS_CERT_FILE") - } - if httpsKeyFile == "" && os.Getenv("HTTPS_KEY_FILE") != "" { - httpsKeyFile = os.Getenv("HTTPS_KEY_FILE") - } - - var serveTLS bool - if httpsCertFile != "" || httpsKeyFile != "" { - serveTLS = true - if httpsCertFile == "" || httpsKeyFile == "" { - fmt.Fprintf(os.Stderr, "Error: https cert and key must both be provided\n\n") - flag.Usage() - os.Exit(1) - } - } - - // useRealHostname will be true if either the `-use-real-hostname` - // arg is given on the command line or if the USE_REAL_HOSTNAME env var - // is one of "1" or "true". - if useRealHostnameEnv := os.Getenv("USE_REAL_HOSTNAME"); useRealHostnameEnv == "1" || useRealHostnameEnv == "true" { - useRealHostname = true - } - - var allowedRedirectDomainsList []string - if allowedRedirectDomains == "" && os.Getenv("ALLOWED_REDIRECT_DOMAINS") != "" { - allowedRedirectDomains = os.Getenv("ALLOWED_REDIRECT_DOMAINS") - } - for _, domain := range strings.Split(allowedRedirectDomains, ",") { - if strings.TrimSpace(domain) != "" { - allowedRedirectDomainsList = append(allowedRedirectDomainsList, strings.TrimSpace(domain)) - } - } - - logger := log.New(os.Stderr, "", 0) - - // A hacky log helper function to ensure that shutdown messages are - // formatted the same as other messages. See StdLogObserver in - // httpbin/middleware.go for the format we're matching here. - serverLog := func(msg string, args ...interface{}) { - const ( - logFmt = "time=%q msg=%q" - dateFmt = "2006-01-02T15:04:05.9999" - ) - logger.Printf(logFmt, time.Now().Format(dateFmt), fmt.Sprintf(msg, args...)) - } - - opts := []httpbin.OptionFunc{ - httpbin.WithMaxBodySize(maxBodySize), - httpbin.WithMaxDuration(maxDuration), - httpbin.WithObserver(httpbin.StdLogObserver(logger)), - } - if useRealHostname { - hostname, err := os.Hostname() - if err != nil { - fmt.Fprintf(os.Stderr, "Error: use-real-hostname=true but hostname lookup failed: %s\n", err) - os.Exit(1) - } - opts = append(opts, httpbin.WithHostname(hostname)) - } - if len(allowedRedirectDomainsList) > 0 { - opts = append(opts, httpbin.WithAllowedRedirectDomains(allowedRedirectDomainsList)) - } - h := httpbin.New(opts...) - - listenAddr := net.JoinHostPort(host, strconv.Itoa(port)) - - server := &http.Server{ - Addr: listenAddr, - Handler: h.Handler(), - } - - // shutdownCh triggers graceful shutdown on SIGINT or SIGTERM - shutdownCh := make(chan os.Signal, 1) - signal.Notify(shutdownCh, syscall.SIGINT, syscall.SIGTERM) - - // exitCh will be closed when it is safe to exit, after graceful shutdown - exitCh := make(chan struct{}) - - go func() { - sig := <-shutdownCh - serverLog("shutdown started by signal: %s", sig) - - shutdownTimeout := maxDuration + 1*time.Second - ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) - defer cancel() - - server.SetKeepAlivesEnabled(false) - if err := server.Shutdown(ctx); err != nil { - serverLog("shutdown error: %s", err) - } - - close(exitCh) - }() - - var listenErr error - if serveTLS { - serverLog("go-httpbin listening on https://%s", listenAddr) - listenErr = server.ListenAndServeTLS(httpsCertFile, httpsKeyFile) - } else { - serverLog("go-httpbin listening on http://%s", listenAddr) - listenErr = server.ListenAndServe() - } - if listenErr != nil && listenErr != http.ErrServerClosed { - logger.Fatalf("failed to listen: %s", listenErr) - } - - <-exitCh - serverLog("shutdown finished") + os.Exit(cmd.Main()) } diff --git a/httpbin/cmd/cmd.go b/httpbin/cmd/cmd.go new file mode 100644 index 0000000000000000000000000000000000000000..97d6736586c35484c25149932d5635b84fe66780 --- /dev/null +++ b/httpbin/cmd/cmd.go @@ -0,0 +1,267 @@ +package cmd + +import ( + "bytes" + "context" + "flag" + "fmt" + "io" + "log" + "net" + "net/http" + "os" + "os/signal" + "strconv" + "strings" + "syscall" + "time" + + "github.com/mccutchen/go-httpbin/v2/httpbin" +) + +const ( + defaultListenHost = "0.0.0.0" + defaultListenPort = 8080 + + // Reasonable defaults for our http server + srvReadTimeout = 5 * time.Second + srvReadHeaderTimeout = 1 * time.Second + srvMaxHeaderBytes = 16 * 1024 // 16kb +) + +// Main is the main entrypoint for the go-httpbin binary. See loadConfig() for +// command line argument parsing. +func Main() int { + return mainImpl(os.Args[1:], os.Getenv, os.Hostname, os.Stderr) +} + +// mainImpl is the real implementation of Main(), extracted for better +// testability. +func mainImpl(args []string, getEnv func(string) string, getHostname func() (string, error), out io.Writer) int { + logger := log.New(out, "", 0) + + cfg, err := loadConfig(args, getEnv, getHostname) + if err != nil { + if cfgErr, ok := err.(ConfigError); ok { + // for -h/-help, just print usage and exit without error + if cfgErr.Err == flag.ErrHelp { + fmt.Fprint(out, cfgErr.Usage) + return 0 + } + + // anything else indicates a problem with CLI arguments and/or + // environment vars, so print error and usage and exit with an + // error status. + // + // note: seems like there's consensus that an exit code of 2 is + // often used to indicate a problem with the way a command was + // called, e.g.: + // https://stackoverflow.com/a/40484670/151221 + // https://linuxconfig.org/list-of-exit-codes-on-linux + fmt.Fprintf(out, "error: %s\n\n%s", cfgErr.Err, cfgErr.Usage) + return 2 + } + fmt.Fprintf(out, "error: %s", err) + return 1 + } + + opts := []httpbin.OptionFunc{ + httpbin.WithMaxBodySize(cfg.MaxBodySize), + httpbin.WithMaxDuration(cfg.MaxDuration), + httpbin.WithObserver(httpbin.StdLogObserver(logger)), + } + if cfg.RealHostname != "" { + opts = append(opts, httpbin.WithHostname(cfg.RealHostname)) + } + if len(cfg.AllowedRedirectDomains) > 0 { + opts = append(opts, httpbin.WithAllowedRedirectDomains(cfg.AllowedRedirectDomains)) + } + app := httpbin.New(opts...) + + srv := &http.Server{ + Addr: net.JoinHostPort(cfg.ListenHost, strconv.Itoa(cfg.ListenPort)), + Handler: app.Handler(), + MaxHeaderBytes: srvMaxHeaderBytes, + ReadHeaderTimeout: srvReadHeaderTimeout, + ReadTimeout: srvReadTimeout, + } + + if err := listenAndServeGracefully(srv, cfg, logger); err != nil { + logger.Printf("error: %s", err) + return 1 + } + + return 0 +} + +// config holds the configuration needed to initialize and run go-httpbin as a +// standalone server. +type config struct { + AllowedRedirectDomains []string + ListenHost string + ListenPort int + MaxBodySize int64 + MaxDuration time.Duration + RealHostname string + TLSCertFile string + TLSKeyFile string + + // temporary placeholders for arguments that need extra processing + rawAllowedRedirectDomains string + rawUseRealHostname bool +} + +// ConfigError is used to signal an error with a command line argument or +// environmment variable. +// +// It carries the command's usage output, so that we can decouple configuration +// parsing from error reporting for better testability. +type ConfigError struct { + Err error + Usage string +} + +// Error implements the error interface. +func (e ConfigError) Error() string { + return e.Err.Error() +} + +// loadConfig parses command line arguments and env vars into a fully resolved +// Config struct. Command line arguments take precedence over env vars. +func loadConfig(args []string, getEnv func(string) string, getHostname func() (string, error)) (*config, error) { + cfg := &config{} + + fs := flag.NewFlagSet("", flag.ContinueOnError) + fs.BoolVar(&cfg.rawUseRealHostname, "use-real-hostname", false, "Expose value of os.Hostname() in the /hostname endpoint instead of dummy value") + fs.DurationVar(&cfg.MaxDuration, "max-duration", httpbin.DefaultMaxDuration, "Maximum duration a response may take") + fs.Int64Var(&cfg.MaxBodySize, "max-body-size", httpbin.DefaultMaxBodySize, "Maximum size of request or response, in bytes") + fs.IntVar(&cfg.ListenPort, "port", defaultListenPort, "Port to listen on") + fs.StringVar(&cfg.rawAllowedRedirectDomains, "allowed-redirect-domains", "", "Comma-separated list of domains the /redirect-to endpoint will allow") + fs.StringVar(&cfg.ListenHost, "host", defaultListenHost, "Host to listen on") + fs.StringVar(&cfg.TLSCertFile, "https-cert-file", "", "HTTPS Server certificate file") + fs.StringVar(&cfg.TLSKeyFile, "https-key-file", "", "HTTPS Server private key file") + + // in order to fully control error output whether CLI arguments or env vars + // are used to configure the app, we need to take control away from the + // flagset, which by defaults prints errors automatically. + // + // so, we capture the "usage" output it would generate and then trick it + // into generating no output on errors, since they'll be handled by the + // caller. + // + // yes, this is goofy, but it makes the CLI testable! + buf := &bytes.Buffer{} + fs.SetOutput(buf) + fs.Usage() + usage := buf.String() + fs.SetOutput(io.Discard) + + if err := fs.Parse(args); err != nil { + return nil, ConfigError{err, usage} + } + + // helper to generate a new ConfigError to return + configErr := func(format string, a ...interface{}) error { + return ConfigError{ + Err: fmt.Errorf(format, a...), + Usage: usage, + } + } + + var err error + + // Command line flags take precedence over environment vars, so we only + // check for environment vars if we have default values for our command + // line flags. + if cfg.MaxBodySize == httpbin.DefaultMaxBodySize && getEnv("MAX_BODY_SIZE") != "" { + cfg.MaxBodySize, err = strconv.ParseInt(getEnv("MAX_BODY_SIZE"), 10, 64) + if err != nil { + return nil, configErr("invalid value %#v for env var MAX_BODY_SIZE: parse error", getEnv("MAX_BODY_SIZE")) + } + } + + if cfg.MaxDuration == httpbin.DefaultMaxDuration && getEnv("MAX_DURATION") != "" { + cfg.MaxDuration, err = time.ParseDuration(getEnv("MAX_DURATION")) + if err != nil { + return nil, configErr("invalid value %#v for env var MAX_DURATION: parse error", getEnv("MAX_DURATION")) + } + } + if cfg.ListenHost == defaultListenHost && getEnv("HOST") != "" { + cfg.ListenHost = getEnv("HOST") + } + if cfg.ListenPort == defaultListenPort && getEnv("PORT") != "" { + cfg.ListenPort, err = strconv.Atoi(getEnv("PORT")) + if err != nil { + return nil, configErr("invalid value %#v for env var PORT: parse error", getEnv("PORT")) + } + } + + if cfg.TLSCertFile == "" && getEnv("HTTPS_CERT_FILE") != "" { + cfg.TLSCertFile = getEnv("HTTPS_CERT_FILE") + } + if cfg.TLSKeyFile == "" && getEnv("HTTPS_KEY_FILE") != "" { + cfg.TLSKeyFile = getEnv("HTTPS_KEY_FILE") + } + if cfg.TLSCertFile != "" || cfg.TLSKeyFile != "" { + if cfg.TLSCertFile == "" || cfg.TLSKeyFile == "" { + return nil, configErr("https cert and key must both be provided") + } + } + + // useRealHostname will be true if either the `-use-real-hostname` + // arg is given on the command line or if the USE_REAL_HOSTNAME env var + // is one of "1" or "true". + if useRealHostnameEnv := getEnv("USE_REAL_HOSTNAME"); useRealHostnameEnv == "1" || useRealHostnameEnv == "true" { + cfg.rawUseRealHostname = true + } + if cfg.rawUseRealHostname { + cfg.RealHostname, err = getHostname() + if err != nil { + return nil, fmt.Errorf("could not look up real hostname: %w", err) + } + } + + // split comma-separated list of domains into a slice, if given + if cfg.rawAllowedRedirectDomains == "" && getEnv("ALLOWED_REDIRECT_DOMAINS") != "" { + cfg.rawAllowedRedirectDomains = getEnv("ALLOWED_REDIRECT_DOMAINS") + } + for _, domain := range strings.Split(cfg.rawAllowedRedirectDomains, ",") { + if strings.TrimSpace(domain) != "" { + cfg.AllowedRedirectDomains = append(cfg.AllowedRedirectDomains, strings.TrimSpace(domain)) + } + } + + // reset temporary fields to their zero values + cfg.rawAllowedRedirectDomains = "" + cfg.rawUseRealHostname = false + return cfg, nil +} + +func listenAndServeGracefully(srv *http.Server, cfg *config, logger *log.Logger) error { + doneCh := make(chan error, 1) + + go func() { + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT) + <-sigCh + + logger.Printf("shutting down ...") + ctx, cancel := context.WithTimeout(context.Background(), cfg.MaxDuration+1*time.Second) + defer cancel() + doneCh <- srv.Shutdown(ctx) + }() + + var err error + if cfg.TLSCertFile != "" && cfg.TLSKeyFile != "" { + logger.Printf("go-httpbin listening on https://%s", srv.Addr) + err = srv.ListenAndServeTLS(cfg.TLSCertFile, cfg.TLSKeyFile) + } else { + logger.Printf("go-httpbin listening on http://%s", srv.Addr) + err = srv.ListenAndServe() + } + if err != nil && err != http.ErrServerClosed { + return err + } + + return <-doneCh +} diff --git a/httpbin/cmd/cmd_test.go b/httpbin/cmd/cmd_test.go new file mode 100644 index 0000000000000000000000000000000000000000..146cfe2f2978785bf86d79f2c8ecbe8ba8e9627b --- /dev/null +++ b/httpbin/cmd/cmd_test.go @@ -0,0 +1,498 @@ +package cmd + +import ( + "bytes" + "errors" + "flag" + "os" + "reflect" + "testing" + "time" + + "github.com/mccutchen/go-httpbin/v2/httpbin" +) + +// To update, run: +// make && ./dist/go-httpbin -h 2>&1 | pbcopy +const usage = `Usage: + -allowed-redirect-domains string + Comma-separated list of domains the /redirect-to endpoint will allow + -host string + Host to listen on (default "0.0.0.0") + -https-cert-file string + HTTPS Server certificate file + -https-key-file string + HTTPS Server private key file + -max-body-size int + Maximum size of request or response, in bytes (default 1048576) + -max-duration duration + Maximum duration a response may take (default 10s) + -port int + Port to listen on (default 8080) + -use-real-hostname + Expose value of os.Hostname() in the /hostname endpoint instead of dummy value +` + +func TestLoadConfig(t *testing.T) { + t.Parallel() + + testDefaultRealHostname := "real-hostname.test" + getHostnameDefault := func() (string, error) { + return testDefaultRealHostname, nil + } + + testCases := map[string]struct { + args []string + env map[string]string + getHostname func() (string, error) + wantCfg *config + wantErr error + wantOut string + }{ + "defaults": { + wantCfg: &config{ + ListenHost: "0.0.0.0", + ListenPort: 8080, + MaxBodySize: httpbin.DefaultMaxBodySize, + MaxDuration: httpbin.DefaultMaxDuration, + }, + }, + "-h": { + args: []string{"-h"}, + wantErr: flag.ErrHelp, + }, + "-help": { + args: []string{"-help"}, + wantErr: flag.ErrHelp, + }, + + // max body size + "invalid -max-body-size": { + args: []string{"-max-body-size", "foo"}, + wantErr: errors.New("invalid value \"foo\" for flag -max-body-size: parse error"), + }, + "invalid MAX_BODY_SIZE": { + env: map[string]string{"MAX_BODY_SIZE": "foo"}, + wantErr: errors.New("invalid value \"foo\" for env var MAX_BODY_SIZE: parse error"), + }, + "ok -max-body-size": { + args: []string{"-max-body-size", "99"}, + wantCfg: &config{ + ListenHost: "0.0.0.0", + ListenPort: 8080, + MaxBodySize: 99, + MaxDuration: httpbin.DefaultMaxDuration, + }, + }, + "ok MAX_BODY_SIZE": { + env: map[string]string{"MAX_BODY_SIZE": "9999"}, + wantCfg: &config{ + ListenHost: "0.0.0.0", + ListenPort: 8080, + MaxBodySize: 9999, + MaxDuration: httpbin.DefaultMaxDuration, + }, + }, + "ok max body size CLI takes precedence over env": { + args: []string{"-max-body-size", "1234"}, + env: map[string]string{"MAX_BODY_SIZE": "5678"}, + wantCfg: &config{ + ListenHost: "0.0.0.0", + ListenPort: 8080, + MaxBodySize: 1234, + MaxDuration: httpbin.DefaultMaxDuration, + }, + }, + + // max duration + "invalid -max-duration": { + args: []string{"-max-duration", "foo"}, + wantErr: errors.New("invalid value \"foo\" for flag -max-duration: parse error"), + }, + "invalid MAX_DURATION": { + env: map[string]string{"MAX_DURATION": "foo"}, + wantErr: errors.New("invalid value \"foo\" for env var MAX_DURATION: parse error"), + }, + "ok -max-duration": { + args: []string{"-max-duration", "99s"}, + wantCfg: &config{ + ListenHost: "0.0.0.0", + ListenPort: 8080, + MaxBodySize: httpbin.DefaultMaxBodySize, + MaxDuration: 99 * time.Second, + }, + }, + "ok MAX_DURATION": { + env: map[string]string{"MAX_DURATION": "9999s"}, + wantCfg: &config{ + ListenHost: "0.0.0.0", + ListenPort: 8080, + MaxBodySize: httpbin.DefaultMaxBodySize, + MaxDuration: 9999 * time.Second, + }, + }, + "ok max duration size CLI takes precedence over env": { + args: []string{"-max-duration", "1234s"}, + env: map[string]string{"MAX_DURATION": "5678s"}, + wantCfg: &config{ + ListenHost: "0.0.0.0", + ListenPort: 8080, + MaxBodySize: httpbin.DefaultMaxBodySize, + MaxDuration: 1234 * time.Second, + }, + }, + + // host + "ok -host": { + args: []string{"-host", "192.0.0.1"}, + wantCfg: &config{ + ListenHost: "192.0.0.1", + ListenPort: 8080, + MaxBodySize: httpbin.DefaultMaxBodySize, + MaxDuration: httpbin.DefaultMaxDuration, + }, + }, + "ok HOST": { + env: map[string]string{"HOST": "192.0.0.2"}, + wantCfg: &config{ + ListenHost: "192.0.0.2", + ListenPort: 8080, + MaxBodySize: httpbin.DefaultMaxBodySize, + MaxDuration: httpbin.DefaultMaxDuration, + }, + }, + "ok host cli takes precedence over end": { + args: []string{"-host", "99.99.99.99"}, + env: map[string]string{"HOST": "11.11.11.11"}, + wantCfg: &config{ + ListenHost: "99.99.99.99", + ListenPort: 8080, + MaxBodySize: httpbin.DefaultMaxBodySize, + MaxDuration: httpbin.DefaultMaxDuration, + }, + }, + + // port + "invalid -port": { + args: []string{"-port", "foo"}, + wantErr: errors.New("invalid value \"foo\" for flag -port: parse error"), + }, + "invalid PORT": { + env: map[string]string{"PORT": "foo"}, + wantErr: errors.New("invalid value \"foo\" for env var PORT: parse error"), + }, + "ok -port": { + args: []string{"-port", "99"}, + wantCfg: &config{ + ListenHost: defaultListenHost, + ListenPort: 99, + MaxBodySize: httpbin.DefaultMaxBodySize, + MaxDuration: httpbin.DefaultMaxDuration, + }, + }, + "ok PORT": { + env: map[string]string{"PORT": "9999"}, + wantCfg: &config{ + ListenHost: defaultListenHost, + ListenPort: 9999, + MaxBodySize: httpbin.DefaultMaxBodySize, + MaxDuration: httpbin.DefaultMaxDuration, + }, + }, + "ok port CLI takes precedence over env": { + args: []string{"-port", "1234"}, + env: map[string]string{"PORT": "5678"}, + wantCfg: &config{ + ListenHost: defaultListenHost, + ListenPort: 1234, + MaxBodySize: httpbin.DefaultMaxBodySize, + MaxDuration: httpbin.DefaultMaxDuration, + }, + }, + + // https cert file + "https cert and key must both be provided, cert only": { + args: []string{"-https-cert-file", "/tmp/test.crt"}, + wantErr: errors.New("https cert and key must both be provided"), + }, + "https cert and key must both be provided, key only": { + args: []string{"-https-key-file", "/tmp/test.crt"}, + wantErr: errors.New("https cert and key must both be provided"), + }, + "ok https CLI": { + args: []string{ + "-https-cert-file", "/tmp/test.crt", + "-https-key-file", "/tmp/test.key", + }, + wantCfg: &config{ + ListenHost: "0.0.0.0", + ListenPort: 8080, + MaxBodySize: httpbin.DefaultMaxBodySize, + MaxDuration: httpbin.DefaultMaxDuration, + TLSCertFile: "/tmp/test.crt", + TLSKeyFile: "/tmp/test.key", + }, + }, + "ok https env": { + env: map[string]string{ + "HTTPS_CERT_FILE": "/tmp/test.crt", + "HTTPS_KEY_FILE": "/tmp/test.key", + }, + wantCfg: &config{ + ListenHost: "0.0.0.0", + ListenPort: 8080, + MaxBodySize: httpbin.DefaultMaxBodySize, + MaxDuration: httpbin.DefaultMaxDuration, + TLSCertFile: "/tmp/test.crt", + TLSKeyFile: "/tmp/test.key", + }, + }, + "ok https CLI takes precedence over env": { + args: []string{ + "-https-cert-file", "/tmp/cli.crt", + "-https-key-file", "/tmp/cli.key", + }, + env: map[string]string{ + "HTTPS_CERT_FILE": "/tmp/env.crt", + "HTTPS_KEY_FILE": "/tmp/env.key", + }, + wantCfg: &config{ + ListenHost: "0.0.0.0", + ListenPort: 8080, + MaxBodySize: httpbin.DefaultMaxBodySize, + MaxDuration: httpbin.DefaultMaxDuration, + TLSCertFile: "/tmp/cli.crt", + TLSKeyFile: "/tmp/cli.key", + }, + }, + + // use-real-hostname + "ok -use-real-hostname": { + args: []string{"-use-real-hostname"}, + wantCfg: &config{ + ListenHost: "0.0.0.0", + ListenPort: 8080, + MaxBodySize: httpbin.DefaultMaxBodySize, + MaxDuration: httpbin.DefaultMaxDuration, + RealHostname: testDefaultRealHostname, + }, + }, + "ok -use-real-hostname=1": { + args: []string{"-use-real-hostname", "1"}, + wantCfg: &config{ + ListenHost: "0.0.0.0", + ListenPort: 8080, + MaxBodySize: httpbin.DefaultMaxBodySize, + MaxDuration: httpbin.DefaultMaxDuration, + RealHostname: testDefaultRealHostname, + }, + }, + "ok -use-real-hostname=true": { + args: []string{"-use-real-hostname", "true"}, + wantCfg: &config{ + ListenHost: "0.0.0.0", + ListenPort: 8080, + MaxBodySize: httpbin.DefaultMaxBodySize, + MaxDuration: httpbin.DefaultMaxDuration, + RealHostname: testDefaultRealHostname, + }, + }, + // any value for the argument is interpreted as true + "ok -use-real-hostname=0": { + args: []string{"-use-real-hostname", "0"}, + wantCfg: &config{ + ListenHost: "0.0.0.0", + ListenPort: 8080, + MaxBodySize: httpbin.DefaultMaxBodySize, + MaxDuration: httpbin.DefaultMaxDuration, + RealHostname: testDefaultRealHostname, + }, + }, + "ok USE_REAL_HOSTNAME=1": { + env: map[string]string{"USE_REAL_HOSTNAME": "1"}, + wantCfg: &config{ + ListenHost: "0.0.0.0", + ListenPort: 8080, + MaxBodySize: httpbin.DefaultMaxBodySize, + MaxDuration: httpbin.DefaultMaxDuration, + RealHostname: testDefaultRealHostname, + }, + }, + "ok USE_REAL_HOSTNAME=true": { + env: map[string]string{"USE_REAL_HOSTNAME": "true"}, + wantCfg: &config{ + ListenHost: "0.0.0.0", + ListenPort: 8080, + MaxBodySize: httpbin.DefaultMaxBodySize, + MaxDuration: httpbin.DefaultMaxDuration, + RealHostname: testDefaultRealHostname, + }, + }, + // case sensitive + "ok USE_REAL_HOSTNAME=TRUE": { + env: map[string]string{"USE_REAL_HOSTNAME": "TRUE"}, + wantCfg: &config{ + ListenHost: "0.0.0.0", + ListenPort: 8080, + MaxBodySize: httpbin.DefaultMaxBodySize, + MaxDuration: httpbin.DefaultMaxDuration, + }, + }, + "ok USE_REAL_HOSTNAME=false": { + env: map[string]string{"USE_REAL_HOSTNAME": "false"}, + wantCfg: &config{ + ListenHost: "0.0.0.0", + ListenPort: 8080, + MaxBodySize: httpbin.DefaultMaxBodySize, + MaxDuration: httpbin.DefaultMaxDuration, + }, + }, + "err real hostname error": { + env: map[string]string{"USE_REAL_HOSTNAME": "true"}, + getHostname: func() (string, error) { return "", errors.New("hostname error") }, + wantErr: errors.New("could not look up real hostname: hostname error"), + }, + + // allowed-redirect-domains + "ok -allowed-redirect-domains": { + args: []string{"-allowed-redirect-domains", "foo,bar"}, + wantCfg: &config{ + ListenHost: "0.0.0.0", + ListenPort: 8080, + MaxBodySize: httpbin.DefaultMaxBodySize, + MaxDuration: httpbin.DefaultMaxDuration, + AllowedRedirectDomains: []string{"foo", "bar"}, + }, + }, + "ok ALLOWED_REDIRECT_DOMAINS": { + env: map[string]string{"ALLOWED_REDIRECT_DOMAINS": "foo,bar"}, + wantCfg: &config{ + ListenHost: "0.0.0.0", + ListenPort: 8080, + MaxBodySize: httpbin.DefaultMaxBodySize, + MaxDuration: httpbin.DefaultMaxDuration, + AllowedRedirectDomains: []string{"foo", "bar"}, + }, + }, + "ok allowed redirect domains CLI takes precedence over env": { + args: []string{"-allowed-redirect-domains", "foo.cli,bar.cli"}, + env: map[string]string{"ALLOWED_REDIRECT_DOMAINS": "foo.env,bar.env"}, + wantCfg: &config{ + ListenHost: "0.0.0.0", + ListenPort: 8080, + MaxBodySize: httpbin.DefaultMaxBodySize, + MaxDuration: httpbin.DefaultMaxDuration, + AllowedRedirectDomains: []string{"foo.cli", "bar.cli"}, + }, + }, + "ok allowed redirect domains are normalized": { + args: []string{"-allowed-redirect-domains", "foo, bar ,, baz "}, + wantCfg: &config{ + ListenHost: "0.0.0.0", + ListenPort: 8080, + MaxBodySize: httpbin.DefaultMaxBodySize, + MaxDuration: httpbin.DefaultMaxDuration, + AllowedRedirectDomains: []string{"foo", "bar", "baz"}, + }, + }, + } + + for name, tc := range testCases { + tc := tc + t.Run(name, func(t *testing.T) { + t.Parallel() + + if tc.getHostname == nil { + tc.getHostname = getHostnameDefault + } + cfg, err := loadConfig(tc.args, func(key string) string { return tc.env[key] }, tc.getHostname) + + switch { + case tc.wantErr != nil && err != nil: + if tc.wantErr.Error() != err.Error() { + t.Fatalf("incorrect error\nwant: %q\ngot: %q", tc.wantErr, err) + } + case tc.wantErr != nil: + t.Fatalf("want error %q, got nil", tc.wantErr) + case err != nil: + t.Fatalf("got unexpected error: %q", err) + } + + if !reflect.DeepEqual(tc.wantCfg, cfg) { + t.Fatalf("bad config\nwant: %#v\ngot: %#v", tc.wantCfg, cfg) + } + }) + } +} + +func TestMainImpl(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + args []string + env map[string]string + getHostname func() (string, error) + wantCode int + wantOut string + }{ + "help": { + args: []string{"-h"}, + wantCode: 0, + wantOut: usage, + }, + "cli error": { + args: []string{"-max-body-size", "foo"}, + wantCode: 2, + wantOut: "error: invalid value \"foo\" for flag -max-body-size: parse error\n\n" + usage, + }, + "unknown argument": { + args: []string{"-zzz"}, + wantCode: 2, + wantOut: "error: flag provided but not defined: -zzz\n\n" + usage, + }, + "real hostname error": { + args: []string{"-use-real-hostname"}, + getHostname: func() (string, error) { return "", errors.New("hostname failure") }, + wantCode: 1, + wantOut: "error: could not look up real hostname: hostname failure", + }, + "server error": { + args: []string{"-port", "-256"}, + wantCode: 1, + wantOut: "go-httpbin listening on http://0.0.0.0:-256\nerror: listen tcp: address -256: invalid port\n", + }, + "tls cert error": { + args: []string{ + "-port", "0", + "-https-cert-file", "./https-cert-does-not-exist", + "-https-key-file", "./https-key-does-not-exist", + }, + wantCode: 1, + wantOut: "go-httpbin listening on https://0.0.0.0:0\nerror: open ./https-cert-does-not-exist: no such file or directory\n", + }, + } + + for name, tc := range testCases { + tc := tc + t.Run(name, func(t *testing.T) { + t.Parallel() + + if tc.getHostname == nil { + tc.getHostname = os.Hostname + } + + buf := &bytes.Buffer{} + gotCode := mainImpl(tc.args, func(key string) string { return tc.env[key] }, tc.getHostname, buf) + out := buf.String() + + if gotCode != tc.wantCode { + t.Logf("unexpected error: output:\n%s", out) + t.Fatalf("expected return code %d, got %d", tc.wantCode, gotCode) + } + + if out != tc.wantOut { + t.Fatalf("output mismatch error:\nwant: %q\ngot: %q", tc.wantOut, out) + } + }) + } +}