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
1 change: 1 addition & 0 deletions .codecov.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,4 @@ comment:

ignore:
- internal/testing
- cmd/go-httpbin/main.go # no logic to test, just calls cmd.Main, which is tested
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ PORT ?= 8080
# =============================================================================
build:
mkdir -p $(DIST_PATH)
CGO_ENABLED=0 go build -ldflags="-s -w" -o $(DIST_PATH)/go-httpbin ./cmd/go-httpbin
GIT_COMMIT=$$(git describe --always --dirty 2>/dev/null || echo "unknown"); \
BUILD_DATE=$$(date -u +%Y-%m-%dT%H:%M:%SZ); \
CGO_ENABLED=0; \
go build -ldflags="-s -w -X main.commit=$$GIT_COMMIT -X main.buildDate=$$BUILD_DATE" -o $(DIST_PATH)/go-httpbin ./cmd/go-httpbin
.PHONY: build

buildexamples: build
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,9 @@ variables (or a combination of the two):
| `-srv-max-header-bytes` | `SRV_MAX_HEADER_BYTES` | Value to use for the http.Server's MaxHeaderBytes option | 16384 |
| `-srv-read-header-timeout` | `SRV_READ_HEADER_TIMEOUT` | Value to use for the http.Server's ReadHeaderTimeout option | 1s |
| `-srv-read-timeout` | `SRV_READ_TIMEOUT` | Value to use for the http.Server's ReadTimeout option | 5s |
| `-use-full-version` | `USE_FULL_VERSION` | Expose full version details (release, commit, build date, Go runtime) via the /version endpoint (default: service name only) | false |
| `-use-real-hostname` | `USE_REAL_HOSTNAME` | Expose real hostname as reported by os.Hostname() in the /hostname endpoint | false |
| `-version` | | Print version and exit | |

> [!WARNING]
> These configuration options are dangerous and/or deprecated and should be
Expand Down
19 changes: 8 additions & 11 deletions cmd/go-httpbin/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,22 @@ package main

import (
"os"
"time"

"github.com/mccutchen/go-httpbin/v2/httpbin/cmd"
)

// Build metadata, populated by the release process (see .goreleaser.yaml).
// Populated at build time
var (
version = "dev"
commit = "HEAD"
buildDate = time.Now().String()
commit = "unknown"
buildDate = "unknown"
)

func main() {
// TODO: incorporate into a `--version` flag.
{
_ = version
_ = commit
_ = buildDate
}
os.Exit(cmd.Main(cmd.BuildInfo{
Version: version,
Commit: commit,
Date: buildDate,
}))

os.Exit(cmd.Main())
}
33 changes: 30 additions & 3 deletions httpbin/cmd/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"net/http"
"os"
"os/signal"
"runtime"
"strconv"
"strings"
"syscall"
Expand All @@ -38,15 +39,22 @@ const (
defaultSrvMaxHeaderBytes = 16 * 1024 // 16kb
)

// BuildInfo holds build metadata.
type BuildInfo struct {
Version string
Commit string
Date string
}

// 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.Environ, os.Hostname, os.Stderr)
func Main(build BuildInfo) int {
return mainImpl(os.Args[1:], build, os.Getenv, os.Environ, os.Hostname, os.Stderr)
}

