diff --git a/README.md b/README.md index 8691978..abd58e9 100644 --- a/README.md +++ b/README.md @@ -61,16 +61,17 @@ CPU supports Kubernetes notation (`"2"` = 2 cores, `"500m"` = 0.5 cores). Memory HTTP service that stores metric summaries in SQLite (via GORM) and exposes a query API. ```bash -./receiver --addr=:8080 --db=metrics.db --read-token=my-secret-token +./receiver --addr=:8080 --db=metrics.db --read-token=my-secret-token --hmac-key=my-hmac-key ``` **Flags:** -| Flag | Environment Variable | Description | Default | -| -------------- | --------------------- | ---------------------------------------------- | ------------ | -| `--addr` | — | HTTP listen address | `:8080` | -| `--db` | — | SQLite database path | `metrics.db` | -| `--read-token` | `RECEIVER_READ_TOKEN` | Pre-shared token for authentication (required) | — | +| Flag | Environment Variable | Description | Default | +| -------------- | --------------------- | ----------------------------------------------------- | ------------ | +| `--addr` | — | HTTP listen address | `:8080` | +| `--db` | — | SQLite database path | `metrics.db` | +| `--read-token` | `RECEIVER_READ_TOKEN` | Pre-shared token for read/admin endpoints (required) | — | +| `--hmac-key` | `RECEIVER_HMAC_KEY` | Secret key for push token generation/validation (required) | — | **Endpoints:** @@ -105,7 +106,7 @@ curl -H "Authorization: Bearer my-secret-token" \ #gitleaks:allow http://localhost:8080/api/v1/metrics/repo/my-org/my-repo/ci.yml/build ``` -Push tokens are HMAC-SHA256 digests derived from the read token and the scope (org/repo/workflow/job). They are stateless — no database storage is needed. +Push tokens are HMAC-SHA256 digests derived from `--hmac-key` and the scope (org/repo/workflow/job). They are stateless — no database storage is needed. The HMAC key is separate from the read token so that compromising a push token does not expose the admin credential. ## How Metrics Are Collected @@ -175,11 +176,28 @@ All memory values are in **bytes**. ### Docker Compose ```bash -docker compose -f test/docker/docker-compose-stress.yaml up -d -# Wait for collection, then trigger shutdown summary: +# Start the receiver (builds image if needed): +docker compose -f test/docker/docker-compose-stress.yaml up -d --build receiver + +# Generate a scoped push token for the collector: +PUSH_TOKEN=$(curl -s -X POST http://localhost:9080/api/v1/token \ + -H "Authorization: Bearer dummyreadtoken" \ + -H "Content-Type: application/json" \ + -d '{"organization":"test-org","repository":"test-org/stress-test","workflow":"stress-test-workflow","job":"heavy-workload"}' \ + | jq -r .token) + +# Start the collector and stress workloads with the push token: +COLLECTOR_PUSH_TOKEN=$PUSH_TOKEN \ + docker compose -f test/docker/docker-compose-stress.yaml up -d --build collector + +# ... Wait for data collection ... + +# Trigger shutdown summary: docker compose -f test/docker/docker-compose-stress.yaml stop collector -# Query results: -curl http://localhost:9080/api/v1/metrics/repo/test-org/test-org%2Fstress-test/stress-test-workflow/heavy-workload + +# Query results with the read token: +curl -H "Authorization: Bearer dummyreadtoken" \ + http://localhost:9080/api/v1/metrics/repo/test-org/test-org%2Fstress-test/stress-test-workflow/heavy-workload ``` ### Local @@ -188,8 +206,21 @@ curl http://localhost:9080/api/v1/metrics/repo/test-org/test-org%2Fstress-test/s go build -o collector ./cmd/collector go build -o receiver ./cmd/receiver -./receiver --addr=:8080 --db=metrics.db -./collector --interval=2s --top=10 --push-endpoint=http://localhost:8080/api/v1/metrics +# Start receiver with both keys: +./receiver --addr=:8080 --db=metrics.db \ + --read-token=my-secret-token --hmac-key=my-hmac-key + +# Generate a scoped push token: +PUSH_TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/token \ + -H "Authorization: Bearer my-secret-token" \ + -H "Content-Type: application/json" \ + -d '{"organization":"my-org","repository":"my-repo","workflow":"ci.yml","job":"build"}' \ + | jq -r .token) + +# Run collector with the push token: +./collector --interval=2s --top=10 \ + --push-endpoint=http://localhost:8080/api/v1/metrics \ + --push-token=$PUSH_TOKEN ``` ## Internal Packages diff --git a/cmd/receiver/main.go b/cmd/receiver/main.go index 21a59d4..1379b53 100644 --- a/cmd/receiver/main.go +++ b/cmd/receiver/main.go @@ -23,6 +23,7 @@ func main() { addr := flag.String("addr", defaultAddr, "HTTP listen address") dbPath := flag.String("db", defaultDBPath, "SQLite database path") readToken := flag.String("read-token", os.Getenv("RECEIVER_READ_TOKEN"), "Pre-shared token for read endpoints (or set RECEIVER_READ_TOKEN)") + hmacKey := flag.String("hmac-key", os.Getenv("RECEIVER_HMAC_KEY"), "Secret key for push token generation/validation (or set RECEIVER_HMAC_KEY)") flag.Parse() logger := slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{ @@ -36,7 +37,7 @@ func main() { } defer func() { _ = store.Close() }() - handler := receiver.NewHandler(store, logger, *readToken) + handler := receiver.NewHandler(store, logger, *readToken, *hmacKey) mux := http.NewServeMux() handler.RegisterRoutes(mux) diff --git a/internal/integration/integration_test.go b/internal/integration/integration_test.go index 0b2fcc5..685d2b6 100644 --- a/internal/integration/integration_test.go +++ b/internal/integration/integration_test.go @@ -18,7 +18,10 @@ import ( "edp.buildth.ing/DevFW-CICD/forgejo-runner-resource-collector/internal/summary" ) -const testReadToken = "integration-test-token" +const ( + testReadToken = "integration-test-token" + testHMACKey = "integration-hmac-key" +) // setupTestReceiver creates a test receiver with SQLite storage, auth, and HTTP server func setupTestReceiver(t *testing.T) (*receiver.Store, *httptest.Server, func()) { @@ -29,7 +32,7 @@ func setupTestReceiver(t *testing.T) (*receiver.Store, *httptest.Server, func()) t.Fatalf("NewStore() error = %v", err) } - handler := receiver.NewHandler(store, slog.New(slog.NewTextHandler(io.Discard, nil)), testReadToken) + handler := receiver.NewHandler(store, slog.New(slog.NewTextHandler(io.Discard, nil)), testReadToken, testHMACKey) mux := http.NewServeMux() handler.RegisterRoutes(mux) @@ -45,7 +48,7 @@ func setupTestReceiver(t *testing.T) (*receiver.Store, *httptest.Server, func()) // generatePushToken generates a scoped push token for an execution context func generatePushToken(exec summary.ExecutionContext) string { - return receiver.GenerateScopedToken(testReadToken, exec.Organization, exec.Repository, exec.Workflow, exec.Job) + return receiver.GenerateScopedToken(testHMACKey, exec.Organization, exec.Repository, exec.Workflow, exec.Job) } func TestPushClientToReceiver(t *testing.T) { @@ -164,7 +167,7 @@ func TestPushClientIntegration(t *testing.T) { t.Setenv("GITHUB_RUN_ID", "push-run-456") // Generate scoped push token - pushToken := receiver.GenerateScopedToken(testReadToken, "push-client-org", "push-client-repo", "push-test.yml", "push-job") + pushToken := receiver.GenerateScopedToken(testHMACKey, "push-client-org", "push-client-repo", "push-test.yml", "push-job") // Create push client with token - it reads execution context from env vars pushClient := summary.NewPushClient(server.URL+"/api/v1/metrics", pushToken) @@ -274,8 +277,9 @@ func TestMultiplePushes(t *testing.T) { } func TestPushClientWithTokenIntegration(t *testing.T) { - readToken := "integration-secret" - store, server, cleanup := setupTestReceiverWithToken(t, readToken) + readToken := "integration-read-secret" + hmacKey := "integration-hmac-secret" + store, server, cleanup := setupTestReceiverWithToken(t, readToken, hmacKey) defer cleanup() // Generate a scoped token via the API @@ -340,8 +344,9 @@ func TestPushClientWithTokenIntegration(t *testing.T) { } func TestPushClientWithWrongTokenIntegration(t *testing.T) { - readToken := "integration-secret" - _, server, cleanup := setupTestReceiverWithToken(t, readToken) + readToken := "integration-read-secret" + hmacKey := "integration-hmac-secret" + _, server, cleanup := setupTestReceiverWithToken(t, readToken, hmacKey) defer cleanup() t.Setenv("GITHUB_REPOSITORY_OWNER", "token-org") @@ -358,7 +363,7 @@ func TestPushClientWithWrongTokenIntegration(t *testing.T) { } } -func setupTestReceiverWithToken(t *testing.T, token string) (*receiver.Store, *httptest.Server, func()) { +func setupTestReceiverWithToken(t *testing.T, readToken, hmacKey string) (*receiver.Store, *httptest.Server, func()) { t.Helper() dbPath := filepath.Join(t.TempDir(), "test.db") store, err := receiver.NewStore(dbPath) @@ -366,7 +371,7 @@ func setupTestReceiverWithToken(t *testing.T, token string) (*receiver.Store, *h t.Fatalf("NewStore() error = %v", err) } - handler := receiver.NewHandler(store, slog.New(slog.NewTextHandler(io.Discard, nil)), token) + handler := receiver.NewHandler(store, slog.New(slog.NewTextHandler(io.Discard, nil)), readToken, hmacKey) mux := http.NewServeMux() handler.RegisterRoutes(mux) diff --git a/internal/receiver/handler.go b/internal/receiver/handler.go index 86e9ceb..d847f62 100644 --- a/internal/receiver/handler.go +++ b/internal/receiver/handler.go @@ -14,13 +14,15 @@ import ( type Handler struct { store *Store logger *slog.Logger - readToken string // Pre-shared token for authentication (required) + readToken string // Pre-shared token for read endpoint authentication + hmacKey string // Separate key for HMAC-based push token generation/validation } // NewHandler creates a new HTTP handler with the given store. -// readToken is required for authenticating all metrics endpoints. -func NewHandler(store *Store, logger *slog.Logger, readToken string) *Handler { - return &Handler{store: store, logger: logger, readToken: readToken} +// readToken authenticates read endpoints and the token generation endpoint. +// hmacKey is the secret used to derive scoped push tokens. +func NewHandler(store *Store, logger *slog.Logger, readToken, hmacKey string) *Handler { + return &Handler{store: store, logger: logger, readToken: readToken, hmacKey: hmacKey} } // RegisterRoutes registers all HTTP routes on the given mux @@ -64,8 +66,8 @@ func (h *Handler) validateReadToken(w http.ResponseWriter, r *http.Request) bool } func (h *Handler) handleGenerateToken(w http.ResponseWriter, r *http.Request) { - if h.readToken == "" { - http.Error(w, "token generation requires a configured read-token", http.StatusBadRequest) + if h.hmacKey == "" { + http.Error(w, "token generation requires a configured HMAC key", http.StatusBadRequest) return } @@ -84,7 +86,7 @@ func (h *Handler) handleGenerateToken(w http.ResponseWriter, r *http.Request) { return } - token := GenerateScopedToken(h.readToken, req.Organization, req.Repository, req.Workflow, req.Job) + token := GenerateScopedToken(h.hmacKey, req.Organization, req.Repository, req.Workflow, req.Job) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(TokenResponse{Token: token}) @@ -92,8 +94,8 @@ func (h *Handler) handleGenerateToken(w http.ResponseWriter, r *http.Request) { // validatePushToken checks push authentication via scoped HMAC token. func (h *Handler) validatePushToken(w http.ResponseWriter, r *http.Request, exec ExecutionContext) bool { - if h.readToken == "" { - h.logger.Warn("no read-token configured, rejecting push", slog.String("path", r.URL.Path)) + if h.hmacKey == "" { + h.logger.Warn("no HMAC key configured, rejecting push", slog.String("path", r.URL.Path)) http.Error(w, "authorization required", http.StatusUnauthorized) return false } @@ -113,7 +115,7 @@ func (h *Handler) validatePushToken(w http.ResponseWriter, r *http.Request, exec } token := strings.TrimPrefix(authHeader, bearerPrefix) - if !ValidateScopedToken(h.readToken, token, exec.Organization, exec.Repository, exec.Workflow, exec.Job) { + if !ValidateScopedToken(h.hmacKey, token, exec.Organization, exec.Repository, exec.Workflow, exec.Job) { h.logger.Warn("invalid push token", slog.String("path", r.URL.Path)) http.Error(w, "invalid token", http.StatusUnauthorized) return false diff --git a/internal/receiver/handler_test.go b/internal/receiver/handler_test.go index 98cad44..cea58f0 100644 --- a/internal/receiver/handler_test.go +++ b/internal/receiver/handler_test.go @@ -448,12 +448,17 @@ func newTestHandler(t *testing.T) (*Handler, func()) { } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - handler := NewHandler(store, logger, "") // no auth token — endpoints will reject + handler := NewHandler(store, logger, "", "") // no auth — endpoints will reject return handler, func() { _ = store.Close() } } -func newTestHandlerWithToken(t *testing.T, token string) (*Handler, func()) { +func newTestHandlerWithToken(t *testing.T, readToken string) (*Handler, func()) { + t.Helper() + return newTestHandlerWithKeys(t, readToken, readToken) +} + +func newTestHandlerWithKeys(t *testing.T, readToken, hmacKey string) (*Handler, func()) { t.Helper() dbPath := filepath.Join(t.TempDir(), "test.db") store, err := NewStore(dbPath) @@ -462,7 +467,7 @@ func newTestHandlerWithToken(t *testing.T, token string) (*Handler, func()) { } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - handler := NewHandler(store, logger, token) + handler := NewHandler(store, logger, readToken, hmacKey) return handler, func() { _ = store.Close() } } diff --git a/test/docker/docker-compose-stress.yaml b/test/docker/docker-compose-stress.yaml index 0d560a7..d4a0be0 100644 --- a/test/docker/docker-compose-stress.yaml +++ b/test/docker/docker-compose-stress.yaml @@ -1,10 +1,12 @@ # Docker Compose stress test with receiver -# Run with: docker compose -f test/docker/docker-compose-stress.yaml up +# See README.md "Docker Compose" section for the full token workflow. # # This test: -# 1. Starts the metrics receiver -# 2. Runs heavy CPU/memory workloads in multiple containers with shared PID namespace -# 3. Collector gathers metrics and pushes summary to receiver on shutdown +# 1. Starts the metrics receiver (with read-token and hmac-key) +# 2. You generate a scoped push token via POST /api/v1/token +# 3. Start the collector with COLLECTOR_PUSH_TOKEN set +# 4. Runs heavy CPU/memory workloads in multiple containers with shared PID namespace +# 5. Collector gathers metrics and pushes summary to receiver on shutdown # # To trigger the push, stop the collector gracefully: # docker compose -f test/docker/docker-compose-stress.yaml stop collector @@ -20,6 +22,8 @@ services: - "9080:8080" environment: - DB_PATH=/data/metrics.db + - RECEIVER_READ_TOKEN=dummyreadtoken + - RECEIVER_HMAC_KEY=dummyhmackey volumes: - receiver-data:/data healthcheck: @@ -98,6 +102,8 @@ services: - --log-format=json - --push-endpoint=http://receiver:8080/api/v1/metrics environment: + # Push token — pass via COLLECTOR_PUSH_TOKEN from host env + COLLECTOR_PUSH_TOKEN: "${COLLECTOR_PUSH_TOKEN}" # Execution context for the receiver GITHUB_REPOSITORY_OWNER: "test-org" GITHUB_REPOSITORY: "test-org/stress-test"