From 15b34b43ad6f7e13ec2a2a08aaa4efff3ed2e379 Mon Sep 17 00:00:00 2001 From: Laurence Withers Date: Wed, 20 Sep 2023 12:21:59 +0100 Subject: [PATCH] Initial working version, with example. --- .gitignore | 1 + LICENSE | 20 +++++-- README.md | 22 +++++++- cmd/example/example.go | 63 ++++++++++++++++++++++ go.mod | 3 ++ reverse.go | 34 ++++++++++++ reverse_test.go | 22 ++++++++ server.go | 70 ++++++++++++++++++++++++ xlist.go | 120 +++++++++++++++++++++++++++++++++++++++++ 9 files changed, 350 insertions(+), 5 deletions(-) create mode 100644 .gitignore create mode 100644 cmd/example/example.go create mode 100644 go.mod create mode 100644 reverse.go create mode 100644 reverse_test.go create mode 100644 server.go create mode 100644 xlist.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7f880ab --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/cmd/example/example diff --git a/LICENSE b/LICENSE index 2071b23..58cf55e 100644 --- a/LICENSE +++ b/LICENSE @@ -1,9 +1,21 @@ MIT License -Copyright (c) +Copyright (c) 2023, Laurence Withers. -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 2558ee9..3b30568 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,23 @@ # dnsxl -DNS xList (block/allow) implementation. \ No newline at end of file +DNS xList (blocklist / allowlist / RBL) implementation for Go, based on +[RFC 5782](https://www.rfc-editor.org/rfc/rfc5782). + +Quick start: + +```go +import "src.lwithers.me.uk/go/dnsxl" + +func main() { + xlist := dnsxl.New(nil) + xlist.AddServers("zen.spamhaus.org") + res, err := xlist.LookupIP(net.IP4(102, 158, 172, 138) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + fmt.Printf("Weight: %d\n", res.Weight()) +} +``` + +A slightly more fully-fledged example is found in `cmd/example`. diff --git a/cmd/example/example.go b/cmd/example/example.go new file mode 100644 index 0000000..e07f915 --- /dev/null +++ b/cmd/example/example.go @@ -0,0 +1,63 @@ +/* +example is a short example program showing the detailed result from a lookup. +*/ +package main + +import ( + "context" + "fmt" + "net" + "os" + + "src.lwithers.me.uk/go/dnsxl" +) + +var ( + xlist *dnsxl.XList +) + +func main() { + if len(os.Args) < 2 { + fmt.Fprintln(os.Stderr, "Expecting one or more names/IPs to look up") + os.Exit(1) + } + + xlist = dnsxl.New(nil) + xlist.AddServers("sbl.spamhaus.org", "xbl.spamhaus.org", "pbl.spamhaus.org") + + for i := 1; i < len(os.Args); i++ { + lookup(os.Args[i]) + } +} + +func lookup(name string) { + fmt.Printf("==== %s ====\n", name) + ips, err := net.DefaultResolver.LookupIP(context.Background(), "ip", name) + switch { + case err != nil: + fmt.Printf("Failed to look up name: %v\n", err) + return + case len(ips) == 0: + fmt.Println("No addresses found for name") + return + } + + for _, ip := range ips { + fmt.Printf("--- %s\n", ip) + res, err := xlist.LookupIP(ip) + if err != nil { + fmt.Printf("Failed to look up IP: %v\n", err) + continue + } + for _, r := range res { + switch { + case r.Error != nil: + fmt.Printf("%s\tError: %v\n", r.Server, r.Error) + case r.InList: + fmt.Printf("%s\tEntry in list (reason: %q)\n", r.Server, r.Reason) + default: + fmt.Printf("%s\tEntry not in list\n", r.Server) + } + } + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3ef6788 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module src.lwithers.me.uk/go/dnsxl + +go 1.21.1 diff --git a/reverse.go b/reverse.go new file mode 100644 index 0000000..eaf6a59 --- /dev/null +++ b/reverse.go @@ -0,0 +1,34 @@ +package dnsxl + +import ( + "net" + "strconv" + "strings" +) + +// reverseName converts an IPv4 or IPv6 address into the reversed name +// notation as described in RFC 1034 (for IPv4) or RFC 3596 (for IPv6). +func reverseName(ip net.IP) string { + const hexdig = "0123456789abcdef" + var b strings.Builder + if ip4 := ip.To4(); ip4 != nil { + b.WriteString(strconv.Itoa(int(ip4[3]))) + b.WriteByte('.') + b.WriteString(strconv.Itoa(int(ip4[2]))) + b.WriteByte('.') + b.WriteString(strconv.Itoa(int(ip4[1]))) + b.WriteByte('.') + b.WriteString(strconv.Itoa(int(ip4[0]))) + } else { + for i := 15; i >= 0; i-- { + x := ip[i] + b.WriteByte(hexdig[x&15]) + b.WriteByte('.') + b.WriteByte(hexdig[x>>4]) + if i > 0 { + b.WriteByte('.') + } + } + } + return b.String() +} diff --git a/reverse_test.go b/reverse_test.go new file mode 100644 index 0000000..4588b55 --- /dev/null +++ b/reverse_test.go @@ -0,0 +1,22 @@ +package dnsxl + +import ( + "net" + "testing" +) + +func TestReverseName(t *testing.T) { + for _, x := range [][2]string{ + {"1.2.3.4", "4.3.2.1"}, + {"127.0.0.1", "1.0.0.127"}, + {"192.168.11.12", "12.11.168.192"}, + {"2001:8b0:8b0::1", "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.b.8.0.0.b.8.0.1.0.0.2"}, + } { + ip := net.ParseIP(x[0]) + r := reverseName(ip) + if r != x[1] { + t.Errorf("for IP %s [parsed from %s]: got reversed name %q but expected %q", + ip, x[0], r, x[1]) + } + } +} diff --git a/server.go b/server.go new file mode 100644 index 0000000..5d23ebb --- /dev/null +++ b/server.go @@ -0,0 +1,70 @@ +package dnsxl + +import ( + "context" + "errors" + "net" + "strings" +) + +// Result of a lookup from a single server. +type Result struct { + // Server is the name of the DNSxL server that produced this result. + Server string + + // InList is true if the IP address that was lookup up is in the list. + InList bool + + // Weight is the server's present/not present weight, as appropriate, or + // 0 on error. + Weight int + + // Reason may be set if the IP address is in the server's list, and the + // server has a reason TXT record present for it. + Reason string + + // Error is set if there was an error looking up the IP address on this + // server. + Error error +} + +// server implements a single DNSxL server. It holds the server's DNS name and +// the configured weights for found / not found results. +type server struct { + Addr string + WeightInList, WeightNotInList int +} + +// Lookup performs a (synchronous) DNSxL lookup. +func (s server) Lookup(ctx context.Context, res *net.Resolver, reversed string) Result { + r := Result{ + Server: s.Addr, + } + + addr := reversed + "." + s.Addr + ips, err := res.LookupIP(ctx, "ip4" /* DNSxL entries are A records */, addr) + switch { + case isNXDOMAIN(err): + r.Weight = s.WeightNotInList + return r + case err != nil: + r.Error = err + return r + case len(ips) == 0: + r.Weight = s.WeightNotInList + return r + } + + txts, _ := res.LookupTXT(ctx, addr) + r.InList = true + r.Weight = s.WeightInList + r.Reason = strings.Join(txts, "\n") + return r +} + +// isNXDOMAIN catches the error that Go returns when getting an NXDOMAIN +// response. This just means "not on list" in our case. +func isNXDOMAIN(err error) bool { + var d *net.DNSError + return errors.As(err, &d) && d.IsNotFound +} diff --git a/xlist.go b/xlist.go new file mode 100644 index 0000000..e7c7be8 --- /dev/null +++ b/xlist.go @@ -0,0 +1,120 @@ +package dnsxl + +import ( + "context" + "errors" + "net" + "time" +) + +const ( + // DefaultTimeout is the maximum time allowed in XList.LookupIP. You can + // use a different timeout by calling LookupIPCtx instead. + DefaultTimeout = 5 * time.Second + + // DefaultWeight is the weight given to a server by AddServer when an IP + // is found to be in the server's list. + DefaultWeight = 1000 +) + +var ( + NoServersConfigured = errors.New("no servers configured") +) + +// LookupFailed is returned from XList.LookupIP if all lookups fail. +type LookupFailed struct { + Exemplar error +} + +func (lf *LookupFailed) Error() string { + return "all lookups failed; example error: " + lf.Exemplar.Error() +} + +// XList implements a DNSxL (blocklist / allowlist) lookup. Create one with New +// and then call AddServers. +type XList struct { + servers []server + res *net.Resolver +} + +// New creates a fresh DNSxL lookup object, using the specified resolver. You +// may pass nil to use net.DefaultResolver. +func New(res *net.Resolver) *XList { + xl := &XList{ + res: res, + } + if xl.res == nil { + xl.res = net.DefaultResolver + } + return xl +} + +// AddServers adds DNSxL servers to the list using the default weights. +func (xl *XList) AddServers(names ...string) { + xl.AddWeightedServers(DefaultWeight, 0, names...) +} + +// AddWeightedServers adds DNSxL servers to the list using the specified weights. +func (xl *XList) AddWeightedServers(weightInList, weightNotInList int, names ...string) { + for _, name := range names { + xl.servers = append(xl.servers, server{ + Addr: name, + WeightInList: weightInList, + WeightNotInList: weightNotInList, + }) + } +} + +// Results is the set of results of an IP lookup. +type Results []Result + +// Weight returns the sum of each server's resulting weight. +func (r Results) Weight() int { + var sum int + for i := range r { + sum += r[i].Weight + } + return sum +} + +// LookupIP checks the configured servers and returns a set of results. It uses +// DefaultTimeout. +func (xl *XList) LookupIP(ip net.IP) (Results, error) { + ctx, cancel := context.WithTimeout(context.Background(), DefaultTimeout) + defer cancel() + return xl.LookupIPCtx(ctx, ip) +} + +// LookupIPCtx checks the given IP address at each of the configured servers, +// and returns a set of results. The individual server lookups are performed +// in parallel and may arrive in any order in the result set. +func (xl *XList) LookupIPCtx(ctx context.Context, ip net.IP) ([]Result, error) { + if len(xl.servers) == 0 { + return nil, NoServersConfigured + } + + reversed := reverseName(ip) + res := make([]Result, 0, len(xl.servers)) + resch := make(chan Result) + + for i := range xl.servers { + go func(i int) { + resch <- xl.servers[i].Lookup(ctx, xl.res, reversed) + }(i) + } + + var nerr int + for _ = range xl.servers { + r := <-resch + if r.Error != nil { + nerr++ + } + res = append(res, r) + } + if nerr == len(xl.servers) { + return res, &LookupFailed{ + Exemplar: res[0].Error, + } + } + return res, nil +}