package httpapi import ( "context" "crypto/tls" "errors" "fmt" "log/slog" "net" "net/http" "github.com/syncova/syncova/packages/platform/config" "github.com/syncova/syncova/packages/platform/logging" ) // Server kapselt den HTTP-Server des API-Dienstes samt geordnetem Herunterfahren. type Server struct { // httpServer ist der zugrunde liegende Standard-Server. httpServer *http.Server // listener ist der gebundene Netzwerk-Listener. listener net.Listener // logger protokolliert Start und Stopp des Servers. logger *slog.Logger // shutdownTimeout ist die Frist für laufende Requests beim Herunterfahren. shutdownTimeout config.HTTPConfig } // NewServer bindet die konfigurierte Adresse und bereitet den Server vor. // // Das Binden erfolgt bereits hier, damit ein belegter Port sofort als Fehler // sichtbar wird und nicht erst im Hintergrund auftaucht. func NewServer(httpConfig config.HTTPConfig, requestHandler http.Handler, baseLogger *slog.Logger) (*Server, error) { networkListener, listenError := net.Listen("tcp", httpConfig.ListenAddress) if listenError != nil { return nil, fmt.Errorf("HTTP-Adresse %q konnte nicht gebunden werden: %w", httpConfig.ListenAddress, listenError) } httpServer := &http.Server{ Handler: requestHandler, ReadHeaderTimeout: httpConfig.ReadHeaderTimeout, ReadTimeout: httpConfig.ReadTimeout, WriteTimeout: httpConfig.WriteTimeout, IdleTimeout: httpConfig.IdleTimeout, // Fehler des Servers laufen über das strukturierte Logging statt über // die Standardausgabe, damit sie auswertbar bleiben. ErrorLog: slog.NewLogLogger(logging.WithComponent(baseLogger, "http").Handler(), slog.LevelError), } // TLS wird eingerichtet, sobald Zertifikat und Schlüssel vorliegen. // // Die Mindestversion ist 1.2, und Renegotiation bleibt aus: Beides sind // die Vorgaben von Go, hier ausdrücklich festgeschrieben, damit eine // künftige Lockerung der Vorgaben diese Anlage nicht mitzieht. if httpConfig.TLSCertificateFile != "" { serverCertificate, certificateError := tls.LoadX509KeyPair( httpConfig.TLSCertificateFile, httpConfig.TLSPrivateKeyFile) if certificateError != nil { _ = networkListener.Close() return nil, fmt.Errorf("das TLS-Zertifikat konnte nicht geladen werden: %w", certificateError) } httpServer.TLSConfig = &tls.Config{ Certificates: []tls.Certificate{serverCertificate}, MinVersion: tls.VersionTLS12, } } return &Server{ httpServer: httpServer, listener: networkListener, logger: logging.WithComponent(baseLogger, "http"), shutdownTimeout: httpConfig, }, nil } // serveConnections bedient Verbindungen, verschlüsselt oder im Klartext. func (server *Server) serveConnections() error { if server.httpServer.TLSConfig != nil { return server.httpServer.ServeTLS(server.listener, "", "") } return server.httpServer.Serve(server.listener) } // UsesTLS meldet einen verschlüsselten Dienst. func (server *Server) UsesTLS() bool { return server.httpServer.TLSConfig != nil } // Address liefert die tatsächlich gebundene Adresse. // // Bei Port 0 weist das Betriebssystem einen freien Port zu; Tests brauchen // deshalb die effektive Adresse statt der konfigurierten. func (server *Server) Address() string { return server.listener.Addr().String() } // Serve bedient Requests, bis der Context abgebrochen wird. // // Danach läuft ein geordnetes Herunterfahren: laufende Requests dürfen innerhalb // der konfigurierten Frist zu Ende laufen. func (server *Server) Serve(runContext context.Context) error { // serveErrorChannel ist gepuffert, damit die Goroutine auch dann endet, // wenn zuerst der Context abgebrochen wird. serveErrorChannel := make(chan error, 1) go func() { server.logger.Info("http server gestartet", slog.String("address", server.Address())) // ServeTLS verschlüsselt, sobald eine TLS-Konfiguration vorliegt. Die // Dateinamen sind leer, weil das Zertifikat bereits geladen ist. serveError := server.serveConnections() // ErrServerClosed ist die erwartete Folge eines geordneten Shutdowns. if errors.Is(serveError, http.ErrServerClosed) { serveErrorChannel <- nil return } serveErrorChannel <- serveError }() select { case serveError := <-serveErrorChannel: return serveError case <-runContext.Done(): server.logger.Info("http server wird beendet") // Der Shutdown-Context hängt bewusst nicht am abgebrochenen runContext, // sonst bliebe für laufende Requests keine Zeit mehr. shutdownContext, cancelShutdownContext := context.WithTimeout(context.Background(), server.shutdownTimeout.ShutdownTimeout) defer cancelShutdownContext() if shutdownError := server.httpServer.Shutdown(shutdownContext); shutdownError != nil { return fmt.Errorf("http server konnte nicht geordnet beendet werden: %w", shutdownError) } server.logger.Info("http server beendet") return nil } }