@marcel91200 / caddy Mirror · read-only / commits / f8b59e77
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 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)