• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

alexferl / zerohttp / 22864950932

09 Mar 2026 05:00PM UTC coverage: 92.958% (-0.3%) from 93.231%
22864950932

Pull #46

github

web-flow
Merge 363865445 into ce6e763fd
Pull Request #46: feat: add IP allowlist support to pprof

68 of 97 new or added lines in 1 file covered. (70.1%)

4 existing lines in 2 files now uncovered.

7564 of 8137 relevant lines covered (92.96%)

29.4 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

92.02
/middleware/reverse_proxy.go
1
package middleware
2

3
import (
4
        "context"
5
        "net/http"
6
        "net/http/httputil"
7
        "net/url"
8
        "path"
9
        "strconv"
10
        "strings"
11
        "sync"
12
        "sync/atomic"
13
        "time"
14

15
        "github.com/alexferl/zerohttp/config"
16
        "github.com/alexferl/zerohttp/metrics"
17
)
18

19
// reverseProxy manages the proxy state including load balancing
20
type reverseProxy struct {
21
        cfg       config.ReverseProxyConfig
22
        backends  []*backend
23
        current   uint64 // for round-robin
24
        transport http.RoundTripper
25
}
26

27
// proxyResponseRecorder wraps http.ResponseWriter to capture status code
28
type proxyResponseRecorder struct {
29
        http.ResponseWriter
30
        statusCode int
31
}
32

33
func (rec *proxyResponseRecorder) WriteHeader(code int) {
24✔
34
        rec.statusCode = code
24✔
35
        rec.ResponseWriter.WriteHeader(code)
24✔
36
}
24✔
37

38
// backend represents a single upstream with health tracking
39
type backend struct {
40
        config.Backend
41
        targetURL   *url.URL
42
        activeConns int64
43
        healthy     int32 // atomic access
44
        proxy       *httputil.ReverseProxy
45
}
46

47
// ReverseProxy creates a reverse proxy middleware from the given configuration
48
func ReverseProxy(cfg config.ReverseProxyConfig) func(http.Handler) http.Handler {
27✔
49
        rp := &reverseProxy{
27✔
50
                cfg:       cfg,
27✔
51
                transport: cfg.Transport,
27✔
52
        }
27✔
53

27✔
54
        if rp.transport == nil {
53✔
55
                rp.transport = http.DefaultTransport
26✔
56
        }
26✔
57

58
        if cfg.Target != "" {
47✔
59
                // Single target mode
20✔
60
                rp.initBackend(cfg.Target, 1, true)
20✔
61
        } else if len(cfg.Targets) > 0 {
33✔
62
                // Load balancer mode
6✔
63
                for _, b := range cfg.Targets {
15✔
64
                        weight := b.Weight
9✔
65
                        if weight <= 0 {
18✔
66
                                weight = 1
9✔
67
                        }
9✔
68
                        rp.initBackend(b.Target, weight, b.Healthy)
9✔
69
                }
70
        } else {
1✔
71
                panic("reverse proxy: Target or Targets is required")
1✔
72
        }
73

74
        if cfg.HealthCheckInterval > 0 {
26✔
75
                go rp.healthCheckLoop()
1✔
76
        }
1✔
77

78
        return func(next http.Handler) http.Handler {
51✔
79
                return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
54✔
80
                        reg := metrics.SafeRegistry(metrics.GetRegistry(r.Context()))
28✔
81

28✔
82
                        for _, exempt := range cfg.ExemptPaths {
31✔
83
                                if pathMatches(r.URL.Path, exempt) {
4✔
84
                                        next.ServeHTTP(w, r)
1✔
85
                                        return
1✔
86
                                }
1✔
87
                        }
88

89
                        b := rp.selectBackend()
27✔
90
                        if b == nil {
30✔
91
                                if cfg.FallbackHandler != nil {
4✔
92
                                        cfg.FallbackHandler.ServeHTTP(w, r)
1✔
93
                                } else {
3✔
94
                                        rp.handleError(w, r, http.ErrHandlerTimeout)
2✔
95
                                }
2✔
96
                                return
3✔
97
                        }
98

99
                        if cfg.LoadBalancer == config.LeastConnections {
25✔
100
                                atomic.AddInt64(&b.activeConns, 1)
1✔
101
                                defer atomic.AddInt64(&b.activeConns, -1)
1✔
102
                        }
1✔
103

104
                        rec := &proxyResponseRecorder{ResponseWriter: w, statusCode: http.StatusOK}
24✔
105
                        start := time.Now()
24✔
106

24✔
107
                        b.proxy.ServeHTTP(rec, r)
24✔
108

24✔
109
                        duration := time.Since(start).Seconds()
24✔
110
                        reg.Counter("proxy_requests_total", "target", "status").WithLabelValues(b.Target, strconv.Itoa(rec.statusCode)).Inc()
24✔
111
                        reg.Histogram("proxy_request_duration_seconds", []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10}, "target").WithLabelValues(b.Target).Observe(duration)
24✔
112
                })
113
        }
114
}
115

