package webingress import ( "context" "encoding/json" "io" "net/http" "strings" "time" ) type RuntimeConfig struct { ServiceType string Scope string ServiceClasses []string TLSMode string 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) 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, req.URL.Path, 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, path string, fallback string) string { path = strings.Trim(strings.ToLower(path), "/") switch strings.TrimSpace(serviceClass) { case "admin-ingress": if strings.HasPrefix(path, "clusters/") { return "cluster" } return firstNonEmpty(strings.TrimSpace(fallback), "platform") case "public-ingress": if strings.HasPrefix(path, "users/") { return "user" } return firstNonEmpty(strings.TrimSpace(fallback), "organization") default: return strings.TrimSpace(fallback) } } func firstNonEmpty(value string, fallback string) string { if strings.TrimSpace(value) != "" { return strings.TrimSpace(value) } return strings.TrimSpace(fallback) } func serviceClassFromPath(path string) string { path = strings.Trim(strings.ToLower(path), "/") switch { case strings.HasPrefix(path, "admin/"), strings.HasPrefix(path, "platform/"), strings.HasPrefix(path, "clusters/"): return "admin-ingress" case strings.HasPrefix(path, "public/"), strings.HasPrefix(path, "organizations/"), strings.HasPrefix(path, "users/"): return "public-ingress" 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 }