diff --git a/context.go b/context.go index c574be7d2..a6386aa82 100644 --- a/context.go +++ b/context.go @@ -430,5 +430,13 @@ func (ctx Context) Storage() certmagic.Storage { // Logger returns a logger that can be used by mod. func (ctx Context) Logger(mod Module) *zap.Logger { + if ctx.cfg == nil { + // often the case in tests; just use a dev logger + l, err := zap.NewDevelopment() + if err != nil { + panic("config missing, unable to create dev logger: " + err.Error()) + } + return l + } return ctx.cfg.Logging.Logger(mod) } diff --git a/modules/caddytls/matchers.go b/modules/caddytls/matchers.go index 50da60978..aee0e726e 100644 --- a/modules/caddytls/matchers.go +++ b/modules/caddytls/matchers.go @@ -16,13 +16,18 @@ package caddytls import ( "crypto/tls" + "fmt" + "net" + "strings" "github.com/caddyserver/caddy/v2" "github.com/caddyserver/certmagic" + "go.uber.org/zap" ) func init() { caddy.RegisterModule(MatchServerName{}) + caddy.RegisterModule(MatchRemoteIP{}) } // MatchServerName matches based on SNI. Names in @@ -48,5 +53,100 @@ func (m MatchServerName) Match(hello *tls.ClientHelloInfo) bool { return false } -// Interface guard -var _ ConnectionMatcher = (*MatchServerName)(nil) +// MatchRemoteIP matches based on the remote IP of the +// connection. Specific IPs or CIDR ranges can be specified. +// +// Note that IPs can sometimes be spoofed, so do not rely +// on this as a replacement for actual authentication. +type MatchRemoteIP struct { + // The IPs or CIDR ranges to match. + Ranges []string `json:"ranges,omitempty"` + + // The IPs or CIDR ranges to *NOT* match. + NotRanges []string `json:"not_ranges,omitempty"` + + cidrs []*net.IPNet + notCidrs []*net.IPNet + logger *zap.Logger +} + +// CaddyModule returns the Caddy module information. +func (MatchRemoteIP) CaddyModule() caddy.ModuleInfo { + return caddy.ModuleInfo{ + ID: "tls.handshake_match.remote_ip", + New: func() caddy.Module { return new(MatchRemoteIP) }, + } +} + +// Provision parses m's IP ranges, either from IP or CIDR expressions. +func (m *MatchRemoteIP) Provision(ctx caddy.Context) error { + m.logger = ctx.Logger(m) + for _, str := range m.Ranges { + cidrs, err := m.parseIPRange(str) + if err != nil { + return err + } + m.cidrs = cidrs + } + for _, str := range m.NotRanges { + cidrs, err := m.parseIPRange(str) + if err != nil { + return err + } + m.notCidrs = cidrs + } + return nil +} + +// Match matches hello based on the connection's remote IP. +func (m MatchRemoteIP) Match(hello *tls.ClientHelloInfo) bool { + remoteAddr := hello.Conn.RemoteAddr().String() + ipStr, _, err := net.SplitHostPort(remoteAddr) + if err != nil { + ipStr = remoteAddr // weird; maybe no port? + } + ip := net.ParseIP(ipStr) + if ip == nil { + m.logger.Error("invalid client IP addresss", zap.String("ip", ipStr)) + return false + } + return (len(m.cidrs) == 0 || m.matches(ip, m.cidrs)) && + (len(m.notCidrs) == 0 || !m.matches(ip, m.notCidrs)) +} + +func (MatchRemoteIP) parseIPRange(str string) ([]*net.IPNet, error) { + var cidrs []*net.IPNet + if strings.Contains(str, "/") { + _, ipNet, err := net.ParseCIDR(str) + if err != nil { + return nil, fmt.Errorf("parsing CIDR expression: %v", err) + } + cidrs = append(cidrs, ipNet) + } else { + ip := net.ParseIP(str) + if ip == nil { + return nil, fmt.Errorf("invalid IP address: %s", str) + } + mask := len(ip) * 8 + cidrs = append(cidrs, &net.IPNet{ + IP: ip, + Mask: net.CIDRMask(mask, mask), + }) + } + return cidrs, nil +} + +func (MatchRemoteIP) matches(ip net.IP, ranges []*net.IPNet) bool { + for _, ipRange := range ranges { + if ipRange.Contains(ip) { + return true + } + } + return false +} + +// Interface guards +var ( + _ ConnectionMatcher = (*MatchServerName)(nil) + _ ConnectionMatcher = (*MatchRemoteIP)(nil) +) diff --git a/modules/caddytls/matchers_test.go b/modules/caddytls/matchers_test.go index 24a015a5a..4522b3377 100644 --- a/modules/caddytls/matchers_test.go +++ b/modules/caddytls/matchers_test.go @@ -15,8 +15,12 @@ package caddytls import ( + "context" "crypto/tls" + "net" "testing" + + "github.com/caddyserver/caddy/v2" ) func TestServerNameMatcher(t *testing.T) { @@ -84,3 +88,91 @@ func TestServerNameMatcher(t *testing.T) { } } } + +func TestRemoteIPMatcher(t *testing.T) { + ctx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()}) + defer cancel() + + for i, tc := range []struct { + ranges []string + notRanges []string + input string + expect bool + }{ + { + ranges: []string{"127.0.0.1"}, + input: "127.0.0.1:12345", + expect: true, + }, + { + ranges: []string{"127.0.0.1"}, + input: "127.0.0.2:12345", + expect: false, + }, + { + ranges: []string{"127.0.0.1/16"}, + input: "127.0.1.23:12345", + expect: true, + }, + { + ranges: []string{"127.0.0.1", "192.168.1.105"}, + input: "192.168.1.105:12345", + expect: true, + }, + { + notRanges: []string{"127.0.0.1"}, + input: "127.0.0.1:12345", + expect: false, + }, + { + notRanges: []string{"127.0.0.2"}, + input: "127.0.0.1:12345", + expect: true, + }, + { + ranges: []string{"127.0.0.1"}, + notRanges: []string{"127.0.0.2"}, + input: "127.0.0.1:12345", + expect: true, + }, + { + ranges: []string{"127.0.0.2"}, + notRanges: []string{"127.0.0.2"}, + input: "127.0.0.2:12345", + expect: false, + }, + { + ranges: []string{"127.0.0.2"}, + notRanges: []string{"127.0.0.2"}, + input: "127.0.0.3:12345", + expect: false, + }, + } { + matcher := MatchRemoteIP{Ranges: tc.ranges, NotRanges: tc.notRanges} + err := matcher.Provision(ctx) + if err != nil { + t.Fatalf("Test %d: Provision failed: %v", i, err) + } + + addr := testAddr(tc.input) + chi := &tls.ClientHelloInfo{Conn: testConn{addr: addr}} + + actual := matcher.Match(chi) + if actual != tc.expect { + t.Errorf("Test %d: Expected %t but got %t (input=%s ranges=%v notRanges=%v)", + i, tc.expect, actual, tc.input, tc.ranges, tc.notRanges) + } + } +} + +type testConn struct { + *net.TCPConn + addr testAddr +} + +func (tc testConn) RemoteAddr() net.Addr { return tc.addr } + +type testAddr string + +func (testAddr) Network() string { return "tcp" } +func (ta testAddr) String() string { return string(ta) }