116
// initBackend initializes a single backend
117
func (rp *reverseProxy) initBackend(target string, weight int, healthy bool) {
29✔
118
        targetURL, err := url.Parse(target)
29✔
119
        if err != nil {
30✔
120
                panic("reverse proxy: invalid target URL: " + err.Error())
1✔
121
        }
122

123
        proxy := httputil.NewSingleHostReverseProxy(targetURL)
28✔
124
        proxy.Transport = rp.transport
28✔
125
        proxy.FlushInterval = rp.cfg.FlushInterval
28✔
126

28✔
127
        if rp.cfg.ErrorHandler != nil {
29✔
128
                proxy.ErrorHandler = rp.cfg.ErrorHandler
1✔
129
        } else {
28✔
130
                proxy.ErrorHandler = rp.defaultErrorHandler
27✔
131
        }
27✔
132

133
        if rp.cfg.ModifyResponse != nil {
29✔
134
                proxy.ModifyResponse = rp.cfg.ModifyResponse
1✔
135
        }
1✔
136

137
        // Clear Director before setting Rewrite (only one can be set)
138
        proxy.Director = nil
28✔
139
        proxy.Rewrite = func(r *httputil.ProxyRequest) {
52✔
140
                r.SetURL(targetURL)
24✔
141
                if rp.cfg.ForwardHeaders {
25✔
142
                        r.SetXForwarded()
1✔
143
                }
1✔
144
                // Preserve query parameters from original request
145
                if r.In.URL.RawQuery != "" {
25✔
146
                        r.Out.URL.RawQuery = r.In.URL.RawQuery
1✔
147
                }
1✔
148
                rp.applyModifications(r.Out)
24✔
149
        }
150

151
        b := &backend{
28✔
152
                Backend: config.Backend{
28✔
153
                        Target: target,
28✔
154
                        Weight: weight,
28✔
155
                },
28✔
156
                targetURL: targetURL,
28✔
157
                proxy:     proxy,
28✔
158
        }
28✔
159
        if healthy {
54✔
160
                atomic.StoreInt32(&b.healthy, 1)
26✔
161
        }
26✔
162
        rp.backends = append(rp.backends, b)
28✔
163
}
164

165
// selectBackend chooses a backend based on the load balancer algorithm
166
func (rp *reverseProxy) selectBackend() *backend {
27✔
167
        switch rp.cfg.LoadBalancer {
27✔
168
        case config.RoundRobin:
2✔
169
                return rp.roundRobin()
2✔
170
        case config.Random:
1✔
171
                return rp.random()
1✔
172
        case config.LeastConnections:
1✔
173
                return rp.leastConnections()
1✔
174
        default:
23✔
175
                return rp.roundRobin()
23✔
176
        }
177
}
178

179
// roundRobin selects backends in order
180
func (rp *reverseProxy) roundRobin() *backend {
25✔
181
        healthy := rp.healthyBackends()
25✔
182
        if len(healthy) == 0 {
28✔
183
                return nil
3✔
184
        }
3✔
185
        next := atomic.AddUint64(&rp.current, 1) - 1
22✔
186
        return healthy[int(next)%len(healthy)]
22✔
187
}
188

189
// random selects a random healthy backend
190
func (rp *reverseProxy) random() *backend {
1✔
191
        healthy := rp.healthyBackends()
1✔
192
        if len(healthy) == 0 {
1✔
193
                return nil
×
194
        }
×
195
        // Simple pseudo-random: use current timestamp nanos
196
        idx := time.Now().UnixNano() % int64(len(healthy))
1✔
197
        return healthy[idx]
1✔
198
}
199

200
// leastConnections selects backend with fewest active connections
201
func (rp *reverseProxy) leastConnections() *backend {
1✔
202
        healthy := rp.healthyBackends()
1✔
203
        if len(healthy) == 0 {
1✔
204
                return nil
×
205
        }
×
206
        var selected *backend
1✔
207
        var minConns int64 = -1
1✔
208
        for _, b := range healthy {
3✔
209
                conns := atomic.LoadInt64(&b.activeConns)
2✔
210
                if minConns == -1 || conns < minConns {
3✔
211
                        selected = b
1✔
212
                        minConns = conns
1✔
213
                }
1✔
214
        }
215
        return selected
1✔
216
}
217

218
// healthyBackends returns only healthy backends
219
func (rp *reverseProxy) healthyBackends() []*backend {
27✔
220
        var healthy []*backend
27✔
221
        for _, b := range rp.backends {
58✔
222
                if atomic.LoadInt32(&b.healthy) == 1 {
59✔
223
                        healthy = append(healthy, b)
28✔
224
                }
28✔
225
        }
226
        return healthy
27✔
227
}
228

