Skip to content

Commit edb789c

Browse files
authored
feat: add /version endpoint and --version CLI flag (#248)
In order not to suddenly reveal _potentially_ sensitive information without users explicitly opting in, the `/version` endpoint will only name the service: { "service": "go-httpbin" } But with `--use-full-version`/`USE_FULL_VERSION=true` set, it exposes more detailed info: { "service": "go-httpbin", "version": "2.22.0-next", "commit": "6c58644", "build_date": "2026-04-11T15:50:03Z", "go_version": "go1.26.1" } Closes #191, #190, and #188.
1 parent 356a145 commit edb789c

12 files changed

Lines changed: 164 additions & 16 deletions

File tree

.codecov.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,4 @@ comment:
3434

3535
ignore:
3636
- internal/testing
37+
- cmd/go-httpbin/main.go # no logic to test, just calls cmd.Main, which is tested

Makefile

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,10 @@ PORT ?= 8080
2323
# =============================================================================
2424
build:
2525
mkdir -p $(DIST_PATH)
26-
CGO_ENABLED=0 go build -ldflags="-s -w" -o $(DIST_PATH)/go-httpbin ./cmd/go-httpbin
26+
GIT_COMMIT=$$(git describe --always --dirty 2>/dev/null || echo "unknown"); \
27+
BUILD_DATE=$$(date -u +%Y-%m-%dT%H:%M:%SZ); \
28+
CGO_ENABLED=0; \
29+
go build -ldflags="-s -w -X main.commit=$$GIT_COMMIT -X main.buildDate=$$BUILD_DATE" -o $(DIST_PATH)/go-httpbin ./cmd/go-httpbin
2730
.PHONY: build
2831

2932
buildexamples: build

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,9 @@ variables (or a combination of the two):
115115
| `-srv-max-header-bytes` | `SRV_MAX_HEADER_BYTES` | Value to use for the http.Server's MaxHeaderBytes option | 16384 |
116116
| `-srv-read-header-timeout` | `SRV_READ_HEADER_TIMEOUT` | Value to use for the http.Server's ReadHeaderTimeout option | 1s |
117117
| `-srv-read-timeout` | `SRV_READ_TIMEOUT` | Value to use for the http.Server's ReadTimeout option | 5s |
118+
| `-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 |
118119
| `-use-real-hostname` | `USE_REAL_HOSTNAME` | Expose real hostname as reported by os.Hostname() in the /hostname endpoint | false |
120+
| `-version` | | Print version and exit | |
119121

120122
> [!WARNING]
121123
> These configuration options are dangerous and/or deprecated and should be

cmd/go-httpbin/main.go

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,25 +3,22 @@ package main
33

44
import (
55
"os"
6-
"time"
76

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

11-
// Build metadata, populated by the release process (see .goreleaser.yaml).
10+
// Populated at build time
1211
var (
1312
version = "dev"
14-
commit = "HEAD"
15-
buildDate = time.Now().String()
13+
commit = "unknown"
14+
buildDate = "unknown"
1615
)
1716

1817
func main() {
19-
// TODO: incorporate into a `--version` flag.
20-
{
21-
_ = version
22-
_ = commit
23-
_ = buildDate
24-
}
18+
os.Exit(cmd.Main(cmd.BuildInfo{
19+
Version: version,
20+
Commit: commit,
21+
Date: buildDate,
22+
}))
2523

26-
os.Exit(cmd.Main())
2724
}

httpbin/cmd/cmd.go

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"net/http"
1515
"os"
1616
"os/signal"
17+
"runtime"
1718
"strconv"
1819
"strings"
1920
"syscall"
@@ -38,15 +39,22 @@ const (
3839
defaultSrvMaxHeaderBytes = 16 * 1024 // 16kb
3940
)
4041

42+
// BuildInfo holds build metadata.
43+
type BuildInfo struct {
44+
Version string
45+
Commit string
46+
Date string
47+
}
48+
4149
// Main is the main entrypoint for the go-httpbin binary. See loadConfig() for
4250
// command line argument parsing.
43-
func Main() int {
44-
return mainImpl(os.Args[1:], os.Getenv, os.Environ, os.Hostname, os.Stderr)
51+
func Main(build BuildInfo) int {
52+
return mainImpl(os.Args[1:], build, os.Getenv, os.Environ, os.Hostname, os.Stderr)
4553
}
4654

4755
// mainImpl is the real implementation of Main(), extracted for better
4856
// testability.
49-
func mainImpl(args []string, getEnvVal func(string) string, getEnviron func() []string, getHostname func() (string, error), out io.Writer) int {
57+
func mainImpl(args []string, build BuildInfo, getEnvVal func(string) string, getEnviron func() []string, getHostname func() (string, error), out io.Writer) int {
5058
cfg, err := loadConfig(args, getEnvVal, getEnviron, getHostname)
5159
if err != nil {
5260
if cfgErr, ok := err.(ConfigError); ok {
@@ -72,6 +80,11 @@ func mainImpl(args []string, getEnvVal func(string) string, getEnviron func() []
7280
return 1
7381
}
7482

83+
if cfg.ShowVersion {
84+
fmt.Fprintf(out, "go-httpbin version %s\n%s %s %s\n", build.Version, runtime.Version(), build.Commit, build.Date)
85+
return 0
86+
}
87+
7588
logger := setupLogger(out, cfg.LogFormat, cfg.LogLevel)
7689

7790
opts := []httpbin.OptionFunc{
@@ -81,6 +94,9 @@ func mainImpl(args []string, getEnvVal func(string) string, getEnviron func() []
8194
httpbin.WithObserver(httpbin.StdLogObserver(logger)),
8295
httpbin.WithExcludeHeaders(cfg.ExcludeHeaders),
8396
}
97+
if cfg.UseFullVersion {
98+
opts = append(opts, httpbin.WithVersion("go-httpbin", build.Version, build.Commit, build.Date, runtime.Version()))
99+
}
84100
if cfg.Prefix != "" {
85101
opts = append(opts, httpbin.WithPrefix(cfg.Prefix))
86102
}
@@ -139,6 +155,12 @@ type config struct {
139155
// absolutely necessary.
140156
UnsafeAllowDangerousResponses bool
141157

158+
// If true, print version info and exit.
159+
ShowVersion bool
160+
161+
// If true, expose full version details via /version (default: service name only).
162+
UseFullVersion bool
163+
142164
// temporary placeholders for arguments that need extra processing
143165
rawAllowedRedirectDomains string
144166
rawLogLevel string
@@ -166,6 +188,7 @@ func loadConfig(args []string, getEnvVal func(string) string, getEnviron func()
166188
cfg := &config{}
167189

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

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

329356
// reset temporary fields to their zero values
330357
cfg.rawAllowedRedirectDomains = ""

httpbin/cmd/cmd_test.go

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,12 @@ const usage = `Usage of go-httpbin:
5151
Value to use for the http.Server's ReadTimeout option (default 5s)
5252
-unsafe-allow-dangerous-responses
5353
Allow endpoints to return unescaped HTML when clients control response Content-Type (enables XSS attacks)
54+
-use-full-version
55+
Expose full version details via /version (default: service name only)
5456
-use-real-hostname
5557
Expose value of os.Hostname() in the /hostname endpoint instead of dummy value
58+
-version
59+
Print version and exit
5660
`
5761

5862
func TestLoadConfig(t *testing.T) {
@@ -550,6 +554,35 @@ func TestLoadConfig(t *testing.T) {
550554
env: map[string]string{"UNSAFE_ALLOW_DANGEROUS_RESPONSES": "false"},
551555
wantCfg: defaultCfg,
552556
},
557+
558+
// use-full-version
559+
"ok -use-full-version": {
560+
args: []string{"-use-full-version"},
561+
wantCfg: mergedConfig(defaultCfg, &config{
562+
UseFullVersion: true,
563+
}),
564+
},
565+
"ok USE_FULL_VERSION=1": {
566+
env: map[string]string{"USE_FULL_VERSION": "1"},
567+
wantCfg: mergedConfig(defaultCfg, &config{
568+
UseFullVersion: true,
569+
}),
570+
},
571+
"ok USE_FULL_VERSION=true": {
572+
env: map[string]string{"USE_FULL_VERSION": "true"},
573+
wantCfg: mergedConfig(defaultCfg, &config{
574+
UseFullVersion: true,
575+
}),
576+
},
577+
// case sensitive
578+
"ok USE_FULL_VERSION=TRUE": {
579+
env: map[string]string{"USE_FULL_VERSION": "TRUE"},
580+
wantCfg: defaultCfg,
581+
},
582+
"ok USE_FULL_VERSION=false": {
583+
env: map[string]string{"USE_FULL_VERSION": "false"},
584+
wantCfg: defaultCfg,
585+
},
553586
}
554587

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

585618
testCases := map[string]struct {
619+
build BuildInfo
586620
args []string
587621
env map[string]string
588622
getHostname func() (string, error)
@@ -595,6 +629,15 @@ func TestMainImpl(t *testing.T) {
595629
wantCode: 0,
596630
wantOut: usage,
597631
},
632+
"version": {
633+
build: BuildInfo{Version: "1.2.3", Commit: "abc123", Date: "1988-11-12T10:00:00Z"},
634+
args: []string{"-version"},
635+
wantCode: 0,
636+
wantOutFn: func(t *testing.T, out string) {
637+
assert.Contains(t, out, "go-httpbin version 1.2.3\n", "version output missing first line")
638+
assert.Contains(t, out, " abc123 1988-11-12T10:00:00Z\n", "version output missing second line")
639+
},
640+
},
598641
"cli error": {
599642
args: []string{"-max-body-size", "foo"},
600643
wantCode: 2,
@@ -654,7 +697,7 @@ func TestMainImpl(t *testing.T) {
654697
}
655698

656699
buf := &bytes.Buffer{}
657-
gotCode := mainImpl(tc.args, func(key string) string { return tc.env[key] }, func() []string { return environSlice(tc.env) }, tc.getHostname, buf)
700+
gotCode := mainImpl(tc.args, tc.build, func(key string) string { return tc.env[key] }, func() []string { return environSlice(tc.env) }, tc.getHostname, buf)
658701
out := buf.String()
659702

660703
if gotCode != tc.wantCode {

httpbin/handlers.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1310,6 +1310,11 @@ func (h *HTTPBin) Hostname(w http.ResponseWriter, _ *http.Request) {
13101310
})
13111311
}
13121312

1313+
// Version - returns version info.
1314+
func (h *HTTPBin) Version(w http.ResponseWriter, _ *http.Request) {
1315+
writeJSON(http.StatusOK, w, h.version)
1316+
}
1317+
13131318
// SSE writes a stream of events over a duration after an optional
13141319
// initial delay.
13151320
func (h *HTTPBin) SSE(w http.ResponseWriter, r *http.Request) {

httpbin/handlers_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3562,6 +3562,38 @@ func TestHostname(t *testing.T) {
35623562
})
35633563
}
35643564

3565+
func TestVersion(t *testing.T) {
3566+
t.Run("service only (default)", func(t *testing.T) {
3567+
t.Parallel()
3568+
app := setupTestApp(t, WithVersion("go-httpbin", "", "", "", ""))
3569+
req := newTestRequest(t, "GET", app.URL("/version"), nil)
3570+
resp := mustDoRequest(t, app, req)
3571+
result := mustParseResponse[versionResponse](t, resp)
3572+
assert.DeepEqual(t, result, versionResponse{
3573+
Service: "go-httpbin",
3574+
Version: "",
3575+
Commit: "",
3576+
BuildDate: "",
3577+
GoVersion: "",
3578+
}, "incorrect version response")
3579+
})
3580+
3581+
t.Run("full version info", func(t *testing.T) {
3582+
t.Parallel()
3583+
app := setupTestApp(t, WithVersion("go-httpbin", "1.2.3", "abc123", "1988-11-12", "go1.22.0"))
3584+
req := newTestRequest(t, "GET", app.URL("/version"), nil)
3585+
resp := mustDoRequest(t, app, req)
3586+
result := mustParseResponse[versionResponse](t, resp)
3587+
assert.DeepEqual(t, result, versionResponse{
3588+
Service: "go-httpbin",
3589+
Version: "1.2.3",
3590+
Commit: "abc123",
3591+
BuildDate: "1988-11-12",
3592+
GoVersion: "go1.22.0",
3593+
}, "incorrect version response")
3594+
})
3595+
}
3596+
35653597
func TestSSE(t *testing.T) {
35663598
t.Parallel()
35673599
app := setupTestApp(t)

httpbin/httpbin.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,9 @@ type HTTPBin struct {
8686
// The hostname to expose via /hostname.
8787
hostname string
8888

89+
// Version info to expose via /version.
90+
version versionResponse
91+
8992
// The app's http handler
9093
handler http.Handler
9194

@@ -119,6 +122,7 @@ func New(opts ...OptionFunc) *HTTPBin {
119122
MaxDuration: DefaultMaxDuration,
120123
DefaultParams: DefaultDefaultParams,
121124
hostname: DefaultHostname,
125+
version: versionResponse{Service: "go-httpbin"},
122126
}
123127
for _, opt := range opts {
124128
opt(h)
@@ -221,6 +225,7 @@ func (h *HTTPBin) Handler() http.Handler {
221225
mux.HandleFunc("PATCH /upload", h.RequestWithBodyDiscard)
222226
mux.HandleFunc("/user-agent", h.UserAgent)
223227
mux.HandleFunc("/uuid", h.UUID)
228+
mux.HandleFunc("/version", h.Version)
224229
mux.HandleFunc("/xml", h.XML)
225230

226231
// existing httpbin endpoints that we do not support

httpbin/httpbin_test.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import (
66
"net/http/httptest"
77
"testing"
88
"time"
9+
10+
"github.com/mccutchen/go-httpbin/v2/internal/testing/assert"
911
)
1012

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

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

3742
if h.MaxBodySize != maxBodySize {
@@ -43,6 +48,13 @@ func TestNewOptions(t *testing.T) {
4348
if h.Observer == nil {
4449
t.Fatalf("expected non-nil Observer")
4550
}
51+
assert.DeepEqual(t, h.version, versionResponse{
52+
Service: "go-httpbin",
53+
Version: "1.2.3",
54+
Commit: "abcd1234",
55+
BuildDate: "1988-11-12T10:00:00Z",
56+
GoVersion: "go2.0.0",
57+
}, "incorrect versionResponse")
4658
}
4759

4860
func TestNewObserver(t *testing.T) {

0 commit comments

Comments
 (0)