feat: add built-in health check command for Docker HEALTHCHECK support

Adds a `health` subcommand and `--healthcheck` flag that performs an HTTP
GET against the running oauth2-proxy's /ping endpoint, returning exit 0
on HTTP 200 and exit 1 otherwise. This eliminates the need to add curl
or wget to distroless container images for Docker health checks.

Two invocation modes:
- `oauth2-proxy health` (subcommand with own flags)
- `oauth2-proxy --healthcheck` (reads proxy config for address/ping-path)

The health subcommand supports:
- --http-address / --https-address to target the correct listener
- --ping-path for custom ping endpoint paths
- --timeout for configurable request timeout (default 5s)
- --insecure-skip-verify for self-signed TLS certificates
- Automatic wildcard-to-loopback address translation (0.0.0.0 -> 127.0.0.1)
- HTTP-first with HTTPS fallback

Also adds HEALTHCHECK instruction to the Dockerfile.

Zero external dependencies - uses only net/http from the Go standard library.

Closes #2555

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
frhack 2026-02-12 19:07:45 +01:00
parent 3a55dadbe8
commit 2febf94e0d
5 changed files with 532 additions and 0 deletions

View File

@ -71,4 +71,7 @@ LABEL org.opencontainers.image.licenses=MIT \
org.opencontainers.image.title=oauth2-proxy \
org.opencontainers.image.version=${VERSION}
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD ["/bin/oauth2-proxy", "health"]
ENTRYPOINT ["/bin/oauth2-proxy"]

74
main.go
View File

