Files
kandev/apps/backend/internal/backendapp/server_test.go
T
João Salavisa cdd0bf8cbd feat: bind HTTP server to multiple addresses (#1792)
* feat: bind HTTP server to multiple addresses

Support a list of bind addresses so the backend can serve on loopback and
a tailnet IP at once, without exposing every interface via 0.0.0.0.

- Config: server.host now parses a comma-separated list; a new server.hosts
  YAML array takes precedence when set. Entries are trimmed, deduped, and
  validated (IP or hostname); a wildcard entry collapses the set.
- Startup: one net.Listener per resolved address serving one shared
  http.Server; the single-listener path is the one-element case.
- Bind-failure policy: all-fail is fatal; partial-fail warns, serves the
  good addresses, and runs a cancellable background retry loop that binds
  late-arriving addresses (e.g. a tailnet IP after tailscaled starts).
- Graceful shutdown stops the retry loop before closing all listeners;
  covered by goleak.
- Readiness probe picks a reachable bound address, preferring loopback.
- Add ServerConfig.NonLoopbackBinds and config.IsLoopbackHost so the
  sibling app-auth task can gate fail-closed auth on off-box binds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: preserve KANDEV_SERVER_HOST over config server.hosts

server.host now wins over the server.hosts array when set, because Host
carries the KANDEV_SERVER_HOST env override and env must beat a config-file
server.hosts (env-over-file precedence and the desktop loopback contract).
The viper default for server.host is empty so an unset host is
distinguishable from an explicit one; server.hosts is used only when host is
unset. Adds regression tests covering the env-override and hosts-when-unset
paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: harden multi-bind host parsing and listener lifecycle

Address review feedback:
- Validate every host before wildcard collapse so an invalid entry is
  rejected regardless of order; canonicalize IPs so equivalent forms
  (::1 and 0:0:0:0:0:0:0:1) dedupe and unspecified longhand IPs count as
  the wildcard.
- Probe the IPv6 loopback (::1) for an IPv6 wildcard listener instead of
  127.0.0.1, so readiness doesn't stall on an IPv6-only socket.
- Make serverListeners.Stop race-free via sync.Once.
- Register t.Cleanup for the blocker listener before the fatal-assertion
  path in the partial-failure test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 10:36:44 +01:00

77 lines
2.1 KiB
Go

package backendapp
import (
"net"
"testing"
)
func TestServerListenAddr(t *testing.T) {
tests := []struct {
name string
host string
port int
want string
}{
{name: "blank host keeps port-only address", host: "", port: 38429, want: ":38429"},
{name: "wildcard host", host: "0.0.0.0", port: 38429, want: "0.0.0.0:38429"},
{name: "loopback host", host: "127.0.0.1", port: 38429, want: "127.0.0.1:38429"},
{name: "ipv6 host", host: "::1", port: 38429, want: "[::1]:38429"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := serverListenAddr(tt.host, tt.port); got != tt.want {
t.Fatalf("serverListenAddr(%q, %d) = %q, want %q", tt.host, tt.port, got, tt.want)
}
})
}
}
func TestDesktopHealthTokenTrimsEnv(t *testing.T) {
t.Setenv(desktopHealthTokenEnv, " token-value ")
if got := desktopHealthToken(); got != "token-value" {
t.Fatalf("desktopHealthToken() = %q, want token-value", got)
}
}
func TestServerProbeAddr(t *testing.T) {
tests := []struct {
name string
listenAddr string
want string
}{
{name: "port-only address probes loopback", listenAddr: ":38429", want: "127.0.0.1:38429"},
{name: "wildcard ipv4 probes loopback", listenAddr: "0.0.0.0:38429", want: "127.0.0.1:38429"},
{name: "wildcard ipv6 probes ipv6 loopback", listenAddr: "[::]:38429", want: "[::1]:38429"},
{name: "loopback address probes itself", listenAddr: "127.0.0.1:38429", want: "127.0.0.1:38429"},
{name: "ipv6 loopback probes itself", listenAddr: "[::1]:38429", want: "[::1]:38429"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := serverProbeAddr(tt.listenAddr); got != tt.want {
t.Fatalf("serverProbeAddr(%q) = %q, want %q", tt.listenAddr, got, tt.want)
}
})
}
}
func listenOnFreePort(t *testing.T) net.Listener {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen on free port: %v", err)
}
return ln
}
func listenerPort(t *testing.T, ln net.Listener) int {
t.Helper()
tcpAddr, ok := ln.Addr().(*net.TCPAddr)
if !ok {
t.Fatalf("expected TCP listener, got %T", ln.Addr())
}
return tcpAddr.Port
}