@marcel91200 / caddy Mirror · read-only
Fork 0

Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS

Mirrored from https://github.com/Slazee/caddy.git

Synced Nov 11, 01:00 UTC

master
go 409 lines 13.8 KB
Raw
1 package caddyhttp
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "net/http"
8 "strings"
9 "sync"
10 "time"
11
12 "github.com/prometheus/client_golang/prometheus"
13 "github.com/prometheus/client_golang/prometheus/promauto"
14 otelprom "go.opentelemetry.io/contrib/bridges/prometheus"
15 "go.opentelemetry.io/contrib/exporters/autoexport"
16 sdkmetric "go.opentelemetry.io/otel/sdk/metric"
17 "go.opentelemetry.io/otel/sdk/resource"
18 semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
19
20 "github.com/caddyserver/caddy/v2"
21 caddymetrics "github.com/caddyserver/caddy/v2/internal/metrics"
22 )
23
24 // Metrics configures metrics observations.
25 // EXPERIMENTAL and subject to change or removal.
26 //
27 // Example configuration:
28 //
29 // {
30 // "apps": {
31 // "http": {
32 // "metrics": {
33 // "per_host": true,
34 // "observe_catchall_hosts": false
35 // },
36 // "servers": {
37 // "srv0": {
38 // "routes": [{
39 // "match": [{"host": ["example.com", "www.example.com"]}],
40 // "handle": [{"handler": "static_response", "body": "Hello"}]
41 // }]
42 // }
43 // }
44 // }
45 // }
46 // }
47 //
48 // In this configuration:
49 // - Requests to example.com and www.example.com get individual host labels
50 // - All other hosts (e.g., attacker.com) are aggregated under "_other" label
51 // - This prevents unlimited cardinality from arbitrary Host headers
52 type Metrics struct {
53 // Enable per-host metrics. Enabling this option may
54 // incur high-memory consumption, depending on the number of hosts
55 // managed by Caddy.
56 //
57 // CARDINALITY PROTECTION: To prevent unbounded cardinality attacks,
58 // only explicitly configured hosts (via host matchers) are allowed
59 // by default. Other hosts are aggregated under the "_other" label.
60 // See AllowCatchAllHosts to change this behavior.
61 PerHost bool `json:"per_host,omitempty"`
62
63 // Allow metrics for catch-all hosts (hosts without explicit configuration).
64 // When false (default), only hosts explicitly configured via host matchers
65 // will get individual metrics labels. All other hosts will be aggregated
66 // under the "_other" label to prevent cardinality explosion.
67 //
68 // This is automatically enabled for HTTPS servers (since certificates provide
69 // some protection against unbounded cardinality), but disabled for HTTP servers
70 // by default to prevent cardinality attacks from arbitrary Host headers.
71 //
72 // Set to true to allow all hosts to get individual metrics (NOT RECOMMENDED
73 // for production environments exposed to the internet).
74 ObserveCatchallHosts bool `json:"observe_catchall_hosts,omitempty"`
75
76 // Enable pushing metrics via OTLP in addition to the existing Prometheus
77 // scrape endpoints. When set, a PeriodicReader is attached to the shared
78 // Prometheus registry (via a Prometheus -> OpenTelemetry bridge), and the
79 // exporter is autoconfigured from the standard OTEL_* environment
80 // variables (OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_EXPORTER_OTLP_PROTOCOL,
81 // OTEL_METRICS_EXPORTER, ...). Set OTEL_METRICS_EXPORTER=none or simply
82 // keep this field false to disable OTLP export.
83 OTLP bool `json:"otlp,omitempty"`
84
85 init sync.Once
86 httpMetrics *httpMetrics
87 allowedHosts map[string]struct{}
88 hasHTTPSServer bool
89 meterProvider *sdkmetric.MeterProvider
90 }
91
92 type httpMetrics struct {
93 requestInFlight *prometheus.GaugeVec
94 requestCount *prometheus.CounterVec
95 requestErrors *prometheus.CounterVec
96 requestDuration *prometheus.HistogramVec
97 requestSize *prometheus.HistogramVec
98 responseSize *prometheus.HistogramVec
99 responseDuration *prometheus.HistogramVec
100 }
101
102 func initHTTPMetrics(ctx caddy.Context, metrics *Metrics) {
103 const ns, sub = "caddy", "http"
104 registry := ctx.GetMetricsRegistry()
105 basicLabels := []string{"server", "handler"}
106 if metrics.PerHost {
107 basicLabels = append(basicLabels, "host")
108 }
109 metrics.httpMetrics.requestInFlight = promauto.With(registry).NewGaugeVec(prometheus.GaugeOpts{
110 Namespace: ns,
111 Subsystem: sub,
112 Name: "requests_in_flight",
113 Help: "Number of requests currently handled by this server.",
114 }, basicLabels)
115 metrics.httpMetrics.requestErrors = promauto.With(registry).NewCounterVec(prometheus.CounterOpts{
116 Namespace: ns,
117 Subsystem: sub,
118 Name: "request_errors_total",
119 Help: "Number of requests resulting in middleware errors.",
120 }, basicLabels)
121 metrics.httpMetrics.requestCount = promauto.With(registry).NewCounterVec(prometheus.CounterOpts{
122 Namespace: ns,
123 Subsystem: sub,
124 Name: "requests_total",
125 Help: "Counter of HTTP(S) requests made.",
126 }, basicLabels)
127
128 // TODO: allow these to be customized in the config
129 durationBuckets := prometheus.DefBuckets
130 sizeBuckets := prometheus.ExponentialBuckets(256, 4, 8)
131
132 httpLabels := []string{"server", "handler", "code", "method"}
133 if metrics.PerHost {
134 httpLabels = append(httpLabels, "host")
135 }
136 metrics.httpMetrics.requestDuration = promauto.With(registry).NewHistogramVec(prometheus.HistogramOpts{
137 Namespace: ns,
138 Subsystem: sub,
139 Name: "request_duration_seconds",
140 Help: "Histogram of round-trip request durations.",
141 Buckets: durationBuckets,
142 }, httpLabels)
143 metrics.httpMetrics.requestSize = promauto.With(registry).NewHistogramVec(prometheus.HistogramOpts{
144 Namespace: ns,
145 Subsystem: sub,
146 Name: "request_size_bytes",
147 Help: "Total size of the request. Includes body",
148 Buckets: sizeBuckets,
149 }, httpLabels)
150 metrics.httpMetrics.responseSize = promauto.With(registry).NewHistogramVec(prometheus.HistogramOpts{
151 Namespace: ns,
152 Subsystem: sub,
153 Name: "response_size_bytes",
154 Help: "Size of the returned response.",
155 Buckets: sizeBuckets,
156 }, httpLabels)
157 metrics.httpMetrics.responseDuration = promauto.With(registry).NewHistogramVec(prometheus.HistogramOpts{
158 Namespace: ns,
159 Subsystem: sub,
160 Name: "response_duration_seconds",
161 Help: "Histogram of times to first byte in response bodies.",
162 Buckets: durationBuckets,
163 }, httpLabels)
164 }
165
166 // provisionOTLP wires a MeterProvider that periodically reads the process-wide
167 // Prometheus registry and pushes the result via OTLP. The exporter and reader
168 // are autoconfigured from the standard OTEL_* environment variables, matching
169 // the ergonomics of the existing `tracing` directive. It is a no-op when
170 // m.OTLP is false, and honors OTEL_METRICS_EXPORTER=none (autoexport
171 // short-circuits to a no-op reader in that case).
172 func (m *Metrics) provisionOTLP(ctx caddy.Context) error {
173 if !m.OTLP {
174 return nil
175 }
176
177 // Register a Prometheus -> OpenTelemetry bridge against the process-wide
178 // Prometheus registry as the *default* source the NewMetricReader below
179 // will read from.
180 //
181 // NB: despite the "With*" naming, autoexport.WithFallbackMetricProducer is
182 // a package-level setter (it returns nothing) — it mutates autoexport's
183 // internal producer registry and takes effect on the very next call to
184 // NewMetricReader. It is NOT a MetricOption and must not be passed as one.
185 // Users can still override the source by setting OTEL_METRICS_PRODUCERS.
186 reg := ctx.GetMetricsRegistry()
187 autoexport.WithFallbackMetricProducer(func(context.Context) (sdkmetric.Producer, error) {
188 return otelprom.NewMetricProducer(otelprom.WithGatherer(reg)), nil
189 })
190
191 reader, err := autoexport.NewMetricReader(ctx)
192 if err != nil {
193 return fmt.Errorf("creating OTLP metric reader: %w", err)
194 }
195
196 version, _ := caddy.Version()
197 res, err := resource.Merge(resource.Default(), resource.NewSchemaless(
198 semconv.WebEngineName(ServerHeader),
199 semconv.WebEngineVersion(version),
200 ))
201 if err != nil {
202 return fmt.Errorf("building OTLP metrics resource: %w", err)
203 }
204
205 m.meterProvider = sdkmetric.NewMeterProvider(
206 sdkmetric.WithResource(res),
207 sdkmetric.WithReader(reader),
208 )
209
210 return nil
211 }
212
213 // shutdown flushes and tears down the OTLP MeterProvider if one was provisioned.
214 // Both ForceFlush and Shutdown are always attempted so that a flush failure
215 // does not prevent the reader goroutines from being stopped; errors from both
216 // are returned joined.
217 func (m *Metrics) shutdown(ctx context.Context) error {
218 if m == nil || m.meterProvider == nil {
219 return nil
220 }
221
222 // ForceFlush gives the final collection a chance to reach the collector
223 // before the reader goroutine is stopped by Shutdown.
224 return errors.Join(
225 m.meterProvider.ForceFlush(ctx),
226 m.meterProvider.Shutdown(ctx),
227 )
228 }
229
230 // scanConfigForHosts scans the HTTP app configuration to build a set of allowed hosts
231 // for metrics collection, similar to how auto-HTTPS scans for domain names.
232 func (m *Metrics) scanConfigForHosts(app *App) {
233 if !m.PerHost {
234 return
235 }
236
237 m.allowedHosts = make(map[string]struct{})
238 m.hasHTTPSServer = false
239
240 for _, srv := range app.Servers {
241 // Check if this server has TLS enabled
242 serverHasTLS := len(srv.TLSConnPolicies) > 0
243 if serverHasTLS {
244 m.hasHTTPSServer = true
245 }
246
247 // Collect hosts from route matchers
248 for _, route := range srv.Routes {
249 for _, matcherSet := range route.MatcherSets {
250 for _, matcher := range matcherSet {
251 if hm, ok := matcher.(*MatchHost); ok {
252 for _, host := range *hm {
253 // Only allow non-fuzzy hosts to prevent unbounded cardinality
254 if !hm.fuzzy(host) {
255 m.allowedHosts[strings.ToLower(host)] = struct{}{}
256 }
257 }
258 }
259 }
260 }
261 }
262 }
263 }
264
265 // shouldAllowHostMetrics determines if metrics should be collected for the given host.
266 // This implements the cardinality protection by only allowing metrics for:
267 // 1. Explicitly configured hosts
268 // 2. Catch-all requests on HTTPS servers (if AllowCatchAllHosts is true or auto-enabled)
269 // 3. Catch-all requests on HTTP servers only if explicitly allowed
270 func (m *Metrics) shouldAllowHostMetrics(host string, isHTTPS bool) bool {
271 if !m.PerHost {
272 return true // host won't be used in labels anyway
273 }
274
275 normalizedHost := strings.ToLower(host)
276
277 // Always allow explicitly configured hosts
278 if _, exists := m.allowedHosts[normalizedHost]; exists {
279 return true
280 }
281
282 // For catch-all requests (not in allowed hosts)
283 allowCatchAll := m.ObserveCatchallHosts || (isHTTPS && m.hasHTTPSServer)
284 return allowCatchAll
285 }
286
287 // serverNameFromContext extracts the current server name from the context.
288 // Returns "UNKNOWN" if none is available (should probably never happen).
289 func serverNameFromContext(ctx context.Context) string {
290 srv, ok := ctx.Value(ServerCtxKey).(*Server)
291 if !ok || srv == nil || srv.name == "" {
292 return "UNKNOWN"
293 }
294 return srv.name
295 }
296
297 // metricsInstrumentedRoute wraps a compiled route Handler with metrics
298 // instrumentation. It wraps the entire compiled route chain once,
299 // collecting metrics only once per route match.
300 type metricsInstrumentedRoute struct {
301 handler string
302 next Handler
303 metrics *Metrics
304 }
305
306 func newMetricsInstrumentedRoute(ctx caddy.Context, handler string, next Handler, m *Metrics) *metricsInstrumentedRoute {
307 m.init.Do(func() {
308 initHTTPMetrics(ctx, m)
309 })
310
311 return &metricsInstrumentedRoute{handler: handler, next: next, metrics: m}
312 }
313
314 func (h *metricsInstrumentedRoute) ServeHTTP(w http.ResponseWriter, r *http.Request) error {
315 server := serverNameFromContext(r.Context())
316 labels := prometheus.Labels{"server": server, "handler": h.handler}
317 method := caddymetrics.SanitizeMethod(r.Method)
318 // the "code" value is set later, but initialized here to eliminate the possibility
319 // of a panic
320 statusLabels := prometheus.Labels{"server": server, "handler": h.handler, "method": method, "code": ""}
321
322 // Determine if this is an HTTPS request
323 isHTTPS := r.TLS != nil
324
325 if h.metrics.PerHost {
326 // Apply cardinality protection for host metrics
327 if h.metrics.shouldAllowHostMetrics(r.Host, isHTTPS) {
328 labels["host"] = strings.ToLower(r.Host)
329 statusLabels["host"] = strings.ToLower(r.Host)
330 } else {
331 // Use a catch-all label for unallowed hosts to prevent cardinality explosion
332 labels["host"] = "_other"
333 statusLabels["host"] = "_other"
334 }
335 }
336
337 inFlight := h.metrics.httpMetrics.requestInFlight.With(labels)
338 inFlight.Inc()
339 defer inFlight.Dec()
340
341 start := time.Now()
342
343 // This is a _bit_ of a hack - it depends on the ShouldBufferFunc always
344 // being called when the headers are written.
345 // Effectively the same behaviour as promhttp.InstrumentHandlerTimeToWriteHeader.
346 writeHeaderRecorder := ShouldBufferFunc(func(status int, header http.Header) bool {
347 statusLabels["code"] = caddymetrics.SanitizeCode(status)
348 ttfb := time.Since(start).Seconds()
349 h.metrics.httpMetrics.responseDuration.With(statusLabels).Observe(ttfb)
350 return false
351 })
352 wrec := NewResponseRecorder(w, nil, writeHeaderRecorder)
353 err := h.next.ServeHTTP(wrec, r)
354 dur := time.Since(start).Seconds()
355 h.metrics.httpMetrics.requestCount.With(labels).Inc()
356
357 observeRequest := func(status int) {
358 // If the code hasn't been set yet, and we didn't encounter an error, we're
359 // probably falling through with an empty handler.
360 if statusLabels["code"] == "" {
361 // we still sanitize it, even though it's likely to be 0. A 200 is
362 // returned on fallthrough so we want to reflect that.
363 statusLabels["code"] = caddymetrics.SanitizeCode(status)
364 }
365
366 h.metrics.httpMetrics.requestDuration.With(statusLabels).Observe(dur)
367 h.metrics.httpMetrics.requestSize.With(statusLabels).Observe(float64(computeApproximateRequestSize(r)))
368 h.metrics.httpMetrics.responseSize.With(statusLabels).Observe(float64(wrec.Size()))
369 }
370
371 if err != nil {
372 if handlerErr, ok := errors.AsType[HandlerError](err); ok {
373 observeRequest(handlerErr.StatusCode)
374 }
375
376 h.metrics.httpMetrics.requestErrors.With(labels).Inc()
377
378 return err
379 }
380
381 observeRequest(wrec.Status())
382
383 return nil
384 }
385
386 // taken from https://github.com/prometheus/client_golang/blob/6007b2b5cae01203111de55f753e76d8dac1f529/prometheus/promhttp/instrument_server.go#L298
387 func computeApproximateRequestSize(r *http.Request) int {
388 s := 0
389 if r.URL != nil {
390 s += len(r.URL.String())
391 }
392
393 s += len(r.Method)
394 s += len(r.Proto)
395 for name, values := range r.Header {
396 s += len(name)
397 for _, value := range values {
398 s += len(value)
399 }
400 }
401 s += len(r.Host)
402
403 // N.B. r.Form and r.MultipartForm are assumed to be included in r.URL.
404
405 if r.ContentLength != -1 {
406 s += int(r.ContentLength)
407 }
408 return s
409 }