mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-22 20:26:44 +00:00
feat: migrate off fiber (#62)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user