diff --git a/dashboard/backend/handlers/static.go b/dashboard/backend/handlers/static.go index 67dc80c59e..0bbe4d034a 100644 --- a/dashboard/backend/handlers/static.go +++ b/dashboard/backend/handlers/static.go @@ -19,7 +19,7 @@ func StaticFileServer(staticDir string) http.Handler { strings.HasPrefix(p, "/metrics/") || strings.HasPrefix(p, "/public/") || strings.HasPrefix(p, "/avatar/") || strings.HasPrefix(p, "/_app/") || strings.HasPrefix(p, "/_next/") || strings.HasPrefix(p, "/chatui/") || - p == "/conversation" || strings.HasPrefix(p, "/conversations") || + strings.HasPrefix(p, "/static/") || p == "/conversation" || strings.HasPrefix(p, "/conversations") || strings.HasPrefix(p, "/settings") || p == "/login" || p == "/logout" || strings.HasPrefix(p, "/r/") { // These paths should have been handled by other handlers diff --git a/dashboard/backend/proxy/proxy.go b/dashboard/backend/proxy/proxy.go index 246fc8eacf..87b1ba53db 100644 --- a/dashboard/backend/proxy/proxy.go +++ b/dashboard/backend/proxy/proxy.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "log" + "net" "net/http" "net/http/httputil" "net/url" @@ -42,7 +43,6 @@ func NewReverseProxy(targetBase, stripPrefix string, forwardAuth bool) (*httputi p = "/" + p } r.URL.Path = p - r.Host = targetURL.Host // Capture incoming Origin for downstream CORS decisions incomingOrigin := r.Header.Get("Origin") @@ -62,9 +62,50 @@ func NewReverseProxy(targetBase, stripPrefix string, forwardAuth bool) (*httputi } // Set Origin header to match the target URL for iframe embedding - // This is required for services like Grafana and Chat UI to accept the iframe embedding + // This is required for services like Grafana, Chat UI, and OpenWebUI to accept the iframe embedding + // and pass CSRF/Origin validation checks. The original Origin is preserved in X-Forwarded-Origin + // for CORS response handling. This override is intentional and necessary for iframe embedding to work. r.Header.Set("Origin", targetURL.Scheme+"://"+targetURL.Host) + // Set X-Forwarded-* headers to preserve client information + // These headers should reflect the original client request, not the target service + r.Header.Set("X-Forwarded-Host", r.Host) + + // Determine the original protocol (http or https) + proto := "http" + if r.TLS != nil { + proto = "https" + } + // Also check X-Forwarded-Proto from upstream (if we're behind another proxy) + if forwardedProto := r.Header.Get("X-Forwarded-Proto"); forwardedProto != "" { + proto = forwardedProto + } + r.Header.Set("X-Forwarded-Proto", proto) + + // Extract client IP from RemoteAddr (strip port if present) + var clientIP string + if r.RemoteAddr != "" { + ip, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + // If SplitHostPort fails, RemoteAddr might not have a port + clientIP = r.RemoteAddr + } else { + clientIP = ip + } + } + + // Append to existing X-Forwarded-For if present (we might be behind another proxy) + if clientIP != "" { + if existing := r.Header.Get("X-Forwarded-For"); existing != "" { + r.Header.Set("X-Forwarded-For", existing+", "+clientIP) + } else { + r.Header.Set("X-Forwarded-For", clientIP) + } + } + + // Set Host header to match target (some services check this) + r.Host = targetURL.Host + // Optionally forward Authorization header if !forwardAuth { r.Header.Del("Authorization") diff --git a/dashboard/backend/router/router.go b/dashboard/backend/router/router.go index 03422226f0..0f52a7caf6 100644 --- a/dashboard/backend/router/router.go +++ b/dashboard/backend/router/router.go @@ -55,22 +55,40 @@ func Setup(cfg *config.Config) *http.ServeMux { }) // Proxy for Grafana static assets (no prefix stripping) - grafanaStaticProxy, _ = proxy.NewReverseProxy(cfg.GrafanaURL, "", false) + grafanaStaticProxy, err = proxy.NewReverseProxy(cfg.GrafanaURL, "", false) + if err != nil { + log.Printf("Warning: failed to create Grafana static proxy: %v", err) + grafanaStaticProxy = nil + } mux.HandleFunc("/public/", func(w http.ResponseWriter, r *http.Request) { if middleware.HandleCORSPreflight(w, r) { return } + if grafanaStaticProxy == nil { + w.Header().Set("Content-Type", "application/json") + http.Error(w, `{"error":"Service not available","message":"Grafana static proxy not configured"}`, http.StatusBadGateway) + return + } grafanaStaticProxy.ServeHTTP(w, r) }) mux.HandleFunc("/avatar/", func(w http.ResponseWriter, r *http.Request) { if middleware.HandleCORSPreflight(w, r) { return } + if grafanaStaticProxy == nil { + w.Header().Set("Content-Type", "application/json") + http.Error(w, `{"error":"Service not available","message":"Grafana static proxy not configured"}`, http.StatusBadGateway) + return + } grafanaStaticProxy.ServeHTTP(w, r) }) - log.Printf("Grafana proxy configured: %s", cfg.GrafanaURL) - log.Printf("Grafana static assets proxied: /public/, /avatar/") + if grafanaStaticProxy != nil { + log.Printf("Grafana proxy configured: %s", cfg.GrafanaURL) + log.Printf("Grafana static assets proxied: /public/, /avatar/") + } else { + log.Printf("Grafana proxy configured: %s (static proxy failed to initialize)", cfg.GrafanaURL) + } } else { mux.HandleFunc("/embedded/grafana/", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") @@ -80,11 +98,34 @@ func Setup(cfg *config.Config) *http.ServeMux { log.Printf("Warning: Grafana URL not configured") } + // OpenWebUI static proxy (needs to be set up early for the smart /api/ router below) + var openwebuiStaticProxy *httputil.ReverseProxy + if cfg.OpenWebUIURL != "" { + var err error + openwebuiStaticProxy, err = proxy.NewReverseProxy(cfg.OpenWebUIURL, "", false) + if err != nil { + log.Printf("Warning: failed to create OpenWebUI static proxy: %v", err) + openwebuiStaticProxy = nil + } + } + // Jaeger API proxy (needs to be set up early for the smart router below) var jaegerAPIProxy *httputil.ReverseProxy + var jaegerStaticProxy *httputil.ReverseProxy if cfg.JaegerURL != "" { // Create proxy for Jaeger API (no prefix stripping for /api/*) - jaegerAPIProxy, _ = proxy.NewReverseProxy(cfg.JaegerURL, "", false) + var err error + jaegerAPIProxy, err = proxy.NewReverseProxy(cfg.JaegerURL, "", false) + if err != nil { + log.Printf("Warning: failed to create Jaeger API proxy: %v", err) + jaegerAPIProxy = nil + } + // Create proxy for Jaeger static assets (reused in handlers) + jaegerStaticProxy, err = proxy.NewReverseProxy(cfg.JaegerURL, "", false) + if err != nil { + log.Printf("Warning: failed to create Jaeger static proxy: %v", err) + jaegerStaticProxy = nil + } } // Chat UI proxy (exposed early for smart /api routing and root-level assets) @@ -92,7 +133,12 @@ func Setup(cfg *config.Config) *http.ServeMux { var chatUIProxy *httputil.ReverseProxy if cfg.ChatUIURL != "" { // Root-level proxy (no prefix stripping) for assets and API - chatUIProxy, _ = proxy.NewReverseProxy(cfg.ChatUIURL, "", false) + var err error + chatUIProxy, err = proxy.NewReverseProxy(cfg.ChatUIURL, "", false) + if err != nil { + log.Printf("Warning: failed to create ChatUI proxy: %v", err) + chatUIProxy = nil + } // Main UI under /embedded/chatui with prefix stripping cup, err := proxy.NewReverseProxy(cfg.ChatUIURL, "/embedded/chatui", false) if err != nil { @@ -110,19 +156,18 @@ func Setup(cfg *config.Config) *http.ServeMux { } cup.ServeHTTP(w, r) }) - // Static assets commonly used by HF Chat UI (SvelteKit/Next) - mux.HandleFunc("/_app/", func(w http.ResponseWriter, r *http.Request) { - if middleware.HandleCORSPreflight(w, r) { - return - } - log.Printf("Proxying Chat UI asset: %s", r.URL.Path) - chatUIProxy.ServeHTTP(w, r) - }) + // Note: /_app/ is also used by OpenWebUI, so it's handled by OpenWebUI's handler + // (registered later) which checks referer and routes to ChatUI if needed // SvelteKit static assets mux.HandleFunc("/_next/", func(w http.ResponseWriter, r *http.Request) { if middleware.HandleCORSPreflight(w, r) { return } + if chatUIProxy == nil { + w.Header().Set("Content-Type", "application/json") + http.Error(w, `{"error":"Service not available","message":"ChatUI proxy not configured"}`, http.StatusBadGateway) + return + } log.Printf("Proxying Chat UI Next.js asset: %s", r.URL.Path) chatUIProxy.ServeHTTP(w, r) }) @@ -131,24 +176,44 @@ func Setup(cfg *config.Config) *http.ServeMux { if middleware.HandleCORSPreflight(w, r) { return } + if chatUIProxy == nil { + w.Header().Set("Content-Type", "application/json") + http.Error(w, `{"error":"Service not available","message":"ChatUI proxy not configured"}`, http.StatusBadGateway) + return + } chatUIProxy.ServeHTTP(w, r) }) mux.HandleFunc("/manifest.webmanifest", func(w http.ResponseWriter, r *http.Request) { if middleware.HandleCORSPreflight(w, r) { return } + if chatUIProxy == nil { + w.Header().Set("Content-Type", "application/json") + http.Error(w, `{"error":"Service not available","message":"ChatUI proxy not configured"}`, http.StatusBadGateway) + return + } chatUIProxy.ServeHTTP(w, r) }) mux.HandleFunc("/manifest.json", func(w http.ResponseWriter, r *http.Request) { if middleware.HandleCORSPreflight(w, r) { return } + if chatUIProxy == nil { + w.Header().Set("Content-Type", "application/json") + http.Error(w, `{"error":"Service not available","message":"ChatUI proxy not configured"}`, http.StatusBadGateway) + return + } chatUIProxy.ServeHTTP(w, r) }) mux.HandleFunc("/robots.txt", func(w http.ResponseWriter, r *http.Request) { if middleware.HandleCORSPreflight(w, r) { return } + if chatUIProxy == nil { + w.Header().Set("Content-Type", "application/json") + http.Error(w, `{"error":"Service not available","message":"ChatUI proxy not configured"}`, http.StatusBadGateway) + return + } chatUIProxy.ServeHTTP(w, r) }) @@ -158,6 +223,11 @@ func Setup(cfg *config.Config) *http.ServeMux { if middleware.HandleCORSPreflight(w, r) { return } + if chatUIProxy == nil { + w.Header().Set("Content-Type", "application/json") + http.Error(w, `{"error":"Service not available","message":"ChatUI proxy not configured"}`, http.StatusBadGateway) + return + } log.Printf("Proxying Chat UI conversation API: %s %s", r.Method, r.URL.Path) chatUIProxy.ServeHTTP(w, r) }) @@ -166,6 +236,11 @@ func Setup(cfg *config.Config) *http.ServeMux { if middleware.HandleCORSPreflight(w, r) { return } + if chatUIProxy == nil { + w.Header().Set("Content-Type", "application/json") + http.Error(w, `{"error":"Service not available","message":"ChatUI proxy not configured"}`, http.StatusBadGateway) + return + } log.Printf("Proxying Chat UI conversation API: %s %s", r.Method, r.URL.Path) chatUIProxy.ServeHTTP(w, r) }) @@ -173,6 +248,11 @@ func Setup(cfg *config.Config) *http.ServeMux { if middleware.HandleCORSPreflight(w, r) { return } + if chatUIProxy == nil { + w.Header().Set("Content-Type", "application/json") + http.Error(w, `{"error":"Service not available","message":"ChatUI proxy not configured"}`, http.StatusBadGateway) + return + } log.Printf("Proxying Chat UI conversations API: %s %s", r.Method, r.URL.Path) chatUIProxy.ServeHTTP(w, r) }) @@ -180,6 +260,11 @@ func Setup(cfg *config.Config) *http.ServeMux { if middleware.HandleCORSPreflight(w, r) { return } + if chatUIProxy == nil { + w.Header().Set("Content-Type", "application/json") + http.Error(w, `{"error":"Service not available","message":"ChatUI proxy not configured"}`, http.StatusBadGateway) + return + } log.Printf("Proxying Chat UI conversations API: %s %s", r.Method, r.URL.Path) chatUIProxy.ServeHTTP(w, r) }) @@ -187,6 +272,11 @@ func Setup(cfg *config.Config) *http.ServeMux { if middleware.HandleCORSPreflight(w, r) { return } + if chatUIProxy == nil { + w.Header().Set("Content-Type", "application/json") + http.Error(w, `{"error":"Service not available","message":"ChatUI proxy not configured"}`, http.StatusBadGateway) + return + } log.Printf("Proxying Chat UI settings: %s %s", r.Method, r.URL.Path) chatUIProxy.ServeHTTP(w, r) }) @@ -194,6 +284,11 @@ func Setup(cfg *config.Config) *http.ServeMux { if middleware.HandleCORSPreflight(w, r) { return } + if chatUIProxy == nil { + w.Header().Set("Content-Type", "application/json") + http.Error(w, `{"error":"Service not available","message":"ChatUI proxy not configured"}`, http.StatusBadGateway) + return + } log.Printf("Proxying Chat UI settings: %s %s", r.Method, r.URL.Path) chatUIProxy.ServeHTTP(w, r) }) @@ -219,6 +314,11 @@ func Setup(cfg *config.Config) *http.ServeMux { grafanaStaticProxy.ServeHTTP(w, r) return } + if chatUIProxy == nil { + w.Header().Set("Content-Type", "application/json") + http.Error(w, `{"error":"Service not available","message":"ChatUI proxy not configured"}`, http.StatusBadGateway) + return + } log.Printf("Proxying Chat UI login: %s %s (contentType=%s)", r.Method, r.URL.Path, contentType) chatUIProxy.ServeHTTP(w, r) }) @@ -226,6 +326,11 @@ func Setup(cfg *config.Config) *http.ServeMux { if middleware.HandleCORSPreflight(w, r) { return } + if chatUIProxy == nil { + w.Header().Set("Content-Type", "application/json") + http.Error(w, `{"error":"Service not available","message":"ChatUI proxy not configured"}`, http.StatusBadGateway) + return + } log.Printf("Proxying Chat UI logout: %s %s", r.Method, r.URL.Path) chatUIProxy.ServeHTTP(w, r) }) @@ -234,6 +339,11 @@ func Setup(cfg *config.Config) *http.ServeMux { if middleware.HandleCORSPreflight(w, r) { return } + if chatUIProxy == nil { + w.Header().Set("Content-Type", "application/json") + http.Error(w, `{"error":"Service not available","message":"ChatUI proxy not configured"}`, http.StatusBadGateway) + return + } log.Printf("Proxying Chat UI shared conversation: %s %s", r.Method, r.URL.Path) chatUIProxy.ServeHTTP(w, r) }) @@ -242,6 +352,11 @@ func Setup(cfg *config.Config) *http.ServeMux { if middleware.HandleCORSPreflight(w, r) { return } + if chatUIProxy == nil { + w.Header().Set("Content-Type", "application/json") + http.Error(w, `{"error":"Service not available","message":"ChatUI proxy not configured"}`, http.StatusBadGateway) + return + } log.Printf("Proxying Chat UI assets: %s", r.URL.Path) chatUIProxy.ServeHTTP(w, r) }) @@ -282,6 +397,20 @@ func Setup(cfg *config.Config) *http.ServeMux { jaegerAPIProxy.ServeHTTP(w, r) return } + // Check if request is from OpenWebUI (by referer) and route to OpenWebUI API + referer := r.Header.Get("Referer") + if openwebuiStaticProxy != nil && referer != "" && strings.Contains(referer, "/embedded/openwebui") { + log.Printf("Routing to OpenWebUI API: %s (referer: %s)", r.URL.Path, referer) + openwebuiStaticProxy.ServeHTTP(w, r) + return + } + // Check if path is a known OpenWebUI API endpoint (even without referer) + // OpenWebUI uses /api/config for configuration + if openwebuiStaticProxy != nil && strings.HasPrefix(r.URL.Path, "/api/config") { + log.Printf("Routing to OpenWebUI API: %s (by path pattern)", r.URL.Path) + openwebuiStaticProxy.ServeHTTP(w, r) + return + } // Prefer Chat UI API when available (to avoid returning HTML from other backends) if chatUIProxy != nil { log.Printf("Routing to Chat UI API: %s", r.URL.Path) @@ -354,18 +483,19 @@ func Setup(cfg *config.Config) *http.ServeMux { }) // Jaeger static assets are typically served under /static/* from the same origin - // Provide a passthrough proxy without prefix stripping - jStatic, _ := proxy.NewReverseProxy(cfg.JaegerURL, "", false) - mux.Handle("/static/", jStatic) + // Note: /static/ is shared with OpenWebUI, so we handle it in OpenWebUI section with referer-based routing // Jaeger /dependencies page (accessible directly, not under /embedded/jaeger) - mux.HandleFunc("/dependencies", func(w http.ResponseWriter, r *http.Request) { - if middleware.HandleCORSPreflight(w, r) { - return - } - log.Printf("Proxying Jaeger dependencies page: %s", r.URL.Path) - jStatic.ServeHTTP(w, r) - }) + // Use the pre-created jaegerStaticProxy + if jaegerStaticProxy != nil { + mux.HandleFunc("/dependencies", func(w http.ResponseWriter, r *http.Request) { + if middleware.HandleCORSPreflight(w, r) { + return + } + log.Printf("Proxying Jaeger dependencies page: %s", r.URL.Path) + jaegerStaticProxy.ServeHTTP(w, r) + }) + } log.Printf("Jaeger proxy configured: %s; static assets proxied at /static/, /dependencies", cfg.JaegerURL) } else { @@ -377,7 +507,7 @@ func Setup(cfg *config.Config) *http.ServeMux { log.Printf("Info: Jaeger URL not configured (optional)") } - // Open WebUI proxy (optional) + // Open WebUI proxy (optional) - MUST handle /static/ with referer-based routing for Jaeger compatibility if cfg.OpenWebUIURL != "" { op, err := proxy.NewReverseProxy(cfg.OpenWebUIURL, "/embedded/openwebui", true) if err != nil { @@ -395,7 +525,70 @@ func Setup(cfg *config.Config) *http.ServeMux { } op.ServeHTTP(w, r) }) + + // Static assets for OpenWebUI and Jaeger - route based on referer + mux.HandleFunc("/static/", func(w http.ResponseWriter, r *http.Request) { + if middleware.HandleCORSPreflight(w, r) { + return + } + // Check referer to determine if request is from Jaeger or OpenWebUI + referer := r.Header.Get("Referer") + if referer != "" && strings.Contains(referer, "/embedded/jaeger") { + // Route to Jaeger if referer indicates Jaeger + if jaegerStaticProxy != nil { + log.Printf("Proxying Jaeger /static/ asset: %s (referer: %s)", r.URL.Path, referer) + jaegerStaticProxy.ServeHTTP(w, r) + return + } + } + // Default to OpenWebUI when it's configured + if openwebuiStaticProxy != nil { + log.Printf("Proxying OpenWebUI /static/ asset: %s (referer: %s)", r.URL.Path, referer) + openwebuiStaticProxy.ServeHTTP(w, r) + } else { + w.Header().Set("Content-Type", "application/json") + http.Error(w, `{"error":"Service not available","message":"OpenWebUI static proxy not configured"}`, http.StatusBadGateway) + } + }) + + // OpenWebUI also uses /_app/ for its main JS/CSS bundles + mux.HandleFunc("/_app/", func(w http.ResponseWriter, r *http.Request) { + if middleware.HandleCORSPreflight(w, r) { + return + } + // Check Referer to determine if request is from OpenWebUI or ChatUI + referer := r.Header.Get("Referer") + isOpenWebUIRequest := referer != "" && strings.Contains(referer, "/embedded/openwebui") + isChatUIRequest := referer != "" && strings.Contains(referer, "/embedded/chatui") + + // If referer indicates OpenWebUI, route to OpenWebUI + if isOpenWebUIRequest && openwebuiStaticProxy != nil { + log.Printf("Proxying OpenWebUI /_app/ asset: %s (referer: %s)", r.URL.Path, referer) + openwebuiStaticProxy.ServeHTTP(w, r) + return + } + // If referer indicates ChatUI, route to ChatUI (if configured) + if isChatUIRequest && chatUIProxy != nil { + log.Printf("Proxying Chat UI /_app/ asset: %s (referer: %s)", r.URL.Path, referer) + chatUIProxy.ServeHTTP(w, r) + return + } + // If no referer or unclear, try OpenWebUI first (since it's configured) + if openwebuiStaticProxy != nil { + log.Printf("Proxying /_app/ asset to OpenWebUI (no clear referer): %s (referer: %s)", r.URL.Path, referer) + openwebuiStaticProxy.ServeHTTP(w, r) + } else { + w.Header().Set("Content-Type", "application/json") + http.Error(w, `{"error":"Service not available","message":"No handler available for /_app/"}`, http.StatusBadGateway) + } + }) + log.Printf("Open WebUI proxy configured: %s", cfg.OpenWebUIURL) + if openwebuiStaticProxy != nil { + log.Printf("Open WebUI static assets proxied at: /static/, /_app/") + } else { + log.Printf("Open WebUI static assets proxy failed to initialize") + } } else { mux.HandleFunc("/embedded/openwebui/", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") diff --git a/dashboard/frontend/src/pages/PlaygroundPage.tsx b/dashboard/frontend/src/pages/PlaygroundPage.tsx index 5dcf0a08ca..23d1b4e50c 100644 --- a/dashboard/frontend/src/pages/PlaygroundPage.tsx +++ b/dashboard/frontend/src/pages/PlaygroundPage.tsx @@ -1,60 +1,15 @@ -import React, { useState, useEffect } from 'react' import styles from './PlaygroundPage.module.css' -const PlaygroundPage: React.FC = () => { - // Detect OpenWebUI URL based on current hostname - const getOpenWebUIUrl = () => { - const hostname = window.location.hostname - const protocol = window.location.protocol - - // Build-time configurable port (e.g., for All-in-One deployment) - const configuredPort = import.meta.env.VITE_OPENWEBUI_PORT - if (configuredPort) { - return `${protocol}//${hostname}:${configuredPort}` - } - - // Assumes openwebui and dashboard have matching hostname patterns - const openwebuiHost = hostname.replace('dashboard', 'openwebui') - if (openwebuiHost === hostname) { - // hostname doesn't contain 'dashboard', cannot determine Open WebUI URL - return '' - } - return `${protocol}//${openwebuiHost}` - } - - const [openWebUIUrl] = useState(() => getOpenWebUIUrl()) - const [currentUrl, setCurrentUrl] = useState('') - - // Auto-load on mount - useEffect(() => { - // Default to loading the configured URL on mount - setCurrentUrl(openWebUIUrl) - }, [openWebUIUrl]) // Load when URL changes - +const PlaygroundPage = () => { return (
- Test your LLM models and semantic routing with Open WebUI. -
-- If unable to load, please check Open WebUI deployment and port configuration. -
-