Files
routewatch/internal/iplist/parse.go
T
sneak 9e8f687511
check / check (push) Failing after 1m7s
Rename blocklists to IP lists and add IP lookup flags (closes #233)
The answer field is now ip_lists, the package internal/iplist and the
state directory iplists. No compatibility is kept for the old names.

Every IP lookup result gains a flags object with is_tor and is_bogon.
The largest list file accepted is now 200 MiB.

Model: opus-5-5
2026-11-10 09:27:12 +00:00

159 lines
3.5 KiB
Go

package iplist
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"io"
"net/netip"
"strings"
"go4.org/netipx"
)
const (
// commentPrefix starts a comment line in a list file.
commentPrefix = "#"
// networkSeparator separates the address and the mask length of a
// network, as in 192.0.2.0/24.
networkSeparator = "/"
// dshieldFieldSeparator separates the fields of a DShield line.
dshieldFieldSeparator = "\t"
// spamhausMetadataType is the type of the line that ends a Spamhaus
// file. That line holds the file's metadata and no network.
spamhausMetadataType = "metadata"
)
var (
errUnknownFormat = errors.New("unknown list format")
errNoNetwork = errors.New("no network on the line")
errBadRange = errors.New("first and last address make no range")
)
// parse reads a list file written in format and returns the addresses it
// holds. A line it cannot read fails the whole file, so a file that is not
// the list, such as an error page, never replaces a good copy.
func parse(r io.Reader, format Format) (*netipx.IPSet, error) {
var builder netipx.IPSetBuilder
scanner := bufio.NewScanner(r)
for lineNumber := 1; scanner.Scan(); lineNumber++ {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, commentPrefix) {
continue
}
err := addLine(&builder, line, format)
if err != nil {
return nil, fmt.Errorf("line %d: %w", lineNumber, err)
}
}
err := scanner.Err()
if err != nil {
return nil, err
}
return builder.IPSet()
}
// addLine adds the address or network on one line of a list file to builder.
func addLine(builder *netipx.IPSetBuilder, line string, format Format) error {
switch format {
case FormatLines:
return addAddressOrNetwork(builder, line)
case FormatSpamhausJSON:
return addSpamhausNetwork(builder, line)
case FormatDShield:
return addDShieldNetwork(builder, line)
default:
return errUnknownFormat
}
}
// addAddressOrNetwork adds an address, such as 192.0.2.1, or a network, such
// as 192.0.2.0/24, to builder.
func addAddressOrNetwork(builder *netipx.IPSetBuilder, text string) error {
if strings.Contains(text, networkSeparator) {
network, err := netip.ParsePrefix(text)
if err != nil {
return err
}
builder.AddPrefix(network)
return nil
}
addr, err := netip.ParseAddr(text)
if err != nil {
return err
}
builder.Add(addr)
return nil
}
// addSpamhausNetwork adds the network on a line of a Spamhaus file to
// builder. The metadata line adds nothing.
func addSpamhausNetwork(builder *netipx.IPSetBuilder, line string) error {
var entry struct {
CIDR string `json:"cidr"`
Type string `json:"type"`
}
err := json.Unmarshal([]byte(line), &entry)
if err != nil {
return err
}
if entry.Type == spamhausMetadataType {
return nil
}
network, err := netip.ParsePrefix(entry.CIDR)
if err != nil {
return err
}
builder.AddPrefix(network)
return nil
}
// addDShieldNetwork adds the network on a line of a DShield file to builder.
// The line's first two fields are the network's first and last address.
func addDShieldNetwork(builder *netipx.IPSetBuilder, line string) error {
firstField, rest, found := strings.Cut(line, dshieldFieldSeparator)
if !found {
return errNoNetwork
}
lastField, _, _ := strings.Cut(rest, dshieldFieldSeparator)
first, err := netip.ParseAddr(firstField)
if err != nil {
return err
}
last, err := netip.ParseAddr(lastField)
if err != nil {
return err
}
network := netipx.IPRangeFrom(first, last)
if !network.IsValid() {
return errBadRange
}
builder.AddRange(network)
return nil
}