All checks were successful
check / check (push) Successful in 1m12s
Addresses the 2026-08-10 FAIL review (findings 1-7, 9, 10). KILL never terminated the victim's connection on either transport: both paths called BroadcastQuit, which deletes the session row and tells the victim's peers it quit, but leaves the victim holding a socket that looks alive and silently delivers nothing while its nick is freed for reuse. Service now owns a session-ID keyed registry of live wire connections that ircserver populates at registration, and both KILL paths go through the new Service.KillSession, which broadcasts the QUIT and then sends the victim a KILL and ERROR :Closing Link before closing its socket. The victim's relay goroutine is cancelled and its cleanup no longer re-broadcasts a QUIT for an already-deleted session. TestIntegrationKill now asserts the victim reads to EOF and is gone from NAMES and WHO, not just that an observer saw the QUIT relay. HTTP MODE <othernick> with no body answered with the requester's own modes, because the target check sat inside the mode-change branch. The check is hoisted above the query/change split, and both transports now compare nicks with EqualFold since IRC nicks are case-insensitive. Service.QueryUserMode returned "+" for a database failure, making an unreadable mode indistinguishable from an unset one; it now returns an error, and both callers surface it. db.GetUserhostInfo likewise treated every scan error as "nick not found"; only sql.ErrNoRows is skipped now. The four new unsynchronized c.nick reads this branch introduced are read through currentNick() under c.mu, and c.closed is now guarded everywhere because KILL writes it from another client's goroutine. Conn.send takes a write mutex, as a connection is now written to by three goroutines. server.Server.Run was left with no in-tree callers when its body was inlined into the fx OnStart hook; it is deleted rather than left to drift. INFO and VERSION had two implementations that had already diverged: the version string is now Service.ServerVersion and the INFO body is Service.InfoLines, used verbatim by both transports. The ctx parameters on handleVersion/handleAdmin/handleInfo/handleTime existed only to be discarded and are gone.
216 lines
4.6 KiB
Go
216 lines
4.6 KiB
Go
// Package server implements the main HTTP server for the neoirc application.
|
|
package server
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"syscall"
|
|
"time"
|
|
|
|
"go.uber.org/fx"
|
|
"sneak.berlin/go/neoirc/internal/config"
|
|
"sneak.berlin/go/neoirc/internal/globals"
|
|
"sneak.berlin/go/neoirc/internal/handlers"
|
|
"sneak.berlin/go/neoirc/internal/logger"
|
|
"sneak.berlin/go/neoirc/internal/middleware"
|
|
|
|
"github.com/getsentry/sentry-go"
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
_ "github.com/joho/godotenv/autoload" // loads .env file
|
|
)
|
|
|
|
const (
|
|
shutdownTimeout = 5 * time.Second
|
|
sentryFlushTime = 2 * time.Second
|
|
)
|
|
|
|
// Params defines the dependencies for creating a Server.
|
|
type Params struct {
|
|
fx.In
|
|
|
|
Logger *logger.Logger
|
|
Globals *globals.Globals
|
|
Config *config.Config
|
|
Middleware *middleware.Middleware
|
|
Handlers *handlers.Handlers
|
|
}
|
|
|
|
// Server is the main HTTP server.
|
|
// It manages routing, middleware, and lifecycle.
|
|
type Server struct {
|
|
startupTime time.Time
|
|
sentryEnabled bool
|
|
log *slog.Logger
|
|
ctx context.Context //nolint:containedctx // signal handling pattern
|
|
cancelFunc context.CancelFunc
|
|
httpServer *http.Server
|
|
router *chi.Mux
|
|
params Params
|
|
mw *middleware.Middleware
|
|
handlers *handlers.Handlers
|
|
}
|
|
|
|
// New creates a new Server and registers its lifecycle hooks.
|
|
func New(
|
|
lifecycle fx.Lifecycle, params Params,
|
|
) (*Server, error) {
|
|
srv := &Server{ //nolint:exhaustruct // fields set during lifecycle
|
|
params: params,
|
|
mw: params.Middleware,
|
|
handlers: params.Handlers,
|
|
log: params.Logger.Get(),
|
|
}
|
|
|
|
lifecycle.Append(fx.Hook{
|
|
OnStart: func(_ context.Context) error {
|
|
srv.startupTime = time.Now()
|
|
// Configure, enable Sentry, and build the router
|
|
// synchronously so that srv.router is fully initialized
|
|
// before OnStart returns. Any HTTP traffic (including
|
|
// httptest harnesses that wrap srv as a handler) is
|
|
// therefore guaranteed to see an initialized router,
|
|
// eliminating the previous race between SetupRoutes
|
|
// and ServeHTTP.
|
|
srv.configure()
|
|
srv.enableSentry()
|
|
srv.SetupRoutes()
|
|
go srv.serve() //nolint:contextcheck
|
|
|
|
return nil
|
|
},
|
|
OnStop: func(_ context.Context) error {
|
|
return nil
|
|
},
|
|
})
|
|
|
|
return srv, nil
|
|
}
|
|
|
|
// ServeHTTP delegates to the chi router.
|
|
func (srv *Server) ServeHTTP(
|
|
writer http.ResponseWriter,
|
|
request *http.Request,
|
|
) {
|
|
srv.router.ServeHTTP(writer, request)
|
|
}
|
|
|
|
// MaintenanceMode reports whether the server is in maintenance mode.
|
|
func (srv *Server) MaintenanceMode() bool {
|
|
return srv.params.Config.MaintenanceMode
|
|
}
|
|
|
|
func (srv *Server) enableSentry() {
|
|
srv.sentryEnabled = false
|
|
|
|
if srv.params.Config.SentryDSN == "" {
|
|
return
|
|
}
|
|
|
|
err := sentry.Init(sentry.ClientOptions{ //nolint:exhaustruct // only essential fields
|
|
Dsn: srv.params.Config.SentryDSN,
|
|
Release: fmt.Sprintf(
|
|
"%s-%s",
|
|
srv.params.Globals.Appname,
|
|
srv.params.Globals.Version,
|
|
),
|
|
})
|
|
if err != nil {
|
|
srv.log.Error("sentry init failure", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
srv.log.Info("sentry error reporting activated")
|
|
srv.sentryEnabled = true
|
|
}
|
|
|
|
func (srv *Server) serve() {
|
|
srv.ctx, srv.cancelFunc = context.WithCancel(
|
|
context.Background(),
|
|
)
|
|
|
|
go func() {
|
|
sigCh := make(chan os.Signal, 1)
|
|
|
|
signal.Ignore(syscall.SIGPIPE)
|
|
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
|
|
|
|
sig := <-sigCh
|
|
|
|
srv.log.Info("signal received", "signal", sig)
|
|
|
|
if srv.cancelFunc != nil {
|
|
srv.cancelFunc()
|
|
}
|
|
}()
|
|
|
|
go srv.serveUntilShutdown()
|
|
|
|
<-srv.ctx.Done()
|
|
|
|
srv.cleanShutdown()
|
|
}
|
|
|
|
func (srv *Server) cleanupForExit() {
|
|
srv.log.Info("cleaning up")
|
|
}
|
|
|
|
func (srv *Server) cleanShutdown() {
|
|
ctxShutdown, shutdownCancel := context.WithTimeout(
|
|
context.Background(), shutdownTimeout,
|
|
)
|
|
|
|
err := srv.httpServer.Shutdown(ctxShutdown)
|
|
if err != nil {
|
|
srv.log.Error(
|
|
"server clean shutdown failed", "error", err,
|
|
)
|
|
}
|
|
|
|
if shutdownCancel != nil {
|
|
shutdownCancel()
|
|
}
|
|
|
|
srv.cleanupForExit()
|
|
|
|
if srv.sentryEnabled {
|
|
sentry.Flush(sentryFlushTime)
|
|
}
|
|
}
|
|
|
|
func (srv *Server) configure() {
|
|
// Server configuration placeholder.
|
|
}
|
|
|
|
func (srv *Server) serveUntilShutdown() {
|
|
listenAddr := fmt.Sprintf(
|
|
":%d", srv.params.Config.Port,
|
|
)
|
|
|
|
srv.httpServer = &http.Server{ //nolint:exhaustruct // optional fields
|
|
Addr: listenAddr,
|
|
ReadTimeout: httpReadTimeout,
|
|
WriteTimeout: httpWriteTimeout,
|
|
MaxHeaderBytes: maxHeaderBytes,
|
|
Handler: srv,
|
|
}
|
|
|
|
srv.log.Info(
|
|
"http begin listen", "listenaddr", listenAddr,
|
|
)
|
|
|
|
err := srv.httpServer.ListenAndServe()
|
|
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
srv.log.Error("listen error", "error", err)
|
|
|
|
if srv.cancelFunc != nil {
|
|
srv.cancelFunc()
|
|
}
|
|
}
|
|
}
|