Skip to content

Commit 0ea89c9

Browse files
committed
Capture metrics with more granular labels
1 parent fc009b1 commit 0ea89c9

11 files changed

Lines changed: 157 additions & 48 deletions

File tree

appsec/appsec.go

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import (
2929
"github.com/hslatman/caddy-crowdsec-bouncer/crowdsec"
3030
"github.com/hslatman/caddy-crowdsec-bouncer/internal/bouncer"
3131
"github.com/hslatman/caddy-crowdsec-bouncer/internal/httputils"
32+
"github.com/hslatman/caddy-crowdsec-bouncer/internal/servername"
3233
)
3334

3435
func init() {
@@ -87,11 +88,14 @@ func (h *Handler) Cleanup() error {
8788
// ServeHTTP is the Caddy handler for serving HTTP requests.
8889
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error {
8990
var (
90-
ctx = r.Context()
91-
ip netip.Addr
91+
ctx = r.Context()
92+
ip netip.Addr
93+
server = servername.FromContext(ctx)
9294
)
9395

9496
ctx, ip = httputils.EnsureIP(ctx)
97+
defer h.crowdsec.IncrementProcessedRequests(server, ip.Is6())
98+
9599
r = r.WithContext(ctx)
96100
if err := h.crowdsec.CheckRequest(ctx, r); err != nil {
97101
a := &bouncer.AppSecError{}
@@ -102,10 +106,18 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyht
102106
switch a.Action {
103107
case "allow":
104108
// nothing to do
109+
h.crowdsec.IncrementBlockedRequests(server, "appsec", "bypass", ip.Is6()) // TODO: properly set the action that was performed
105110
case "log":
106111
h.logger.Info("appsec rule triggered", zap.String("ip", ip.String()), zap.String("action", a.Action))
112+
h.crowdsec.IncrementBlockedRequests(server, "appsec", "log", ip.Is6()) // TODO: properly set the action that was performed
107113
default:
108-
return httputils.WriteResponse(w, h.logger, a.Action, ip.String(), a.Duration, a.StatusCode)
114+
if err := httputils.WriteResponse(w, h.logger, a.Action, ip.String(), a.Duration, a.StatusCode); err != nil {
115+
h.crowdsec.IncrementBlockedRequests(server, "appsec", a.Action, ip.Is6()) // TODO: properly set the action that was performed
116+
return err
117+
}
118+
119+
h.crowdsec.IncrementBlockedRequests(server, "appsec", a.Action, ip.Is6()) // TODO: properly set the action that was performed
120+
return nil
109121
}
110122
}
111123

crowdsec/crowdsec.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,14 @@ func (c *CrowdSec) CheckRequest(ctx context.Context, r *http.Request) error {
313313
return c.bouncer.CheckRequest(ctx, r)
314314
}
315315

316+
func (c *CrowdSec) IncrementProcessedRequests(server string, isIPv6 bool) {
317+
c.bouncer.IncrementProcessedRequests(server, isIPv6)
318+
}
319+
320+
func (c *CrowdSec) IncrementBlockedRequests(server, origin, remediation string, isIPv6 bool) {
321+
c.bouncer.IncrementBlockedRequests(server, origin, remediation, isIPv6)
322+
}
323+
316324
func (c *CrowdSec) metricsInterval() time.Duration {
317325
return time.Duration(c.MetricsInterval)
318326
}

crowdsec/crowdsec_test.go

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -205,16 +205,13 @@ func TestCrowdSecStreamingBouncerRuntime(t *testing.T) {
205205
require.NoError(t, err)
206206

207207
wg := &sync.WaitGroup{}
208-
wg.Add(1)
209-
go func() {
210-
// simulate request coming in and stopping the server from another goroutine
211-
defer wg.Done()
208+
wg.Go(func() {
212209

213210
// wait a little bit of time to let the go-cs-bouncer do _some_ work,
214211
// before it properly returns; seems to hang otherwise on b.wg.Wait().
215212
time.Sleep(100 * time.Millisecond)
216213

217-
// simulate a lookup
214+
// simulate request coming in and stopping the server from another goroutine
218215
allowed, decision, err := c.IsAllowed(netip.MustParseAddr("127.0.0.1"))
219216
assert.NoError(t, err)
220217
assert.Nil(t, decision)
@@ -225,7 +222,7 @@ func TestCrowdSecStreamingBouncerRuntime(t *testing.T) {
225222

226223
err = c.Cleanup()
227224
require.NoError(t, err)
228-
}()
225+
})
229226

230227
// wait for the stop and cleanup process
231228
wg.Wait()

go.mod

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,12 @@ require (
99
github.com/crowdsecurity/go-cs-lib v0.0.15
1010
github.com/google/go-cmp v0.7.0
1111
github.com/google/uuid v1.6.0
12-
github.com/hslatman/ipstore v0.4.0
12+
github.com/hslatman/ipstore v0.5.0
1313
github.com/jarcoal/httpmock v1.4.1
1414
github.com/mholt/caddy-l4 v0.0.0-20231016112149-a362a1fbf652
1515
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c
1616
github.com/prometheus/client_golang v1.23.0
17+
github.com/prometheus/client_model v0.6.2
1718
github.com/sirupsen/logrus v1.9.3
1819
github.com/spf13/cobra v1.9.1
1920
github.com/stretchr/testify v1.11.1
@@ -137,7 +138,6 @@ require (
137138
github.com/pkg/errors v0.9.1 // indirect
138139
github.com/pmezard/go-difflib v1.0.0 // indirect
139140
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
140-
github.com/prometheus/client_model v0.6.2 // indirect
141141
github.com/prometheus/common v0.65.0 // indirect
142142
github.com/prometheus/procfs v0.16.1 // indirect
143143
github.com/quic-go/qpack v0.5.1 // indirect

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -288,8 +288,8 @@ github.com/grpc-ecosystem/grpc-gateway v1.5.0/go.mod h1:RSKVYQBd5MCa4OVpNdGskqpg
288288
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww=
289289
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90=
290290
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
291-
github.com/hslatman/ipstore v0.4.0 h1:s9HAmDwJ5gdpD1heI24XqDdWaZJvZfp7DERimtV50uw=
292-
github.com/hslatman/ipstore v0.4.0/go.mod h1:IauyP66Q5kk1mfjysfAfa2Y4AnN7tFLYJlYHhkPlEPY=
291+
github.com/hslatman/ipstore v0.5.0 h1:pFbh8OKWOCttHwMvjtF9O02vPKI5HSHlhRk5hlO82rk=
292+
github.com/hslatman/ipstore v0.5.0/go.mod h1:IauyP66Q5kk1mfjysfAfa2Y4AnN7tFLYJlYHhkPlEPY=
293293
github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
294294
github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
295295
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=

http/http.go

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import (
2929
_ "github.com/hslatman/caddy-crowdsec-bouncer/appsec" // always include AppSec module when HTTP is added
3030
"github.com/hslatman/caddy-crowdsec-bouncer/crowdsec"
3131
"github.com/hslatman/caddy-crowdsec-bouncer/internal/httputils"
32+
"github.com/hslatman/caddy-crowdsec-bouncer/internal/servername"
3233
)
3334

3435
func init() {
@@ -86,11 +87,14 @@ func (h *Handler) Cleanup() error {
8687
// ServeHTTP is the Caddy handler for serving HTTP requests.
8788
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error {
8889
var (
89-
ctx = r.Context()
90-
ip netip.Addr
90+
ctx = r.Context()
91+
ip netip.Addr
92+
server = servername.FromContext(ctx)
9193
)
9294

9395
ctx, ip = httputils.EnsureIP(ctx)
96+
defer h.crowdsec.IncrementProcessedRequests(server, ip.Is6())
97+
9498
isAllowed, decision, err := h.crowdsec.IsAllowed(ip)
9599
if err != nil {
96100
return err // TODO: return error here? Or just log it and continue serving
@@ -104,8 +108,16 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyht
104108
typ := *decision.Type
105109
value := *decision.Value
106110
duration := *decision.Duration
111+
origin := *decision.Origin
112+
113+
if err := httputils.WriteResponse(w, h.logger, typ, value, duration, 0); err != nil {
114+
h.crowdsec.IncrementBlockedRequests(server, origin, typ, ip.Is6()) // TODO: properly set the action that was performed
115+
return err
116+
}
107117

108-
return httputils.WriteResponse(w, h.logger, typ, value, duration, 0)
118+
h.crowdsec.IncrementBlockedRequests(server, origin, typ, ip.Is6()) // TODO: properly set the action that was performed
119+
120+
return nil
109121
}
110122

111123
// Continue down the handler stack

internal/bouncer/bouncer.go

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -241,11 +241,6 @@ func (b *Bouncer) Shutdown() error {
241241
// IsAllowed checks if an IP is allowed or not
242242
func (b *Bouncer) IsAllowed(ip netip.Addr, forceLive bool) (bool, *models.Decision, error) {
243243
isAllowed, decision, err := b.isAllowed(ip, forceLive)
244-
245-
if !isAllowed {
246-
blockedRequestsCounter.Inc()
247-
}
248-
249244
return isAllowed, decision, err
250245
}
251246

@@ -276,6 +271,23 @@ func (b *Bouncer) CheckRequest(ctx context.Context, r *http.Request) error {
276271
return b.appsec.checkRequest(ctx, r)
277272
}
278273

274+
func toIPType(isIPv6 bool) (ipType string) {
275+
ipType = "ipv4"
276+
if isIPv6 {
277+
ipType = "ipv6"
278+
}
279+
280+
return
281+
}
282+
283+
func (b *Bouncer) IncrementProcessedRequests(server string, isIPv6 bool) {
284+
processedRequestsCounter.With(prometheus.Labels{"server": server, "ip_type": toIPType(isIPv6)}).Inc() // TODO: test whether these globals are OK to use
285+
}
286+
287+
func (b *Bouncer) IncrementBlockedRequests(server, origin, remediation string, isIPv6 bool) {
288+
blockedRequestsCounter.With(prometheus.Labels{"server": server, "origin": origin, "remediation": remediation, "ip_type": toIPType(isIPv6)}).Inc() // TODO: test whether these globals are OK to use
289+
}
290+
279291
func generateInstanceID(t time.Time) (string, error) {
280292
r := rand.New(rand.NewSource(t.Unix()))
281293
b := [4]byte{}

internal/bouncer/decisions.go

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -132,26 +132,46 @@ func (b *Bouncer) retrieveDecision(ip netip.Addr, forceLive bool) (*models.Decis
132132
return nil, nil
133133
}
134134

135+
type ipType string
136+
137+
const (
138+
ipv4 ipType = "ipv4"
139+
ipv6 ipType = "ipv6"
140+
)
141+
135142
func (b *Bouncer) recalculateAndRecordDecisionCounts() {
136143
// initialize map, so that these origins are always
137144
// known and recorded
138-
m := map[string]float64{
139-
"CAPI": 0,
140-
"crowdsec": 0,
141-
"cscli": 0,
142-
"cscli-import": 0,
143-
"console": 0,
144-
"appsec": 0,
145-
"remediation_sync": 0,
145+
m := map[string]map[ipType]int{
146+
"CAPI": {ipv4: 0, ipv6: 0},
147+
"crowdsec": {ipv4: 0, ipv6: 0},
148+
"cscli": {ipv4: 0, ipv6: 0},
149+
"cscli-import": {ipv4: 0, ipv6: 0},
150+
"console": {ipv4: 0, ipv6: 0},
151+
"appsec": {ipv4: 0, ipv6: 0},
152+
"remediation_sync": {ipv4: 0, ipv6: 0},
146153
}
147154

148-
for _, v := range b.store.store.All() {
155+
for prefix, v := range b.store.store.All() {
149156
origin := *v.Origin
150-
m[origin] += 1
157+
isIPv6 := prefix.Bits() > 32
158+
n, ok := m[origin]
159+
if !ok {
160+
n = map[ipType]int{ipv4: 0, ipv6: 0}
161+
m[origin] = n
162+
}
163+
164+
if isIPv6 {
165+
n[ipv6] += 1
166+
} else {
167+
n[ipv4] += 1
168+
}
151169
}
152170

153-
for origin, count := range m {
154-
// update the count of active decisions
155-
activeDecisionsGauge.With(map[string]string{"origin": origin}).Set(count)
171+
// update the count of active decisions per origin and IP type
172+
for origin, tuple := range m {
173+
for ipType, count := range tuple {
174+
activeDecisionsGauge.With(map[string]string{"origin": origin, "ip_type": string(ipType)}).Set(float64(count))
175+
}
156176
}
157177
}

internal/bouncer/metrics.go

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -53,15 +53,15 @@ var (
5353
activeDecisionsGauge = prometheus.NewGaugeVec(prometheus.GaugeOpts{
5454
Name: string(activeDecisionsName),
5555
Help: "The current number of active decisions",
56-
}, []string{"origin"}) // TODO: additional labels, similar to firewall bouncer?
56+
}, []string{"origin", "ip_type"})
5757
blockedRequestsCounter = prometheus.NewCounterVec(prometheus.CounterOpts{
5858
Name: string(blockedRequestsCounterName),
5959
Help: "The total number of requests blocked",
60-
}, []string{"origin", "remediation"})
61-
processedRequestsCounter = prometheus.NewCounter(prometheus.CounterOpts{
60+
}, []string{"server", "origin", "remediation", "ip_type"})
61+
processedRequestsCounter = prometheus.NewCounterVec(prometheus.CounterOpts{
6262
Name: string(processedRequestsCounterName),
6363
Help: "The total number of requests handled",
64-
})
64+
}, []string{"server", "ip_type"})
6565

6666
// TODO: referencing the global metrics from csbouncer may not be the right
6767
// thing to do with how the CrowdSec module operates as part of Caddy. On
@@ -113,32 +113,34 @@ func newMetricsProvider(client *apiclient.ApiClient, metricsRegistry, caddyMetri
113113
Name: "active_decisions",
114114
Unit: "ip",
115115
Collector: activeDecisionsGauge,
116-
LabelKeys: []string{"origin"},
116+
LabelKeys: []string{"origin", "ip_type"},
117117
LastValueMap: nil, // absolute value
118118
KeyFunc: func(labels []*model.LabelPair) string {
119-
return getLabelValue(labels, "origin")
119+
return getLabelValue(labels, "origin") + getLabelValue(labels, "ip_type")
120120
},
121121
SendToLAPI: true,
122122
},
123123
blockedRequestsCounterName: {
124124
Name: "dropped",
125125
Unit: "request",
126126
Collector: blockedRequestsCounter,
127-
LabelKeys: []string{"origin", "remediation"},
127+
LabelKeys: []string{"server", "origin", "remediation", "ip_type"},
128128
LastValueMap: make(map[string]float64),
129129
KeyFunc: func(labels []*model.LabelPair) string {
130-
return getLabelValue(labels, "origin") + getLabelValue(labels, "remediation")
130+
return getLabelValue(labels, "server") + getLabelValue(labels, "origin") + getLabelValue(labels, "remediation") + getLabelValue(labels, "ip_type")
131131
},
132132
SendToLAPI: true,
133133
},
134134
processedRequestsCounterName: {
135135
Name: "processed",
136136
Unit: "request",
137137
Collector: processedRequestsCounter,
138-
LabelKeys: []string{},
138+
LabelKeys: []string{"server", "ip_type"},
139139
LastValueMap: make(map[string]float64),
140-
KeyFunc: func([]*model.LabelPair) string { return "" },
141-
SendToLAPI: true,
140+
KeyFunc: func(labels []*model.LabelPair) string {
141+
return getLabelValue(labels, "server") + getLabelValue(labels, "ip_type")
142+
},
143+
SendToLAPI: true,
142144
},
143145
totalBouncerCallsName: {
144146
Name: "bouncer_calls",
@@ -198,7 +200,7 @@ func newMetricsProvider(client *apiclient.ApiClient, metricsRegistry, caddyMetri
198200
Name: &osName,
199201
Version: &osVersion,
200202
},
201-
bouncerFeatureFlags: []string{}, // not used in bouncers
203+
bouncerFeatureFlags: []string{}, // not used in bouncers?
202204
logger: logger.With(zap.String("instance_id", instanceID)),
203205
instanceID: instanceID,
204206
}
@@ -229,7 +231,7 @@ func (m *metricsProvider) metricsPayload(now time.Time) (metrics *models.AllMetr
229231
metrics = &models.AllMetrics{
230232
RemediationComponents: []*models.RemediationComponentsMetrics{
231233
{
232-
Name: userAgentName, // TODO: verify this is OK to use as-is
234+
Name: userAgentName,
233235
Type: m.bouncerType,
234236
BaseMetrics: models.BaseMetrics{
235237
Os: &m.bouncerOS,

internal/servername/context.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package servername
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
8+
l4 "github.com/mholt/caddy-l4/layer4"
9+
)
10+
11+
const unknownServerName = "UNKNOWN"
12+
13+
// FromContext extracts the current server name from the
14+
// [context.Context]. Returns "UNKNOWN" string if none is available.
15+
func FromContext(ctx context.Context) string {
16+
srv, ok := ctx.Value(caddyhttp.ServerCtxKey).(*caddyhttp.Server)
17+
if !ok || srv == nil {
18+
return ""
19+
}
20+
21+
if srv.Name() == "" {
22+
return unknownServerName
23+
}
24+
25+
return srv.Name()
26+
}
27+
28+
// FromConnection extracts the current server name from the
29+
// [l4.Connection]. Returns "UNKNOWN" string if none is available.
30+
func FromConnection(cx *l4.Connection) string {
31+
server := FromContext(cx.Context) // TODO: change layer4 to also have a server name; they're named
32+
if server == "" {
33+
server = cx.LocalAddr().String()
34+
}
35+
36+
if server == "" {
37+
return unknownServerName
38+
}
39+
40+
return fmt.Sprintf("layer4-%s", server)
41+
}

0 commit comments

Comments
 (0)