From 0defb3cec50cf801932403e03024027ab41fa938 Mon Sep 17 00:00:00 2001
From: Will McCutchen <will@mccutch.org>
Date: Tue, 5 Jul 2022 09:20:59 -0400
Subject: [PATCH] Add /hostname endpoint (#81)

This adds a new /hostname endpoint as originally proposed in #66. In
this implementation, it exposes a dummy hostname by default, and only
exposes the real hostname (via `os.Hostname()`) if the
`-use-real-hostname` flag is given on the command line.
---
 Makefile                  |  2 +-
 README.md                 | 45 +++++++++++++++++++++++---------------
 cmd/go-httpbin/main.go    | 34 ++++++++++++++++++++++-------
 httpbin/handlers.go       |  8 +++++++
 httpbin/handlers_test.go  | 46 +++++++++++++++++++++++++++++++++++++++
 httpbin/httpbin.go        | 17 +++++++++++++++
 httpbin/static/index.html |  1 +
 7 files changed, 126 insertions(+), 27 deletions(-)

diff --git a/Makefile b/Makefile
index e5e0d13..b577d44 100644
--- a/Makefile
+++ b/Makefile
@@ -16,7 +16,7 @@ TOOL_BIN_DIR     ?= $(shell go env GOPATH)/bin
 TOOL_GOLINT      := $(TOOL_BIN_DIR)/golint
 TOOL_STATICCHECK := $(TOOL_BIN_DIR)/staticcheck
 
-GO_SOURCES = $(wildcard **/*.go)
+GO_SOURCES = $(shell find . -name *.go)
 
 
 # =============================================================================
diff --git a/README.md b/README.md
index c954209..0b05863 100644
--- a/README.md
+++ b/README.md
@@ -6,29 +6,34 @@ A reasonably complete and well-tested golang port of [Kenneth Reitz][kr]'s
 [![GoDoc](https://pkg.go.dev/badge/github.com/mccutchen/go-httpbin/v2)](https://pkg.go.dev/github.com/mccutchen/go-httpbin/v2)
 [![Build status](https://github.com/mccutchen/go-httpbin/actions/workflows/test.yaml/badge.svg)](https://github.com/mccutchen/go-httpbin/actions/workflows/test.yaml)
 [![Coverage](https://codecov.io/gh/mccutchen/go-httpbin/branch/main/graph/badge.svg)](https://codecov.io/gh/mccutchen/go-httpbin)
+[![Docker Pulls](https://badgen.net/docker/pulls/mccutchen/go-httpbin?icon=docker&label=pulls)](https://hub.docker.com/r/mccutchen/go-httpbin/)
 
 
 ## Usage
 
-Run as a standalone binary, configured by command line flags or environment
-variables:
 
-```
-$ go-httpbin --help
-Usage of go-httpbin:
-  -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)
-```
+### Configuration
+
+go-httpbin can be configured via either command line arguments or environment
+variables (or a combination of the two):
+
+| Argument| Env var | Documentation | Default |
+| - | - | - | - |
+| `-host` | `HOST` | Host to listen on | "0.0.0.0" |
+| `-https-cert-file` | `HTTPS_CERT_FILE` | HTTPS Server certificate file | |
+| `-https-key-file` | `HTTPS_KEY_FILE` | HTTPS Server private key file | |
+| `-max-body-size` | `MAX_BODY_SIZE` | Maximum size of request or response, in bytes | 1048576 |
+| `-max-duration` | `MAX_DURATION` | Maximum duration a response may take | 10s |
+| `-port` | `PORT` | Port to listen on | 8080 |
+| `-use-real-hostname` | `USE_REAL_HOSTNAME` | Expose real hostname as reported by os.Hostname() in the /hostname endpoint | false |
+
+**Note:** Command line arguments take precedence over environment variables.
+
+
+### Standalone binary
+
+Follow the [Installation](#installation) instructions to install go-httpbin as
+a standalone binary. (This currently requires a working Go runtime.)
 
 Examples:
 
@@ -43,6 +48,8 @@ $ openssl req -new -x509 -sha256 -key server.key -out server.crt -days 3650
 $ go-httpbin -host 127.0.0.1 -port 8081 -https-cert-file ./server.crt -https-key-file ./server.key
 ```
 
+### Docker
+
 Docker images are published to [Docker Hub][docker-hub]:
 
 ```bash
@@ -53,6 +60,8 @@ $ docker run -P mccutchen/go-httpbin
 $ docker run -e HTTPS_CERT_FILE='/tmp/server.crt' -e HTTPS_KEY_FILE='/tmp/server.key' -p 8080:8080 -v /tmp:/tmp mccutchen/go-httpbin
 ```
 
+### Unit testing helper library
+
 The `github.com/mccutchen/go-httpbin/httpbin/v2` package can also be used as a
 library for testing an application's interactions with an upstream HTTP
 service, like so:
diff --git a/cmd/go-httpbin/main.go b/cmd/go-httpbin/main.go
index 0743a11..b9196f4 100644
--- a/cmd/go-httpbin/main.go
+++ b/cmd/go-httpbin/main.go
@@ -22,12 +22,13 @@ const (
 )
 
 var (
-	host          string
-	port          int
-	maxBodySize   int64
-	maxDuration   time.Duration
-	httpsCertFile string
-	httpsKeyFile  string
+	host            string
+	port            int
+	maxBodySize     int64
+	maxDuration     time.Duration
+	httpsCertFile   string
+	httpsKeyFile    string
+	useRealHostname bool
 )
 
 func main() {
@@ -37,6 +38,7 @@ func main() {
 	flag.StringVar(&httpsKeyFile, "https-key-file", "", "HTTPS Server private key file")
 	flag.Int64Var(&maxBodySize, "max-body-size", httpbin.DefaultMaxBodySize, "Maximum size of request or response, in bytes")
 	flag.DurationVar(&maxDuration, "max-duration", httpbin.DefaultMaxDuration, "Maximum duration a response may take")
+	flag.BoolVar(&useRealHostname, "use-real-hostname", false, "Expose value of os.Hostname() in the /hostname endpoint instead of dummy value")
 	flag.Parse()
 
 	// Command line flags take precedence over environment vars, so we only
@@ -88,6 +90,13 @@ func main() {
 		}
 	}
 
+	// 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
+	}
+
 	logger := log.New(os.Stderr, "", 0)
 
 	// A hacky log helper function to ensure that shutdown messages are
@@ -101,11 +110,20 @@ func main() {
 		logger.Printf(logFmt, time.Now().Format(dateFmt), fmt.Sprintf(msg, args...))
 	}
 
-	h := httpbin.New(
+	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))
+	}
+	h := httpbin.New(opts...)
 
 	listenAddr := net.JoinHostPort(host, strconv.Itoa(port))
 
diff --git a/httpbin/handlers.go b/httpbin/handlers.go
index 3ff4ccf..ebb2c33 100644
--- a/httpbin/handlers.go
+++ b/httpbin/handlers.go
@@ -1004,3 +1004,11 @@ func (h *HTTPBin) Bearer(w http.ResponseWriter, r *http.Request) {
 	})
 	writeJSON(w, body, http.StatusOK)
 }
+
+// Hostname - returns the hostname.
+func (h *HTTPBin) Hostname(w http.ResponseWriter, r *http.Request) {
+	body, _ := json.Marshal(hostnameResponse{
+		Hostname: h.hostname,
+	})
+	writeJSON(w, body, http.StatusOK)
+}
diff --git a/httpbin/handlers_test.go b/httpbin/handlers_test.go
index 8c4a31c..b27d76f 100644
--- a/httpbin/handlers_test.go
+++ b/httpbin/handlers_test.go
@@ -2571,3 +2571,49 @@ func TestNotImplemented(t *testing.T) {
 		})
 	}
 }
+
+func TestHostname(t *testing.T) {
+	t.Parallel()
+
+	loadResponse := func(t *testing.T, bodyBytes []byte) hostnameResponse {
+		var resp hostnameResponse
+		err := json.Unmarshal(bodyBytes, &resp)
+		if err != nil {
+			t.Fatalf("failed to unmarshal body %q from JSON: %s", string(bodyBytes), err)
+		}
+		return resp
+	}
+
+	t.Run("default hostname", func(t *testing.T) {
+		t.Parallel()
+
+		var (
+			handler = New().Handler()
+			r, _    = http.NewRequest("GET", "/hostname", nil)
+			w       = httptest.NewRecorder()
+		)
+		handler.ServeHTTP(w, r)
+		assertStatusCode(t, w, http.StatusOK)
+		resp := loadResponse(t, w.Body.Bytes())
+		if resp.Hostname != DefaultHostname {
+			t.Errorf("expected hostname %q, got %q", DefaultHostname, resp.Hostname)
+		}
+	})
+
+	t.Run("real hostname", func(t *testing.T) {
+		t.Parallel()
+
+		var (
+			realHostname = "real-hostname"
+			handler      = New(WithHostname(realHostname)).Handler()
+			r, _         = http.NewRequest("GET", "/hostname", nil)
+			w            = httptest.NewRecorder()
+		)
+		handler.ServeHTTP(w, r)
+		assertStatusCode(t, w, http.StatusOK)
+		resp := loadResponse(t, w.Body.Bytes())
+		if resp.Hostname != realHostname {
+			t.Errorf("expected hostname %q, got %q", realHostname, resp.Hostname)
+		}
+	})
+}
diff --git a/httpbin/httpbin.go b/httpbin/httpbin.go
index d7336ad..7f5fa9e 100644
--- a/httpbin/httpbin.go
+++ b/httpbin/httpbin.go
@@ -10,6 +10,7 @@ import (
 const (
 	DefaultMaxBodySize int64 = 1024 * 1024
 	DefaultMaxDuration       = 10 * time.Second
+	DefaultHostname          = "go-httpbin"
 )
 
 const (
@@ -87,6 +88,10 @@ type bearerResponse struct {
 	Token         string `json:"token"`
 }
 
+type hostnameResponse struct {
+	Hostname string `json:"hostname"`
+}
+
 // HTTPBin contains the business logic
 type HTTPBin struct {
 	// Max size of an incoming request generated response body, in bytes
@@ -101,6 +106,9 @@ type HTTPBin struct {
 
 	// Default parameter values
 	DefaultParams DefaultParams
+
+	// The hostname to expose via /hostname.
+	hostname string
 }
 
 // DefaultParams defines default parameter values
@@ -137,6 +145,7 @@ func (h *HTTPBin) Handler() http.Handler {
 	mux.HandleFunc("/user-agent", h.UserAgent)
 	mux.HandleFunc("/headers", h.Headers)
 	mux.HandleFunc("/response-headers", h.ResponseHeaders)
+	mux.HandleFunc("/hostname", h.Hostname)
 
 	mux.HandleFunc("/status/", h.Status)
 	mux.HandleFunc("/unstable", h.Unstable)
@@ -222,6 +231,7 @@ func New(opts ...OptionFunc) *HTTPBin {
 		MaxBodySize:   DefaultMaxBodySize,
 		MaxDuration:   DefaultMaxDuration,
 		DefaultParams: DefaultDefaultParams,
+		hostname:      DefaultHostname,
 	}
 	for _, opt := range opts {
 		opt(h)
@@ -254,6 +264,13 @@ func WithMaxDuration(d time.Duration) OptionFunc {
 	}
 }
 
+// WithHostname sets the hostname to return via the /hostname endpoint.
+func WithHostname(s string) OptionFunc {
+	return func(h *HTTPBin) {
+		h.hostname = s
+	}
+}
+
 // WithObserver sets the request observer callback
 func WithObserver(o Observer) OptionFunc {
 	return func(h *HTTPBin) {
diff --git a/httpbin/static/index.html b/httpbin/static/index.html
index e9ed07b..a4fa3c0 100644
--- a/httpbin/static/index.html
+++ b/httpbin/static/index.html
@@ -88,6 +88,7 @@
 <li><a href="/headers"><code>/headers</code></a> Returns request header dict.</li>
 <li><a href="/hidden-basic-auth/user/passwd"><code>/hidden-basic-auth/:user/:passwd</code></a> 404'd BasicAuth.</li>
 <li><a href="/html"><code>/html</code></a> Renders an HTML Page.</li>
+<li><a href="/hostname"><code>/hostname</code></a> Returns the name of the host serving the request.</li>
 <li><a href="/image"><code>/image</code></a> Returns page containing an image based on sent Accept header.</li>
 <li><a href="/image/jpeg"><code>/image/jpeg</code></a> Returns a JPEG image.</li>
 <li><a href="/image/png"><code>/image/png</code></a> Returns a PNG image.</li>
-- 
GitLab