package webingress import ( "context" "encoding/json" "io" "net/http" "strings" "time" ) type RuntimeConfig struct { ServiceType string Scope string ServiceClasses []string TLSMode string HTTPPort int HTTPSPort int } type Runtime struct { Config RuntimeConfig Binder FabricBinder Now func() time.Time } type FabricBinder interface { Forward(ctx context.Context, request FabricRequest) (FabricResponse, error) } type FabricRequest struct { SchemaVersion string Method string Path string Query string Host string ServiceType string Scope string ServiceClass string Headers http.Header Body []byte ObservedAt time.Time } type FabricResponse struct { StatusCode int Headers http.Header Body []byte } type Response struct { SchemaVersion string `json:"schema_version"` Status string `json:"status"` Reason string `json:"reason,omitempty"` ServiceType string `json:"service_type,omitempty"` Scope string `json:"scope,omitempty"` ServiceClass string `json:"service_class,omitempty"` Allowed []string `json:"allowed_service_classes,omitempty"` ObservedAt string `json:"observed_at"` } func (r Runtime) HTTPHandler() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { if strings.HasPrefix(req.URL.Path, "/.well-known/acme-challenge/") { writeJSON(w, http.StatusNotFound, r.response("not_found", "acme_challenge_backend_not_configured", "")) return } if req.URL.Path == "/healthz" || req.URL.Path == "/readyz" { writeJSON(w, http.StatusOK, r.response("ready", "http_redirect_runtime_ready", "")) return } target := "https://" + req.Host + req.URL.RequestURI() w.Header().Set("Location", target) w.Header().Set("Cache-Control", "no-store") w.WriteHeader(http.StatusPermanentRedirect) }) } func (r Runtime) HTTPSHandler() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { if req.URL.Path == "/healthz" || req.URL.Path == "/readyz" { writeJSON(w, http.StatusOK, r.response("ready", "https_runtime_ready", "")) return } serviceClass := strings.TrimSpace(req.Header.Get("X-RAP-Service-Class")) if serviceClass == "" { serviceClass = serviceClassFromPath(req.URL.Path) } if serviceClass == "" { writeJSON(w, http.StatusBadRequest, r.response("blocked", "service_class_required", "")) return } if !contains(r.Config.ServiceClasses, serviceClass) { writeJSON(w, http.StatusForbidden, r.response("blocked", "service_class_not_allowed", serviceClass)) return } if r.Binder == nil { writeJSON(w, http.StatusNotImplemented, r.response("blocked", "fabric_service_channel_binding_not_implemented", serviceClass)) return } scope := scopeForServiceClass(serviceClass, r.Config.Scope) body, err := io.ReadAll(http.MaxBytesReader(w, req.Body, 1<<20)) if err != nil { writeJSON(w, http.StatusRequestEntityTooLarge, r.response("blocked", "request_body_too_large", serviceClass)) return } now := time.Now().UTC() if r.Now != nil { now = r.Now().UTC() } fabricResponse, err := r.Binder.Forward(req.Context(), FabricRequest{ SchemaVersion: "rap.web_ingress.fabric_request.v1", Method: req.Method, Path: req.URL.Path, Query: req.URL.RawQuery, Host: req.Host, ServiceType: strings.TrimSpace(r.Config.ServiceType), Scope: scope, ServiceClass: serviceClass, Headers: cloneSafeHeaders(req.Header), Body: body, ObservedAt: now, }) if err != nil { writeJSON(w, http.StatusBadGateway, r.response("blocked", "fabric_service_channel_forward_failed", serviceClass)) return } writeFabricResponse(w, fabricResponse) }) } func (r Runtime) response(status, reason, serviceClass string) Response { now := time.Now().UTC() if r.Now != nil { now = r.Now().UTC() } return Response{ SchemaVersion: "rap.web_ingress.runtime_response.v1", Status: status, Reason: reason, ServiceType: strings.TrimSpace(r.Config.ServiceType), Scope: strings.TrimSpace(r.Config.Scope), ServiceClass: serviceClass, Allowed: append([]string{}, r.Config.ServiceClasses...), ObservedAt: now.Format(time.RFC3339Nano), } } func scopeForServiceClass(serviceClass string, fallback string) string { switch strings.TrimSpace(serviceClass) { case "platform_admin": return "platform" case "cluster_admin": return "cluster" case "organization_portal": return "organization" case "user_portal": return "user" default: return strings.TrimSpace(fallback) } } func serviceClassFromPath(path string) string { path = strings.Trim(strings.ToLower(path), "/") switch { case strings.HasPrefix(path, "platform-admin"): return "platform_admin" case strings.HasPrefix(path, "cluster-admin"): return "cluster_admin" case strings.HasPrefix(path, "organizations/"): return "organization_portal" case strings.HasPrefix(path, "users/"): return "user_portal" default: return "" } } func writeJSON(w http.ResponseWriter, status int, payload Response) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(payload) } func writeFabricResponse(w http.ResponseWriter, payload FabricResponse) { for key, values := range payload.Headers { if !safeResponseHeader(key) { continue } for _, value := range values { w.Header().Add(key, value) } } w.Header().Set("Cache-Control", "no-store") status := payload.StatusCode if status < 100 || status > 599 { status = http.StatusOK } w.WriteHeader(status) _, _ = w.Write(payload.Body) } func cloneSafeHeaders(headers http.Header) http.Header { out := http.Header{} for key, values := range headers { if !safeRequestHeader(key) { continue } for _, value := range values { out.Add(key, value) } } return out } func safeRequestHeader(key string) bool { switch strings.ToLower(strings.TrimSpace(key)) { case "authorization", "cookie", "set-cookie", "x-rap-service-channel-token": return false default: return true } } func safeResponseHeader(key string) bool { switch strings.ToLower(strings.TrimSpace(key)) { case "set-cookie", "transfer-encoding", "connection": return false default: return true } } func contains(values []string, needle string) bool { for _, value := range values { if value == needle { return true } } return false }