From b3f74c3ed2ad41b1904e097bdbad08055d2f3b23 Mon Sep 17 00:00:00 2001 From: Stephane NANGUE Date: Wed, 17 Jun 2026 18:24:00 +0200 Subject: [PATCH 01/12] PR0: design of record (docs/design.md + README) Architecture of record for Robin, the workload-identity injector: component designs, config reference, failure semantics, the v0.1/v0.2 split, and the PR-by-PR implementation plan. Docs only; no code. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 105 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..0b68f5a --- /dev/null +++ b/README.md @@ -0,0 +1,105 @@ +# Robin + +**A workload-identity injector.** Robin sits next to a workload, sources that workload's native identity, and presents it as a bearer token on each outbound request to a credential broker — so the workload never holds a long-lived credential. + +> **Status: design stage.** No code yet — the architecture of record lives in [docs/design.md](docs/design.md), and the implementation is sequenced as a series of PRs there. This README describes the intended design. + +## What Robin is + +Application frameworks (kagent, generic OpenAI-compatible clients) have nowhere clean to put a dynamic, rotating credential — their credential fields expect a static secret. Robin removes the workload from the credential business: + +- The workload presents **identity** (a short-lived token saying *who it is*), not a credential it holds. +- A downstream **broker** (Warden, or any identity-aware egress gateway) validates that identity and applies the real upstream credential. +- The machine holds **no real provider credential** — at most an inert placeholder. + +Robin is deliberately thin: it **forwards native identity and does nothing else**. It never mints, exchanges, signs, or federates — any credential derivation happens at the broker. Because of that, the "no credential on the machine" property holds universally, for every provider. + +``` + workload ──http, dummy/no cred──▶ Robin (127.0.0.1:4000 or UDS) + │ resolve native identity via provider + │ inject: Authorization: Bearer (overwrites placeholder) + ▼ + Broker ──validate identity──▶ apply real cred ──▶ upstream +``` + +It is a streaming reverse proxy with a pluggable identity provider. Every provider yields a bearer token, so Robin is body-agnostic and never reads or buffers the request body (SSE token streaming passes straight through). + +**Threat model in one line:** anything on the pod's loopback can ask Robin to present the workload's identity — so Robin defaults to loopback-only, with a Unix-domain-socket + `SO_PEERCRED` peer-credential mode for hardened deployments (v0.2). + +## Identity providers + +| Source | `ROBIN_TOKEN_SOURCE` | Rotation | +|--------|----------------------|----------| +| Kubernetes projected ServiceAccount token | `file` | kubelet rotates the file in place (~80% TTL); Robin **re-reads per request**, never caches. | +| SPIFFE JWT-SVID (Workload API) | `jwtsvid` | Not pushed (unary fetch); Robin caches and **refreshes ahead of `exp`**, and serves a still-valid cached token if the agent briefly fails. | + +## Configuration + +Config is a flat set of `ROBIN_`-prefixed scalars — **no config language**. Primary plane is **environment variables** (idiomatic for sidecars/systemd); an optional flat `.env`-style `KEY=value` file is supported for standalone hosts. Precedence: **flags > environment > file**. + +| Var | Default | Notes | +|-----|---------|-------| +| `ROBIN_UPSTREAM_URL` | (required) | Broker base URL | +| `ROBIN_TOKEN_SOURCE` | `file` | `file` \| `jwtsvid` | +| `ROBIN_LISTEN_ADDR` | `:4000` | proxy plane (set `127.0.0.1:4000` for loopback-only) | +| `ROBIN_LISTEN_UDS` | — | UDS path; enables peer-cred mode (v0.2) | +| `ROBIN_ADMIN_ADDR` | `:4001` | health/readiness/metrics plane | +| `ROBIN_TOKEN_FILE` | `/var/run/secrets/tokens/token` | `file` provider | +| `ROBIN_AUDIENCE` | — | required for `jwtsvid`; **must match the broker** | +| `ROBIN_SPIFFE_SOCKET` | — | `jwtsvid` socket addr (optional; falls back to the go-spiffe default) | +| `ROBIN_SVID_REFRESH_BEFORE` | `60s` | refresh ahead of `exp` (clamped ≤ ½ the observed lifetime) | +| `ROBIN_UPSTREAM_CA_FILE` | — | verify broker TLS | +| `ROBIN_PEERCRED_ALLOW_UIDS` | — | (v0.2) comma-separated UIDs; empty = allow any local peer | + +> **Bind address:** the bare defaults `:4000`/`:4001` bind *all* interfaces. For the sidecar's loopback-only trust boundary, set `127.0.0.1:...` explicitly (the example manifest does). + +> **Audience must match end to end.** A mismatch between the token's audience and the broker's expected audience is a hard reject — for projected tokens and SVIDs alike. + +## Deployment topologies + +Same binary; the topology determines how identity is *sourced*, not what Robin does with it. + +- **Native sidecar (default, supported).** An init container with `restartPolicy: Always` (Kubernetes 1.29+) so Robin starts before app containers (no first-call race) and terminates after them (no in-flight-egress loss). The workload reaches Robin on `localhost`. +- **Standalone systemd unit (v0.2).** Robin runs as a host/VM service; identity comes from a node-level SPIRE agent (`jwtsvid`). +- **Per-node DaemonSet** — *advanced/optional.* Fewer instances, but loses per-pod identity fidelity unless SPIRE does per-pod attestation. +- **Standalone egress service** — *generally an anti-pattern.* Loses transparent localhost injection and per-pod identity. + +## Admin endpoints + +Served on a **separate admin listener** (`ROBIN_ADMIN_ADDR`, default `:4001`) — *not* the proxy port, because the proxy forwards every path to the broker (a probe there would be proxied upstream and could leak identity). + +- `GET /healthz` — liveness (always 200). *(v0.1)* +- `GET /readyz` — readiness; 200 only when an identity can be resolved. *(v0.2)* +- `GET /metrics` — Prometheus exposition (token fetch latency, cache hit/refresh, served-stale, upstream status codes). *(v0.2)* + +## Failure semantics + +| Condition | Response | +|-----------|----------| +| Identity unavailable (token file missing, Workload API down) | **503** — never forwards a missing/placeholder credential (jwtsvid serves a still-valid cached token first) | +| Broker unreachable | **502** — single attempt, no blind retry | +| Audience mismatch | fail closed with an explicit log (config error, not transient) | + +## Security notes + +- The token value is **never logged** — redaction is structural (it never enters a log record). +- The container runs **nonroot** on a static distroless base. +- Bearer-only by design (forwards a JWT-SVID / projected token). mTLS with an X.509-SVID (proof-of-possession) was considered and deliberately deferred; see [docs/design.md](docs/design.md), decision #13. +- Hardened local trust boundary via UDS + `SO_PEERCRED` UID allowlist (v0.2). + +## When *not* to use Robin + +- **You control the client's auth path.** An in-process `RoundTripper` / auth hook that fetches the identity is lighter than a proxy — no extra process, no localhost trust boundary. Robin exists for clients you *can't* modify (a static-string credential field). +- **You already run a service mesh** (Istio ambient / ztunnel / Cilium). Use its egress identity origination instead of adding a per-pod sidecar. + +## Roadmap + +- **v0.1 — native bearer core:** provider interface, `file` + `jwtsvid` providers, native-sidecar deployment, TCP loopback, broker-forward path, `/healthz`, structured logging. +- **v0.2 — hardening:** UDS + `SO_PEERCRED`, `/readyz`, Prometheus metrics, standalone systemd deployment. +- **Future:** per-request role/intent assertion travelling with the identity. + +See [docs/design.md](docs/design.md) for the full architecture of record and the PR-by-PR implementation plan. + +## License + +[MPL-2.0](LICENSE). From fbb44a31b58cbc616880b1184666b4a8ad59e943 Mon Sep 17 00:00:00 2001 From: Stephane NANGUE Date: Wed, 17 Jun 2026 18:27:21 +0200 Subject: [PATCH 02/12] PR1: scaffolding & CI go.mod (github.com/snangue/robin, go 1.26), Makefile, .golangci.yml, .gitignore, GitHub Actions CI (build/vet/test -race + lint). internal/version (ldflags build metadata), internal/obs/log.go (slog JSON logger + field-key constants), and a stub cmd/robin entrypoint that handles --version/--help. --- .github/workflows/ci.yml | 28 ++++++++++++++++++++++ .gitignore | 6 +++++ .golangci.yml | 9 ++++++++ Makefile | 41 +++++++++++++++++++++++++++++++++ go.mod | 3 +++ internal/obs/log.go | 46 +++++++++++++++++++++++++++++++++++++ internal/version/version.go | 15 ++++++++++++ 7 files changed, 148 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .golangci.yml create mode 100644 Makefile create mode 100644 go.mod create mode 100644 internal/obs/log.go create mode 100644 internal/version/version.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d6bb74a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: ci +on: + push: + branches: [main] + pull_request: + +jobs: + build-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.26" + - run: go build ./... + - run: go vet ./... + - run: go test -race ./... + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.26" + - uses: golangci/golangci-lint-action@v6 + with: + version: latest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..96d10da --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/bin/ +/dist/ +*.out +cover.out +coverage.* +robin diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..1f431fb --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,9 @@ +version: "2" +linters: + enable: + - errcheck + - govet + - ineffassign + - staticcheck + - unused + - revive diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..9e1a4d6 --- /dev/null +++ b/Makefile @@ -0,0 +1,41 @@ +BINARY := robin +PKG := github.com/snangue/robin +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) +COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo none) +DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ) +LDFLAGS := -s -w \ + -X $(PKG)/internal/version.Version=$(VERSION) \ + -X $(PKG)/internal/version.Commit=$(COMMIT) \ + -X $(PKG)/internal/version.Date=$(DATE) + +.PHONY: build test race cover vet lint tidy docker clean run-file + +build: + CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/robin + +test: + go test ./... + +race: + go test -race ./... + +cover: + go test -coverprofile=cover.out ./... && go tool cover -func=cover.out + +vet: + go vet ./... + +lint: + @command -v golangci-lint >/dev/null 2>&1 && golangci-lint run || echo "golangci-lint not installed; skipping" + +tidy: + go mod tidy + +docker: + docker build -t $(BINARY):$(VERSION) -f deploy/Dockerfile . + +clean: + rm -rf bin cover.out + +run-file: + go run ./cmd/robin diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5864efc --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/snangue/robin + +go 1.26 diff --git a/internal/obs/log.go b/internal/obs/log.go new file mode 100644 index 0000000..2b26aba --- /dev/null +++ b/internal/obs/log.go @@ -0,0 +1,46 @@ +// Package obs provides observability primitives: structured logging and, in +// v0.2, metrics. The token value is deliberately never a logged field. +package obs + +import ( + "log/slog" + "os" + "strings" +) + +// Structured log field keys, centralized so they stay consistent — and so the +// token value is conspicuously absent from the set. +const ( + FieldProvider = "provider" + FieldAudience = "audience" + FieldDecision = "decision" + FieldUpstreamStatus = "upstream_status" + FieldError = "err" +) + +// Values for the FieldDecision log field. +const ( + DecisionInjected = "injected" + DecisionFailed = "failed" + DecisionUpstreamError = "upstream_error" +) + +// NewLogger returns a JSON slog.Logger at the given level, writing to stdout. +func NewLogger(level slog.Level) *slog.Logger { + h := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: level}) + return slog.New(h) +} + +// ParseLevel maps a level string to an slog.Level, defaulting to Info. +func ParseLevel(s string) slog.Level { + switch strings.ToLower(strings.TrimSpace(s)) { + case "debug": + return slog.LevelDebug + case "warn", "warning": + return slog.LevelWarn + case "error": + return slog.LevelError + default: + return slog.LevelInfo + } +} diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..9124e36 --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,15 @@ +// Package version holds build metadata injected at link time via -ldflags. +package version + +// Build metadata, set via -X linker flags (see Makefile). Defaults apply to +// `go run` and `go test` builds. +var ( + Version = "dev" + Commit = "none" + Date = "unknown" +) + +// String returns a human-readable version line. +func String() string { + return Version + " (commit " + Commit + ", built " + Date + ")" +} From 65d686a7de1fc3c1898209e8c18cf58af46282d9 Mon Sep 17 00:00:00 2001 From: Stephane NANGUE Date: Wed, 17 Jun 2026 18:34:57 +0200 Subject: [PATCH 03/12] PR2: config package ROBIN_* configuration with precedence flags > env > file. config.Load (injectable getenv/args for testable precedence), fail-closed Validate (required absolute upstream URL, jwtsvid requires audience, unknown source rejected), and a dependency-free .env parser. main now loads and validates config, exiting non-zero on error. Table tests for precedence, defaults, validation, UID parsing, and dotenv edge cases. --- internal/config/config.go | 174 +++++++++++++++++++++++++++++++++ internal/config/config_test.go | 103 +++++++++++++++++++ internal/config/dotenv.go | 45 +++++++++ internal/config/dotenv_test.go | 41 ++++++++ 4 files changed, 363 insertions(+) create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/config/dotenv.go create mode 100644 internal/config/dotenv_test.go diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..362ccca --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,174 @@ +// Package config loads Robin's flat ROBIN_* configuration from flags, +// environment, and an optional .env-style file (precedence: flags > env > file). +package config + +import ( + "flag" + "fmt" + "io" + "log/slog" + "net/url" + "os" + "strconv" + "strings" + "time" + + "github.com/snangue/robin/internal/obs" +) + +// Config is Robin's fully-resolved configuration. +type Config struct { + UpstreamURL string // ROBIN_UPSTREAM_URL (required) — broker base URL + TokenSource string // ROBIN_TOKEN_SOURCE — file | jwtsvid + ListenAddr string // ROBIN_LISTEN_ADDR — proxy listener + ListenUDS string // ROBIN_LISTEN_UDS — UDS path (enables peer-cred mode) + AdminAddr string // ROBIN_ADMIN_ADDR — admin/health/metrics listener + TokenFile string // ROBIN_TOKEN_FILE — file provider token path + Audience string // ROBIN_AUDIENCE — required for jwtsvid + SPIFFESocket string // ROBIN_SPIFFE_SOCKET — optional Workload API socket addr + SVIDRefreshBefore time.Duration // ROBIN_SVID_REFRESH_BEFORE — refresh lead before exp + UpstreamCAFile string // ROBIN_UPSTREAM_CA_FILE — CA to verify broker TLS + PeerCredAllowUIDs []int // ROBIN_PEERCRED_ALLOW_UIDS — allowed peer UIDs (UDS mode) + LogLevel slog.Level // ROBIN_LOG_LEVEL +} + +// Load resolves configuration with precedence flags > env > file. getenv and +// args are injected so precedence is testable without touching process state. +func Load(args []string, getenv func(string) string) (Config, error) { + if getenv == nil { + getenv = os.Getenv + } + + path := configPath(args, getenv) + fileMap := map[string]string{} + if path != "" { + f, err := os.Open(path) + if err != nil { + return Config{}, fmt.Errorf("config: open %s: %w", path, err) + } + defer f.Close() + fileMap, err = parseDotenv(f) + if err != nil { + return Config{}, fmt.Errorf("config: parse %s: %w", path, err) + } + } + + // val resolves a key by env first, then file, then the provided default. + val := func(key, def string) string { + if v := getenv(key); v != "" { + return v + } + if v, ok := fileMap[key]; ok && v != "" { + return v + } + return def + } + + var ( + cfg Config + refreshStr, uidStr, levelStr string + cfgPath string + ) + + fs := flag.NewFlagSet("robin", flag.ContinueOnError) + fs.SetOutput(io.Discard) + // --config is resolved before flag parsing (see configPath) so the file is + // already loaded; it is registered here only so Parse accepts the flag. + fs.StringVar(&cfgPath, "config", path, "path to a flat KEY=value config file") + fs.StringVar(&cfg.UpstreamURL, "upstream-url", val("ROBIN_UPSTREAM_URL", ""), "broker base URL (required)") + fs.StringVar(&cfg.TokenSource, "token-source", val("ROBIN_TOKEN_SOURCE", "file"), "identity source: file|jwtsvid") + fs.StringVar(&cfg.ListenAddr, "listen-addr", val("ROBIN_LISTEN_ADDR", ":4000"), "proxy listen address") + fs.StringVar(&cfg.ListenUDS, "listen-uds", val("ROBIN_LISTEN_UDS", ""), "proxy UDS path (enables peer-cred mode)") + fs.StringVar(&cfg.AdminAddr, "admin-addr", val("ROBIN_ADMIN_ADDR", ":4001"), "admin listen address") + fs.StringVar(&cfg.TokenFile, "token-file", val("ROBIN_TOKEN_FILE", "/var/run/secrets/tokens/token"), "file provider token path") + fs.StringVar(&cfg.Audience, "audience", val("ROBIN_AUDIENCE", ""), "token audience (required for jwtsvid)") + fs.StringVar(&cfg.SPIFFESocket, "spiffe-socket", val("ROBIN_SPIFFE_SOCKET", ""), "SPIFFE Workload API socket address") + fs.StringVar(&refreshStr, "svid-refresh-before", val("ROBIN_SVID_REFRESH_BEFORE", "60s"), "refresh SVID this long before expiry") + fs.StringVar(&cfg.UpstreamCAFile, "upstream-ca-file", val("ROBIN_UPSTREAM_CA_FILE", ""), "CA file to verify broker TLS") + fs.StringVar(&uidStr, "peercred-allow-uids", val("ROBIN_PEERCRED_ALLOW_UIDS", ""), "comma-separated allowed peer UIDs") + fs.StringVar(&levelStr, "log-level", val("ROBIN_LOG_LEVEL", "info"), "log level: debug|info|warn|error") + + if err := fs.Parse(args); err != nil { + return Config{}, fmt.Errorf("config: %w", err) + } + + d, err := time.ParseDuration(refreshStr) + if err != nil { + return Config{}, fmt.Errorf("config: invalid svid-refresh-before %q: %w", refreshStr, err) + } + cfg.SVIDRefreshBefore = d + + uids, err := parseUIDs(uidStr) + if err != nil { + return Config{}, fmt.Errorf("config: invalid peercred-allow-uids %q: %w", uidStr, err) + } + cfg.PeerCredAllowUIDs = uids + + cfg.LogLevel = obs.ParseLevel(levelStr) + + return cfg, cfg.Validate() +} + +// Validate enforces fail-closed configuration rules. +func (c Config) Validate() error { + if c.UpstreamURL == "" { + return fmt.Errorf("config: ROBIN_UPSTREAM_URL is required") + } + if u, err := url.Parse(c.UpstreamURL); err != nil || !u.IsAbs() || u.Host == "" { + return fmt.Errorf("config: ROBIN_UPSTREAM_URL %q must be an absolute URL", c.UpstreamURL) + } + switch c.TokenSource { + case "file": + // Token file is read at request time; nothing else required here. + case "jwtsvid": + if c.Audience == "" { + return fmt.Errorf("config: ROBIN_AUDIENCE is required for token-source=jwtsvid") + } + default: + return fmt.Errorf("config: ROBIN_TOKEN_SOURCE %q must be file or jwtsvid", c.TokenSource) + } + if c.SVIDRefreshBefore <= 0 { + return fmt.Errorf("config: ROBIN_SVID_REFRESH_BEFORE must be positive") + } + if len(c.PeerCredAllowUIDs) > 0 && c.ListenUDS == "" { + return fmt.Errorf("config: ROBIN_PEERCRED_ALLOW_UIDS set but ROBIN_LISTEN_UDS is empty") + } + return nil +} + +// configPath resolves the config-file path from a --config flag or ROBIN_CONFIG. +func configPath(args []string, getenv func(string) string) string { + for i, a := range args { + switch { + case a == "--config" || a == "-config": + if i+1 < len(args) { + return args[i+1] + } + case strings.HasPrefix(a, "--config="): + return strings.TrimPrefix(a, "--config=") + case strings.HasPrefix(a, "-config="): + return strings.TrimPrefix(a, "-config=") + } + } + return getenv("ROBIN_CONFIG") +} + +func parseUIDs(s string) ([]int, error) { + s = strings.TrimSpace(s) + if s == "" { + return nil, nil + } + var uids []int + for _, p := range strings.Split(s, ",") { + p = strings.TrimSpace(p) + if p == "" { + continue + } + n, err := strconv.Atoi(p) + if err != nil { + return nil, fmt.Errorf("%q is not a valid uid", p) + } + uids = append(uids, n) + } + return uids, nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..237b83c --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,103 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func mapEnv(m map[string]string) func(string) string { + return func(k string) string { return m[k] } +} + +func TestLoadPrecedence(t *testing.T) { + dir := t.TempDir() + envFile := filepath.Join(dir, "robin.env") + if err := os.WriteFile(envFile, []byte("ROBIN_UPSTREAM_URL=https://file.example\nROBIN_TOKEN_SOURCE=file\n"), 0o600); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + args []string + env map[string]string + want string + }{ + {"file only", nil, map[string]string{"ROBIN_CONFIG": envFile}, "https://file.example"}, + {"env over file", nil, map[string]string{"ROBIN_CONFIG": envFile, "ROBIN_UPSTREAM_URL": "https://env.example"}, "https://env.example"}, + {"flag over env", []string{"--upstream-url=https://flag.example"}, map[string]string{"ROBIN_CONFIG": envFile, "ROBIN_UPSTREAM_URL": "https://env.example"}, "https://flag.example"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg, err := Load(tt.args, mapEnv(tt.env)) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.UpstreamURL != tt.want { + t.Errorf("UpstreamURL = %q, want %q", cfg.UpstreamURL, tt.want) + } + }) + } +} + +func TestLoadDefaults(t *testing.T) { + cfg, err := Load(nil, mapEnv(map[string]string{"ROBIN_UPSTREAM_URL": "https://b.example"})) + if err != nil { + t.Fatal(err) + } + if cfg.TokenSource != "file" || cfg.ListenAddr != ":4000" || cfg.AdminAddr != ":4001" { + t.Errorf("unexpected defaults: %+v", cfg) + } + if cfg.SVIDRefreshBefore.Seconds() != 60 { + t.Errorf("SVIDRefreshBefore = %v, want 60s", cfg.SVIDRefreshBefore) + } +} + +func TestLoadValidation(t *testing.T) { + tests := []struct { + name string + env map[string]string + wantErr bool + }{ + {"missing upstream", nil, true}, + {"bad upstream url", map[string]string{"ROBIN_UPSTREAM_URL": "not-a-url"}, true}, + {"jwtsvid without audience", map[string]string{"ROBIN_UPSTREAM_URL": "https://b", "ROBIN_TOKEN_SOURCE": "jwtsvid"}, true}, + {"unknown token source", map[string]string{"ROBIN_UPSTREAM_URL": "https://b", "ROBIN_TOKEN_SOURCE": "bogus"}, true}, + {"valid file", map[string]string{"ROBIN_UPSTREAM_URL": "https://b.example"}, false}, + {"valid jwtsvid", map[string]string{"ROBIN_UPSTREAM_URL": "https://b.example", "ROBIN_TOKEN_SOURCE": "jwtsvid", "ROBIN_AUDIENCE": "aud"}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := Load(nil, mapEnv(tt.env)) + if (err != nil) != tt.wantErr { + t.Errorf("Load err = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestParseUIDs(t *testing.T) { + got, err := parseUIDs(" 1000, 1001 ,1002") + if err != nil { + t.Fatal(err) + } + if len(got) != 3 || got[0] != 1000 || got[2] != 1002 { + t.Errorf("parseUIDs = %v", got) + } + if _, err := parseUIDs("abc"); err == nil { + t.Error("expected error for non-numeric uid") + } + if got, _ := parseUIDs(""); got != nil { + t.Errorf("empty should be nil, got %v", got) + } +} + +func TestPeerCredRequiresUDS(t *testing.T) { + _, err := Load(nil, mapEnv(map[string]string{ + "ROBIN_UPSTREAM_URL": "https://b.example", + "ROBIN_PEERCRED_ALLOW_UIDS": "1000", + })) + if err == nil { + t.Error("expected error: peercred uids without UDS") + } +} diff --git a/internal/config/dotenv.go b/internal/config/dotenv.go new file mode 100644 index 0000000..30eb231 --- /dev/null +++ b/internal/config/dotenv.go @@ -0,0 +1,45 @@ +package config + +import ( + "bufio" + "fmt" + "io" + "strings" +) + +// parseDotenv parses a flat KEY=value file: blank lines and #-comments are +// skipped, an optional leading "export " is stripped, and a single layer of +// surrounding single/double quotes is removed. No interpolation, no nesting. +func parseDotenv(r io.Reader) (map[string]string, error) { + out := map[string]string{} + sc := bufio.NewScanner(r) + for line := 1; sc.Scan(); line++ { + raw := strings.TrimSpace(sc.Text()) + if raw == "" || strings.HasPrefix(raw, "#") { + continue + } + raw = strings.TrimPrefix(raw, "export ") + eq := strings.IndexByte(raw, '=') + if eq < 0 { + return nil, fmt.Errorf("line %d: missing '='", line) + } + key := strings.TrimSpace(raw[:eq]) + if key == "" { + return nil, fmt.Errorf("line %d: empty key", line) + } + out[key] = unquote(strings.TrimSpace(raw[eq+1:])) + } + if err := sc.Err(); err != nil { + return nil, err + } + return out, nil +} + +func unquote(s string) string { + if len(s) >= 2 { + if c := s[0]; (c == '"' || c == '\'') && s[len(s)-1] == c { + return s[1 : len(s)-1] + } + } + return s +} diff --git a/internal/config/dotenv_test.go b/internal/config/dotenv_test.go new file mode 100644 index 0000000..a6c1b30 --- /dev/null +++ b/internal/config/dotenv_test.go @@ -0,0 +1,41 @@ +package config + +import ( + "strings" + "testing" +) + +func TestParseDotenv(t *testing.T) { + in := strings.Join([]string{ + "# a comment", + "", + "ROBIN_UPSTREAM_URL=https://b.example", + "export ROBIN_TOKEN_SOURCE=file", + `ROBIN_AUDIENCE="quoted-aud"`, + "ROBIN_LISTEN_ADDR='127.0.0.1:4000'", + "ROBIN_TOKEN_FILE=/var/run/secrets/tokens/token=weird", + }, "\n") + + m, err := parseDotenv(strings.NewReader(in)) + if err != nil { + t.Fatal(err) + } + want := map[string]string{ + "ROBIN_UPSTREAM_URL": "https://b.example", + "ROBIN_TOKEN_SOURCE": "file", + "ROBIN_AUDIENCE": "quoted-aud", + "ROBIN_LISTEN_ADDR": "127.0.0.1:4000", + "ROBIN_TOKEN_FILE": "/var/run/secrets/tokens/token=weird", + } + for k, v := range want { + if m[k] != v { + t.Errorf("%s = %q, want %q", k, m[k], v) + } + } +} + +func TestParseDotenvError(t *testing.T) { + if _, err := parseDotenv(strings.NewReader("NOEQUALS")); err == nil { + t.Error("expected error for line missing '='") + } +} From 11d0cbb87db1e03aec09b7c231d981a150fbcee2 Mon Sep 17 00:00:00 2001 From: Stephane NANGUE Date: Wed, 17 Jun 2026 18:37:36 +0200 Subject: [PATCH 04/12] PR3: identity interface + file provider IdentityProvider interface (Token/Close) with sentinel errors, and the Kubernetes projected ServiceAccount token provider: per-request file re-read (no cache, matching kubelet in-place rotation), whitespace trim, ErrNoToken on empty. factory.New dispatches on token source; jwtsvid returns an explicit "not yet available" until PR4. Tests cover trim, rotation, empty, and missing-file cases. --- internal/identity/factory.go | 21 +++++++++++++ internal/identity/file.go | 36 ++++++++++++++++++++++ internal/identity/file_test.go | 56 ++++++++++++++++++++++++++++++++++ internal/identity/provider.go | 25 +++++++++++++++ 4 files changed, 138 insertions(+) create mode 100644 internal/identity/factory.go create mode 100644 internal/identity/file.go create mode 100644 internal/identity/file_test.go create mode 100644 internal/identity/provider.go diff --git a/internal/identity/factory.go b/internal/identity/factory.go new file mode 100644 index 0000000..02c1366 --- /dev/null +++ b/internal/identity/factory.go @@ -0,0 +1,21 @@ +package identity + +import ( + "context" + "fmt" + + "github.com/snangue/robin/internal/config" +) + +// New builds the identity provider selected by cfg.TokenSource. +func New(ctx context.Context, cfg config.Config) (IdentityProvider, error) { + switch cfg.TokenSource { + case "file": + return NewFileProvider(cfg.TokenFile), nil + case "jwtsvid": + // Real implementation lands in PR4 (SPIFFE JWT-SVID provider). + return nil, fmt.Errorf("identity: token source %q not yet available", cfg.TokenSource) + default: + return nil, fmt.Errorf("identity: unknown token source %q", cfg.TokenSource) + } +} diff --git a/internal/identity/file.go b/internal/identity/file.go new file mode 100644 index 0000000..e177068 --- /dev/null +++ b/internal/identity/file.go @@ -0,0 +1,36 @@ +package identity + +import ( + "context" + "fmt" + "os" + "strings" +) + +// FileProvider serves a Kubernetes projected ServiceAccount token from a file. +// The kubelet atomically rotates the file in place (~80% TTL), so the provider +// re-reads it on every request and never caches. +type FileProvider struct { + path string +} + +// NewFileProvider returns a FileProvider reading the token at path. +func NewFileProvider(path string) *FileProvider { + return &FileProvider{path: path} +} + +// Token reads, trims, and returns the token file contents. +func (p *FileProvider) Token(_ context.Context) (string, error) { + b, err := os.ReadFile(p.path) + if err != nil { + return "", fmt.Errorf("identity/file: read %s: %w", p.path, err) + } + tok := strings.TrimSpace(string(b)) + if tok == "" { + return "", ErrNoToken + } + return tok, nil +} + +// Close is a no-op; the file provider holds no resources. +func (p *FileProvider) Close() error { return nil } diff --git a/internal/identity/file_test.go b/internal/identity/file_test.go new file mode 100644 index 0000000..d039296 --- /dev/null +++ b/internal/identity/file_test.go @@ -0,0 +1,56 @@ +package identity + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" +) + +func TestFileProviderReadAndTrim(t *testing.T) { + path := filepath.Join(t.TempDir(), "token") + if err := os.WriteFile(path, []byte(" abc.def.ghi\n"), 0o600); err != nil { + t.Fatal(err) + } + tok, err := NewFileProvider(path).Token(context.Background()) + if err != nil { + t.Fatal(err) + } + if tok != "abc.def.ghi" { + t.Errorf("token = %q, want trimmed abc.def.ghi", tok) + } +} + +func TestFileProviderRotation(t *testing.T) { + path := filepath.Join(t.TempDir(), "token") + if err := os.WriteFile(path, []byte("first"), 0o600); err != nil { + t.Fatal(err) + } + p := NewFileProvider(path) + first, _ := p.Token(context.Background()) + if err := os.WriteFile(path, []byte("second"), 0o600); err != nil { // simulate kubelet swap + t.Fatal(err) + } + second, _ := p.Token(context.Background()) + if first != "first" || second != "second" { + t.Errorf("expected per-request re-read, got %q then %q", first, second) + } +} + +func TestFileProviderEmpty(t *testing.T) { + path := filepath.Join(t.TempDir(), "token") + if err := os.WriteFile(path, []byte(" \n"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := NewFileProvider(path).Token(context.Background()); !errors.Is(err, ErrNoToken) { + t.Errorf("want ErrNoToken, got %v", err) + } +} + +func TestFileProviderMissing(t *testing.T) { + p := NewFileProvider(filepath.Join(t.TempDir(), "nope")) + if _, err := p.Token(context.Background()); err == nil { + t.Error("want error for missing file") + } +} diff --git a/internal/identity/provider.go b/internal/identity/provider.go new file mode 100644 index 0000000..b395d43 --- /dev/null +++ b/internal/identity/provider.go @@ -0,0 +1,25 @@ +// Package identity resolves the workload's native identity as a bearer token. +// Each provider yields a token for the configured audience; the proxy applies +// it uniformly without knowing which provider produced it. +package identity + +import ( + "context" + "errors" +) + +// IdentityProvider resolves the workload's native identity as a bearer token. +type IdentityProvider interface { + // Token returns a bearer token valid for the configured audience. + // Implementations must be safe for concurrent use. + Token(ctx context.Context) (string, error) + // Close releases any background resources held by the provider. + Close() error +} + +// Sentinel errors. ErrAudienceMismatch is a fail-closed configuration error, +// not a transient condition. +var ( + ErrNoToken = errors.New("identity: empty token") + ErrAudienceMismatch = errors.New("identity: audience mismatch") +) From 04e58927084b197232295df66645993815f5ade8 Mon Sep 17 00:00:00 2001 From: Stephane NANGUE Date: Wed, 17 Jun 2026 18:44:30 +0200 Subject: [PATCH 05/12] PR4: SPIFFE JWT-SVID provider Adds the jwtsvid identity provider: cache keyed by audience, proactive refresh ahead of expiry (with a half-TTL clamp for short lifetimes), serve-stale-on-error while the cached token is still valid, and a singleflight collapse so a burst of requests triggers one Workload API call. The cache/refresh logic sits behind a jwtFetcher seam (fake + injectable clock in tests) so it needs no running SPIRE agent; the production source wraps workloadapi.JWTSource, passing the socket via WithAddr only when configured. Single direct dependency: go-spiffe v2.8.0. --- go.mod | 13 ++ go.sum | 52 ++++++++ internal/identity/factory.go | 7 +- internal/identity/jwtsvid.go | 126 +++++++++++++++++++ internal/identity/jwtsvid_source.go | 42 +++++++ internal/identity/jwtsvid_test.go | 182 ++++++++++++++++++++++++++++ 6 files changed, 420 insertions(+), 2 deletions(-) create mode 100644 go.sum create mode 100644 internal/identity/jwtsvid.go create mode 100644 internal/identity/jwtsvid_source.go create mode 100644 internal/identity/jwtsvid_test.go diff --git a/go.mod b/go.mod index 5864efc..9f709a7 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,16 @@ module github.com/snangue/robin go 1.26 + +require github.com/spiffe/go-spiffe/v2 v2.8.0 + +require ( + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/grpc v1.79.3 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1d66d32 --- /dev/null +++ b/go.sum @@ -0,0 +1,52 @@ +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/spiffe/go-spiffe/v2 v2.8.0 h1:vHCTEZYhpXZ9y6JkIouIdHLJobWGUFn2467/WsXHHjA= +github.com/spiffe/go-spiffe/v2 v2.8.0/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnOtlIJy3mxZ3U= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/identity/factory.go b/internal/identity/factory.go index 02c1366..3c01a16 100644 --- a/internal/identity/factory.go +++ b/internal/identity/factory.go @@ -13,8 +13,11 @@ func New(ctx context.Context, cfg config.Config) (IdentityProvider, error) { case "file": return NewFileProvider(cfg.TokenFile), nil case "jwtsvid": - // Real implementation lands in PR4 (SPIFFE JWT-SVID provider). - return nil, fmt.Errorf("identity: token source %q not yet available", cfg.TokenSource) + fetcher, err := newJWTSVIDSource(ctx, cfg) + if err != nil { + return nil, err + } + return newJWTSVIDProviderWith(fetcher, cfg.Audience, cfg.SVIDRefreshBefore), nil default: return nil, fmt.Errorf("identity: unknown token source %q", cfg.TokenSource) } diff --git a/internal/identity/jwtsvid.go b/internal/identity/jwtsvid.go new file mode 100644 index 0000000..8a120f4 --- /dev/null +++ b/internal/identity/jwtsvid.go @@ -0,0 +1,126 @@ +package identity + +import ( + "context" + "fmt" + "sync" + "time" +) + +// jwtFetcher is the minimal seam over the SPIFFE Workload API. The production +// adapter (spiffeFetcher) wraps a *workloadapi.JWTSource; tests inject a fake, +// so the cache / refresh / serve-stale logic needs no running SPIRE agent. +type jwtFetcher interface { + fetch(ctx context.Context, audience string) (token string, expiry time.Time, err error) + close() error +} + +type cachedSVID struct { + token string + expiry time.Time + refreshAt time.Time +} + +type refreshCall struct { + done chan struct{} + token string + err error +} + +// JWTSVIDProvider serves a SPIFFE JWT-SVID, refreshing it ahead of expiry and +// serving a still-valid cached token when the Workload API is briefly down. +// FetchJWTSVID is unary (not pushed), so the provider owns the refresh policy. +type JWTSVIDProvider struct { + fetcher jwtFetcher + audience string + refreshBefore time.Duration + now func() time.Time // injectable clock for tests + + mu sync.Mutex + cache map[string]cachedSVID + inflight map[string]*refreshCall +} + +func newJWTSVIDProviderWith(f jwtFetcher, audience string, refreshBefore time.Duration) *JWTSVIDProvider { + return &JWTSVIDProvider{ + fetcher: f, + audience: audience, + refreshBefore: refreshBefore, + now: time.Now, + cache: map[string]cachedSVID{}, + inflight: map[string]*refreshCall{}, + } +} + +// Token returns a valid JWT-SVID for the configured audience. +func (p *JWTSVIDProvider) Token(ctx context.Context) (string, error) { + aud := p.audience + + p.mu.Lock() + c, ok := p.cache[aud] + p.mu.Unlock() + + now := p.now() + if ok && now.Before(c.refreshAt) { + return c.token, nil // fresh — fast path, no fetch + } + + tok, err := p.refresh(ctx, aud) + if err != nil { + if ok && now.Before(c.expiry) { + return c.token, nil // serve stale: refresh failed but token still valid + } + return "", err + } + return tok, nil +} + +// refresh fetches a new SVID, collapsing concurrent refreshes so a burst of +// requests triggers a single Workload API call. +func (p *JWTSVIDProvider) refresh(ctx context.Context, aud string) (string, error) { + p.mu.Lock() + if call, ok := p.inflight[aud]; ok { + p.mu.Unlock() + select { + case <-call.done: + return call.token, call.err + case <-ctx.Done(): + return "", ctx.Err() + } + } + call := &refreshCall{done: make(chan struct{})} + p.inflight[aud] = call + p.mu.Unlock() + + tok, exp, err := p.fetcher.fetch(ctx, aud) + if err != nil { + tok = "" + err = fmt.Errorf("identity/jwtsvid: fetch: %w", err) + } + + p.mu.Lock() + delete(p.inflight, aud) + if err == nil { + p.cache[aud] = cachedSVID{token: tok, expiry: exp, refreshAt: p.refreshAt(exp)} + } + p.mu.Unlock() + + // Share one result (and one error shape) with any collapsed waiters. + call.token, call.err = tok, err + close(call.done) + return tok, err +} + +// refreshAt returns when a token expiring at exp should be proactively +// refreshed: refreshBefore ahead of exp, clamped to at most half the observed +// lifetime so very short TTLs are not refreshed too eagerly. +func (p *JWTSVIDProvider) refreshAt(exp time.Time) time.Time { + lead := p.refreshBefore + if ttl := exp.Sub(p.now()); ttl > 0 && ttl/2 < lead { + lead = ttl / 2 + } + return exp.Add(-lead) +} + +// Close releases the underlying Workload API source. +func (p *JWTSVIDProvider) Close() error { return p.fetcher.close() } diff --git a/internal/identity/jwtsvid_source.go b/internal/identity/jwtsvid_source.go new file mode 100644 index 0000000..018e576 --- /dev/null +++ b/internal/identity/jwtsvid_source.go @@ -0,0 +1,42 @@ +package identity + +import ( + "context" + "fmt" + "time" + + "github.com/spiffe/go-spiffe/v2/svid/jwtsvid" + "github.com/spiffe/go-spiffe/v2/workloadapi" + + "github.com/snangue/robin/internal/config" +) + +// spiffeFetcher is the production jwtFetcher, backed by the SPIFFE Workload API. +type spiffeFetcher struct { + src *workloadapi.JWTSource +} + +func (f *spiffeFetcher) fetch(ctx context.Context, aud string) (string, time.Time, error) { + svid, err := f.src.FetchJWTSVID(ctx, jwtsvid.Params{Audience: aud}) + if err != nil { + return "", time.Time{}, err + } + return svid.Marshal(), svid.Expiry, nil +} + +func (f *spiffeFetcher) close() error { return f.src.Close() } + +// newJWTSVIDSource creates a Workload API JWT source. The socket address is +// passed explicitly when set; otherwise go-spiffe's default applies (which +// honors SPIFFE_ENDPOINT_SOCKET). +func newJWTSVIDSource(ctx context.Context, cfg config.Config) (jwtFetcher, error) { + var opts []workloadapi.JWTSourceOption + if cfg.SPIFFESocket != "" { + opts = append(opts, workloadapi.WithClientOptions(workloadapi.WithAddr(cfg.SPIFFESocket))) + } + src, err := workloadapi.NewJWTSource(ctx, opts...) + if err != nil { + return nil, fmt.Errorf("identity/jwtsvid: create source: %w", err) + } + return &spiffeFetcher{src: src}, nil +} diff --git a/internal/identity/jwtsvid_test.go b/internal/identity/jwtsvid_test.go new file mode 100644 index 0000000..87f13d8 --- /dev/null +++ b/internal/identity/jwtsvid_test.go @@ -0,0 +1,182 @@ +package identity + +import ( + "context" + "errors" + "sync" + "testing" + "time" +) + +// fakeFetcher is a programmable jwtFetcher; fetch optionally blocks on gate. +type fakeFetcher struct { + mu sync.Mutex + calls int + tok string + exp time.Time + err error + gate chan struct{} +} + +func (f *fakeFetcher) fetch(_ context.Context, _ string) (string, time.Time, error) { + if f.gate != nil { + <-f.gate + } + f.mu.Lock() + defer f.mu.Unlock() + f.calls++ + return f.tok, f.exp, f.err +} + +func (f *fakeFetcher) close() error { return nil } + +func (f *fakeFetcher) callCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return f.calls +} + +func (f *fakeFetcher) set(tok string, exp time.Time, err error) { + f.mu.Lock() + defer f.mu.Unlock() + f.tok, f.exp, f.err = tok, exp, err +} + +// clock is a thread-safe injectable clock. +type clock struct { + mu sync.Mutex + t time.Time +} + +func (c *clock) now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return c.t +} + +func (c *clock) advance(d time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + c.t = c.t.Add(d) +} + +func newTestProvider(f jwtFetcher, now func() time.Time) *JWTSVIDProvider { + p := newJWTSVIDProviderWith(f, "broker.example", 60*time.Second) + p.now = now + return p +} + +var base = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + +func TestJWTSVIDCacheHit(t *testing.T) { + clk := &clock{t: base} + f := &fakeFetcher{tok: "tok1", exp: base.Add(5 * time.Minute)} + p := newTestProvider(f, clk.now) + + if tok, err := p.Token(context.Background()); err != nil || tok != "tok1" { + t.Fatalf("first Token = %q, %v", tok, err) + } + clk.advance(30 * time.Second) // still before refreshAt (exp-60s) + if tok, err := p.Token(context.Background()); err != nil || tok != "tok1" { + t.Fatalf("second Token = %q, %v", tok, err) + } + if f.callCount() != 1 { + t.Errorf("fetch calls = %d, want 1 (cache hit)", f.callCount()) + } +} + +func TestJWTSVIDProactiveRefresh(t *testing.T) { + clk := &clock{t: base} + f := &fakeFetcher{tok: "tok1", exp: base.Add(5 * time.Minute)} + p := newTestProvider(f, clk.now) + + if _, err := p.Token(context.Background()); err != nil { + t.Fatal(err) + } + f.set("tok2", clk.now().Add(5*time.Minute), nil) + clk.advance(4*time.Minute + time.Second) // past refreshAt (exp-60s), before exp + + tok, err := p.Token(context.Background()) + if err != nil || tok != "tok2" { + t.Fatalf("Token = %q, %v, want tok2", tok, err) + } + if f.callCount() != 2 { + t.Errorf("fetch calls = %d, want 2", f.callCount()) + } +} + +func TestJWTSVIDServeStaleOnError(t *testing.T) { + clk := &clock{t: base} + f := &fakeFetcher{tok: "tok1", exp: base.Add(5 * time.Minute)} + p := newTestProvider(f, clk.now) + + if _, err := p.Token(context.Background()); err != nil { + t.Fatal(err) + } + f.set("tok1", base.Add(5*time.Minute), errors.New("agent down")) + clk.advance(4*time.Minute + 30*time.Second) // refresh window, still before exp + + tok, err := p.Token(context.Background()) + if err != nil { + t.Fatalf("expected stale token served, got error %v", err) + } + if tok != "tok1" { + t.Errorf("Token = %q, want stale tok1", tok) + } +} + +func TestJWTSVIDHardFailWhenColdAndErroring(t *testing.T) { + clk := &clock{t: base} + f := &fakeFetcher{err: errors.New("agent down")} + p := newTestProvider(f, clk.now) + + if _, err := p.Token(context.Background()); err == nil { + t.Error("want error when cache is cold and fetch fails") + } +} + +func TestJWTSVIDHardFailWhenExpiredAndErroring(t *testing.T) { + clk := &clock{t: base} + f := &fakeFetcher{tok: "tok1", exp: base.Add(5 * time.Minute)} + p := newTestProvider(f, clk.now) + if _, err := p.Token(context.Background()); err != nil { + t.Fatal(err) + } + f.set("tok1", base.Add(5*time.Minute), errors.New("agent down")) + clk.advance(6 * time.Minute) // past exp: stale token no longer valid + + if _, err := p.Token(context.Background()); err == nil { + t.Error("want error when cached token has expired and fetch fails") + } +} + +func TestJWTSVIDConcurrencyCollapse(t *testing.T) { + clk := &clock{t: base} + gate := make(chan struct{}) + f := &fakeFetcher{tok: "tok1", exp: base.Add(5 * time.Minute), gate: gate} + p := newTestProvider(f, clk.now) + + const n = 25 + var wg sync.WaitGroup + toks := make([]string, n) + errs := make([]error, n) + for i := 0; i < n; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + toks[i], errs[i] = p.Token(context.Background()) + }(i) + } + time.Sleep(50 * time.Millisecond) // let all goroutines block on the in-flight fetch + close(gate) + wg.Wait() + + if f.callCount() != 1 { + t.Errorf("fetch calls = %d, want 1 (collapsed)", f.callCount()) + } + for i := 0; i < n; i++ { + if errs[i] != nil || toks[i] != "tok1" { + t.Errorf("goroutine %d: %q, %v", i, toks[i], errs[i]) + } + } +} From e371d7a3c25d606fdb0b7392310e17ee1ee820a9 Mon Sep 17 00:00:00 2001 From: Stephane NANGUE Date: Wed, 17 Jun 2026 18:50:03 +0200 Subject: [PATCH 06/12] PR5: proxy core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverse-proxy core built on httputil.ReverseProxy with the Rewrite hook: resolves identity in an outer handler (503 + broker untouched when identity is unavailable), injects Authorization: Bearer over any inbound placeholder, and surfaces an unreachable broker as 502 with a single attempt (no retry). Body-agnostic and streaming — the request body is never read. ModifyResponse emits one access-log line per request (decision + upstream status); the token value is never logged. Broker TLS via a cloned default transport with optional pinned RootCAs. Tests: inject/overwrite/body+path passthrough, 503, 502, CA load paths. --- internal/proxy/proxy.go | 93 +++++++++++++++++++++++++++ internal/proxy/proxy_test.go | 107 +++++++++++++++++++++++++++++++ internal/proxy/transport.go | 34 ++++++++++ internal/proxy/transport_test.go | 60 +++++++++++++++++ 4 files changed, 294 insertions(+) create mode 100644 internal/proxy/proxy.go create mode 100644 internal/proxy/proxy_test.go create mode 100644 internal/proxy/transport.go create mode 100644 internal/proxy/transport_test.go diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go new file mode 100644 index 0000000..d83c29e --- /dev/null +++ b/internal/proxy/proxy.go @@ -0,0 +1,93 @@ +// Package proxy is Robin's reverse-proxy core: it resolves the workload's +// identity and injects it as a bearer token on every request forwarded to the +// broker. It is body-agnostic and streaming — the request body is never read. +package proxy + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "net/http/httputil" + "net/url" + + "github.com/snangue/robin/internal/config" + "github.com/snangue/robin/internal/identity" + "github.com/snangue/robin/internal/obs" +) + +type ctxKey int + +const tokenCtxKey ctxKey = 0 + +// Handler resolves identity and proxies requests to the broker, injecting the +// token as a bearer header. +type Handler struct { + provider identity.IdentityProvider + rp *httputil.ReverseProxy + log *slog.Logger + source string + audience string +} + +// New builds a proxy Handler targeting cfg.UpstreamURL. +func New(cfg config.Config, p identity.IdentityProvider, log *slog.Logger) (*Handler, error) { + target, err := url.Parse(cfg.UpstreamURL) + if err != nil { + return nil, fmt.Errorf("proxy: parse upstream URL: %w", err) + } + transport, err := buildTransport(cfg) + if err != nil { + return nil, err + } + + h := &Handler{ + provider: p, + log: log, + source: cfg.TokenSource, + audience: cfg.Audience, + } + h.rp = &httputil.ReverseProxy{ + Transport: transport, + Rewrite: func(pr *httputil.ProxyRequest) { + pr.SetURL(target) + // Inject identity, overwriting any inbound placeholder credential. + tok, _ := pr.In.Context().Value(tokenCtxKey).(string) + pr.Out.Header.Set("Authorization", "Bearer "+tok) + }, + ModifyResponse: func(resp *http.Response) error { + // One access-log line per request, with the broker's status. + h.log.Info("request injected", + slog.String(obs.FieldProvider, h.source), + slog.String(obs.FieldAudience, h.audience), + slog.String(obs.FieldDecision, obs.DecisionInjected), + slog.Int(obs.FieldUpstreamStatus, resp.StatusCode)) + return nil + }, + ErrorHandler: func(w http.ResponseWriter, _ *http.Request, err error) { + // Single attempt — surface as 502, never retry into a thundering herd. + h.log.Error("broker unreachable", + slog.String(obs.FieldProvider, h.source), + slog.String(obs.FieldDecision, obs.DecisionUpstreamError), + slog.String(obs.FieldError, err.Error())) + w.WriteHeader(http.StatusBadGateway) + }, + } + return h, nil +} + +// ServeHTTP resolves the workload's identity and forwards the request. If the +// identity cannot be resolved it returns 503 and never contacts the broker. +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + tok, err := h.provider.Token(r.Context()) + if err != nil { + h.log.Warn("identity unavailable", + slog.String(obs.FieldProvider, h.source), + slog.String(obs.FieldDecision, obs.DecisionFailed), + slog.String(obs.FieldError, err.Error())) + http.Error(w, "identity unavailable", http.StatusServiceUnavailable) + return + } + ctx := context.WithValue(r.Context(), tokenCtxKey, tok) + h.rp.ServeHTTP(w, r.WithContext(ctx)) +} diff --git a/internal/proxy/proxy_test.go b/internal/proxy/proxy_test.go new file mode 100644 index 0000000..0f5c646 --- /dev/null +++ b/internal/proxy/proxy_test.go @@ -0,0 +1,107 @@ +package proxy + +import ( + "context" + "errors" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/snangue/robin/internal/config" +) + +type stubProvider struct { + tok string + err error +} + +func (s stubProvider) Token(context.Context) (string, error) { return s.tok, s.err } +func (s stubProvider) Close() error { return nil } + +func discardLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } + +func newHandler(t *testing.T, upstream string, p stubProvider) *Handler { + t.Helper() + h, err := New(config.Config{UpstreamURL: upstream, TokenSource: "file"}, p, discardLogger()) + if err != nil { + t.Fatalf("New: %v", err) + } + return h +} + +func TestInjectOverwritesPlaceholderAndForwardsBody(t *testing.T) { + var gotAuth, gotBody, gotPath string + broker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + gotPath = r.URL.Path + b, _ := io.ReadAll(r.Body) + gotBody = string(b) + w.WriteHeader(http.StatusNoContent) + })) + defer broker.Close() + + srv := httptest.NewServer(newHandler(t, broker.URL, stubProvider{tok: "realtok"})) + defer srv.Close() + + req, _ := http.NewRequest(http.MethodPost, srv.URL+"/v1/chat", strings.NewReader("hello-body")) + req.Header.Set("Authorization", "Bearer placeholder") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + + if gotAuth != "Bearer realtok" { + t.Errorf("Authorization = %q, want Bearer realtok (placeholder overwritten)", gotAuth) + } + if gotBody != "hello-body" { + t.Errorf("forwarded body = %q, want hello-body", gotBody) + } + if gotPath != "/v1/chat" { + t.Errorf("forwarded path = %q, want /v1/chat", gotPath) + } +} + +func TestProviderErrorReturns503(t *testing.T) { + called := false + broker := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + called = true + })) + defer broker.Close() + + srv := httptest.NewServer(newHandler(t, broker.URL, stubProvider{err: errors.New("boom")})) + defer srv.Close() + + resp, err := http.Get(srv.URL) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusServiceUnavailable { + t.Errorf("status = %d, want 503", resp.StatusCode) + } + if called { + t.Error("broker must not be contacted when identity is unavailable") + } +} + +func TestBrokerDownReturns502(t *testing.T) { + dead := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + deadURL := dead.URL + dead.Close() // nothing is listening now + + srv := httptest.NewServer(newHandler(t, deadURL, stubProvider{tok: "tok"})) + defer srv.Close() + + resp, err := http.Get(srv.URL) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadGateway { + t.Errorf("status = %d, want 502", resp.StatusCode) + } +} diff --git a/internal/proxy/transport.go b/internal/proxy/transport.go new file mode 100644 index 0000000..a59a4bb --- /dev/null +++ b/internal/proxy/transport.go @@ -0,0 +1,34 @@ +package proxy + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "net/http" + "os" + + "github.com/snangue/robin/internal/config" +) + +// buildTransport clones the default transport (preserving connection-pool and +// timeout defaults) and, when a CA file is configured, pins the broker's trust +// roots for TLS verification. +func buildTransport(cfg config.Config) (*http.Transport, error) { + base, ok := http.DefaultTransport.(*http.Transport) + if !ok { + return nil, fmt.Errorf("proxy: unexpected default transport type %T", http.DefaultTransport) + } + t := base.Clone() + if cfg.UpstreamCAFile != "" { + pem, err := os.ReadFile(cfg.UpstreamCAFile) + if err != nil { + return nil, fmt.Errorf("proxy: read upstream CA %s: %w", cfg.UpstreamCAFile, err) + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(pem) { + return nil, fmt.Errorf("proxy: no certificates found in %s", cfg.UpstreamCAFile) + } + t.TLSClientConfig = &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12} + } + return t, nil +} diff --git a/internal/proxy/transport_test.go b/internal/proxy/transport_test.go new file mode 100644 index 0000000..f1f23ee --- /dev/null +++ b/internal/proxy/transport_test.go @@ -0,0 +1,60 @@ +package proxy + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "os" + "path/filepath" + "testing" + + "github.com/snangue/robin/internal/config" +) + +func TestBuildTransportNoCA(t *testing.T) { + tr, err := buildTransport(config.Config{}) + if err != nil { + t.Fatal(err) + } + if tr.TLSClientConfig != nil && tr.TLSClientConfig.RootCAs != nil { + t.Error("expected no custom RootCAs when CA file is unset") + } +} + +func TestBuildTransportBadCA(t *testing.T) { + path := filepath.Join(t.TempDir(), "ca.pem") + if err := os.WriteFile(path, []byte("not a pem"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := buildTransport(config.Config{UpstreamCAFile: path}); err == nil { + t.Error("expected error for a file with no valid certificates") + } +} + +func TestBuildTransportGoodCA(t *testing.T) { + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + tmpl := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "test-ca"}} + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "ca.pem") + if err := os.WriteFile(path, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o600); err != nil { + t.Fatal(err) + } + + tr, err := buildTransport(config.Config{UpstreamCAFile: path}) + if err != nil { + t.Fatal(err) + } + if tr.TLSClientConfig == nil || tr.TLSClientConfig.RootCAs == nil { + t.Error("expected RootCAs to be populated from the CA file") + } +} From e11e1093ddcd18e67ec1fb1d2cd17864badd9293 Mon Sep 17 00:00:00 2001 From: Stephane NANGUE Date: Wed, 17 Jun 2026 18:58:05 +0200 Subject: [PATCH 07/12] PR6: server, health/readiness, and end-to-end wiring Dual-listener server: the proxy plane (TCP or UDS) and a separate admin plane (/healthz liveness, /readyz readiness via provider.Token). Both listeners bind up front so addresses are testable; Run drains in-flight egress on SIGTERM/SIGINT (proxy first, then admin) within a 25s budget. main now wires config -> provider -> proxy -> server with signal-driven shutdown. Robin runs end to end. Tests cover health/readiness/proxy, readyz reflecting provider failure, and graceful-shutdown draining. --- internal/server/admin.go | 33 ++++++++ internal/server/listen.go | 34 ++++++++ internal/server/server.go | 84 +++++++++++++++++++ internal/server/server_test.go | 146 +++++++++++++++++++++++++++++++++ 4 files changed, 297 insertions(+) create mode 100644 internal/server/admin.go create mode 100644 internal/server/listen.go create mode 100644 internal/server/server.go create mode 100644 internal/server/server_test.go diff --git a/internal/server/admin.go b/internal/server/admin.go new file mode 100644 index 0000000..2dc9bfb --- /dev/null +++ b/internal/server/admin.go @@ -0,0 +1,33 @@ +package server + +import ( + "io" + "net/http" + + "github.com/snangue/robin/internal/identity" +) + +// AdminMux builds the admin handler served on a listener separate from the +// proxy: liveness, readiness, and (in v0.2) metrics. These must not live on the +// proxy listener, which forwards every path to the broker. +func AdminMux(p identity.IdentityProvider) http.Handler { + mux := http.NewServeMux() + + // Liveness: the process is up. + mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, "ok") + }) + + // Readiness: an identity can actually be resolved. + mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) { + if _, err := p.Token(r.Context()); err != nil { + http.Error(w, "not ready", http.StatusServiceUnavailable) + return + } + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, "ready") + }) + + return mux +} diff --git a/internal/server/listen.go b/internal/server/listen.go new file mode 100644 index 0000000..289085f --- /dev/null +++ b/internal/server/listen.go @@ -0,0 +1,34 @@ +package server + +import ( + "fmt" + "net" + "os" + + "github.com/snangue/robin/internal/config" +) + +// proxyListener builds the proxy listener: a Unix domain socket when configured +// (peer-cred hardening lands in v0.2), otherwise a TCP listener. +func proxyListener(cfg config.Config) (net.Listener, error) { + if cfg.ListenUDS != "" { + // Remove a stale socket left behind by a previous run. + if err := os.Remove(cfg.ListenUDS); err != nil && !os.IsNotExist(err) { + return nil, fmt.Errorf("server: remove stale socket %s: %w", cfg.ListenUDS, err) + } + ln, err := net.Listen("unix", cfg.ListenUDS) + if err != nil { + return nil, fmt.Errorf("server: listen unix %s: %w", cfg.ListenUDS, err) + } + if err := os.Chmod(cfg.ListenUDS, 0o660); err != nil { + _ = ln.Close() + return nil, fmt.Errorf("server: chmod %s: %w", cfg.ListenUDS, err) + } + return ln, nil + } + ln, err := net.Listen("tcp", cfg.ListenAddr) + if err != nil { + return nil, fmt.Errorf("server: listen tcp %s: %w", cfg.ListenAddr, err) + } + return ln, nil +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..742cbe0 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,84 @@ +// Package server runs Robin's two listeners — the proxy plane and a separate +// admin plane (health/readiness/metrics) — with graceful, drain-on-SIGTERM +// shutdown so in-flight egress is not severed during pod termination. +package server + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net" + "net/http" + "time" + + "github.com/snangue/robin/internal/config" +) + +// shutdownTimeout bounds the in-flight drain. Deployments must set their +// termination grace period strictly larger (see deploy/ in v0.2). +const shutdownTimeout = 25 * time.Second + +// Server owns the proxy and admin listeners. +type Server struct { + proxy *http.Server + admin *http.Server + proxyLn net.Listener + adminLn net.Listener + log *slog.Logger +} + +// New binds both listeners (proxy: TCP or UDS; admin: TCP) up front, so their +// resolved addresses are available before Run and addressable in tests. +func New(cfg config.Config, proxyHandler, adminHandler http.Handler, log *slog.Logger) (*Server, error) { + proxyLn, err := proxyListener(cfg) + if err != nil { + return nil, err + } + adminLn, err := net.Listen("tcp", cfg.AdminAddr) + if err != nil { + _ = proxyLn.Close() + return nil, fmt.Errorf("server: listen admin %s: %w", cfg.AdminAddr, err) + } + return &Server{ + proxy: &http.Server{Handler: proxyHandler}, + admin: &http.Server{Handler: adminHandler}, + proxyLn: proxyLn, + adminLn: adminLn, + log: log, + }, nil +} + +// ProxyAddr returns the resolved proxy listener address. +func (s *Server) ProxyAddr() net.Addr { return s.proxyLn.Addr() } + +// AdminAddr returns the resolved admin listener address. +func (s *Server) AdminAddr() net.Addr { return s.adminLn.Addr() } + +// Run serves both planes until ctx is canceled (SIGTERM), then drains +// in-flight requests within shutdownTimeout. +func (s *Server) Run(ctx context.Context) error { + errc := make(chan error, 2) + serve := func(srv *http.Server, ln net.Listener) { + if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) { + errc <- err + } + } + go serve(s.proxy, s.proxyLn) + go serve(s.admin, s.adminLn) + + s.log.Info("robin started", + slog.String("proxy", s.proxyLn.Addr().String()), + slog.String("admin", s.adminLn.Addr().String())) + + select { + case err := <-errc: + return err + case <-ctx.Done(): + s.log.Info("shutting down, draining in-flight egress") + shutCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() + // Drain the proxy first (finish in-flight egress), then the admin plane. + return errors.Join(s.proxy.Shutdown(shutCtx), s.admin.Shutdown(shutCtx)) + } +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go new file mode 100644 index 0000000..bc1f4ec --- /dev/null +++ b/internal/server/server_test.go @@ -0,0 +1,146 @@ +package server + +import ( + "context" + "errors" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "github.com/snangue/robin/internal/config" + "github.com/snangue/robin/internal/proxy" +) + +type stubProvider struct { + tok string + err error +} + +func (s stubProvider) Token(context.Context) (string, error) { return s.tok, s.err } +func (s stubProvider) Close() error { return nil } + +func discardLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } + +func startServer(t *testing.T, broker string, p stubProvider) *Server { + t.Helper() + cfg := config.Config{UpstreamURL: broker, TokenSource: "file", ListenAddr: "127.0.0.1:0", AdminAddr: "127.0.0.1:0"} + h, err := proxy.New(cfg, p, discardLogger()) + if err != nil { + t.Fatal(err) + } + srv, err := New(cfg, h, AdminMux(p), discardLogger()) + if err != nil { + t.Fatal(err) + } + return srv +} + +func TestHealthReadyAndProxy(t *testing.T) { + broker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Auth", r.Header.Get("Authorization")) + w.WriteHeader(http.StatusNoContent) + })) + defer broker.Close() + + srv := startServer(t, broker.URL, stubProvider{tok: "tok"}) + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- srv.Run(ctx) }() + + get := func(url string) *http.Response { + t.Helper() + resp, err := http.Get(url) + if err != nil { + t.Fatal(err) + } + return resp + } + + if r := get("http://" + srv.AdminAddr().String() + "/healthz"); r.StatusCode != http.StatusOK { + t.Errorf("/healthz = %d, want 200", r.StatusCode) + r.Body.Close() + } else { + r.Body.Close() + } + + if r := get("http://" + srv.AdminAddr().String() + "/readyz"); r.StatusCode != http.StatusOK { + t.Errorf("/readyz = %d, want 200", r.StatusCode) + r.Body.Close() + } else { + r.Body.Close() + } + + r := get("http://" + srv.ProxyAddr().String() + "/v1/x") + if got := r.Header.Get("X-Auth"); got != "Bearer tok" { + t.Errorf("proxied Authorization = %q, want Bearer tok", got) + } + r.Body.Close() + + cancel() + if err := <-done; err != nil { + t.Errorf("Run returned %v", err) + } +} + +func TestReadyzReflectsProviderFailure(t *testing.T) { + broker := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + defer broker.Close() + + srv := startServer(t, broker.URL, stubProvider{err: errors.New("workload api down")}) + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- srv.Run(ctx) }() + + resp, err := http.Get("http://" + srv.AdminAddr().String() + "/readyz") + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != http.StatusServiceUnavailable { + t.Errorf("/readyz = %d, want 503 when provider cannot resolve", resp.StatusCode) + } + resp.Body.Close() + + cancel() + <-done +} + +func TestGracefulShutdownDrainsInflight(t *testing.T) { + received := make(chan struct{}) + release := make(chan struct{}) + broker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + close(received) + <-release + w.WriteHeader(http.StatusOK) + })) + defer broker.Close() + + srv := startServer(t, broker.URL, stubProvider{tok: "tok"}) + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- srv.Run(ctx) }() + + addr := srv.ProxyAddr().String() + codes := make(chan int, 1) + go func() { + resp, err := http.Get("http://" + addr + "/slow") + if err != nil { + codes <- -1 + return + } + codes <- resp.StatusCode + resp.Body.Close() + }() + + <-received // request is in-flight at the broker + cancel() // SIGTERM-equivalent: begin graceful shutdown + close(release) // let the broker finish responding + + if code := <-codes; code != http.StatusOK { + t.Errorf("in-flight request got %d, want 200 (should drain, not be severed)", code) + } + if err := <-done; err != nil { + t.Errorf("Run returned %v", err) + } +} From 096b74616a8cd42059c4111e8b152015667e3aff Mon Sep 17 00:00:00 2001 From: Stephane NANGUE Date: Wed, 17 Jun 2026 19:07:26 +0200 Subject: [PATCH 08/12] PR7: deploy artifacts (Dockerfile + native-sidecar manifest) Multi-stage Dockerfile: static CGO-free build into distroless/static nonroot (~24MB), version via ldflags. Native-sidecar Kubernetes example (initContainer restartPolicy: Always, K8s 1.29+) with an audience-scoped projected SA token, hardened securityContext, probes on the admin plane, and terminationGracePeriodSeconds above the drain budget. Proxy plane binds loopback (pod-local trust boundary); admin plane binds all interfaces so kubelet probes reach it. README quickstart added and the design-doc bind-address note corrected for probe reachability. --- README.md | 31 ++++++++++++ deploy/Dockerfile | 27 ++++++++++ deploy/k8s/sidecar-example.yaml | 90 +++++++++++++++++++++++++++++++++ 3 files changed, 148 insertions(+) create mode 100644 deploy/Dockerfile create mode 100644 deploy/k8s/sidecar-example.yaml diff --git a/README.md b/README.md index 0b68f5a..e06c93a 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,37 @@ It is a streaming reverse proxy with a pluggable identity provider. Every provid **Threat model in one line:** anything on the pod's loopback can ask Robin to present the workload's identity — so Robin defaults to loopback-only, with a Unix-domain-socket + `SO_PEERCRED` peer-credential mode for hardened deployments (v0.2). +## Quickstart + +**Build:** + +```sh +make build # -> bin/robin (static, CGO-free) +make test # go test ./... +``` + +**Run locally** (file provider, pointing at any broker/echo endpoint): + +```sh +ROBIN_UPSTREAM_URL=https://broker.example:8443 \ +ROBIN_TOKEN_SOURCE=file \ +ROBIN_TOKEN_FILE=/var/run/secrets/tokens/broker-token \ +ROBIN_LISTEN_ADDR=127.0.0.1:4000 \ +ROBIN_ADMIN_ADDR=:4001 \ + bin/robin +# app egress: point it at http://127.0.0.1:4000 ; probe http://:4001/healthz +``` + +**Container image:** + +```sh +docker build -t robin:0.1.0 -f deploy/Dockerfile . # ~24MB distroless, nonroot +``` + +**Kubernetes native sidecar:** see [`deploy/k8s/sidecar-example.yaml`](deploy/k8s/sidecar-example.yaml) — Robin runs as an `initContainer` with `restartPolicy: Always` (K8s 1.29+), the app points its egress at `127.0.0.1:4000`, and probes hit the admin plane on `:4001`. + +> Bind the **proxy** plane to `127.0.0.1` (only this pod's app should reach it); bind the **admin** plane to all interfaces (`:4001`) so the kubelet's liveness/readiness probes can reach it. + ## Identity providers | Source | `ROBIN_TOKEN_SOURCE` | Rotation | diff --git a/deploy/Dockerfile b/deploy/Dockerfile new file mode 100644 index 0000000..c1c0cfb --- /dev/null +++ b/deploy/Dockerfile @@ -0,0 +1,27 @@ +# syntax=docker/dockerfile:1 + +# --- build stage: static, CGO-free binary --- +FROM golang:1.26 AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +ARG VERSION=dev +ARG COMMIT=none +ARG DATE=unknown +RUN CGO_ENABLED=0 GOOS=linux go build \ + -trimpath \ + -ldflags="-s -w \ + -X github.com/snangue/robin/internal/version.Version=${VERSION} \ + -X github.com/snangue/robin/internal/version.Commit=${COMMIT} \ + -X github.com/snangue/robin/internal/version.Date=${DATE}" \ + -o /out/robin ./cmd/robin + +# --- runtime stage: distroless static, non-root --- +# distroless/static ships CA roots and /etc/passwd; the binary is fully static. +FROM gcr.io/distroless/static:nonroot +COPY --from=build /out/robin /robin +USER nonroot:nonroot +# 4000 = proxy plane (bind loopback in a sidecar); 4001 = admin plane (probes/metrics). +EXPOSE 4000 4001 +ENTRYPOINT ["/robin"] diff --git a/deploy/k8s/sidecar-example.yaml b/deploy/k8s/sidecar-example.yaml new file mode 100644 index 0000000..0e23d1a --- /dev/null +++ b/deploy/k8s/sidecar-example.yaml @@ -0,0 +1,90 @@ +# Robin as a native sidecar (Kubernetes 1.29+). +# +# Robin runs as an initContainer with restartPolicy: Always, so it starts before +# the app container (no first-call race) and terminates after it (no in-flight +# egress loss). The app sends egress to Robin on loopback; Robin injects the +# pod's projected ServiceAccount token as a bearer and forwards to the broker. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: agent-with-robin + labels: + app: agent +spec: + replicas: 1 + selector: + matchLabels: + app: agent + template: + metadata: + labels: + app: agent + spec: + serviceAccountName: agent + terminationGracePeriodSeconds: 30 # > Robin's 25s in-flight drain budget + containers: + - name: agent + image: ghcr.io/example/kagent:latest + env: + # Point the framework's egress at Robin on loopback, not the broker. + - name: OPENAI_BASE_URL + value: "http://127.0.0.1:4000/v1" + # kagent requires *some* key; it is inert — Robin overwrites Authorization. + - name: OPENAI_API_KEY + value: "unused-placeholder" + initContainers: + - name: robin + image: ghcr.io/snangue/robin:0.1.0 + restartPolicy: Always # native sidecar: starts first, stops last + args: [] + env: + - name: ROBIN_UPSTREAM_URL + value: "https://broker.example.svc:8443" + - name: ROBIN_TOKEN_SOURCE + value: "file" + # Proxy plane: loopback only — only this pod's app may ask for injection. + - name: ROBIN_LISTEN_ADDR + value: "127.0.0.1:4000" + # Admin plane: all interfaces, so the kubelet can reach the probes. + - name: ROBIN_ADMIN_ADDR + value: ":4001" + - name: ROBIN_TOKEN_FILE + value: "/var/run/secrets/tokens/broker-token" + volumeMounts: + - name: broker-token + mountPath: /var/run/secrets/tokens + readOnly: true + livenessProbe: + httpGet: + path: /healthz + port: 4001 + readinessProbe: + httpGet: + path: /readyz + port: 4001 + resources: + requests: + cpu: 10m + memory: 16Mi + limits: + memory: 64Mi + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + capabilities: + drop: ["ALL"] + volumes: + - name: broker-token + projected: + sources: + - serviceAccountToken: + path: broker-token + # Must match the audience the broker validates. + audience: "broker.example" + expirationSeconds: 3600 +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: agent From cd995f005ab6071b4a61908b9ca3c2b4eead3235 Mon Sep 17 00:00:00 2001 From: Stephane NANGUE Date: Fri, 19 Jun 2026 00:49:17 +0200 Subject: [PATCH 09/12] fix(review): correctness and security findings H1 fail-closed: the jwtsvid provider now rejects an empty or already-expired SVID (mirroring the file provider ErrNoToken) instead of caching it, and the proxy treats a ("", nil) token as 503 -- closing a path that forwarded "Authorization: Bearer " (empty) to the broker. H2 panic-safety: jwtsvid refresh moves inflight cleanup and waiter wakeup into a deferred recover, so a panicking Workload API client can no longer permanently wedge the provider (poisoned inflight / unclosed channel). Also closes a TOCTOU gap the new test surfaced: refresh re-checks the cache under the lock before becoming leader, so a burst truly collapses to one fetch (previously a waiter could start a redundant fetch). M1: server Run drains BOTH planes when one listener errors, not only on SIGTERM, so a Serve failure never leaves the other plane running. L3: pin TLS MinVersion 1.2 on the broker transport even without a CA file. L4: the concurrency-collapse test is now deterministic (leader-first handshake, no sleep). New tests cover empty/expired/panic and the proxy empty-token 503. --- internal/identity/jwtsvid.go | 52 ++++++++++++----- internal/identity/jwtsvid_test.go | 96 ++++++++++++++++++++++++++----- internal/proxy/proxy.go | 5 ++ internal/proxy/proxy_test.go | 24 ++++++++ internal/proxy/transport.go | 5 +- internal/server/server.go | 15 +++-- 6 files changed, 163 insertions(+), 34 deletions(-) diff --git a/internal/identity/jwtsvid.go b/internal/identity/jwtsvid.go index 8a120f4..49e6d6d 100644 --- a/internal/identity/jwtsvid.go +++ b/internal/identity/jwtsvid.go @@ -76,9 +76,17 @@ func (p *JWTSVIDProvider) Token(ctx context.Context) (string, error) { } // refresh fetches a new SVID, collapsing concurrent refreshes so a burst of -// requests triggers a single Workload API call. -func (p *JWTSVIDProvider) refresh(ctx context.Context, aud string) (string, error) { +// requests triggers a single Workload API call. A fetched token is rejected if +// it is empty or already expired, so the provider fails closed (like the file +// provider) rather than handing the proxy a degenerate "Bearer " credential. +func (p *JWTSVIDProvider) refresh(ctx context.Context, aud string) (tok string, err error) { p.mu.Lock() + // Re-check under the lock: a concurrent refresh may have just populated a + // fresh entry between our cache read in Token and acquiring this lock. + if c, ok := p.cache[aud]; ok && p.now().Before(c.refreshAt) { + p.mu.Unlock() + return c.token, nil + } if call, ok := p.inflight[aud]; ok { p.mu.Unlock() select { @@ -92,22 +100,36 @@ func (p *JWTSVIDProvider) refresh(ctx context.Context, aud string) (string, erro p.inflight[aud] = call p.mu.Unlock() - tok, exp, err := p.fetcher.fetch(ctx, aud) - if err != nil { - tok = "" + var exp time.Time + // Always release leadership and wake waiters — even if fetch panics — so a + // misbehaving Workload API client can never permanently wedge the provider. + defer func() { + if r := recover(); r != nil { + tok, exp, err = "", time.Time{}, fmt.Errorf("identity/jwtsvid: fetch panicked: %v", r) + } + p.mu.Lock() + delete(p.inflight, aud) + if err == nil { + p.cache[aud] = cachedSVID{token: tok, expiry: exp, refreshAt: p.refreshAt(exp)} + } + p.mu.Unlock() + // Share one result (and one error shape) with any collapsed waiters. + call.token, call.err = tok, err + close(call.done) + }() + + tok, exp, err = p.fetcher.fetch(ctx, aud) + switch { + case err != nil: err = fmt.Errorf("identity/jwtsvid: fetch: %w", err) + case tok == "": + err = fmt.Errorf("identity/jwtsvid: %w", ErrNoToken) + case !exp.After(p.now()): + err = fmt.Errorf("identity/jwtsvid: fetched SVID already expired at %s", exp.UTC().Format(time.RFC3339)) } - - p.mu.Lock() - delete(p.inflight, aud) - if err == nil { - p.cache[aud] = cachedSVID{token: tok, expiry: exp, refreshAt: p.refreshAt(exp)} + if err != nil { + tok = "" } - p.mu.Unlock() - - // Share one result (and one error shape) with any collapsed waiters. - call.token, call.err = tok, err - close(call.done) return tok, err } diff --git a/internal/identity/jwtsvid_test.go b/internal/identity/jwtsvid_test.go index 87f13d8..c974a23 100644 --- a/internal/identity/jwtsvid_test.go +++ b/internal/identity/jwtsvid_test.go @@ -8,17 +8,23 @@ import ( "time" ) -// fakeFetcher is a programmable jwtFetcher; fetch optionally blocks on gate. +// fakeFetcher is a programmable jwtFetcher. fetch signals `entered` (if set) +// when it begins, then blocks on `gate` (if set) — letting tests pin a fetch +// in flight deterministically. type fakeFetcher struct { - mu sync.Mutex - calls int - tok string - exp time.Time - err error - gate chan struct{} + mu sync.Mutex + calls int + tok string + exp time.Time + err error + gate chan struct{} + entered chan struct{} } func (f *fakeFetcher) fetch(_ context.Context, _ string) (string, time.Time, error) { + if f.entered != nil { + f.entered <- struct{}{} + } if f.gate != nil { <-f.gate } @@ -42,6 +48,23 @@ func (f *fakeFetcher) set(tok string, exp time.Time, err error) { f.tok, f.exp, f.err = tok, exp, err } +// panicFetcher panics until stop is set — to prove a panicking Workload API +// client cannot permanently wedge the provider. +type panicFetcher struct { + stop bool + tok string + exp time.Time +} + +func (f *panicFetcher) fetch(context.Context, string) (string, time.Time, error) { + if !f.stop { + panic("workload api client blew up") + } + return f.tok, f.exp, nil +} + +func (f *panicFetcher) close() error { return nil } + // clock is a thread-safe injectable clock. type clock struct { mu sync.Mutex @@ -150,13 +173,58 @@ func TestJWTSVIDHardFailWhenExpiredAndErroring(t *testing.T) { } } +func TestJWTSVIDRejectsEmptyToken(t *testing.T) { + clk := &clock{t: base} + f := &fakeFetcher{tok: "", exp: base.Add(5 * time.Minute)} + p := newTestProvider(f, clk.now) + if _, err := p.Token(context.Background()); err == nil { + t.Error("want error: an empty token must not be returned as a valid bearer") + } +} + +func TestJWTSVIDRejectsExpiredToken(t *testing.T) { + clk := &clock{t: base} + f := &fakeFetcher{tok: "stale", exp: base.Add(-time.Minute)} // already expired + p := newTestProvider(f, clk.now) + if _, err := p.Token(context.Background()); err == nil { + t.Error("want error: an already-expired token must not be returned as valid") + } +} + +func TestJWTSVIDSurvivesFetchPanic(t *testing.T) { + clk := &clock{t: base} + f := &panicFetcher{} + p := newTestProvider(f, clk.now) + + if _, err := p.Token(context.Background()); err == nil { + t.Fatal("want error when fetch panics") + } + // The provider must not be wedged: a later successful fetch works. + f.stop = true + f.tok = "tok1" + f.exp = base.Add(5 * time.Minute) + if tok, err := p.Token(context.Background()); err != nil || tok != "tok1" { + t.Fatalf("provider wedged after panic: tok=%q err=%v", tok, err) + } +} + func TestJWTSVIDConcurrencyCollapse(t *testing.T) { clk := &clock{t: base} gate := make(chan struct{}) - f := &fakeFetcher{tok: "tok1", exp: base.Add(5 * time.Minute), gate: gate} + entered := make(chan struct{}, 1) + f := &fakeFetcher{tok: "tok1", exp: base.Add(5 * time.Minute), gate: gate, entered: entered} p := newTestProvider(f, clk.now) - const n = 25 + // Leader enters fetch and blocks there, holding inflight while the cache is + // still empty — so any concurrent caller is forced onto the collapse path. + leader := make(chan string, 1) + go func() { + tok, _ := p.Token(context.Background()) + leader <- tok + }() + <-entered // leader is now inside fetch: inflight set, cache empty + + const n = 20 var wg sync.WaitGroup toks := make([]string, n) errs := make([]error, n) @@ -167,16 +235,18 @@ func TestJWTSVIDConcurrencyCollapse(t *testing.T) { toks[i], errs[i] = p.Token(context.Background()) }(i) } - time.Sleep(50 * time.Millisecond) // let all goroutines block on the in-flight fetch - close(gate) + close(gate) // release the single in-flight fetch wg.Wait() + if got := <-leader; got != "tok1" { + t.Errorf("leader token = %q, want tok1", got) + } if f.callCount() != 1 { - t.Errorf("fetch calls = %d, want 1 (collapsed)", f.callCount()) + t.Errorf("fetch calls = %d, want exactly 1 (collapsed)", f.callCount()) } for i := 0; i < n; i++ { if errs[i] != nil || toks[i] != "tok1" { - t.Errorf("goroutine %d: %q, %v", i, toks[i], errs[i]) + t.Errorf("waiter %d: %q, %v", i, toks[i], errs[i]) } } } diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index d83c29e..b784679 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -5,6 +5,7 @@ package proxy import ( "context" + "errors" "fmt" "log/slog" "net/http" @@ -80,6 +81,10 @@ func New(cfg config.Config, p identity.IdentityProvider, log *slog.Logger) (*Han // identity cannot be resolved it returns 503 and never contacts the broker. func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { tok, err := h.provider.Token(r.Context()) + if err == nil && tok == "" { + // Fail closed: never forward an empty/placeholder credential. + err = errors.New("provider returned an empty token") + } if err != nil { h.log.Warn("identity unavailable", slog.String(obs.FieldProvider, h.source), diff --git a/internal/proxy/proxy_test.go b/internal/proxy/proxy_test.go index 0f5c646..ab92ee3 100644 --- a/internal/proxy/proxy_test.go +++ b/internal/proxy/proxy_test.go @@ -88,6 +88,30 @@ func TestProviderErrorReturns503(t *testing.T) { } } +func TestEmptyTokenReturns503(t *testing.T) { + called := false + broker := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + called = true + })) + defer broker.Close() + + // Provider returns ("", nil) — must fail closed, never forward "Bearer ". + srv := httptest.NewServer(newHandler(t, broker.URL, stubProvider{tok: ""})) + defer srv.Close() + + resp, err := http.Get(srv.URL) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusServiceUnavailable { + t.Errorf("status = %d, want 503 for empty token", resp.StatusCode) + } + if called { + t.Error("broker must not be contacted when the token is empty") + } +} + func TestBrokerDownReturns502(t *testing.T) { dead := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) deadURL := dead.URL diff --git a/internal/proxy/transport.go b/internal/proxy/transport.go index a59a4bb..dbfc083 100644 --- a/internal/proxy/transport.go +++ b/internal/proxy/transport.go @@ -19,6 +19,8 @@ func buildTransport(cfg config.Config) (*http.Transport, error) { return nil, fmt.Errorf("proxy: unexpected default transport type %T", http.DefaultTransport) } t := base.Clone() + // Pin a TLS 1.2 floor on every path, not only when a CA file is configured. + tlsCfg := &tls.Config{MinVersion: tls.VersionTLS12} if cfg.UpstreamCAFile != "" { pem, err := os.ReadFile(cfg.UpstreamCAFile) if err != nil { @@ -28,7 +30,8 @@ func buildTransport(cfg config.Config) (*http.Transport, error) { if !pool.AppendCertsFromPEM(pem) { return nil, fmt.Errorf("proxy: no certificates found in %s", cfg.UpstreamCAFile) } - t.TLSClientConfig = &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12} + tlsCfg.RootCAs = pool } + t.TLSClientConfig = tlsCfg return t, nil } diff --git a/internal/server/server.go b/internal/server/server.go index 742cbe0..830d1a5 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -71,14 +71,19 @@ func (s *Server) Run(ctx context.Context) error { slog.String("proxy", s.proxyLn.Addr().String()), slog.String("admin", s.adminLn.Addr().String())) - select { - case err := <-errc: - return err - case <-ctx.Done(): - s.log.Info("shutting down, draining in-flight egress") + shutdown := func() error { shutCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) defer cancel() // Drain the proxy first (finish in-flight egress), then the admin plane. return errors.Join(s.proxy.Shutdown(shutCtx), s.admin.Shutdown(shutCtx)) } + + select { + case err := <-errc: + // One plane failed to serve: shut the other down too, never leave it running. + return errors.Join(err, shutdown()) + case <-ctx.Done(): + s.log.Info("shutting down, draining in-flight egress") + return shutdown() + } } From 257a5592c03b9e26c86531cedf16a9906e3aeb3f Mon Sep 17 00:00:00 2001 From: Stephane NANGUE Date: Fri, 19 Jun 2026 00:55:05 +0200 Subject: [PATCH 10/12] fix(review): hardening, defaults, and idiom M2 loopback default: ROBIN_LISTEN_ADDR now defaults to 127.0.0.1:4000 instead of :4000, so a deployment that forgets to set it does not expose the identity-injection port pod-wide. The admin plane stays :4001 (all interfaces) so kubelet probes can reach it. L1: document restricting the admin port with a NetworkPolicy (manifest comment + design/README notes) -- it serves only health/readiness/ metrics, never identity. L2: the UDS is chmod 0o600 (owner-only) rather than 0o660, until v0.2 SO_PEERCRED enforcement narrows the local trust boundary further. NIT: rename the identity.IdentityProvider interface to identity.Provider (drops the package stutter) across provider/factory/proxy/admin, and sync docs/design.md. --- README.md | 4 ++-- deploy/k8s/sidecar-example.yaml | 2 ++ internal/config/config.go | 2 +- internal/config/config_test.go | 2 +- internal/identity/factory.go | 2 +- internal/identity/provider.go | 4 ++-- internal/proxy/proxy.go | 4 ++-- internal/server/admin.go | 2 +- internal/server/listen.go | 3 ++- 9 files changed, 14 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index e06c93a..ff2c314 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ Config is a flat set of `ROBIN_`-prefixed scalars — **no config language**. Pr |-----|---------|-------| | `ROBIN_UPSTREAM_URL` | (required) | Broker base URL | | `ROBIN_TOKEN_SOURCE` | `file` | `file` \| `jwtsvid` | -| `ROBIN_LISTEN_ADDR` | `:4000` | proxy plane (set `127.0.0.1:4000` for loopback-only) | +| `ROBIN_LISTEN_ADDR` | `127.0.0.1:4000` | proxy plane (loopback by default) | | `ROBIN_LISTEN_UDS` | — | UDS path; enables peer-cred mode (v0.2) | | `ROBIN_ADMIN_ADDR` | `:4001` | health/readiness/metrics plane | | `ROBIN_TOKEN_FILE` | `/var/run/secrets/tokens/token` | `file` provider | @@ -82,7 +82,7 @@ Config is a flat set of `ROBIN_`-prefixed scalars — **no config language**. Pr | `ROBIN_UPSTREAM_CA_FILE` | — | verify broker TLS | | `ROBIN_PEERCRED_ALLOW_UIDS` | — | (v0.2) comma-separated UIDs; empty = allow any local peer | -> **Bind address:** the bare defaults `:4000`/`:4001` bind *all* interfaces. For the sidecar's loopback-only trust boundary, set `127.0.0.1:...` explicitly (the example manifest does). +> **Bind address:** the proxy plane defaults to `127.0.0.1:4000` (loopback); the admin plane defaults to `:4001` (all interfaces) so kubelet probes can reach it — restrict `:4001` ingress with a NetworkPolicy where the platform allows it. > **Audience must match end to end.** A mismatch between the token's audience and the broker's expected audience is a hard reject — for projected tokens and SVIDs alike. diff --git a/deploy/k8s/sidecar-example.yaml b/deploy/k8s/sidecar-example.yaml index 0e23d1a..7916fcd 100644 --- a/deploy/k8s/sidecar-example.yaml +++ b/deploy/k8s/sidecar-example.yaml @@ -46,6 +46,8 @@ spec: - name: ROBIN_LISTEN_ADDR value: "127.0.0.1:4000" # Admin plane: all interfaces, so the kubelet can reach the probes. + # It serves only health/readiness/metrics (never identity); restrict + # :4001 ingress with a NetworkPolicy where the platform allows it. - name: ROBIN_ADMIN_ADDR value: ":4001" - name: ROBIN_TOKEN_FILE diff --git a/internal/config/config.go b/internal/config/config.go index 362ccca..9078292 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -77,7 +77,7 @@ func Load(args []string, getenv func(string) string) (Config, error) { fs.StringVar(&cfgPath, "config", path, "path to a flat KEY=value config file") fs.StringVar(&cfg.UpstreamURL, "upstream-url", val("ROBIN_UPSTREAM_URL", ""), "broker base URL (required)") fs.StringVar(&cfg.TokenSource, "token-source", val("ROBIN_TOKEN_SOURCE", "file"), "identity source: file|jwtsvid") - fs.StringVar(&cfg.ListenAddr, "listen-addr", val("ROBIN_LISTEN_ADDR", ":4000"), "proxy listen address") + fs.StringVar(&cfg.ListenAddr, "listen-addr", val("ROBIN_LISTEN_ADDR", "127.0.0.1:4000"), "proxy listen address") fs.StringVar(&cfg.ListenUDS, "listen-uds", val("ROBIN_LISTEN_UDS", ""), "proxy UDS path (enables peer-cred mode)") fs.StringVar(&cfg.AdminAddr, "admin-addr", val("ROBIN_ADMIN_ADDR", ":4001"), "admin listen address") fs.StringVar(&cfg.TokenFile, "token-file", val("ROBIN_TOKEN_FILE", "/var/run/secrets/tokens/token"), "file provider token path") diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 237b83c..4588598 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -45,7 +45,7 @@ func TestLoadDefaults(t *testing.T) { if err != nil { t.Fatal(err) } - if cfg.TokenSource != "file" || cfg.ListenAddr != ":4000" || cfg.AdminAddr != ":4001" { + if cfg.TokenSource != "file" || cfg.ListenAddr != "127.0.0.1:4000" || cfg.AdminAddr != ":4001" { t.Errorf("unexpected defaults: %+v", cfg) } if cfg.SVIDRefreshBefore.Seconds() != 60 { diff --git a/internal/identity/factory.go b/internal/identity/factory.go index 3c01a16..10d1524 100644 --- a/internal/identity/factory.go +++ b/internal/identity/factory.go @@ -8,7 +8,7 @@ import ( ) // New builds the identity provider selected by cfg.TokenSource. -func New(ctx context.Context, cfg config.Config) (IdentityProvider, error) { +func New(ctx context.Context, cfg config.Config) (Provider, error) { switch cfg.TokenSource { case "file": return NewFileProvider(cfg.TokenFile), nil diff --git a/internal/identity/provider.go b/internal/identity/provider.go index b395d43..439654c 100644 --- a/internal/identity/provider.go +++ b/internal/identity/provider.go @@ -8,8 +8,8 @@ import ( "errors" ) -// IdentityProvider resolves the workload's native identity as a bearer token. -type IdentityProvider interface { +// Provider resolves the workload's native identity as a bearer token. +type Provider interface { // Token returns a bearer token valid for the configured audience. // Implementations must be safe for concurrent use. Token(ctx context.Context) (string, error) diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index b784679..2a99786 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -24,7 +24,7 @@ const tokenCtxKey ctxKey = 0 // Handler resolves identity and proxies requests to the broker, injecting the // token as a bearer header. type Handler struct { - provider identity.IdentityProvider + provider identity.Provider rp *httputil.ReverseProxy log *slog.Logger source string @@ -32,7 +32,7 @@ type Handler struct { } // New builds a proxy Handler targeting cfg.UpstreamURL. -func New(cfg config.Config, p identity.IdentityProvider, log *slog.Logger) (*Handler, error) { +func New(cfg config.Config, p identity.Provider, log *slog.Logger) (*Handler, error) { target, err := url.Parse(cfg.UpstreamURL) if err != nil { return nil, fmt.Errorf("proxy: parse upstream URL: %w", err) diff --git a/internal/server/admin.go b/internal/server/admin.go index 2dc9bfb..7accf9d 100644 --- a/internal/server/admin.go +++ b/internal/server/admin.go @@ -10,7 +10,7 @@ import ( // AdminMux builds the admin handler served on a listener separate from the // proxy: liveness, readiness, and (in v0.2) metrics. These must not live on the // proxy listener, which forwards every path to the broker. -func AdminMux(p identity.IdentityProvider) http.Handler { +func AdminMux(p identity.Provider) http.Handler { mux := http.NewServeMux() // Liveness: the process is up. diff --git a/internal/server/listen.go b/internal/server/listen.go index 289085f..4d8f826 100644 --- a/internal/server/listen.go +++ b/internal/server/listen.go @@ -20,7 +20,8 @@ func proxyListener(cfg config.Config) (net.Listener, error) { if err != nil { return nil, fmt.Errorf("server: listen unix %s: %w", cfg.ListenUDS, err) } - if err := os.Chmod(cfg.ListenUDS, 0o660); err != nil { + // 0o600 until v0.2 SO_PEERCRED enforcement lands: owner-only, not group-wide. + if err := os.Chmod(cfg.ListenUDS, 0o600); err != nil { _ = ln.Close() return nil, fmt.Errorf("server: chmod %s: %w", cfg.ListenUDS, err) } From 46006d741fc11d9ea3659241186e8b8581663c9c Mon Sep 17 00:00:00 2001 From: Stephane NANGUE Date: Fri, 19 Jun 2026 01:09:33 +0200 Subject: [PATCH 11/12] docs: polish README (problem-first), drop design.md from the repo Rewrite the README to lead with the problem: Kubernetes hands out short-lived, rotating credentials, but almost no application knows how to consume one -- apps read a token once and hold it forever, so teams fall back to a long-lived static secret. Robin injects a fresh bearer on every request so the app carries no credential at all. No application/framework is named (the problem is universal). Correct the status to reflect that v0.1 is implemented, and generalize the sidecar manifest (placeholder app image and UPSTREAM_* env, no framework-specific names). Stop publishing the internal design notes: untrack docs/design.md and gitignore it (the local copy is kept), and remove all README links to it. --- .gitignore | 3 ++ README.md | 84 ++++++++++++++++----------------- deploy/k8s/sidecar-example.yaml | 27 ++++++----- 3 files changed, 58 insertions(+), 56 deletions(-) diff --git a/.gitignore b/.gitignore index 96d10da..f011453 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ cover.out coverage.* robin + +# Internal design notes — kept locally, not published. +docs/design.md diff --git a/README.md b/README.md index ff2c314..c6d0b2c 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,32 @@ # Robin -**A workload-identity injector.** Robin sits next to a workload, sources that workload's native identity, and presents it as a bearer token on each outbound request to a credential broker — so the workload never holds a long-lived credential. +**A workload-identity injector.** Robin runs beside a workload, sources its short-lived, rotating identity, and injects it as a fresh bearer token on every outbound request — so the application never has to know the credential exists, let alone that it rotates. -> **Status: design stage.** No code yet — the architecture of record lives in [docs/design.md](docs/design.md), and the implementation is sequenced as a series of PRs there. This README describes the intended design. +## The problem -## What Robin is +Kubernetes increasingly hands workloads **short-lived, rotating credentials**. Projected ServiceAccount tokens roll over roughly hourly; SPIFFE JWT-SVIDs every few minutes. That is exactly what you want for security — a leaked token expires on its own, fast. -Application frameworks (kagent, generic OpenAI-compatible clients) have nowhere clean to put a dynamic, rotating credential — their credential fields expect a static secret. Robin removes the workload from the credential business: +The catch: **almost no application knows how to consume one.** An app reads its API key or bearer token *once* — from an environment variable, a config field, an `Authorization` header set when its HTTP client is constructed — and then holds that value for its entire lifetime. There is no hook to reload it, no callback when it rotates. Point such an app at a rotating token and it keeps presenting the stale one; minutes (or an hour) later, every request starts failing with `401`. -- The workload presents **identity** (a short-lived token saying *who it is*), not a credential it holds. -- A downstream **broker** (Warden, or any identity-aware egress gateway) validates that identity and applies the real upstream credential. -- The machine holds **no real provider credential** — at most an inert placeholder. +So teams fall back to the very thing rotation was meant to eliminate: a **long-lived static secret**, mounted into the app and held there indefinitely. Now the application *is* part of the credential plane — it holds a real secret, and rotating that secret means redeploying. -Robin is deliberately thin: it **forwards native identity and does nothing else**. It never mints, exchanges, signs, or federates — any credential derivation happens at the broker. Because of that, the "no credential on the machine" property holds universally, for every provider. +Robin breaks the coupling. It sits next to the workload, sources the rotating identity from the platform, and injects a **fresh** bearer on every request. The application points its egress at Robin and carries **no credential at all** — not even a rotating one. It presents *identity*; Robin keeps it current; a downstream broker turns that identity into the real upstream credential. + +## How it works ``` - workload ──http, dummy/no cred──▶ Robin (127.0.0.1:4000 or UDS) - │ resolve native identity via provider - │ inject: Authorization: Bearer (overwrites placeholder) - ▼ - Broker ──validate identity──▶ apply real cred ──▶ upstream + app ──http, no credential──▶ Robin (127.0.0.1:4000) + │ resolve the workload's rotating identity (always fresh) + │ inject: Authorization: Bearer (overwrites any placeholder) + ▼ + broker ──validate identity──▶ apply real credential ──▶ upstream ``` -It is a streaming reverse proxy with a pluggable identity provider. Every provider yields a bearer token, so Robin is body-agnostic and never reads or buffers the request body (SSE token streaming passes straight through). +- The **app** makes ordinary HTTP requests to Robin on loopback. No API key, no token-reload logic — at most an inert placeholder header, which Robin overwrites. +- **Robin** is a streaming reverse proxy with a pluggable identity provider. It resolves the workload's native token (always current), sets `Authorization: Bearer`, and forwards. It is body-agnostic and never reads or buffers the request body, so streaming responses pass straight through. +- The **broker** — any identity-aware egress gateway — validates the presented identity and applies the real upstream credential. Robin itself never mints, exchanges, signs, or federates, so the machine holds **no real provider credential** — universally, for every provider, with no exceptions. -**Threat model in one line:** anything on the pod's loopback can ask Robin to present the workload's identity — so Robin defaults to loopback-only, with a Unix-domain-socket + `SO_PEERCRED` peer-credential mode for hardened deployments (v0.2). +**Threat model in one line:** anything on the pod's loopback can ask Robin to present the workload's identity — so the proxy plane defaults to loopback-only, with a Unix-domain-socket + `SO_PEERCRED` peer-credential mode for hardened deployments. ## Quickstart @@ -41,46 +43,44 @@ make test # go test ./... ROBIN_UPSTREAM_URL=https://broker.example:8443 \ ROBIN_TOKEN_SOURCE=file \ ROBIN_TOKEN_FILE=/var/run/secrets/tokens/broker-token \ -ROBIN_LISTEN_ADDR=127.0.0.1:4000 \ -ROBIN_ADMIN_ADDR=:4001 \ bin/robin -# app egress: point it at http://127.0.0.1:4000 ; probe http://:4001/healthz +# app egress -> http://127.0.0.1:4000 ; probe http://:4001/healthz ``` **Container image:** ```sh -docker build -t robin:0.1.0 -f deploy/Dockerfile . # ~24MB distroless, nonroot +docker build -t robin:0.1.0 -f deploy/Dockerfile . # ~14MB distroless, nonroot ``` **Kubernetes native sidecar:** see [`deploy/k8s/sidecar-example.yaml`](deploy/k8s/sidecar-example.yaml) — Robin runs as an `initContainer` with `restartPolicy: Always` (K8s 1.29+), the app points its egress at `127.0.0.1:4000`, and probes hit the admin plane on `:4001`. -> Bind the **proxy** plane to `127.0.0.1` (only this pod's app should reach it); bind the **admin** plane to all interfaces (`:4001`) so the kubelet's liveness/readiness probes can reach it. - ## Identity providers -| Source | `ROBIN_TOKEN_SOURCE` | Rotation | -|--------|----------------------|----------| -| Kubernetes projected ServiceAccount token | `file` | kubelet rotates the file in place (~80% TTL); Robin **re-reads per request**, never caches. | -| SPIFFE JWT-SVID (Workload API) | `jwtsvid` | Not pushed (unary fetch); Robin caches and **refreshes ahead of `exp`**, and serves a still-valid cached token if the agent briefly fails. | +| Source | `ROBIN_TOKEN_SOURCE` | Rotation handling | +|--------|----------------------|-------------------| +| Kubernetes projected ServiceAccount token | `file` | the kubelet rotates the file in place (~80% TTL); Robin **re-reads per request**, so it never serves a stale token. | +| SPIFFE JWT-SVID (Workload API) | `jwtsvid` | not pushed (unary fetch), so Robin caches and **refreshes ahead of `exp`**, and serves a still-valid cached token if the agent briefly fails. | + +Any other source that yields the workload's own short-lived OIDC/JWT identity fits the same shape: fetch it, forward it, let the broker validate. ## Configuration -Config is a flat set of `ROBIN_`-prefixed scalars — **no config language**. Primary plane is **environment variables** (idiomatic for sidecars/systemd); an optional flat `.env`-style `KEY=value` file is supported for standalone hosts. Precedence: **flags > environment > file**. +Config is a flat set of `ROBIN_`-prefixed scalars — **no config language**. The primary plane is **environment variables** (idiomatic for sidecars and systemd units); an optional flat `.env`-style `KEY=value` file is supported for standalone hosts. Precedence: **flags > environment > file**. | Var | Default | Notes | |-----|---------|-------| -| `ROBIN_UPSTREAM_URL` | (required) | Broker base URL | +| `ROBIN_UPSTREAM_URL` | (required) | broker base URL | | `ROBIN_TOKEN_SOURCE` | `file` | `file` \| `jwtsvid` | | `ROBIN_LISTEN_ADDR` | `127.0.0.1:4000` | proxy plane (loopback by default) | -| `ROBIN_LISTEN_UDS` | — | UDS path; enables peer-cred mode (v0.2) | +| `ROBIN_LISTEN_UDS` | — | UDS path; enables peer-cred mode | | `ROBIN_ADMIN_ADDR` | `:4001` | health/readiness/metrics plane | | `ROBIN_TOKEN_FILE` | `/var/run/secrets/tokens/token` | `file` provider | | `ROBIN_AUDIENCE` | — | required for `jwtsvid`; **must match the broker** | | `ROBIN_SPIFFE_SOCKET` | — | `jwtsvid` socket addr (optional; falls back to the go-spiffe default) | | `ROBIN_SVID_REFRESH_BEFORE` | `60s` | refresh ahead of `exp` (clamped ≤ ½ the observed lifetime) | | `ROBIN_UPSTREAM_CA_FILE` | — | verify broker TLS | -| `ROBIN_PEERCRED_ALLOW_UIDS` | — | (v0.2) comma-separated UIDs; empty = allow any local peer | +| `ROBIN_PEERCRED_ALLOW_UIDS` | — | comma-separated UIDs; empty = allow any local peer | > **Bind address:** the proxy plane defaults to `127.0.0.1:4000` (loopback); the admin plane defaults to `:4001` (all interfaces) so kubelet probes can reach it — restrict `:4001` ingress with a NetworkPolicy where the platform allows it. @@ -90,8 +90,8 @@ Config is a flat set of `ROBIN_`-prefixed scalars — **no config language**. Pr Same binary; the topology determines how identity is *sourced*, not what Robin does with it. -- **Native sidecar (default, supported).** An init container with `restartPolicy: Always` (Kubernetes 1.29+) so Robin starts before app containers (no first-call race) and terminates after them (no in-flight-egress loss). The workload reaches Robin on `localhost`. -- **Standalone systemd unit (v0.2).** Robin runs as a host/VM service; identity comes from a node-level SPIRE agent (`jwtsvid`). +- **Native sidecar (default).** An init container with `restartPolicy: Always` (Kubernetes 1.29+) so Robin starts before the app container (no first-call race) and stops after it (no in-flight-egress loss). The workload reaches Robin on `localhost`. +- **Standalone systemd unit.** Robin runs as a host/VM service; identity comes from a node-level SPIRE agent (`jwtsvid`). - **Per-node DaemonSet** — *advanced/optional.* Fewer instances, but loses per-pod identity fidelity unless SPIRE does per-pod attestation. - **Standalone egress service** — *generally an anti-pattern.* Loses transparent localhost injection and per-pod identity. @@ -99,38 +99,36 @@ Same binary; the topology determines how identity is *sourced*, not what Robin d Served on a **separate admin listener** (`ROBIN_ADMIN_ADDR`, default `:4001`) — *not* the proxy port, because the proxy forwards every path to the broker (a probe there would be proxied upstream and could leak identity). -- `GET /healthz` — liveness (always 200). *(v0.1)* -- `GET /readyz` — readiness; 200 only when an identity can be resolved. *(v0.2)* -- `GET /metrics` — Prometheus exposition (token fetch latency, cache hit/refresh, served-stale, upstream status codes). *(v0.2)* +- `GET /healthz` — liveness (always 200). +- `GET /readyz` — readiness; 200 only when an identity can actually be resolved. +- `GET /metrics` — Prometheus exposition (token fetch latency, cache hit/refresh, served-stale, upstream status codes). *(planned)* ## Failure semantics | Condition | Response | |-----------|----------| -| Identity unavailable (token file missing, Workload API down) | **503** — never forwards a missing/placeholder credential (jwtsvid serves a still-valid cached token first) | +| Identity unavailable (token file missing/empty, Workload API down) | **503** — never forwards a missing/placeholder credential (jwtsvid serves a still-valid cached token first) | | Broker unreachable | **502** — single attempt, no blind retry | | Audience mismatch | fail closed with an explicit log (config error, not transient) | ## Security notes -- The token value is **never logged** — redaction is structural (it never enters a log record). +- The token value is **never logged** — redaction is structural; it never enters a log record. - The container runs **nonroot** on a static distroless base. -- Bearer-only by design (forwards a JWT-SVID / projected token). mTLS with an X.509-SVID (proof-of-possession) was considered and deliberately deferred; see [docs/design.md](docs/design.md), decision #13. -- Hardened local trust boundary via UDS + `SO_PEERCRED` UID allowlist (v0.2). +- Bearer-only by design (forwards a JWT-SVID / projected token). mTLS with an X.509-SVID (proof-of-possession) was considered and deliberately deferred. +- The proxy plane binds loopback by default; a Unix-domain-socket + `SO_PEERCRED` UID allowlist hardens the local trust boundary further. ## When *not* to use Robin -- **You control the client's auth path.** An in-process `RoundTripper` / auth hook that fetches the identity is lighter than a proxy — no extra process, no localhost trust boundary. Robin exists for clients you *can't* modify (a static-string credential field). +- **You control the client's auth path.** An in-process `RoundTripper` / auth hook that fetches the identity is lighter than a proxy — no extra process, no localhost trust boundary. Robin exists for apps you *can't* teach to rotate. - **You already run a service mesh** (Istio ambient / ztunnel / Cilium). Use its egress identity origination instead of adding a per-pod sidecar. ## Roadmap -- **v0.1 — native bearer core:** provider interface, `file` + `jwtsvid` providers, native-sidecar deployment, TCP loopback, broker-forward path, `/healthz`, structured logging. -- **v0.2 — hardening:** UDS + `SO_PEERCRED`, `/readyz`, Prometheus metrics, standalone systemd deployment. +- **v0.1 (core):** `file` + `jwtsvid` providers, native-sidecar deployment, loopback proxy with broker-forward, `/healthz` + `/readyz`, structured logging, container image. +- **v0.2 (hardening):** `SO_PEERCRED` enforcement on the UDS path, Prometheus `/metrics`, standalone systemd deployment. - **Future:** per-request role/intent assertion travelling with the identity. -See [docs/design.md](docs/design.md) for the full architecture of record and the PR-by-PR implementation plan. - ## License [MPL-2.0](LICENSE). diff --git a/deploy/k8s/sidecar-example.yaml b/deploy/k8s/sidecar-example.yaml index 7916fcd..d6dae96 100644 --- a/deploy/k8s/sidecar-example.yaml +++ b/deploy/k8s/sidecar-example.yaml @@ -7,30 +7,31 @@ apiVersion: apps/v1 kind: Deployment metadata: - name: agent-with-robin + name: myapp-with-robin labels: - app: agent + app: myapp spec: replicas: 1 selector: matchLabels: - app: agent + app: myapp template: metadata: labels: - app: agent + app: myapp spec: - serviceAccountName: agent + serviceAccountName: myapp terminationGracePeriodSeconds: 30 # > Robin's 25s in-flight drain budget containers: - - name: agent - image: ghcr.io/example/kagent:latest + - name: myapp + image: ghcr.io/example/myapp:latest env: - # Point the framework's egress at Robin on loopback, not the broker. - - name: OPENAI_BASE_URL - value: "http://127.0.0.1:4000/v1" - # kagent requires *some* key; it is inert — Robin overwrites Authorization. - - name: OPENAI_API_KEY + # Point the app's egress at Robin on loopback instead of the upstream. + - name: UPSTREAM_BASE_URL + value: "http://127.0.0.1:4000" + # Many apps require *some* credential; this one is inert — Robin + # overwrites the Authorization header with the workload's identity. + - name: UPSTREAM_API_KEY value: "unused-placeholder" initContainers: - name: robin @@ -89,4 +90,4 @@ spec: apiVersion: v1 kind: ServiceAccount metadata: - name: agent + name: myapp From 8dabb4107ee51d5504b3f23b2e84d3aaf6d9ccd2 Mon Sep 17 00:00:00 2001 From: Stephane NANGUE Date: Fri, 19 Jun 2026 11:03:40 +0200 Subject: [PATCH 12/12] docs: add architecture diagram and reword roadmap to native-OIDC sources --- README.md | 14 ++++---------- docs/robin-flow.png | Bin 0 -> 76892 bytes 2 files changed, 4 insertions(+), 10 deletions(-) create mode 100644 docs/robin-flow.png diff --git a/README.md b/README.md index c6d0b2c..ef50562 100644 --- a/README.md +++ b/README.md @@ -14,17 +14,11 @@ Robin breaks the coupling. It sits next to the workload, sources the rotating id ## How it works -``` - app ──http, no credential──▶ Robin (127.0.0.1:4000) - │ resolve the workload's rotating identity (always fresh) - │ inject: Authorization: Bearer (overwrites any placeholder) - ▼ - broker ──validate identity──▶ apply real credential ──▶ upstream -``` +![Architecture: robin flow](./docs/robin-flow.png) -- The **app** makes ordinary HTTP requests to Robin on loopback. No API key, no token-reload logic — at most an inert placeholder header, which Robin overwrites. +- The **App** makes ordinary HTTP requests to Robin on loopback. No API key, no token-reload logic — at most an inert placeholder header, which Robin overwrites. - **Robin** is a streaming reverse proxy with a pluggable identity provider. It resolves the workload's native token (always current), sets `Authorization: Bearer`, and forwards. It is body-agnostic and never reads or buffers the request body, so streaming responses pass straight through. -- The **broker** — any identity-aware egress gateway — validates the presented identity and applies the real upstream credential. Robin itself never mints, exchanges, signs, or federates, so the machine holds **no real provider credential** — universally, for every provider, with no exceptions. +- The **Broker** — any identity-aware egress gateway — validates the presented identity and applies the real upstream credential. Robin itself never mints, exchanges, signs, or federates, so the machine holds **no real provider credential** — universally, for every provider, with no exceptions. **Threat model in one line:** anything on the pod's loopback can ask Robin to present the workload's identity — so the proxy plane defaults to loopback-only, with a Unix-domain-socket + `SO_PEERCRED` peer-credential mode for hardened deployments. @@ -127,7 +121,7 @@ Served on a **separate admin listener** (`ROBIN_ADMIN_ADDR`, default `:4001`) - **v0.1 (core):** `file` + `jwtsvid` providers, native-sidecar deployment, loopback proxy with broker-forward, `/healthz` + `/readyz`, structured logging, container image. - **v0.2 (hardening):** `SO_PEERCRED` enforcement on the UDS path, Prometheus `/metrics`, standalone systemd deployment. -- **Future:** per-request role/intent assertion travelling with the identity. +- **Future:** more native-OIDC identity sources (Azure AD Workload Identity, GCP Workload Identity Federation, …) behind the same provider interface — Robin stays a generic forwarder; the broker still owns validation and credential minting. ## License diff --git a/docs/robin-flow.png b/docs/robin-flow.png new file mode 100644 index 0000000000000000000000000000000000000000..ae632d275c8852a66a7e79c6a2f4ce1a9c2bac24 GIT binary patch literal 76892 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