Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit ce8d747

Browse files
authoredSep 27, 2024··
feat: add /env endpoint to allow exposing operator-controlled info from the server (#189)
Fixes #114
1 parent 34a21a3 commit ce8d747

File tree

8 files changed

+131
-29
lines changed

8 files changed

+131
-29
lines changed
 

‎httpbin/cmd/cmd.go

+42-27
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ const (
2525
defaultListenHost = "0.0.0.0"
2626
defaultListenPort = 8080
2727
defaultLogFormat = "text"
28+
defaultEnvPrefix = "HTTPBIN_ENV_"
2829

2930
// Reasonable defaults for our http server
3031
srvReadTimeout = 5 * time.Second
@@ -35,13 +36,13 @@ const (
3536
// Main is the main entrypoint for the go-httpbin binary. See loadConfig() for
3637
// command line argument parsing.
3738
func Main() int {
38-
return mainImpl(os.Args[1:], os.Getenv, os.Hostname, os.Stderr)
39+
return mainImpl(os.Args[1:], os.Getenv, os.Environ, os.Hostname, os.Stderr)
3940
}
4041

4142
// mainImpl is the real implementation of Main(), extracted for better
4243
// testability.
43-
func mainImpl(args []string, getEnv func(string) string, getHostname func() (string, error), out io.Writer) int {
44-
cfg, err := loadConfig(args, getEnv, getHostname)
44+
func mainImpl(args []string, getEnvVal func(string) string, getEnviron func() []string, getHostname func() (string, error), out io.Writer) int {
45+
cfg, err := loadConfig(args, getEnvVal, getEnviron, getHostname)
4546
if err != nil {
4647
if cfgErr, ok := err.(ConfigError); ok {
4748
// for -h/-help, just print usage and exit without error
@@ -75,6 +76,7 @@ func mainImpl(args []string, getEnv func(string) string, getHostname func() (str
7576
}
7677

7778
opts := []httpbin.OptionFunc{
79+
httpbin.WithEnv(cfg.Env),
7880
httpbin.WithMaxBodySize(cfg.MaxBodySize),
7981
httpbin.WithMaxDuration(cfg.MaxDuration),
8082
httpbin.WithObserver(httpbin.StdLogObserver(logger)),
@@ -110,6 +112,7 @@ func mainImpl(args []string, getEnv func(string) string, getHostname func() (str
110112
// config holds the configuration needed to initialize and run go-httpbin as a
111113
// standalone server.
112114
type config struct {
115+
Env map[string]string
113116
AllowedRedirectDomains []string
114117
ListenHost string
115118
ExcludeHeaders string
@@ -144,7 +147,7 @@ func (e ConfigError) Error() string {
144147

145148
// loadConfig parses command line arguments and env vars into a fully resolved
146149
// Config struct. Command line arguments take precedence over env vars.
147-
func loadConfig(args []string, getEnv func(string) string, getHostname func() (string, error)) (*config, error) {
150+
func loadConfig(args []string, getEnvVal func(string) string, getEnviron func() []string, getHostname func() (string, error)) (*config, error) {
148151
cfg := &config{}
149152

150153
fs := flag.NewFlagSet("go-httpbin", flag.ContinueOnError)
@@ -192,24 +195,24 @@ func loadConfig(args []string, getEnv func(string) string, getHostname func() (s
192195
// Command line flags take precedence over environment vars, so we only
193196
// check for environment vars if we have default values for our command
194197
// line flags.
195-
if cfg.MaxBodySize == httpbin.DefaultMaxBodySize && getEnv("MAX_BODY_SIZE") != "" {
196-
cfg.MaxBodySize, err = strconv.ParseInt(getEnv("MAX_BODY_SIZE"), 10, 64)
198+
if cfg.MaxBodySize == httpbin.DefaultMaxBodySize && getEnvVal("MAX_BODY_SIZE") != "" {
199+
cfg.MaxBodySize, err = strconv.ParseInt(getEnvVal("MAX_BODY_SIZE"), 10, 64)
197200
if err != nil {
198-
return nil, configErr("invalid value %#v for env var MAX_BODY_SIZE: parse error", getEnv("MAX_BODY_SIZE"))
201+
return nil, configErr("invalid value %#v for env var MAX_BODY_SIZE: parse error", getEnvVal("MAX_BODY_SIZE"))
199202
}
200203
}
201204

202-
if cfg.MaxDuration == httpbin.DefaultMaxDuration && getEnv("MAX_DURATION") != "" {
203-
cfg.MaxDuration, err = time.ParseDuration(getEnv("MAX_DURATION"))
205+
if cfg.MaxDuration == httpbin.DefaultMaxDuration && getEnvVal("MAX_DURATION") != "" {
206+
cfg.MaxDuration, err = time.ParseDuration(getEnvVal("MAX_DURATION"))
204207
if err != nil {
205-
return nil, configErr("invalid value %#v for env var MAX_DURATION: parse error", getEnv("MAX_DURATION"))
208+
return nil, configErr("invalid value %#v for env var MAX_DURATION: parse error", getEnvVal("MAX_DURATION"))
206209
}
207210
}
208-
if cfg.ListenHost == defaultListenHost && getEnv("HOST") != "" {
209-
cfg.ListenHost = getEnv("HOST")
211+
if cfg.ListenHost == defaultListenHost && getEnvVal("HOST") != "" {
212+
cfg.ListenHost = getEnvVal("HOST")
210213
}
211214
if cfg.Prefix == "" {
212-
if prefix := getEnv("PREFIX"); prefix != "" {
215+
if prefix := getEnvVal("PREFIX"); prefix != "" {
213216
cfg.Prefix = prefix
214217
}
215218
}
@@ -221,29 +224,29 @@ func loadConfig(args []string, getEnv func(string) string, getHostname func() (s
221224
return nil, configErr("Prefix %#v must not end with a slash", cfg.Prefix)
222225
}
223226
}
224-
if cfg.ExcludeHeaders == "" && getEnv("EXCLUDE_HEADERS") != "" {
225-
cfg.ExcludeHeaders = getEnv("EXCLUDE_HEADERS")
227+
if cfg.ExcludeHeaders == "" && getEnvVal("EXCLUDE_HEADERS") != "" {
228+
cfg.ExcludeHeaders = getEnvVal("EXCLUDE_HEADERS")
226229
}
227-
if cfg.ListenPort == defaultListenPort && getEnv("PORT") != "" {
228-
cfg.ListenPort, err = strconv.Atoi(getEnv("PORT"))
230+
if cfg.ListenPort == defaultListenPort && getEnvVal("PORT") != "" {
231+
cfg.ListenPort, err = strconv.Atoi(getEnvVal("PORT"))
229232
if err != nil {
230-
return nil, configErr("invalid value %#v for env var PORT: parse error", getEnv("PORT"))
233+
return nil, configErr("invalid value %#v for env var PORT: parse error", getEnvVal("PORT"))
231234
}
232235
}
233236

234-
if cfg.TLSCertFile == "" && getEnv("HTTPS_CERT_FILE") != "" {
235-
cfg.TLSCertFile = getEnv("HTTPS_CERT_FILE")
237+
if cfg.TLSCertFile == "" && getEnvVal("HTTPS_CERT_FILE") != "" {
238+
cfg.TLSCertFile = getEnvVal("HTTPS_CERT_FILE")
236239
}
237-
if cfg.TLSKeyFile == "" && getEnv("HTTPS_KEY_FILE") != "" {
238-
cfg.TLSKeyFile = getEnv("HTTPS_KEY_FILE")
240+
if cfg.TLSKeyFile == "" && getEnvVal("HTTPS_KEY_FILE") != "" {
241+
cfg.TLSKeyFile = getEnvVal("HTTPS_KEY_FILE")
239242
}
240243
if cfg.TLSCertFile != "" || cfg.TLSKeyFile != "" {
241244
if cfg.TLSCertFile == "" || cfg.TLSKeyFile == "" {
242245
return nil, configErr("https cert and key must both be provided")
243246
}
244247
}
245-
if cfg.LogFormat == defaultLogFormat && getEnv("LOG_FORMAT") != "" {
246-
cfg.LogFormat = getEnv("LOG_FORMAT")
248+
if cfg.LogFormat == defaultLogFormat && getEnvVal("LOG_FORMAT") != "" {
249+
cfg.LogFormat = getEnvVal("LOG_FORMAT")
247250
}
248251
if cfg.LogFormat != "text" && cfg.LogFormat != "json" {
249252
return nil, configErr(`invalid log format %q, must be "text" or "json"`, cfg.LogFormat)
@@ -252,7 +255,7 @@ func loadConfig(args []string, getEnv func(string) string, getHostname func() (s
252255
// useRealHostname will be true if either the `-use-real-hostname`
253256
// arg is given on the command line or if the USE_REAL_HOSTNAME env var
254257
// is one of "1" or "true".
255-
if useRealHostnameEnv := getEnv("USE_REAL_HOSTNAME"); useRealHostnameEnv == "1" || useRealHostnameEnv == "true" {
258+
if useRealHostnameEnv := getEnvVal("USE_REAL_HOSTNAME"); useRealHostnameEnv == "1" || useRealHostnameEnv == "true" {
256259
cfg.rawUseRealHostname = true
257260
}
258261
if cfg.rawUseRealHostname {
@@ -263,8 +266,8 @@ func loadConfig(args []string, getEnv func(string) string, getHostname func() (s
263266
}
264267

265268
// split comma-separated list of domains into a slice, if given
266-
if cfg.rawAllowedRedirectDomains == "" && getEnv("ALLOWED_REDIRECT_DOMAINS") != "" {
267-
cfg.rawAllowedRedirectDomains = getEnv("ALLOWED_REDIRECT_DOMAINS")
269+
if cfg.rawAllowedRedirectDomains == "" && getEnvVal("ALLOWED_REDIRECT_DOMAINS") != "" {
270+
cfg.rawAllowedRedirectDomains = getEnvVal("ALLOWED_REDIRECT_DOMAINS")
268271
}
269272
for _, domain := range strings.Split(cfg.rawAllowedRedirectDomains, ",") {
270273
if strings.TrimSpace(domain) != "" {
@@ -275,6 +278,18 @@ func loadConfig(args []string, getEnv func(string) string, getHostname func() (s
275278
// reset temporary fields to their zero values
276279
cfg.rawAllowedRedirectDomains = ""
277280
cfg.rawUseRealHostname = false
281+
282+
for _, envVar := range getEnviron() {
283+
name, value, _ := strings.Cut(envVar, "=")
284+
if !strings.HasPrefix(name, defaultEnvPrefix) {
285+
continue
286+
}
287+
if cfg.Env == nil {
288+
cfg.Env = make(map[string]string)
289+
}
290+
cfg.Env[name] = value
291+
}
292+
278293
return cfg, nil
279294
}
280295

‎httpbin/cmd/cmd_test.go

+54-2
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"bytes"
55
"errors"
66
"flag"
7+
"fmt"
78
"os"
89
"reflect"
910
"testing"
@@ -77,6 +78,49 @@ func TestLoadConfig(t *testing.T) {
7778
wantErr: flag.ErrHelp,
7879
},
7980

81+
// env
82+
"ok env with empty variables": {
83+
env: map[string]string{},
84+
wantCfg: &config{
85+
Env: nil,
86+
ListenHost: "0.0.0.0",
87+
ListenPort: 8080,
88+
MaxBodySize: httpbin.DefaultMaxBodySize,
89+
MaxDuration: httpbin.DefaultMaxDuration,
90+
LogFormat: defaultLogFormat,
91+
},
92+
},
93+
"ok env with recognized variables": {
94+
env: map[string]string{
95+
fmt.Sprintf("%sFOO", defaultEnvPrefix): "foo",
96+
fmt.Sprintf("%s%sBAR", defaultEnvPrefix, defaultEnvPrefix): "bar",
97+
fmt.Sprintf("%s123", defaultEnvPrefix): "123",
98+
},
99+
wantCfg: &config{
100+
Env: map[string]string{
101+
fmt.Sprintf("%sFOO", defaultEnvPrefix): "foo",
102+
fmt.Sprintf("%s%sBAR", defaultEnvPrefix, defaultEnvPrefix): "bar",
103+
fmt.Sprintf("%s123", defaultEnvPrefix): "123",
104+
},
105+
ListenHost: "0.0.0.0",
106+
ListenPort: 8080,
107+
MaxBodySize: httpbin.DefaultMaxBodySize,
108+
MaxDuration: httpbin.DefaultMaxDuration,
109+
LogFormat: defaultLogFormat,
110+
},
111+
},
112+
"ok env with unrecognized variables": {
113+
env: map[string]string{"HTTPBIN_FOO": "foo", "BAR": "bar"},
114+
wantCfg: &config{
115+
Env: nil,
116+
ListenHost: "0.0.0.0",
117+
ListenPort: 8080,
118+
MaxBodySize: httpbin.DefaultMaxBodySize,
119+
MaxDuration: httpbin.DefaultMaxDuration,
120+
LogFormat: defaultLogFormat,
121+
},
122+
},
123+
80124
// max body size
81125
"invalid -max-body-size": {
82126
args: []string{"-max-body-size", "foo"},
@@ -515,7 +559,7 @@ func TestLoadConfig(t *testing.T) {
515559
if tc.getHostname == nil {
516560
tc.getHostname = getHostnameDefault
517561
}
518-
cfg, err := loadConfig(tc.args, func(key string) string { return tc.env[key] }, tc.getHostname)
562+
cfg, err := loadConfig(tc.args, func(key string) string { return tc.env[key] }, func() []string { return environSlice(tc.env) }, tc.getHostname)
519563

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

608652
buf := &bytes.Buffer{}
609-
gotCode := mainImpl(tc.args, func(key string) string { return tc.env[key] }, tc.getHostname, buf)
653+
gotCode := mainImpl(tc.args, func(key string) string { return tc.env[key] }, func() []string { return environSlice(tc.env) }, tc.getHostname, buf)
610654
out := buf.String()
611655

612656
if gotCode != tc.wantCode {
@@ -625,3 +669,11 @@ func TestMainImpl(t *testing.T) {
625669
})
626670
}
627671
}
672+
673+
func environSlice(env map[string]string) []string {
674+
envStrings := make([]string, 0, len(env))
675+
for name, value := range env {
676+
envStrings = append(envStrings, fmt.Sprintf("%s=%s", name, value))
677+
}
678+
return envStrings
679+
}

‎httpbin/handlers.go

+7
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,13 @@ func (h *HTTPBin) Index(w http.ResponseWriter, r *http.Request) {
3535
writeHTML(w, h.indexHTML, http.StatusOK)
3636
}
3737

38+
// Env - returns environment variables with HTTPBIN_ prefix, if any pre-configured by operator
39+
func (h *HTTPBin) Env(w http.ResponseWriter, _ *http.Request) {
40+
writeJSON(http.StatusOK, w, &envResponse{
41+
Env: h.env,
42+
})
43+
}
44+
3845
// FormsPost renders an HTML form that submits a request to the /post endpoint
3946
func (h *HTTPBin) FormsPost(w http.ResponseWriter, _ *http.Request) {
4047
writeHTML(w, h.formsPostHTML, http.StatusOK)

‎httpbin/handlers_test.go

+10
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,16 @@ func TestIndex(t *testing.T) {
120120
}
121121
}
122122

123+
func TestEnv(t *testing.T) {
124+
t.Run("default environment", func(t *testing.T) {
125+
t.Parallel()
126+
req := newTestRequest(t, "GET", "/env")
127+
resp := must.DoReq(t, client, req)
128+
result := mustParseResponse[envResponse](t, resp)
129+
assert.Equal(t, len(result.Env), 0, "environment variables unexpected")
130+
})
131+
}
132+
123133
func TestFormsPost(t *testing.T) {
124134
t.Parallel()
125135

‎httpbin/httpbin.go

+5
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@ type HTTPBin struct {
5757
// Set of hosts to which the /redirect-to endpoint will allow redirects
5858
AllowedRedirectDomains map[string]struct{}
5959

60+
// The operator-controlled environment variables filtered from
61+
// the process environment, based on named HTTPBIN_ prefix.
62+
env map[string]string
63+
6064
// Pre-computed error message for the /redirect-to endpoint, based on
6165
// -allowed-redirect-domains/ALLOWED_REDIRECT_DOMAINS
6266
forbiddenRedirectError string
@@ -159,6 +163,7 @@ func (h *HTTPBin) Handler() http.Handler {
159163
mux.HandleFunc("/digest-auth/{qop}/{user}/{password}/{algorithm}", h.DigestAuth)
160164
mux.HandleFunc("/drip", h.Drip)
161165
mux.HandleFunc("/dump/request", h.DumpRequest)
166+
mux.HandleFunc("/env", h.Env)
162167
mux.HandleFunc("/etag/{etag}", h.ETag)
163168
mux.HandleFunc("/gzip", h.Gzip)
164169
mux.HandleFunc("/headers", h.Headers)

‎httpbin/options.go

+8
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,14 @@ func WithObserver(o Observer) OptionFunc {
4646
}
4747
}
4848

49+
// WithEnv sets the HTTPBIN_-prefixed environment variables reported
50+
// by the /env endpoint.
51+
func WithEnv(env map[string]string) OptionFunc {
52+
return func(h *HTTPBin) {
53+
h.env = env
54+
}
55+
}
56+
4957
// WithExcludeHeaders sets the headers to exclude in outgoing responses, to
5058
// prevent possible information leakage.
5159
func WithExcludeHeaders(excludeHeaders string) OptionFunc {

‎httpbin/responses.go

+4
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ const (
1313
textContentType = "text/plain; charset=utf-8"
1414
)
1515

16+
type envResponse struct {
17+
Env map[string]string `json:"env"`
18+
}
19+
1620
type headersResponse struct {
1721
Headers http.Header `json:"headers"`
1822
}

‎httpbin/static/index.html.tmpl

+1
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@
8282
<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>
8383
<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>
8484
<li><a href="{{.Prefix}}/encoding/utf8"><code>{{.Prefix}}/encoding/utf8</code></a> Returns page containing UTF-8 data.</li>
85+
<li><a href="{{.Prefix}}/env"><code>{{.Prefix}}/env</code></a> Returns all environment variables named with <code>HTTPBIN_ENV_</code> prefix.</li>
8586
<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>
8687
<li><a href="{{.Prefix}}/forms/post"><code>{{.Prefix}}/forms/post</code></a> HTML form that submits to <em>{{.Prefix}}/post</em></li>
8788
<li><a href="{{.Prefix}}/get"><code>{{.Prefix}}/get</code></a> Returns GET data.</li>

0 commit comments

Comments
 (0)
Please sign in to comment.