Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ Use `/visual-test` command for the full workflow (cluster check, Playwright MCP,
- GitOps detail data: `/api/gitops/tree/{kind}/{ns}/{name}` (resource tree + ownership edges), `/api/gitops/insights/{kind}/{ns}/{name}` (curated diagnosis: summary + issues + drift + events + plan + history + capabilities)
- Nodes: `/api/nodes/{name}/...` (cordon, uncordon, drain, debug)
- Audit: `/api/audit`, `/api/audit/resource/{kind}/{ns}/{name}`, `/api/settings/audit` (GET/PUT)
- Network trace: `/api/trace/{kind}/{ns}/{name}` (path-shaped diagnosis for Service/Ingress/HTTPRoute/GRPCRoute/Gateway; `?probe=true` runs DNS/TCP/TLS/HTTP probes against the declared path - direct TCP in-cluster, K8s API server proxy from a laptop, gated by the user's `services/proxy` + `pods/proxy` RBAC).
- CAPI: `/api/capi/clusters/{ns}/{name}/kubeconfig` (GET), `/api/capi/clusters/{ns}/{name}/connect` (POST)
- RBAC reverse-lookup: `/api/rbac/subject/{kind}/{namespace}/{name}` (ServiceAccount) and `/api/rbac/subject/{kind}/{name}` (User/Group) return direct + group-inherited bindings + flattened effective rules. SA subjects also get a `usedByPods` list (Pods whose `spec.serviceAccountName` matches — closes the loop on the SA detail page). `/api/rbac/role/{kind}/{namespace}/{name}` (use `_` for ClusterRole's empty namespace) returns the inverse — bindings that reference the role + their subjects. `/api/rbac/namespace/{namespace}` returns RoleBindings in the namespace + ClusterRoleBindings with at least one SA subject in it + a ServiceAccount count (backs the NamespaceRenderer's RBAC section; group-only ClusterRoleBindings like `system:authenticated` grants are deliberately excluded — they'd appear in every namespace and would be noise). `/api/rbac/whoami?namespace=...` is a pass-through of `SelfSubjectRulesReview` for the current user. Backed by `pkg/rbac/` (pure index + 5s TTL memo); endpoints gate on `list rolebindings` AND `list clusterrolebindings` (403 when either is denied — silent partial views would mislead operators).

Expand Down
17 changes: 17 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,23 @@ deploy-test: frontend embed
kubectl rollout status deploy/$(CLUSTER_DEPLOY) -n $(CLUSTER_NS) --timeout=60s
@echo "=== Done. Tail logs: kubectl logs -n $(CLUSTER_NS) -l app.kubernetes.io/name=$(CLUSTER_DEPLOY) -f ==="

# Build a probe image from the CURRENT code and load it into a kind cluster, for
# developing the in-cluster reachability probe. A -dirty local build's version is
# not a published tag, so the default image won't exist - load a local one and run
# radar with --reachability-image $(PROBE_IMAGE). Binary path /radar + ENTRYPOINT
# match the official image so the probe Job's `["/radar","probe",...]` command works.
KIND_CLUSTER ?= test
PROBE_IMAGE ?= radar-probe:dev
kind-load-probe:
@echo "Building probe binary for linux/$$(go env GOARCH)..."
GOOS=linux GOARCH=$$(go env GOARCH) CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o /tmp/radar-probe-bin ./cmd/explorer
@echo 'FROM gcr.io/distroless/static-debian12:nonroot' > /tmp/Dockerfile.probe
@echo 'COPY radar-probe-bin /radar' >> /tmp/Dockerfile.probe
@echo 'ENTRYPOINT ["/radar"]' >> /tmp/Dockerfile.probe
docker build -t $(PROBE_IMAGE) -f /tmp/Dockerfile.probe /tmp
kind load docker-image $(PROBE_IMAGE) --name $(KIND_CLUSTER)
@echo "Loaded $(PROBE_IMAGE) into kind/$(KIND_CLUSTER). Run radar with: --reachability-image $(PROBE_IMAGE)"

## Build targets

# Build the complete application (frontend + embedded binary)
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,18 @@ Proactive best-practices scanner with 31 checks across security, reliability, an
- Framework labels: NSA/CISA, CIS benchmarks
- MCP tool (`get_cluster_audit`) for AI-assisted cluster analysis

### Network Path Diagnose

Hop-ordered diagnosis for Service, Ingress, HTTPRoute, GRPCRoute, and Gateway - answering "if traffic is sent toward this resource, does it reach a healthy process, and if not which hop breaks first?"

- Composes the detections Radar already runs (missing backend Service, port mismatches, no-ready-endpoints, route not Accepted by parent Gateway, readiness probe targeting the wrong port) into a path shape ordered along the traffic flow
- Upstreams (Ingresses / Routes pointing at a Service) are judged independently - one broken Ingress doesn't condemn the other delivery paths
- First critical hop is named explicitly so the operator can localize the break without reading the whole list; each finding ships a kubectl reproducer
- **Optional one-shot reachability test** runs DNS / TCP / TLS / HTTP probes against the declared path - direct TCP when Radar is in-cluster, K8s API server proxy when running from a laptop - so the same button works regardless of where Radar runs. Probes never override the static verdict; they add evidence.
- NetworkPolicies that select the subject's pods are statically evaluated for their caller-independent ingress rules: a "would block" **WARNING prediction** when no rule admits the path's port, a source-restricted advisory, or an outbound egress note. It's a prediction, never a verdict - the CNI is the only enforcement authority, so the live in-cluster probe confirms or downgrades it
- Static trace is pure functions over the in-memory informer cache. Active probing from a laptop uses the cluster's normal RBAC (`get services/proxy`, `get pods/proxy`); in-cluster mode goes directly to the data path.
- Exposed via the **Diagnose** tab in the resource detail view and via the MCP `diagnose` tool for AI consumers - see [docs/diagnose.md](docs/diagnose.md)

### Access Control (RBAC visibility)

Inspect what any ServiceAccount can actually do — without three `kubectl describe` calls.
Expand Down
27 changes: 27 additions & 0 deletions cmd/explorer/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"github.com/skyhook-io/radar/internal/config"
"github.com/skyhook-io/radar/internal/k8s"
mcppkg "github.com/skyhook-io/radar/internal/mcp"
"github.com/skyhook-io/radar/internal/reachability"
"github.com/skyhook-io/radar/internal/server"
"golang.org/x/net/http/httpguts"
_ "k8s.io/client-go/plugin/pkg/client/auth" // Register all auth provider plugins (OIDC, GCP, Azure, etc.)
Expand All @@ -34,13 +35,27 @@ var (
func main() {
startupStart := time.Now()

// Subcommand dispatch: `radar probe` runs a one-shot reachability probe and
// exits - no server, no cluster client. The in-cluster reachability runner
// executes this binary in that mode. Intercept before the server flag
// parsing, since "probe" is a positional argument, not a flag.
if len(os.Args) > 1 && os.Args[1] == "probe" {
os.Exit(runProbeCommand(os.Args[2:]))
}

// Propagate the build-time version to the cloud dialer so the agent
// advertises the real version (e.g. "1.5.5") on the tunnel handshake
// instead of the "dev" default. Dockerfile + Makefile inject
// `-X main.version=...`; mirror it here rather than adding a second
// ldflags target so there's a single source of truth.
cloud.Version = version

// The in-cluster reachability probe Job runs the version-matched published
// Radar image by default (overridable via --reachability-image / RADAR_IMAGE).
// The flag override is applied just after it is parsed (see below) so BOTH the
// REST handler and the MCP diagnose(inCluster) path resolve the same image.
reachability.DefaultImageRef = "ghcr.io/skyhook-io/radar:" + version

// Load persistent config (~/.radar/config.json) for flag defaults.
// CLI flags override config file values.
fileCfg := config.Load()
Expand All @@ -62,6 +77,7 @@ func main() {
disableLocalTerminal := flag.Bool("disable-local-terminal", false, "Disable local terminal feature")
podShellDefault := flag.String("pod-shell-default", "", "Override the default pod exec shell command (runs as 'sh -c <value>'; empty = built-in bash -il → ash → sh cascade)")
debugImage := flag.String("debug-image", fileCfg.DebugImage, "Image for ephemeral debug containers and node debug pods (empty = busybox:latest; point at a mirror for air-gapped/private-registry clusters)")
reachabilityImage := flag.String("reachability-image", fileCfg.ReachabilityImage, "Image for the in-cluster reachability probe Job (empty = RADAR_IMAGE env, then the version-matched published Radar image; point at a mirror for air-gapped clusters)")
listPageSize := flag.Int64("list-page-size", 0, "Paginate the initial LIST of high-cardinality kinds (Pods, ReplicaSets) at this page size on clusters without WatchList streaming. 0 = off (single LIST). Try 2000 if a very large cluster fails to sync.")
namespaceScope := flag.Bool("namespace-scope", false, "Scope namespaced informer caches to a single namespace (multiple namespaces are not supported yet). Requires --namespace or a kubeconfig context namespace. Local mode can rescope by switching namespaces; auth/cloud mode locks to the startup namespace.")
// Timeline storage options
Expand Down Expand Up @@ -119,6 +135,16 @@ func main() {
maxScopeCandidates := flag.Int("max-scope-candidates", k8s.EnvIntOr("RADAR_MAX_SCOPE_CANDIDATES", 20), "Cap on the namespace-fallback probe fanout for users who can list namespaces cluster-wide but not list a specific kind cluster-wide (default: 20). Raise for clusters with more than 20 namespaces to avoid silently marking kinds as denied in dropped namespaces. Env: RADAR_MAX_SCOPE_CANDIDATES")
flag.Parse()

// An explicit --reachability-image override applies to BOTH probe paths. It must
// win over the in-cluster self-read, so record it as the configured override
// (checked ABOVE self-read), not as DefaultImageRef (the last-resort fallback,
// below self-read - which would let radar's own pod image beat the operator's
// explicit choice on the MCP path). REST passes the config as the override arg;
// MCP has no server config, so it relies on this. Air-gapped mirrors work on both.
if *reachabilityImage != "" {
reachability.SetConfiguredImage(*reachabilityImage)
}

// Cloud-mode: Radar runs inside a customer cluster and fronts Radar
// Cloud. Under cloud-mode the tunnel is the only path to this listener
// and it delivers Cloud-authenticated identity headers on every request.
Expand Down Expand Up @@ -212,6 +238,7 @@ func main() {
DisableLocalTerminal: *disableLocalTerminal,
PodShellDefault: *podShellDefault,
DebugImage: *debugImage,
ReachabilityImage: *reachabilityImage,
ListPageSize: *listPageSize,
NamespaceScope: *namespaceScope,
TimelineStorage: *timelineStorage,
Expand Down
94 changes: 94 additions & 0 deletions cmd/explorer/probe_cmd.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package main

import (
"context"
"encoding/json"
"flag"
"fmt"
"net"
"os"
"strings"
"time"

"github.com/skyhook-io/radar/pkg/probe"
)

// runProbeCommand implements `radar probe` - run reachability probes against ONE
// target from wherever this process runs and print the results as JSON. When the
// process runs inside a cluster pod, the probes traverse the real dataplane
// (subject to NetworkPolicy + mesh mTLS), which is exactly what the laptop's
// apiserver-proxy view cannot confirm. No HTTP server, no Kubernetes client:
// probe-and-exit. This is the binary the in-cluster reachability runner executes
// (the Radar image invoked as `radar probe ...`).
func runProbeCommand(args []string) int {
fs := flag.NewFlagSet("probe", flag.ContinueOnError)
target := fs.String("target", "", "host:port to probe (host alone for a dns-only check)")
host := fs.String("host", "", "TLS SNI / HTTP Host header (default: the host part of --target)")
scheme := fs.String("scheme", "http", "http or https (https adds a TLS check and uses an https URL)")
path := fs.String("path", "/", "request path for the HTTP probe")
layersFlag := fs.String("layers", "tcp,http", "comma-separated layers: dns,tcp,tls,http")
timeout := fs.Duration("timeout", 3*time.Second, "overall probe budget")
jsonOut := fs.Bool("json", true, "emit JSON (false = one human line per probe)")
if err := fs.Parse(args); err != nil {
return 2
}
if *target == "" {
fmt.Fprintln(os.Stderr, "radar probe: --target host:port is required")
return 2
}

h := *host
if h == "" {
if hp, _, err := net.SplitHostPort(*target); err == nil && hp != "" {
h = hp
} else {
h = *target
}
}

ctx, cancel := context.WithTimeout(context.Background(), *timeout)
defer cancel()
vantage := probe.DetectVantage()

want := map[string]bool{}
for _, l := range strings.Split(*layersFlag, ",") {
if t := strings.TrimSpace(strings.ToLower(l)); t != "" {
want[t] = true
}
}

var results []probe.Result
if want["dns"] {
results = append(results, probe.DNS(ctx, h, vantage))
}
if want["tcp"] {
results = append(results, probe.TCP(ctx, *target, vantage))
}
if want["tls"] || strings.EqualFold(*scheme, "https") {
results = append(results, probe.TLS(ctx, *target, h, vantage))
}
if want["http"] {
reqPath := *path
if !strings.HasPrefix(reqPath, "/") {
reqPath = "/" + reqPath
}
results = append(results, probe.HTTP(ctx, fmt.Sprintf("%s://%s%s", *scheme, *target, reqPath), h, vantage))
}

if *jsonOut {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(results); err != nil {
return 1
}
return 0
}
for _, r := range results {
detail := r.Detail
if detail == "" {
detail = r.Error
}
fmt.Printf("%-5s %-22s ok=%-5v tone=%-9s %s\n", r.Layer, r.Target, r.OK, r.Tone, detail)
}
return 0
}
12 changes: 11 additions & 1 deletion deploy/helm/radar/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -183,11 +183,21 @@ spec:
{{- end }}
{{- end }}
{{- end }}
{{- if .Values.rbac.selfUpgrade }}
- name: MY_POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: MY_POD_NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
# Pin the in-cluster reachability probe to radar's own deployed image.
# This is the no-RBAC fallback for when the runtime self-read of radar's
# pod can't run; an explicit --reachability-image / config still wins,
# and the self-read (radar's live pod image) is preferred when available.
- name: RADAR_IMAGE
value: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
{{- if .Values.rbac.selfUpgrade }}
- name: MY_DEPLOYMENT_NAME
value: {{ include "radar.fullname" . }}
{{- end }}
Expand Down
Loading
Loading