CI / CI (push) Successful in 3m43s
SQS dispatcher hardcoded a JWT validator pointing at authentik.riotpiao.com/application/o/sqs/jwks/ — provider doesn't exist. Every SQS request got 403 regardless of token. Co-authored-by: poimen <[email protected]>
225 lines
6.6 KiB
Go
225 lines
6.6 KiB
Go
package serviceadapter
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httputil"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/credentials/insecure"
|
|
|
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/auth"
|
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/identity"
|
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/problem"
|
|
)
|
|
|
|
// Dispatcher routes X-Service requests to upstreams.
|
|
type Dispatcher struct {
|
|
registry *Registry
|
|
jwtValidator *auth.Validator
|
|
}
|
|
|
|
// NewDispatcher creates a dispatcher with a shared multi-issuer JWT validator.
|
|
// Pass nil to disable auth enforcement (all requests pass through).
|
|
func NewDispatcher(registry *Registry, jwtValidator *auth.Validator) *Dispatcher {
|
|
return &Dispatcher{
|
|
registry: registry,
|
|
jwtValidator: jwtValidator,
|
|
}
|
|
}
|
|
|
|
// Matches returns true if the request has an X-Service header.
|
|
func (d *Dispatcher) Matches(r *http.Request) bool {
|
|
return r.Header.Get("X-Service") != ""
|
|
}
|
|
|
|
// Dispatch routes a request to the appropriate adapter.
|
|
func (d *Dispatcher) Dispatch(w http.ResponseWriter, r *http.Request) {
|
|
serviceName := r.Header.Get("X-Service")
|
|
if serviceName == "" {
|
|
d.writeError(w, problem.BadRequest("X-Service header required"))
|
|
return
|
|
}
|
|
|
|
adapter := d.registry.Get(serviceName)
|
|
if adapter == nil {
|
|
d.writeError(w, problem.NotFound(fmt.Sprintf("service '%s' not found", serviceName)))
|
|
return
|
|
}
|
|
|
|
resourceName := r.Header.Get("X-Resource")
|
|
if resourceName == "" {
|
|
d.writeError(w, problem.BadRequest("X-Resource header required"))
|
|
return
|
|
}
|
|
|
|
resource := findResource(adapter, resourceName)
|
|
if resource == nil {
|
|
d.writeError(w, problem.NotFound(
|
|
fmt.Sprintf("resource '%s' not found in service '%s'", resourceName, serviceName)))
|
|
return
|
|
}
|
|
|
|
method := findMethod(resource, r.Method)
|
|
if method == nil {
|
|
d.writeError(w, problem.NotFound(
|
|
fmt.Sprintf("method %s not defined for resource '%s'", r.Method, resourceName)))
|
|
return
|
|
}
|
|
|
|
// JWT auth enforcement for adapters that require it
|
|
if adapter.Spec.Auth.Required && d.jwtValidator != nil {
|
|
if !d.authenticate(w, r, serviceName, method.Verb) {
|
|
return
|
|
}
|
|
}
|
|
|
|
upstreamURL := adapter.Spec.Upstream.URL
|
|
if strings.HasPrefix(upstreamURL, "grpc://") {
|
|
d.dispatchGRPC(w, r, upstreamURL, method, adapter)
|
|
} else {
|
|
d.dispatchHTTP(w, r, upstreamURL, method, adapter)
|
|
}
|
|
}
|
|
|
|
// authenticate validates the JWT and checks service-level capability.
|
|
// Returns false (and writes error response) if auth fails.
|
|
func (d *Dispatcher) authenticate(w http.ResponseWriter, r *http.Request, serviceName, verb string) bool {
|
|
authHeader := r.Header.Get("Authorization")
|
|
if authHeader == "" {
|
|
d.writeError(w, problem.NewProblem(http.StatusUnauthorized,
|
|
"about:blank#unauthorized", "Unauthorized",
|
|
fmt.Sprintf("service '%s' requires Authorization header", serviceName)))
|
|
return false
|
|
}
|
|
|
|
claims, err := d.jwtValidator.ValidateBearerToken(authHeader)
|
|
if err != nil {
|
|
d.writeError(w, problem.NewProblem(http.StatusForbidden,
|
|
"about:blank#forbidden", "Forbidden",
|
|
fmt.Sprintf("JWT validation failed: %v", err)))
|
|
return false
|
|
}
|
|
|
|
// Check capability: <service>:read for GET/HEAD, <service>:write for mutating verbs
|
|
required := capabilityForVerb(serviceName, verb)
|
|
if !d.jwtValidator.CheckPermissions(claims, required, "*") {
|
|
d.writeError(w, problem.NewProblem(http.StatusForbidden,
|
|
"about:blank#insufficient-permissions", "Insufficient Permissions",
|
|
fmt.Sprintf("required capability: %s", required)))
|
|
return false
|
|
}
|
|
|
|
// Inject identity headers for downstream
|
|
identity.Inject(r, claims)
|
|
return true
|
|
}
|
|
|
|
// capabilityForVerb maps HTTP verbs to <service>:read or <service>:write.
|
|
func capabilityForVerb(serviceName, verb string) string {
|
|
switch verb {
|
|
case "GET", "HEAD", "OPTIONS":
|
|
return serviceName + ":read"
|
|
default:
|
|
return serviceName + ":write"
|
|
}
|
|
}
|
|
|
|
func findResource(adapter *ServiceAdapter, name string) *Resource {
|
|
for i := range adapter.Spec.Resources {
|
|
if adapter.Spec.Resources[i].Name == name {
|
|
return &adapter.Spec.Resources[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func findMethod(resource *Resource, verb string) *Method {
|
|
for i := range resource.Methods {
|
|
if resource.Methods[i].Verb == verb {
|
|
return &resource.Methods[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (d *Dispatcher) dispatchHTTP(w http.ResponseWriter, r *http.Request, upstreamURL string, method *Method, adapter *ServiceAdapter) {
|
|
parsedURL, err := url.Parse(upstreamURL)
|
|
if err != nil {
|
|
d.writeError(w, problem.NewProblem(http.StatusInternalServerError,
|
|
"about:blank#server-error", "Internal Server Error",
|
|
fmt.Sprintf("invalid upstream URL: %v", err)))
|
|
return
|
|
}
|
|
|
|
proxy := httputil.NewSingleHostReverseProxy(parsedURL)
|
|
proxy.Director = func(req *http.Request) {
|
|
req.URL.Scheme = parsedURL.Scheme
|
|
req.URL.Host = parsedURL.Host
|
|
req.URL.Path = method.UpstreamPath
|
|
req.RequestURI = ""
|
|
req.Host = parsedURL.Host
|
|
}
|
|
|
|
timeout := adapter.Spec.Upstream.TimeoutSeconds
|
|
if timeout <= 0 {
|
|
timeout = 30
|
|
}
|
|
proxy.Transport = &http.Transport{
|
|
DialContext: (&net.Dialer{Timeout: time.Duration(timeout) * time.Second}).DialContext,
|
|
TLSHandshakeTimeout: time.Duration(timeout) * time.Second,
|
|
}
|
|
|
|
proxy.ServeHTTP(w, r)
|
|
}
|
|
|
|
func (d *Dispatcher) dispatchGRPC(w http.ResponseWriter, r *http.Request, upstreamURL string, method *Method, adapter *ServiceAdapter) {
|
|
host := strings.TrimPrefix(upstreamURL, "grpc://")
|
|
if host == upstreamURL {
|
|
d.writeError(w, problem.NewProblem(http.StatusInternalServerError,
|
|
"about:blank#server-error", "Internal Server Error",
|
|
"invalid gRPC URL format"))
|
|
return
|
|
}
|
|
|
|
if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/grpc") {
|
|
d.writeError(w, problem.NewProblem(http.StatusBadRequest,
|
|
"about:blank#bad-request", "Bad Request",
|
|
"gRPC service requires application/grpc content-type"))
|
|
return
|
|
}
|
|
|
|
timeout := adapter.Spec.Upstream.TimeoutSeconds
|
|
if timeout <= 0 {
|
|
timeout = 30
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), time.Duration(timeout)*time.Second)
|
|
defer cancel()
|
|
|
|
conn, err := grpc.DialContext(ctx, host,
|
|
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
|
grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(100*1024*1024)),
|
|
)
|
|
if err != nil {
|
|
d.writeError(w, problem.NewProblem(http.StatusBadGateway,
|
|
"about:blank#bad-gateway", "Bad Gateway",
|
|
fmt.Sprintf("failed to dial gRPC upstream: %v", err)))
|
|
return
|
|
}
|
|
defer conn.Close()
|
|
|
|
d.writeError(w, problem.NewProblem(http.StatusNotImplemented,
|
|
"about:blank#not-implemented", "Not Implemented",
|
|
"gRPC forwarding not yet implemented"))
|
|
}
|
|
|
|
func (d *Dispatcher) writeError(w http.ResponseWriter, p *problem.Problem) {
|
|
_ = p.Write(w)
|
|
}
|