From c4f799b19b756e9e77953d5252e1a5cf276b4136 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Wed, 29 Jul 2026 11:59:00 -0400 Subject: [PATCH] Feat: one-command local quickstart (--demo) + Cortex README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `authbridge-proxy --demo`: a built-in demo config (forward-only proxy + TLS bridge with an auto-generated CA + the LLM/MCP/A2A parsers). No absolute path is baked in — the config is written to demo.yaml inside ca_dir (default ./cortex-ca relative to the cwd; override with --ca-dir), next to the generated CA, and loaded through the normal file path, so editing it hot-reloads live (the reloader watches by filename, so the co-located CA writes don't trigger spurious reloads). An agent's egress is decrypted and parsed with no cluster, Keycloak, or SPIRE. Mutually exclusive with --config. The demo binds the listeners it uses — forward proxy :8081 and session API :9094 — to loopback: it runs on a laptop, so a wildcard bind would expose an open forward proxy and the unauthenticated session API (which carries decrypted bodies and any injected tokens) to the LAN. The forward-role preset also defaults the enforce-redirect transparent listener (:8082), but the demo has no iptables redirecting to it, so --demo skips starting it (config can't unset it — the preset refills an empty transparent_proxy_addr). Rewrite the root README as a short "Cortex" overview (formerly AuthBridge): - position it as more than auth — identity/tokens, protocol-aware observability, egress control/policy — running in Kubernetes or standalone; - quick start is the non-K8s local flow, now a single command (authbridge-proxy --demo), and the written config is editable + hot-reloadable; - point K8s users to the weather-agent walkthrough + the authbridge/ reference; - drop the Container Images and Development sections. Binaries and dirs keep their names (authbridge-proxy, abctl, authlib, authbridge/); a note preserves the AuthBridge-to-binary link. No code rename. Test: writeDemoConfig produces a file in ca_dir that loads/presets/ validates as proxy-sidecar, forward-only, with the forward proxy and session API pinned to loopback, tls_bridge + generate_ca (ca_dir), and the ordered parser set inference-parser, mcp-parser, a2a-parser. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- README.md | 82 +++++++++----------- authbridge/cmd/authbridge-proxy/demo.go | 61 +++++++++++++++ authbridge/cmd/authbridge-proxy/demo_test.go | 75 ++++++++++++++++++ authbridge/cmd/authbridge-proxy/main.go | 46 ++++++++++- 4 files changed, 216 insertions(+), 48 deletions(-) create mode 100644 authbridge/cmd/authbridge-proxy/demo.go create mode 100644 authbridge/cmd/authbridge-proxy/demo_test.go diff --git a/README.md b/README.md index b29a82de..274edef5 100644 --- a/README.md +++ b/README.md @@ -1,67 +1,57 @@ -# Rossoctl Extensions +# Cortex -Kubernetes security extensions for the [Rossoctl](https://github.com/rossoctl/rossoctl) ecosystem, providing **zero-trust authentication** for workloads through transparent token exchange and dynamic Keycloak client registration using SPIFFE/SPIRE identities. +Cortex is a sidecar framework that **secures and observes the traffic of AI agents and Kubernetes workloads**. It sits in the request path — as a local forward/reverse proxy, an Envoy `ext_proc` filter, or a mesh waypoint — and adds: -## AuthBridge +- **Identity & tokens** — SPIFFE/SPIRE workload identity, JWT validation, and RFC 8693 token exchange (the original "AuthBridge" capability). +- **Protocol-aware observability** — decrypts egress (TLS bridge) and parses LLM inference, MCP, and A2A calls into a live session view (`abctl`). +- **Egress control & policy** — guardrails (IBAC / OPA) and per-host routing over a workload's outbound calls. -[AuthBridge](./authbridge/) provides end-to-end authentication for Kubernetes workloads with [SPIFFE/SPIRE](https://spiffe.io) integration. It consists of: +It runs the same way **in Kubernetes** (operator-injected sidecar) or **standalone** on a laptop/VM (a single binary, no cluster). -- **[Authlib](./authbridge/authlib/)** — shared Go library: JWT validation, RFC 8693 token exchange, plugin pipeline, listener implementations. -- **Mode-specific binaries** under [`authbridge/cmd/`](./authbridge/cmd/): - - [`authbridge-proxy`](./authbridge/cmd/authbridge-proxy/) — proxy-sidecar (default): HTTP forward + reverse proxies, full plugin set. - - [`authbridge-envoy`](./authbridge/cmd/authbridge-envoy/) — envoy-sidecar: ext_proc gRPC server hooked into Envoy, full plugin set. - - `authbridge-lite` — **not a separate binary**: the `authbridge-lite` image is `authbridge-proxy` built with `exclude_plugin_*` tags (auth-only: jwt-validation + token-exchange), for size-constrained deployments. See [`authbridge-proxy`](./authbridge/cmd/authbridge-proxy/). -- **[proxy-init](./authbridge/proxy-init/)** — iptables init container used by envoy-sidecar mode for transparent traffic interception. -- **[Keycloak Sync](./authbridge/keycloak_sync.py)** — Declarative tool for synchronizing Keycloak configuration. +> Formerly **AuthBridge** — the code lives under [`authbridge/`](./authbridge/) and ships as the `authbridge-proxy` binary and the `abctl` session viewer. -Keycloak client registration runs in the [operator](https://github.com/rossoctl/operator) (separate repo, post-#411 / operator#361 — no in-pod registration sidecar). +## Quick start (local, no Kubernetes) -See the [AuthBridge README](./authbridge/README.md) for architecture details and the [demos index](./authbridge/demos/README.md) for getting started. +See an AI agent's egress — LLM, MCP, and A2A calls — decrypted and parsed live on your laptop. No cluster, no Keycloak, no SPIRE. -## Container Images +1. **Get the binaries.** Download prebuilt `abctl` and `authbridge-proxy` (linux/macOS, amd64/arm64) from the [Releases page](https://github.com/rossoctl/cortex/releases) and put them on your `PATH`. On macOS, clear the quarantine once: `xattr -dr com.apple.quarantine ./abctl ./authbridge-proxy`. + _(Or build from source: `cd authbridge/cmd/abctl && go build .` then `cd ../authbridge-proxy && go build .`.)_ -All images are published to `ghcr.io/rossoctl/cortex/`. After -cortex#411 the unified binary was split into three -mode-specific combined images, and the per-component sidecars -(`client-registration`, standalone `spiffe-helper`) were retired: +2. **Start Cortex** with the built-in local preset — a forward-only proxy (loopback-only) with the TLS bridge on and the protocol parsers, no config file needed. On first run it generates a demo CA under `./cortex-ca` (override with `--ca-dir`) and logs the exact `NODE_EXTRA_CA_CERTS=…` line to trust it: -| Image | Description | -|-------|-------------| -| `authbridge` | proxy-sidecar combined (default): authbridge-proxy + bundled spiffe-helper, full plugin set | -| `authbridge-envoy` | envoy-sidecar combined: Envoy + ext_proc + bundled spiffe-helper, full plugin set | -| `authbridge-lite` | `authbridge-proxy` built with `exclude_plugin_*` tags — auth-only (jwt-validation + token-exchange, OPA + parsers dropped), for size-constrained deployments. A build variant, not a separate binary | -| `proxy-init` | Alpine + iptables init container (envoy-sidecar mode only) | + ```sh + authbridge-proxy --demo + ``` -`spiffe-helper` is bundled inside each combined image and gated per -workload by the `SPIRE_ENABLED` env var. Client registration is -handled by the operator's `ClientRegistrationReconciler` and -no longer ships as a separate image. The legacy `authbridge-unified`, -`authbridge-light`, `client-registration`, and standalone `spiffe-helper` -images are no longer published; older release tags continue to -publish the previous shape. + _(It also writes that config to `./cortex-ca/demo.yaml` — edit that file and the running proxy hot-reloads it.)_ -## Development +3. **Open the session viewer** in another terminal: -```bash -# Install pre-commit hooks -make pre-commit + ```sh + abctl --endpoint http://localhost:9094 + ``` -# Run formatters -make fmt +4. **Run your agent through it** — e.g. Claude Code (from the same directory, so `./cortex-ca` resolves — or use the absolute path the proxy logged): -# Build the proxy-init iptables init container image -make build-proxy-init + ```sh + HTTPS_PROXY=http://localhost:8081 \ + NODE_EXTRA_CA_CERTS="$PWD/cortex-ca/ca.crt" \ + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 \ + claude + ``` -# Run local testing (requires Kind cluster) -./local-build-and-test.sh -``` + Its LLM, MCP, and A2A calls appear live in `abctl`, decrypted and parsed. -See [LOCAL_TESTING_GUIDE.md](./LOCAL_TESTING_GUIDE.md) for the full local development setup. +> Local / observe-only: the parsers *observe* traffic; nothing is enforced. `generate_ca` and the self-signed CA are for local use — in Kubernetes the CA is a mounted cert-manager Secret. -## Related Repositories +## Running on Kubernetes -- [rossoctl](https://github.com/rossoctl/rossoctl) — Core Rossoctl platform -- [operator](https://github.com/rossoctl/operator) — Kubernetes operator for sidecar injection (includes the admission webhook) +In a cluster, Cortex sidecars are injected automatically by the [operator](https://github.com/rossoctl/operator), with Keycloak + SPIFFE/SPIRE for identity and token exchange. Start with the end-to-end **[Weather Agent walkthrough](./authbridge/demos/weather-agent/demo-ui.md)** (or the [`abctl` version](./authbridge/demos/weather-agent/demo-with-abctl.md)); see the [demos index](./authbridge/demos/README.md) and the [architecture reference](./authbridge/README.md) for all modes and details. + +## Related repositories + +- [rossoctl](https://github.com/rossoctl/rossoctl) — core platform +- [operator](https://github.com/rossoctl/operator) — sidecar injection + admission webhook ## License diff --git a/authbridge/cmd/authbridge-proxy/demo.go b/authbridge/cmd/authbridge-proxy/demo.go new file mode 100644 index 00000000..9865a11d --- /dev/null +++ b/authbridge/cmd/authbridge-proxy/demo.go @@ -0,0 +1,61 @@ +package main + +import ( + "os" + "path/filepath" +) + +// demoCADirDefault is the default CA directory for --demo, relative to the +// current working directory — no absolute path is baked into the binary. +// Override with --ca-dir. The built-in config is written into this same +// directory (demo.yaml), next to the generated CA. +const demoCADirDefault = "cortex-ca" + +// demoConfigYAML returns the built-in --demo config with caDir interpolated: a +// forward-only proxy with the TLS bridge on (auto-generated CA in caDir) and +// the LLM / MCP / A2A parsers, so an agent's egress is decrypted and parsed. +// Kept in sync with the root README. +// +// The listeners the demo actually uses are pinned to loopback: this runs on a +// laptop, so a wildcard bind would expose an open forward proxy and the +// unauthenticated session API (which carries decrypted bodies and any injected +// tokens) to the LAN. The preset only fills empty addresses, so these explicit +// values win. The enforce-redirect transparent listener isn't used here (no +// iptables) and main.go skips starting it under --demo. +// +// The YAML body is flush-left on purpose — a raw string literal preserves +// leading whitespace, so indenting these lines in source would corrupt the YAML. +func demoConfigYAML(caDir string) string { + return `# Built-in config for: authbridge-proxy --demo +# Forward-only proxy + TLS bridge (auto-generated CA) + LLM/MCP/A2A parsers. +# The running proxy watches this file — edit it to hot-reload. +mode: proxy-sidecar +listener: + roles: [forward] + forward_proxy_addr: 127.0.0.1:8081 + session_api_addr: 127.0.0.1:9094 +tls_bridge: + mode: enabled + ca_dir: "` + caDir + `" + generate_ca: true +pipeline: + outbound: + plugins: [inference-parser, mcp-parser, a2a-parser] +` +} + +// writeDemoConfig writes the built-in --demo config next to the CA (in caDir) +// and returns its path, so --demo reuses the normal file-based load + +// hot-reload path — edits to the file are picked up live. caDir is +// caller-resolved (cwd-relative by default, or --ca-dir); no absolute path is +// baked into the binary. Overwrites any prior copy so the preset is canonical. +func writeDemoConfig(caDir string) (string, error) { + if err := os.MkdirAll(caDir, 0o755); err != nil { + return "", err + } + path := filepath.Join(caDir, "demo.yaml") + if err := os.WriteFile(path, []byte(demoConfigYAML(caDir)), 0o644); err != nil { + return "", err + } + return path, nil +} diff --git a/authbridge/cmd/authbridge-proxy/demo_test.go b/authbridge/cmd/authbridge-proxy/demo_test.go new file mode 100644 index 00000000..c3092ba5 --- /dev/null +++ b/authbridge/cmd/authbridge-proxy/demo_test.go @@ -0,0 +1,75 @@ +package main + +import ( + "path/filepath" + "slices" + "testing" + + "github.com/rossoctl/cortex/authbridge/authlib/config" +) + +// writeDemoConfig must produce a config file inside caDir that loads, presets, +// and validates cleanly and describes a forward-only TLS-bridge observe +// pipeline pointed at that dir — otherwise --demo would fail at boot instead of +// giving users a working, hot-reloadable local demo. +func TestDemoConfig_WriteLoadsAndValidates(t *testing.T) { + caDir := t.TempDir() + + p, err := writeDemoConfig(caDir) + if err != nil { + t.Fatalf("writeDemoConfig: %v", err) + } + if filepath.Dir(p) != caDir { + t.Errorf("config written to %q, want inside %q", p, caDir) + } + + cfg, err := config.Load(p) + if err != nil { + t.Fatalf("Load: %v", err) + } + config.ApplyPreset(cfg) + if err := config.Validate(cfg); err != nil { + t.Fatalf("Validate: %v", err) + } + + if cfg.Mode != config.ModeProxySidecar { + t.Errorf("Mode = %q, want %q", cfg.Mode, config.ModeProxySidecar) + } + + roles := cfg.Listener.ActiveRoles() + if !roles[config.RoleForward] || roles[config.RoleReverse] { + t.Errorf("expected forward-only roles, got %v", roles) + } + + // The listeners the demo uses must bind loopback, never a wildcard that + // would expose an open forward proxy or the unauthenticated session API + // (decrypted bodies + injected tokens) to the LAN. The transparent listener + // isn't started under --demo (main.go gates it), so it's not asserted here. + if got := cfg.Listener.ForwardProxyAddr; got != "127.0.0.1:8081" { + t.Errorf("ForwardProxyAddr = %q, want loopback 127.0.0.1:8081", got) + } + if got := cfg.Listener.SessionAPIAddr; got != "127.0.0.1:9094" { + t.Errorf("SessionAPIAddr = %q, want loopback 127.0.0.1:9094", got) + } + + if cfg.TLSBridge == nil { + t.Fatalf("expected tls_bridge config, got nil") + } + if cfg.TLSBridge.Mode != "enabled" || !cfg.TLSBridge.GenerateCA { + t.Errorf("expected tls_bridge enabled with generate_ca, got %+v", cfg.TLSBridge) + } + if cfg.TLSBridge.CADir != caDir { + t.Errorf("CADir = %q, want %q", cfg.TLSBridge.CADir, caDir) + } + + // Assert the exact parser set and order, not just the count — a swapped or + // renamed plugin would otherwise pass silently. + gotPlugins := make([]string, len(cfg.Pipeline.Outbound.Plugins)) + for i, p := range cfg.Pipeline.Outbound.Plugins { + gotPlugins[i] = p.Name + } + wantPlugins := []string{"inference-parser", "mcp-parser", "a2a-parser"} + if !slices.Equal(gotPlugins, wantPlugins) { + t.Errorf("outbound plugins = %v, want %v", gotPlugins, wantPlugins) + } +} diff --git a/authbridge/cmd/authbridge-proxy/main.go b/authbridge/cmd/authbridge-proxy/main.go index 483a7c4a..0fa871ff 100644 --- a/authbridge/cmd/authbridge-proxy/main.go +++ b/authbridge/cmd/authbridge-proxy/main.go @@ -26,6 +26,7 @@ import ( "net/http" "os" "os/signal" + "path/filepath" "strings" "syscall" "time" @@ -61,6 +62,14 @@ var logLevel = new(slog.LevelVar) // via -ldflags "-X main.version=". Defaults to "dev" for local builds. var version = "dev" +// demoMode is set by --demo. It suppresses listeners that only make sense with +// iptables enforce-redirect: the demo uses cooperative HTTPS_PROXY, so nothing +// is ever REDIRECTed to the transparent listener and opening it would just be +// an idle port. The forward-role preset defaults transparent_proxy_addr to +// :8082, and config can't unset it (the preset refills an empty value), so this +// gate is the only way to keep the demo to the listeners it actually uses. +var demoMode bool + func initLogging() { switch strings.ToLower(os.Getenv("LOG_LEVEL")) { case "debug": @@ -146,6 +155,10 @@ func pluginUsesSPIFFEIdentity(p config.PluginEntry) bool { func main() { configPath := flag.String("config", "", "path to config YAML file") showVersion := flag.Bool("version", false, "print version and exit") + demo := flag.Bool("demo", false, + "run a built-in local demo (forward-only TLS bridge + protocol parsers) that decrypts and parses an agent's egress; no --config, cluster, Keycloak, or SPIRE needed") + caDir := flag.String("ca-dir", "", + "CA directory for --demo (auto-generated); defaults to ./"+demoCADirDefault) flag.Parse() if *showVersion { @@ -156,8 +169,34 @@ func main() { initLogging() startSignalToggle() + if *demo { + demoMode = true + if *configPath != "" { + log.Fatal("--demo and --config are mutually exclusive") + } + dir := *caDir + if dir == "" { + dir = demoCADirDefault // relative to cwd — no absolute path baked in + } + abs, aerr := filepath.Abs(dir) + if aerr != nil { + log.Fatalf("--demo: resolving --ca-dir %q: %v", dir, aerr) + } + // Write the built-in config next to the CA and drive the normal + // file-based load + hot-reload path — so editing the file reloads live. + p, werr := writeDemoConfig(abs) + if werr != nil { + log.Fatalf("--demo: %v", werr) + } + *configPath = p + slog.Info("demo mode — wrote built-in config next to the CA; edit it to hot-reload", + "config", p, "ca_dir", abs) + } else if *caDir != "" { + log.Fatal("--ca-dir only applies with --demo") + } + if *configPath == "" { - log.Fatal("--config is required and must point to a YAML file") + log.Fatal("--config is required (or use --demo for the local demo)") } // Build the SPIFFE Provider when the spiffe block is configured. The @@ -411,7 +450,10 @@ func main() { // forward proxy's outbound pipeline via HandleTransparentConn, so explicit // HTTP_PROXY egress and iptables-REDIRECTed bypass egress are gated and // tunnelled identically. Closed explicitly on shutdown (not an *http.Server). - transparentLn = startTransparentProxy(fpSrv, cfg.Listener.TransparentProxyAddr) + // Skipped in --demo: no iptables there, so nothing is ever REDIRECTed to it. + if !demoMode { + transparentLn = startTransparentProxy(fpSrv, cfg.Listener.TransparentProxyAddr) + } } _ = mtlsMetrics // TODO Phase 2: surface metrics through /stats