@ -4,8 +4,10 @@ import (
"fmt"
"os"
"runtime"
"time"
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options"
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/healthcheck"
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger"
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/validation"
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/version"
@ -16,6 +18,12 @@ import (
func main() {
logger.SetFlags(logger.Lshortfile)
// Check if "health" subcommand is being invoked (e.g., "oauth2-proxy health")
if len(os.Args) > 1 && os.Args[1] == "health" {
runHealthCheck(os.Args[2:])
return
}
configFlagSet := pflag.NewFlagSet("oauth2-proxy", pflag.ContinueOnError)
// Because we parse early to determine alpha vs legacy config, we have to
@ -26,6 +34,7 @@ func main() {
alphaConfig := configFlagSet.String("alpha-config", "", "path to alpha config file (use at your own risk - the structure in this config file may change between minor releases)")
convertConfig := configFlagSet.Bool("convert-config-to-alpha", false, "if true, the proxy will load configuration as normal and convert existing configuration to the alpha config structure, and print it to stdout")
showVersion := configFlagSet.Bool("version", false, "print version string")
checkHealth := configFlagSet.Bool("healthcheck", false, "perform a health check against a running oauth2-proxy instance and exit")
configFlagSet.Parse(os.Args[1:])
if *showVersion {
@ -33,6 +42,11 @@ func main() {
return
}
if *checkHealth {
runHealthCheckFromConfig(*config, *alphaConfig, configFlagSet, os.Args[1:])
return
}
if *convertConfig && *alphaConfig != "" {
logger.Fatal("cannot use alpha-config and convert-config-to-alpha together")
}
@ -64,6 +78,66 @@ func main() {
}
}
// runHealthCheck handles the "health" subcommand with its own flag set.
func runHealthCheck(args []string) {
fs := pflag.NewFlagSet("health", pflag.ContinueOnError)
httpAddr := fs.String("http-address", healthcheck.DefaultHTTPAddress, "HTTP address of the oauth2-proxy instance to check")
httpsAddr := fs.String("https-address", "", "HTTPS address of the oauth2-proxy instance to check")
pingPath := fs.String("ping-path", healthcheck.DefaultPingPath, "path of the ping endpoint")
timeout := fs.Duration("timeout", healthcheck.DefaultTimeout, "timeout for the health check request")
insecure := fs.Bool("insecure-skip-verify", false, "skip TLS certificate verification for HTTPS health checks")
if err := fs.Parse(args); err != nil {
logger.Fatalf("ERROR: %v", err)
}
opts := healthcheck.CheckOptions{
HTTPAddress: *httpAddr,
HTTPSAddress: *httpsAddr,
PingPath: *pingPath,
Timeout: *timeout,
InsecureSkipVerify: *insecure,
}
if err := healthcheck.Run(opts); err != nil {
fmt.Fprintf(os.Stderr, "healthcheck failed: %v\n", err)
os.Exit(1)
}
fmt.Println("OK")
}
// runHealthCheckFromConfig performs a health check using the loaded configuration.
// This supports the --healthcheck flag which respects the same configuration as the proxy.
func runHealthCheckFromConfig(config, alphaConfig string, extraFlags *pflag.FlagSet, args []string) {
opts, err := loadConfiguration(config, alphaConfig, extraFlags, args)
if err != nil {
// If config loading fails, fall back to defaults
logger.Printf("WARNING: failed to load configuration: %v; using defaults", err)
checkOpts := healthcheck.DefaultCheckOptions()
if err := healthcheck.Run(checkOpts); err != nil {
fmt.Fprintf(os.Stderr, "healthcheck failed: %v\n", err)
os.Exit(1)
}
fmt.Println("OK")
return
}
checkOpts := healthcheck.CheckOptions{
HTTPAddress: opts.Server.BindAddress,
HTTPSAddress: opts.Server.SecureBindAddress,
PingPath: opts.PingPath,
Timeout: 5 * time.Second,
}
if err := healthcheck.Run(checkOpts); err != nil {
fmt.Fprintf(os.Stderr, "healthcheck failed: %v\n", err)
os.Exit(1)
}
fmt.Println("OK")
}
// loadConfiguration will load in the user's configuration.
// It will either load the alpha configuration (if alphaConfig is given)
// or the legacy configuration.

View File

@ -0,0 +1,156 @@
package healthcheck
import (
"crypto/tls"
"fmt"
"io"
"net"
"net/http"
"strings"
"time"
)
const (
// DefaultHTTPAddress is the default bind address for the HTTP server.
DefaultHTTPAddress = "127.0.0.1:4180"
// DefaultPingPath is the default path for the ping endpoint.
DefaultPingPath = "/ping"
// DefaultTimeout is the default timeout for the health check request.
DefaultTimeout = 5 * time.Second
)
// CheckOptions holds configuration for a health check request.
type CheckOptions struct {
// HTTPAddress is the address the oauth2-proxy HTTP server is bound to.
// Format: [http://]<addr>:<port>
HTTPAddress string
// HTTPSAddress is the address the oauth2-proxy HTTPS server is bound to.
// Format: <addr>:<port>
HTTPSAddress string
// PingPath is the URL path for the ping endpoint.
PingPath string
// Timeout is the maximum duration for the health check request.
Timeout time.Duration
// InsecureSkipVerify skips TLS certificate verification for HTTPS checks.
InsecureSkipVerify bool
}
// DefaultCheckOptions returns CheckOptions with sensible defaults.
func DefaultCheckOptions() CheckOptions {
return CheckOptions{
HTTPAddress: DefaultHTTPAddress,
HTTPSAddress: "",
PingPath: DefaultPingPath,
Timeout: DefaultTimeout,
}
}
// Run performs the health check and returns nil on success or an error on failure.
// It checks the HTTP address first. If the HTTP address is empty or disabled,
// it falls back to the HTTPS address.
func Run(opts CheckOptions) error {
if opts.PingPath == "" {
opts.PingPath = DefaultPingPath
}
if opts.Timeout == 0 {
opts.Timeout = DefaultTimeout
}
httpAddr := normalizeAddress(opts.HTTPAddress)
httpsAddr := normalizeAddress(opts.HTTPSAddress)
// Try HTTP first, then HTTPS
if httpAddr != "" && httpAddr != "-" {
return checkEndpoint("http", httpAddr, opts.PingPath, opts.Timeout, opts.InsecureSkipVerify)
}
if httpsAddr != "" && httpsAddr != "-" {
return checkEndpoint("https", httpsAddr, opts.PingPath, opts.Timeout, opts.InsecureSkipVerify)
}
return fmt.Errorf("no bind address configured; cannot perform health check")
}
// normalizeAddress strips an optional scheme prefix and returns the host:port.
func normalizeAddress(addr string) string {
addr = strings.TrimSpace(addr)
// Strip optional scheme prefix (e.g., "http://127.0.0.1:4180")
for _, prefix := range []string{"http://", "https://"} {
if strings.HasPrefix(strings.ToLower(addr), prefix) {
addr = addr[len(prefix):]
break
}
}
return addr
}
// checkEndpoint performs a GET request against scheme://addr/pingPath and validates
// that the response status is 200 OK.
func checkEndpoint(scheme, addr, pingPath string, timeout time.Duration, insecureSkipVerify bool) error {
// Replace unspecified addresses with loopback so the check connects locally.
host, port, err := net.SplitHostPort(addr)
if err != nil {
return fmt.Errorf("invalid address %q: %v", addr, err)
}
host = replaceUnspecified(host)
target := net.JoinHostPort(host, port)
url := fmt.Sprintf("%s://%s%s", scheme, target, pingPath)
client := &http.Client{
Timeout: timeout,
// Do not follow redirects; we expect a direct 200 from the ping endpoint.
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
if scheme == "https" {
client.Transport = &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: insecureSkipVerify, //nolint:gosec // intentional for local health check against self-signed certs
},
}
}
resp, err := client.Get(url) //nolint:gosec // URL is constructed from known configuration, not user input
if err != nil {
return fmt.Errorf("health check failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
return fmt.Errorf("health check returned status %d: %s", resp.StatusCode, string(body))
}
return nil
}
// replaceUnspecified replaces unspecified (wildcard) addresses with their
// loopback equivalents so the health check connects locally.
func replaceUnspecified(host string) string {
switch host {
case "", "0.0.0.0":
return "127.0.0.1"
case "::", "[::]":
return "::1"
}
// Strip brackets from IPv6 addresses that net.SplitHostPort already handled
host = strings.Trim(host, "[]")
ip := net.ParseIP(host)
if ip != nil && ip.IsUnspecified() {
if ip.To4() != nil {
return "127.0.0.1"
}
return "::1"
}
return host
}

View File

@ -0,0 +1,13 @@
package healthcheck
import (
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestHealthcheckSuite(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Healthcheck")
}

View File

@ -0,0 +1,286 @@
package healthcheck
import (
"fmt"
"net"
"net/http"
"net/http/httptest"
"time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Healthcheck", func() {
Describe("normalizeAddress", func() {
type normalizeInput struct {
input string
expected string
}
DescribeTable("should strip scheme prefixes and whitespace",
func(in normalizeInput) {
Expect(normalizeAddress(in.input)).To(Equal(in.expected))
},
Entry("plain address", normalizeInput{
input: "127.0.0.1:4180", expected: "127.0.0.1:4180",
}),
Entry("with http scheme", normalizeInput{
input: "http://127.0.0.1:4180", expected: "127.0.0.1:4180",
}),
Entry("with https scheme", normalizeInput{
input: "https://127.0.0.1:443", expected: "127.0.0.1:443",
}),
Entry("with leading whitespace", normalizeInput{
input: " 127.0.0.1:4180", expected: "127.0.0.1:4180",
}),
Entry("empty string", normalizeInput{
input: "", expected: "",
}),
Entry("disabled address", normalizeInput{
input: "-", expected: "-",
}),
)
})
Describe("replaceUnspecified", func() {
type replaceInput struct {
input string
expected string
}
DescribeTable("should replace unspecified addresses with loopback",
func(in replaceInput) {
Expect(replaceUnspecified(in.input)).To(Equal(in.expected))
},
Entry("empty string", replaceInput{
input: "", expected: "127.0.0.1",
}),
Entry("IPv4 unspecified", replaceInput{
input: "0.0.0.0", expected: "127.0.0.1",
}),
Entry("IPv6 unspecified (::)", replaceInput{
input: "::", expected: "::1",
}),
Entry("IPv6 unspecified with brackets", replaceInput{
input: "[::]", expected: "::1",
}),
Entry("IPv4 localhost", replaceInput{
input: "127.0.0.1", expected: "127.0.0.1",
}),
Entry("specific IPv4 address", replaceInput{
input: "10.0.0.1", expected: "10.0.0.1",
}),
)
})
Describe("Run", func() {
var (
server *httptest.Server
listener net.Listener
)
AfterEach(func() {
if server != nil {
server.Close()
}
})
It("should succeed when ping endpoint returns 200", func() {
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/ping" {
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, "OK")
return
}
w.WriteHeader(http.StatusNotFound)
}))
// Extract host:port from the test server URL
addr := server.Listener.Addr().String()
opts := CheckOptions{
HTTPAddress: addr,
PingPath: "/ping",
Timeout: 2 * time.Second,
}
Expect(Run(opts)).To(Succeed())
})
It("should fail when ping endpoint returns non-200", func() {
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
fmt.Fprint(w, "not ready")
}))
addr := server.Listener.Addr().String()
opts := CheckOptions{
HTTPAddress: addr,
PingPath: "/ping",
Timeout: 2 * time.Second,
}
err := Run(opts)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("status 503"))
})
It("should fail when server is not reachable", func() {
// Use a random port that is unlikely to have a server
listener, _ = net.Listen("tcp", "127.0.0.1:0")
addr := listener.Addr().String()
listener.Close() // Close immediately so the port is free but nothing is listening
opts := CheckOptions{
HTTPAddress: addr,
PingPath: "/ping",
Timeout: 1 * time.Second,
}
err := Run(opts)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("health check failed"))
})
It("should fail when no address is configured", func() {
opts := CheckOptions{
HTTPAddress: "",
HTTPSAddress: "",
PingPath: "/ping",
Timeout: 1 * time.Second,
}
err := Run(opts)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("no bind address configured"))
})
It("should fail when address is disabled with -", func() {
opts := CheckOptions{
HTTPAddress: "-",
HTTPSAddress: "-",
PingPath: "/ping",
Timeout: 1 * time.Second,
}
err := Run(opts)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("no bind address configured"))
})
It("should use default ping path when not specified", func() {
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/ping" {
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, "OK")
return
}
w.WriteHeader(http.StatusNotFound)
}))
addr := server.Listener.Addr().String()
opts := CheckOptions{
HTTPAddress: addr,
PingPath: "", // should default to /ping
Timeout: 2 * time.Second,
}
Expect(Run(opts)).To(Succeed())
})
It("should use a custom ping path", func() {
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/healthz" {
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, "OK")
return
}
w.WriteHeader(http.StatusNotFound)
}))
addr := server.Listener.Addr().String()
opts := CheckOptions{
HTTPAddress: addr,
PingPath: "/healthz",
Timeout: 2 * time.Second,
}
Expect(Run(opts)).To(Succeed())
})
It("should handle address with http:// scheme prefix", func() {
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, "OK")
}))
addr := server.Listener.Addr().String()
opts := CheckOptions{
HTTPAddress: "http://" + addr,
PingPath: "/ping",
Timeout: 2 * time.Second,
}
Expect(Run(opts)).To(Succeed())
})
It("should fall back to HTTPS when HTTP address is empty", func() {
server = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/ping" {
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, "OK")
return
}
w.WriteHeader(http.StatusNotFound)
}))
addr := server.Listener.Addr().String()
opts := CheckOptions{
HTTPAddress: "",
HTTPSAddress: addr,
PingPath: "/ping",
Timeout: 2 * time.Second,
InsecureSkipVerify: true,
}
Expect(Run(opts)).To(Succeed())
})
It("should respect timeout", func() {
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Simulate a slow server
time.Sleep(3 * time.Second)
w.WriteHeader(http.StatusOK)
}))
addr := server.Listener.Addr().String()
opts := CheckOptions{
HTTPAddress: addr,
PingPath: "/ping",
Timeout: 500 * time.Millisecond,
}
err := Run(opts)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("health check failed"))
})
})
Describe("DefaultCheckOptions", func() {
It("should return sensible defaults", func() {
opts := DefaultCheckOptions()
Expect(opts.HTTPAddress).To(Equal(DefaultHTTPAddress))
Expect(opts.PingPath).To(Equal(DefaultPingPath))
Expect(opts.Timeout).To(Equal(DefaultTimeout))
Expect(opts.HTTPSAddress).To(BeEmpty())
Expect(opts.InsecureSkipVerify).To(BeFalse())
})
})
})