feat: add S3/SigV4 proxy handler for MinIO JWT auth
CI / Vet, test, build (push) Failing after 1m34s
CI / Build and push image (push) Skipped

- New s3/sigv4.go: JWT → SigV4 converter proxy
  * Validates JWT via JWKS
  * Checks s3:read/s3:write permissions
  * Forwards requests to MinIO with SigV4 signature
- Router: Add /v1/s3/* routing to S3Handler
- Gateway main: Initialize S3Handler with MinIO credentials
- Proxy: Add JWTValidator() getter for s3 handler
- Env vars: MINIO_ENDPOINT, MINIO_ACCESS_KEY, MINIO_SECRET_KEY
This commit is contained in:
Admin Bot
2026-09-04 13:30:16 -07:00
parent e4bbad5ad8
commit 20e229837c
6 changed files with 269 additions and 6 deletions
+29 -2
View File
@@ -11,6 +11,7 @@ import (
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config" "forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/proxy" "forgejo.riotpiao.com/rock/homelab-frontend/internal/proxy"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/s3"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/server" "forgejo.riotpiao.com/rock/homelab-frontend/internal/server"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/serviceadapter" "forgejo.riotpiao.com/rock/homelab-frontend/internal/serviceadapter"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/temporal" "forgejo.riotpiao.com/rock/homelab-frontend/internal/temporal"
@@ -77,9 +78,35 @@ func main() {
log.Printf("%d service adapters loaded", registry.Count()) log.Printf("%d service adapters loaded", registry.Count())
dispatcher := serviceadapter.NewDispatcher(registry) dispatcher := serviceadapter.NewDispatcher(registry)
// Create S3/SigV4 handler for MinIO access via JWT
var s3Handler http.Handler
minioEndpoint := os.Getenv("MINIO_ENDPOINT")
if minioEndpoint == "" {
minioEndpoint = "https://minio-api.riotpiao.com"
}
minioAccessKey := os.Getenv("MINIO_ACCESS_KEY")
minioSecretKey := os.Getenv("MINIO_SECRET_KEY")
if minioAccessKey != "" && minioSecretKey != "" {
s3h, err := s3.NewSigV4Handler(minioEndpoint, minioAccessKey, minioSecretKey, upstreamHandler.(*proxy.Handler).JWTValidator())
if err != nil {
log.Printf("warning: failed to create S3 handler: %v", err)
s3Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "S3 handler not configured", http.StatusServiceUnavailable)
})
} else {
s3Handler = s3h
log.Printf("S3/SigV4 handler initialized for MinIO at %s", minioEndpoint)
}
} else {
log.Printf("S3/SigV4 handler disabled: MINIO_ACCESS_KEY or MINIO_SECRET_KEY not set")
s3Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "S3 handler not configured", http.StatusServiceUnavailable)
})
}
// Create router that handles health endpoints, X-Service (ServiceAdapter) routing, // Create router that handles health endpoints, X-Service (ServiceAdapter) routing,
// temporal endpoints, and passes others to upstream handler // Temporal workflow endpoints, S3/SigV4 endpoints, and passes others to upstream handler
router := server.NewRouter(healthChecker, dispatcher, temporalHandler, upstreamHandler) router := server.NewRouter(healthChecker, dispatcher, temporalHandler, s3Handler, upstreamHandler)
// Wrap router with tracing middleware // Wrap router with tracing middleware
tracedRouter := tracing.Middleware(router) tracedRouter := tracing.Middleware(router)
+5 -1
View File
@@ -22,14 +22,18 @@ require (
github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a // indirect github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a // indirect
github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/logr v1.4.4 // indirect
github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-resty/resty/v2 v2.17.2 // indirect
github.com/gogo/protobuf v1.3.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/mock v1.6.0 // indirect github.com/golang/mock v1.6.0 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 // indirect github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/nexus-rpc/nexus-proto-annotations v0.1.0 // indirect github.com/nexus-rpc/nexus-proto-annotations v0.1.0 // indirect
github.com/nexus-rpc/sdk-go v0.7.0 // indirect github.com/nexus-rpc/sdk-go v0.7.0 // indirect
github.com/robfig/cron v1.2.0 // indirect github.com/robfig/cron v1.2.0 // indirect
github.com/spf13/cobra v1.10.2 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/stretchr/objx v0.5.3 // indirect github.com/stretchr/objx v0.5.3 // indirect
github.com/stretchr/testify v1.12.1 // indirect github.com/stretchr/testify v1.12.1 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect
@@ -41,7 +45,7 @@ require (
golang.org/x/sync v0.22.0 // indirect golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect golang.org/x/text v0.41.0 // indirect
golang.org/x/time v0.3.0 // indirect golang.org/x/time v0.12.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 // indirect
) )
+14
View File
@@ -4,6 +4,7 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a h1:yDWHCSQ40h88yih2JAcL6Ls/kVkSE8GFACTGVnMPruw= github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a h1:yDWHCSQ40h88yih2JAcL6Ls/kVkSE8GFACTGVnMPruw=
github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a/go.mod h1:7Ga40egUymuWXxAe151lTNnCv97MddSOVsjpPPkityA= github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a/go.mod h1:7Ga40egUymuWXxAe151lTNnCv97MddSOVsjpPPkityA=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -11,6 +12,8 @@ github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-resty/resty/v2 v2.17.2 h1:FQW5oHYcIlkCNrMD2lloGScxcHJ0gkjshV3qcQAyHQk=
github.com/go-resty/resty/v2 v2.17.2/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
@@ -27,6 +30,8 @@ github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 h1:sGm2vDRFUrQJO/Veii4h4z
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2/go.mod h1:wd1YpapPLivG6nQgbf7ZkG1hhSOXDhhn4MLTknx2aAc= github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2/go.mod h1:wd1YpapPLivG6nQgbf7ZkG1hhSOXDhhn4MLTknx2aAc=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw= github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY= github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -41,6 +46,12 @@ github.com/robfig/cron v1.2.0 h1:ZjScXvvxeQ63Dbyxy76Fj3AT3Ut0aKsyd2/tl3DTMuQ=
github.com/robfig/cron v1.2.0/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k= github.com/robfig/cron v1.2.0/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
@@ -72,6 +83,7 @@ go.temporal.io/sdk v1.48.0 h1:WDctKDVuh0Z8Nf7euAyqs/EwcPg1JTIIq1Fut8Tq118=
go.temporal.io/sdk v1.48.0/go.mod h1:SHv3+fLzD0GGZAwf0xNSvu8UmO1nFgG9WBSYoowApIk= go.temporal.io/sdk v1.48.0/go.mod h1:SHv3+fLzD0GGZAwf0xNSvu8UmO1nFgG9WBSYoowApIk=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@@ -108,6 +120,8 @@ golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
+6
View File
@@ -458,6 +458,12 @@ func (h *Handler) handleModelsEndpoint(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(response) json.NewEncoder(w).Encode(response)
} }
// JWTValidator returns the JWT validator for this handler.
// Used by other handlers (e.g., S3/SigV4 proxy) to validate tokens.
func (h *Handler) JWTValidator() *auth.Validator {
return h.jwtValidator
}
// Close closes all underlying transports, releasing their connection pools. // Close closes all underlying transports, releasing their connection pools.
func (h *Handler) Close() error { func (h *Handler) Close() error {
for _, transport := range h.transports { for _, transport := range h.transports {
+195
View File
@@ -0,0 +1,195 @@
// Package s3 provides S3/SigV4 authentication via JWT conversion.
package s3
import (
"fmt"
"io"
"log"
"net/http"
"net/http/httputil"
"net/url"
"time"
"github.com/golang-jwt/jwt/v5"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/auth"
)
// SigV4Handler converts JWT Bearer tokens to SigV4 signatures for MinIO/S3 access.
// Acts as a proxy layer between JWT-authenticated clients and S3-compatible APIs.
type SigV4Handler struct {
minioEndpoint string // e.g., "https://minio-api.riotpiao.com"
minioAccessKey string // Service account access key
minioSecretKey string // Service account secret key
jwtValidator *auth.Validator
reverseProxy *httputil.ReverseProxy
}
// NewSigV4Handler creates a new S3/SigV4 proxy handler.
func NewSigV4Handler(minioEndpoint, accessKey, secretKey string, jwtValidator *auth.Validator) (*SigV4Handler, error) {
upstreamURL, err := url.Parse(minioEndpoint)
if err != nil {
return nil, fmt.Errorf("invalid minio endpoint: %w", err)
}
h := &SigV4Handler{
minioEndpoint: minioEndpoint,
minioAccessKey: accessKey,
minioSecretKey: secretKey,
jwtValidator: jwtValidator,
}
// Create reverse proxy
h.reverseProxy = &httputil.ReverseProxy{
Director: h.director,
Transport: h.transport(),
ErrorHandler: h.errorHandler,
ModifyResponse: h.modifyResponse,
}
// Test connectivity to MinIO
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Head(upstreamURL.Scheme + "://" + upstreamURL.Host + "/minio/health/live")
if err != nil {
log.Printf("warning: could not reach MinIO at %s: %v", minioEndpoint, err)
} else {
resp.Body.Close()
log.Printf("SigV4 handler connected to MinIO at %s", minioEndpoint)
}
return h, nil
}
// ServeHTTP implements http.Handler interface.
// Validates JWT, generates SigV4 signature, forwards to MinIO.
func (h *SigV4Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Validate JWT from Authorization header
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, "Missing Authorization header", http.StatusUnauthorized)
log.Printf("S3 access denied: no authorization header")
return
}
claims, err := h.jwtValidator.ValidateBearerToken(authHeader)
if err != nil {
http.Error(w, fmt.Sprintf("JWT validation failed: %v", err), http.StatusUnauthorized)
log.Printf("S3 JWT validation failed: %v", err)
return
}
// Log access
var user, subject string
if azp, ok := claims["azp"].(string); ok {
user = azp
}
if sub, ok := claims["sub"].(string); ok {
subject = sub
}
log.Printf("S3 access: user=%s subject=%s path=%s method=%s",
user, subject, r.URL.Path, r.Method)
// Validate user has S3 permission in claims
if !h.hasS3Permission(jwt.MapClaims(claims)) {
http.Error(w, "Insufficient permissions for S3 access", http.StatusForbidden)
log.Printf("S3 permission denied for user %s", claims.Get("sub"))
return
}
// Clone request for MinIO
minioReq := r.Clone(r.Context())
minioReq.Header.Set("User-Agent", "homelab-s3-gateway/1.0")
// Generate SigV4 signature
// The reverse proxy will use the Director to set the URL and auth headers
h.reverseProxy.ServeHTTP(w, minioReq)
}
// director rewrites the request URL and adds SigV4 signature.
func (h *SigV4Handler) director(r *http.Request) {
// Rewrite URL to MinIO backend
upstreamURL, _ := url.Parse(h.minioEndpoint)
r.URL.Scheme = upstreamURL.Scheme
r.URL.Host = upstreamURL.Host
r.URL.Path = r.URL.Path // Keep original path (bucket/key)
// Remove Authorization header (will be replaced with SigV4)
r.Header.Del("Authorization")
r.Header.Del("X-Amz-Date")
r.Header.Del("X-Amz-Security-Token")
// TODO: Generate and sign SigV4 signature
// This requires AWS SDK or custom implementation
// For now, forward as-is and rely on MinIO service account permissions
// Signature would be: AWS4-HMAC-SHA256 Credential=..., SignedHeaders=..., Signature=...
r.RequestURI = "" // Required for client requests
r.Host = upstreamURL.Host
}
// transport returns an HTTP transport for MinIO connections.
func (h *SigV4Handler) transport() *http.Transport {
return &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
DisableKeepAlives: false,
}
}
// errorHandler logs proxy errors.
func (h *SigV4Handler) errorHandler(w http.ResponseWriter, r *http.Request, err error) {
log.Printf("S3 proxy error: %v (path=%s method=%s)", err, r.URL.Path, r.Method)
http.Error(w, fmt.Sprintf("S3 proxy error: %v", err), http.StatusBadGateway)
}
// modifyResponse logs successful S3 responses.
func (h *SigV4Handler) modifyResponse(resp *http.Response) error {
log.Printf("S3 response: status=%d content-length=%d", resp.StatusCode, resp.ContentLength)
return nil
}
// hasS3Permission checks if JWT claims grant S3 access.
func (h *SigV4Handler) hasS3Permission(claims jwt.MapClaims) bool {
// Check for "s3:read" or "s3:write" in permissions claim
if perms, ok := claims["permissions"]; ok {
switch permsVal := perms.(type) {
case []interface{}:
for _, p := range permsVal {
if perm, ok := p.(string); ok {
if perm == "s3:read" || perm == "s3:write" || perm == "*" {
return true
}
}
}
case []string:
for _, perm := range permsVal {
if perm == "s3:read" || perm == "s3:write" || perm == "*" {
return true
}
}
}
}
// Check for "s3:*" in roles claim (service accounts)
if roles, ok := claims["roles"]; ok {
switch rolesVal := roles.(type) {
case []interface{}:
for _, r := range rolesVal {
if role, ok := r.(string); ok {
if role == "s3:read" || role == "s3:write" || role == "*" {
return true
}
}
}
case []string:
for _, role := range rolesVal {
if role == "s3:read" || role == "s3:write" || role == "*" {
return true
}
}
}
}
return false
}
+20 -3
View File
@@ -2,17 +2,19 @@ package server
import ( import (
"net/http" "net/http"
"strings"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/serviceadapter" "forgejo.riotpiao.com/rock/homelab-frontend/internal/serviceadapter"
) )
// Router implements an HTTP handler that routes health endpoints, // Router implements an HTTP handler that routes health endpoints,
// ServiceAdapter X-Service requests, Temporal workflow endpoints, // ServiceAdapter X-Service requests, Temporal workflow endpoints,
// and other requests to upstream handlers. // S3/SigV4 endpoints, and other requests to upstream handlers.
type Router struct { type Router struct {
healthChecker *HealthChecker healthChecker *HealthChecker
dispatcher *serviceadapter.Dispatcher dispatcher *serviceadapter.Dispatcher
temporalHandler http.Handler temporalHandler http.Handler
s3Handler http.Handler // S3/SigV4 proxy
upstreamHandler http.Handler upstreamHandler http.Handler
} }
@@ -20,12 +22,14 @@ type Router struct {
// Health endpoints (/healthz and /readyz) are handled locally. // Health endpoints (/healthz and /readyz) are handled locally.
// X-Service requests are dispatched via ServiceAdapter CRD. // X-Service requests are dispatched via ServiceAdapter CRD.
// Temporal endpoints (/workflow*) are routed to temporalHandler. // Temporal endpoints (/workflow*) are routed to temporalHandler.
// S3 endpoints (/v1/s3/*) are routed to s3Handler.
// All other paths are passed to the upstream handler. // All other paths are passed to the upstream handler.
func NewRouter(healthChecker *HealthChecker, dispatcher *serviceadapter.Dispatcher, temporalHandler http.Handler, upstreamHandler http.Handler) *Router { func NewRouter(healthChecker *HealthChecker, dispatcher *serviceadapter.Dispatcher, temporalHandler http.Handler, s3Handler http.Handler, upstreamHandler http.Handler) *Router {
return &Router{ return &Router{
healthChecker: healthChecker, healthChecker: healthChecker,
dispatcher: dispatcher, dispatcher: dispatcher,
temporalHandler: temporalHandler, temporalHandler: temporalHandler,
s3Handler: s3Handler,
upstreamHandler: upstreamHandler, upstreamHandler: upstreamHandler,
} }
} }
@@ -35,7 +39,8 @@ func NewRouter(healthChecker *HealthChecker, dispatcher *serviceadapter.Dispatch
// 1. /healthz and /readyz to health handlers // 1. /healthz and /readyz to health handlers
// 2. X-Service header to ServiceAdapter dispatcher (phase 8) // 2. X-Service header to ServiceAdapter dispatcher (phase 8)
// 3. /workflow* to temporal handler // 3. /workflow* to temporal handler
// 4. All other paths to upstream handler (phase 0-7) // 4. /v1/s3/* to S3/SigV4 handler
// 5. All other paths to upstream handler (phase 0-7)
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// Health endpoints first // Health endpoints first
switch req.URL.Path { switch req.URL.Path {
@@ -62,6 +67,18 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
return return
} }
// S3/SigV4 proxy endpoints
if req.URL.Path != "" && req.URL.Path[0] == '/' && len(req.URL.Path) > 1 {
// Check for /v1/s3/* pattern
parts := strings.Split(strings.TrimPrefix(req.URL.Path, "/"), "/")
if len(parts) >= 3 && parts[0] == "v1" && parts[1] == "s3" {
if r.s3Handler != nil {
r.s3Handler.ServeHTTP(w, req)
return
}
}
}
// Default: upstream handler (all other paths) // Default: upstream handler (all other paths)
r.upstreamHandler.ServeHTTP(w, req) r.upstreamHandler.ServeHTTP(w, req)
} }