Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/bin/
/dist/
*.out
cover.out
coverage.*
robin

# Internal design notes — kept locally, not published.
docs/design.md
9 changes: 9 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
version: "2"
linters:
enable:
- errcheck
- govet
- ineffassign
- staticcheck
- unused
- revive
41 changes: 41 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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
128 changes: 128 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# Robin

**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.

## The problem

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.

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`.

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 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

![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.
- **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 the proxy plane defaults to loopback-only, with a Unix-domain-socket + `SO_PEERCRED` peer-credential mode for hardened deployments.

## 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 \
bin/robin
# app egress -> http://127.0.0.1:4000 ; probe http://<pod-ip>:4001/healthz
```

**Container image:**

```sh
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`.

## Identity providers

| 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**. 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_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 |
| `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` | — | 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.

> **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).** 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.

## 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).
- `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/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 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.
- 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 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 (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:** 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

[MPL-2.0](LICENSE).
27 changes: 27 additions & 0 deletions deploy/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
93 changes: 93 additions & 0 deletions deploy/k8s/sidecar-example.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# 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: myapp-with-robin
labels:
app: myapp
spec:
replicas: 1
selector:
matchLabels:
app: myapp
template:
metadata:
labels:
app: myapp
spec:
serviceAccountName: myapp
terminationGracePeriodSeconds: 30 # > Robin's 25s in-flight drain budget
containers:
- name: myapp
image: ghcr.io/example/myapp:latest
env:
# 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
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.
# 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
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: myapp
Binary file added docs/robin-flow.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 16 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +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
)
Loading
Loading