// mainImpl is the real implementation of Main(), extracted for better
// testability.
func mainImpl(args []string, getEnvVal func(string) string, getEnviron func() []string, getHostname func() (string, error), out io.Writer) int {
func mainImpl(args []string, build BuildInfo, 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 {
Expand All @@ -72,6 +80,11 @@ func mainImpl(args []string, getEnvVal func(string) string, getEnviron func() []
return 1
}

if cfg.ShowVersion {
fmt.Fprintf(out, "go-httpbin version %s\n%s %s %s\n", build.Version, runtime.Version(), build.Commit, build.Date)
return 0
}

logger := setupLogger(out, cfg.LogFormat, cfg.LogLevel)

opts := []httpbin.OptionFunc{
Expand All @@ -81,6 +94,9 @@ func mainImpl(args []string, getEnvVal func(string) string, getEnviron func() []
httpbin.WithObserver(httpbin.StdLogObserver(logger)),
httpbin.WithExcludeHeaders(cfg.ExcludeHeaders),
}
if cfg.UseFullVersion {
opts = append(opts, httpbin.WithVersion("go-httpbin", build.Version, build.Commit, build.Date, runtime.Version()))
}
if cfg.Prefix != "" {
opts = append(opts, httpbin.WithPrefix(cfg.Prefix))
}
Expand Down Expand Up @@ -139,6 +155,12 @@ type config struct {
// absolutely necessary.
UnsafeAllowDangerousResponses bool

// If true, print version info and exit.
ShowVersion bool

// If true, expose full version details via /version (default: service name only).
UseFullVersion bool

// temporary placeholders for arguments that need extra processing
rawAllowedRedirectDomains string
rawLogLevel string
Expand Down Expand Up @@ -166,6 +188,7 @@ func loadConfig(args []string, getEnvVal func(string) string, getEnviron func()
cfg := &config{}

fs := flag.NewFlagSet("go-httpbin", flag.ContinueOnError)
fs.BoolVar(&cfg.ShowVersion, "version", false, "Print version and exit")
fs.BoolVar(&cfg.rawUseRealHostname, "use-real-hostname", false, "Expose value of os.Hostname() in the /hostname endpoint instead of dummy value")
fs.DurationVar(&cfg.MaxDuration, "max-duration", httpbin.DefaultMaxDuration, "Maximum duration a response may take")
fs.Int64Var(&cfg.MaxBodySize, "max-body-size", httpbin.DefaultMaxBodySize, "Maximum size of request or response, in bytes")
Expand All @@ -185,6 +208,7 @@ func loadConfig(args []string, getEnvVal func(string) string, getEnviron func()
// Here be dragons! This flag is only for backwards compatibility and
// should not be used in production.
fs.BoolVar(&cfg.UnsafeAllowDangerousResponses, "unsafe-allow-dangerous-responses", false, "Allow endpoints to return unescaped HTML when clients control response Content-Type (enables XSS attacks)")
fs.BoolVar(&cfg.UseFullVersion, "use-full-version", false, "Expose full version details via /version (default: service name only)")

// in order to fully control error output whether CLI arguments or env vars
// are used to configure the app, we need to take control away from the
Expand Down Expand Up @@ -325,6 +349,9 @@ func loadConfig(args []string, getEnvVal func(string) string, getEnviron func()
if getEnvBool(getEnvVal("UNSAFE_ALLOW_DANGEROUS_RESPONSES")) {
cfg.UnsafeAllowDangerousResponses = true
}
if getEnvBool(getEnvVal("USE_FULL_VERSION")) {
cfg.UseFullVersion = true
}

// reset temporary fields to their zero values
cfg.rawAllowedRedirectDomains = ""
Expand Down
45 changes: 44 additions & 1 deletion httpbin/cmd/cmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,12 @@ const usage = `Usage of go-httpbin:
Value to use for the http.Server's ReadTimeout option (default 5s)
-unsafe-allow-dangerous-responses
Allow endpoints to return unescaped HTML when clients control response Content-Type (enables XSS attacks)
-use-full-version
Expose full version details via /version (default: service name only)
-use-real-hostname
Expose value of os.Hostname() in the /hostname endpoint instead of dummy value
-version
Print version and exit
`

func TestLoadConfig(t *testing.T) {
Expand Down Expand Up @@ -550,6 +554,35 @@ func TestLoadConfig(t *testing.T) {
env: map[string]string{"UNSAFE_ALLOW_DANGEROUS_RESPONSES": "false"},
wantCfg: defaultCfg,
},

// use-full-version
"ok -use-full-version": {
args: []string{"-use-full-version"},
wantCfg: mergedConfig(defaultCfg, &config{
UseFullVersion: true,
}),
},
"ok USE_FULL_VERSION=1": {
env: map[string]string{"USE_FULL_VERSION": "1"},
wantCfg: mergedConfig(defaultCfg, &config{
UseFullVersion: true,
}),
},
"ok USE_FULL_VERSION=true": {
env: map[string]string{"USE_FULL_VERSION": "true"},
wantCfg: mergedConfig(defaultCfg, &config{
UseFullVersion: true,
}),
},
// case sensitive
"ok USE_FULL_VERSION=TRUE": {
env: map[string]string{"USE_FULL_VERSION": "TRUE"},
wantCfg: defaultCfg,
},
"ok USE_FULL_VERSION=false": {
env: map[string]string{"USE_FULL_VERSION": "false"},
wantCfg: defaultCfg,
},
}

for name, tc := range testCases {
Expand Down Expand Up @@ -583,6 +616,7 @@ func TestMainImpl(t *testing.T) {
t.Parallel()

testCases := map[string]struct {
build BuildInfo
args []string
env map[string]string
getHostname func() (string, error)
Expand All @@ -595,6 +629,15 @@ func TestMainImpl(t *testing.T) {
wantCode: 0,
wantOut: usage,
},
"version": {
build: BuildInfo{Version: "1.2.3", Commit: "abc123", Date: "1988-11-12T10:00:00Z"},
args: []string{"-version"},
wantCode: 0,
wantOutFn: func(t *testing.T, out string) {
assert.Contains(t, out, "go-httpbin version 1.2.3\n", "version output missing first line")
assert.Contains(t, out, " abc123 1988-11-12T10:00:00Z\n", "version output missing second line")
},
},
"cli error": {
args: []string{"-max-body-size", "foo"},
wantCode: 2,
Expand Down Expand Up @@ -654,7 +697,7 @@ func TestMainImpl(t *testing.T) {
}

buf := &bytes.Buffer{}
gotCode := mainImpl(tc.args, func(key string) string { return tc.env[key] }, func() []string { return environSlice(tc.env) }, tc.getHostname, buf)
gotCode := mainImpl(tc.args, tc.build, 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 Down
5 changes: 5 additions & 0 deletions httpbin/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -1310,6 +1310,11 @@ func (h *HTTPBin) Hostname(w http.ResponseWriter, _ *http.Request) {
})
}

// Version - returns version info.
func (h *HTTPBin) Version(w http.ResponseWriter, _ *http.Request) {
writeJSON(http.StatusOK, w, h.version)
}

// SSE writes a stream of events over a duration after an optional
// initial delay.
func (h *HTTPBin) SSE(w http.ResponseWriter, r *http.Request) {
Expand Down
32 changes: 32 additions & 0 deletions httpbin/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3562,6 +3562,38 @@ func TestHostname(t *testing.T) {
})
}

func TestVersion(t *testing.T) {
t.Run("service only (default)", func(t *testing.T) {
t.Parallel()
app := setupTestApp(t, WithVersion("go-httpbin", "", "", "", ""))
req := newTestRequest(t, "GET", app.URL("/version"), nil)
resp := mustDoRequest(t, app, req)
result := mustParseResponse[versionResponse](t, resp)
assert.DeepEqual(t, result, versionResponse{
Service: "go-httpbin",
Version: "",
Commit: "",
BuildDate: "",
GoVersion: "",
}, "incorrect version response")
})

t.Run("full version info", func(t *testing.T) {
t.Parallel()
app := setupTestApp(t, WithVersion("go-httpbin", "1.2.3", "abc123", "1988-11-12", "go1.22.0"))
req := newTestRequest(t, "GET", app.URL("/version"), nil)
resp := mustDoRequest(t, app, req)
result := mustParseResponse[versionResponse](t, resp)
assert.DeepEqual(t, result, versionResponse{
Service: "go-httpbin",
Version: "1.2.3",
Commit: "abc123",
BuildDate: "1988-11-12",
GoVersion: "go1.22.0",
}, "incorrect version response")
})
}

func TestSSE(t *testing.T) {
t.Parallel()
app := setupTestApp(t)
Expand Down
5 changes: 5 additions & 0 deletions httpbin/httpbin.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ type HTTPBin struct {
// The hostname to expose via /hostname.
hostname string

// Version info to expose via /version.
version versionResponse

// The app's http handler
handler http.Handler

Expand Down Expand Up @@ -119,6 +122,7 @@ func New(opts ...OptionFunc) *HTTPBin {
MaxDuration: DefaultMaxDuration,
DefaultParams: DefaultDefaultParams,
hostname: DefaultHostname,
version: versionResponse{Service: "go-httpbin"},
}
for _, opt := range opts {
opt(h)
Expand Down Expand Up @@ -221,6 +225,7 @@ func (h *HTTPBin) Handler() http.Handler {
mux.HandleFunc("PATCH /upload", h.RequestWithBodyDiscard)
mux.HandleFunc("/user-agent", h.UserAgent)
mux.HandleFunc("/uuid", h.UUID)
mux.HandleFunc("/version", h.Version)
mux.HandleFunc("/xml", h.XML)

// existing httpbin endpoints that we do not support
Expand Down
12 changes: 12 additions & 0 deletions httpbin/httpbin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"net/http/httptest"
"testing"
"time"

"github.com/mccutchen/go-httpbin/v2/internal/testing/assert"
)

func TestNew(t *testing.T) {
Expand All @@ -20,6 +22,8 @@ func TestNew(t *testing.T) {
if h.Observer != nil {
t.Fatalf("expected default Observer == nil, got %#v", h.Observer)
}
assert.DeepEqual(t, h.version, versionResponse{Service: "go-httpbin"}, "incorrect default versionResponse")

}

func TestNewOptions(t *testing.T) {
Expand All @@ -32,6 +36,7 @@ func TestNewOptions(t *testing.T) {
WithMaxBodySize(maxBodySize),
WithMaxDuration(maxDuration),
WithObserver(observer),
WithVersion("go-httpbin", "1.2.3", "abcd1234", "1988-11-12T10:00:00Z", "go2.0.0"),
)

if h.MaxBodySize != maxBodySize {
Expand All @@ -43,6 +48,13 @@ func TestNewOptions(t *testing.T) {
if h.Observer == nil {
t.Fatalf("expected non-nil Observer")
}
assert.DeepEqual(t, h.version, versionResponse{
Service: "go-httpbin",
Version: "1.2.3",
Commit: "abcd1234",
BuildDate: "1988-11-12T10:00:00Z",
GoVersion: "go2.0.0",
}, "incorrect versionResponse")
}

func TestNewObserver(t *testing.T) {
Expand Down
13 changes: 13 additions & 0 deletions httpbin/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,19 @@ Allowed redirect destinations:
}
}

// WithVersion sets the service name and build metadata to expose via /version.
func WithVersion(service, version, commit, buildDate, goVersion string) OptionFunc {
return func(h *HTTPBin) {
h.version = versionResponse{
Service: service,
Version: version,
Commit: commit,
BuildDate: buildDate,
GoVersion: goVersion,
}
}
}

// WithUnsafeAllowDangerousResponses means endpoints that allow clients to
// specify a response Conntent-Type WILL NOT escape HTML entities in the
// response body, which can enable (e.g.) reflected XSS attacks.
Expand Down
8 changes: 8 additions & 0 deletions httpbin/responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ type envResponse struct {
Env map[string]string `json:"env"`
}

type versionResponse struct {
Service string `json:"service"`
Version string `json:"version,omitempty"`
Commit string `json:"commit,omitempty"`
BuildDate string `json:"build_date,omitempty"`
GoVersion string `json:"go_version,omitempty"`
}

type headersResponse struct {
Headers http.Header `json:"headers"`
}
Expand Down
Loading