From ef0c3e85f5b0ef6392599abc1b491f593e030c2f Mon Sep 17 00:00:00 2001 From: bsaurusrex <82356519+bsaurusrex@users.noreply.github.com> Date: Fri, 9 Oct 2026 09:54:56 +0800 Subject: [PATCH 1/2] fix: support IPv6 server addresses and unix sockets in the healthcheck The server and healthcheck built the listen address with fmt.Sprintf("%s:%s"), so an unbracketed IPv6 address such as :: or ::1 failed with "too many colons". Both now use net.JoinHostPort, and an already bracketed address like [::] keeps working. A link-local zone id (fe80::1%eth0) is percent-encoded as %25 in the healthcheck URL, which the listener accepts raw but http.NewRequest does not. The healthcheck also probes 127.0.0.1 when the server listens on a wildcard address (0.0.0.0 or ::, which Go serves dual-stack), connects through server.socketPath when it is set instead of probing a TCP port that is not listening, and closes the response body on non-200 responses. Refs #685 Co-Authored-By: Claude Opus 5.5 --- cmd/tinyauth/healthcheck.go | 60 ++++++++++++++++++++------ cmd/tinyauth/healthcheck_test.go | 31 +++++++++++++ internal/bootstrap/router_bootstrap.go | 3 +- internal/utils/app_utils.go | 13 ++++++ internal/utils/app_utils_test.go | 15 +++++++ 5 files changed, 108 insertions(+), 14 deletions(-) create mode 100644 cmd/tinyauth/healthcheck_test.go diff --git a/cmd/tinyauth/healthcheck.go b/cmd/tinyauth/healthcheck.go index 921479a5d..df021ef96 100644 --- a/cmd/tinyauth/healthcheck.go +++ b/cmd/tinyauth/healthcheck.go @@ -1,15 +1,19 @@ package main import ( + "context" "encoding/json" "errors" "fmt" "io" + "net" "net/http" "os" + "strings" "time" "github.com/tinyauthapp/paerser/cli" + "github.com/tinyauthapp/tinyauth/internal/utils" "github.com/tinyauthapp/tinyauth/internal/utils/logger" ) @@ -29,20 +33,15 @@ func healthcheckCmd() *cli.Command { log := logger.NewLogger().WithSimpleConfig() log.Init() - srvAddr := os.Getenv("TINYAUTH_SERVER_ADDRESS") - if srvAddr == "" { - srvAddr = "127.0.0.1" - } - - srvPort := os.Getenv("TINYAUTH_SERVER_PORT") - if srvPort == "" { - srvPort = "3000" - } - - appUrl := fmt.Sprintf("http://%s:%s", srvAddr, srvPort) + appUrl, socketPath := healthcheckTarget( + os.Getenv("TINYAUTH_SERVER_ADDRESS"), + os.Getenv("TINYAUTH_SERVER_PORT"), + os.Getenv("TINYAUTH_SERVER_SOCKETPATH"), + ) if len(args) > 0 { appUrl = args[0] + socketPath = "" } if appUrl == "" { @@ -55,6 +54,16 @@ func healthcheckCmd() *cli.Command { Timeout: 30 * time.Second, } + if socketPath != "" { + log.App.Info().Str("socket_path", socketPath).Msg("Using unix socket") + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.Proxy = nil + transport.DialContext = func(ctx context.Context, _, _ string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, "unix", socketPath) + } + client.Transport = transport + } + req, err := http.NewRequest("GET", appUrl+"/api/healthz", nil) if err != nil { @@ -67,12 +76,12 @@ func healthcheckCmd() *cli.Command { return fmt.Errorf("failed to perform request: %w", err) } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { return fmt.Errorf("service is not healthy, got: %s", resp.Status) } - defer resp.Body.Close() - var healthResp healthzResponse body, err := io.ReadAll(resp.Body) @@ -93,3 +102,28 @@ func healthcheckCmd() *cli.Command { }, } } + +// healthcheckTarget returns the URL to probe and, when tinyauth serves on a unix socket, the socket to dial. +// Wildcard listen addresses are probed on IPv4 loopback. +func healthcheckTarget(addr string, port string, socketPath string) (string, string) { + if socketPath != "" { + return "http://tinyauth", socketPath + } + + host := utils.TrimHostBrackets(addr) + // Go listens dual-stack on [::], so IPv4 loopback also works when IPv6 is disabled + switch host { + case "", "0.0.0.0", "::": + host = "127.0.0.1" + } + + if port == "" { + port = "3000" + } + + // A link-local address keeps its zone id (fe80::1%eth0); the listener accepts the raw + // %, but it must be percent-encoded as %25 before it goes into the probe URL host. + host = strings.ReplaceAll(host, "%", "%25") + + return "http://" + utils.JoinHostPort(host, port), "" +} diff --git a/cmd/tinyauth/healthcheck_test.go b/cmd/tinyauth/healthcheck_test.go new file mode 100644 index 000000000..0a5bde690 --- /dev/null +++ b/cmd/tinyauth/healthcheck_test.go @@ -0,0 +1,31 @@ +package main + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestHealthcheckTarget(t *testing.T) { + tests := []struct { + addr, port, socket string + url, socketPath string + }{ + {"", "", "", "http://127.0.0.1:3000", ""}, + {"0.0.0.0", "3003", "", "http://127.0.0.1:3003", ""}, + {"::", "3000", "", "http://127.0.0.1:3000", ""}, + {"[::]", "3000", "", "http://127.0.0.1:3000", ""}, + {"::1", "3000", "", "http://[::1]:3000", ""}, + {"fe80::1%eth0", "3000", "", "http://[fe80::1%25eth0]:3000", ""}, + {"[fe80::1%eth0]", "3000", "", "http://[fe80::1%25eth0]:3000", ""}, + {"192.0.2.10", "", "", "http://192.0.2.10:3000", ""}, + {"tinyauth", "8080", "", "http://tinyauth:8080", ""}, + {"0.0.0.0", "3000", "/run/tinyauth.sock", "http://tinyauth", "/run/tinyauth.sock"}, + } + + for _, tt := range tests { + url, socketPath := healthcheckTarget(tt.addr, tt.port, tt.socket) + assert.Equal(t, tt.url, url, "addr=%q port=%q socket=%q", tt.addr, tt.port, tt.socket) + assert.Equal(t, tt.socketPath, socketPath, "addr=%q port=%q socket=%q", tt.addr, tt.port, tt.socket) + } +} diff --git a/internal/bootstrap/router_bootstrap.go b/internal/bootstrap/router_bootstrap.go index ae82c2d36..9e1b1a98e 100644 --- a/internal/bootstrap/router_bootstrap.go +++ b/internal/bootstrap/router_bootstrap.go @@ -12,6 +12,7 @@ import ( "github.com/tinyauthapp/tinyauth/internal/controller" "github.com/tinyauthapp/tinyauth/internal/middleware" "github.com/tinyauthapp/tinyauth/internal/model" + "github.com/tinyauthapp/tinyauth/internal/utils" "go.uber.org/dig" "github.com/gin-gonic/gin" @@ -147,7 +148,7 @@ func (app *BootstrapApp) getListenerFunc() (func(ctx context.Context) error, err } func (app *BootstrapApp) serveHTTP(ctx context.Context) error { - address := fmt.Sprintf("%s:%d", app.config.Server.Address, app.config.Server.Port) + address := utils.JoinHostPort(app.config.Server.Address, fmt.Sprint(app.config.Server.Port)) app.log.App.Info().Msgf("Starting server on http://%s", address) diff --git a/internal/utils/app_utils.go b/internal/utils/app_utils.go index 7c168423c..6f610532c 100644 --- a/internal/utils/app_utils.go +++ b/internal/utils/app_utils.go @@ -125,3 +125,16 @@ func Filter[T any](slice []T, test func(T) bool) (res []T) { } return res } + +// JoinHostPort joins a host and port, bracketing IPv6 addresses. Already bracketed hosts (e.g. [::]) are accepted too. +func JoinHostPort(host string, port string) string { + return net.JoinHostPort(TrimHostBrackets(host), port) +} + +// TrimHostBrackets removes one pair of enclosing brackets (e.g. [::] becomes ::), unbalanced brackets are kept so they still fail to parse +func TrimHostBrackets(host string) string { + if len(host) >= 2 && host[0] == '[' && host[len(host)-1] == ']' { + return host[1 : len(host)-1] + } + return host +} diff --git a/internal/utils/app_utils_test.go b/internal/utils/app_utils_test.go index 6dbe44929..ad608a621 100644 --- a/internal/utils/app_utils_test.go +++ b/internal/utils/app_utils_test.go @@ -232,3 +232,18 @@ func TestFilter(t *testing.T) { resultStr := utils.Filter(sliceStr, testFuncStr) assert.Equal(t, expectedStr, resultStr) } + +func TestJoinHostPort(t *testing.T) { + assert.Equal(t, "0.0.0.0:3000", utils.JoinHostPort("0.0.0.0", "3000")) + assert.Equal(t, "[::]:3000", utils.JoinHostPort("::", "3000")) + assert.Equal(t, "[::]:3000", utils.JoinHostPort("[::]", "3000")) + assert.Equal(t, "[::1]:3000", utils.JoinHostPort("::1", "3000")) + assert.Equal(t, "[fe80::1%eth0]:3000", utils.JoinHostPort("fe80::1%eth0", "3000")) + assert.Equal(t, "localhost:3000", utils.JoinHostPort("localhost", "3000")) + assert.Equal(t, ":3000", utils.JoinHostPort("", "3000")) + + // Unbalanced brackets are not stripped, so listening on them still fails + assert.Equal(t, "[::]]:3000", utils.JoinHostPort("::]", "3000")) + assert.Equal(t, "[[::]:3000", utils.JoinHostPort("[::", "3000")) + assert.Equal(t, "[127.0.0.1:3000", utils.JoinHostPort("[127.0.0.1", "3000")) +} From 481ff9a083afbe0efc5f0d5ae37f2ca2c0f85957 Mon Sep 17 00:00:00 2001 From: bsaurusrex <82356519+bsaurusrex@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:17:23 +0800 Subject: [PATCH 2/2] fix: probe IPv6 loopback for an IPv6 wildcard healthcheck target A [::] listener on a platform without IPv4-mapped IPv6 (e.g. bindv6only) accepts IPv6 only, so the previous 127.0.0.1 probe reported an unhealthy server that was in fact accepting connections. Probe ::1 for a :: or [::] address, which reaches the listener whether it is dual-stack or IPv6-only. IPv4 and empty wildcard addresses keep using 127.0.0.1. Refs #685 Co-Authored-By: Claude Opus 4.8 --- cmd/tinyauth/healthcheck.go | 9 +++++++-- cmd/tinyauth/healthcheck_test.go | 4 ++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/cmd/tinyauth/healthcheck.go b/cmd/tinyauth/healthcheck.go index df021ef96..3a17d1861 100644 --- a/cmd/tinyauth/healthcheck.go +++ b/cmd/tinyauth/healthcheck.go @@ -111,10 +111,15 @@ func healthcheckTarget(addr string, port string, socketPath string) (string, str } host := utils.TrimHostBrackets(addr) - // Go listens dual-stack on [::], so IPv4 loopback also works when IPv6 is disabled switch host { - case "", "0.0.0.0", "::": + case "", "0.0.0.0": + // IPv4 wildcard (and the dual-stack :port listener): IPv4 loopback reaches it. host = "127.0.0.1" + case "::": + // IPv6 wildcard: probe IPv6 loopback. It reaches a [::] listener whether it is + // dual-stack or IPv6-only, whereas 127.0.0.1 fails on an IPv6-only listener + // (a platform without IPv4-mapped IPv6, e.g. bindv6only). + host = "::1" } if port == "" { diff --git a/cmd/tinyauth/healthcheck_test.go b/cmd/tinyauth/healthcheck_test.go index 0a5bde690..fd5b54857 100644 --- a/cmd/tinyauth/healthcheck_test.go +++ b/cmd/tinyauth/healthcheck_test.go @@ -13,8 +13,8 @@ func TestHealthcheckTarget(t *testing.T) { }{ {"", "", "", "http://127.0.0.1:3000", ""}, {"0.0.0.0", "3003", "", "http://127.0.0.1:3003", ""}, - {"::", "3000", "", "http://127.0.0.1:3000", ""}, - {"[::]", "3000", "", "http://127.0.0.1:3000", ""}, + {"::", "3000", "", "http://[::1]:3000", ""}, + {"[::]", "3000", "", "http://[::1]:3000", ""}, {"::1", "3000", "", "http://[::1]:3000", ""}, {"fe80::1%eth0", "3000", "", "http://[fe80::1%25eth0]:3000", ""}, {"[fe80::1%eth0]", "3000", "", "http://[fe80::1%25eth0]:3000", ""},