feat: migrate off fiber (#62)

This commit is contained in:
Julian Sommer
2026-03-05 11:43:25 +10:00
parent 49c5807ef1
commit 8af3d2e5fa
13 changed files with 498 additions and 510 deletions
+1 -6
View File
@@ -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
-12
View File
@@ -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=
+44 -26
View File
@@ -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)
}
}
+3 -2
View File
@@ -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)
}
}
+2 -2
View File
@@ -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
}
+160 -125
View File
@@ -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)
}
+114 -207
View File
@@ -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.
+11 -6
View File
@@ -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
}
+81 -61
View File
@@ -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))
})
}
+16 -11
View File
@@ -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)
+11 -6
View File
@@ -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
}
+34 -43
View File
@@ -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)
+21 -3
View File
@@ -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