package blocklist 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 }