Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add /env endpoint to allow exposing operator-controlled info from the server #189

Merged
merged 13 commits into from
Sep 27, 2024
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 @@
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 @@
// 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)

Check warning on line 39 in httpbin/cmd/cmd.go

View check run for this annotation

Codecov / codecov/patch

httpbin/cmd/cmd.go#L39

Added line #L39 was not covered by tests
}

// 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 @@
}

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 @@
// 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 @@

// 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 @@
// 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 @@
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")

Check warning on line 228 in httpbin/cmd/cmd.go

View check run for this annotation

Codecov / codecov/patch

httpbin/cmd/cmd.go#L228

Added line #L228 was not covered by tests
}
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 @@
// 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 @@
}

// 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 @@
// 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 @@
}
}

// 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

Check warning on line 53 in httpbin/options.go

View check run for this annotation

Codecov / codecov/patch

httpbin/options.go#L51-L53

Added lines #L51 - L53 were not covered by tests
}
}

// 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
Loading