337 lines
9.0 KiB
Go
337 lines
9.0 KiB
Go
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)
|
|
}
|