Bind the app port deliberately and document the proxy deployment (closes #268)
All checks were successful
check / check (push) Successful in 3m41s
All checks were successful
check / check (push) Successful in 3m41s
The plaintext listener bound `:PORT`, so it answered on every interface with no way to say otherwise. That published the admin UI and the unauthenticated receiver in cleartext beside whatever TLS proxy was in front of them, reachable from any host that could route to the machine. BIND_ADDRESS now selects the address. The binary defaults to 127.0.0.1, which is the safe answer for a bare host: reaching webhooker from elsewhere becomes a deliberate act. The image sets 0.0.0.0, which is the correct answer inside a container, where the network namespace is already the boundary and exposure is decided by the publish flag instead — so `-p 127.0.0.1:8080:8080` is what the README shows. Existing container deployments are unaffected. Only IP address literals are accepted: hostnames, host:port and CIDR blocks abort startup naming the variable and the value, and a literal that is not an address of this host fails at listen and exits non-zero. The http.Server is now built in New rather than in the serving goroutine, and sentryEnabled is atomic. Both fields were written by the serving goroutine and read by the fx stop hook with nothing ordering them, and the OnStart hook returns before that goroutine has necessarily run: cleanShutdown could dereference a nil httpServer on an early SIGTERM, and both reads raced. No test started and stopped the server, so nothing observed it. Closes #226. README gains a "Deployment behind a reverse proxy" section: a working nginx server block, and the five things that are silent when wrong — bind or firewall the app port, WEBHOOKER_ENVIRONMENT=prod, TRUSTED_PROXIES, Host as $http_host rather than $host, and keeping the proxy's access log because webhooker's own records only the proxy.
This commit is contained in:
336
internal/server/bind_address_test.go
Normal file
336
internal/server/bind_address_test.go
Normal file
@@ -0,0 +1,336 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user