package server_test import ( "context" "net" "net/http" "strconv" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/fx" "sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/globals" "sneak.berlin/go/webhooker/internal/server" ) const ( // loopbackV4 is the shipped BIND_ADDRESS default. loopbackV4 = "127.0.0.1" // wildcardV4 is the value a container deployment must set, // where a loopback-bound process is unreachable from outside // its network namespace even with a published port. wildcardV4 = "0.0.0.0" // 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 // unexpected. unavailableAddr = "192.0.2.1" // listenReadyTimeout bounds the wait for the listener to accept // connections. The bind itself is immediate; this only covers // goroutine scheduling. listenReadyTimeout = 3 * time.Second // listenPollInterval is how often the readiness wait retries. listenPollInterval = 10 * time.Millisecond // dialTimeout bounds a single connection attempt in these // tests. Everything dialled here is on this host, so a dial // that is not answered immediately is a failure, not slowness. dialTimeout = time.Second ) // freePort returns a TCP port that is free on every local address at // the moment it returns, by taking one on the wildcard and releasing // it. The window between release and re-bind is the standard one // every "pick a free port" helper carries. func freePort(t *testing.T) int { t.Helper() var listenCfg net.ListenConfig l, err := listenCfg.Listen(t.Context(), "tcp", "0.0.0.0:0") require.NoError(t, err) addr, ok := l.Addr().(*net.TCPAddr) require.True(t, ok, "listener is not TCP") require.NoError(t, l.Close()) return addr.Port } // otherLocalAddr returns a local IPv4 address that is not // loopbackV4, or skips the test when the host has none. // // The bind-address tests need a second address of this host to stand // in for "another interface": what a wildcard bind claims and a // loopback bind does not. 127.0.0.2 is that address on Linux, where // the whole 127.0.0.0/8 is local; elsewhere an interface address is // used instead. Each candidate is proven bindable before it is // returned, so a host that offers neither skips rather than fails on // something that was never about the code under test. func otherLocalAddr(t *testing.T) string { t.Helper() candidates := []string{"127.0.0.2"} ifaceAddrs, err := net.InterfaceAddrs() require.NoError(t, err) for _, a := range ifaceAddrs { ipNet, ok := a.(*net.IPNet) if !ok { continue } ip4 := ipNet.IP.To4() if ip4 == nil || ip4.String() == loopbackV4 { continue } candidates = append(candidates, ip4.String()) } var listenCfg net.ListenConfig for _, candidate := range candidates { l, listenErr := listenCfg.Listen( t.Context(), "tcp", net.JoinHostPort(candidate, "0"), ) if listenErr != nil { continue } require.NoError(t, l.Close()) return candidate } t.Skip("host has no second local IPv4 address to bind") return "" } // startBoundServer starts the wired app with the given bind address // on a free port and returns that port. The app is stopped on // cleanup. func startBoundServer(t *testing.T, bindAddress string) int { t.Helper() port := freePort(t) env := newTestEnv(t) env.cfg.BindAddress = bindAddress env.cfg.Port = port app := fx.New( fx.NopLogger, fx.Supply(env.log, env.cfg, env.mw, env.hnd), fx.Provide(globals.New, server.New), fx.Invoke(func(*server.Server) {}), ) startCtx, cancelStart := context.WithTimeout( context.Background(), lifecycleTimeout, ) defer cancelStart() require.NoError(t, app.Start(startCtx)) t.Cleanup(func() { stopCtx, cancelStop := context.WithTimeout( context.Background(), lifecycleTimeout, ) defer cancelStop() require.NoError(t, app.Stop(stopCtx)) }) return port } // dialable reports whether a TCP connection to addr succeeds. func dialable(ctx context.Context, addr string) bool { dialer := net.Dialer{Timeout: dialTimeout} conn, err := dialer.DialContext(ctx, "tcp", addr) if err != nil { return false } _ = conn.Close() return true } // requireDialable waits for addr to accept connections, failing the // test if it never does. func requireDialable(t *testing.T, addr string) { t.Helper() deadline := time.Now().Add(listenReadyTimeout) for time.Now().Before(deadline) { if dialable(t.Context(), addr) { return } time.Sleep(listenPollInterval) } t.Fatalf("nothing accepted connections on %s", addr) } // TestListenAddr pins how BindAddress and Port are rendered into the // listen address. // // The defect this covers was a bare fmt.Sprintf(":%d", port), which // binds every interface with no way to say otherwise. The IPv6 rows // are here because an unbracketed IPv6 host would produce an address // net.Listen rejects, turning a valid configuration into a startup // failure. func TestListenAddr(t *testing.T) { t.Parallel() tests := []struct { name string bindAddress string port int expected string }{ { name: "loopback default", bindAddress: loopbackV4, port: 8080, expected: "127.0.0.1:8080", }, { name: "ipv4 wildcard", bindAddress: wildcardV4, port: 8080, expected: "0.0.0.0:8080", }, { name: "ipv6 wildcard is bracketed", bindAddress: "::", port: 8080, expected: "[::]:8080", }, { name: "ipv6 literal is bracketed", bindAddress: "2001:db8::5", port: 9001, expected: "[2001:db8::5]:9001", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() assert.Equal(t, tt.expected, server.ListenAddrForTest( &config.Config{ BindAddress: tt.bindAddress, Port: tt.port, }, )) }) } } // TestBindAddress_LoopbackIsNotOnOtherAddresses proves the fix end to // end: with BIND_ADDRESS at its loopback default, the cleartext // listener answers on loopback and has not claimed any other address // of this host. // // The second address is proven free by binding it on the same port // while the server runs. That is the assertion that fails against the // old wildcard bind — a wildcard listener owns the port on every // address, so this bind would return EADDRINUSE. Dialling from // another machine is what the operator cares about, and this is the // in-process form of it: the socket the remote host would connect to // does not exist. func TestBindAddress_LoopbackIsNotOnOtherAddresses(t *testing.T) { t.Parallel() other := otherLocalAddr(t) port := startBoundServer(t, loopbackV4) // Positive control: the service really is up and serving. requireDialable(t, net.JoinHostPort(loopbackV4, strconv.Itoa(port))) var listenCfg net.ListenConfig l, err := listenCfg.Listen( t.Context(), "tcp", net.JoinHostPort(other, strconv.Itoa(port)), ) require.NoError( t, err, "port %d on %s is taken while bound to %s: the listener "+ "claimed more than its configured address", port, other, loopbackV4, ) require.NoError(t, l.Close()) } // TestBindAddress_WildcardReachesOtherAddresses is the counterpart: // the value a container deployment sets does reach the addresses the // default withholds. Without this, a loopback-only bind would pass // the test above by never listening at all. func TestBindAddress_WildcardReachesOtherAddresses(t *testing.T) { t.Parallel() other := otherLocalAddr(t) port := startBoundServer(t, wildcardV4) requireDialable(t, net.JoinHostPort(other, 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 // is honoured and broken. func TestBindAddress_ServesRequestsOnConfiguredAddress(t *testing.T) { t.Parallel() port := startBoundServer(t, loopbackV4) addr := net.JoinHostPort(loopbackV4, strconv.Itoa(port)) requireDialable(t, addr) req, err := http.NewRequestWithContext( t.Context(), http.MethodGet, "http://"+addr+"/.well-known/healthcheck", nil, ) require.NoError(t, err) client := &http.Client{Timeout: dialTimeout} resp, err := client.Do(req) require.NoError(t, err) defer func() { _ = resp.Body.Close() }() assert.Equal(t, http.StatusOK, resp.StatusCode) } // TestBindAddress_UnavailableAddressShutsDownTheApp covers the half // of the fail-loud rule that configuration parsing cannot reach. A // syntactically valid address that is not assigned to this host // parses fine and fails at bind time, after fx has already reported // RUNNING. It must end the process non-zero rather than leave it // alive with nothing listening. func TestBindAddress_UnavailableAddressShutsDownTheApp(t *testing.T) { t.Parallel() env := newTestEnv(t) env.cfg.BindAddress = unavailableAddr env.cfg.Port = freePort(t) requireListenFailureExit(t, env) }