feat: add S3/SigV4 proxy handler for MinIO JWT auth
- 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:
@@ -458,6 +458,12 @@ func (h *Handler) handleModelsEndpoint(w http.ResponseWriter, r *http.Request) {
|
||||
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.
|
||||
func (h *Handler) Close() error {
|
||||
for _, transport := range h.transports {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -2,17 +2,19 @@ package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/serviceadapter"
|
||||
)
|
||||
|
||||
// Router implements an HTTP handler that routes health 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 {
|
||||
healthChecker *HealthChecker
|
||||
dispatcher *serviceadapter.Dispatcher
|
||||
temporalHandler http.Handler
|
||||
s3Handler http.Handler // S3/SigV4 proxy
|
||||
upstreamHandler http.Handler
|
||||
}
|
||||
|
||||
@@ -20,12 +22,14 @@ type Router struct {
|
||||
// Health endpoints (/healthz and /readyz) are handled locally.
|
||||
// X-Service requests are dispatched via ServiceAdapter CRD.
|
||||
// Temporal endpoints (/workflow*) are routed to temporalHandler.
|
||||
// S3 endpoints (/v1/s3/*) are routed to s3Handler.
|
||||
// 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{
|
||||
healthChecker: healthChecker,
|
||||
dispatcher: dispatcher,
|
||||
temporalHandler: temporalHandler,
|
||||
s3Handler: s3Handler,
|
||||
upstreamHandler: upstreamHandler,
|
||||
}
|
||||
}
|
||||
@@ -35,7 +39,8 @@ func NewRouter(healthChecker *HealthChecker, dispatcher *serviceadapter.Dispatch
|
||||
// 1. /healthz and /readyz to health handlers
|
||||
// 2. X-Service header to ServiceAdapter dispatcher (phase 8)
|
||||
// 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) {
|
||||
// Health endpoints first
|
||||
switch req.URL.Path {
|
||||
@@ -62,6 +67,18 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
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)
|
||||
r.upstreamHandler.ServeHTTP(w, req)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user