Skip to content

Commit 7bbc3ba

Browse files
committed
Reachability: honest, DevOps-grade network-path diagnosis
1 parent b655fac commit 7bbc3ba

80 files changed

Lines changed: 24008 additions & 54 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ Use `/visual-test` command for the full workflow (cluster check, Playwright MCP,
137137
- 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)
138138
- Nodes: `/api/nodes/{name}/...` (cordon, uncordon, drain, debug)
139139
- Audit: `/api/audit`, `/api/audit/resource/{kind}/{ns}/{name}`, `/api/settings/audit` (GET/PUT)
140+
- 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).
140141
- CAPI: `/api/capi/clusters/{ns}/{name}/kubeconfig` (GET), `/api/capi/clusters/{ns}/{name}/connect` (POST)
141142
- 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).
142143

Makefile

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,23 @@ deploy-test: frontend embed
2828
kubectl rollout status deploy/$(CLUSTER_DEPLOY) -n $(CLUSTER_NS) --timeout=60s
2929
@echo "=== Done. Tail logs: kubectl logs -n $(CLUSTER_NS) -l app.kubernetes.io/name=$(CLUSTER_DEPLOY) -f ==="
3030

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

3350
# Build the complete application (frontend + embedded binary)

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,18 @@ Proactive best-practices scanner with 31 checks across security, reliability, an
362362
- Framework labels: NSA/CISA, CIS benchmarks
363363
- MCP tool (`get_cluster_audit`) for AI-assisted cluster analysis
364364

365+
### Network Path Diagnose
366+
367+
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?"
368+
369+
- 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
370+
- Upstreams (Ingresses / Routes pointing at a Service) are judged independently — one broken Ingress doesn't condemn the other delivery paths
371+
- First critical hop is named explicitly so the operator can localize the break without reading the whole list; each finding ships a kubectl reproducer
372+
- **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.
373+
- 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
374+
- 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.
375+
- 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)
376+
365377
### Access Control (RBAC visibility)
366378

367379
Inspect what any ServiceAccount can actually do — without three `kubectl describe` calls.

