Bind 0.0.0.0 to IPv4 only (closes #528)
check / check (push) Waiting to run

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; :: still binds both.

The image's health check asked for localhost, which wget reached over
::1 only, so it now asks for 127.0.0.1. The README says what that
means for a loopback bind in a container, drops the bindv6only
condition from :: (Go sets that socket option itself), and names an
ss check.

Model: opus-5-5
This commit is contained in:
2026-10-15 08:02:19 +00:00
parent 573f7f986a
commit a5295ea3b7
4 changed files with 110 additions and 12 deletions
+68
View File
@@ -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,50 @@ 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. 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)
port := startBoundServer(t, wildcardV4)
// 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", wildcardV4, 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