diff --git a/.codecov.yml b/.codecov.yml index ae6d6a6..7abee32 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -34,3 +34,4 @@ comment: ignore: - internal/testing +- cmd/go-httpbin/main.go # no logic to test, just calls cmd.Main, which is tested diff --git a/Makefile b/Makefile index d02f1c2..1c2d58c 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index 8cf94b2..f193a36 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cmd/go-httpbin/main.go b/cmd/go-httpbin/main.go index 10ef741..35e884e 100644 --- a/cmd/go-httpbin/main.go +++ b/cmd/go-httpbin/main.go @@ -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()) } diff --git a/httpbin/cmd/cmd.go b/httpbin/cmd/cmd.go index 67b4256..d8a4ae1 100644 --- a/httpbin/cmd/cmd.go +++ b/httpbin/cmd/cmd.go @@ -14,6 +14,7 @@ import ( "net/http" "os" "os/signal" + "runtime" "strconv" "strings" "syscall" @@ -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 { @@ -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{ @@ -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)) } @@ -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 @@ -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") @@ -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 @@ -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 = "" diff --git a/httpbin/cmd/cmd_test.go b/httpbin/cmd/cmd_test.go index 8de5bbd..d2d9eff 100644 --- a/httpbin/cmd/cmd_test.go +++ b/httpbin/cmd/cmd_test.go @@ -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) { @@ -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 { @@ -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) @@ -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, @@ -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 { diff --git a/httpbin/handlers.go b/httpbin/handlers.go index 0eef4d0..1648158 100644 --- a/httpbin/handlers.go +++ b/httpbin/handlers.go @@ -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) { diff --git a/httpbin/handlers_test.go b/httpbin/handlers_test.go index 928ce44..dbbfeec 100644 --- a/httpbin/handlers_test.go +++ b/httpbin/handlers_test.go @@ -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) diff --git a/httpbin/httpbin.go b/httpbin/httpbin.go index 8ae1f2c..c16675b 100644 --- a/httpbin/httpbin.go +++ b/httpbin/httpbin.go @@ -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 @@ -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) @@ -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 diff --git a/httpbin/httpbin_test.go b/httpbin/httpbin_test.go index 3fcd17d..3a85a57 100644 --- a/httpbin/httpbin_test.go +++ b/httpbin/httpbin_test.go @@ -6,6 +6,8 @@ import ( "net/http/httptest" "testing" "time" + + "github.com/mccutchen/go-httpbin/v2/internal/testing/assert" ) func TestNew(t *testing.T) { @@ -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) { @@ -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 { @@ -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) { diff --git a/httpbin/options.go b/httpbin/options.go index 720355a..4c45cdf 100644 --- a/httpbin/options.go +++ b/httpbin/options.go @@ -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. diff --git a/httpbin/responses.go b/httpbin/responses.go index 9d686c0..61b8832 100644 --- a/httpbin/responses.go +++ b/httpbin/responses.go @@ -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"` }