package serviceadapter import ( "fmt" "net" "net/http" "net/http/httputil" "net/url" "time" "forgejo.riotpiao.com/rock/homelab-frontend/internal/auth" "forgejo.riotpiao.com/rock/homelab-frontend/internal/problem" ) // Dispatcher handles X-Service based routing to service adapters. type Dispatcher struct { registry *Registry validators map[string]*auth.Validator // Per-service JWT validators } // NewDispatcher creates a new service adapter dispatcher with Authentik JWT validators. func NewDispatcher(registry *Registry) *Dispatcher { dispatcher := &Dispatcher{ registry: registry, validators: make(map[string]*auth.Validator), } // Create validators for all registered services for _, adapter := range registry.List() { dispatcher.validators[adapter.ServiceName] = auth.NewValidator(adapter.ServiceName) } return dispatcher } // Matches returns true if the request should be dispatched based on 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. // Returns a problem document if the adapter or resource is not found. func (d *Dispatcher) Dispatch(w http.ResponseWriter, r *http.Request) { serviceName := r.Header.Get("X-Service") if serviceName == "" { // No X-Service header — this shouldn't happen if Matches() was called d.writeError(w, problem.BadRequest("X-Service header required")) return } // Look up service adapter adapter := d.registry.Get(serviceName) if adapter == nil { p := problem.NotFound(fmt.Sprintf("service '%s' not found", serviceName)) _ = p.Write(w) return } // Get resource and method from request resourceName := r.Header.Get("X-Resource") if resourceName == "" { d.writeError(w, problem.BadRequest("X-Resource header required")) return } // Find resource var resource *Resource for i := range adapter.Spec.Resources { if adapter.Spec.Resources[i].Name == resourceName { resource = &adapter.Spec.Resources[i] break } } if resource == nil { p := problem.NotFound(fmt.Sprintf("resource '%s' not found in service '%s'", resourceName, serviceName)) _ = p.Write(w) return } // Find method matching HTTP verb var method *Method for i := range resource.Methods { if resource.Methods[i].Verb == r.Method { method = &resource.Methods[i] break } } if method == nil { p := problem.NotFound(fmt.Sprintf("method %s not defined for resource '%s'", r.Method, resourceName)) _ = p.Write(w) return } // Check auth requirements using JWT validation requiredCapability := "" auth := resource.Auth if auth == nil { auth = &adapter.Spec.Auth } if method.Auth != nil { auth = method.Auth } if auth != nil && auth.Required && auth.Capability != "" { requiredCapability = auth.Capability // Validate JWT token validator := d.validators[serviceName] if validator == nil { p := problem.NewProblem(http.StatusInternalServerError, "about:blank#server-error", "Internal Server Error", fmt.Sprintf("no validator for service '%s'", serviceName)) _ = p.Write(w) return } claims, err := validator.ValidateToken(r.Header.Get("Authorization")) if err != nil { p := problem.NewProblem(http.StatusForbidden, "about:blank#forbidden", "Forbidden", fmt.Sprintf("authentication failed: %v", err)) _ = p.Write(w) return } // Check permission if !validator.HasPermission(claims, requiredCapability) { p := problem.NewProblem(http.StatusForbidden, "about:blank#forbidden", "Forbidden", fmt.Sprintf("permission '%s' required", requiredCapability)) _ = p.Write(w) return } } // Build upstream URL upstreamURL, err := url.Parse(adapter.Spec.Upstream.URL) 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 } // Create reverse proxy with director that rewrites the path proxy := httputil.NewSingleHostReverseProxy(upstreamURL) proxy.Director = func(req *http.Request) { req.URL.Scheme = upstreamURL.Scheme req.URL.Host = upstreamURL.Host req.URL.Path = method.UpstreamPath req.RequestURI = "" req.Host = upstreamURL.Host } // Set timeout 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, } // Forward the request proxy.ServeHTTP(w, r) } func (d *Dispatcher) writeError(w http.ResponseWriter, p *problem.Problem) { _ = p.Write(w) }