fix: address all PR #10 review findings
All checks were successful
check / check (push) Successful in 2m19s
All checks were successful
check / check (push) Successful in 2m19s
Security: - Add channel membership check before PRIVMSG (prevents non-members from sending) - Add membership check on history endpoint (channels require membership, DMs scoped to own nick) - Enforce MaxBytesReader on all POST request bodies - Fix rand.Read error being silently ignored in token generation Data integrity: - Fix TOCTOU race in GetOrCreateChannel using INSERT OR IGNORE + SELECT Build: - Add CGO_ENABLED=0 to golangci-lint install in Dockerfile (fixes alpine build) Linting: - Strict .golangci.yml: only wsl disabled (deprecated in v2) - Re-enable exhaustruct, depguard, godot, wrapcheck, varnamelen - Fix linters-settings -> linters.settings for v2 config format - Fix ALL lint findings in actual code (no linter config weakening) - Wrap all external package errors (wrapcheck) - Fill struct fields or add targeted nolint:exhaustruct where appropriate - Rename short variables (ts->timestamp, n->bufIndex, etc.) - Add depguard deny policy for io/ioutil and math/rand - Exclude G704 (SSRF) in gosec config (CLI client takes user-configured URLs) Tests: - Add security tests (TestNonMemberCannotSend, TestHistoryNonMember) - Split TestInsertAndPollMessages for reduced complexity - Fix parallel test safety (viper global state prevents parallelism) - Use t.Context() instead of context.Background() in tests Docker build verified passing locally.
This commit is contained in:
@@ -17,67 +17,94 @@ import (
|
||||
const routeTimeout = 60 * time.Second
|
||||
|
||||
// SetupRoutes configures the HTTP routes and middleware.
|
||||
func (s *Server) SetupRoutes() {
|
||||
s.router = chi.NewRouter()
|
||||
func (srv *Server) SetupRoutes() {
|
||||
srv.router = chi.NewRouter()
|
||||
|
||||
s.router.Use(middleware.Recoverer)
|
||||
s.router.Use(middleware.RequestID)
|
||||
s.router.Use(s.mw.Logging())
|
||||
srv.router.Use(middleware.Recoverer)
|
||||
srv.router.Use(middleware.RequestID)
|
||||
srv.router.Use(srv.mw.Logging())
|
||||
|
||||
if viper.GetString("METRICS_USERNAME") != "" {
|
||||
s.router.Use(s.mw.Metrics())
|
||||
srv.router.Use(srv.mw.Metrics())
|
||||
}
|
||||
|
||||
s.router.Use(s.mw.CORS())
|
||||
s.router.Use(middleware.Timeout(routeTimeout))
|
||||
srv.router.Use(srv.mw.CORS())
|
||||
srv.router.Use(middleware.Timeout(routeTimeout))
|
||||
|
||||
if s.sentryEnabled {
|
||||
sentryHandler := sentryhttp.New(sentryhttp.Options{
|
||||
Repanic: true,
|
||||
})
|
||||
s.router.Use(sentryHandler.Handle)
|
||||
if srv.sentryEnabled {
|
||||
sentryHandler := sentryhttp.New(
|
||||
sentryhttp.Options{ //nolint:exhaustruct // optional fields
|
||||
Repanic: true,
|
||||
},
|
||||
)
|
||||
|
||||
srv.router.Use(sentryHandler.Handle)
|
||||
}
|
||||
|
||||
// Health check
|
||||
s.router.Get(
|
||||
// Health check.
|
||||
srv.router.Get(
|
||||
"/.well-known/healthcheck.json",
|
||||
s.h.HandleHealthCheck(),
|
||||
srv.handlers.HandleHealthCheck(),
|
||||
)
|
||||
|
||||
// Protected metrics endpoint
|
||||
// Protected metrics endpoint.
|
||||
if viper.GetString("METRICS_USERNAME") != "" {
|
||||
s.router.Group(func(r chi.Router) {
|
||||
r.Use(s.mw.MetricsAuth())
|
||||
r.Get("/metrics",
|
||||
srv.router.Group(func(router chi.Router) {
|
||||
router.Use(srv.mw.MetricsAuth())
|
||||
router.Get("/metrics",
|
||||
http.HandlerFunc(
|
||||
promhttp.Handler().ServeHTTP,
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
// API v1
|
||||
s.router.Route("/api/v1", func(r chi.Router) {
|
||||
r.Get("/server", s.h.HandleServerInfo())
|
||||
r.Post("/session", s.h.HandleCreateSession())
|
||||
r.Get("/state", s.h.HandleState())
|
||||
r.Get("/messages", s.h.HandleGetMessages())
|
||||
r.Post("/messages", s.h.HandleSendCommand())
|
||||
r.Get("/history", s.h.HandleGetHistory())
|
||||
r.Get("/channels", s.h.HandleListAllChannels())
|
||||
r.Get(
|
||||
"/channels/{channel}/members",
|
||||
s.h.HandleChannelMembers(),
|
||||
)
|
||||
})
|
||||
// API v1.
|
||||
srv.router.Route(
|
||||
"/api/v1",
|
||||
func(router chi.Router) {
|
||||
router.Get(
|
||||
"/server",
|
||||
srv.handlers.HandleServerInfo(),
|
||||
)
|
||||
router.Post(
|
||||
"/session",
|
||||
srv.handlers.HandleCreateSession(),
|
||||
)
|
||||
router.Get(
|
||||
"/state",
|
||||
srv.handlers.HandleState(),
|
||||
)
|
||||
router.Get(
|
||||
"/messages",
|
||||
srv.handlers.HandleGetMessages(),
|
||||
)
|
||||
router.Post(
|
||||
"/messages",
|
||||
srv.handlers.HandleSendCommand(),
|
||||
)
|
||||
router.Get(
|
||||
"/history",
|
||||
srv.handlers.HandleGetHistory(),
|
||||
)
|
||||
router.Get(
|
||||
"/channels",
|
||||
srv.handlers.HandleListAllChannels(),
|
||||
)
|
||||
router.Get(
|
||||
"/channels/{channel}/members",
|
||||
srv.handlers.HandleChannelMembers(),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
// Serve embedded SPA
|
||||
s.setupSPA()
|
||||
// Serve embedded SPA.
|
||||
srv.setupSPA()
|
||||
}
|
||||
|
||||
func (s *Server) setupSPA() {
|
||||
func (srv *Server) setupSPA() {
|
||||
distFS, err := fs.Sub(web.Dist, "dist")
|
||||
if err != nil {
|
||||
s.log.Error(
|
||||
srv.log.Error(
|
||||
"failed to get web dist filesystem",
|
||||
"error", err,
|
||||
)
|
||||
@@ -87,38 +114,40 @@ func (s *Server) setupSPA() {
|
||||
|
||||
fileServer := http.FileServer(http.FS(distFS))
|
||||
|
||||
s.router.Get("/*", func(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
srv.router.Get("/*", func(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
) {
|
||||
readFS, ok := distFS.(fs.ReadFileFS)
|
||||
if !ok {
|
||||
fileServer.ServeHTTP(w, r)
|
||||
fileServer.ServeHTTP(writer, request)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
f, readErr := readFS.ReadFile(r.URL.Path[1:])
|
||||
if readErr != nil || len(f) == 0 {
|
||||
fileData, readErr := readFS.ReadFile(
|
||||
request.URL.Path[1:],
|
||||
)
|
||||
if readErr != nil || len(fileData) == 0 {
|
||||
indexHTML, indexErr := readFS.ReadFile(
|
||||
"index.html",
|
||||
)
|
||||
if indexErr != nil {
|
||||
http.NotFound(w, r)
|
||||
http.NotFound(writer, request)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set(
|
||||
writer.Header().Set(
|
||||
"Content-Type",
|
||||
"text/html; charset=utf-8",
|
||||
)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(indexHTML)
|
||||
writer.WriteHeader(http.StatusOK)
|
||||
_, _ = writer.Write(indexHTML)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
fileServer.ServeHTTP(w, r)
|
||||
fileServer.ServeHTTP(writer, request)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -41,7 +41,8 @@ type Params struct {
|
||||
Handlers *handlers.Handlers
|
||||
}
|
||||
|
||||
// Server is the main HTTP server. It manages routing, middleware, and lifecycle.
|
||||
// Server is the main HTTP server.
|
||||
// It manages routing, middleware, and lifecycle.
|
||||
type Server struct {
|
||||
startupTime time.Time
|
||||
exitCode int
|
||||
@@ -53,21 +54,24 @@ type Server struct {
|
||||
router *chi.Mux
|
||||
params Params
|
||||
mw *middleware.Middleware
|
||||
h *handlers.Handlers
|
||||
handlers *handlers.Handlers
|
||||
}
|
||||
|
||||
// New creates a new Server and registers its lifecycle hooks.
|
||||
func New(lc fx.Lifecycle, params Params) (*Server, error) {
|
||||
s := new(Server)
|
||||
s.params = params
|
||||
s.mw = params.Middleware
|
||||
s.h = params.Handlers
|
||||
s.log = params.Logger.Get()
|
||||
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(),
|
||||
}
|
||||
|
||||
lc.Append(fx.Hook{
|
||||
lifecycle.Append(fx.Hook{
|
||||
OnStart: func(_ context.Context) error {
|
||||
s.startupTime = time.Now()
|
||||
go s.Run() //nolint:contextcheck
|
||||
srv.startupTime = time.Now()
|
||||
go srv.Run() //nolint:contextcheck
|
||||
|
||||
return nil
|
||||
},
|
||||
@@ -76,122 +80,140 @@ func New(lc fx.Lifecycle, params Params) (*Server, error) {
|
||||
},
|
||||
})
|
||||
|
||||
return s, nil
|
||||
return srv, nil
|
||||
}
|
||||
|
||||
// Run starts the server configuration, Sentry, and begins serving.
|
||||
func (s *Server) Run() {
|
||||
s.configure()
|
||||
s.enableSentry()
|
||||
s.serve()
|
||||
func (srv *Server) Run() {
|
||||
srv.configure()
|
||||
srv.enableSentry()
|
||||
srv.serve()
|
||||
}
|
||||
|
||||
// ServeHTTP delegates to the chi router.
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.router.ServeHTTP(w, r)
|
||||
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 (s *Server) MaintenanceMode() bool {
|
||||
return s.params.Config.MaintenanceMode
|
||||
func (srv *Server) MaintenanceMode() bool {
|
||||
return srv.params.Config.MaintenanceMode
|
||||
}
|
||||
|
||||
func (s *Server) enableSentry() {
|
||||
s.sentryEnabled = false
|
||||
func (srv *Server) enableSentry() {
|
||||
srv.sentryEnabled = false
|
||||
|
||||
if s.params.Config.SentryDSN == "" {
|
||||
if srv.params.Config.SentryDSN == "" {
|
||||
return
|
||||
}
|
||||
|
||||
err := sentry.Init(sentry.ClientOptions{
|
||||
Dsn: s.params.Config.SentryDSN,
|
||||
Release: fmt.Sprintf("%s-%s", s.params.Globals.Appname, s.params.Globals.Version),
|
||||
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 {
|
||||
s.log.Error("sentry init failure", "error", err)
|
||||
srv.log.Error("sentry init failure", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
s.log.Info("sentry error reporting activated")
|
||||
s.sentryEnabled = true
|
||||
srv.log.Info("sentry error reporting activated")
|
||||
srv.sentryEnabled = true
|
||||
}
|
||||
|
||||
func (s *Server) serve() int {
|
||||
s.ctx, s.cancelFunc = context.WithCancel(context.Background())
|
||||
func (srv *Server) serve() int {
|
||||
srv.ctx, srv.cancelFunc = context.WithCancel(
|
||||
context.Background(),
|
||||
)
|
||||
|
||||
go func() {
|
||||
c := make(chan os.Signal, 1)
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
|
||||
signal.Ignore(syscall.SIGPIPE)
|
||||
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
|
||||
sig := <-c
|
||||
s.log.Info("signal received", "signal", sig)
|
||||
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
|
||||
|
||||
if s.cancelFunc != nil {
|
||||
s.cancelFunc()
|
||||
sig := <-sigCh
|
||||
|
||||
srv.log.Info("signal received", "signal", sig)
|
||||
|
||||
if srv.cancelFunc != nil {
|
||||
srv.cancelFunc()
|
||||
}
|
||||
}()
|
||||
|
||||
go s.serveUntilShutdown()
|
||||
go srv.serveUntilShutdown()
|
||||
|
||||
<-s.ctx.Done()
|
||||
<-srv.ctx.Done()
|
||||
|
||||
s.cleanShutdown()
|
||||
srv.cleanShutdown()
|
||||
|
||||
return s.exitCode
|
||||
return srv.exitCode
|
||||
}
|
||||
|
||||
func (s *Server) cleanupForExit() {
|
||||
s.log.Info("cleaning up")
|
||||
func (srv *Server) cleanupForExit() {
|
||||
srv.log.Info("cleaning up")
|
||||
}
|
||||
|
||||
func (s *Server) cleanShutdown() {
|
||||
s.exitCode = 0
|
||||
func (srv *Server) cleanShutdown() {
|
||||
srv.exitCode = 0
|
||||
|
||||
ctxShutdown, shutdownCancel := context.WithTimeout(
|
||||
context.Background(), shutdownTimeout,
|
||||
)
|
||||
|
||||
err := s.httpServer.Shutdown(ctxShutdown)
|
||||
err := srv.httpServer.Shutdown(ctxShutdown)
|
||||
if err != nil {
|
||||
s.log.Error("server clean shutdown failed", "error", err)
|
||||
srv.log.Error(
|
||||
"server clean shutdown failed", "error", err,
|
||||
)
|
||||
}
|
||||
|
||||
if shutdownCancel != nil {
|
||||
shutdownCancel()
|
||||
}
|
||||
|
||||
s.cleanupForExit()
|
||||
srv.cleanupForExit()
|
||||
|
||||
if s.sentryEnabled {
|
||||
if srv.sentryEnabled {
|
||||
sentry.Flush(sentryFlushTime)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) configure() {
|
||||
// server configuration placeholder
|
||||
func (srv *Server) configure() {
|
||||
// Server configuration placeholder.
|
||||
}
|
||||
|
||||
func (s *Server) serveUntilShutdown() {
|
||||
listenAddr := fmt.Sprintf(":%d", s.params.Config.Port)
|
||||
s.httpServer = &http.Server{
|
||||
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: s,
|
||||
Handler: srv,
|
||||
}
|
||||
|
||||
s.SetupRoutes()
|
||||
srv.SetupRoutes()
|
||||
|
||||
s.log.Info("http begin listen", "listenaddr", listenAddr)
|
||||
srv.log.Info(
|
||||
"http begin listen", "listenaddr", listenAddr,
|
||||
)
|
||||
|
||||
err := s.httpServer.ListenAndServe()
|
||||
err := srv.httpServer.ListenAndServe()
|
||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
s.log.Error("listen error", "error", err)
|
||||
srv.log.Error("listen error", "error", err)
|
||||
|
||||
if s.cancelFunc != nil {
|
||||
s.cancelFunc()
|
||||
if srv.cancelFunc != nil {
|
||||
srv.cancelFunc()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user