Files
kandev/apps/backend/internal/backendapp/httpserver.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

183 lines
5.7 KiB
Go

package backendapp
import (
"context"
"net"
"net/http"
"sync"
"time"
"github.com/kandev/kandev/internal/common/config"
"github.com/kandev/kandev/internal/common/logger"
"go.uber.org/zap"
)
// serverBindRetryInterval is how often the background retry loop re-attempts a
// bind for an address that failed the initial bind (e.g. a tailnet IP that only
// exists once tailscaled comes up). It is a var so tests can shorten it.
var serverBindRetryInterval = 5 * time.Second
// serverListeners owns the set of net.Listeners that serve a single shared
// http.Server, plus a background retry loop that keeps trying hosts whose
// initial bind failed. The single-listener case is just the one-element form.
//
// All active listeners are closed by the shared http.Server on Shutdown; the
// retry loop is stopped explicitly via Stop() before that Shutdown so no new
// listener can appear after shutdown begins.
type serverListeners struct {
server *http.Server
port int
log *logger.Logger
mu sync.Mutex
bound []string // ln.Addr().String() for each active listener
retryCancel context.CancelFunc
retryDone chan struct{}
stopOnce sync.Once
}
// startHTTPServers binds one listener per host and serves the shared handler on
// each. Bind-failure policy: if EVERY bind fails it returns false (fatal); if
// SOME fail it logs a prominent warning naming the failed hosts, serves the
// rest, and launches a background retry loop that binds the failed hosts once
// they become available. Returns the manager so shutdown can stop the retry
// loop and the readiness probe can pick a reachable address.
func startHTTPServers(server *http.Server, hosts []string, port int, log *logger.Logger) (*serverListeners, bool) {
sl := &serverListeners{server: server, port: port, log: log}
var failed []string
for _, h := range hosts {
if err := sl.bind(h); err != nil {
failed = append(failed, h)
}
}
if len(sl.boundAddrs()) == 0 {
log.Error("Server failed to bind any configured address", zap.Strings("hosts", hosts))
return nil, false
}
if len(failed) > 0 {
log.Warn("Server bound only some addresses; retrying the rest in the background",
zap.Strings("failed", failed),
zap.Strings("bound", sl.boundAddrs()))
sl.startRetry(failed)
}
return sl, true
}
// bind listens on host:port and, on success, serves the shared handler on the
// new listener. A failure is logged and returned so the caller can decide the
// all-fail vs. partial-fail policy.
func (sl *serverListeners) bind(host string) error {
addr := serverListenAddr(host, sl.port)
ln, err := net.Listen("tcp", addr)
if err != nil {
sl.log.Warn("Server listen error", zap.String("addr", addr), zap.Error(err))
return err
}
sl.mu.Lock()
sl.bound = append(sl.bound, ln.Addr().String())
sl.mu.Unlock()
sl.serve(ln)
return nil
}
// serve runs the shared http.Server on ln in its own goroutine. http.Server
// supports Serve being called concurrently on multiple listeners and closes
// all of them on Shutdown. The defensive ln.Close() covers the narrow race
// where a retry bind completes after Shutdown has already started (Serve then
// returns ErrServerClosed without closing the listener).
func (sl *serverListeners) serve(ln net.Listener) {
go func() {
defer func() { _ = ln.Close() }()
sl.log.Info("WebSocket server listening",
zap.String("addr", ln.Addr().String()), zap.Int("port", sl.port))
if err := sl.server.Serve(ln); err != nil && err != http.ErrServerClosed {
sl.log.Error("Server serve error",
zap.String("addr", ln.Addr().String()), zap.Error(err))
}
}()
}
func (sl *serverListeners) boundAddrs() []string {
sl.mu.Lock()
defer sl.mu.Unlock()
return append([]string(nil), sl.bound...)
}
// startRetry launches the background retry loop for the given failed hosts.
func (sl *serverListeners) startRetry(hosts []string) {
ctx, cancel := context.WithCancel(context.Background())
sl.retryCancel = cancel
sl.retryDone = make(chan struct{})
go sl.retryLoop(ctx, hosts)
}
// retryLoop periodically re-attempts binding the still-failed hosts until all
// succeed or the context is cancelled (graceful shutdown). Each successful bind
// starts serving immediately, so a late-arriving tailnet IP self-heals.
func (sl *serverListeners) retryLoop(ctx context.Context, hosts []string) {
defer close(sl.retryDone)
ticker := time.NewTicker(serverBindRetryInterval)
defer ticker.Stop()
pending := append([]string(nil), hosts...)
for len(pending) > 0 {
select {
case <-ctx.Done():
return
case <-ticker.C:
}
var still []string
for _, h := range pending {
if err := sl.bind(h); err != nil {
still = append(still, h)
continue
}
sl.log.Info("Server bound previously-failed address", zap.String("host", h))
}
pending = still
}
}
// Stop cancels the background retry loop and waits for it to drain. Safe to
// call concurrently and when no retry loop was started; the cancel+drain runs
// at most once via stopOnce. Must run before server.Shutdown so no new listener
// appears after shutdown begins.
func (sl *serverListeners) Stop() {
if sl == nil {
return
}
sl.stopOnce.Do(func() {
if sl.retryCancel != nil {
sl.retryCancel()
<-sl.retryDone
}
})
}
// probeAddr returns an address the readiness probe can dial. It prefers a bound
// address that resolves to loopback (always reachable from the box without
// depending on a routable interface), falling back to the first bound address.
func (sl *serverListeners) probeAddr() string {
sl.mu.Lock()
defer sl.mu.Unlock()
if len(sl.bound) == 0 {
return ""
}
for _, a := range sl.bound {
probe := serverProbeAddr(a)
if host, _, err := net.SplitHostPort(probe); err == nil && config.IsLoopbackHost(host) {
return probe
}
}
return serverProbeAddr(sl.bound[0])
}