Skip to content

Commit

Permalink
feat: add /env endpoint to allow exposing operator-controlled info fr…
Browse files Browse the repository at this point in the history
…om the server (#189)

Fixes #114
  • Loading branch information
mloskot authored Sep 27, 2024
1 parent 34a21a3 commit ce8d747
Show file tree
Hide file tree
Showing 8 changed files with 131 additions and 29 deletions.
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

0 comments on commit ce8d747

Please sign in to comment.