Files
3x-ui/internal/xray/geodata/reader.go
T
Grigoriy d7698ec7aa feat(xray): browse geosite/geoip categories from routing rules (#6165)
* feat(xray): browse geosite/geoip categories from routing rules

Routing rules made you type category names from memory: nothing showed which
categories a database actually contains, what is inside one, or whether a
name resolves at all — a typo only surfaced when Xray refused the config.

The panel now reads Xray's .dat databases itself and exposes them over four
endpoints: databases in the asset folder, a database's categories, one page
of a category's rules, and validation of the tokens already in a rule. The
reader walks the protobuf wire format directly rather than decoding into Go
structs, because a 10 MB geosite.dat holds well over a million domains and
materialising them costs ~284 MB where streaming costs ~19 MB. Only the
category index is cached, entry pages are scanned on demand, and scans are
serialised, so twenty concurrent requests peak at 87 MB instead of 1 GB.
A database's type is decided by its contents, not its file name, since
custom .dat files are named freely.

In the rule form, the source-IP, IP and domain fields gain a database button
opening the browser: search over categories, a preview of what a category
holds, and a multi-select that merges into the field. Plain domains, CIDRs
and categories the panel does not know are left untouched; categories already
present come back ticked, and unticking one removes it from the rule.

* fix(xray): read geo databases through os.Root and match codes verbatim

CodeQL flagged the database read as a path built from a user-supplied value,
and it was right about the shape of it. The file name arrives in a request;
resolve() rejects traversal and stats the file through an os.Root, but the
read itself went through a joined path with os.ReadFile. That left the
symlink defence incomplete: the stat could pass while the read followed a
link planted — or swapped in — afterwards.

Reads now go through the same root, so a request-supplied name never becomes
a path this code resolves on its own, and the size limit is applied to the
opened file rather than to a separate stat of it.

Lookup no longer trims the category code either. It backs the routing-token
validator, and the core matches codes verbatim: "geosite: cn" will not start
Xray, so repairing that space here hid exactly the typo the validator exists
to report.

* fix(xray): address review findings on the geo category browser

Asset folder. The browser read config.GetBinFolderPath() unconditionally,
but the core honours a preset XRAY_LOCATION_ASSET and only falls back to the
bin folder (ensureXrayAssetLocation). On an install pointing at a shared
asset directory the panel listed an empty folder and reported perfectly
valid geosite:/geoip: tokens as missing — the validator warning about a
correct config. The directory is now resolved with the core's precedence.

Paging. Serving one page read and rescanned the whole database, so walking
category-ads-all re-read it per page. The index now records each category's
byte range and a page reads only that record through the os.Root handle,
with the current category's records held for the duration of a paging
session. Profiling that also showed the real cost was not the read but the
slice of payload pointers built per call — a category holds a hundred
thousand of them — so records are now walked with a callback instead.
Ten pages over category-ads-all: 239 MB allocated, now 4.3 MB.

Cached failures. Any error from reading a file was latched under the file's
size+mtime, so a transient ENOMEM or EMFILE marked a healthy database as
damaged until it changed on disk. Only deterministic failures are cached.

Wrong kind. A geoip: token typed into a domain field parsed as a plain
domain and was waved through, though the core cannot resolve it as one. It
is now reported, with its own reason and wording.

Frontend. The category filter fed the query key on every keystroke, so each
character triggered a request that re-scanned the database; it is debounced
now. GeoTokenInput accepts and forwards a ref, so React Hook Form can focus
these three fields on a validation error again. A failed validation shows
that it failed instead of rendering the same empty state as "no issues".

Also drops an unreachable branch in the token-count guard and corrects the
categories endpoint docs, where limit is unbounded by default.

---------

Co-authored-by: STRENCH0 <17428017+STRENCH0@users.noreply.github.com>
2026-08-15 17:12:59 +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
}