Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 42 additions & 27 deletions httpbin/cmd/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const (
defaultListenHost = "0.0.0.0"
defaultListenPort = 8080
defaultLogFormat = "text"
defaultEnvPrefix = "HTTPBIN_ENV_"

// Reasonable defaults for our http server
srvReadTimeout = 5 * time.Second
Expand All @@ -35,13 +36,13 @@ const (
// 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)
return mainImpl(os.Args[1:], os.Getenv, os.Environ, 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 {
cfg, err := loadConfig(args, getEnv, getHostname)
func mainImpl(args []string, getEnvVal func(string) string, getEnviron func() []string, getHostname func() (string, error), out io.Writer) int {
cfg, err := loadConfig(args, getEnvVal, getEnviron, getHostname)
if err != nil {
if cfgErr, ok := err.(ConfigError); ok {
// for -h/-help, just print usage and exit without error
Expand Down Expand Up @@ -75,6 +76,7 @@ func mainImpl(args []string, getEnv func(string) string, getHostname func() (str
}

opts := []httpbin.OptionFunc{
httpbin.WithEnv(cfg.Env),
httpbin.WithMaxBodySize(cfg.MaxBodySize),
httpbin.WithMaxDuration(cfg.MaxDuration),
httpbin.WithObserver(httpbin.StdLogObserver(logger)),
Expand Down Expand Up @@ -110,6 +112,7 @@ func mainImpl(args []string, getEnv func(string) string, getHostname func() (str
// config holds the configuration needed to initialize and run go-httpbin as a
// standalone server.
type config struct {
Env map[string]string
AllowedRedirectDomains []string
ListenHost string
ExcludeHeaders string
Expand Down Expand Up @@ -144,7 +147,7 @@ func (e ConfigError) Error() string {

// 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) {
func loadConfig(args []string, getEnvVal func(string) string, getEnviron func() []string, getHostname func() (string, error)) (*config, error) {
cfg := &config{}

fs := flag.NewFlagSet("go-httpbin", flag.ContinueOnError)
Expand Down Expand Up @@ -192,24 +195,24 @@ func loadConfig(args []string, getEnv func(string) string, getHostname func() (s
// 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 cfg.MaxBodySize == httpbin.DefaultMaxBodySize && getEnvVal("MAX_BODY_SIZE") != "" {
cfg.MaxBodySize, err = strconv.ParseInt(getEnvVal("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"))
return nil, configErr("invalid value %#v for env var MAX_BODY_SIZE: parse error", getEnvVal("MAX_BODY_SIZE"))
}
}

if cfg.MaxDuration == httpbin.DefaultMaxDuration && getEnv("MAX_DURATION") != "" {
cfg.MaxDuration, err = time.ParseDuration(getEnv("MAX_DURATION"))
if cfg.MaxDuration == httpbin.DefaultMaxDuration && getEnvVal("MAX_DURATION") != "" {
cfg.MaxDuration, err = time.ParseDuration(getEnvVal("MAX_DURATION"))
if err != nil {
return nil, configErr("invalid value %#v for env var MAX_DURATION: parse error", getEnv("MAX_DURATION"))
return nil, configErr("invalid value %#v for env var MAX_DURATION: parse error", getEnvVal("MAX_DURATION"))
}
}
if cfg.ListenHost == defaultListenHost && getEnv("HOST") != "" {
cfg.ListenHost = getEnv("HOST")
if cfg.ListenHost == defaultListenHost && getEnvVal("HOST") != "" {
cfg.ListenHost = getEnvVal("HOST")
}
if cfg.Prefix == "" {
if prefix := getEnv("PREFIX"); prefix != "" {
if prefix := getEnvVal("PREFIX"); prefix != "" {
cfg.Prefix = prefix
}
}
Expand All @@ -221,29 +224,29 @@ func loadConfig(args []string, getEnv func(string) string, getHostname func() (s
return nil, configErr("Prefix %#v must not end with a slash", cfg.Prefix)
}
}
if cfg.ExcludeHeaders == "" && getEnv("EXCLUDE_HEADERS") != "" {
cfg.ExcludeHeaders = getEnv("EXCLUDE_HEADERS")
if cfg.ExcludeHeaders == "" && getEnvVal("EXCLUDE_HEADERS") != "" {
cfg.ExcludeHeaders = getEnvVal("EXCLUDE_HEADERS")
}
if cfg.ListenPort == defaultListenPort && getEnv("PORT") != "" {
cfg.ListenPort, err = strconv.Atoi(getEnv("PORT"))
if cfg.ListenPort == defaultListenPort && getEnvVal("PORT") != "" {
cfg.ListenPort, err = strconv.Atoi(getEnvVal("PORT"))
if err != nil {
return nil, configErr("invalid value %#v for env var PORT: parse error", getEnv("PORT"))
return nil, configErr("invalid value %#v for env var PORT: parse error", getEnvVal("PORT"))
}
}

if cfg.TLSCertFile == "" && getEnv("HTTPS_CERT_FILE") != "" {
cfg.TLSCertFile = getEnv("HTTPS_CERT_FILE")
if cfg.TLSCertFile == "" && getEnvVal("HTTPS_CERT_FILE") != "" {
cfg.TLSCertFile = getEnvVal("HTTPS_CERT_FILE")
}
if cfg.TLSKeyFile == "" && getEnv("HTTPS_KEY_FILE") != "" {
cfg.TLSKeyFile = getEnv("HTTPS_KEY_FILE")
if cfg.TLSKeyFile == "" && getEnvVal("HTTPS_KEY_FILE") != "" {
cfg.TLSKeyFile = getEnvVal("HTTPS_KEY_FILE")
}
if cfg.TLSCertFile != "" || cfg.TLSKeyFile != "" {
if cfg.TLSCertFile == "" || cfg.TLSKeyFile == "" {
return nil, configErr("https cert and key must both be provided")
}
}
if cfg.LogFormat == defaultLogFormat && getEnv("LOG_FORMAT") != "" {
cfg.LogFormat = getEnv("LOG_FORMAT")
if cfg.LogFormat == defaultLogFormat && getEnvVal("LOG_FORMAT") != "" {
cfg.LogFormat = getEnvVal("LOG_FORMAT")
}
if cfg.LogFormat != "text" && cfg.LogFormat != "json" {
return nil, configErr(`invalid log format %q, must be "text" or "json"`, cfg.LogFormat)
Expand All @@ -252,7 +255,7 @@ func loadConfig(args []string, getEnv func(string) string, getHostname func() (s
// 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" {
if useRealHostnameEnv := getEnvVal("USE_REAL_HOSTNAME"); useRealHostnameEnv == "1" || useRealHostnameEnv == "true" {
cfg.rawUseRealHostname = true
}
if cfg.rawUseRealHostname {
Expand All @@ -263,8 +266,8 @@ func loadConfig(args []string, getEnv func(string) string, getHostname func() (s
}

// split comma-separated list of domains into a slice, if given
if cfg.rawAllowedRedirectDomains == "" && getEnv("ALLOWED_REDIRECT_DOMAINS") != "" {
cfg.rawAllowedRedirectDomains = getEnv("ALLOWED_REDIRECT_DOMAINS")
if cfg.rawAllowedRedirectDomains == "" && getEnvVal("ALLOWED_REDIRECT_DOMAINS") != "" {
cfg.rawAllowedRedirectDomains = getEnvVal("ALLOWED_REDIRECT_DOMAINS")
}
for _, domain := range strings.Split(cfg.rawAllowedRedirectDomains, ",") {
if strings.TrimSpace(domain) != "" {
Expand All @@ -275,6 +278,18 @@ func loadConfig(args []string, getEnv func(string) string, getHostname func() (s
// reset temporary fields to their zero values
cfg.rawAllowedRedirectDomains = ""
cfg.rawUseRealHostname = false

for _, envVar := range getEnviron() {
name, value, _ := strings.Cut(envVar, "=")
if !strings.HasPrefix(name, defaultEnvPrefix) {
continue
}
if cfg.Env == nil {
cfg.Env = make(map[string]string)
}
cfg.Env[name] = value
}

return cfg, nil
}

Expand Down
56 changes: 54 additions & 2 deletions httpbin/cmd/cmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"errors"
"flag"
"fmt"
"os"
"reflect"
"testing"
Expand Down Expand Up @@ -77,6 +78,49 @@ func TestLoadConfig(t *testing.T) {
wantErr: flag.ErrHelp,
},

// env
"ok env with empty variables": {
env: map[string]string{},
wantCfg: &config{
Env: nil,
ListenHost: "0.0.0.0",
ListenPort: 8080,
MaxBodySize: httpbin.DefaultMaxBodySize,
MaxDuration: httpbin.DefaultMaxDuration,
LogFormat: defaultLogFormat,
},
},
"ok env with recognized variables": {
env: map[string]string{
fmt.Sprintf("%sFOO", defaultEnvPrefix): "foo",
fmt.Sprintf("%s%sBAR", defaultEnvPrefix, defaultEnvPrefix): "bar",
fmt.Sprintf("%s123", defaultEnvPrefix): "123",
},
wantCfg: &config{
Env: map[string]string{
fmt.Sprintf("%sFOO", defaultEnvPrefix): "foo",
fmt.Sprintf("%s%sBAR", defaultEnvPrefix, defaultEnvPrefix): "bar",
fmt.Sprintf("%s123", defaultEnvPrefix): "123",
},
ListenHost: "0.0.0.0",
ListenPort: 8080,
MaxBodySize: httpbin.DefaultMaxBodySize,
MaxDuration: httpbin.DefaultMaxDuration,
LogFormat: defaultLogFormat,
},
},
"ok env with unrecognized variables": {
env: map[string]string{"HTTPBIN_FOO": "foo", "BAR": "bar"},
wantCfg: &config{
Env: nil,
ListenHost: "0.0.0.0",
ListenPort: 8080,
MaxBodySize: httpbin.DefaultMaxBodySize,
MaxDuration: httpbin.DefaultMaxDuration,
LogFormat: defaultLogFormat,
},
},

// max body size
"invalid -max-body-size": {
args: []string{"-max-body-size", "foo"},
Expand Down Expand Up @@ -515,7 +559,7 @@ func TestLoadConfig(t *testing.T) {
if tc.getHostname == nil {
tc.getHostname = getHostnameDefault
}
cfg, err := loadConfig(tc.args, func(key string) string { return tc.env[key] }, tc.getHostname)
cfg, err := loadConfig(tc.args, func(key string) string { return tc.env[key] }, func() []string { return environSlice(tc.env) }, tc.getHostname)

switch {
case tc.wantErr != nil && err != nil:
Expand Down Expand Up @@ -606,7 +650,7 @@ func TestMainImpl(t *testing.T) {
}

buf := &bytes.Buffer{}
gotCode := mainImpl(tc.args, func(key string) string { return tc.env[key] }, tc.getHostname, buf)
gotCode := mainImpl(tc.args, func(key string) string { return tc.env[key] }, func() []string { return environSlice(tc.env) }, tc.getHostname, buf)
out := buf.String()

if gotCode != tc.wantCode {
Expand All @@ -625,3 +669,11 @@ func TestMainImpl(t *testing.T) {
})
}
}

func environSlice(env map[string]string) []string {
envStrings := make([]string, 0, len(env))
for name, value := range env {
envStrings = append(envStrings, fmt.Sprintf("%s=%s", name, value))
}
return envStrings
}
7 changes: 7 additions & 0 deletions httpbin/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ func (h *HTTPBin) Index(w http.ResponseWriter, r *http.Request) {
writeHTML(w, h.indexHTML, http.StatusOK)
}

// Env - returns environment variables with HTTPBIN_ prefix, if any pre-configured by operator
func (h *HTTPBin) Env(w http.ResponseWriter, _ *http.Request) {
writeJSON(http.StatusOK, w, &envResponse{
Env: h.env,
})
}

// FormsPost renders an HTML form that submits a request to the /post endpoint
func (h *HTTPBin) FormsPost(w http.ResponseWriter, _ *http.Request) {
writeHTML(w, h.formsPostHTML, http.StatusOK)
Expand Down
10 changes: 10 additions & 0 deletions httpbin/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,16 @@ func TestIndex(t *testing.T) {
}
}

func TestEnv(t *testing.T) {
t.Run("default environment", func(t *testing.T) {
t.Parallel()
req := newTestRequest(t, "GET", "/env")
resp := must.DoReq(t, client, req)
result := mustParseResponse[envResponse](t, resp)
assert.Equal(t, len(result.Env), 0, "environment variables unexpected")
})
}

func TestFormsPost(t *testing.T) {
t.Parallel()

Expand Down
5 changes: 5 additions & 0 deletions httpbin/httpbin.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ type HTTPBin struct {
// Set of hosts to which the /redirect-to endpoint will allow redirects
AllowedRedirectDomains map[string]struct{}

// The operator-controlled environment variables filtered from
// the process environment, based on named HTTPBIN_ prefix.
env map[string]string

// Pre-computed error message for the /redirect-to endpoint, based on
// -allowed-redirect-domains/ALLOWED_REDIRECT_DOMAINS
forbiddenRedirectError string
Expand Down Expand Up @@ -159,6 +163,7 @@ func (h *HTTPBin) Handler() http.Handler {
mux.HandleFunc("/digest-auth/{qop}/{user}/{password}/{algorithm}", h.DigestAuth)
mux.HandleFunc("/drip", h.Drip)
mux.HandleFunc("/dump/request", h.DumpRequest)
mux.HandleFunc("/env", h.Env)
mux.HandleFunc("/etag/{etag}", h.ETag)
mux.HandleFunc("/gzip", h.Gzip)
mux.HandleFunc("/headers", h.Headers)
Expand Down
8 changes: 8 additions & 0 deletions httpbin/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@ func WithObserver(o Observer) OptionFunc {
}
}

// WithEnv sets the HTTPBIN_-prefixed environment variables reported
// by the /env endpoint.
func WithEnv(env map[string]string) OptionFunc {
return func(h *HTTPBin) {
h.env = env
}
}

// WithExcludeHeaders sets the headers to exclude in outgoing responses, to
// prevent possible information leakage.
func WithExcludeHeaders(excludeHeaders string) OptionFunc {
Expand Down
4 changes: 4 additions & 0 deletions httpbin/responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ const (
textContentType = "text/plain; charset=utf-8"
)

type envResponse struct {
Env map[string]string `json:"env"`
}

type headersResponse struct {
Headers http.Header `json:"headers"`
}
Expand Down
1 change: 1 addition & 0 deletions httpbin/static/index.html.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@
<li><a href="{{.Prefix}}/drip?code=200&amp;numbytes=5&amp;duration=5"><code>{{.Prefix}}/drip?numbytes=n&amp;duration=s&amp;delay=s&amp;code=code</code></a> Drips data over the given duration after an optional initial delay, simulating a slow HTTP server.</li>
<li><a href="{{.Prefix}}/dump/request"><code>{{.Prefix}}/dump/request</code></a> Returns the given request in its HTTP/1.x wire approximate representation.</li>
<li><a href="{{.Prefix}}/encoding/utf8"><code>{{.Prefix}}/encoding/utf8</code></a> Returns page containing UTF-8 data.</li>
<li><a href="{{.Prefix}}/env"><code>{{.Prefix}}/env</code></a> Returns all environment variables named with <code>HTTPBIN_ENV_</code> prefix.</li>
<li><a href="{{.Prefix}}/etag/etag"><code>{{.Prefix}}/etag/:etag</code></a> Assumes the resource has the given etag and responds to If-None-Match header with a 200 or 304 and If-Match with a 200 or 412 as appropriate.</li>
<li><a href="{{.Prefix}}/forms/post"><code>{{.Prefix}}/forms/post</code></a> HTML form that submits to <em>{{.Prefix}}/post</em></li>
<li><a href="{{.Prefix}}/get"><code>{{.Prefix}}/get</code></a> Returns GET data.</li>
Expand Down