Initial working version, with example.

This commit is contained in:
Laurence Withers 2023-09-20 12:21:59 +01:00
parent 307d0b2a94
commit 15b34b43ad
9 changed files with 350 additions and 5 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/cmd/example/example

20
LICENSE
View File

@ -1,9 +1,21 @@
MIT License
Copyright (c) <year> <copyright holders>
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.

View File

@ -1,3 +1,23 @@
# dnsxl
DNS xList (block/allow) implementation.
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`.

63
cmd/example/example.go Normal file
View File

@ -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)
}
}
}
}

3
go.mod Normal file
View File

@ -0,0 +1,3 @@
module src.lwithers.me.uk/go/dnsxl
go 1.21.1

34
reverse.go Normal file
View File

@ -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()
}

22
reverse_test.go Normal file
View File

@ -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])
}
}
}

70
server.go Normal file
View File

@ -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
}

120
xlist.go Normal file
View File

@ -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
}