From 8af3d2e5faf844afc68e820248a6d852adc3a12a Mon Sep 17 00:00:00 2001 From: Julian Sommer <115516918+juliansommer@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:43:25 +1000 Subject: [PATCH] feat: migrate off fiber (#62) --- go.mod | 7 +- go.sum | 12 -- hive/daemon/daemon.go | 70 ++++--- hive/daemon/shutdown.go | 5 +- hive/gateway/account.go | 4 +- hive/gateway/auth.go | 285 +++++++++++++++------------ hive/gateway/auth_test.go | 321 +++++++++++-------------------- hive/gateway/ec2.go | 17 +- hive/gateway/gateway.go | 142 ++++++++------ hive/gateway/gateway_test.go | 27 +-- hive/gateway/iam.go | 17 +- hive/gateway/iam_request_test.go | 77 ++++---- hive/services/awsgw/awsgw.go | 24 ++- 13 files changed, 498 insertions(+), 510 deletions(-) diff --git a/go.mod b/go.mod index 60d374d85..e4222e5d2 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.26.0 require ( github.com/NYTimes/gziphandler v1.1.1 github.com/aws/aws-sdk-go v1.55.8 - github.com/gofiber/fiber/v2 v2.52.12 + github.com/go-chi/chi/v5 v5.2.4 github.com/google/uuid v1.6.0 github.com/kdomanski/iso9660 v0.4.0 github.com/klauspost/cpuid/v2 v2.3.0 @@ -32,7 +32,6 @@ require ( cloud.google.com/go/auth v0.16.5 // indirect cloud.google.com/go/compute/metadata v0.8.0 // indirect github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c // indirect - github.com/andybalholm/brotli v1.1.1 // indirect github.com/anthropics/anthropic-sdk-go v1.19.0 // indirect github.com/antithesishq/antithesis-sdk-go v0.5.0-default-no-op // indirect github.com/armon/go-metrics v0.4.1 // indirect @@ -54,7 +53,6 @@ require ( github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.8.0 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect - github.com/go-chi/chi/v5 v5.2.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-playground/locales v0.14.1 // indirect @@ -111,9 +109,6 @@ require ( github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect - github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasthttp v1.57.0 // indirect - github.com/valyala/tcplisten v1.0.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.etcd.io/bbolt v1.3.5 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect diff --git a/go.sum b/go.sum index d56202543..23999cedf 100644 --- a/go.sum +++ b/go.sum @@ -36,8 +36,6 @@ github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuy github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= -github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= -github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= github.com/anthropics/anthropic-sdk-go v1.19.0 h1:mO6E+ffSzLRvR/YUH9KJC0uGw0uV8GjISIuzem//3KE= github.com/anthropics/anthropic-sdk-go v1.19.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE= github.com/antithesishq/antithesis-sdk-go v0.5.0-default-no-op h1:Ucf+QxEKMbPogRO5guBNe5cgd9uZgfoJLOYs8WWhtjM= @@ -159,8 +157,6 @@ github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1v github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/gofiber/fiber/v2 v2.52.12 h1:0LdToKclcPOj8PktUdIKo9BUohjjwfnQl42Dhw8/WUw= -github.com/gofiber/fiber/v2 v2.52.12/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -420,17 +416,9 @@ github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhso github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= -github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= -github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasthttp v1.57.0 h1:Xw8SjWGEP/+wAAgyy5XTvgrWlOD1+TxbbvNADYCm1Tg= -github.com/valyala/fasthttp v1.57.0/go.mod h1:h6ZBaPRlzpZ6O3H5t2gEk1Qi33+TmLvfwgLLp0t9CpE= -github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= -github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= -github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.etcd.io/bbolt v1.3.5 h1:XAzx9gjCb0Rxj7EoqcClPD1d5ZBxZJk0jbuoPHenBt0= go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ= diff --git a/hive/daemon/daemon.go b/hive/daemon/daemon.go index 87911dc56..33bbbd295 100644 --- a/hive/daemon/daemon.go +++ b/hive/daemon/daemon.go @@ -24,7 +24,7 @@ import ( "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/ec2" - "github.com/gofiber/fiber/v2" + "github.com/go-chi/chi/v5" "github.com/mulgadc/hive/hive/awserrors" "github.com/mulgadc/hive/hive/config" handlers_ec2_account "github.com/mulgadc/hive/hive/handlers/ec2/account" @@ -45,6 +45,7 @@ import ( "github.com/mulgadc/viperblock/viperblock" "github.com/nats-io/nats.go" "github.com/pelletier/go-toml/v2" + "net/http" ) type BlockDeviceMapping struct { @@ -112,9 +113,9 @@ type Daemon struct { natsSubscriptions map[string]*nats.Subscription // Cluster manager - clusterApp *fiber.App - startTime time.Time - configPath string + clusterServer *http.Server + startTime time.Time + configPath string // JetStream manager for KV state storage (nil if JetStream disabled) jsManager *JetStreamManager @@ -984,19 +985,16 @@ func (d *Daemon) ClusterManager() error { return fmt.Errorf("daemon.host not configured") } - // Create Fiber app - d.clusterApp = fiber.New(fiber.Config{ - DisableStartupMessage: true, - AppName: "Hive Cluster Manager", - }) + r := chi.NewRouter() // Health endpoint - responds to HTTP and NATS - d.clusterApp.Get("/health", func(c *fiber.Ctx) error { + r.Get("/health", func(w http.ResponseWriter, r *http.Request) { configHash, err := d.computeConfigHash() if err != nil { - return c.Status(500).JSON(fiber.Map{ - "error": "failed to compute config hash", - }) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + _, _ = w.Write([]byte(`{"error":"failed to compute config hash"}`)) + return } serviceHealth := make(map[string]string) @@ -1040,35 +1038,45 @@ func (d *Daemon) ClusterManager() error { ServiceHealth: serviceHealth, } - return c.JSON(response) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) }) // Join endpoint - accepts new nodes joining the cluster - d.clusterApp.Post("/join", func(c *fiber.Ctx) error { + r.Post("/join", func(w http.ResponseWriter, r *http.Request) { var req config.NodeJoinRequest - if err := c.BodyParser(&req); err != nil { - return c.Status(400).JSON(config.NodeJoinResponse{ + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + _ = json.NewEncoder(w).Encode(config.NodeJoinResponse{ Success: false, Message: "invalid request body", }) + return } slog.Info("Node join request received", "node", req.Node, "region", req.Region, "az", req.AZ) // Validate request if req.Node == "" || req.Region == "" || req.AZ == "" { - return c.Status(400).JSON(config.NodeJoinResponse{ + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + _ = json.NewEncoder(w).Encode(config.NodeJoinResponse{ Success: false, Message: "node, region, and az are required", }) + return } // Check if node already exists if _, exists := d.clusterConfig.Nodes[req.Node]; exists { - return c.Status(409).JSON(config.NodeJoinResponse{ + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(409) + _ = json.NewEncoder(w).Encode(config.NodeJoinResponse{ Success: false, Message: fmt.Sprintf("node %s already exists in cluster", req.Node), }) + return } // Add new node to cluster config @@ -1095,10 +1103,13 @@ func (d *Daemon) ClusterManager() error { // Save updated config if err := d.saveClusterConfig(); err != nil { slog.Error("Failed to save cluster config", "error", err) - return c.Status(500).JSON(config.NodeJoinResponse{ + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + _ = json.NewEncoder(w).Encode(config.NodeJoinResponse{ Success: false, Message: "failed to save cluster config", }) + return } configHash, _ := d.computeConfigHash() @@ -1149,7 +1160,8 @@ func (d *Daemon) ClusterManager() error { slog.Warn("Could not read predastore.toml for join response", "path", predastorePath, "error", err) } - return c.JSON(config.NodeJoinResponse{ + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(config.NodeJoinResponse{ Success: true, Message: fmt.Sprintf("node %s successfully joined cluster", req.Node), SharedData: sharedData, @@ -1162,19 +1174,25 @@ func (d *Daemon) ClusterManager() error { }) // Get cluster config endpoint - d.clusterApp.Get("/config", func(c *fiber.Ctx) error { + r.Get("/config", func(w http.ResponseWriter, r *http.Request) { configHash, _ := d.computeConfigHash() - return c.JSON(fiber.Map{ + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ "config": d.clusterConfig, "config_hash": configHash, }) }) // Start HTTP server in goroutine + d.clusterServer = &http.Server{ + Addr: daemonHost, + Handler: r, + ReadHeaderTimeout: 10 * time.Second, + } go func() { slog.Info("Starting cluster manager", "host", daemonHost) - if err := d.clusterApp.Listen(daemonHost); err != nil { + if err := d.clusterServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { slog.Error("Cluster manager failed to start", "error", err) } }() @@ -1465,9 +1483,9 @@ func (d *Daemon) setupShutdown() { d.natsConn.Close() // Shutdown cluster manager - if d.clusterApp != nil { + if d.clusterServer != nil { slog.Info("Shutting down cluster manager...") - if err := d.clusterApp.Shutdown(); err != nil { + if err := d.clusterServer.Shutdown(context.Background()); err != nil { slog.Error("Error shutting down cluster manager", "err", err) } } diff --git a/hive/daemon/shutdown.go b/hive/daemon/shutdown.go index c815923c0..8322d7f16 100644 --- a/hive/daemon/shutdown.go +++ b/hive/daemon/shutdown.go @@ -1,6 +1,7 @@ package daemon import ( + "context" "encoding/json" "log/slog" "os" @@ -242,8 +243,8 @@ func (d *Daemon) handleShutdownInfra(msg *nats.Msg) { } // Shutdown cluster manager - if d.clusterApp != nil { - if err := d.clusterApp.Shutdown(); err != nil { + if d.clusterServer != nil { + if err := d.clusterServer.Shutdown(context.Background()); err != nil { slog.Warn("Failed to shutdown cluster manager", "error", err) } } diff --git a/hive/gateway/account.go b/hive/gateway/account.go index 5e73abb01..05e9e440a 100644 --- a/hive/gateway/account.go +++ b/hive/gateway/account.go @@ -1,8 +1,8 @@ package gateway -import "github.com/gofiber/fiber/v2" +import "net/http" -func (gw *GatewayConfig) Account_Request(ctx *fiber.Ctx) error { +func (gw *GatewayConfig) Account_Request(w http.ResponseWriter, r *http.Request) error { return nil } diff --git a/hive/gateway/auth.go b/hive/gateway/auth.go index f00e7a2de..107486930 100644 --- a/hive/gateway/auth.go +++ b/hive/gateway/auth.go @@ -1,14 +1,17 @@ package gateway import ( + "bytes" + "context" "crypto/subtle" "fmt" + "io" "log/slog" + "net/http" "sort" "strings" "time" - "github.com/gofiber/fiber/v2" "github.com/google/uuid" "github.com/mulgadc/hive/hive/awserrors" handlers_iam "github.com/mulgadc/hive/hive/handlers/iam" @@ -20,135 +23,163 @@ const ( maxClockSkew = 5 * time.Minute ) -// SigV4AuthMiddleware returns a Fiber middleware that validates AWS Signature V4 authentication. -// It verifies the Authorization header against the configured credentials. -func (gw *GatewayConfig) SigV4AuthMiddleware() fiber.Handler { - return func(c *fiber.Ctx) error { - // Skip OPTIONS requests (CORS preflight) - if c.Method() == "OPTIONS" { - return c.Next() - } - - authHeader := c.Get("Authorization") - if authHeader == "" { - return gw.writeSigV4Error(c, awserrors.ErrorMissingAuthenticationToken) - } - - // Parse the Authorization header - // Format: AWS4-HMAC-SHA256 Credential=ACCESS_KEY/DATE/REGION/SERVICE/aws4_request, SignedHeaders=..., Signature=... - if !strings.HasPrefix(authHeader, "AWS4-HMAC-SHA256 ") { - return gw.writeSigV4Error(c, awserrors.ErrorIncompleteSignature) - } - - // Extract components from the Authorization header - parts := strings.Split(authHeader[len("AWS4-HMAC-SHA256 "):], ", ") - if len(parts) != 3 { - return gw.writeSigV4Error(c, awserrors.ErrorIncompleteSignature) - } - - // Parse Credential - var accessKey, date, region, service string - if after, ok := strings.CutPrefix(parts[0], "Credential="); ok { - credParts := strings.Split(after, "/") - if len(credParts) != 5 || credParts[4] != "aws4_request" { - return gw.writeSigV4Error(c, awserrors.ErrorIncompleteSignature) +// SigV4AuthMiddleware returns stdlib middleware that validates AWS Signature V4 authentication. +func (gw *GatewayConfig) SigV4AuthMiddleware() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Skip OPTIONS requests (CORS preflight) + if r.Method == "OPTIONS" { + next.ServeHTTP(w, r) + return } - accessKey = credParts[0] - date = credParts[1] - region = credParts[2] - service = credParts[3] - } else { - return gw.writeSigV4Error(c, awserrors.ErrorIncompleteSignature) - } - // Parse SignedHeaders - var signedHeaders string - if after, ok := strings.CutPrefix(parts[1], "SignedHeaders="); ok { - signedHeaders = after - } else { - return gw.writeSigV4Error(c, awserrors.ErrorIncompleteSignature) - } - - // Parse Signature - var providedSignature string - if after, ok := strings.CutPrefix(parts[2], "Signature="); ok { - providedSignature = after - } else { - return gw.writeSigV4Error(c, awserrors.ErrorIncompleteSignature) - } - - // Lookup access key in IAM KV store - if gw.IAMService == nil { - slog.Error("SigV4 auth: IAM service not initialized") - return gw.writeSigV4Error(c, awserrors.ErrorInternalError) - } - - ak, err := gw.IAMService.LookupAccessKey(accessKey) - if err != nil { - if strings.Contains(err.Error(), awserrors.ErrorIAMNoSuchEntity) { - slog.Debug("Access key not found", "accessKeyID", accessKey) - return gw.writeSigV4Error(c, awserrors.ErrorInvalidClientTokenId) + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + gw.writeSigV4Error(w, r, awserrors.ErrorMissingAuthenticationToken) + return } - slog.Error("IAM lookup failed", "accessKeyID", accessKey, "err", err) - return gw.writeSigV4Error(c, awserrors.ErrorInternalError) - } - if ak.Status != handlers_iam.AccessKeyStatusActive { - slog.Debug("Access key inactive", "accessKeyID", accessKey) - return gw.writeSigV4Error(c, awserrors.ErrorInvalidClientTokenId) - } - secret, err := gw.IAMService.DecryptSecret(ak.SecretAccessKey) - if err != nil { - slog.Error("Failed to decrypt IAM secret", "accessKeyID", accessKey, "err", err) - return gw.writeSigV4Error(c, awserrors.ErrorInternalError) - } + // Parse the Authorization header + // Format: AWS4-HMAC-SHA256 Credential=ACCESS_KEY/DATE/REGION/SERVICE/aws4_request, SignedHeaders=..., Signature=... + if !strings.HasPrefix(authHeader, "AWS4-HMAC-SHA256 ") { + gw.writeSigV4Error(w, r, awserrors.ErrorIncompleteSignature) + return + } - // Get timestamp from X-Amz-Date header - timestamp := c.Get("X-Amz-Date") - if timestamp == "" { - return gw.writeSigV4Error(c, awserrors.ErrorIncompleteSignature) - } + // Extract components from the Authorization header + parts := strings.Split(authHeader[len("AWS4-HMAC-SHA256 "):], ", ") + if len(parts) != 3 { + gw.writeSigV4Error(w, r, awserrors.ErrorIncompleteSignature) + return + } - // Validate timestamp is within acceptable bounds to prevent replay attacks - parsedTime, err := time.Parse("20060102T150405Z", timestamp) - if err != nil { - slog.Debug("Invalid timestamp format", "timestamp", timestamp) - return gw.writeSigV4Error(c, awserrors.ErrorIncompleteSignature) - } - if time.Since(parsedTime).Abs() > maxClockSkew { - slog.Debug("Signature expired", "timestamp", timestamp, "skew", time.Since(parsedTime)) - return gw.writeSigV4Error(c, awserrors.ErrorSignatureDoesNotMatch) - } + // Parse Credential + var accessKey, date, region, service string + if after, ok := strings.CutPrefix(parts[0], "Credential="); ok { + credParts := strings.Split(after, "/") + if len(credParts) != 5 || credParts[4] != "aws4_request" { + gw.writeSigV4Error(w, r, awserrors.ErrorIncompleteSignature) + return + } + accessKey = credParts[0] + date = credParts[1] + region = credParts[2] + service = credParts[3] + } else { + gw.writeSigV4Error(w, r, awserrors.ErrorIncompleteSignature) + return + } - // Compute expected signature using decrypted secret - expectedSignature := computeSignatureWithSecret(c, secret, date, timestamp, region, service, signedHeaders) + // Parse SignedHeaders + var signedHeaders string + if after, ok := strings.CutPrefix(parts[1], "SignedHeaders="); ok { + signedHeaders = after + } else { + gw.writeSigV4Error(w, r, awserrors.ErrorIncompleteSignature) + return + } - // Compare signatures using constant-time comparison to prevent timing attacks - if subtle.ConstantTimeCompare([]byte(expectedSignature), []byte(providedSignature)) != 1 { - slog.Debug("Signature mismatch", - "expected", expectedSignature, - "provided", providedSignature, - ) - return gw.writeSigV4Error(c, awserrors.ErrorSignatureDoesNotMatch) - } + // Parse Signature + var providedSignature string + if after, ok := strings.CutPrefix(parts[2], "Signature="); ok { + providedSignature = after + } else { + gw.writeSigV4Error(w, r, awserrors.ErrorIncompleteSignature) + return + } - // Store parsed auth data in context for downstream handlers - c.Locals("sigv4.identity", ak.UserName) - c.Locals("sigv4.accountId", ak.AccountID) - c.Locals("sigv4.service", service) - c.Locals("sigv4.region", region) - c.Locals("sigv4.accessKey", accessKey) + // Lookup access key in IAM KV store + if gw.IAMService == nil { + slog.Error("SigV4 auth: IAM service not initialized") + gw.writeSigV4Error(w, r, awserrors.ErrorInternalError) + return + } - slog.Debug("SigV4 authentication successful", "accessKey", accessKey, "identity", ak.UserName) - return c.Next() + ak, err := gw.IAMService.LookupAccessKey(accessKey) + if err != nil { + if strings.Contains(err.Error(), awserrors.ErrorIAMNoSuchEntity) { + slog.Debug("Access key not found", "accessKeyID", accessKey) + gw.writeSigV4Error(w, r, awserrors.ErrorInvalidClientTokenId) + return + } + slog.Error("IAM lookup failed", "accessKeyID", accessKey, "err", err) + gw.writeSigV4Error(w, r, awserrors.ErrorInternalError) + return + } + if ak.Status != handlers_iam.AccessKeyStatusActive { + slog.Debug("Access key inactive", "accessKeyID", accessKey) + gw.writeSigV4Error(w, r, awserrors.ErrorInvalidClientTokenId) + return + } + + secret, err := gw.IAMService.DecryptSecret(ak.SecretAccessKey) + if err != nil { + slog.Error("Failed to decrypt IAM secret", "accessKeyID", accessKey, "err", err) + gw.writeSigV4Error(w, r, awserrors.ErrorInternalError) + return + } + + // Get timestamp from X-Amz-Date header + timestamp := r.Header.Get("X-Amz-Date") + if timestamp == "" { + gw.writeSigV4Error(w, r, awserrors.ErrorIncompleteSignature) + return + } + + // Validate timestamp is within acceptable bounds to prevent replay attacks + parsedTime, err := time.Parse("20060102T150405Z", timestamp) + if err != nil { + slog.Debug("Invalid timestamp format", "timestamp", timestamp) + gw.writeSigV4Error(w, r, awserrors.ErrorIncompleteSignature) + return + } + if time.Since(parsedTime).Abs() > maxClockSkew { + slog.Debug("Signature expired", "timestamp", timestamp, "skew", time.Since(parsedTime)) + gw.writeSigV4Error(w, r, awserrors.ErrorSignatureDoesNotMatch) + return + } + + // Read body once and re-buffer for downstream handlers + body, err := io.ReadAll(r.Body) + if err != nil { + slog.Error("Failed to read request body", "err", err) + gw.writeSigV4Error(w, r, awserrors.ErrorInternalError) + return + } + r.Body = io.NopCloser(bytes.NewReader(body)) + + // Compute expected signature using decrypted secret + expectedSignature := computeSignatureWithSecret(r, body, secret, date, timestamp, region, service, signedHeaders) + + // Compare signatures using constant-time comparison to prevent timing attacks + if subtle.ConstantTimeCompare([]byte(expectedSignature), []byte(providedSignature)) != 1 { + slog.Debug("Signature mismatch", + "expected", expectedSignature, + "provided", providedSignature, + ) + gw.writeSigV4Error(w, r, awserrors.ErrorSignatureDoesNotMatch) + return + } + + // Store parsed auth data in context for downstream handlers + ctx := r.Context() + ctx = context.WithValue(ctx, ctxIdentity, ak.UserName) + ctx = context.WithValue(ctx, ctxAccountID, ak.AccountID) + ctx = context.WithValue(ctx, ctxService, service) + ctx = context.WithValue(ctx, ctxRegion, region) + ctx = context.WithValue(ctx, ctxAccessKey, accessKey) + + slog.Debug("SigV4 authentication successful", "accessKey", accessKey, "identity", ak.UserName) + next.ServeHTTP(w, r.WithContext(ctx)) + }) } } // computeSignatureWithSecret builds the canonical request and computes the AWS Signature V4 signature -// using the provided secret key. -func computeSignatureWithSecret(c *fiber.Ctx, secretKey, date, timestamp, region, service, signedHeaders string) string { +// using the provided secret key. The body is passed explicitly since it has already been read from r.Body. +func computeSignatureWithSecret(r *http.Request, body []byte, secretKey, date, timestamp, region, service, signedHeaders string) string { // Build canonical URI (URI-encoded path) - path := c.Path() + path := r.URL.Path if path == "" { path = "/" } @@ -158,7 +189,7 @@ func computeSignatureWithSecret(c *fiber.Ctx, secretKey, date, timestamp, region } // Build canonical query string (sorted, encoded) - canonicalQueryString := buildCanonicalQueryString(string(c.Request().URI().QueryString())) + canonicalQueryString := buildCanonicalQueryString(r.URL.RawQuery) // Build canonical headers from SignedHeaders list headersList := strings.Split(signedHeaders, ";") @@ -169,9 +200,9 @@ func computeSignatureWithSecret(c *fiber.Ctx, secretKey, date, timestamp, region header = strings.ToLower(strings.TrimSpace(header)) var value string if header == "host" { - value = string(c.Request().Host()) + value = r.Host } else { - value = c.Get(canonicalHeaderName(header)) + value = r.Header.Get(canonicalHeaderName(header)) } // Trim leading/trailing whitespace and collapse multiple spaces value = strings.TrimSpace(value) @@ -182,12 +213,12 @@ func computeSignatureWithSecret(c *fiber.Ctx, secretKey, date, timestamp, region } // Hash payload body with SHA256 - payloadHash := auth.HashSHA256(string(c.Body())) + payloadHash := auth.HashSHA256(string(body)) // Build canonical request canonicalRequest := fmt.Sprintf( "%s\n%s\n%s\n%s\n%s\n%s", - c.Method(), + r.Method, canonicalURI, canonicalQueryString, canonicalHeaders.String(), @@ -259,7 +290,7 @@ func buildCanonicalQueryString(queryString string) string { // canonicalHeaderName converts a lowercase header name to the canonical form for lookup. func canonicalHeaderName(header string) string { - // Convert header names like "x-amz-date" to "X-Amz-Date" for Fiber's Get method + // Convert header names like "x-amz-date" to "X-Amz-Date" for http.Header.Get parts := strings.Split(header, "-") for i, part := range parts { if len(part) > 0 { @@ -270,8 +301,11 @@ func canonicalHeaderName(header string) string { } // writeSigV4Error writes an EC2-compatible XML error response for authentication failures. -func (gw *GatewayConfig) writeSigV4Error(c *fiber.Ctx, errorCode string) error { - requestID := c.Get("x-amz-request-id", uuid.NewString()) +func (gw *GatewayConfig) writeSigV4Error(w http.ResponseWriter, r *http.Request, errorCode string) { + requestID := r.Header.Get("x-amz-request-id") + if requestID == "" { + requestID = uuid.NewString() + } errorMsg, exists := awserrors.ErrorLookup[errorCode] if !exists { @@ -280,6 +314,7 @@ func (gw *GatewayConfig) writeSigV4Error(c *fiber.Ctx, errorCode string) error { xmlError := GenerateEC2ErrorResponse(errorCode, errorMsg.Message, requestID) - c.Set("Content-Type", "application/xml") - return c.Status(errorMsg.HTTPCode).Send(xmlError) + w.Header().Set("Content-Type", "application/xml") + w.WriteHeader(errorMsg.HTTPCode) + _, _ = w.Write(xmlError) } diff --git a/hive/gateway/auth_test.go b/hive/gateway/auth_test.go index 86c81ead0..12ae761a2 100644 --- a/hive/gateway/auth_test.go +++ b/hive/gateway/auth_test.go @@ -4,13 +4,14 @@ import ( "errors" "fmt" "io" + "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/aws/aws-sdk-go/service/iam" - "github.com/gofiber/fiber/v2" + "github.com/go-chi/chi/v5" "github.com/mulgadc/hive/hive/awserrors" handlers_iam "github.com/mulgadc/hive/hive/handlers/iam" "github.com/mulgadc/hive/hive/utils" @@ -174,7 +175,7 @@ func generateTestAuthHeader(method, path, queryString, body, accessKey, secretKe return authHeader, timestamp } -func setupTestApp(accessKey, secretKey string) *fiber.App { +func setupTestApp(accessKey, secretKey string) http.Handler { encryptedSecret, err := handlers_iam.EncryptSecret(secretKey, testMasterKey) if err != nil { panic("failed to encrypt test secret: " + err.Error()) @@ -198,27 +199,22 @@ func setupTestApp(accessKey, secretKey string) *fiber.App { IAMService: mockSvc, } - app := fiber.New() - app.Use(gw.SigV4AuthMiddleware()) - - // Simple test handler - app.All("/*", func(c *fiber.Ctx) error { - return c.SendString("OK") + r := chi.NewRouter() + r.Use(gw.SigV4AuthMiddleware()) + r.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("OK")) }) - return app + return r } func TestSigV4Auth_NoAuthorizationHeader(t *testing.T) { - app := setupTestApp(testAccessKey, testSecretKey) + handler := setupTestApp(testAccessKey, testSecretKey) req := httptest.NewRequest("GET", "/", nil) req.Host = "localhost:9999" - resp, err := app.Test(req) - if err != nil { - t.Fatalf("Failed to test request: %v", err) - } + resp := doRequest(handler, req) if resp.StatusCode != 403 { t.Errorf("Expected status 403, got %d", resp.StatusCode) @@ -231,7 +227,7 @@ func TestSigV4Auth_NoAuthorizationHeader(t *testing.T) { } func TestSigV4Auth_MalformedHeader(t *testing.T) { - app := setupTestApp(testAccessKey, testSecretKey) + handler := setupTestApp(testAccessKey, testSecretKey) testCases := []struct { name string @@ -251,10 +247,7 @@ func TestSigV4Auth_MalformedHeader(t *testing.T) { req.Header.Set("Authorization", tc.authHeader) req.Header.Set("X-Amz-Date", time.Now().UTC().Format(auth.TimeFormat)) - resp, err := app.Test(req) - if err != nil { - t.Fatalf("Failed to test request: %v", err) - } + resp := doRequest(handler, req) if resp.StatusCode != 400 { t.Errorf("Expected status 400, got %d", resp.StatusCode) @@ -269,7 +262,7 @@ func TestSigV4Auth_MalformedHeader(t *testing.T) { } func TestSigV4Auth_InvalidAccessKey(t *testing.T) { - app := setupTestApp(testAccessKey, testSecretKey) + handler := setupTestApp(testAccessKey, testSecretKey) authHeader, timestamp := generateTestAuthHeader( "GET", "/", "", "", @@ -281,10 +274,7 @@ func TestSigV4Auth_InvalidAccessKey(t *testing.T) { req.Header.Set("Authorization", authHeader) req.Header.Set("X-Amz-Date", timestamp) - resp, err := app.Test(req) - if err != nil { - t.Fatalf("Failed to test request: %v", err) - } + resp := doRequest(handler, req) if resp.StatusCode != 403 { t.Errorf("Expected status 403, got %d", resp.StatusCode) @@ -297,7 +287,7 @@ func TestSigV4Auth_InvalidAccessKey(t *testing.T) { } func TestSigV4Auth_InvalidSignature(t *testing.T) { - app := setupTestApp(testAccessKey, testSecretKey) + handler := setupTestApp(testAccessKey, testSecretKey) // Generate auth header with wrong secret key authHeader, timestamp := generateTestAuthHeader( @@ -310,10 +300,7 @@ func TestSigV4Auth_InvalidSignature(t *testing.T) { req.Header.Set("Authorization", authHeader) req.Header.Set("X-Amz-Date", timestamp) - resp, err := app.Test(req) - if err != nil { - t.Fatalf("Failed to test request: %v", err) - } + resp := doRequest(handler, req) if resp.StatusCode != 403 { t.Errorf("Expected status 403, got %d", resp.StatusCode) @@ -326,7 +313,7 @@ func TestSigV4Auth_InvalidSignature(t *testing.T) { } func TestSigV4Auth_ValidSignature(t *testing.T) { - app := setupTestApp(testAccessKey, testSecretKey) + handler := setupTestApp(testAccessKey, testSecretKey) authHeader, timestamp := generateTestAuthHeader( "GET", "/", "", "", @@ -338,10 +325,7 @@ func TestSigV4Auth_ValidSignature(t *testing.T) { req.Header.Set("Authorization", authHeader) req.Header.Set("X-Amz-Date", timestamp) - resp, err := app.Test(req) - if err != nil { - t.Fatalf("Failed to test request: %v", err) - } + resp := doRequest(handler, req) if resp.StatusCode != 200 { body, _ := io.ReadAll(resp.Body) @@ -350,16 +334,13 @@ func TestSigV4Auth_ValidSignature(t *testing.T) { } func TestSigV4Auth_OptionsSkipsAuth(t *testing.T) { - app := setupTestApp(testAccessKey, testSecretKey) + handler := setupTestApp(testAccessKey, testSecretKey) req := httptest.NewRequest("OPTIONS", "/", nil) req.Host = "localhost:9999" // No Authorization header - resp, err := app.Test(req) - if err != nil { - t.Fatalf("Failed to test request: %v", err) - } + resp := doRequest(handler, req) if resp.StatusCode != 200 { t.Errorf("Expected status 200 for OPTIONS, got %d", resp.StatusCode) @@ -367,7 +348,7 @@ func TestSigV4Auth_OptionsSkipsAuth(t *testing.T) { } func TestSigV4Auth_ValidSignatureWithBody(t *testing.T) { - app := setupTestApp(testAccessKey, testSecretKey) + handler := setupTestApp(testAccessKey, testSecretKey) body := "Action=DescribeInstances&Version=2016-11-15" authHeader, timestamp := generateTestAuthHeader( @@ -381,10 +362,7 @@ func TestSigV4Auth_ValidSignatureWithBody(t *testing.T) { req.Header.Set("X-Amz-Date", timestamp) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - resp, err := app.Test(req) - if err != nil { - t.Fatalf("Failed to test request: %v", err) - } + resp := doRequest(handler, req) if resp.StatusCode != 200 { respBody, _ := io.ReadAll(resp.Body) @@ -393,7 +371,7 @@ func TestSigV4Auth_ValidSignatureWithBody(t *testing.T) { } func TestSigV4Auth_ValidSignatureWithQueryString(t *testing.T) { - app := setupTestApp(testAccessKey, testSecretKey) + handler := setupTestApp(testAccessKey, testSecretKey) queryString := "Action=DescribeInstances&Version=2016-11-15" authHeader, timestamp := generateTestAuthHeader( @@ -406,10 +384,7 @@ func TestSigV4Auth_ValidSignatureWithQueryString(t *testing.T) { req.Header.Set("Authorization", authHeader) req.Header.Set("X-Amz-Date", timestamp) - resp, err := app.Test(req) - if err != nil { - t.Fatalf("Failed to test request: %v", err) - } + resp := doRequest(handler, req) if resp.StatusCode != 200 { respBody, _ := io.ReadAll(resp.Body) @@ -441,10 +416,10 @@ func TestSigV4Auth_InactiveAccessKey(t *testing.T) { IAMService: mockSvc, } - app := fiber.New() - app.Use(gw.SigV4AuthMiddleware()) - app.All("/*", func(c *fiber.Ctx) error { - return c.SendString("OK") + r := chi.NewRouter() + r.Use(gw.SigV4AuthMiddleware()) + r.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("OK")) }) authHeader, timestamp := generateTestAuthHeader( @@ -457,10 +432,7 @@ func TestSigV4Auth_InactiveAccessKey(t *testing.T) { req.Header.Set("Authorization", authHeader) req.Header.Set("X-Amz-Date", timestamp) - resp, err := app.Test(req) - if err != nil { - t.Fatalf("Failed to test request: %v", err) - } + resp := doRequest(r, req) if resp.StatusCode != 403 { t.Errorf("Expected status 403 for inactive key, got %d", resp.StatusCode) @@ -479,10 +451,10 @@ func TestSigV4Auth_NilIAMService(t *testing.T) { IAMService: nil, } - app := fiber.New() - app.Use(gw.SigV4AuthMiddleware()) - app.All("/*", func(c *fiber.Ctx) error { - return c.SendString("OK") + r := chi.NewRouter() + r.Use(gw.SigV4AuthMiddleware()) + r.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("OK")) }) authHeader, timestamp := generateTestAuthHeader( @@ -495,10 +467,7 @@ func TestSigV4Auth_NilIAMService(t *testing.T) { req.Header.Set("Authorization", authHeader) req.Header.Set("X-Amz-Date", timestamp) - resp, err := app.Test(req) - if err != nil { - t.Fatalf("Failed to test request: %v", err) - } + resp := doRequest(r, req) if resp.StatusCode != 500 { t.Errorf("Expected status 500 for nil IAM service, got %d", resp.StatusCode) @@ -534,11 +503,11 @@ func TestSigV4Auth_CallerIdentity(t *testing.T) { IAMService: mockSvc, } - app := fiber.New() - app.Use(gw.SigV4AuthMiddleware()) - app.All("/*", func(c *fiber.Ctx) error { - identity := c.Locals("sigv4.identity") - return c.SendString(fmt.Sprintf("identity=%v", identity)) + r := chi.NewRouter() + r.Use(gw.SigV4AuthMiddleware()) + r.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) { + identity := r.Context().Value(ctxIdentity) + fmt.Fprintf(w, "identity=%v", identity) }) authHeader, timestamp := generateTestAuthHeader( @@ -551,10 +520,7 @@ func TestSigV4Auth_CallerIdentity(t *testing.T) { req.Header.Set("Authorization", authHeader) req.Header.Set("X-Amz-Date", timestamp) - resp, err := app.Test(req) - if err != nil { - t.Fatalf("Failed to test request: %v", err) - } + resp := doRequest(r, req) if resp.StatusCode != 200 { body, _ := io.ReadAll(resp.Body) @@ -647,10 +613,10 @@ func TestSigV4Auth_DecryptFailure(t *testing.T) { IAMService: mockSvc, } - app := fiber.New() - app.Use(gw.SigV4AuthMiddleware()) - app.All("/*", func(c *fiber.Ctx) error { - return c.SendString("OK") + r := chi.NewRouter() + r.Use(gw.SigV4AuthMiddleware()) + r.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("OK")) }) authHeader, timestamp := generateTestAuthHeader( @@ -663,10 +629,7 @@ func TestSigV4Auth_DecryptFailure(t *testing.T) { req.Header.Set("Authorization", authHeader) req.Header.Set("X-Amz-Date", timestamp) - resp, err := app.Test(req) - if err != nil { - t.Fatalf("Failed to test request: %v", err) - } + resp := doRequest(r, req) if resp.StatusCode != 500 { t.Errorf("Expected status 500 for decrypt failure, got %d", resp.StatusCode) @@ -783,7 +746,7 @@ func generateTestAuthHeaderWithSignedHeaders(method, path, queryString, body, ac // --- Clock Skew / Replay Protection Tests --- func TestSigV4Auth_ExpiredTimestamp(t *testing.T) { - app := setupTestApp(testAccessKey, testSecretKey) + handler := setupTestApp(testAccessKey, testSecretKey) // 6 minutes in the past — exceeds the 5-minute maxClockSkew past := time.Now().UTC().Add(-6 * time.Minute) @@ -797,10 +760,7 @@ func TestSigV4Auth_ExpiredTimestamp(t *testing.T) { req.Header.Set("Authorization", authHeader) req.Header.Set("X-Amz-Date", timestamp) - resp, err := app.Test(req) - if err != nil { - t.Fatalf("Failed to test request: %v", err) - } + resp := doRequest(handler, req) if resp.StatusCode != 403 { t.Errorf("Expected status 403, got %d", resp.StatusCode) @@ -813,7 +773,7 @@ func TestSigV4Auth_ExpiredTimestamp(t *testing.T) { } func TestSigV4Auth_FutureTimestamp(t *testing.T) { - app := setupTestApp(testAccessKey, testSecretKey) + handler := setupTestApp(testAccessKey, testSecretKey) // 6 minutes in the future — exceeds the 5-minute maxClockSkew future := time.Now().UTC().Add(6 * time.Minute) @@ -827,10 +787,7 @@ func TestSigV4Auth_FutureTimestamp(t *testing.T) { req.Header.Set("Authorization", authHeader) req.Header.Set("X-Amz-Date", timestamp) - resp, err := app.Test(req) - if err != nil { - t.Fatalf("Failed to test request: %v", err) - } + resp := doRequest(handler, req) if resp.StatusCode != 403 { t.Errorf("Expected status 403, got %d", resp.StatusCode) @@ -843,7 +800,7 @@ func TestSigV4Auth_FutureTimestamp(t *testing.T) { } func TestSigV4Auth_TimestampWithinSkew(t *testing.T) { - app := setupTestApp(testAccessKey, testSecretKey) + handler := setupTestApp(testAccessKey, testSecretKey) // 4 minutes ago — within the 5-minute window recent := time.Now().UTC().Add(-4 * time.Minute) @@ -857,10 +814,7 @@ func TestSigV4Auth_TimestampWithinSkew(t *testing.T) { req.Header.Set("Authorization", authHeader) req.Header.Set("X-Amz-Date", timestamp) - resp, err := app.Test(req) - if err != nil { - t.Fatalf("Failed to test request: %v", err) - } + resp := doRequest(handler, req) if resp.StatusCode != 200 { body, _ := io.ReadAll(resp.Body) @@ -869,7 +823,7 @@ func TestSigV4Auth_TimestampWithinSkew(t *testing.T) { } func TestSigV4Auth_MissingXAmzDate(t *testing.T) { - app := setupTestApp(testAccessKey, testSecretKey) + handler := setupTestApp(testAccessKey, testSecretKey) authHeader, _ := generateTestAuthHeader( "GET", "/", "", "", @@ -881,10 +835,7 @@ func TestSigV4Auth_MissingXAmzDate(t *testing.T) { req.Header.Set("Authorization", authHeader) // Deliberately omit X-Amz-Date - resp, err := app.Test(req) - if err != nil { - t.Fatalf("Failed to test request: %v", err) - } + resp := doRequest(handler, req) if resp.StatusCode != 400 { t.Errorf("Expected status 400, got %d", resp.StatusCode) @@ -897,7 +848,7 @@ func TestSigV4Auth_MissingXAmzDate(t *testing.T) { } func TestSigV4Auth_MalformedTimestamp(t *testing.T) { - app := setupTestApp(testAccessKey, testSecretKey) + handler := setupTestApp(testAccessKey, testSecretKey) authHeader, _ := generateTestAuthHeader( "GET", "/", "", "", @@ -909,10 +860,7 @@ func TestSigV4Auth_MalformedTimestamp(t *testing.T) { req.Header.Set("Authorization", authHeader) req.Header.Set("X-Amz-Date", "not-a-valid-date") - resp, err := app.Test(req) - if err != nil { - t.Fatalf("Failed to test request: %v", err) - } + resp := doRequest(handler, req) if resp.StatusCode != 400 { t.Errorf("Expected status 400, got %d", resp.StatusCode) @@ -951,18 +899,18 @@ func TestSigV4Auth_ContextPropagation(t *testing.T) { IAMService: mockSvc, } - app := fiber.New() - app.Use(gw.SigV4AuthMiddleware()) - app.All("/*", func(c *fiber.Ctx) error { - identity := c.Locals("sigv4.identity") - accountId := c.Locals("sigv4.accountId") - service := c.Locals("sigv4.service") - region := c.Locals("sigv4.region") - accessKey := c.Locals("sigv4.accessKey") - return c.SendString(fmt.Sprintf( + r := chi.NewRouter() + r.Use(gw.SigV4AuthMiddleware()) + r.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) { + identity := r.Context().Value(ctxIdentity) + accountId := r.Context().Value(ctxAccountID) + service := r.Context().Value(ctxService) + region := r.Context().Value(ctxRegion) + accessKey := r.Context().Value(ctxAccessKey) + fmt.Fprintf(w, "identity=%v|accountId=%v|service=%v|region=%v|accessKey=%v", identity, accountId, service, region, accessKey, - )) + ) }) authHeader, timestamp := generateTestAuthHeader( @@ -975,10 +923,7 @@ func TestSigV4Auth_ContextPropagation(t *testing.T) { req.Header.Set("Authorization", authHeader) req.Header.Set("X-Amz-Date", timestamp) - resp, err := app.Test(req) - if err != nil { - t.Fatalf("Failed to test request: %v", err) - } + resp := doRequest(r, req) if resp.StatusCode != 200 { body, _ := io.ReadAll(resp.Body) @@ -1015,10 +960,10 @@ func TestSigV4Auth_LookupAccessKeyUnexpectedError(t *testing.T) { IAMService: &errorLookupMockIAMService{err: errors.New("nats: connection closed")}, } - app := fiber.New() - app.Use(gw.SigV4AuthMiddleware()) - app.All("/*", func(c *fiber.Ctx) error { - return c.SendString("OK") + r := chi.NewRouter() + r.Use(gw.SigV4AuthMiddleware()) + r.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("OK")) }) authHeader, timestamp := generateTestAuthHeader( @@ -1031,10 +976,7 @@ func TestSigV4Auth_LookupAccessKeyUnexpectedError(t *testing.T) { req.Header.Set("Authorization", authHeader) req.Header.Set("X-Amz-Date", timestamp) - resp, err := app.Test(req) - if err != nil { - t.Fatalf("Failed to test request: %v", err) - } + resp := doRequest(r, req) if resp.StatusCode != 500 { t.Errorf("Expected status 500 for unexpected lookup error, got %d", resp.StatusCode) @@ -1049,33 +991,31 @@ func TestSigV4Auth_LookupAccessKeyUnexpectedError(t *testing.T) { // --- Signature Edge Cases --- func TestSigV4Auth_PathWithSpecialCharacters(t *testing.T) { - app := setupTestApp(testAccessKey, testSecretKey) + handler := setupTestApp(testAccessKey, testSecretKey) testCases := []struct { - name string - path string + name string + signPath string // decoded path used for signing + reqPath string // percent-encoded path for the HTTP request line }{ - {"encoded space", "/my%20resource"}, - {"nested slashes", "/a/b/c/d"}, - {"trailing slash", "/path/"}, + {"encoded space", "/my resource", "/my%20resource"}, + {"nested slashes", "/a/b/c/d", "/a/b/c/d"}, + {"trailing slash", "/path/", "/path/"}, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { authHeader, timestamp := generateTestAuthHeader( - "GET", tc.path, "", "", + "GET", tc.signPath, "", "", testAccessKey, testSecretKey, testRegion, testService, ) - req := httptest.NewRequest("GET", tc.path, nil) + req := httptest.NewRequest("GET", tc.reqPath, nil) req.Host = "localhost:9999" req.Header.Set("Authorization", authHeader) req.Header.Set("X-Amz-Date", timestamp) - resp, err := app.Test(req) - if err != nil { - t.Fatalf("Failed to test request: %v", err) - } + resp := doRequest(handler, req) if resp.StatusCode != 200 { body, _ := io.ReadAll(resp.Body) @@ -1110,10 +1050,10 @@ func TestSigV4Auth_SignedContentType(t *testing.T) { IAMService: mockSvc, } - app := fiber.New() - app.Use(gw.SigV4AuthMiddleware()) - app.All("/*", func(c *fiber.Ctx) error { - return c.SendString("OK") + r := chi.NewRouter() + r.Use(gw.SigV4AuthMiddleware()) + r.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("OK")) }) body := "Action=DescribeInstances" @@ -1137,10 +1077,7 @@ func TestSigV4Auth_SignedContentType(t *testing.T) { req.Header.Set("X-Amz-Date", timestamp) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - resp, err := app.Test(req) - if err != nil { - t.Fatalf("Failed to test request: %v", err) - } + resp := doRequest(r, req) if resp.StatusCode != 200 { respBody, _ := io.ReadAll(resp.Body) @@ -1149,7 +1086,7 @@ func TestSigV4Auth_SignedContentType(t *testing.T) { } func TestSigV4Auth_EmptyBodyPOST(t *testing.T) { - app := setupTestApp(testAccessKey, testSecretKey) + handler := setupTestApp(testAccessKey, testSecretKey) // POST with empty body — payload hash is hash of "" authHeader, timestamp := generateTestAuthHeader( @@ -1162,10 +1099,7 @@ func TestSigV4Auth_EmptyBodyPOST(t *testing.T) { req.Header.Set("Authorization", authHeader) req.Header.Set("X-Amz-Date", timestamp) - resp, err := app.Test(req) - if err != nil { - t.Fatalf("Failed to test request: %v", err) - } + resp := doRequest(handler, req) if resp.StatusCode != 200 { body, _ := io.ReadAll(resp.Body) @@ -1174,7 +1108,7 @@ func TestSigV4Auth_EmptyBodyPOST(t *testing.T) { } func TestSigV4Auth_QueryStringEdgeCases(t *testing.T) { - app := setupTestApp(testAccessKey, testSecretKey) + handler := setupTestApp(testAccessKey, testSecretKey) testCases := []struct { name string @@ -1197,10 +1131,7 @@ func TestSigV4Auth_QueryStringEdgeCases(t *testing.T) { req.Header.Set("Authorization", authHeader) req.Header.Set("X-Amz-Date", timestamp) - resp, err := app.Test(req) - if err != nil { - t.Fatalf("Failed to test request: %v", err) - } + resp := doRequest(handler, req) if resp.StatusCode != 200 { body, _ := io.ReadAll(resp.Body) @@ -1235,21 +1166,18 @@ func (m *errorLookupMockIAMService) LookupAccessKey(_ string) (*handlers_iam.Acc return nil, m.err } -// setupPolicyTestApp creates a Fiber app that authenticates with SigV4 then calls checkPolicy. -func setupPolicyTestApp(gw *GatewayConfig) *fiber.App { - app := fiber.New(fiber.Config{ - ErrorHandler: func(ctx *fiber.Ctx, err error) error { - return gw.ErrorHandler(ctx, err) - }, - }) - app.Use(gw.SigV4AuthMiddleware()) - app.All("/*", func(c *fiber.Ctx) error { - if err := gw.checkPolicy(c, "ec2", "RunInstances"); err != nil { - return err +// setupPolicyTestHandler creates an http.Handler that authenticates with SigV4 then calls checkPolicy. +func setupPolicyTestHandler(gw *GatewayConfig) http.Handler { + r := chi.NewRouter() + r.Use(gw.SigV4AuthMiddleware()) + r.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) { + if err := gw.checkPolicy(r, "ec2", "RunInstances"); err != nil { + gw.ErrorHandler(w, r, err) + return } - return c.SendString("OK") + w.Write([]byte("OK")) }) - return app + return r } func TestCheckPolicy_NonRootNoPolicies_Denied(t *testing.T) { @@ -1282,7 +1210,7 @@ func TestCheckPolicy_NonRootNoPolicies_Denied(t *testing.T) { IAMService: mockSvc, } - app := setupPolicyTestApp(gw) + handler := setupPolicyTestHandler(gw) authHeader, timestamp := generateTestAuthHeader( "GET", "/", "", "", @@ -1294,10 +1222,7 @@ func TestCheckPolicy_NonRootNoPolicies_Denied(t *testing.T) { req.Header.Set("Authorization", authHeader) req.Header.Set("X-Amz-Date", timestamp) - resp, err := app.Test(req) - if err != nil { - t.Fatalf("Failed to test request: %v", err) - } + resp := doRequest(handler, req) if resp.StatusCode != 403 { body, _ := io.ReadAll(resp.Body) @@ -1351,7 +1276,7 @@ func TestCheckPolicy_NonRootWithAllow_Passes(t *testing.T) { IAMService: mockSvc, } - app := setupPolicyTestApp(gw) + handler := setupPolicyTestHandler(gw) authHeader, timestamp := generateTestAuthHeader( "GET", "/", "", "", @@ -1363,10 +1288,7 @@ func TestCheckPolicy_NonRootWithAllow_Passes(t *testing.T) { req.Header.Set("Authorization", authHeader) req.Header.Set("X-Amz-Date", timestamp) - resp, err := app.Test(req) - if err != nil { - t.Fatalf("Failed to test request: %v", err) - } + resp := doRequest(handler, req) if resp.StatusCode != 200 { body, _ := io.ReadAll(resp.Body) @@ -1420,7 +1342,7 @@ func TestCheckPolicy_NonRootWithExplicitDeny_Denied(t *testing.T) { IAMService: mockSvc, } - app := setupPolicyTestApp(gw) + handler := setupPolicyTestHandler(gw) authHeader, timestamp := generateTestAuthHeader( "GET", "/", "", "", @@ -1432,10 +1354,7 @@ func TestCheckPolicy_NonRootWithExplicitDeny_Denied(t *testing.T) { req.Header.Set("Authorization", authHeader) req.Header.Set("X-Amz-Date", timestamp) - resp, err := app.Test(req) - if err != nil { - t.Fatalf("Failed to test request: %v", err) - } + resp := doRequest(handler, req) if resp.StatusCode != 403 { body, _ := io.ReadAll(resp.Body) @@ -1490,7 +1409,7 @@ func TestCheckPolicy_RootGlobalAccount_Bypasses(t *testing.T) { IAMService: mockSvc, } - app := setupPolicyTestApp(gw) + handler := setupPolicyTestHandler(gw) authHeader, timestamp := generateTestAuthHeader( "GET", "/", "", "", @@ -1502,10 +1421,7 @@ func TestCheckPolicy_RootGlobalAccount_Bypasses(t *testing.T) { req.Header.Set("Authorization", authHeader) req.Header.Set("X-Amz-Date", timestamp) - resp, err := app.Test(req) - if err != nil { - t.Fatalf("Failed to test request: %v", err) - } + resp := doRequest(handler, req) if resp.StatusCode != 200 { body, _ := io.ReadAll(resp.Body) @@ -1541,7 +1457,7 @@ func TestCheckPolicy_MissingAccountID_InternalError(t *testing.T) { IAMService: mockSvc, } - app := setupPolicyTestApp(gw) + handler := setupPolicyTestHandler(gw) authHeader, timestamp := generateTestAuthHeader( "GET", "/", "", "", @@ -1553,10 +1469,7 @@ func TestCheckPolicy_MissingAccountID_InternalError(t *testing.T) { req.Header.Set("Authorization", authHeader) req.Header.Set("X-Amz-Date", timestamp) - resp, err := app.Test(req) - if err != nil { - t.Fatalf("Failed to test request: %v", err) - } + resp := doRequest(handler, req) if resp.StatusCode != 500 { body, _ := io.ReadAll(resp.Body) @@ -1594,7 +1507,7 @@ func TestCheckPolicy_GetUserPoliciesError_InternalError(t *testing.T) { IAMService: mockSvc, } - app := setupPolicyTestApp(gw) + handler := setupPolicyTestHandler(gw) authHeader, timestamp := generateTestAuthHeader( "GET", "/", "", "", @@ -1606,10 +1519,7 @@ func TestCheckPolicy_GetUserPoliciesError_InternalError(t *testing.T) { req.Header.Set("Authorization", authHeader) req.Header.Set("X-Amz-Date", timestamp) - resp, err := app.Test(req) - if err != nil { - t.Fatalf("Failed to test request: %v", err) - } + resp := doRequest(handler, req) if resp.StatusCode != 500 { body, _ := io.ReadAll(resp.Body) @@ -1649,7 +1559,7 @@ func TestCheckPolicy_RootNonGlobalAccount_StillEvaluated(t *testing.T) { IAMService: mockSvc, } - app := setupPolicyTestApp(gw) + handler := setupPolicyTestHandler(gw) authHeader, timestamp := generateTestAuthHeader( "GET", "/", "", "", @@ -1661,10 +1571,7 @@ func TestCheckPolicy_RootNonGlobalAccount_StillEvaluated(t *testing.T) { req.Header.Set("Authorization", authHeader) req.Header.Set("X-Amz-Date", timestamp) - resp, err := app.Test(req) - if err != nil { - t.Fatalf("Failed to test request: %v", err) - } + resp := doRequest(handler, req) // root on non-global account is evaluated by the policy engine like any // other user. With no policies attached, the default deny applies. diff --git a/hive/gateway/ec2.go b/hive/gateway/ec2.go index dd9780e10..46dc4eb25 100644 --- a/hive/gateway/ec2.go +++ b/hive/gateway/ec2.go @@ -4,11 +4,12 @@ package gateway import ( "errors" + "io" "log/slog" + "net/http" "strings" "github.com/aws/aws-sdk-go/service/ec2" - "github.com/gofiber/fiber/v2" "github.com/mulgadc/hive/hive/awsec2query" "github.com/mulgadc/hive/hive/awserrors" gateway_ec2_account "github.com/mulgadc/hive/hive/gateway/ec2/account" @@ -229,8 +230,9 @@ var ec2LocalActions = map[string]bool{ "DescribeAccountAttributes": true, } -func (gw *GatewayConfig) EC2_Request(ctx *fiber.Ctx) error { - queryArgs := ParseAWSQueryArgs(string(ctx.Body())) +func (gw *GatewayConfig) EC2_Request(w http.ResponseWriter, r *http.Request) error { + body, _ := io.ReadAll(r.Body) + queryArgs := ParseAWSQueryArgs(string(body)) action := queryArgs["Action"] handler, ok := ec2Actions[action] @@ -238,7 +240,7 @@ func (gw *GatewayConfig) EC2_Request(ctx *fiber.Ctx) error { return errors.New(awserrors.ErrorInvalidAction) } - if err := gw.checkPolicy(ctx, "ec2", action); err != nil { + if err := gw.checkPolicy(r, "ec2", action); err != nil { return err } @@ -247,7 +249,7 @@ func (gw *GatewayConfig) EC2_Request(ctx *fiber.Ctx) error { } // Extract account ID from auth context - accountID, _ := ctx.Locals("sigv4.accountId").(string) + accountID, _ := r.Context().Value(ctxAccountID).(string) if accountID == "" { slog.Error("EC2_Request: no account ID in auth context") return errors.New(awserrors.ErrorServerInternal) @@ -258,5 +260,8 @@ func (gw *GatewayConfig) EC2_Request(ctx *fiber.Ctx) error { return err } - return ctx.Status(fiber.StatusOK).Type("text/xml").Send(xmlOutput) + w.Header().Set("Content-Type", "text/xml") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(xmlOutput) + return nil } diff --git a/hive/gateway/gateway.go b/hive/gateway/gateway.go index 85459b6c5..5f51beaa9 100644 --- a/hive/gateway/gateway.go +++ b/hive/gateway/gateway.go @@ -6,14 +6,13 @@ import ( "errors" "fmt" "log/slog" + "net/http" "net/url" "os" "strings" "time" - "github.com/gofiber/fiber/v2" - "github.com/gofiber/fiber/v2/middleware/cors" - "github.com/gofiber/fiber/v2/middleware/logger" + "github.com/go-chi/chi/v5" "github.com/google/uuid" "github.com/mulgadc/hive/hive/awsec2query" "github.com/mulgadc/hive/hive/awserrors" @@ -23,6 +22,17 @@ import ( "github.com/nats-io/nats.go" ) +// contextKey is a typed key for storing values in request context. +type contextKey string + +const ( + ctxIdentity contextKey = "sigv4.identity" + ctxAccountID contextKey = "sigv4.accountId" + ctxService contextKey = "sigv4.service" + ctxRegion contextKey = "sigv4.region" + ctxAccessKey contextKey = "sigv4.accessKey" +) + type GatewayConfig struct { Debug bool `json:"debug"` DisableLogging bool `json:"disable_logging"` @@ -55,7 +65,7 @@ type ErrorDetail struct { Message error `xml:"Message"` } -func (gw *GatewayConfig) SetupRoutes() *fiber.App { +func (gw *GatewayConfig) SetupRoutes() http.Handler { var logLevel slog.Level @@ -77,49 +87,37 @@ func (gw *GatewayConfig) SetupRoutes() *fiber.App { // Set it as the default logger slog.SetDefault(slogger) - // Configure slog for logging - slog.New(slog.NewTextHandler(os.Stdout, nil)) - - app := fiber.New(fiber.Config{ - - // Disable the startup banner - DisableStartupMessage: gw.DisableLogging, - - // Set the body limit for S3 specs to 5GiB - //BodyLimit: 5 * 1024 * 1024 * 1024, - - // Override default error handler - ErrorHandler: func(ctx *fiber.Ctx, err error) error { - return gw.ErrorHandler(ctx, err) - }}, - ) + r := chi.NewRouter() if !gw.DisableLogging { - app.Use(logger.New()) + r.Use(slogRequestLogger) } - // Add CORS middleware for browser requests - app.Use(cors.New(cors.Config{ - AllowOrigins: "https://localhost:3000", - AllowMethods: "GET,POST,PUT,DELETE,HEAD,OPTIONS", - AllowHeaders: "*", - AllowCredentials: true, - })) + // CORS middleware for browser requests + r.Use(corsMiddleware) - // Add AWS SigV4 authentication middleware - app.Use(gw.SigV4AuthMiddleware()) - // Define routes - app.Get("/*", func(c *fiber.Ctx) error { + // AWS SigV4 authentication middleware + r.Use(gw.SigV4AuthMiddleware()) - return gw.Request(c) + // Catch-all routes + r.HandleFunc("/*", gw.Request) + + return r +} + +// corsMiddleware adds CORS headers for browser requests from the dev UI. +func corsMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "https://localhost:3000") + w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,HEAD,OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "*") + w.Header().Set("Access-Control-Allow-Credentials", "true") + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, r) }) - - app.Post("/*", func(c *fiber.Ctx) error { - - return gw.Request(c) - }) - - return app } // Note, custom endpoints can be configured via ENV vars to the AWS SDK/CLI tool, with individual endpoints depending the service @@ -127,41 +125,39 @@ func (gw *GatewayConfig) SetupRoutes() *fiber.App { // aws --endpoint-url https://localhost:9999/ --no-verify-ssl eks list-clusters // AWS_ENDPOINT_URL=https://localhost:9999/ aws --no-verify-ssl ec2 describe-instances -func (gw *GatewayConfig) Request(ctx *fiber.Ctx) error { +func (gw *GatewayConfig) Request(w http.ResponseWriter, r *http.Request) { // Route the request to the appropriate endpoint (e.g EC2, IAM, etc) - svc, err := gw.GetService(ctx) - slog.Info("Request", "service", svc, "method", ctx.Method(), "path", ctx.Path()) + svc, err := gw.GetService(r) + slog.Info("Request", "service", svc, "method", r.Method, "path", r.URL.Path) if err != nil { slog.Error("GetService error", "error", err) - return gw.ErrorHandler(ctx, err) + gw.ErrorHandler(w, r, err) + return } switch svc { case "ec2": - err = gw.EC2_Request(ctx) + err = gw.EC2_Request(w, r) case "account": - err = gw.Account_Request(ctx) + err = gw.Account_Request(w, r) case "iam": - err = gw.IAM_Request(ctx) + err = gw.IAM_Request(w, r) default: err = errors.New(awserrors.ErrorUnsupportedOperation) } if err != nil { slog.Error("Service request error", "service", svc, "error", err) - return gw.ErrorHandler(ctx, err) + gw.ErrorHandler(w, r, err) } else { slog.Info("Service request completed", "service", svc) } - - return nil - } -func (gw *GatewayConfig) GetService(ctx *fiber.Ctx) (string, error) { - svc, ok := ctx.Locals("sigv4.service").(string) +func (gw *GatewayConfig) GetService(r *http.Request) (string, error) { + svc, ok := r.Context().Value(ctxService).(string) if !ok { return "", errors.New(awserrors.ErrorAuthFailure) } @@ -176,14 +172,14 @@ func (gw *GatewayConfig) GetService(ctx *fiber.Ctx) (string, error) { // if access is allowed, or an ErrorAccessDenied error if denied. // Root users bypass evaluation entirely. If the IAM service is unavailable, // access is allowed (pre-IAM compatibility). -func (gw *GatewayConfig) checkPolicy(ctx *fiber.Ctx, service, action string) error { +func (gw *GatewayConfig) checkPolicy(r *http.Request, service, action string) error { if gw.IAMService == nil { slog.Warn("checkPolicy: IAM service not available, skipping policy check", "service", service, "action", action) return nil } - identityVal := ctx.Locals("sigv4.identity") + identityVal := r.Context().Value(ctxIdentity) if identityVal == nil { // No auth context — pre-IAM compatibility return nil @@ -194,7 +190,7 @@ func (gw *GatewayConfig) checkPolicy(ctx *fiber.Ctx, service, action string) err return errors.New(awserrors.ErrorInternalError) } // Extract account ID from auth context - accountID, _ := ctx.Locals("sigv4.accountId").(string) + accountID, _ := r.Context().Value(ctxAccountID).(string) if accountID == "" { slog.Error("checkPolicy: no account ID in auth context", "user", identity) return errors.New(awserrors.ErrorInternalError) @@ -221,13 +217,15 @@ func (gw *GatewayConfig) checkPolicy(ctx *fiber.Ctx, service, action string) err return nil } -func (gw *GatewayConfig) ErrorHandler(ctx *fiber.Ctx, err error) error { - svc, _ := gw.GetService(ctx) +func (gw *GatewayConfig) ErrorHandler(w http.ResponseWriter, r *http.Request, err error) { + svc, _ := gw.GetService(r) slog.Debug("ErrorHandler", "service", svc, "error", err.Error()) // Get the request ID var requestId = uuid.NewString() - requestId = ctx.Get("x-amz-request-id", requestId) + if rid := r.Header.Get("x-amz-request-id"); rid != "" { + requestId = rid + } var errorMsg = awserrors.ErrorMessage{} @@ -253,8 +251,9 @@ func (gw *GatewayConfig) ErrorHandler(ctx *fiber.Ctx, err error) error { errorMsg.HTTPCode = 500 } - ctx.Set("Content-Type", "application/xml") - return ctx.Status(errorMsg.HTTPCode).Send(xmlError) + w.Header().Set("Content-Type", "application/xml") + w.WriteHeader(errorMsg.HTTPCode) + _, _ = w.Write(xmlError) } // Parse AWS query arguments (used by some services like EC2/S3) @@ -419,3 +418,24 @@ func (gw *GatewayConfig) DiscoverActiveNodes() int { slog.Debug("DiscoverActiveNodes: Discovered active nodes", "count", activeNodes) return activeNodes } + +// statusWriter wraps http.ResponseWriter to capture the status code. +type statusWriter struct { + http.ResponseWriter + status int +} + +func (w *statusWriter) WriteHeader(code int) { + w.status = code + w.ResponseWriter.WriteHeader(code) +} + +// SlogRequestLogger is a middleware that logs each request using slog. +func slogRequestLogger(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + ww := &statusWriter{ResponseWriter: w, status: 200} + next.ServeHTTP(ww, r) + slog.Info("request", "method", r.Method, "path", r.URL.Path, "status", ww.status, "duration", time.Since(start)) + }) +} diff --git a/hive/gateway/gateway_test.go b/hive/gateway/gateway_test.go index e8eeb5bb9..d55639b09 100644 --- a/hive/gateway/gateway_test.go +++ b/hive/gateway/gateway_test.go @@ -1,16 +1,17 @@ package gateway import ( + "context" "encoding/json" "encoding/xml" "errors" "io" + "net/http" "net/http/httptest" "strings" "testing" "time" - "github.com/gofiber/fiber/v2" "github.com/mulgadc/hive/hive/awserrors" "github.com/nats-io/nats-server/v2/server" "github.com/nats-io/nats.go" @@ -18,6 +19,13 @@ import ( "github.com/stretchr/testify/require" ) +// doRequest sends a request through an http.Handler and returns the response. +func doRequest(handler http.Handler, req *http.Request) *http.Response { + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + return w.Result() +} + func TestGenerateEC2ErrorResponse_Structure(t *testing.T) { tests := []struct { name string @@ -156,19 +164,16 @@ func TestGenerateIAMErrorResponse_ValidXML(t *testing.T) { func TestErrorHandler_IAMService(t *testing.T) { gw := &GatewayConfig{DisableLogging: true} - app := fiber.New(fiber.Config{ - ErrorHandler: func(ctx *fiber.Ctx, err error) error { - return gw.ErrorHandler(ctx, err) - }, - }) - app.Get("/", func(c *fiber.Ctx) error { - c.Locals("sigv4.service", "iam") - return errors.New(awserrors.ErrorIAMNoSuchEntity) + + // Build a handler that sets service context and returns an IAM error + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := context.WithValue(r.Context(), ctxService, "iam") + r = r.WithContext(ctx) + gw.ErrorHandler(w, r, errors.New(awserrors.ErrorIAMNoSuchEntity)) }) req := httptest.NewRequest("GET", "/", nil) - resp, err := app.Test(req) - require.NoError(t, err) + resp := doRequest(handler, req) assert.Equal(t, 404, resp.StatusCode) body, _ := io.ReadAll(resp.Body) diff --git a/hive/gateway/iam.go b/hive/gateway/iam.go index 30a3ffb79..7f8e5c5fe 100644 --- a/hive/gateway/iam.go +++ b/hive/gateway/iam.go @@ -2,10 +2,11 @@ package gateway import ( "errors" + "io" "log/slog" + "net/http" "github.com/aws/aws-sdk-go/service/iam" - "github.com/gofiber/fiber/v2" "github.com/mulgadc/hive/hive/awsec2query" "github.com/mulgadc/hive/hive/awserrors" gateway_iam "github.com/mulgadc/hive/hive/gateway/iam" @@ -92,8 +93,9 @@ var iamActions = map[string]IAMHandler{ }), } -func (gw *GatewayConfig) IAM_Request(ctx *fiber.Ctx) error { - queryArgs := ParseAWSQueryArgs(string(ctx.Body())) +func (gw *GatewayConfig) IAM_Request(w http.ResponseWriter, r *http.Request) error { + body, _ := io.ReadAll(r.Body) + queryArgs := ParseAWSQueryArgs(string(body)) action := queryArgs["Action"] handler, ok := iamActions[action] @@ -107,12 +109,12 @@ func (gw *GatewayConfig) IAM_Request(ctx *fiber.Ctx) error { return errors.New(awserrors.ErrorInternalError) } - if err := gw.checkPolicy(ctx, "iam", action); err != nil { + if err := gw.checkPolicy(r, "iam", action); err != nil { return err } // Extract account ID from auth context - accountID, _ := ctx.Locals("sigv4.accountId").(string) + accountID, _ := r.Context().Value(ctxAccountID).(string) if accountID == "" { slog.Error("IAM_Request: no account ID in auth context") return errors.New(awserrors.ErrorInternalError) @@ -123,5 +125,8 @@ func (gw *GatewayConfig) IAM_Request(ctx *fiber.Ctx) error { return err } - return ctx.Status(fiber.StatusOK).Type("text/xml").Send(xmlOutput) + w.Header().Set("Content-Type", "text/xml") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(xmlOutput) + return nil } diff --git a/hive/gateway/iam_request_test.go b/hive/gateway/iam_request_test.go index 73f18b13d..2b1e06547 100644 --- a/hive/gateway/iam_request_test.go +++ b/hive/gateway/iam_request_test.go @@ -1,19 +1,19 @@ package gateway import ( + "context" "errors" "io" + "net/http" "net/http/httptest" "strings" "testing" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/iam" - "github.com/gofiber/fiber/v2" "github.com/mulgadc/hive/hive/awserrors" handlers_iam "github.com/mulgadc/hive/hive/handlers/iam" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) // flexMockIAMService is a configurable mock with per-method overrides. @@ -134,23 +134,22 @@ func (m *flexMockIAMService) CreateAccount(_ string) (*handlers_iam.Account, err func (m *flexMockIAMService) GetAccount(_ string) (*handlers_iam.Account, error) { return nil, nil } func (m *flexMockIAMService) ListAccounts() ([]*handlers_iam.Account, error) { return nil, nil } -// setupIAMRequestApp creates a Fiber app wired for IAM_Request testing. -func setupIAMRequestApp(svc handlers_iam.IAMService) *fiber.App { +// setupIAMRequestHandler creates an http.Handler wired for IAM_Request testing. +// It injects sigv4 context values so individual tests don't need SigV4 auth. +func setupIAMRequestHandler(svc handlers_iam.IAMService) http.Handler { gw := &GatewayConfig{ DisableLogging: true, IAMService: svc, } - app := fiber.New(fiber.Config{ - ErrorHandler: func(ctx *fiber.Ctx, err error) error { - return gw.ErrorHandler(ctx, err) - }, + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + ctx = context.WithValue(ctx, ctxService, "iam") + ctx = context.WithValue(ctx, ctxAccountID, "000000000000") + r = r.WithContext(ctx) + if err := gw.IAM_Request(w, r); err != nil { + gw.ErrorHandler(w, r, err) + } }) - app.Post("/", func(c *fiber.Ctx) error { - c.Locals("sigv4.service", "iam") - c.Locals("sigv4.accountId", "000000000000") - return gw.IAM_Request(c) - }) - return app } func TestIAMRequest_CreateUser_Success(t *testing.T) { @@ -166,13 +165,12 @@ func TestIAMRequest_CreateUser_Success(t *testing.T) { }, nil }, } - app := setupIAMRequestApp(svc) + handler := setupIAMRequestHandler(svc) req := httptest.NewRequest("POST", "/", strings.NewReader("Action=CreateUser&UserName=alice")) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - resp, err := app.Test(req) - require.NoError(t, err) + resp := doRequest(handler, req) assert.Equal(t, 200, resp.StatusCode) body, _ := io.ReadAll(resp.Body) @@ -193,13 +191,12 @@ func TestIAMRequest_ListUsers_Success(t *testing.T) { }, nil }, } - app := setupIAMRequestApp(svc) + handler := setupIAMRequestHandler(svc) req := httptest.NewRequest("POST", "/", strings.NewReader("Action=ListUsers")) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - resp, err := app.Test(req) - require.NoError(t, err) + resp := doRequest(handler, req) assert.Equal(t, 200, resp.StatusCode) body, _ := io.ReadAll(resp.Body) @@ -210,13 +207,12 @@ func TestIAMRequest_ListUsers_Success(t *testing.T) { } func TestIAMRequest_UnknownAction(t *testing.T) { - app := setupIAMRequestApp(&flexMockIAMService{}) + handler := setupIAMRequestHandler(&flexMockIAMService{}) req := httptest.NewRequest("POST", "/", strings.NewReader("Action=DoesNotExist")) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - resp, err := app.Test(req) - require.NoError(t, err) + resp := doRequest(handler, req) assert.Equal(t, 400, resp.StatusCode) body, _ := io.ReadAll(resp.Body) @@ -224,13 +220,12 @@ func TestIAMRequest_UnknownAction(t *testing.T) { } func TestIAMRequest_EmptyAction(t *testing.T) { - app := setupIAMRequestApp(&flexMockIAMService{}) + handler := setupIAMRequestHandler(&flexMockIAMService{}) req := httptest.NewRequest("POST", "/", strings.NewReader("UserName=alice")) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - resp, err := app.Test(req) - require.NoError(t, err) + resp := doRequest(handler, req) assert.Equal(t, 400, resp.StatusCode) body, _ := io.ReadAll(resp.Body) @@ -242,22 +237,20 @@ func TestIAMRequest_NilService(t *testing.T) { DisableLogging: true, IAMService: nil, } - app := fiber.New(fiber.Config{ - ErrorHandler: func(ctx *fiber.Ctx, err error) error { - return gw.ErrorHandler(ctx, err) - }, - }) - app.Post("/", func(c *fiber.Ctx) error { - c.Locals("sigv4.service", "iam") - c.Locals("sigv4.accountId", "000000000000") - return gw.IAM_Request(c) + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + ctx = context.WithValue(ctx, ctxService, "iam") + ctx = context.WithValue(ctx, ctxAccountID, "000000000000") + r = r.WithContext(ctx) + if err := gw.IAM_Request(w, r); err != nil { + gw.ErrorHandler(w, r, err) + } }) req := httptest.NewRequest("POST", "/", strings.NewReader("Action=CreateUser&UserName=alice")) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - resp, err := app.Test(req) - require.NoError(t, err) + resp := doRequest(handler, req) assert.Equal(t, 500, resp.StatusCode) body, _ := io.ReadAll(resp.Body) @@ -270,13 +263,12 @@ func TestIAMRequest_ServiceError(t *testing.T) { return nil, errors.New(awserrors.ErrorIAMEntityAlreadyExists) }, } - app := setupIAMRequestApp(svc) + handler := setupIAMRequestHandler(svc) req := httptest.NewRequest("POST", "/", strings.NewReader("Action=CreateUser&UserName=alice")) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - resp, err := app.Test(req) - require.NoError(t, err) + resp := doRequest(handler, req) assert.Equal(t, 409, resp.StatusCode) body, _ := io.ReadAll(resp.Body) @@ -287,13 +279,12 @@ func TestIAMRequest_ServiceError(t *testing.T) { func TestIAMRequest_ValidationError(t *testing.T) { // CreateUser with missing UserName should return MissingParameter - app := setupIAMRequestApp(&flexMockIAMService{}) + handler := setupIAMRequestHandler(&flexMockIAMService{}) req := httptest.NewRequest("POST", "/", strings.NewReader("Action=CreateUser")) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - resp, err := app.Test(req) - require.NoError(t, err) + resp := doRequest(handler, req) assert.Equal(t, 400, resp.StatusCode) body, _ := io.ReadAll(resp.Body) diff --git a/hive/services/awsgw/awsgw.go b/hive/services/awsgw/awsgw.go index 863b8c070..ad607c1a7 100644 --- a/hive/services/awsgw/awsgw.go +++ b/hive/services/awsgw/awsgw.go @@ -1,8 +1,10 @@ package awsgw import ( + "crypto/tls" "fmt" "log/slog" + "net/http" "os" "path/filepath" "strings" @@ -113,15 +115,31 @@ func launchService(config *config.ClusterConfig) error { IAMService: iamService, } - app := gw.SetupRoutes() + handler := gw.SetupRoutes() - if err := app.ListenTLS(nodeConfig.AWSGW.Host, nodeConfig.AWSGW.TLSCert, nodeConfig.AWSGW.TLSKey); err != nil { + // Load TLS certificate + cert, err := tls.LoadX509KeyPair(nodeConfig.AWSGW.TLSCert, nodeConfig.AWSGW.TLSKey) + if err != nil { + return fmt.Errorf("load TLS cert: %w", err) + } + + server := &http.Server{ + Addr: nodeConfig.AWSGW.Host, + Handler: handler, + ReadHeaderTimeout: 10 * time.Second, + TLSConfig: &tls.Config{ + Certificates: []tls.Certificate{cert}, + NextProtos: []string{"h2", "http/1.1"}, + }, + } + + slog.Info("AWS Gateway listening", "addr", nodeConfig.AWSGW.Host) + if err := server.ListenAndServeTLS("", ""); err != nil { slog.Error("Failed to start TLS listener", "err", err) os.Exit(1) } return nil - } // connectNATS establishes a connection to NATS with retry/backoff. On concurrent