244 lines
6.5 KiB
Go
244 lines
6.5 KiB
Go
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
|
|
}
|