Add Prometheus metrics for connections, firewall, L7, and bytes transferred

Instrument mc-proxy with prometheus/client_golang. New internal/metrics/
package defines counters, gauges, and histograms for connection totals,
active connections, firewall blocks by reason, backend dial latency,
bytes transferred, L7 HTTP status codes, and L7 policy blocks. Optional
[metrics] config section starts a scrape endpoint. Firewall gains
BlockedWithReason() to report block cause. L7 handler wraps
ResponseWriter to record status codes per hostname.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-25 18:05:25 -07:00
parent 42c7fffc3e
commit ffc31f7d55
16 changed files with 439 additions and 32 deletions

View File

@@ -3,6 +3,8 @@ package l7
import (
"net/http"
"strings"
"git.wntrmute.dev/kyle/mc-proxy/internal/metrics"
)
// PolicyRule defines an L7 blocking policy.
@@ -14,7 +16,7 @@ type PolicyRule struct {
// PolicyMiddleware returns an http.Handler that evaluates L7 policies
// before delegating to next. Returns HTTP 403 if any policy blocks.
// If policies is empty, returns next unchanged.
func PolicyMiddleware(policies []PolicyRule, next http.Handler) http.Handler {
func PolicyMiddleware(policies []PolicyRule, hostname string, next http.Handler) http.Handler {
if len(policies) == 0 {
return next
}
@@ -23,11 +25,13 @@ func PolicyMiddleware(policies []PolicyRule, next http.Handler) http.Handler {
switch p.Type {
case "block_user_agent":
if strings.Contains(r.UserAgent(), p.Value) {
metrics.L7PolicyBlocksTotal.WithLabelValues(hostname, "block_user_agent").Inc()
w.WriteHeader(http.StatusForbidden)
return
}
case "require_header":
if r.Header.Get(p.Value) == "" {
metrics.L7PolicyBlocksTotal.WithLabelValues(hostname, "require_header").Inc()
w.WriteHeader(http.StatusForbidden)
return
}

View File

@@ -13,7 +13,7 @@ func TestPolicyMiddlewareNoPolicies(t *testing.T) {
w.WriteHeader(200)
})
handler := PolicyMiddleware(nil, next)
handler := PolicyMiddleware(nil, "test.example.com", next)
req := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
@@ -35,7 +35,7 @@ func TestPolicyBlockUserAgentMatch(t *testing.T) {
policies := []PolicyRule{
{Type: "block_user_agent", Value: "BadBot"},
}
handler := PolicyMiddleware(policies, next)
handler := PolicyMiddleware(policies, "test.example.com", next)
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("User-Agent", "Mozilla/5.0 BadBot/1.0")
@@ -57,7 +57,7 @@ func TestPolicyBlockUserAgentNoMatch(t *testing.T) {
policies := []PolicyRule{
{Type: "block_user_agent", Value: "BadBot"},
}
handler := PolicyMiddleware(policies, next)
handler := PolicyMiddleware(policies, "test.example.com", next)
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("User-Agent", "Mozilla/5.0 GoodBrowser/1.0")
@@ -82,7 +82,7 @@ func TestPolicyRequireHeaderPresent(t *testing.T) {
policies := []PolicyRule{
{Type: "require_header", Value: "X-API-Key"},
}
handler := PolicyMiddleware(policies, next)
handler := PolicyMiddleware(policies, "test.example.com", next)
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("X-API-Key", "secret")
@@ -105,7 +105,7 @@ func TestPolicyRequireHeaderAbsent(t *testing.T) {
policies := []PolicyRule{
{Type: "require_header", Value: "X-API-Key"},
}
handler := PolicyMiddleware(policies, next)
handler := PolicyMiddleware(policies, "test.example.com", next)
req := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
@@ -125,7 +125,7 @@ func TestPolicyMultipleRules(t *testing.T) {
{Type: "block_user_agent", Value: "BadBot"},
{Type: "require_header", Value: "X-Token"},
}
handler := PolicyMiddleware(policies, next)
handler := PolicyMiddleware(policies, "test.example.com", next)
// Blocked by UA even though header is present.
req := httptest.NewRequest("GET", "/", nil)

View File

@@ -12,14 +12,17 @@ import (
"net/http/httputil"
"net/netip"
"net/url"
"strconv"
"time"
"git.wntrmute.dev/kyle/mc-proxy/internal/metrics"
"git.wntrmute.dev/kyle/mc-proxy/internal/proxyproto"
"golang.org/x/net/http2"
)
// RouteConfig holds the L7 route parameters needed by the l7 package.
type RouteConfig struct {
Hostname string
Backend string
TLSCert string
TLSKey string
@@ -29,6 +32,21 @@ type RouteConfig struct {
Policies []PolicyRule
}
// statusRecorder wraps http.ResponseWriter to capture the status code.
type statusRecorder struct {
http.ResponseWriter
status int
}
func (sr *statusRecorder) WriteHeader(code int) {
sr.status = code
sr.ResponseWriter.WriteHeader(code)
}
func (sr *statusRecorder) Unwrap() http.ResponseWriter {
return sr.ResponseWriter
}
// contextKey is an unexported type for context keys in this package.
type contextKey int
@@ -75,12 +93,14 @@ func Serve(ctx context.Context, conn net.Conn, peeked []byte, route RouteConfig,
return fmt.Errorf("creating reverse proxy: %w", err)
}
// Build handler chain: context injection → L7 policies → reverse proxy.
// Build handler chain: context injection → metrics → L7 policies → reverse proxy.
var inner http.Handler = rp
inner = PolicyMiddleware(route.Policies, inner)
inner = PolicyMiddleware(route.Policies, route.Hostname, inner)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r = r.WithContext(context.WithValue(r.Context(), clientAddrKey, clientAddr))
inner.ServeHTTP(w, r)
sr := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
inner.ServeHTTP(sr, r)
metrics.L7ResponsesTotal.WithLabelValues(route.Hostname, strconv.Itoa(sr.status)).Inc()
})
// Serve HTTP on the TLS connection. Use HTTP/2 if negotiated,