cmd/explorer/main.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"github.com/skyhook-io/radar/internal/config"
2222
"github.com/skyhook-io/radar/internal/k8s"
2323
mcppkg "github.com/skyhook-io/radar/internal/mcp"
24+
"github.com/skyhook-io/radar/internal/reachability"
2425
"github.com/skyhook-io/radar/internal/server"
2526
"golang.org/x/net/http/httpguts"
2627
_ "k8s.io/client-go/plugin/pkg/client/auth" // Register all auth provider plugins (OIDC, GCP, Azure, etc.)
@@ -34,13 +35,27 @@ var (
3435
func main() {
3536
startupStart := time.Now()
3637

38+
// Subcommand dispatch: `radar probe` runs a one-shot reachability probe and
39+
// exits — no server, no cluster client. The in-cluster reachability runner
40+
// executes this binary in that mode. Intercept before the server flag
41+
// parsing, since "probe" is a positional argument, not a flag.
42+
if len(os.Args) > 1 && os.Args[1] == "probe" {
43+
os.Exit(runProbeCommand(os.Args[2:]))
44+
}
45+
3746
// Propagate the build-time version to the cloud dialer so the agent
3847
// advertises the real version (e.g. "1.5.5") on the tunnel handshake
3948
// instead of the "dev" default. Dockerfile + Makefile inject
4049
// `-X main.version=...`; mirror it here rather than adding a second
4150
// ldflags target so there's a single source of truth.
4251
cloud.Version = version
4352

53+
// The in-cluster reachability probe Job runs the version-matched published
54+
// Radar image by default (overridable via --reachability-image / RADAR_IMAGE).
55+
// The flag override is applied just after it is parsed (see below) so BOTH the
56+
// REST handler and the MCP diagnose(inCluster) path resolve the same image.
57+
reachability.DefaultImageRef = "ghcr.io/skyhook-io/radar:" + version
58+
4459
// Load persistent config (~/.radar/config.json) for flag defaults.
4560
// CLI flags override config file values.
4661
fileCfg := config.Load()
@@ -62,6 +77,7 @@ func main() {
6277
disableLocalTerminal := flag.Bool("disable-local-terminal", false, "Disable local terminal feature")
6378
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)")
6479
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)")
80+
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)")
6581
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.")
6682
// Timeline storage options
6783
timelineStorage := flag.String("timeline-storage", fileCfg.TimelineStorageOr("memory"), "Timeline storage backend: memory or sqlite")
@@ -118,6 +134,16 @@ func main() {
118134
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")
119135
flag.Parse()
120136

137+
// An explicit --reachability-image override applies to BOTH probe paths. It must
138+
// win over the in-cluster self-read, so record it as the configured override
139+
// (checked ABOVE self-read), not as DefaultImageRef (the last-resort fallback,
140+
// below self-read — which would let radar's own pod image beat the operator's
141+
// explicit choice on the MCP path). REST passes the config as the override arg;
142+
// MCP has no server config, so it relies on this. Air-gapped mirrors work on both.
143+
if *reachabilityImage != "" {
144+
reachability.SetConfiguredImage(*reachabilityImage)
145+
}
146+
121147
// Cloud-mode: Radar runs inside a customer cluster and fronts Radar
122148
// Cloud. Under cloud-mode the tunnel is the only path to this listener
123149
// and it delivers Cloud-authenticated identity headers on every request.
@@ -211,6 +237,7 @@ func main() {
211237
DisableLocalTerminal: *disableLocalTerminal,
212238
PodShellDefault: *podShellDefault,
213239
DebugImage: *debugImage,
240+
ReachabilityImage: *reachabilityImage,
214241
ListPageSize: *listPageSize,
215242
TimelineStorage: *timelineStorage,
216243
TimelineDBPath: *timelineDBPath,

cmd/explorer/probe_cmd.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"flag"
7+
"fmt"
8+
"net"
9+
"os"
10+
"strings"
11+
"time"
12+
13+
"github.com/skyhook-io/radar/pkg/probe"
14+
)
15+
16+
// runProbeCommand implements `radar probe` — run reachability probes against ONE
17+
// target from wherever this process runs and print the results as JSON. When the
18+
// process runs inside a cluster pod, the probes traverse the real dataplane
19+
// (subject to NetworkPolicy + mesh mTLS), which is exactly what the laptop's
20+
// apiserver-proxy view cannot confirm. No HTTP server, no Kubernetes client:
21+
// probe-and-exit. This is the binary the in-cluster reachability runner executes
22+
// (the Radar image invoked as `radar probe ...`).
23+
func runProbeCommand(args []string) int {
24+
fs := flag.NewFlagSet("probe", flag.ContinueOnError)
25+
target := fs.String("target", "", "host:port to probe (host alone for a dns-only check)")
26+
host := fs.String("host", "", "TLS SNI / HTTP Host header (default: the host part of --target)")
27+
scheme := fs.String("scheme", "http", "http or https (https adds a TLS check and uses an https URL)")
28+
path := fs.String("path", "/", "request path for the HTTP probe")
29+
layersFlag := fs.String("layers", "tcp,http", "comma-separated layers: dns,tcp,tls,http")
30+
timeout := fs.Duration("timeout", 3*time.Second, "overall probe budget")
31+
jsonOut := fs.Bool("json", true, "emit JSON (false = one human line per probe)")
32+
if err := fs.Parse(args); err != nil {
33+
return 2
34+
}
35+
if *target == "" {
36+
fmt.Fprintln(os.Stderr, "radar probe: --target host:port is required")
37+
return 2
38+
}
39+
40+
h := *host
41+
if h == "" {
42+
if hp, _, err := net.SplitHostPort(*target); err == nil && hp != "" {
43+
h = hp
44+
} else {
45+
h = *target
46+
}
47+
}
48+
49+
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
50+
defer cancel()
51+
vantage := probe.DetectVantage()
52+
53+
want := map[string]bool{}
54+
for _, l := range strings.Split(*layersFlag, ",") {
55+
if t := strings.TrimSpace(strings.ToLower(l)); t != "" {
56+
want[t] = true
57+
}
58+
}
59+
60+
var results []probe.Result
61+
if want["dns"] {
62+
results = append(results, probe.DNS(ctx, h, vantage))
63+
}
64+
if want["tcp"] {
65+
results = append(results, probe.TCP(ctx, *target, vantage))
66+
}
67+
if want["tls"] || strings.EqualFold(*scheme, "https") {
68+
results = append(results, probe.TLS(ctx, *target, h, vantage))
69+
}
70+
if want["http"] {
71+
reqPath := *path
72+
if !strings.HasPrefix(reqPath, "/") {
73+
reqPath = "/" + reqPath
74+
}
75+
results = append(results, probe.HTTP(ctx, fmt.Sprintf("%s://%s%s", *scheme, *target, reqPath), h, vantage))
76+
}
77+
78+
if *jsonOut {
79+
enc := json.NewEncoder(os.Stdout)
80+
enc.SetIndent("", " ")
81+
if err := enc.Encode(results); err != nil {
82+
return 1
83+
}
84+
return 0
85+
}
86+
for _, r := range results {
87+
detail := r.Detail
88+
if detail == "" {
89+
detail = r.Error
90+
}
91+
fmt.Printf("%-5s %-22s ok=%-5v tone=%-9s %s\n", r.Layer, r.Target, r.OK, r.Tone, detail)
92+
}
93+
return 0
94+
}

deploy/helm/radar/templates/deployment.yaml

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,11 +179,21 @@ spec:
179179
{{- end }}
180180
{{- end }}
181181
{{- end }}
182-
{{- if .Values.rbac.selfUpgrade }}
182+
- name: MY_POD_NAME
183+
valueFrom:
184+
fieldRef:
185+
fieldPath: metadata.name
183186
- name: MY_POD_NAMESPACE
184187
valueFrom:
185188
fieldRef:
186189
fieldPath: metadata.namespace
190+
# Pin the in-cluster reachability probe to radar's own deployed image.
191+
# This is the no-RBAC fallback for when the runtime self-read of radar's
192+
# pod can't run; an explicit --reachability-image / config still wins,
193+
# and the self-read (radar's live pod image) is preferred when available.
194+
- name: RADAR_IMAGE
195+
value: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
196+
{{- if .Values.rbac.selfUpgrade }}
187197
- name: MY_DEPLOYMENT_NAME
188198
value: {{ include "radar.fullname" . }}
189199
{{- end }}

0 commit comments

Comments
 (0)