229
// applyModifications applies all configured modifications to the request
230
func (rp *reverseProxy) applyModifications(r *http.Request) {
24✔
231
        cfg := rp.cfg
24✔
232

24✔
233
        if cfg.StripPrefix != "" {
27✔
234
                r.URL.Path = strings.TrimPrefix(r.URL.Path, cfg.StripPrefix)
3✔
235
                if r.URL.RawPath != "" {
3✔
236
                        r.URL.RawPath = strings.TrimPrefix(r.URL.RawPath, cfg.StripPrefix)
×
237
                }
×
238
                if r.URL.Path == "" {
4✔
239
                        r.URL.Path = "/"
1✔
240
                }
1✔
241
        }
242

243
        if cfg.AddPrefix != "" {
26✔
244
                r.URL.Path = cfg.AddPrefix + r.URL.Path
2✔
245
                if r.URL.RawPath != "" {
2✔
246
                        r.URL.RawPath = cfg.AddPrefix + r.URL.RawPath
×
247
                }
×
248
        }
249

250
        for _, rule := range cfg.Rewrites {
25✔
251
                if matched, _ := path.Match(rule.Pattern, r.URL.Path); matched {
2✔
252
                        r.URL.Path = rule.Replacement
1✔
253
                        if r.URL.RawPath != "" {
1✔
254
                                r.URL.RawPath = rule.Replacement
×
255
                        }
×
256
                        break
1✔
257
                }
258
        }
259

260
        for _, header := range cfg.RemoveHeaders {
25✔
261
                r.Header.Del(header)
1✔
262
        }
1✔
263

264
        for key, value := range cfg.SetHeaders {
25✔
265
                r.Header.Set(key, value)
1✔
266
        }
1✔
267

268
        if cfg.ForwardHeaders {
25✔
269
                clientIP := r.RemoteAddr
1✔
270
                if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
2✔
271
                        clientIP = xff + ", " + clientIP
1✔
272
                }
1✔
273
                r.Header.Set("X-Forwarded-For", clientIP)
1✔
274

1✔
275
                if r.TLS != nil {
1✔
276
                        r.Header.Set("X-Forwarded-Proto", "https")
×
277
                } else {
1✔
278
                        r.Header.Set("X-Forwarded-Proto", "http")
1✔
279
                }
1✔
280

281
                // Only set X-Forwarded-Host if it's not already set (e.g., by SetXForwarded)
282
                if r.Header.Get("X-Forwarded-Host") == "" && r.Host != "" {
1✔
283
                        r.Header.Set("X-Forwarded-Host", r.Host)
×
284
                }
×
285
        }
286

287
        if cfg.ModifyRequest != nil {
25✔
288
                cfg.ModifyRequest(r)
1✔
289
        }
1✔
290
}
291

292
// healthCheckLoop periodically checks backend health
293
func (rp *reverseProxy) healthCheckLoop() {
1✔
294
        ticker := time.NewTicker(rp.cfg.HealthCheckInterval)
1✔
295
        defer ticker.Stop()
1✔
296

1✔
297
        for range ticker.C {
3✔
298
                rp.checkHealth()
2✔
299
        }
2✔
300
}
301

302
// checkHealth performs health checks on all backends
303
func (rp *reverseProxy) checkHealth() {
2✔
304
        var wg sync.WaitGroup
2✔
305
        for _, b := range rp.backends {
4✔
306
                wg.Add(1)
2✔
307
                go func(be *backend) {
4✔
308
                        defer wg.Done()
2✔
309
                        healthy := rp.checkBackendHealth(be)
2✔
310
                        if healthy {
2✔
311
                                atomic.StoreInt32(&be.healthy, 1)
×
312
                        } else {
2✔
313
                                atomic.StoreInt32(&be.healthy, 0)
2✔
314
                        }
2✔
315
                }(b)
316
        }
317
        wg.Wait()
2✔
318
}
319

320
// checkBackendHealth checks a single backend
321
func (rp *reverseProxy) checkBackendHealth(b *backend) bool {
2✔
322
        client := &http.Client{
2✔
323
                Timeout:   rp.cfg.HealthCheckTimeout,
2✔
324
                Transport: rp.transport,
2✔
325
        }
2✔
326

2✔
327
        healthURL := b.targetURL.Scheme + "://" + b.targetURL.Host + rp.cfg.HealthCheckPath
2✔
328
        ctx, cancel := context.WithTimeout(context.Background(), rp.cfg.HealthCheckTimeout)
2✔
329
        defer cancel()
2✔
330

2✔
331
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, healthURL, nil)
2✔
332
        if err != nil {
2✔
333
                return false
×
334
        }
×
335

336
        resp, err := client.Do(req)
2✔
337
        if err != nil {
2✔
UNCOV
338
                return false
×
UNCOV
339
        }
×
340
        defer func() { _ = resp.Body.Close() }()
4✔
341

342
        return resp.StatusCode < 500
2✔
343
}
344

345
// handleError handles proxy errors
346
func (rp *reverseProxy) handleError(w http.ResponseWriter, r *http.Request, err error) {
2✔
347
        if rp.cfg.ErrorHandler != nil {
2✔
348
                rp.cfg.ErrorHandler(w, r, err)
×
349
        } else {
2✔
350
                rp.defaultErrorHandler(w, r, err)
2✔
351
        }
2✔
352
}
353

354
// defaultErrorHandler handles proxy errors
355
func (rp *reverseProxy) defaultErrorHandler(w http.ResponseWriter, r *http.Request, err error) {
2✔
356
        w.WriteHeader(http.StatusBadGateway)
2✔
357
        _, _ = w.Write([]byte("Bad Gateway"))
2✔
358
}
2✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc