Hardening
- Chart defaults are attack surface
- The HTTP server
- Ingest endpoints
- Secrets
- Rotating credentials without losing data
- Checklist
- Related lessons
A collector reads every signal your platform produces, and it often holds the credentials for every backend.
Most of its exposure comes from chart defaults nobody reads. Render the chart with
helm templateand read what you actually deploy.
Chart defaults are attack surface
| Chart | Default | Risk | Fix |
|---|---|---|---|
grafana/alloy |
ClusterRole grants cluster-wide get/list/watch on secrets and configmaps (for remote.kubernetes.secret) |
A compromised collector pod reads every Secret in the cluster | Custom ClusterRole without secrets; namespaced Role where the component needs one |
grafana/alloy |
HTTP server on 0.0.0.0:12345 |
UI, /metrics, /-/reload and the config reachable from any pod |
NetworkPolicy; enableHttpServerPort: false only removes the container port, not the listener |
open-telemetry/opentelemetry-collector |
Merges jaeger, zipkin and prometheus receivers into the config |
Extra unauthenticated ingest paths. In a measured case, zipkin accepted spans (HTTP 202) while the OTLP receiver returned 401 | Remove them explicitly: config.receivers.jaeger: null, zipkin: null, prometheus: null, and drop them from pipelines |
Disabling a port does not remove a receiver. Our helm_values/elasticsearch/otel-es-gateway.values.yaml used to set only ports.zipkin.enabled: false and ports.jaeger-*.enabled: false. Rendered with the chart, the config still had:
traces:
receivers:
- otlp
- jaeger
- zipkin
The receivers still listened on the pod IP; only the Service port was gone. The file now sets jaeger: null, zipkin: null, prometheus: null under config.receivers, and traces: null, metrics: null, logs: null under service.pipelines. The rendered config passes otelcol validate on contrib 0.111.0.
Check ours:
kubectl get clusterrole -l app.kubernetes.io/name=alloy -o yaml | grep -B2 -A6 secrets
helm template g open-telemetry/opentelemetry-collector -f helm_values/elasticsearch/otel-es-gateway.values.yaml | grep -A6 "traces:"
The HTTP server
Alloy’s port 12345 serves:
| Path | Exposes | Auth |
|---|---|---|
/ (UI), /api/v0/web/components |
Full component graph, arguments, live debugging stream | None |
/metrics |
Self-monitoring | None |
POST /-/reload |
Re-reads config from disk | None |
/debug/pprof |
Go profiles | None |
- Our gateway publishes the UI through ingress (
alloy.workshop2.indexoutofrange.com, path/), so everything in the table above is reachable from the internet. Our ingress-nginx has snippet annotations disabled, so a path cannot simply be denied. A second Ingress on the same host (alloy-ui-denyinextraObjects) instead claims/-/reload(Exact) and/debug(Prefix), which beat/by path specificity. It sends them to the OTLP/HTTP port, which answers404. The UI,/metricsand/api/v0/web/*stay public and read-only. - Secrets are redacted in the UI and support bundles, but not in Alloy’s own logs. A debug log or a
debugexporter can print tokens and payloads. - Live debugging streams raw telemetry — it is off by default for that reason (
livedebugging { enabled = true }turns it on; our gateway has it on). - Auth on
/metricsbreaks probes unless/-/readyand/-/healthystay open.
Ingest endpoints
| Endpoint | Default auth | Harden with |
|---|---|---|
| OTLP gRPC / HTTP | None | otelcol.auth.bearer (Alloy), bearertokenauth (Collector); mTLS; NetworkPolicy |
| Ingress in front of OTLP | None | nginx.ingress.kubernetes.io/whitelist-source-range, auth at the ingress |
Our gateway exposes OTLP publicly without authentication on purpose — participants export from their laptops. The comment in alloy-gateway.values.yaml says so and shows the whitelist annotation to add. Anyone who knows the hostname can write into Prometheus, Mimir, Loki and Tempo.
Client side in Alloy: otelcol.auth.bearer as a client requires TLS. For plaintext inside the cluster, send the header with otelcol.auth.headers instead.
Secrets
| Source | Rotates without restart | Notes |
|---|---|---|
sys.env("TOKEN") |
No — read once at process start | Simple; every rotation is a rollout |
local.file with is_secret = true |
Yes — file watched | Mount a Kubernetes Secret as a volume; kubelet updates the file |
remote.kubernetes.secret |
Yes | Needs RBAC on secrets — scope it to one namespace |
remote.vault |
Yes | Vault agent not needed |
Collector equivalents: bearertokenauth with filename: (file is watched), and from contrib v0.122 a tokens: list on the server side.
Rotating credentials without losing data
UNAUTHENTICATED and HTTP 401 are not retried (lesson 06). A sender with the old token loses data the moment the receiver stops accepting it.
| Approach | How |
|---|---|
| Two valid tokens during the switch | Server accepts old and new (tokens: list, or two receivers on two ports); roll senders; remove the old token |
| File-based tokens on both sides | Update the Secret; the kubelet refreshes the file; both sides pick it up |
| Absent rather than rejecting | If the server must restart with a new token, scale it to 0 first. Senders get UNAVAILABLE, which is retried and buffered, instead of UNAUTHENTICATED, which is dropped |
Checklist
| Item | Our gateway | Our DaemonSet |
|---|---|---|
| Non-root user | ✅ UID 473 (set explicitly in values) | ❌ root, privileged — required for eBPF |
| Minimal capabilities | ✅ drop: [ALL] |
❌ SYS_ADMIN, BPF, PERFMON, … — required for eBPF |
ClusterRole without secrets |
❌ chart default | ❌ chart default |
| UI / reload not public | ⚠️ UI public; /-/reload and /debug blocked at the ingress |
✅ ClusterIP only |
| OTLP authenticated | ❌ intentionally open | n/a |
| NetworkPolicy on 12345 | ❌ | ❌ |
| Image pinned by digest | ❌ tag from chart appVersion |
❌ |
Liveness probe not on /-/healthy |
✅ alloy.livenessProbe on /-/ready |
✅ alloy.livenessProbe on /-/ready |
A workshop cluster makes different trade-offs from production. The point of the checklist is that each ❌ is a decision you should be able to name, not a default you never saw.