Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
82 changes: 36 additions & 46 deletions README.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
61 changes: 61 additions & 0 deletions authbridge/cmd/authbridge-proxy/demo.go
Original file line number Diff line number Diff line change
@@ -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 + `"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Escape caDir before embedding it in YAML.

Direct interpolation makes paths such as Windows C:\..., or filenames containing quotes/newlines, produce invalid or altered YAML before config.Load can start the proxy.

Proposed fix
 import (
 	"os"
 	"path/filepath"
+	"strconv"
 )

-  ca_dir: "` + caDir + `"
+  ca_dir: ` + strconv.Quote(caDir) + `
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
ca_dir: "` + caDir + `"
import (
"os"
"path/filepath"
"strconv"
)
Suggested change
ca_dir: "` + caDir + `"
ca_dir: ` + strconv.Quote(caDir) + `
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@authbridge/cmd/authbridge-proxy/demo.go` at line 30, Escape caDir before
interpolating it into the YAML configuration in the demo setup, preserving
Windows backslashes and quotes/newlines as literal path data. Use the YAML-safe
encoding or quoting utility already available in the project, and keep
config.Load receiving valid YAML for all path values.

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
}
75 changes: 75 additions & 0 deletions authbridge/cmd/authbridge-proxy/demo_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
46 changes: 44 additions & 2 deletions authbridge/cmd/authbridge-proxy/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
Expand Down Expand Up @@ -61,6 +62,14 @@ var logLevel = new(slog.LevelVar)
// via -ldflags "-X main.version=<tag>". 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":
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading