Files
Sanaei f727d04f65
CI / go-test (push) Canceled after 0s
CI / postgres-durable-first (push) Canceled after 0s
CI / codegen (push) Canceled after 0s
CI / govulncheck (push) Canceled after 0s
CI / race (push) Canceled after 0s
CI / fuzz-smoke (push) Canceled after 0s
CI / golangci (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CodeQL Advanced / Analyze (go) (push) Canceled after 0s
CodeQL Advanced / Analyze (actions) (push) Canceled after 0s
CodeQL Advanced / Analyze (javascript-typescript) (push) Canceled after 0s
Docs CI / build (push) Canceled after 0s
Docs Deploy (GitHub Pages) / build (push) Canceled after 0s
Release 3X-UI / build (386) (push) Canceled after 0s
Release 3X-UI / build (amd64) (push) Canceled after 0s
Release 3X-UI / build (arm64) (push) Canceled after 0s
Release 3X-UI / build (armv5) (push) Canceled after 0s
Release 3X-UI / build (armv6) (push) Canceled after 0s
Release 3X-UI / build (armv7) (push) Canceled after 0s
Release 3X-UI / build (s390x) (push) Canceled after 0s
Release 3X-UI / Build for Windows (amd64) (push) Canceled after 0s
Deploy Smoke Tests / noninteractive-install (ubuntu-24.04-arm) (push) Canceled after 0s
Deploy Smoke Tests / noninteractive-install (ubuntu-latest) (push) Canceled after 0s
Deploy Smoke Tests / release-tag-install (ubuntu-24.04-arm) (push) Canceled after 0s
Deploy Smoke Tests / release-tag-install (ubuntu-latest) (push) Canceled after 0s
Docs Deploy (GitHub Pages) / deploy (push) Canceled after 0s
Release 3X-UI / Publish rolling dev release (push) Canceled after 0s
v3.7.0
2026-08-24 15:07:15 +02:00

488 lines
13 KiB
Go

package geodata
import (
"errors"
"fmt"
"io"
"net/netip"
"os"
"sort"
"strings"
"google.golang.org/protobuf/encoding/protowire"
)
// ErrUnrecognized reports a file that parses as neither database layout, which
// in practice means a truncated download or an unrelated file renamed to .dat.
var ErrUnrecognized = errors.New("file is not a geosite or geoip database")
const (
fieldListEntry = 1
fieldEntryCode = 1
fieldEntryPayload = 2
fieldDomainType = 1
fieldDomainValue = 2
fieldDomainAttr = 3
fieldAttrKey = 1
fieldCIDRAddress = 1
fieldCIDRPrefixLen = 2
)
const (
domainTypeSubstr = 0
domainTypeRegex = 1
domainTypeFull = 3
)
type categoryScan struct {
kind GeoKind
categories []GeoCategory
spans map[string][]byteSpan
usable int
}
// byteSpan locates one category's record inside the database file, so a page of
// its rules can be read without pulling the whole file into memory again.
type byteSpan struct {
offset int64
length int64
}
// scanIndex walks the database once and reports every category with its entry
// count and attribute keys, holding nothing else in memory.
func scanIndex(data []byte, kind GeoKind) (*categoryScan, error) {
scan := &categoryScan{kind: kind, spans: make(map[string][]byteSpan)}
byCode := make(map[string]int)
err := eachListEntry(data, func(entry []byte, span byteSpan) error {
count := 0
attributes := make(map[string]struct{})
code, err := walkEntry(entry, func(payload []byte) error {
if kind == KindSite {
value, attrs, err := domainValue(payload)
if err != nil {
return err
}
if len(value) == 0 {
return nil
}
for _, attr := range attrs {
attributes[attr] = struct{}{}
}
} else {
_, ok, err := cidrBytes(payload)
if err != nil {
return err
}
if !ok {
return nil
}
}
count++
return nil
})
if err != nil || code == "" {
return err
}
scan.usable += count
scan.spans[code] = append(scan.spans[code], span)
if position, seen := byCode[code]; seen {
scan.categories[position].Entries += count
scan.categories[position].Attributes = mergeAttributes(scan.categories[position].Attributes, attributes)
return nil
}
byCode[code] = len(scan.categories)
scan.categories = append(scan.categories, GeoCategory{
Code: code,
Entries: count,
Attributes: mergeAttributes(nil, attributes),
})
return nil
})
if err != nil {
return nil, err
}
sort.Slice(scan.categories, func(i, j int) bool { return scan.categories[i].Code < scan.categories[j].Code })
return scan, nil
}
// scanEntries walks the database once and materialises only the requested page
// of one category, so browsing a category with hundreds of thousands of rules
// costs no more than browsing a small one.
func scanEntries(records [][]byte, kind GeoKind, code, query string, offset, limit int) (GeoEntryPage, error) {
page := GeoEntryPage{Items: []GeoEntry{}}
matched := 0
for _, entry := range records {
// Values stay as raw bytes until a row is known to belong on the
// requested page: turning all 170k rules of a category into strings
// to serve one screenful is what made this expensive.
if _, err := walkEntry(entry, func(payload []byte) error {
var raw []byte
var ok bool
var err error
if kind == KindSite {
raw, _, err = domainValue(payload)
if err != nil {
return err
}
ok = len(raw) > 0
} else {
raw, ok, err = cidrBytes(payload)
if err != nil {
return err
}
}
if !ok {
return nil
}
if query != "" && !containsFold(raw, query) {
return nil
}
if matched >= offset && len(page.Items) < limit {
if kind == KindSite {
page.Items = append(page.Items, GeoEntry{Kind: domainKind(payload), Value: string(raw)})
} else {
page.Items = append(page.Items, GeoEntry{Kind: "cidr", Value: string(raw)})
}
}
matched++
return nil
}); err != nil {
return GeoEntryPage{}, err
}
}
page.Total = matched
return page, nil
}
// detectKind reports which layout the file uses. The two share a wire layout
// whose field types disagree, so decoding one as the other yields no usable
// values at all — the count of readable entries is what tells them apart. The
// file name only picks which layout to try first, so the common case scans once.
func detectKind(data []byte, name string) (GeoKind, *categoryScan, error) {
first, second := KindSite, KindIP
if strings.Contains(strings.ToLower(name), "ip") {
first, second = KindIP, KindSite
}
var firstErr error
for _, kind := range [...]GeoKind{first, second} {
scan, err := scanIndex(data, kind)
if err != nil {
if firstErr == nil {
firstErr = err
}
continue
}
if scan.usable > 0 {
return kind, scan, nil
}
}
if firstErr != nil {
// A truncated download is the common case here, and it reads very
// differently to the user than "this is not a geo database at all".
return "", nil, fmt.Errorf("%w: %w", ErrUnrecognized, firstErr)
}
return "", nil, ErrUnrecognized
}
// readSpans reads only the recorded slices of the file, so serving a page of a
// category costs its own record rather than the whole database. The handle is
// opened through an os.Root for the same reason readDatabase is.
func readSpans(dir, name string, spans []byteSpan) ([][]byte, error) {
root, err := os.OpenRoot(dir)
if err != nil {
return nil, err
}
defer root.Close()
file, err := root.Open(name)
if err != nil {
return nil, err
}
defer file.Close()
records := make([][]byte, 0, len(spans))
for _, span := range spans {
if span.length <= 0 || span.length > MaxFileSize {
return nil, ErrUnrecognized
}
record := make([]byte, span.length)
if _, err := file.ReadAt(record, span.offset); err != nil {
return nil, err
}
records = append(records, record)
}
return records, nil
}
// readDatabase reads one database through an os.Root rooted at the asset
// directory. Going through the root rather than a joined path means the file
// name — which arrives from an HTTP request — never becomes a path this code
// resolves itself: a symlink planted in the folder, or swapped in between the
// check and the read, cannot pull in a file from elsewhere on disk.
func readDatabase(dir, name string) ([]byte, error) {
root, err := os.OpenRoot(dir)
if err != nil {
return nil, err
}
defer root.Close()
file, err := root.Open(name)
if err != nil {
return nil, err
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return nil, err
}
if !info.Mode().IsRegular() {
return nil, ErrInvalidName
}
if info.Size() > MaxFileSize {
return nil, ErrFileTooLarge
}
return io.ReadAll(io.LimitReader(file, MaxFileSize))
}
func eachListEntry(data []byte, visit func(entry []byte, span byteSpan) error) error {
total := int64(len(data))
for len(data) > 0 {
consumedSoFar := total - int64(len(data))
number, wireType, consumed := protowire.ConsumeTag(data)
if consumed < 0 {
return protowire.ParseError(consumed)
}
data = data[consumed:]
if number == fieldListEntry && wireType == protowire.BytesType {
entry, size := protowire.ConsumeBytes(data)
if size < 0 {
return protowire.ParseError(size)
}
span := byteSpan{offset: consumedSoFar + int64(consumed) + int64(size) - int64(len(entry)), length: int64(len(entry))}
if err := visit(entry, span); err != nil {
return err
}
data = data[size:]
continue
}
size := protowire.ConsumeFieldValue(number, wireType, data)
if size < 0 {
return protowire.ParseError(size)
}
data = data[size:]
}
return nil
}
// walkEntry reports a record's category code and hands each rule to visit.
// The rules are not collected into a slice first: a single category can hold
// a hundred thousand of them, and that slice was the bulk of what serving one
// page allocated.
func walkEntry(entry []byte, visit func(payload []byte) error) (string, error) {
code := ""
for len(entry) > 0 {
number, wireType, consumed := protowire.ConsumeTag(entry)
if consumed < 0 {
return "", protowire.ParseError(consumed)
}
entry = entry[consumed:]
switch {
case number == fieldEntryCode && wireType == protowire.BytesType:
value, size := protowire.ConsumeBytes(entry)
if size < 0 {
return "", protowire.ParseError(size)
}
code = strings.ToLower(string(value))
entry = entry[size:]
case number == fieldEntryPayload && wireType == protowire.BytesType:
payload, size := protowire.ConsumeBytes(entry)
if size < 0 {
return "", protowire.ParseError(size)
}
if visit != nil {
if err := visit(payload); err != nil {
return "", err
}
}
entry = entry[size:]
default:
size := protowire.ConsumeFieldValue(number, wireType, entry)
if size < 0 {
return "", protowire.ParseError(size)
}
entry = entry[size:]
}
}
return code, nil
}
func containsFold(haystack []byte, needle string) bool {
return strings.Contains(strings.ToLower(string(haystack)), needle)
}
func domainValue(payload []byte) ([]byte, []string, error) {
var value []byte
var attributes []string
for len(payload) > 0 {
number, wireType, consumed := protowire.ConsumeTag(payload)
if consumed < 0 {
return nil, nil, protowire.ParseError(consumed)
}
payload = payload[consumed:]
switch {
case number == fieldDomainValue && wireType == protowire.BytesType:
raw, size := protowire.ConsumeBytes(payload)
if size < 0 {
return nil, nil, protowire.ParseError(size)
}
value = raw
payload = payload[size:]
case number == fieldDomainAttr && wireType == protowire.BytesType:
raw, size := protowire.ConsumeBytes(payload)
if size < 0 {
return nil, nil, protowire.ParseError(size)
}
if key := attributeKey(raw); key != "" {
attributes = append(attributes, key)
}
payload = payload[size:]
default:
size := protowire.ConsumeFieldValue(number, wireType, payload)
if size < 0 {
return nil, nil, protowire.ParseError(size)
}
payload = payload[size:]
}
}
return value, attributes, nil
}
func attributeKey(attribute []byte) string {
for len(attribute) > 0 {
number, wireType, consumed := protowire.ConsumeTag(attribute)
if consumed < 0 {
return ""
}
attribute = attribute[consumed:]
if number == fieldAttrKey && wireType == protowire.BytesType {
raw, size := protowire.ConsumeBytes(attribute)
if size < 0 {
return ""
}
return strings.ToLower(string(raw))
}
size := protowire.ConsumeFieldValue(number, wireType, attribute)
if size < 0 {
return ""
}
attribute = attribute[size:]
}
return ""
}
// domainKind maps a domain's match type. proto3 omits zero values, so a domain
// with no type field on the wire is a Substr (keyword) rule, not a domain one.
func domainKind(payload []byte) string {
matchType := uint64(domainTypeSubstr)
for len(payload) > 0 {
number, wireType, consumed := protowire.ConsumeTag(payload)
if consumed < 0 {
break
}
payload = payload[consumed:]
if number == fieldDomainType && wireType == protowire.VarintType {
raw, size := protowire.ConsumeVarint(payload)
if size < 0 {
break
}
matchType = raw
break
}
size := protowire.ConsumeFieldValue(number, wireType, payload)
if size < 0 {
break
}
payload = payload[size:]
}
switch matchType {
case domainTypeFull:
return "full"
case domainTypeRegex:
return "regexp"
case domainTypeSubstr:
return "keyword"
default:
return "domain"
}
}
// cidrBytes renders one CIDR. proto3 omits zero values, so a missing prefix
// field means /0 — a default route, which a hand-built ext: database may well
// contain — and must not be read as "no prefix given".
func cidrBytes(payload []byte) ([]byte, bool, error) {
var address []byte
prefix := uint64(0)
for len(payload) > 0 {
number, wireType, consumed := protowire.ConsumeTag(payload)
if consumed < 0 {
return nil, false, protowire.ParseError(consumed)
}
payload = payload[consumed:]
switch {
case number == fieldCIDRAddress && wireType == protowire.BytesType:
raw, size := protowire.ConsumeBytes(payload)
if size < 0 {
return nil, false, protowire.ParseError(size)
}
address = raw
payload = payload[size:]
case number == fieldCIDRPrefixLen && wireType == protowire.VarintType:
raw, size := protowire.ConsumeVarint(payload)
if size < 0 {
return nil, false, protowire.ParseError(size)
}
prefix = raw
payload = payload[size:]
default:
size := protowire.ConsumeFieldValue(number, wireType, payload)
if size < 0 {
return nil, false, protowire.ParseError(size)
}
payload = payload[size:]
}
}
addr, ok := netip.AddrFromSlice(address)
if !ok || prefix > uint64(addr.BitLen()) {
return nil, false, nil
}
return []byte(netip.PrefixFrom(addr, int(prefix)).String()), true, nil
}
// mergeAttributes always returns a non-nil slice: the JSON contract declares
// attributes as an array, and a nil slice would marshal to null and break
// clients validating against it.
func mergeAttributes(existing []string, attributes map[string]struct{}) []string {
if len(attributes) == 0 {
if existing == nil {
return []string{}
}
return existing
}
merged := make(map[string]struct{}, len(existing)+len(attributes))
for _, attribute := range existing {
merged[attribute] = struct{}{}
}
for attribute := range attributes {
merged[attribute] = struct{}{}
}
out := make([]string, 0, len(merged))
for attribute := range merged {
out = append(out, attribute)
}
sort.Strings(out)
return out
}