From dfe8f3138ff5f8ad27ac4e2d640b9baa5218817a Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Thu, 15 Oct 2026 15:44:42 +0200 Subject: [PATCH] Bind 0.0.0.0 to IPv4 only (closes #528) Go listens on any unspecified address with one socket that answers IPv4 and IPv6, so BIND_ADDRESS=0.0.0.0 also bound every IPv6 address, which the README says it does not. The listener now uses tcp4 for an IPv4 address, written plain or IPv4-mapped; :: still binds both. The server's start hook suppresses contextcheck, as the other start hooks do. The image's health check asked for localhost, which wget reached over ::1 only. It now asks for the container's own address: its IPv4 address, or its IPv6 address on an IPv6-only network. A loopback bind answers neither. The README says such a network needs ::, drops the bindv6only condition from :: (Go sets that socket option itself) and names an ss check. Model: opus-5-5 --- Dockerfile | 11 +++- README.md | 24 +++++---- internal/server/bind_address_test.go | 77 ++++++++++++++++++++++++++++ internal/server/http.go | 31 ++++++++++- internal/server/server.go | 2 + 5 files changed, 132 insertions(+), 13 deletions(-) diff --git a/Dockerfile b/Dockerfile index e72dee7..51831be 100644 --- a/Dockerfile +++ b/Dockerfile @@ -274,8 +274,17 @@ EXPOSE 8080 # port. ENV BIND_ADDRESS=0.0.0.0 +# The probe requests the container's own address, not loopback, so a +# listener that answers only on loopback (an empty or loopback +# BIND_ADDRESS) fails it. On a network with IPv4, hostname -i prints the +# container's IPv4 address, which 0.0.0.0 and :: both answer. On an +# IPv6-only network it prints none, and the probe requests the IPv6 +# address /etc/hosts lists under the container's hostname instead, which +# only :: answers. HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://localhost:8080/.well-known/healthcheck || exit 1 + CMD addr="$(hostname -i 2>/dev/null | cut -d ' ' -f 1)"; \ + [ -n "$addr" ] || addr="[$(getent hosts "$(hostname)" | cut -d ' ' -f 1)]"; \ + wget --no-verbose --tries=1 --spider "http://$addr:8080/.well-known/healthcheck" || exit 1 ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] CMD ["/app/webhooker"] diff --git a/README.md b/README.md index 4f2520a..d5febdb 100644 --- a/README.md +++ b/README.md @@ -310,16 +310,17 @@ the namespace, whatever the process bound. Exposure is decided at the publish flag instead — `-p 127.0.0.1:8080:8080` rather than `-p 8080:8080` — which is the operator's to choose and is what [Running with Docker](#running-with-docker) shows. A loopback bind inside a container buys nothing and makes the process -unreachable through its own published port. +unreachable through its own published port. A container on an IPv6-only Docker +network has no IPv4 address for `0.0.0.0` to answer on, so there it needs +`BIND_ADDRESS=::`. The value must be an IP address literal: - `127.0.0.1` — loopback only (the binary's default). Use this with a reverse proxy on the same host. -- `0.0.0.0` — every IPv4 address. The image's default; on a bare host, only - behind a firewall on the port. -- `::` — every address, IPv6 and (on Linux, with the default - `net.ipv6.bindv6only=0`) IPv4 as well. +- `0.0.0.0` — every IPv4 address and no IPv6 address. The image's default; on a + bare host, only behind a firewall on the port. +- `::` — every IPv6 address and every IPv4 address. - A specific address such as `10.0.0.5` — that interface only. An **empty** value is treated as unset, as everywhere else here, and takes the @@ -340,7 +341,8 @@ machine that has no such interface — is a valid literal, so it reaches the listener and fails there. The process logs the bind error and exits non-zero rather than staying up with nothing listening. The effective value is in the `bindAddress` field of the startup log line, which is the way to check what a -running deployment actually bound. +running deployment actually bound. On a bare host, `ss -ltn` lists the socket +itself as `0.0.0.0:8080` for a `0.0.0.0` bind and as `*:8080` for `::`. #### Metrics credentials @@ -668,9 +670,9 @@ Either set a literal or leave the variable out entirely. Overriding `BIND_ADDRESS` to a loopback address in a container — by that route or deliberately — makes the container unreachable from outside its namespace even with `-p`. The published port answers nothing, and the health check fails -too: it requests `http://localhost:8080`, `localhost` resolves to `::1` first, -and a `127.0.0.1` bind is not listening there. The container then goes -`unhealthy` about **65 seconds** after start — from +too: it requests the container's own address (its IPv4 address, or its IPv6 +address on an IPv6-only network), where a loopback bind is not listening. The +container then goes `unhealthy` about **65 seconds** after start — from `HEALTHCHECK --start-period=5s --interval=30s --retries=3`, so failing probes at 5s, 35s and 65s, and `unhealthy` on the third. (Docker's probe cadence during the start period has changed between versions; re-derive from those three values @@ -726,8 +728,8 @@ the credentials. address (directly, or through anything that can rewrite source addresses, such as NAT or a published container port). The `remoteIP` field of the `http request` log line for a request that came through the proxy shows - that address; the health check's own lines show `::1`. See - [Trusted proxies](#trusted-proxies). + that address; the health check's own lines show the container's own + address. See [Trusted proxies](#trusted-proxies). - Leave `BIND_ADDRESS` and `DATA_DIR` unset: the image sets `BIND_ADDRESS` to `0.0.0.0`, and `DATA_DIR` defaults to `/var/lib/webhooker`. - Everything else is optional; see [Configuration](#configuration). diff --git a/internal/server/bind_address_test.go b/internal/server/bind_address_test.go index 9329180..db735b3 100644 --- a/internal/server/bind_address_test.go +++ b/internal/server/bind_address_test.go @@ -5,6 +5,7 @@ import ( "net" "net/http" "strconv" + "syscall" "testing" "time" @@ -25,6 +26,11 @@ const ( // its network namespace even with a published port. wildcardV4 = "0.0.0.0" + // loopbackV6 and wildcardV6 are the IPv6 counterparts of the two + // above. + loopbackV6 = "::1" + wildcardV6 = "::" + // unavailableAddr is a TEST-NET-1 address (RFC 5737). It is a // well-formed literal that no host is assigned, so binding it // fails with EADDRNOTAVAIL rather than succeeding somewhere @@ -116,6 +122,24 @@ func otherLocalAddr(t *testing.T) string { return "" } +// skipWithoutIPv6 skips the test when this host cannot bind +// loopbackV6. Without IPv6 there is no second family for a bind to +// claim or leave alone. +func skipWithoutIPv6(t *testing.T) { + t.Helper() + + var listenCfg net.ListenConfig + + l, err := listenCfg.Listen( + t.Context(), "tcp6", net.JoinHostPort(loopbackV6, "0"), + ) + if err != nil { + t.Skip("host has no IPv6 loopback to bind") + } + + require.NoError(t, l.Close()) +} + // startBoundServer starts the wired app with the given bind address // on a free port and returns that port. The app is stopped on // cleanup. @@ -292,6 +316,59 @@ func TestBindAddress_WildcardReachesOtherAddresses(t *testing.T) { requireDialable(t, net.JoinHostPort(other, strconv.Itoa(port))) } +// TestBindAddress_WildcardV4IsIPv4Only pins that 0.0.0.0 answers on +// IPv4 alone, as the README says, whether it is written as 0.0.0.0 or +// as the IPv4-mapped ::ffff:0.0.0.0. Go listens on 0.0.0.0 with a +// socket that answers IPv6 too unless told otherwise, which put the +// admin UI and the receiver on every IPv6 address of a host whose +// operator had chosen an IPv4 bind. +func TestBindAddress_WildcardV4IsIPv4Only(t *testing.T) { + t.Parallel() + + skipWithoutIPv6(t) + + for _, bindAddress := range []string{wildcardV4, "::ffff:0.0.0.0"} { + t.Run(bindAddress, func(t *testing.T) { + t.Parallel() + + port := startBoundServer(t, bindAddress) + + // Positive control: the service really is up, on IPv4. + requireDialable( + t, net.JoinHostPort(loopbackV4, strconv.Itoa(port)), + ) + + dialer := net.Dialer{Timeout: dialTimeout} + + conn, err := dialer.DialContext( + t.Context(), "tcp", + net.JoinHostPort(loopbackV6, strconv.Itoa(port)), + ) + if err == nil { + _ = conn.Close() + } + + require.ErrorIs( + t, err, syscall.ECONNREFUSED, + "a %s bind answered on %s", bindAddress, loopbackV6, + ) + }) + } +} + +// TestBindAddress_WildcardV6ReachesBothFamilies is the counterpart: +// :: is the value that binds IPv6, and it answers IPv4 as well. +func TestBindAddress_WildcardV6ReachesBothFamilies(t *testing.T) { + t.Parallel() + + skipWithoutIPv6(t) + + port := startBoundServer(t, wildcardV6) + + requireDialable(t, net.JoinHostPort(loopbackV6, strconv.Itoa(port))) + requireDialable(t, net.JoinHostPort(loopbackV4, strconv.Itoa(port))) +} + // TestBindAddress_ServesRequestsOnConfiguredAddress proves the bound // listener serves the application rather than merely accepting TCP, // so a bind address that is honoured cannot be mistaken for one that diff --git a/internal/server/http.go b/internal/server/http.go index 1a01999..4964c31 100644 --- a/internal/server/http.go +++ b/internal/server/http.go @@ -1,9 +1,11 @@ package server import ( + "context" "errors" "net" "net/http" + "net/netip" "strconv" "time" ) @@ -39,6 +41,23 @@ func (s *Server) listenAddr() string { ) } +// listenNetwork returns the network the HTTP listener binds on, "tcp4" +// for an IPv4 BindAddress and "tcp" for any other. +// +// On "tcp", Go binds 0.0.0.0 with a socket that answers IPv6 as well, +// exactly as it binds ::. "tcp4" keeps 0.0.0.0 to IPv4, which is what +// the README says it means. :: stays on "tcp" and answers both. An +// IPv4-mapped address such as ::ffff:0.0.0.0 is the IPv4 address it +// carries to Go's listener, so it gets "tcp4" too. +func (s *Server) listenNetwork() string { + addr, err := netip.ParseAddr(s.params.Config.BindAddress) + if err == nil && addr.Unmap().Is4() { + return "tcp4" + } + + return "tcp" +} + // newHTTPServer builds the HTTP server for this Server's // configuration. // @@ -67,7 +86,17 @@ func (s *Server) serveUntilShutdown() { s.log.Info("http begin listen", "listenaddr", s.httpServer.Addr) - err := s.httpServer.ListenAndServe() + // ListenAndServe always listens on "tcp", which would undo + // listenNetwork. + var listenCfg net.ListenConfig + + listener, err := listenCfg.Listen( + context.Background(), s.listenNetwork(), s.httpServer.Addr, + ) + if err == nil { + err = s.httpServer.Serve(listener) + } + if err != nil && !errors.Is(err, http.ErrServerClosed) { s.log.Error("listen error", "error", err) s.shutdownWithFailure() diff --git a/internal/server/server.go b/internal/server/server.go index 804b991..0d11c0f 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -135,6 +135,8 @@ func New(lc fx.Lifecycle, params ServerParams) (*Server, error) { s.httpServer = s.newHTTPServer() lc.Append(fx.Hook{ + //nolint:contextcheck // The listener outlives this hook, whose + // context fx cancels once the app has started. OnStart: func(_ context.Context) error { s.startupTime = time.Now() go s.Run()