diff --git a/go.mod b/go.mod index eb57bab..9e498f5 100644 --- a/go.mod +++ b/go.mod @@ -6,8 +6,10 @@ toolchain go1.23.0 require ( github.com/caddyserver/caddy/v2 v2.8.4 + github.com/fsnotify/fsnotify v1.8.0 github.com/mastercactapus/proxyprotocol v0.0.4 github.com/miekg/dns v1.1.62 + github.com/quic-go/quic-go v0.44.0 github.com/things-go/go-socks5 v0.0.5 go.uber.org/zap v1.27.0 golang.org/x/crypto v0.28.0 @@ -91,7 +93,6 @@ require ( github.com/prometheus/common v0.48.0 // indirect github.com/prometheus/procfs v0.12.0 // indirect github.com/quic-go/qpack v0.4.0 // indirect - github.com/quic-go/quic-go v0.44.0 // indirect github.com/rs/xid v1.5.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/shopspring/decimal v1.3.1 // indirect @@ -139,7 +140,7 @@ require ( golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect golang.org/x/mod v0.18.0 // indirect golang.org/x/sync v0.8.0 // indirect - golang.org/x/sys v0.26.0 // indirect + golang.org/x/sys v0.27.0 // indirect golang.org/x/term v0.25.0 // indirect golang.org/x/text v0.19.0 // indirect golang.org/x/tools v0.22.0 // indirect diff --git a/go.sum b/go.sum index 3f9d3ce..2e20969 100644 --- a/go.sum +++ b/go.sum @@ -135,6 +135,8 @@ github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSw github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY= github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= +github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.6.0 h1:sU6J2usfADwWlYDAFhZBQ6TnLFBHxgesMrQfQgk1tWA= github.com/fxamacker/cbor/v2 v2.6.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s= @@ -580,8 +582,8 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= -golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= +golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= diff --git a/imports.go b/imports.go index 2c38c1f..c78c8a7 100644 --- a/imports.go +++ b/imports.go @@ -28,6 +28,7 @@ import ( _ "github.com/mholt/caddy-l4/modules/l4quic" _ "github.com/mholt/caddy-l4/modules/l4rdp" _ "github.com/mholt/caddy-l4/modules/l4regexp" + _ "github.com/mholt/caddy-l4/modules/l4remoteiplist" _ "github.com/mholt/caddy-l4/modules/l4socks" _ "github.com/mholt/caddy-l4/modules/l4ssh" _ "github.com/mholt/caddy-l4/modules/l4subroute" diff --git a/integration/caddyfile_adapt/gd_matcher_remoteiplist.caddytest b/integration/caddyfile_adapt/gd_matcher_remoteiplist.caddytest new file mode 100644 index 0000000..23d2440 --- /dev/null +++ b/integration/caddyfile_adapt/gd_matcher_remoteiplist.caddytest @@ -0,0 +1,47 @@ +{ + layer4 { + :12345 { + @f1 remote_ip_list /tmp/remote-ips + route @f1 { + proxy f1.machine.local:54321 + } + } + } +} +---------- +{ + "apps": { + "layer4": { + "servers": { + "srv0": { + "listen": [ + ":12345" + ], + "routes": [ + { + "match": [ + { + "remote_ip_list": { + "remote_ip_file": "/tmp/remote-ips" + } + } + ], + "handle": [ + { + "handler": "proxy", + "upstreams": [ + { + "dial": [ + "f1.machine.local:54321" + ] + } + ] + } + ] + } + ] + } + } + } + } +} \ No newline at end of file diff --git a/modules/l4remoteiplist/iplist.go b/modules/l4remoteiplist/iplist.go new file mode 100644 index 0000000..0abdba7 --- /dev/null +++ b/modules/l4remoteiplist/iplist.go @@ -0,0 +1,186 @@ +// Copyright (c) 2024 SICK AG +// +// 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 l4remoteiplist + +import ( + "bufio" + "fmt" + "net/netip" + "os" + "path/filepath" + "sync" + + "github.com/caddyserver/caddy/v2" + "github.com/caddyserver/caddy/v2/modules/caddyhttp" + "github.com/fsnotify/fsnotify" + "go.uber.org/zap" +) + +type IPList struct { + ipFile string // File containing all IPs / CIDRs to be matched, gets continously monitored + cidrs []netip.Prefix // List of currently loaded CIDRs + ctx caddy.Context // Caddy context, used to detect when to shut down + logger *zap.Logger + reloadNeededMutex sync.Mutex // Mutex to ensure proper concurrent handling of reloads + reloadNeeded bool // Flag indicating whether a reload of the IPs is needed +} + +// Creates a new IPList, creating the ipFile if it is not present +func NewIPList(ipFile string, ctx caddy.Context, logger *zap.Logger) (*IPList, error) { + ipList := &IPList{ + ipFile: ipFile, + ctx: ctx, + logger: logger, + reloadNeeded: true, + } + + // make sure the directory containing the ipFile exists + // otherwise, the fsnotify watcher will not work + if !ipList.ipFileDirectoryExists() { + return nil, fmt.Errorf("could not find the directory containing the IP file to monitor: %v", ipFile) + } + + return ipList, nil +} + +// Check whether a IP address is currently contained in the IP list +func (b *IPList) IsMatched(ip netip.Addr) bool { + // First reload the IP list if needed to ensure IPs are always up to date + b.reloadNeededMutex.Lock() + if b.reloadNeeded { + err := b.loadIPAddresses() + if err != nil { + b.logger.Error("could not load IP addresses", zap.Error(err)) + } else { + b.reloadNeeded = false + b.logger.Debug("reloaded IP addresses") + } + } + b.reloadNeededMutex.Unlock() + + for _, cidr := range b.cidrs { + if cidr.Contains(ip) { + return true + } + } + return false +} + +// Start to monitor the IP list +func (b *IPList) StartMonitoring() { + go b.monitor() +} + +func (b *IPList) ipFileDirectoryExists() bool { + // Make sure the directory containing the IP list exists + dirpath := filepath.Dir(b.ipFile) + st, err := os.Lstat(dirpath) + if err != nil || !st.IsDir() { + return false + } + return true +} + +func (b *IPList) ipFileExists() bool { + // Make sure the IP list exists and is a file + st, err := os.Lstat(b.ipFile) + if err != nil || st.IsDir() { + return false + } + return true +} + +func (b *IPList) monitor() { + // Create a new watcher + w, err := fsnotify.NewWatcher() + if err != nil { + b.logger.Error("error creating a new filesystem watcher", zap.Error(err)) + return + } + defer w.Close() + + if !b.ipFileDirectoryExists() { + b.logger.Error("directory containing the IP file to monitor does not exist") + return + } + + // Monitor the directory of the file + err = w.Add(filepath.Dir(b.ipFile)) + if err != nil { + b.logger.Error("error watching the file", zap.Error(err)) + return + } + + for { + select { + case <-b.ctx.Done(): + // Check if Caddy closed the context + b.logger.Debug("caddy closed the context") + return + case err, ok := <-w.Errors: + b.logger.Error("error from file watcher", zap.Error(err)) + if !ok { + b.logger.Error("file watcher was closed") + return + } + case e, ok := <-w.Events: + if !ok { + b.logger.Error("file watcher was closed") + return + } + + // Check if the IP list has changed + if b.ipFile == e.Name && (e.Has(fsnotify.Create) || e.Has(fsnotify.Write)) { + b.reloadNeededMutex.Lock() + b.reloadNeeded = true + b.reloadNeededMutex.Unlock() + } + } + } +} + +// Loads the IP addresses from the IP list +func (b *IPList) loadIPAddresses() error { + if !b.ipFileExists() { + b.logger.Debug("ip file not found, nothing to monitor") + b.cidrs = make([]netip.Prefix, 0) + return nil + } + + file, err := os.Open(b.ipFile) + if err != nil { + return fmt.Errorf("error opening the IP list file %v: %w", b.ipFile, err) + } + defer file.Close() + + var cidrs []netip.Prefix + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + cidr, err := caddyhttp.CIDRExpressionToPrefix(line) + if err == nil { + // only append valid IP addresses / CIDRs (ignore lines that + // have not been parsed successfully, e.g. comments) + cidrs = append(cidrs, cidr) + } + } + err = scanner.Err() + if err != nil { + return fmt.Errorf("error reading the IPs from %v: %w", b.ipFile, err) + } + + b.cidrs = cidrs + return nil +} diff --git a/modules/l4remoteiplist/matcher.go b/modules/l4remoteiplist/matcher.go new file mode 100644 index 0000000..75bba4c --- /dev/null +++ b/modules/l4remoteiplist/matcher.go @@ -0,0 +1,126 @@ +// Copyright (c) 2024 SICK AG +// +// 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 l4remoteiplist + +import ( + "fmt" + "net" + "net/netip" + + "github.com/caddyserver/caddy/v2" + "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" + + "github.com/mholt/caddy-l4/layer4" + "go.uber.org/zap" +) + +func init() { + caddy.RegisterModule(&RemoteIPList{}) +} + +type RemoteIPList struct { + RemoteIPFile string `json:"remote_ip_file"` + + logger *zap.Logger + remoteIPList *IPList +} + +// CaddyModule returns the Caddy module information. +func (*RemoteIPList) CaddyModule() caddy.ModuleInfo { + return caddy.ModuleInfo{ + ID: "layer4.matchers.remote_ip_list", + New: func() caddy.Module { return new(RemoteIPList) }, + } +} + +// Provision implements caddy.Provisioner. +func (m *RemoteIPList) Provision(ctx caddy.Context) error { + m.logger = ctx.Logger() + + remoteIPList, err := NewIPList(m.RemoteIPFile, ctx, m.logger) + if err != nil { + m.logger.Error("error creating a new IP list", zap.Error(err)) + return err + } + m.remoteIPList = remoteIPList + m.remoteIPList.StartMonitoring() + return nil +} + +// The Match will return true if the remote IP is found in the remote IP list +func (m *RemoteIPList) Match(cx *layer4.Connection) (bool, error) { + remoteIP, err := m.getRemoteIP(cx) + if err != nil { + // Error, tread IP as matched + m.logger.Error("error parsing the remote IP from the connection", zap.Error(err)) + return true, err + } + + // IP not matched + m.logger.Debug("received request", zap.String("remote_addr", remoteIP.String())) + + if m.remoteIPList.IsMatched(remoteIP) { + m.logger.Info("matched IP found", zap.String("remote_addr", remoteIP.String())) + return true, nil + } + return false, nil +} + +// Returns the remote IP address for a given layer4 connection. +// Same method as in layer4.MatchRemoteIP.getRemoteIP +func (m *RemoteIPList) getRemoteIP(cx *layer4.Connection) (netip.Addr, error) { + remote := cx.Conn.RemoteAddr().String() + + ipStr, _, err := net.SplitHostPort(remote) + if err != nil { + ipStr = remote + } + + ip, err := netip.ParseAddr(ipStr) + if err != nil { + return netip.Addr{}, fmt.Errorf("invalid remote IP address: %s", ipStr) + } + return ip, nil +} + +// UnmarshalCaddyfile sets up the ip_file from Caddyfile. Syntax: +// +// remote_ip_list +func (m *RemoteIPList) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { + _, wrapper := d.Next(), d.Val() // consume wrapper name + + // Only one same-line argument is supported + if d.CountRemainingArgs() > 1 { + return d.ArgErr() + } + + if d.NextArg() { + m.RemoteIPFile = d.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 ( + _ layer4.ConnMatcher = (*RemoteIPList)(nil) + _ caddy.Provisioner = (*RemoteIPList)(nil) + _ caddyfile.Unmarshaler = (*RemoteIPList)(nil) +) diff --git a/modules/l4remoteiplist/matcher_test.go b/modules/l4remoteiplist/matcher_test.go new file mode 100644 index 0000000..e584282 --- /dev/null +++ b/modules/l4remoteiplist/matcher_test.go @@ -0,0 +1,263 @@ +// Copyright (c) 2024 SICK AG +// +// 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 l4remoteiplist + +import ( + "context" + "net" + "os" + "path/filepath" + "testing" + "time" + + "github.com/caddyserver/caddy/v2" + "github.com/mholt/caddy-l4/layer4" + "go.uber.org/zap" +) + +// Setup dummy structs for test cases as in +// https://github.com/mholt/caddy-l4/blob/master/layer4/matchers_test.go +var _ net.Conn = &dummyConn{} +var _ net.Addr = dummyAddr{} + +type dummyAddr struct { + ip string + network string +} + +// Network implements net.Addr. +func (da dummyAddr) Network() string { + return da.network +} + +// String implements net.Addr. +func (da dummyAddr) String() string { + return da.ip +} + +type dummyConn struct { + net.Conn + remoteAddr net.Addr +} + +// RemoteAddr implements net.Conn. +func (dc *dummyConn) RemoteAddr() net.Addr { + return dc.remoteAddr +} + +func assertNoError(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Fatalf("Unexpected error: %s\n", err) + } +} + +// Create a temporary directory and a remote IP file +func createIPFile(t *testing.T) (string, string) { + t.Helper() + tempDir, err := os.MkdirTemp("", "caddy-l4-remoteiplist-test") + assertNoError(t, err) + + remoteIPFile := filepath.Join(tempDir, "remote-ips") + + // Create the file + file, err := os.Create(remoteIPFile) + assertNoError(t, err) + defer file.Close() + + return tempDir, remoteIPFile +} + +// Cleanup the temporary directory and the remote IP file +func cleanupIPFile(t *testing.T, tempDir string) { + t.Helper() + err := os.RemoveAll(tempDir) + assertNoError(t, err) +} + +func wait() { + time.Sleep(10 * time.Millisecond) +} + +func appendToFile(t *testing.T, filename string, ip string) { + // Append new IP to end of file + f, err := os.OpenFile(filename, os.O_APPEND|os.O_WRONLY, 0600) + assertNoError(t, err) + defer f.Close() + + _, err = f.WriteString(ip + "\n") + assertNoError(t, err) +} + +// simple template for testing: write IP ipInFile to file and test against the IP ipInConnection +// expected result of the matcher is matchExpected +func simpleIPMatchTest(t *testing.T, ipInFile string, ipInConnection string, matchExpected bool) { + tempDir, ipFile := createIPFile(t) + defer cleanupIPFile(t, tempDir) + + appendToFile(t, ipFile, ipInFile) + + ctx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()}) + // Give some time to react to the context close + defer wait() + defer cancel() + + matcher := RemoteIPList{ + RemoteIPFile: ipFile, + } + + err := matcher.Provision(ctx) + assertNoError(t, err) + + cx := &layer4.Connection{ + Conn: &dummyConn{ + remoteAddr: dummyAddr{ip: ipInConnection, network: "tcp"}, + }, + Logger: zap.NewNop(), + } + + matched, err := matcher.Match(cx) + assertNoError(t, err) + + if matched != matchExpected { + t.Errorf("Matcher returned %t (expected was %t)", matched, matchExpected) + } +} + +// Test if a remote IPv4 address is matched +func TestRemoteIPv4Match(t *testing.T) { + simpleIPMatchTest(t, "127.0.0.99", "127.0.0.99", true) +} + +// Test if a remote IPv4 network is matched +func TestRemoteIPv4NetworkMatch(t *testing.T) { + simpleIPMatchTest(t, "127.0.0.1/8", "127.0.0.99", true) +} + +// Test if an IP that is not contained in the remote IP list is not matched +func TestRemoteIPv4NoMatch(t *testing.T) { + simpleIPMatchTest(t, "127.0.0.1", "127.0.0.99", false) +} + +// Test if a remote IPv6 address is matched +func TestRemoteIPv6Match(t *testing.T) { + simpleIPMatchTest(t, "fd00::1", "fd00:0:0:0:0:0:0:1", true) +} + +// Test if a remote IPv6 network is matched +func TestRemoteIPv6NetworkMatch(t *testing.T) { + simpleIPMatchTest(t, "fd00::1/8", "fd00:0:0:0:0:0:0:99", true) +} + +// Test if an IP that is not contained in the remote IP list is not matched +func TestRemoteIPv6NoMatch(t *testing.T) { + simpleIPMatchTest(t, "fd00::1", "fd00::2", false) +} + +// Test if an IP file only containing a comment is handled correctly +func TestNoMatch(t *testing.T) { + simpleIPMatchTest(t, "// this is a comment", "127.0.0.1", false) +} + +// Test if a remote IP is matched (added to the file after first match call) +func TestRemoteIPMatchDynamic(t *testing.T) { + tempDir, ipFile := createIPFile(t) + defer cleanupIPFile(t, tempDir) + + appendToFile(t, ipFile, "127.0.0.80") + + ctx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()}) + // Give some time to react to the context close + defer wait() + defer cancel() + + matcher := RemoteIPList{ + RemoteIPFile: ipFile, + } + + err := matcher.Provision(ctx) + assertNoError(t, err) + + // Give the file system watcher some time to set up + wait() + + cx := &layer4.Connection{ + Conn: &dummyConn{ + remoteAddr: dummyAddr{ip: "127.0.0.99", network: "tcp"}, + }, + Logger: zap.NewNop(), + } + + // IP should not match + matched, err := matcher.Match(cx) + assertNoError(t, err) + + if matched { + t.Error("Matcher did match") + } + + appendToFile(t, ipFile, "127.0.0.99") + + // Allow some time to register the file change + wait() + + // IP should match now + matched, err = matcher.Match(cx) + assertNoError(t, err) + + if !matched { + t.Error("Matcher did not match") + } +} + +// Test if the matcher still works of no remote IP file exists +func TestNoRemoteIPFile(t *testing.T) { + t.Helper() + tempDir, err := os.MkdirTemp("", "caddy-l4-remoteiplist-test") + assertNoError(t, err) + defer cleanupIPFile(t, tempDir) + + ipFile := filepath.Join(tempDir, "remote-ips") + + ctx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()}) + // Give some time to react to the context close + defer wait() + defer cancel() + + matcher := RemoteIPList{ + RemoteIPFile: ipFile, + } + + err = matcher.Provision(ctx) + assertNoError(t, err) + + cx := &layer4.Connection{ + Conn: &dummyConn{ + remoteAddr: dummyAddr{ip: "127.0.0.99", network: "tcp"}, + }, + Logger: zap.NewNop(), + } + + matched, err := matcher.Match(cx) + assertNoError(t, err) + + if matched { + t.Error("Matcher did match, although no remote IP file existed") + } + + if _, err := os.Stat(ipFile); err == nil { + t.Error("IP file does exist") + } +}