Files
Igor Sirotin 5029be4631 refactor: dissolve the root server package
`server` was one Go package doing three unrelated jobs. Each moves to
where it belongs:

  server/pairing/         -> services/pairing/
  server_media*.go,
  handlers*.go, testdata/ -> services/media/
  server.go, certs.go,
  ips.go, timeout.go,
  device.go, listen_*.go,
  servertest/             -> internal/httpserver/

MediaServer is renamed to media.Server (and NewMediaServer to
media.NewServer) now that it has a package to be named against.
Deliberately untouched: StatusNode.MediaServer(), which is an accessor
method rather than the type, and the unrelated identifiers that only
share the prefix (MediaServerImageID, MediaServerContactIcon,
MediaServerEnableTLS, ...).

Two identifiers had to be reassigned to make the boundary clean:

- certs.go held both the generic X509/TLS helpers and the media server's
  process-global certificate. Split: the generic half stays in
  internal/httpserver, generateMediaTLSCert and PublicMediaTLSCert move
  to services/media.
- HandlerPatternMap was declared in handlers.go but is a plain HTTP type
  that server.go depends on; it moves to internal/httpserver.

The media server URL tests moved with the type, and reached three
unexported Server fields they could touch while everything shared a
package. internal/httpserver now exposes ListeningAddr() and CachedPort()
(both reasonable API) plus a clearly-marked SetURLStateForTest.

Nothing else crossed the boundary, and the split is one-directional:
services/media and services/pairing import internal/httpserver, never the
reverse.

refs #7067
2026-08-20 20:59:45 +02:00

435 lines
11 KiB
Go

