Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS
Mirrored from https://github.com/Slazee/caddy.git
Synced Nov 13, 04:15 UTC
reverseproxy: Add `query` and `client_ip_hash` lb policies (#5468)
Francis Lavoie committed
Apr 3, 2023 at 23:31 UTC
f8b59e77f83c05da87bd5e3780fb7522b863d462
2 files changed
+300
modules/caddyhttp/reverseproxy/selectionpolicies.go
+85
@@ -24,11 +24,13 @@ import (
24
"net"
25
"net/http"
26
"strconv"
27
+ "strings"
28
"sync/atomic"
29
"time"
30
31
"github.com/caddyserver/caddy/v2"
32
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
33
+ "github.com/caddyserver/caddy/v2/modules/caddyhttp"
34
)
35
36
func init() {
@@ -38,7 +40,9 @@ func init() {
40
caddy.RegisterModule(RoundRobinSelection{})
41
caddy.RegisterModule(FirstSelection{})
42
caddy.RegisterModule(IPHashSelection{})
43
+ caddy.RegisterModule(ClientIPHashSelection{})
44
caddy.RegisterModule(URIHashSelection{})
45
+ caddy.RegisterModule(QueryHashSelection{})
46
caddy.RegisterModule(HeaderHashSelection{})
47
caddy.RegisterModule(CookieHashSelection{})
48
@@ -303,6 +307,39 @@ func (r *IPHashSelection) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
307
return nil
308
}
309
310
+// ClientIPHashSelection is a policy that selects a host
311
+// based on hashing the client IP of the request, as determined
312
+// by the HTTP app's trusted proxies settings.
313
+type ClientIPHashSelection struct{}
314
+
315
+// CaddyModule returns the Caddy module information.
316
+func (ClientIPHashSelection) CaddyModule() caddy.ModuleInfo {
317
+ return caddy.ModuleInfo{
318
+ ID: "http.reverse_proxy.selection_policies.client_ip_hash",
319
+ New: func() caddy.Module { return new(ClientIPHashSelection) },
320
+ }
321
+}
322
+
323
+// Select returns an available host, if any.
324
+func (ClientIPHashSelection) Select(pool UpstreamPool, req *http.Request, _ http.ResponseWriter) *Upstream {
325
+ address := caddyhttp.GetVar(req.Context(), caddyhttp.ClientIPVarKey).(string)
326
+ clientIP, _, err := net.SplitHostPort(address)
327
+ if err != nil {
328
+ clientIP = address // no port
329
+ }
330
+ return hostByHashing(pool, clientIP)
331
+}
332
+
333
+// UnmarshalCaddyfile sets up the module from Caddyfile tokens.
334
+func (r *ClientIPHashSelection) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
335
+ for d.Next() {
336
+ if d.NextArg() {
337
+ return d.ArgErr()
338
+ }
339
+ }
340
+ return nil
341
+}
342
+
343
// URIHashSelection is a policy that selects a
344
// host by hashing the request URI.
345
type URIHashSelection struct{}
@@ -330,6 +367,52 @@ func (r *URIHashSelection) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
367
return nil
368
}
369
370
+// QueryHashSelection is a policy that selects
371
+// a host based on a given request query parameter.
372
+type QueryHashSelection struct {
373
+ // The query key whose value is to be hashed and used for upstream selection.
374
+ Key string `json:"key,omitempty"`
375
+}
376
+
377
+// CaddyModule returns the Caddy module information.
378
+func (QueryHashSelection) CaddyModule() caddy.ModuleInfo {
379
+ return caddy.ModuleInfo{
380
+ ID: "http.reverse_proxy.selection_policies.query",
381
+ New: func() caddy.Module { return new(QueryHashSelection) },
382
+ }
383
+}
384
+
385
+// Select returns an available host, if any.
386
+func (s QueryHashSelection) Select(pool UpstreamPool, req *http.Request, _ http.ResponseWriter) *Upstream {
387
+ if s.Key == "" {
388
+ return nil
389
+ }
390
+
391
+ // Since the query may have multiple values for the same key,
392
+ // we'll join them to avoid a problem where the user can control
393
+ // the upstream that the request goes to by sending multiple values
394
+ // for the same key, when the upstream only considers the first value.
395
+ // Keep in mind that a client changing the order of the values may
396
+ // affect which upstream is selected, but this is a semantically
397
+ // different request, because the order of the values is significant.
398
+ vals := strings.Join(req.URL.Query()[s.Key], ",")
399
+ if vals == "" {
400
+ return RandomSelection{}.Select(pool, req, nil)
401
+ }
402
+ return hostByHashing(pool, vals)
403
+}
404
+
405
+// UnmarshalCaddyfile sets up the module from Caddyfile tokens.
406
+func (s *QueryHashSelection) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
407
+ for d.Next() {
408
+ if !d.NextArg() {
409
+ return d.ArgErr()
410
+ }
411
+ s.Key = d.Val()
412
+ }
413
+ return nil
414
+}
415
+
416
// HeaderHashSelection is a policy that selects
417
// a host based on a given request header.
418
type HeaderHashSelection struct {
@@ -552,7 +635,9 @@ var (
635
_ Selector = (*RoundRobinSelection)(nil)
636
_ Selector = (*FirstSelection)(nil)
637
_ Selector = (*IPHashSelection)(nil)
638
+ _ Selector = (*ClientIPHashSelection)(nil)
639
_ Selector = (*URIHashSelection)(nil)
640
+ _ Selector = (*QueryHashSelection)(nil)
641
_ Selector = (*HeaderHashSelection)(nil)
642
_ Selector = (*CookieHashSelection)(nil)
643
modules/caddyhttp/reverseproxy/selectionpolicies_test.go
+215
@@ -15,9 +15,12 @@
15
package reverseproxy
16
17
import (
18
+ "context"
19
"net/http"
20
"net/http/httptest"
21
"testing"
22
+
23
+ "github.com/caddyserver/caddy/v2/modules/caddyhttp"
24
)
25
26
func testPool() UpstreamPool {
@@ -229,6 +232,149 @@ func TestIPHashPolicy(t *testing.T) {
232
}
233
}
234
235
+func TestClientIPHashPolicy(t *testing.T) {
236
+ pool := testPool()
237
+ ipHash := new(ClientIPHashSelection)
238
+ req, _ := http.NewRequest("GET", "/", nil)
239
+ req = req.WithContext(context.WithValue(req.Context(), caddyhttp.VarsCtxKey, make(map[string]any)))
240
+
241
+ // We should be able to predict where every request is routed.
242
+ caddyhttp.SetVar(req.Context(), caddyhttp.ClientIPVarKey, "172.0.0.1:80")
243
+ h := ipHash.Select(pool, req, nil)
244
+ if h != pool[1] {
245
+ t.Error("Expected ip hash policy host to be the second host.")
246
+ }
247
+ caddyhttp.SetVar(req.Context(), caddyhttp.ClientIPVarKey, "172.0.0.2:80")
248
+ h = ipHash.Select(pool, req, nil)
249
+ if h != pool[1] {
250
+ t.Error("Expected ip hash policy host to be the second host.")
251
+ }
252
+ caddyhttp.SetVar(req.Context(), caddyhttp.ClientIPVarKey, "172.0.0.3:80")
253
+ h = ipHash.Select(pool, req, nil)
254
+ if h != pool[1] {
255
+ t.Error("Expected ip hash policy host to be the second host.")
256
+ }
257
+ caddyhttp.SetVar(req.Context(), caddyhttp.ClientIPVarKey, "172.0.0.4:80")
258
+ h = ipHash.Select(pool, req, nil)
259
+ if h != pool[1] {
260
+ t.Error("Expected ip hash policy host to be the second host.")
261
+ }
262
+
263
+ // we should get the same results without a port
264
+ caddyhttp.SetVar(req.Context(), caddyhttp.ClientIPVarKey, "172.0.0.1")
265
+ h = ipHash.Select(pool, req, nil)
266
+ if h != pool[1] {
267
+ t.Error("Expected ip hash policy host to be the second host.")
268
+ }
269
+ caddyhttp.SetVar(req.Context(), caddyhttp.ClientIPVarKey, "172.0.0.2")
270
+ h = ipHash.Select(pool, req, nil)
271
+ if h != pool[1] {
272
+ t.Error("Expected ip hash policy host to be the second host.")
273
+ }
274
+ caddyhttp.SetVar(req.Context(), caddyhttp.ClientIPVarKey, "172.0.0.3")
275
+ h = ipHash.Select(pool, req, nil)
276
+ if h != pool[1] {
277
+ t.Error("Expected ip hash policy host to be the second host.")
278
+ }
279
+ caddyhttp.SetVar(req.Context(), caddyhttp.ClientIPVarKey, "172.0.0.4")
280
+ h = ipHash.Select(pool, req, nil)
281
+ if h != pool[1] {
282
+ t.Error("Expected ip hash policy host to be the second host.")
283
+ }
284
+
285
+ // we should get a healthy host if the original host is unhealthy and a
286
+ // healthy host is available
287
+ caddyhttp.SetVar(req.Context(), caddyhttp.ClientIPVarKey, "172.0.0.4")
288
+ pool[1].setHealthy(false)
289
+ h = ipHash.Select(pool, req, nil)
290
+ if h != pool[0] {
291
+ t.Error("Expected ip hash policy host to be the first host.")
292
+ }
293
+
294
+ caddyhttp.SetVar(req.Context(), caddyhttp.ClientIPVarKey, "172.0.0.2")
295
+ h = ipHash.Select(pool, req, nil)
296
+ if h != pool[0] {
297
+ t.Error("Expected ip hash policy host to be the first host.")
298
+ }
299
+ pool[1].setHealthy(true)
300
+
301
+ caddyhttp.SetVar(req.Context(), caddyhttp.ClientIPVarKey, "172.0.0.3")
302
+ pool[2].setHealthy(false)
303
+ h = ipHash.Select(pool, req, nil)
304
+ if h != pool[1] {
305
+ t.Error("Expected ip hash policy host to be the second host.")
306
+ }
307
+ caddyhttp.SetVar(req.Context(), caddyhttp.ClientIPVarKey, "172.0.0.4")
308
+ h = ipHash.Select(pool, req, nil)
309
+ if h != pool[1] {
310
+ t.Error("Expected ip hash policy host to be the second host.")
311
+ }
312
+
313
+ // We should be able to resize the host pool and still be able to predict
314
+ // where a req will be routed with the same IP's used above
315
+ pool = UpstreamPool{
316
+ {Host: new(Host), Dial: "0.0.0.2"},
317
+ {Host: new(Host), Dial: "0.0.0.3"},
318
+ }
319
+ caddyhttp.SetVar(req.Context(), caddyhttp.ClientIPVarKey, "172.0.0.1:80")
320
+ h = ipHash.Select(pool, req, nil)
321
+ if h != pool[0] {
322
+ t.Error("Expected ip hash policy host to be the first host.")
323
+ }
324
+ caddyhttp.SetVar(req.Context(), caddyhttp.ClientIPVarKey, "172.0.0.2:80")
325
+ h = ipHash.Select(pool, req, nil)
326
+ if h != pool[0] {
327
+ t.Error("Expected ip hash policy host to be the first host.")
328
+ }
329
+ caddyhttp.SetVar(req.Context(), caddyhttp.ClientIPVarKey, "172.0.0.3:80")
330
+ h = ipHash.Select(pool, req, nil)
331
+ if h != pool[0] {
332
+ t.Error("Expected ip hash policy host to be the first host.")
333
+ }
334
+ caddyhttp.SetVar(req.Context(), caddyhttp.ClientIPVarKey, "172.0.0.4:80")
335
+ h = ipHash.Select(pool, req, nil)
336
+ if h != pool[0] {
337
+ t.Error("Expected ip hash policy host to be the first host.")
338
+ }
339
+
340
+ // We should get nil when there are no healthy hosts
341
+ pool[0].setHealthy(false)
342
+ pool[1].setHealthy(false)
343
+ h = ipHash.Select(pool, req, nil)
344
+ if h != nil {
345
+ t.Error("Expected ip hash policy host to be nil.")
346
+ }
347
+
348
+ // Reproduce #4135
349
+ pool = UpstreamPool{
350
+ {Host: new(Host)},
351
+ {Host: new(Host)},
352
+ {Host: new(Host)},
353
+ {Host: new(Host)},
354
+ {Host: new(Host)},
355
+ {Host: new(Host)},
356
+ {Host: new(Host)},
357
+ {Host: new(Host)},
358
+ {Host: new(Host)},
359
+ }
360
+ pool[0].setHealthy(false)
361
+ pool[1].setHealthy(false)
362
+ pool[2].setHealthy(false)
363
+ pool[3].setHealthy(false)
364
+ pool[4].setHealthy(false)
365
+ pool[5].setHealthy(false)
366
+ pool[6].setHealthy(false)
367
+ pool[7].setHealthy(false)
368
+ pool[8].setHealthy(true)
369
+
370
+ // We should get a result back when there is one healthy host left.
371
+ h = ipHash.Select(pool, req, nil)
372
+ if h == nil {
373
+ // If it is nil, it means we missed a host even though one is available
374
+ t.Error("Expected ip hash policy host to not be nil, but it is nil.")
375
+ }
376
+}
377
+
378
func TestFirstPolicy(t *testing.T) {
379
pool := testPool()
380
firstPolicy := new(FirstSelection)
@@ -246,6 +392,75 @@ func TestFirstPolicy(t *testing.T) {
392
}
393
}
394
395
+func TestQueryHashPolicy(t *testing.T) {
396
+ pool := testPool()
397
+ queryPolicy := QueryHashSelection{Key: "foo"}
398
+
399
+ request := httptest.NewRequest(http.MethodGet, "/?foo=1", nil)
400
+ h := queryPolicy.Select(pool, request, nil)
401
+ if h != pool[0] {
402
+ t.Error("Expected query policy host to be the first host.")
403
+ }
404
+
405
+ request = httptest.NewRequest(http.MethodGet, "/?foo=100000", nil)
406
+ h = queryPolicy.Select(pool, request, nil)
407
+ if h != pool[0] {
408
+ t.Error("Expected query policy host to be the first host.")
409
+ }
410
+
411
+ request = httptest.NewRequest(http.MethodGet, "/?foo=1", nil)
412
+ pool[0].setHealthy(false)
413
+ h = queryPolicy.Select(pool, request, nil)
414
+ if h != pool[1] {
415
+ t.Error("Expected query policy host to be the second host.")
416
+ }
417
+
418
+ request = httptest.NewRequest(http.MethodGet, "/?foo=100000", nil)
419
+ h = queryPolicy.Select(pool, request, nil)
420
+ if h != pool[2] {
421
+ t.Error("Expected query policy host to be the third host.")
422
+ }
423
+
424
+ // We should be able to resize the host pool and still be able to predict
425
+ // where a request will be routed with the same query used above
426
+ pool = UpstreamPool{
427
+ {Host: new(Host)},
428
+ {Host: new(Host)},
429
+ }
430
+
431
+ request = httptest.NewRequest(http.MethodGet, "/?foo=1", nil)
432
+ h = queryPolicy.Select(pool, request, nil)
433
+ if h != pool[0] {
434
+ t.Error("Expected query policy host to be the first host.")
435
+ }
436
+
437
+ pool[0].setHealthy(false)
438
+ h = queryPolicy.Select(pool, request, nil)
439
+ if h != pool[1] {
440
+ t.Error("Expected query policy host to be the second host.")
441
+ }
442
+
443
+ request = httptest.NewRequest(http.MethodGet, "/?foo=4", nil)
444
+ h = queryPolicy.Select(pool, request, nil)
445
+ if h != pool[1] {
446
+ t.Error("Expected query policy host to be the second host.")
447
+ }
448
+
449
+ pool[0].setHealthy(false)
450
+ pool[1].setHealthy(false)
451
+ h = queryPolicy.Select(pool, request, nil)
452
+ if h != nil {
453
+ t.Error("Expected query policy policy host to be nil.")
454
+ }
455
+
456
+ request = httptest.NewRequest(http.MethodGet, "/?foo=aa11&foo=bb22", nil)
457
+ pool = testPool()
458
+ h = queryPolicy.Select(pool, request, nil)
459
+ if h != pool[0] {
460
+ t.Error("Expected query policy host to be the first host.")
461
+ }
462
+}
463
+
464
func TestURIHashPolicy(t *testing.T) {
465
pool := testPool()
466
uriPolicy := new(URIHashSelection)