From e989eb52f354bca3ad986fa386e19804f27a910a Mon Sep 17 00:00:00 2001 From: vnxme <46669194+vnxme@users.noreply.github.com> Date: Mon, 22 Jul 2024 18:05:27 +0300 Subject: [PATCH] Regular expression (regexp) matcher --- README.md | 1 + imports.go | 1 + modules/l4regexp/matcher.go | 109 +++++++++++++++++++++++++++++++ modules/l4regexp/matcher_test.go | 90 +++++++++++++++++++++++++ 4 files changed, 201 insertions(+) create mode 100644 modules/l4regexp/matcher.go create mode 100644 modules/l4regexp/matcher_test.go diff --git a/README.md b/README.md index d1a1d79..d337cd1 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ Current matchers: - **layer4.matchers.local_ip** - matches connections based on local IP (or CIDR range). - **layer4.matchers.proxy_protocol** - matches connections that start with [HAPROXY proxy protocol](https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt). - **layer4.matchers.rdp** - matches connections that look like [RDP](https://winprotocoldoc.blob.core.windows.net/productionwindowsarchives/MS-RDPBCGR/%5BMS-RDPBCGR%5D.pdf). +- **layer4.matchers.regexp** - matchers connections that have the first packet bytes matching a regular expression. - **layer4.matchers.socks4** - matches connections that look like [SOCKSv4](https://www.openssh.com/txt/socks4.protocol). - **layer4.matchers.socks5** - matches connections that look like [SOCKSv5](https://www.rfc-editor.org/rfc/rfc1928.html). diff --git a/imports.go b/imports.go index 52a258a..80c6681 100644 --- a/imports.go +++ b/imports.go @@ -23,6 +23,7 @@ import ( _ "github.com/mholt/caddy-l4/modules/l4proxy" _ "github.com/mholt/caddy-l4/modules/l4proxyprotocol" _ "github.com/mholt/caddy-l4/modules/l4rdp" + _ "github.com/mholt/caddy-l4/modules/l4regexp" _ "github.com/mholt/caddy-l4/modules/l4socks" _ "github.com/mholt/caddy-l4/modules/l4ssh" _ "github.com/mholt/caddy-l4/modules/l4subroute" diff --git a/modules/l4regexp/matcher.go b/modules/l4regexp/matcher.go new file mode 100644 index 0000000..6e41ebe --- /dev/null +++ b/modules/l4regexp/matcher.go @@ -0,0 +1,109 @@ +// Copyright 2024 VNXME +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package l4regexp + +import ( + "io" + "regexp" + "strconv" + + "github.com/caddyserver/caddy/v2" + "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" + "github.com/mholt/caddy-l4/layer4" +) + +func init() { + caddy.RegisterModule(&MatchRegexp{}) +} + +// MatchRegexp is able to match any connections with regular expressions. +type MatchRegexp struct { + Count uint16 `json:"count,omitempty"` + Pattern string `json:"pattern,omitempty"` + + compiled *regexp.Regexp +} + +// CaddyModule returns the Caddy module information. +func (m *MatchRegexp) CaddyModule() caddy.ModuleInfo { + return caddy.ModuleInfo{ + ID: "layer4.matchers.regexp", + New: func() caddy.Module { return new(MatchRegexp) }, + } +} + +// Match returns true if the connection bytes match the regular expression. +func (m *MatchRegexp) Match(cx *layer4.Connection) (bool, error) { + // Read a number of bytes + buf := make([]byte, m.Count) + n, err := io.ReadFull(cx, buf) + if err != nil || n < int(m.Count) { + return false, nil + } + + // Match these bytes against the regular expression + return m.compiled.Match(buf), nil +} + +// Provision parses m's regular expression and sets m's minimum read bytes count. +func (m *MatchRegexp) Provision(_ caddy.Context) (err error) { + if m.Count == 0 { + m.Count = minCount + } + m.compiled, err = regexp.Compile(m.Pattern) + if err != nil { + return err + } + return nil +} + +// UnmarshalCaddyfile sets up the MatchRegexp from Caddyfile tokens. Syntax: +// +// regexp [] +func (m *MatchRegexp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { + _, wrapper := d.Next(), d.Val() // consume wrapper name + + // One or two same-line argument must be provided + if d.CountRemainingArgs() == 0 || d.CountRemainingArgs() > 2 { + return d.ArgErr() + } + + m.Pattern = d.Val() + if d.NextArg() { + val, err := strconv.ParseUint(d.Val(), 10, 16) + if err != nil { + return d.Errf("parsing %s count: %v", wrapper, err) + } + m.Count = uint16(val) + } + + // No blocks are supported + if d.NextBlock(d.Nesting()) { + return d.Errf("malformed %s option: blocks are not supported", wrapper) + } + + return nil +} + +// Interface guards +var ( + _ caddy.Provisioner = (*MatchRegexp)(nil) + _ caddyfile.Unmarshaler = (*MatchRegexp)(nil) + _ layer4.ConnMatcher = (*MatchRegexp)(nil) +) + +const ( + minCount uint16 = 4 // by default, read this many bytes to match against +) diff --git a/modules/l4regexp/matcher_test.go b/modules/l4regexp/matcher_test.go new file mode 100644 index 0000000..0acc14b --- /dev/null +++ b/modules/l4regexp/matcher_test.go @@ -0,0 +1,90 @@ +// Copyright 2024 VNXME +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package l4regexp + +import ( + "context" + "io" + "net" + "testing" + + "github.com/caddyserver/caddy/v2" + "github.com/mholt/caddy-l4/layer4" + "go.uber.org/zap" +) + +func assertNoError(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Fatalf("Unexpected error: %s\n", err) + } +} + +func Test_MatchRegexp_Match(t *testing.T) { + type test struct { + matcher *MatchRegexp + data []byte + shouldMatch bool + } + + tests := []test{ + {matcher: &MatchRegexp{}, data: packet0123, shouldMatch: true}, + {matcher: &MatchRegexp{Pattern: ""}, data: packet0123, shouldMatch: true}, + {matcher: &MatchRegexp{Pattern: "12"}, data: packet0123, shouldMatch: true}, + {matcher: &MatchRegexp{Pattern: "^0123$"}, data: packet0123, shouldMatch: true}, + {matcher: &MatchRegexp{Pattern: "^012$"}, data: packet0123, shouldMatch: false}, + {matcher: &MatchRegexp{Pattern: "^0123$", Count: 5}, data: packet0123, shouldMatch: false}, + {matcher: &MatchRegexp{Pattern: "^012$", Count: 3}, data: packet0123, shouldMatch: true}, + {matcher: &MatchRegexp{Pattern: "^\\d+$"}, data: packet0123, shouldMatch: true}, + {matcher: &MatchRegexp{Pattern: "^\\d+$", Count: 0}, data: packet0123, shouldMatch: true}, + {matcher: &MatchRegexp{Pattern: "^\x30\x31\x32(\x33|\x34)$", Count: 0}, data: packet0123, shouldMatch: true}, + } + + ctx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()}) + defer cancel() + + for i, tc := range tests { + func() { + err := tc.matcher.Provision(ctx) + assertNoError(t, err) + + in, out := net.Pipe() + defer func() { + _, _ = io.Copy(io.Discard, out) + _ = out.Close() + }() + + cx := layer4.WrapConnection(out, []byte{}, zap.NewNop()) + go func() { + _, err := in.Write(tc.data) + assertNoError(t, err) + _ = in.Close() + }() + + matched, err := tc.matcher.Match(cx) + assertNoError(t, err) + + if matched != tc.shouldMatch { + if tc.shouldMatch { + t.Fatalf("test %d: matcher did not match | %+v\n", i, tc.matcher) + } else { + t.Fatalf("test %d: matcher should not match | %+v\n", i, tc.matcher) + } + } + }() + } +} + +var packet0123 = []byte{0x30, 0x31, 0x32, 0x33}