package httpserver
import (
"context"
"crypto/tls"
"errors"
"net"
"net/http"
"net/netip"
"net/url"
"strconv"
"sync"
"sync/atomic"
"syscall"
"time"
"go.uber.org/zap"
"github.com/status-im/status-go/internal/panics"
)
// backgroundShutdownTimeout bounds how long Server.ToBackground waits for
// active HTTP handlers to drain before force-closing remaining connections.
const backgroundShutdownTimeout = 300 * time.Millisecond
// teardownShutdownTimeout bounds the same drain at teardown. It is generous
// enough for normal in-flight requests to finish, but a handler that never
// returns must not be able to wedge Stop — and with it the logout path that
// holds the backend mutex.
const teardownShutdownTimeout = 3 * time.Second
// tcpListenConfig sets SO_REUSEADDR so that after the previous listener is fully
// closed, the kernel can allow binding the same cached ephemeral port again (e.g.
// TIME_WAIT) without a long delay. It does not help if Stop returns before that
// socket is closed — Stop waits on serveWg for that ordering.
var tcpListenConfig = net.ListenConfig{
Control: func(network, address string, c syscall.RawConn) error {
var optErr error
if err := c.Control(func(fd uintptr) {
optErr = setReuseAddr(fd)
}); err != nil {
return err
}
return optErr
},
}
type Config struct {
Cert *tls.Certificate
AddrPort netip.AddrPort
// AdvertizeHost and AdvertizePort define the a different host/port to be advertized in media URLs than the one server listen on.
// Can be used when status-go is running behind a NAT/PAT. If empty/zero, the actual listening address is used.
AdvertizeHost string
AdvertizePort int
}
type Server struct {
mu sync.Mutex
listener net.Listener
server *http.Server
logger *zap.Logger
handlers HandlerPatternMap
config *Config
// address is the host and port the server is listening on
address *net.TCPAddr
// running is true if the server was started and Serve is active; read via IsRunning().
running atomic.Bool
// cachedPort stores the port from the first successful bind when AddrPort used port 0 (ephemeral).
// Reused on pause/resume so URLs remain valid across ToBackground/ToForeground cycles.
cachedPort int
// serveWg waits for the serve goroutine to finish all defers after http.Server.Serve returns,
// so Stop does not return while the previous listener may still be tearing down (avoids EADDRINUSE
// when rebinding the same cached ephemeral port).
serveWg sync.WaitGroup
// serveDone is closed when the current serve goroutine has fully exited.
// The dead-listener rebind path waits on the instance it captured — never
// on serveWg, which a concurrent Start could grow while the mutex is
// released, making the wait block on an unrelated healthy server.
serveDone chan struct{}
*timeoutManager
}
func NewServer(logger *zap.Logger, config *Config) *Server {
return &Server{
logger: logger,
config: config,
timeoutManager: newTimeoutManager(),
}
}
func (s *Server) GetAddrPort() string {
host := ""
if s.address != nil {
host = s.address.IP.String()
} else if s.config != nil {
host = s.config.AddrPort.Addr().String()
}
if s.config != nil && s.config.Cert != nil && len(s.config.Cert.Leaf.DNSNames) > 0 {
host = s.config.Cert.Leaf.DNSNames[0]
}
if s.config != nil && s.config.AdvertizeHost != "" {
host = s.config.AdvertizeHost
}
port := s.GetPort()
if host == "" || port == 0 {
return ""
}
return net.JoinHostPort(host, strconv.Itoa(port))
}
func (s *Server) GetPort() int {
if s.config != nil && s.config.AdvertizePort != 0 {
return s.config.AdvertizePort
}
if s.address != nil {
return s.address.Port
}
if s.cachedPort != 0 {
return s.cachedPort
}
if s.config != nil && s.config.AddrPort.Port() != 0 {
return int(s.config.AddrPort.Port())
}
return 0
}
func (s *Server) GetListeningAddrPort() string {
if s.address == nil {
return ""
}
return s.address.String()
}
func (s *Server) GetCert() *tls.Certificate {
return s.config.Cert
}
func (s *Server) GetLogger() *zap.Logger {
return s.logger
}
// getBindAddrPort returns the address to bind to. When config used port 0 (ephemeral)
// and we have a cached port from a previous run, reuse it so URLs stay stable across pause/resume.
func (s *Server) getBindAddrPort() netip.AddrPort {
if s.cachedPort != 0 && s.config.AddrPort.Port() == 0 {
return netip.AddrPortFrom(s.config.AddrPort.Addr(), uint16(s.cachedPort))
}
return s.config.AddrPort
}
func (s *Server) createListener() (net.Listener, error) {
addr := s.getBindAddrPort()
addrStr := addr.String()
ln, err := tcpListenConfig.Listen(context.Background(), "tcp", addrStr)
if err != nil {
return nil, err
}
if s.config.Cert == nil {
return ln, nil
}
serverName := addr.Addr().String()
if len(s.config.Cert.Leaf.DNSNames) > 0 {
serverName = s.config.Cert.Leaf.DNSNames[0]
}
cfg := &tls.Config{
Certificates: []tls.Certificate{*s.config.Cert},
ServerName: serverName,
MinVersion: tls.VersionTLS12,
}
return tls.NewListener(ln, cfg), nil
}
func (s *Server) listen() error {
s.address = nil
var err error
s.listener, err = s.createListener()
if err != nil {
s.logger.Error("failed to start server", zap.Error(err))
return err
}
s.address = s.listener.Addr().(*net.TCPAddr)
if s.config.AddrPort.Port() == 0 {
s.cachedPort = s.address.Port
}
s.StartTimeout(func() {
err := s.Stop()
if err != nil {
s.logger.Error("server termination fail", zap.Error(err))
}
})
return nil
}
func (s *Server) serve(currentServer *http.Server, currentListener net.Listener) {
defer panics.LogOnPanic()
defer func() {
s.mu.Lock()
defer s.mu.Unlock()
// If a newer Start() already replaced server/listener, do not clobber
// the latest running instance state.
if s.server == currentServer && s.listener == currentListener {
s.running.Store(false)
s.address = nil
}
}()
err := currentServer.Serve(currentListener)
if errors.Is(err, http.ErrServerClosed) {
return
}
s.logger.Error("server failed unexpectedly, restarting", zap.Error(err))
return
}
func (s *Server) resetServer() {
s.StopTimeout()
s.server = new(http.Server)
s.address = nil
}
func (s *Server) applyHandlers() {
if s.server == nil {
s.server = new(http.Server)
}
mux := http.NewServeMux()
for p, h := range s.handlers {
mux.HandleFunc(p, h)
}
s.server.Handler = mux
}
// listenerAlive verifies the bound listener actually accepts connections.
// On iOS a process suspension can kill the listening socket without the accept
// loop ever returning an error, leaving `running` true while every client
// connect is refused (observed when the app is suspended on the login screen,
// before the pausable-services bridge has anything to drive).
//
// A raw TCP dial is sufficient and safe with a TLS listener: crypto/tls only
// handshakes on the first read/write of an accepted conn, and net/http runs
// that in a per-connection goroutine — an immediate close surfaces (at most) a
// per-conn handshake error, never an Accept error, so Serve keeps running.
func (s *Server) listenerAlive() bool {
addr := s.GetListeningAddrPort()
if addr == "" {
return false
}
conn, err := net.DialTimeout("tcp", addr, time.Second)
if err != nil {
return false
}
_ = conn.Close()
return true
}
func (s *Server) Start() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.running.Load() {
if s.listenerAlive() {
return nil
}
s.logger.Warn("server marked running but listener is dead; rebinding")
currentServer := s.server
done := s.serveDone
s.running.Store(false)
_ = currentServer.Close()
// The serve goroutine's deferred cleanup takes s.mu — release it while
// waiting, and wait only on the captured instance, never on serveWg
// (a concurrent Start could grow it with a healthy new server).
s.mu.Unlock()
if done != nil {
<-done
}
s.mu.Lock()
// Re-validate after the unlocked window: a concurrent Start may have
// already rebound — in that case this call's work is done.
if s.running.Load() {
return nil
}
}
// Once Shutdown has been called on a server, it may not be reused;
s.resetServer()
s.applyHandlers()
err := s.listen()
if err != nil {
return err
}
// Mark running synchronously to avoid pause/play races where ToBackground
// can run before serve() goroutine has a chance to set the state.
s.running.Store(true)
done := make(chan struct{})
s.serveDone = done
s.serveWg.Add(1)
go func() {
defer panics.LogOnPanic()
defer close(done)
defer s.serveWg.Done()
s.serve(s.server, s.listener)
}()
return nil
}
func (s *Server) Stop() error {
ctx, cancel := context.WithTimeout(context.Background(), teardownShutdownTimeout)
defer cancel()
err := s.stopWith(ctx)
if shouldForceCloseOnShutdownError(err) {
s.logger.Warn("server graceful shutdown did not complete in time; forced close",
zap.Error(err), zap.Duration("timeout", teardownShutdownTimeout))
return nil
}
return err
}
// stopWith performs the graceful shutdown sequence bounded by ctx. If ctx
// expires or is canceled before active connections drain, the server is
// force-closed via Close so the caller isn't blocked by a slow or stuck
// handler. Callers always pass a deadline: a generous one at teardown, a short
// one for lifecycle events like pause/ToBackground.
func (s *Server) stopWith(ctx context.Context) error {
s.mu.Lock()
s.StopTimeout()
if !s.running.Load() || s.server == nil {
s.mu.Unlock()
return nil
}
// Capture the current instance and release the lock before Shutdown.
// Shutdown waits for Serve() to return, and Serve() may update state in
// its defer path under the same mutex.
currentServer := s.server
s.running.Store(false)
s.mu.Unlock()
err := currentServer.Shutdown(ctx)
if shouldForceCloseOnShutdownError(err) {
_ = currentServer.Close()
}
s.serveWg.Wait()
return err
}
func shouldForceCloseOnShutdownError(err error) bool {
return errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled)
}
func (s *Server) IsRunning() bool {
return s.running.Load()
}
func (s *Server) ToForeground() {
if err := s.Start(); err != nil {
s.logger.Error("server start failed during foreground transition", zap.Error(err))
}
}
func (s *Server) ToBackground() {
ctx, cancel := context.WithTimeout(context.Background(), backgroundShutdownTimeout)
defer cancel()
if err := s.stopWith(ctx); err != nil {
if shouldForceCloseOnShutdownError(err) {
s.logger.Warn("server graceful shutdown did not complete in time; forced close",
zap.Error(err), zap.Duration("timeout", backgroundShutdownTimeout))
return
}
s.logger.Error("server shutdown failed during background transition", zap.Error(err))
}
}
func (s *Server) SetHandlers(handlers HandlerPatternMap) {
s.handlers = handlers
}
func (s *Server) AddHandlers(handlers HandlerPatternMap) {
if s.handlers == nil {
s.handlers = make(HandlerPatternMap)
}
for name := range handlers {
s.handlers[name] = handlers[name]
}
}
func (s *Server) MakeBaseURL() *url.URL {
hostPort := s.GetAddrPort()
if hostPort == "" {
return &url.URL{}
}
scheme := "http"
if s.config != nil && s.config.Cert != nil {
scheme = "https"
}
return &url.URL{
Scheme: scheme,
Host: hostPort,
}
}