diff --git a/README.md b/README.md index 6eb764933..244fabea1 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,7 @@ radar | `--namespace` | (all) | Initial namespace filter (supports multi-select in the UI; also used as RBAC fallback for namespace-scoped users) | | `--namespace-scope` | `false` | Pin namespaced informer caches to a **single** namespace for large clusters (scoping to multiple namespaces is not supported yet). Requires `--namespace`, a kubeconfig context namespace, or a saved local single-namespace pick. Local mode can rebuild the cache when switching namespaces; auth/cloud mode locks the shared cache to the startup namespace. | | `--port` | `9280` | Server port | +| `--base-path` | | Serve Radar under a URL prefix such as `/radar`. Use when an ingress forwards a subpath without stripping it. | | `--no-browser` | `false` | Don't auto-open browser | | `--browser` | | Browser to use when opening the UI, e.g. `firefox`, `google-chrome`, or `Google Chrome` on macOS | | `--timeline-storage` | `memory` | Timeline storage backend: `memory` or `sqlite` | diff --git a/cmd/explorer/main.go b/cmd/explorer/main.go index f4258527d..6d4b64c32 100644 --- a/cmd/explorer/main.go +++ b/cmd/explorer/main.go @@ -50,6 +50,7 @@ func main() { kubeconfigDir := flag.String("kubeconfig-dir", fileCfg.KubeconfigDirsFlag(), "Comma-separated directories containing kubeconfig files (mutually exclusive with --kubeconfig)") namespace := flag.String("namespace", fileCfg.Namespace, "Initial namespace filter (empty = all namespaces)") port := flag.Int("port", fileCfg.PortOr(9280), "Server port") + basePath := flag.String("base-path", "", "URL path prefix to serve Radar under, e.g. /radar (empty = root). Use when an ingress forwards a subpath without stripping it.") noBrowser := flag.Bool("no-browser", fileCfg.NoBrowser, "Don't auto-open browser") browser := flag.String("browser", fileCfg.Browser, "Browser to use when opening the UI (default: OS default browser; macOS app names supported)") devMode := flag.Bool("dev", false, "Development mode (serve frontend from filesystem)") @@ -171,6 +172,10 @@ func main() { if *kubeconfig != "" && *kubeconfigDir != "" { log.Fatalf("--kubeconfig and --kubeconfig-dir are mutually exclusive") } + normalizedBasePath, err := server.NormalizeBasePath(*basePath) + if err != nil { + log.Fatalf("Invalid --base-path %q: %v", *basePath, err) + } timelineMaxSizeBytes, err := config.ParseByteSize(*timelineMaxSize) if err != nil { log.Fatalf("Invalid --timeline-max-size %q: %v", *timelineMaxSize, err) @@ -201,6 +206,7 @@ func main() { KubeconfigDirs: app.ParseKubeconfigDirs(*kubeconfigDir), Namespace: *namespace, Port: *port, + BasePath: normalizedBasePath, NoBrowser: *noBrowser, Browser: *browser, DevMode: *devMode, @@ -302,7 +308,10 @@ func main() { // Open browser — server is confirmed ready to accept connections if !cfg.NoBrowser { - url := fmt.Sprintf("http://localhost:%d", cfg.Port) + url := fmt.Sprintf("http://localhost:%d%s", cfg.Port, cfg.BasePath) + if cfg.BasePath != "" { + url += "/" + } if cfg.Namespace != "" { url += fmt.Sprintf("?namespace=%s", cfg.Namespace) } diff --git a/deploy/helm/radar/README.md b/deploy/helm/radar/README.md index 9e3d8f9cb..88f6329a9 100644 --- a/deploy/helm/radar/README.md +++ b/deploy/helm/radar/README.md @@ -86,6 +86,7 @@ touches its contents. | `image.tag` | Image tag | Chart appVersion | | `service.type` | Service type | `ClusterIP` | | `service.port` | Service port | `9280` | +| `basePath` | URL prefix Radar serves under, e.g. `/radar` for no-strip-prefix subpath ingress | `""` | | `debug.image` | Image for ephemeral debug containers and node debug pods. In built-in restricted PodSecurity namespaces, pod debug containers may retry as the target/pod non-root UID, or UID `65532` by default; point at a compatible mirror for air-gapped / private-registry clusters. | `""` (busybox:latest) | | `listPageSize` | Paginate the initial LIST of high-cardinality kinds (Pods, ReplicaSets) on very large clusters; `0` = off, try `2000`. Only used when the apiserver lacks WatchList streaming. | `0` | | `ingress.enabled` | Enable ingress | `false` | diff --git a/deploy/helm/radar/templates/deployment.yaml b/deploy/helm/radar/templates/deployment.yaml index f048157d9..953909710 100644 --- a/deploy/helm/radar/templates/deployment.yaml +++ b/deploy/helm/radar/templates/deployment.yaml @@ -1,6 +1,11 @@ {{- if and .Values.persistence.enabled (eq .Values.timeline.storage "sqlite") (gt (int .Values.replicaCount) 1) }} {{- fail "replicaCount must be 1 when persistence.enabled=true and timeline.storage=sqlite: a single PVC cannot be safely shared across pods (RWO volumes won't attach to a second pod, RWX volumes risk SQLite corruption)" }} {{- end }} +{{- $basePath := "" -}} +{{- $trimmedBasePath := trimAll "/" (default "" .Values.basePath) -}} +{{- if $trimmedBasePath -}} +{{- $basePath = printf "/%s" $trimmedBasePath -}} +{{- end -}} apiVersion: apps/v1 kind: Deployment metadata: @@ -48,6 +53,9 @@ spec: args: - --port={{ .Values.service.port }} - --no-browser + {{- if $basePath }} + - {{ printf "--base-path=%s" $basePath | quote }} + {{- end }} - --timeline-storage={{ .Values.timeline.storage }} {{- if eq .Values.timeline.storage "sqlite" }} - --timeline-db={{ .Values.timeline.dbPath }} @@ -197,7 +205,7 @@ spec: {{- if .Values.probes.liveness.enabled }} livenessProbe: httpGet: - path: /api/health + path: {{ printf "%s/api/health" $basePath | quote }} port: http initialDelaySeconds: {{ .Values.probes.liveness.initialDelaySeconds }} periodSeconds: {{ .Values.probes.liveness.periodSeconds }} @@ -207,7 +215,7 @@ spec: {{- if .Values.probes.readiness.enabled }} readinessProbe: httpGet: - path: /api/health + path: {{ printf "%s/api/health" $basePath | quote }} port: http initialDelaySeconds: {{ .Values.probes.readiness.initialDelaySeconds }} periodSeconds: {{ .Values.probes.readiness.periodSeconds }} diff --git a/deploy/helm/radar/values.schema.json b/deploy/helm/radar/values.schema.json index ae857ae2f..8a586263b 100644 --- a/deploy/helm/radar/values.schema.json +++ b/deploy/helm/radar/values.schema.json @@ -116,6 +116,11 @@ "port": { "type": "integer", "minimum": 1, "maximum": 65535 } } }, + "basePath": { + "type": "string", + "pattern": "^$|^/?[^:?#{}*]+$", + "description": "Optional URL path prefix served by Radar itself, e.g. /radar. Use when ingress forwards the subpath without stripping it." + }, "ingress": { "type": "object", "additionalProperties": true, diff --git a/deploy/helm/radar/values.yaml b/deploy/helm/radar/values.yaml index aa9fbc63e..53dac7189 100644 --- a/deploy/helm/radar/values.yaml +++ b/deploy/helm/radar/values.yaml @@ -197,6 +197,11 @@ service: type: ClusterIP port: 9280 +# URL path prefix that Radar itself serves under, e.g. /radar. Set this when +# your ingress forwards a subpath through to the service without stripping it. +# Leave empty for root serving or for ingresses that rewrite /radar -> /. +basePath: "" + ingress: enabled: false className: "" diff --git a/docs/in-cluster.md b/docs/in-cluster.md index f36a5ac50..7a7dc9161 100644 --- a/docs/in-cluster.md +++ b/docs/in-cluster.md @@ -39,6 +39,26 @@ helm upgrade --install radar skyhook/radar \ -n radar -f values.yaml ``` +### Subpath Ingress (No Strip-Prefix) + +If your ingress forwards `/radar/...` to the Radar service as `/radar/...`, set `basePath` to the same prefix: + +```yaml +# values.yaml +basePath: /radar + +ingress: + enabled: true + className: nginx + hosts: + - host: tools.your-domain.com + paths: + - path: /radar + pathType: Prefix +``` + +Then open `https://tools.your-domain.com/radar/`. Do not set `basePath` when your ingress already rewrites `/radar` to `/`. + ### With Basic Authentication 1. **Create the auth secret:** @@ -295,6 +315,7 @@ See [Helm Chart README](../deploy/helm/radar/README.md) for all available values | `ingress.enabled` | Enable ingress | `false` | | `ingress.className` | Ingress class | `""` | | `service.port` | Service port | `9280` | +| `basePath` | URL prefix Radar serves under, e.g. `/radar` for no-strip-prefix subpath ingress | `""` | | `mcp.enabled` | Enable MCP server for AI tools | `true` | | `debug.image` | Image for ephemeral debug containers and node debug pods. In built-in restricted PodSecurity namespaces, pod debug containers may retry as the target/pod non-root UID, or UID `65532` by default; point at a compatible mirror for air-gapped / private-registry clusters. | `""` (busybox:latest) | | `listPageSize` | Paginate the initial LIST of high-cardinality kinds (Pods, ReplicaSets) on very large clusters that fail to sync; `0` = off, try `2000`. Only used when the apiserver lacks WatchList streaming. | `0` | diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index e09618252..47cf93a68 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -33,6 +33,7 @@ type AppConfig struct { KubeconfigDirs []string Namespace string Port int + BasePath string NoBrowser bool Browser string DevMode bool @@ -245,6 +246,7 @@ func CreateServer(cfg AppConfig) *server.Server { serverCfg := server.Config{ Port: cfg.Port, + BasePath: cfg.BasePath, DevMode: cfg.DevMode, StaticFS: static.FS, StaticRoot: "dist", diff --git a/internal/server/base_path_test.go b/internal/server/base_path_test.go new file mode 100644 index 000000000..6d6addb50 --- /dev/null +++ b/internal/server/base_path_test.go @@ -0,0 +1,164 @@ +package server + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "testing/fstest" +) + +func TestNormalizeBasePath(t *testing.T) { + tests := []struct { + name string + raw string + want string + wantErr bool + }{ + {name: "empty", raw: "", want: ""}, + {name: "root", raw: "/", want: ""}, + {name: "adds leading slash", raw: "radar", want: "/radar"}, + {name: "trims trailing slash", raw: "/radar/", want: "/radar"}, + {name: "nested path", raw: "/tools/radar/", want: "/tools/radar"}, + {name: "query rejected", raw: "/radar?x=1", wantErr: true}, + {name: "fragment rejected", raw: "/radar#top", wantErr: true}, + {name: "url rejected", raw: "https://example.com/radar", wantErr: true}, + {name: "route pattern rejected", raw: "/{tenant}/radar", wantErr: true}, + {name: "dot segment rejected", raw: "/radar/../api", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := NormalizeBasePath(tt.raw) + if tt.wantErr { + if err == nil { + t.Fatalf("NormalizeBasePath(%q) returned nil error", tt.raw) + } + return + } + if err != nil { + t.Fatalf("NormalizeBasePath(%q) returned error: %v", tt.raw, err) + } + if got != tt.want { + t.Fatalf("NormalizeBasePath(%q) = %q, want %q", tt.raw, got, tt.want) + } + }) + } +} + +func TestRewriteFrontendIndexInjectsBasePathRuntimeConfig(t *testing.T) { + input := []byte(` + + + + + + + + + +`) + + got := string(rewriteFrontendIndex(input, "/radar")) + for _, want := range []string{ + `window.__RADAR_RUNTIME_CONFIG__={"basePath":"/radar","apiBase":"/radar/api","assetBase":"/radar"};`, + `href="/radar/favicon.svg"`, + `src="/radar/assets/index.js"`, + `href="/radar/assets/vendor.js"`, + `src="/radar/images/radar/radar-icon.svg"`, + } { + if !strings.Contains(got, want) { + t.Fatalf("rewritten index missing %q:\n%s", want, got) + } + } + + runtimeIdx := strings.Index(got, `window.__RADAR_RUNTIME_CONFIG__`) + moduleIdx := strings.Index(got, ``) + got := rewriteFrontendIndex(input, "") + if string(got) != string(input) { + t.Fatalf("root base path should not rewrite index:\ngot %s\nwant %s", got, input) + } +} + +func TestRewriteFrontendIndexRootMakesRelativeAssetsAbsolute(t *testing.T) { + input := []byte(``) + got := string(rewriteFrontendIndex(input, "")) + for _, want := range []string{ + `src="/assets/index.js"`, + `href="/assets/index.css"`, + } { + if !strings.Contains(got, want) { + t.Fatalf("rewritten root index missing %q:\n%s", want, got) + } + } + if strings.Contains(got, `__RADAR_RUNTIME_CONFIG__`) { + t.Fatalf("root base path should not inject runtime config:\n%s", got) + } +} + +func TestFrontendHandlerRewritesIndexFallbackButNotAssets(t *testing.T) { + fsys := http.FS(fstest.MapFS{ + "index.html": &fstest.MapFile{Data: []byte(``)}, + "assets/index.js": &fstest.MapFile{Data: []byte(`console.log("ok")`)}, + }) + handler := frontendHandler(fsys, "/radar") + + indexResp := httptest.NewRecorder() + handler.ServeHTTP(indexResp, httptest.NewRequest(http.MethodGet, "/topology", nil)) + if indexResp.Code != http.StatusOK { + t.Fatalf("client-route fallback status = %d, want 200", indexResp.Code) + } + indexBody := indexResp.Body.String() + if !strings.Contains(indexBody, `window.__RADAR_RUNTIME_CONFIG__={"basePath":"/radar","apiBase":"/radar/api","assetBase":"/radar"};`) { + t.Fatalf("client-route fallback did not inject runtime config:\n%s", indexBody) + } + if !strings.Contains(indexBody, `src="/radar/assets/index.js"`) { + t.Fatalf("client-route fallback did not rewrite asset src:\n%s", indexBody) + } + + assetResp := httptest.NewRecorder() + handler.ServeHTTP(assetResp, httptest.NewRequest(http.MethodGet, "/radar/assets/index.js", nil)) + if assetResp.Code != http.StatusOK { + t.Fatalf("asset status = %d, want 200", assetResp.Code) + } + assetBody, err := io.ReadAll(assetResp.Result().Body) + if err != nil { + t.Fatalf("read asset response: %v", err) + } + if string(assetBody) != `console.log("ok")` { + t.Fatalf("asset body = %q", assetBody) + } +} + +func TestServerMountsRoutesUnderBasePath(t *testing.T) { + srv := New(Config{DevMode: true, BasePath: "/radar"}) + + rootResp := httptest.NewRecorder() + srv.Handler().ServeHTTP(rootResp, httptest.NewRequest(http.MethodGet, "/?namespace=default", nil)) + if rootResp.Code != http.StatusFound { + t.Fatalf("root status = %d, want 302", rootResp.Code) + } + if got := rootResp.Header().Get("Location"); got != "/radar/?namespace=default" { + t.Fatalf("root redirect Location = %q, want /radar/?namespace=default", got) + } + + unprefixedResp := httptest.NewRecorder() + srv.Handler().ServeHTTP(unprefixedResp, httptest.NewRequest(http.MethodGet, "/api/health", nil)) + if unprefixedResp.Code != http.StatusNotFound { + t.Fatalf("unprefixed API status = %d, want 404", unprefixedResp.Code) + } + + prefixedResp := httptest.NewRecorder() + srv.Handler().ServeHTTP(prefixedResp, httptest.NewRequest(http.MethodGet, "/radar/api/health", nil)) + if prefixedResp.Code != http.StatusOK { + t.Fatalf("prefixed API status = %d, want 200", prefixedResp.Code) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 777ee6aac..a518fa8e0 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -1,6 +1,7 @@ package server import ( + "bytes" "context" "embed" "encoding/json" @@ -13,6 +14,7 @@ import ( "net/http" "net/http/pprof" "net/url" + pathpkg "path" "reflect" "runtime" "slices" @@ -58,6 +60,7 @@ type Server struct { router *chi.Mux broadcaster *SSEBroadcaster port int + basePath string devMode bool staticFS fs.FS startTime time.Time @@ -105,6 +108,7 @@ type Server struct { // Config holds server configuration type Config struct { Port int + BasePath string // Optional URL path prefix for self-hosted subpath deployments DevMode bool // Serve frontend from filesystem instead of embedded StaticFS embed.FS // Embedded frontend files StaticRoot string // Path within StaticFS @@ -117,11 +121,16 @@ type Config struct { // New creates a new server instance func New(cfg Config) *Server { cfg.AuthConfig.Defaults() + basePath, err := NormalizeBasePath(cfg.BasePath) + if err != nil { + log.Fatalf("Invalid base path %q: %v", cfg.BasePath, err) + } s := &Server{ router: chi.NewRouter(), broadcaster: NewSSEBroadcaster(), port: cfg.Port, + basePath: basePath, devMode: cfg.DevMode, startTime: time.Now(), mcpHandler: cfg.MCPHandler, @@ -204,7 +213,30 @@ func New(cfg Config) *Server { } func (s *Server) setupRoutes() { - r := s.router + if s.basePath != "" { + appRouter := chi.NewRouter() + s.setupAppRoutes(appRouter) + s.router.Get("/", func(w http.ResponseWriter, r *http.Request) { + target := s.basePath + "/" + if r.URL.RawQuery != "" { + target += "?" + r.URL.RawQuery + } + http.Redirect(w, r, target, http.StatusFound) + }) + s.router.Get(s.basePath, func(w http.ResponseWriter, r *http.Request) { + target := s.basePath + "/" + if r.URL.RawQuery != "" { + target += "?" + r.URL.RawQuery + } + http.Redirect(w, r, target, http.StatusMovedPermanently) + }) + s.router.Mount(s.basePath, appRouter) + return + } + s.setupAppRoutes(s.router) +} + +func (s *Server) setupAppRoutes(r chi.Router) { // Middleware (applied to all routes) r.Use(middleware.Logger) @@ -568,26 +600,75 @@ func (s *Server) setupRoutes() { // Static files (frontend) - index.html fallback for client-side routes. if s.staticFS != nil { - r.Handle("/*", frontendHandler(http.FS(s.staticFS))) + r.Handle("/*", frontendHandler(http.FS(s.staticFS), s.basePath)) } else if s.devMode { // In dev mode, serve from web/dist - r.Handle("/*", frontendHandler(http.Dir("web/dist"))) + r.Handle("/*", frontendHandler(http.Dir("web/dist"), s.basePath)) + } +} + +// NormalizeBasePath canonicalizes the optional URL prefix used when Radar is +// served behind an ingress path like /radar. Empty and "/" mean root. +func NormalizeBasePath(raw string) (string, error) { + p := strings.TrimSpace(raw) + if p == "" || p == "/" { + return "", nil + } + if strings.ContainsAny(p, "?#") { + return "", fmt.Errorf("must be a path only, without query or fragment") + } + if strings.ContainsAny(p, "{}*") { + return "", fmt.Errorf("must not contain route pattern characters") + } + if strings.Contains(p, "://") || strings.HasPrefix(p, "//") { + return "", fmt.Errorf("must be a path, not a URL") + } + if !strings.HasPrefix(p, "/") { + p = "/" + p + } + for _, segment := range strings.Split(p, "/") { + if segment == "." || segment == ".." { + return "", fmt.Errorf("must not contain . or .. path segments") + } + } + clean := pathpkg.Clean(p) + if clean == "/" || clean == "." { + return "", nil } + return clean, nil } // frontendHandler serves static files, falling back to index.html for client-side routing -func frontendHandler(fsys http.FileSystem) http.Handler { +func frontendHandler(fsys http.FileSystem, basePath string) http.Handler { fileServer := http.FileServer(fsys) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { path := r.URL.Path + serveReq := r + if basePath != "" { + switch { + case path == basePath: + path = "/" + case strings.HasPrefix(path, basePath+"/"): + path = strings.TrimPrefix(path, basePath) + } + if path != r.URL.Path { + serveReq = r.Clone(r.Context()) + serveReq.URL = cloneURL(r.URL) + serveReq.URL.Path = path + } + } + + if path == "/" || path == "/index.html" { + serveFrontendIndex(w, r, fsys, basePath) + return + } // Try to open the file f, err := fsys.Open(path) if err != nil { // File doesn't exist - serve index.html for client-side routing - r.URL.Path = "/" - fileServer.ServeHTTP(w, r) + serveFrontendIndex(w, r, fsys, basePath) return } defer f.Close() @@ -596,13 +677,76 @@ func frontendHandler(fsys http.FileSystem) http.Handler { stat, err := f.Stat() if err != nil || (stat.IsDir() && path != "/") { // For directories without index.html, serve root index.html - r.URL.Path = "/" + serveFrontendIndex(w, r, fsys, basePath) + return } - fileServer.ServeHTTP(w, r) + fileServer.ServeHTTP(w, serveReq) }) } +func cloneURL(u *url.URL) *url.URL { + cloned := *u + return &cloned +} + +func serveFrontendIndex(w http.ResponseWriter, r *http.Request, fsys http.FileSystem, basePath string) { + f, err := fsys.Open("/index.html") + if err != nil { + http.NotFound(w, r) + return + } + defer f.Close() + + stat, err := f.Stat() + if err != nil { + http.NotFound(w, r) + return + } + body, err := io.ReadAll(f) + if err != nil { + http.Error(w, "failed to read frontend index", http.StatusInternalServerError) + return + } + body = rewriteFrontendIndex(body, basePath) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + http.ServeContent(w, r, "index.html", stat.ModTime(), bytes.NewReader(body)) +} + +func rewriteFrontendIndex(body []byte, basePath string) []byte { + html := string(body) + if basePath != "" { + html = strings.ReplaceAll(html, `href="/`, `href="`+basePath+`/`) + html = strings.ReplaceAll(html, `src="/`, `src="`+basePath+`/`) + } + html = strings.ReplaceAll(html, `href="./`, `href="`+basePath+`/`) + html = strings.ReplaceAll(html, `src="./`, `src="`+basePath+`/`) + if basePath == "" { + return []byte(html) + } + cfg := struct { + BasePath string `json:"basePath"` + ApiBase string `json:"apiBase"` + AssetBase string `json:"assetBase"` + }{ + BasePath: basePath, + ApiBase: basePath + "/api", + AssetBase: basePath, + } + cfgJSON, err := json.Marshal(cfg) + if err != nil { + return body + } + + runtimeScript := `` + if strings.Contains(html, `