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