@marcel91200 / caddy Mirror · read-only / commits / 8507b63c
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 11, 03:30 UTC

caddytls: add not matcher for TLS handshakes (#8138)

Simon Sudarushkin committed Oct 5, 2026 at 02:22 UTC 8507b63cf03a7a0d90b2084f3aafbcc1d8e60a7b
2 files changed +252
modules/caddytls/matchers.go
+113
@@ -17,6 +17,7 @@ package caddytls
17 import (
18 "context"
19 "crypto/tls"
20 + "encoding/json"
21 "fmt"
22 "net"
23 "net/netip"
@@ -40,6 +41,7 @@ func init() {
41 caddy.RegisterModule(MatchServerNameRE{})
42 caddy.RegisterModule(MatchRemoteIP{})
43 caddy.RegisterModule(MatchLocalIP{})
44 + caddy.RegisterModule(MatchNot{})
45 }
46
47 // MatchServerName matches based on SNI. Names in
@@ -538,21 +540,132 @@ func (m *MatchLocalIP) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
540 return nil
541 }
542
543 +// MatchNot matches a TLS handshake by negating the results of its
544 +// matcher sets. A single "not" matcher takes one or more matcher sets.
545 +// Each matcher set is OR'ed; in other words, if any matcher set returns
546 +// true, the final result of the "not" matcher is false. Individual
547 +// matchers within a set work the same (i.e. different matchers in
548 +// the same set are AND'ed).
549 +//
550 +// NOTE: The generated docs which describe the structure of this
551 +// module are wrong because of how this type unmarshals JSON in a
552 +// custom way. The correct structure is:
553 +//
554 +// ```json
555 +// [{}, {}]
556 +// ```
557 +//
558 +// where each of the array elements is a matcher set, i.e. an
559 +// object keyed by matcher name.
560 +type MatchNot struct {
561 + MatcherSetsRaw []caddy.ModuleMap `json:"-" caddy:"namespace=tls.handshake_match"`
562 +
563 + matcherSets [][]ConnectionMatcher
564 +}
565 +
566 +// CaddyModule returns the Caddy module information.
567 +func (MatchNot) CaddyModule() caddy.ModuleInfo {
568 + return caddy.ModuleInfo{
569 + ID: "tls.handshake_match.not",
570 + New: func() caddy.Module { return new(MatchNot) },
571 + }
572 +}
573 +
574 +// UnmarshalJSON satisfies json.Unmarshaler. It puts the JSON
575 +// bytes directly into m's MatcherSetsRaw field.
576 +func (m *MatchNot) UnmarshalJSON(data []byte) error {
577 + return json.Unmarshal(data, &m.MatcherSetsRaw)
578 +}
579 +
580 +// MarshalJSON satisfies json.Marshaler by marshaling
581 +// m's raw matcher sets.
582 +func (m MatchNot) MarshalJSON() ([]byte, error) {
583 + return json.Marshal(m.MatcherSetsRaw)
584 +}
585 +
586 +// Provision loads the matcher modules to be negated.
587 +func (m *MatchNot) Provision(ctx caddy.Context) error {
588 + matcherSets, err := ctx.LoadModule(m, "MatcherSetsRaw")
589 + if err != nil {
590 + return fmt.Errorf("loading matcher sets: %v", err)
591 + }
592 + for _, modMap := range matcherSets.([]map[string]any) {
593 + var ms []ConnectionMatcher
594 + for _, modIface := range modMap {
595 + cm, ok := modIface.(ConnectionMatcher)
596 + if !ok {
597 + return fmt.Errorf("module is not a TLS handshake matcher: %T", modIface)
598 + }
599 + ms = append(ms, cm)
600 + }
601 + m.matcherSets = append(m.matcherSets, ms)
602 + }
603 + return nil
604 +}
605 +
606 +// Match returns true if hello does not match any of m's
607 +// matcher sets.
608 +func (m MatchNot) Match(hello *tls.ClientHelloInfo) bool {
609 +setLoop:
610 + for _, ms := range m.matcherSets {
611 + for _, matcher := range ms {
612 + if !matcher.Match(hello) {
613 + continue setLoop
614 + }
615 + }
616 + return false
617 + }
618 + return true
619 +}
620 +
621 +// UnmarshalCaddyfile sets up the MatchNot from Caddyfile tokens. Syntax:
622 +//
623 +// not <matcher> [<args...>]
624 +// not {
625 +// <matcher> [<args...>]
626 +// }
627 +func (m *MatchNot) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
628 + // iterate to merge multiple matchers into one
629 + for d.Next() {
630 + wrapper := d.Val()
631 +
632 + matcherSet, err := ParseCaddyfileNestedMatcherSet(d)
633 + if err != nil {
634 + return err
635 + }
636 +
637 + // At least one matcher must be provided
638 + if len(matcherSet) == 0 {
639 + return d.Errf("malformed TLS handshake matcher '%s': no matchers to negate", wrapper)
640 + }
641 +
642 + m.MatcherSetsRaw = append(m.MatcherSetsRaw, matcherSet)
643 + }
644 +
645 + return nil
646 +}
647 +
648 // Interface guards
649 var (
650 _ ConnectionMatcher = (*MatchLocalIP)(nil)
651 + _ ConnectionMatcher = (*MatchNot)(nil)
652 _ ConnectionMatcher = (*MatchRemoteIP)(nil)
653 _ ConnectionMatcher = (*MatchServerName)(nil)
654 _ ConnectionMatcher = (*MatchServerNameRE)(nil)
655
656 _ caddy.Provisioner = (*MatchLocalIP)(nil)
657 + _ caddy.Provisioner = (*MatchNot)(nil)
658 _ caddy.Provisioner = (*MatchRemoteIP)(nil)
659 _ caddy.Provisioner = (*MatchServerNameRE)(nil)
660
661 _ caddyfile.Unmarshaler = (*MatchLocalIP)(nil)
662 + _ caddyfile.Unmarshaler = (*MatchNot)(nil)
663 _ caddyfile.Unmarshaler = (*MatchRemoteIP)(nil)
664 _ caddyfile.Unmarshaler = (*MatchServerName)(nil)
665 _ caddyfile.Unmarshaler = (*MatchServerNameRE)(nil)
666 +
667 + _ json.Marshaler = (*MatchNot)(nil)
668 + _ json.Unmarshaler = (*MatchNot)(nil)
669 )
670
671 var wordRE = regexp.MustCompile(`\w+`)
modules/caddytls/matchers_test.go
+139
@@ -17,10 +17,12 @@ package caddytls
17 import (
18 "context"
19 "crypto/tls"
20 + "encoding/json"
21 "net"
22 "testing"
23
24 "github.com/caddyserver/caddy/v2"
25 + "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
26 )
27
28 func TestServerNameMatcher(t *testing.T) {
@@ -302,6 +304,143 @@ func TestLocalIPMatcher(t *testing.T) {
304 }
305 }
306
307 +func TestNotMatcher(t *testing.T) {
308 + ctx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()})
309 + defer cancel()
310 +
311 + for i, tc := range []struct {
312 + config string
313 + serverName string
314 + input string
315 + expect bool
316 + }{
317 + {
318 + config: `[]`,
319 + input: "10.1.2.3:12345",
320 + expect: true,
321 + },
322 + {
323 + config: `[{"local_ip": {"ranges": ["10.1.2.3"]}}]`,
324 + input: "10.1.2.3:12345",
325 + expect: false,
326 + },
327 + {
328 + config: `[{"local_ip": {"ranges": ["10.1.2.3"]}}]`,
329 + input: "10.2.3.4:12345",
330 + expect: true,
331 + },
332 + {
333 + config: `[{"remote_ip": {"ranges": ["10.0.0.0/8"]}, "sni": ["example.com"]}]`,
334 + serverName: "example.com",
335 + input: "10.1.2.3:12345",
336 + expect: false,
337 + },
338 + {
339 + config: `[{"remote_ip": {"ranges": ["10.0.0.0/8"]}, "sni": ["example.com"]}]`,
340 + serverName: "example.net",
341 + input: "10.1.2.3:12345",
342 + expect: true,
343 + },
344 + {
345 + config: `[{"remote_ip": {"ranges": ["10.0.0.0/8"]}}, {"sni": ["example.com"]}]`,
346 + serverName: "example.com",
347 + input: "192.168.1.1:12345",
348 + expect: false,
349 + },
350 + {
351 + config: `[{"remote_ip": {"ranges": ["10.0.0.0/8"]}}, {"sni": ["example.com"]}]`,
352 + serverName: "example.net",
353 + input: "192.168.1.1:12345",
354 + expect: true,
355 + },
356 + {
357 + config: `[{"not": [{"remote_ip": {"ranges": ["10.0.0.0/8"]}}]}]`,
358 + input: "10.1.2.3:12345",
359 + expect: true,
360 + },
361 + } {
362 + var matcher MatchNot
363 + if err := json.Unmarshal([]byte(tc.config), &matcher); err != nil {
364 + t.Fatalf("Test %d: unmarshaling config: %v", i, err)
365 + }
366 + if err := matcher.Provision(ctx); err != nil {
367 + t.Fatalf("Test %d: Provision failed: %v", i, err)
368 + }
369 +
370 + addr := testAddr(tc.input)
371 + chi := &tls.ClientHelloInfo{ServerName: tc.serverName, Conn: testConn{addr: addr}}
372 +
373 + actual := matcher.Match(chi)
374 + if actual != tc.expect {
375 + t.Errorf("Test %d: Expected %t but got %t (config=%s input=%s serverName=%s)",
376 + i, tc.expect, actual, tc.config, tc.input, tc.serverName)
377 + }
378 + }
379 +}
380 +
381 +func TestNotMatcherUnmarshalCaddyfile(t *testing.T) {
382 + for i, tc := range []struct {
383 + input string
384 + expect string
385 + wantErr bool
386 + }{
387 + {
388 + input: `not local_ip 10.1.2.3`,
389 + expect: `[{"local_ip":{"ranges":["10.1.2.3"]}}]`,
390 + },
391 + {
392 + input: `not {
393 + remote_ip 10.0.0.0/8
394 + sni example.com
395 + }`,
396 + expect: `[{"remote_ip":{"ranges":["10.0.0.0/8"]},"sni":["example.com"]}]`,
397 + },
398 + {
399 + input: `not sni example.com
400 + not remote_ip 10.0.0.0/8`,
401 + expect: `[{"sni":["example.com"]},{"remote_ip":{"ranges":["10.0.0.0/8"]}}]`,
402 + },
403 + {
404 + input: `not not sni example.com`,
405 + expect: `[{"not":[{"sni":["example.com"]}]}]`,
406 + },
407 + {
408 + input: `not`,
409 + wantErr: true,
410 + },
411 + {
412 + input: `not {
413 + }`,
414 + wantErr: true,
415 + },
416 + {
417 + input: `not foo`,
418 + wantErr: true,
419 + },
420 + } {
421 + var matcher MatchNot
422 + err := matcher.UnmarshalCaddyfile(caddyfile.NewTestDispenser(tc.input))
423 + if tc.wantErr {
424 + if err == nil {
425 + t.Errorf("Test %d: expected error but got none", i)
426 + }
427 + continue
428 + }
429 + if err != nil {
430 + t.Errorf("Test %d: unexpected error: %v", i, err)
431 + continue
432 + }
433 +
434 + actual, err := json.Marshal(matcher)
435 + if err != nil {
436 + t.Fatalf("Test %d: marshaling matcher: %v", i, err)
437 + }
438 + if string(actual) != tc.expect {
439 + t.Errorf("Test %d: expected %s, got %s", i, tc.expect, actual)
440 + }
441 + }
442 +}
443 +
444 type testConn struct {
445 *net.TCPConn
446 addr testAddr