Refactor RDP proxy handling and update related tests
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user