diff --git a/gearbox/cmd/server/main.go b/gearbox/cmd/server/main.go index e33b5c6..dc8a48f 100644 --- a/gearbox/cmd/server/main.go +++ b/gearbox/cmd/server/main.go @@ -27,7 +27,7 @@ import ( "github.com/sarg3nt/gearbox/internal/framework/services" "github.com/sarg3nt/gearbox/internal/framework/services/agent_keyring" "github.com/sarg3nt/gearbox/internal/framework/services/alerts" - "github.com/sarg3nt/gearbox/internal/framework/services/crypto" + "github.com/sarg3nt/webcore/core/crypto" "github.com/sarg3nt/gearbox/internal/framework/services/email" // Import gears - blank identifier triggers init() registration @@ -188,7 +188,7 @@ func main() { logger.Info("email service initialized") // Initialize encryptor for secrets - encryptor, err := crypto.NewEncryptor(cfg.SessionSecret) + encryptor, err := crypto.NewFromHashedKey(cfg.SessionSecret) if err != nil { log.Fatalf("Failed to create encryptor: %v", err) } diff --git a/gearbox/go.mod b/gearbox/go.mod index a9ab876..9298680 100644 --- a/gearbox/go.mod +++ b/gearbox/go.mod @@ -3,23 +3,24 @@ module github.com/sarg3nt/gearbox go 1.25.5 require ( - github.com/a-h/templ v0.3.977 + github.com/a-h/templ v0.3.1020 github.com/go-chi/chi/v5 v5.2.5 - github.com/go-webauthn/webauthn v0.17.3 + github.com/go-webauthn/webauthn v0.17.4 github.com/golang-migrate/migrate/v4 v4.19.1 github.com/google/uuid v1.6.0 github.com/gorilla/sessions v1.4.0 github.com/gorilla/websocket v1.5.3 + github.com/sarg3nt/webcore v0.1.0 github.com/wagslane/go-password-validator v0.3.0 - golang.org/x/crypto v0.51.0 - modernc.org/sqlite v1.46.1 + golang.org/x/crypto v0.52.0 + modernc.org/sqlite v1.50.1 ) require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/fxamacker/cbor/v2 v2.9.2 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect - github.com/go-webauthn/x v0.2.5 // indirect + github.com/go-webauthn/x v0.2.6 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/google/go-tpm v0.9.8 // indirect github.com/gorilla/securecookie v1.1.2 // indirect @@ -29,9 +30,8 @@ require ( github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/tinylib/msgp v1.6.4 // indirect github.com/x448/float16 v0.8.4 // indirect - golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect - golang.org/x/sys v0.44.0 // indirect - modernc.org/libc v1.67.6 // indirect + golang.org/x/sys v0.45.0 // indirect + modernc.org/libc v1.72.3 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect ) diff --git a/gearbox/go.sum b/gearbox/go.sum index 21e37b1..60d905b 100644 --- a/gearbox/go.sum +++ b/gearbox/go.sum @@ -1,5 +1,5 @@ -github.com/a-h/templ v0.3.977 h1:kiKAPXTZE2Iaf8JbtM21r54A8bCNsncrfnokZZSrSDg= -github.com/a-h/templ v0.3.977/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo= +github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw= +github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= @@ -10,10 +10,10 @@ github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/go-webauthn/webauthn v0.17.3 h1:XHZ0TXV7k8vChcE4TFgPitOPJ5cb7h1dpAeFDS0cjCo= -github.com/go-webauthn/webauthn v0.17.3/go.mod h1:PlkMgmuL9McCT7dvgBj/Sz/fgs3V6ZID6/KnFkEcPvQ= -github.com/go-webauthn/x v0.2.5 h1:wEVTfU04XFyPTXGQbKOQwMKhcDWfDAkdsDDBsDaG9yY= -github.com/go-webauthn/x v0.2.5/go.mod h1:Qna/yJz9rV6lRzwl5BfYbmTJpVGxcBIds3gJtw2tlGg= +github.com/go-webauthn/webauthn v0.17.4 h1:KFTSz3R2RYDiUn/0cDi3XTJgFenSG74eKTTHlqWhlxk= +github.com/go-webauthn/webauthn v0.17.4/go.mod h1:pZk63EE/BdztlmyS4Yc+9H5g4a8blNlbtGmdHQHbZX8= +github.com/go-webauthn/x v0.2.6 h1:TEyDuQAIiEgYpx60nKiBJIX/5nSUC8LxNbH+uf5U9uk= +github.com/go-webauthn/x v0.2.6/go.mod h1:45bA7YEqyQhRcQJ/TiBb46Ww8yqHBGvgEhQ3WWF0aDo= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= @@ -50,6 +50,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/sarg3nt/webcore v0.1.0 h1:lB3PtuMb4/5guPj6DTn2Azi7drOY0deNDAWWwZZPOtc= +github.com/sarg3nt/webcore v0.1.0/go.mod h1:LQipBfXp8zn3tWhCvjpopvYQVDFTv65EGilgGeJ4ZTU= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= @@ -60,45 +62,43 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= -golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= -golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= -golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= -golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= -golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= -golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= -golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= -golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= -golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= -modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= -modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= -modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= -modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= -modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= +modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ= +modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= -modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE= -modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= -modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI= -modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE= +modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU= +modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= -modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU= -modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= +modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w= +modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= diff --git a/gearbox/internal/framework/errors/errors.go b/gearbox/internal/framework/errors/errors.go deleted file mode 100644 index 5482a45..0000000 --- a/gearbox/internal/framework/errors/errors.go +++ /dev/null @@ -1,241 +0,0 @@ -// Package errors provides error sanitization and structured error handling -// for the HAProxy monitoring application. -// -// This package implements a centralized error handling system that: -// - Sanitizes internal errors before exposing them to users -// - Provides structured logging for error diagnosis -// - Maintains consistent HTTP status codes across the application -// - Separates user-facing messages from internal error details -// -// Example usage: -// -// // In an HTTP handler: -// user, err := db.GetUser(id) -// if err != nil { -// errors.WriteHTTPError(w, logger, errors.Internal("fetch user", err)) -// return -// } -// -// // Or using specific error constructors: -// if !authorized { -// errors.WriteHTTPError(w, logger, errors.Forbidden("Access denied", nil)) -// return -// } -package errors - -import ( - "context" - "database/sql" - "encoding/json" - "errors" - "fmt" - "log/slog" - "net/http" -) - -// AppError represents a structured application error with user-safe message -// and internal details for logging. -type AppError struct { - // Code is the HTTP status code - Code int - // UserMessage is safe to display to end users - UserMessage string - // InternalError contains the actual error (logged but not shown to user) - InternalError error - // LogContext contains additional context for structured logging - LogContext map[string]any -} - -// Error implements the error interface. -func (e *AppError) Error() string { - if e.InternalError != nil { - return fmt.Sprintf("%s: %v", e.UserMessage, e.InternalError) - } - return e.UserMessage -} - -// Unwrap implements error unwrapping for errors.Is and errors.As. -func (e *AppError) Unwrap() error { - return e.InternalError -} - -// ErrorType represents common error categories. -type ErrorType string - -const ( - ErrorTypeNotFound ErrorType = "not_found" - ErrorTypeUnauthorized ErrorType = "unauthorized" - ErrorTypeForbidden ErrorType = "forbidden" - ErrorTypeBadRequest ErrorType = "bad_request" - ErrorTypeInternal ErrorType = "internal_error" - ErrorTypeServiceUnavailable ErrorType = "service_unavailable" - ErrorTypeConflict ErrorType = "conflict" -) - -// New creates a new AppError with the given parameters. -func New(code int, userMsg string, internalErr error) *AppError { - return &AppError{ - Code: code, - UserMessage: userMsg, - InternalError: internalErr, - LogContext: make(map[string]any), - } -} - -// WithContext adds structured logging context to the error. -func (e *AppError) WithContext(key string, value any) *AppError { - e.LogContext[key] = value - return e -} - -// Common error constructors - -// NotFound creates a 404 error. -func NotFound(resource string, internalErr error) *AppError { - return New( - http.StatusNotFound, - fmt.Sprintf("%s not found", resource), - internalErr, - ).WithContext("resource", resource) -} - -// Unauthorized creates a 401 error. -func Unauthorized(message string, internalErr error) *AppError { - return New(http.StatusUnauthorized, message, internalErr) -} - -// Forbidden creates a 403 error. -func Forbidden(message string, internalErr error) *AppError { - return New(http.StatusForbidden, message, internalErr) -} - -// BadRequest creates a 400 error. -func BadRequest(message string, internalErr error) *AppError { - return New(http.StatusBadRequest, message, internalErr) -} - -// Internal creates a 500 error with a generic user message. -func Internal(operation string, internalErr error) *AppError { - return New( - http.StatusInternalServerError, - fmt.Sprintf("Failed to %s. Please try again later.", operation), - internalErr, - ).WithContext("operation", operation) -} - -// ServiceUnavailable creates a 503 error. -func ServiceUnavailable(service string, internalErr error) *AppError { - return New( - http.StatusServiceUnavailable, - fmt.Sprintf("%s is currently unavailable", service), - internalErr, - ).WithContext("service", service) -} - -// Conflict creates a 409 error. -func Conflict(message string, internalErr error) *AppError { - return New(http.StatusConflict, message, internalErr) -} - -// WrapDatabaseError converts database errors to appropriate AppErrors. -// Use this for database operations where ErrNoRows is unexpected. -func WrapDatabaseError(err error, operation string) *AppError { - if err == nil { - return nil - } - - // Handle specific database errors - if errors.Is(err, sql.ErrNoRows) { - return NotFound("Resource", err) - } - - // Generic database error - don't expose internal details - return Internal(operation, err) -} - -// IsNotFound checks if an error is sql.ErrNoRows. -// Use this helper when ErrNoRows is expected behavior (e.g., optional lookups). -func IsNotFound(err error) bool { - return errors.Is(err, sql.ErrNoRows) -} - -// IgnoreNotFound returns nil if the error is sql.ErrNoRows, otherwise returns the error. -// Use this when a record not existing is acceptable/expected behavior. -func IgnoreNotFound(err error) error { - if errors.Is(err, sql.ErrNoRows) { - return nil - } - return err -} - -// WriteHTTPError writes an AppError to an HTTP response and logs it. -// -// The wire format is a JSON envelope: `{"success": false, "message": "..."}` -// with `Content-Type: application/json`. This matches the shape produced by -// Handler.jsonError() and lets the frontend `response.json()` parse error -// responses just like success responses — historically this used -// `http.Error` (plain text), which caused JS callers that unconditionally -// `JSON.parse` the body to throw `Unexpected token 'F', "Failed to "...` -// (see issue #112 for the user-visible symptom on the Logs and Services -// pages). -func WriteHTTPError(w http.ResponseWriter, logger *slog.Logger, err error) { - var appErr *AppError - - // Convert to AppError if not already - if !errors.As(err, &appErr) { - // Unknown error - create generic internal error - appErr = Internal("process request", err) - } - - // Log the error with full details - logAttrs := []slog.Attr{ - slog.Int("status_code", appErr.Code), - slog.String("user_message", appErr.UserMessage), - } - - if appErr.InternalError != nil { - logAttrs = append(logAttrs, slog.String("internal_error", appErr.InternalError.Error())) - } - - // Add context fields - for key, value := range appErr.LogContext { - logAttrs = append(logAttrs, slog.Any(key, value)) - } - - logger.LogAttrs(context.Background(), slog.LevelError, "HTTP error", logAttrs...) - - // Write sanitized error to client as a JSON envelope so JS callers can - // `response.json()` it without throwing on plain-text bodies. - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(appErr.Code) - if encErr := json.NewEncoder(w).Encode(map[string]any{ - "success": false, - "message": appErr.UserMessage, - }); encErr != nil { - // Encoding can fail only if the writer is broken — the header is - // already on the wire so we can't recover the response, just log. - logger.LogAttrs(context.Background(), slog.LevelError, "encode error envelope failed", - slog.String("error", encErr.Error())) - } -} - -// SanitizeError converts any error to a user-safe message. -// This is a fallback for cases where structured errors aren't used. -func SanitizeError(err error) string { - if err == nil { - return "" - } - - var appErr *AppError - if errors.As(err, &appErr) { - return appErr.UserMessage - } - - // Database errors - if errors.Is(err, sql.ErrNoRows) { - return "Resource not found" - } - - // Generic fallback - never expose internal error details - return "An error occurred. Please try again later." -} diff --git a/gearbox/internal/framework/errors/errors_writehttp_test.go b/gearbox/internal/framework/errors/errors_writehttp_test.go deleted file mode 100644 index 54622d0..0000000 --- a/gearbox/internal/framework/errors/errors_writehttp_test.go +++ /dev/null @@ -1,64 +0,0 @@ -package errors - -import ( - "encoding/json" - "io" - "log/slog" - "net/http" - "net/http/httptest" - "strings" - "testing" -) - -// TestWriteHTTPError_JSONEnvelope guards the wire format consumers of -// WriteHTTPError rely on — Logs page, Services page, and any other JS that -// calls `response.json()` on both success and error responses. Historically -// WriteHTTPError emitted `text/plain` ("Failed to ...") which caused -// `JSON.parse` to throw `Unexpected token 'F'` in the browser; the fix in -// issue #112 standardizes on a JSON envelope. -func TestWriteHTTPError_JSONEnvelope(t *testing.T) { - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - w := httptest.NewRecorder() - - WriteHTTPError(w, logger, Internal("fetch logs", io.EOF)) - - if got := w.Header().Get("Content-Type"); !strings.Contains(got, "application/json") { - t.Fatalf("Content-Type = %q, want application/json", got) - } - if got := w.Code; got != http.StatusInternalServerError { - t.Fatalf("Status = %d, want 500", got) - } - - var body map[string]any - if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { - t.Fatalf("response body is not JSON: %v\nbody=%q", err, w.Body.String()) - } - if body["success"] != false { - t.Errorf("body.success = %v, want false", body["success"]) - } - msg, _ := body["message"].(string) - if !strings.Contains(msg, "fetch logs") { - t.Errorf("body.message = %q, want it to contain 'fetch logs'", msg) - } -} - -// TestWriteHTTPError_UnknownErrorWraps verifies that a plain error (not an -// AppError) still serializes as a JSON envelope with the generic -// "process request" wrapping that Internal() produces. -func TestWriteHTTPError_UnknownErrorWraps(t *testing.T) { - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - w := httptest.NewRecorder() - - WriteHTTPError(w, logger, io.ErrUnexpectedEOF) - - if got := w.Header().Get("Content-Type"); !strings.Contains(got, "application/json") { - t.Fatalf("Content-Type = %q, want application/json", got) - } - var body map[string]any - if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { - t.Fatalf("response body is not JSON: %v\nbody=%q", err, w.Body.String()) - } - if body["success"] != false { - t.Errorf("body.success = %v, want false", body["success"]) - } -} diff --git a/gearbox/internal/framework/events/hub.go b/gearbox/internal/framework/events/hub.go index d5e2cd2..7367ee8 100644 --- a/gearbox/internal/framework/events/hub.go +++ b/gearbox/internal/framework/events/hub.go @@ -137,6 +137,7 @@ type Hub struct { register chan *Subscriber unregister chan *Subscriber done chan struct{} + stopOnce sync.Once // subscriberDrops is owned by run() and must not be touched from other // goroutines. subscriberDrops map[dropKey]*dropAggregator @@ -166,9 +167,10 @@ func (h *Hub) Start() { go h.run() } -// Stop gracefully shuts down the hub. +// Stop gracefully shuts down the hub. Safe to call more than once (e.g. from +// both a signal handler and a defer). func (h *Hub) Stop() { - close(h.done) + h.stopOnce.Do(func() { close(h.done) }) } // run is the main event loop. @@ -221,7 +223,11 @@ func (h *Hub) run() { } } -// Subscribe creates a new subscriber. +// Subscribe creates a new subscriber. If the hub has already been stopped, +// the returned subscriber's Events channel is pre-closed so consumers (e.g. +// the SSE handler's `event, ok := <-sub.Events`) observe !ok and exit — +// instead of this call blocking forever on a registration that nothing will +// ever drain (shutdown-race goroutine/connection leak). func (h *Hub) Subscribe(id string, serverID string) *Subscriber { sub := &Subscriber{ ID: id, @@ -230,7 +236,11 @@ func (h *Hub) Subscribe(id string, serverID string) *Subscriber { done: make(chan struct{}), } - h.register <- sub + select { + case h.register <- sub: + case <-h.done: + close(sub.Events) + } return sub } diff --git a/gearbox/internal/framework/handler/alerts.go b/gearbox/internal/framework/handler/alerts.go index 060bb75..c13cc3b 100644 --- a/gearbox/internal/framework/handler/alerts.go +++ b/gearbox/internal/framework/handler/alerts.go @@ -9,7 +9,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/sarg3nt/gearbox/internal/framework/auth" "github.com/sarg3nt/gearbox/internal/framework/database" - apperrors "github.com/sarg3nt/gearbox/internal/framework/errors" + apperrors "github.com/sarg3nt/webcore/core/errors" "github.com/sarg3nt/gearbox/internal/framework/models" "github.com/sarg3nt/gearbox/internal/framework/templates/pages" ) diff --git a/gearbox/internal/framework/handler/api_certificates.go b/gearbox/internal/framework/handler/api_certificates.go index fa74c0a..6dd0d72 100644 --- a/gearbox/internal/framework/handler/api_certificates.go +++ b/gearbox/internal/framework/handler/api_certificates.go @@ -6,7 +6,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/sarg3nt/gearbox/internal/framework/agent" - apperrors "github.com/sarg3nt/gearbox/internal/framework/errors" + apperrors "github.com/sarg3nt/webcore/core/errors" "github.com/sarg3nt/gearbox/internal/framework/models" ) diff --git a/gearbox/internal/framework/handler/api_logs.go b/gearbox/internal/framework/handler/api_logs.go index 69c75e7..ce95410 100644 --- a/gearbox/internal/framework/handler/api_logs.go +++ b/gearbox/internal/framework/handler/api_logs.go @@ -4,7 +4,7 @@ import ( "net/http" "github.com/go-chi/chi/v5" - apperrors "github.com/sarg3nt/gearbox/internal/framework/errors" + apperrors "github.com/sarg3nt/webcore/core/errors" "github.com/sarg3nt/gearbox/internal/framework/models" ) diff --git a/gearbox/internal/framework/handler/api_services.go b/gearbox/internal/framework/handler/api_services.go index 4b3503b..d443dfa 100644 --- a/gearbox/internal/framework/handler/api_services.go +++ b/gearbox/internal/framework/handler/api_services.go @@ -9,7 +9,7 @@ import ( "github.com/sarg3nt/gearbox/internal/framework/agent" "github.com/sarg3nt/gearbox/internal/framework/auth" "github.com/sarg3nt/gearbox/internal/framework/database" - apperrors "github.com/sarg3nt/gearbox/internal/framework/errors" + apperrors "github.com/sarg3nt/webcore/core/errors" "github.com/sarg3nt/gearbox/internal/framework/models" "github.com/sarg3nt/gearbox/internal/gears/services" ) diff --git a/gearbox/internal/framework/handler/api_traffic.go b/gearbox/internal/framework/handler/api_traffic.go index bd52939..f322bcb 100644 --- a/gearbox/internal/framework/handler/api_traffic.go +++ b/gearbox/internal/framework/handler/api_traffic.go @@ -10,7 +10,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/sarg3nt/gearbox/internal/framework/agent" "github.com/sarg3nt/gearbox/internal/framework/database" - apperrors "github.com/sarg3nt/gearbox/internal/framework/errors" + apperrors "github.com/sarg3nt/webcore/core/errors" "github.com/sarg3nt/gearbox/internal/framework/models" ) diff --git a/gearbox/internal/framework/handler/backup.go b/gearbox/internal/framework/handler/backup.go index b9dbbf8..b60bbad 100644 --- a/gearbox/internal/framework/handler/backup.go +++ b/gearbox/internal/framework/handler/backup.go @@ -10,7 +10,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/sarg3nt/gearbox/internal/framework/database" - apperrors "github.com/sarg3nt/gearbox/internal/framework/errors" + apperrors "github.com/sarg3nt/webcore/core/errors" "github.com/sarg3nt/gearbox/internal/framework/templates/pages" ) diff --git a/gearbox/internal/framework/handler/config.go b/gearbox/internal/framework/handler/config.go index 5900061..7be3518 100644 --- a/gearbox/internal/framework/handler/config.go +++ b/gearbox/internal/framework/handler/config.go @@ -11,7 +11,7 @@ import ( "github.com/sarg3nt/gearbox/internal/framework/agent" "github.com/sarg3nt/gearbox/internal/framework/auth" "github.com/sarg3nt/gearbox/internal/framework/database" - apperrors "github.com/sarg3nt/gearbox/internal/framework/errors" + apperrors "github.com/sarg3nt/webcore/core/errors" "github.com/sarg3nt/gearbox/internal/framework/models" "github.com/sarg3nt/gearbox/internal/framework/templates/pages" ) diff --git a/gearbox/internal/framework/handler/handler.go b/gearbox/internal/framework/handler/handler.go index 2d3fd3c..77a29f4 100644 --- a/gearbox/internal/framework/handler/handler.go +++ b/gearbox/internal/framework/handler/handler.go @@ -13,7 +13,7 @@ import ( "github.com/sarg3nt/gearbox/internal/framework/database" "github.com/sarg3nt/gearbox/internal/framework/events" "github.com/sarg3nt/gearbox/internal/framework/models" - "github.com/sarg3nt/gearbox/internal/framework/services/crypto" + "github.com/sarg3nt/webcore/core/crypto" "github.com/sarg3nt/gearbox/internal/framework/services/email" "github.com/sarg3nt/gearbox/internal/framework/services/geoip" ) diff --git a/gearbox/internal/framework/handler/haproxy_config.go b/gearbox/internal/framework/handler/haproxy_config.go index 221d8f6..a2be88a 100644 --- a/gearbox/internal/framework/handler/haproxy_config.go +++ b/gearbox/internal/framework/handler/haproxy_config.go @@ -11,7 +11,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/sarg3nt/gearbox/internal/framework/agent" "github.com/sarg3nt/gearbox/internal/framework/auth" - "github.com/sarg3nt/gearbox/internal/framework/services/crypto" + "github.com/sarg3nt/webcore/core/crypto" "github.com/sarg3nt/gearbox/internal/framework/database" "github.com/sarg3nt/gearbox/internal/framework/models" "github.com/sarg3nt/gearbox/internal/framework/templates/pages" diff --git a/gearbox/internal/framework/handler/security.go b/gearbox/internal/framework/handler/security.go index 3999128..39dda2a 100644 --- a/gearbox/internal/framework/handler/security.go +++ b/gearbox/internal/framework/handler/security.go @@ -7,7 +7,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/sarg3nt/gearbox/internal/framework/auth" - apperrors "github.com/sarg3nt/gearbox/internal/framework/errors" + apperrors "github.com/sarg3nt/webcore/core/errors" "github.com/sarg3nt/gearbox/internal/framework/models" "github.com/sarg3nt/gearbox/internal/framework/templates/pages" ) diff --git a/gearbox/internal/framework/handler/users.go b/gearbox/internal/framework/handler/users.go index 4108c6c..947de88 100644 --- a/gearbox/internal/framework/handler/users.go +++ b/gearbox/internal/framework/handler/users.go @@ -11,7 +11,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/sarg3nt/gearbox/internal/framework/auth" - apperrors "github.com/sarg3nt/gearbox/internal/framework/errors" + apperrors "github.com/sarg3nt/webcore/core/errors" "github.com/sarg3nt/gearbox/internal/framework/models" "github.com/sarg3nt/gearbox/internal/framework/templates/pages" ) diff --git a/gearbox/internal/framework/services/agent_keyring/rotator.go b/gearbox/internal/framework/services/agent_keyring/rotator.go index 44ae8f7..8b790d3 100644 --- a/gearbox/internal/framework/services/agent_keyring/rotator.go +++ b/gearbox/internal/framework/services/agent_keyring/rotator.go @@ -20,7 +20,7 @@ import ( "github.com/sarg3nt/gearbox/internal/framework/agent" "github.com/sarg3nt/gearbox/internal/framework/database" - "github.com/sarg3nt/gearbox/internal/framework/services/crypto" + "github.com/sarg3nt/webcore/core/crypto" ) // DefaultOverlapWindow is the time that elapses between flipping diff --git a/gearbox/internal/framework/services/agent_keyring/rotator_test.go b/gearbox/internal/framework/services/agent_keyring/rotator_test.go index 77ee50c..071c397 100644 --- a/gearbox/internal/framework/services/agent_keyring/rotator_test.go +++ b/gearbox/internal/framework/services/agent_keyring/rotator_test.go @@ -16,7 +16,7 @@ import ( "time" "github.com/sarg3nt/gearbox/internal/framework/database" - dashcrypto "github.com/sarg3nt/gearbox/internal/framework/services/crypto" + dashcrypto "github.com/sarg3nt/webcore/core/crypto" ) // agentMock fakes the subset of the agent's /api/v1/system/keyring/* @@ -191,9 +191,9 @@ func setupRotator(t *testing.T) (*Rotator, *agentMock, *database.DB, *database.B } t.Cleanup(func() { _ = db.Close() }) - enc, err := dashcrypto.NewEncryptor("test-encryption-secret-32-bytes!") + enc, err := dashcrypto.NewFromHashedKey("test-encryption-secret-32-bytes!") if err != nil { - t.Fatalf("NewEncryptor: %v", err) + t.Fatalf("NewFromHashedKey: %v", err) } box := &database.BoxDB{ diff --git a/gearbox/internal/framework/services/auth_adapter.go b/gearbox/internal/framework/services/auth_adapter.go index 8473c58..bbe992d 100644 --- a/gearbox/internal/framework/services/auth_adapter.go +++ b/gearbox/internal/framework/services/auth_adapter.go @@ -10,7 +10,7 @@ import ( "github.com/sarg3nt/gearbox/internal/framework/database" "github.com/sarg3nt/gearbox/internal/framework/gear" "github.com/sarg3nt/gearbox/internal/framework/models" - "github.com/sarg3nt/gearbox/internal/framework/services/crypto" + "github.com/sarg3nt/webcore/core/crypto" ) // AuthAdapter wraps the auth.Manager to implement gear.AuthChecker. diff --git a/gearbox/internal/framework/services/crypto/encryption.go b/gearbox/internal/framework/services/crypto/encryption.go deleted file mode 100644 index 23b6727..0000000 --- a/gearbox/internal/framework/services/crypto/encryption.go +++ /dev/null @@ -1,116 +0,0 @@ -package crypto - -import ( - "crypto/aes" - "crypto/cipher" - "crypto/rand" - "crypto/sha256" - "errors" - "io" -) - -var ( - ErrInvalidCiphertext = errors.New("invalid ciphertext") - ErrInvalidKey = errors.New("invalid encryption key") -) - -// Encryptor handles AES-256-GCM encryption/decryption for secrets. -type Encryptor struct { - key []byte -} - -// NewEncryptor creates a new encryptor using the provided secret key. -// The key is hashed with SHA-256 to ensure it's exactly 32 bytes for AES-256. -func NewEncryptor(secret string) (*Encryptor, error) { - if len(secret) < 32 { - return nil, ErrInvalidKey - } - - // Derive a 32-byte key using SHA-256 - hash := sha256.Sum256([]byte(secret)) - key := hash[:] - - return &Encryptor{key: key}, nil -} - -// Encrypt encrypts plaintext using AES-256-GCM. -// Returns ciphertext with prepended nonce (12 bytes + encrypted data). -func (e *Encryptor) Encrypt(plaintext []byte) ([]byte, error) { - if len(plaintext) == 0 { - return nil, nil - } - - block, err := aes.NewCipher(e.key) - if err != nil { - return nil, err - } - - gcm, err := cipher.NewGCM(block) - if err != nil { - return nil, err - } - - // Generate a random nonce - nonce := make([]byte, gcm.NonceSize()) - if _, err := io.ReadFull(rand.Reader, nonce); err != nil { - return nil, err - } - - // Encrypt and prepend nonce to ciphertext - ciphertext := gcm.Seal(nonce, nonce, plaintext, nil) - return ciphertext, nil -} - -// Decrypt decrypts ciphertext using AES-256-GCM. -// Expects ciphertext with prepended nonce (12 bytes + encrypted data). -func (e *Encryptor) Decrypt(ciphertext []byte) ([]byte, error) { - if len(ciphertext) == 0 { - return nil, nil - } - - block, err := aes.NewCipher(e.key) - if err != nil { - return nil, err - } - - gcm, err := cipher.NewGCM(block) - if err != nil { - return nil, err - } - - nonceSize := gcm.NonceSize() - if len(ciphertext) < nonceSize { - return nil, ErrInvalidCiphertext - } - - // Extract nonce and actual ciphertext - nonce, encryptedData := ciphertext[:nonceSize], ciphertext[nonceSize:] - - // Decrypt - plaintext, err := gcm.Open(nil, nonce, encryptedData, nil) - if err != nil { - return nil, ErrInvalidCiphertext - } - - return plaintext, nil -} - -// EncryptString is a convenience method for encrypting strings. -func (e *Encryptor) EncryptString(plaintext string) ([]byte, error) { - if plaintext == "" { - return nil, nil - } - return e.Encrypt([]byte(plaintext)) -} - -// DecryptString is a convenience method for decrypting to strings. -func (e *Encryptor) DecryptString(ciphertext []byte) (string, error) { - if len(ciphertext) == 0 { - return "", nil - } - plaintext, err := e.Decrypt(ciphertext) - if err != nil { - return "", err - } - return string(plaintext), nil -} diff --git a/gearbox/internal/framework/services/server_adapter.go b/gearbox/internal/framework/services/server_adapter.go index 520ec21..e6dcc3c 100644 --- a/gearbox/internal/framework/services/server_adapter.go +++ b/gearbox/internal/framework/services/server_adapter.go @@ -6,7 +6,7 @@ import ( "sync" "github.com/sarg3nt/gearbox/internal/framework/agent" - "github.com/sarg3nt/gearbox/internal/framework/services/crypto" + "github.com/sarg3nt/webcore/core/crypto" "github.com/sarg3nt/gearbox/internal/framework/database" "github.com/sarg3nt/gearbox/internal/framework/gear" "github.com/sarg3nt/gearbox/internal/framework/models" diff --git a/gearbox/internal/framework/templates/components/helpers.go b/gearbox/internal/framework/templates/components/helpers.go index 492ee42..8cc035b 100644 --- a/gearbox/internal/framework/templates/components/helpers.go +++ b/gearbox/internal/framework/templates/components/helpers.go @@ -50,8 +50,3 @@ func calculateCPUPercentage(loadAvg float64) float64 { return percentage } -// intToString converts an integer to a string. -// Used by settings components for HTML attribute values. -func intToString(n int) string { - return fmt.Sprintf("%d", n) -} diff --git a/gearbox/internal/framework/templates/components/info_tooltip.templ b/gearbox/internal/framework/templates/components/info_tooltip.templ deleted file mode 100644 index acbeb37..0000000 --- a/gearbox/internal/framework/templates/components/info_tooltip.templ +++ /dev/null @@ -1,29 +0,0 @@ -package components - -// InfoTooltip renders the canonical "info-circle + hover tooltip" widget -// used across the app — small grey filled-circle "i" icon that, on hover, -// reveals a short tooltip bubble. Behaviour matches the HAProxy overview -// pattern verbatim: pure CSS via Tailwind `group` / `group-hover:visible` -// (no JS), no cursor change (cursor stays as the default arrow), no -// transition delay. -// -// Use this anywhere you want to attach a short explanation to a label, -// table column header, KPI title, etc. For richer multi-paragraph -// content (the "VPN Gateway Architecture" style with a bold title + -// body), drop the same wrapper markup inline so the tooltip can hold -// arbitrary HTML; this component is the simple text-only variant. -// -// Width defaults to w-64 (16rem). Anchor is left-0 / top-6 — the -// tooltip extends down-and-right from the icon, which keeps it inside -// content cards whose icons sit near the left edge. -templ InfoTooltip(text string) { - - - - { text } - - - -} diff --git a/gearbox/internal/framework/templates/components/live_refresh.templ b/gearbox/internal/framework/templates/components/live_refresh.templ deleted file mode 100644 index 0a95f9d..0000000 --- a/gearbox/internal/framework/templates/components/live_refresh.templ +++ /dev/null @@ -1,58 +0,0 @@ -package components - -// LiveRefreshButton creates a refresh button that changes color based on SSE connection status -// The button shows: -// - Green when connected (Live) -// - Yellow when connecting -// - Red when disconnected -// - Gray as default -// -// Usage: Include this button in your page header controls section, then call updateSSEStatus(status) -// from your JavaScript to update the button's appearance based on connection state. -templ LiveRefreshButton() { - -} - -// LiveRefreshButtonScript provides the JavaScript function to update the button status -// This should be included once per page that uses the LiveRefreshButton component -templ LiveRefreshButtonScript() { - -} diff --git a/gearbox/internal/framework/templates/components/settings.templ b/gearbox/internal/framework/templates/components/settings.templ deleted file mode 100644 index 39f5405..0000000 --- a/gearbox/internal/framework/templates/components/settings.templ +++ /dev/null @@ -1,123 +0,0 @@ -package components - -// SettingsSection wraps a group of settings with a title and description. -templ SettingsSection(title, description string) { -
- { description } -
-- { helpText } -
- } -- { helpText } -
- } -- { helpText } -
- } -- { helpText } -
- } -{ config.Description }
- } -{ description }
- } -{ config.Description }
- } -{ description }
- } -{ description }
- } -