Files
3x-ui-web-src/internal/xray/geodata/geodata.go
T
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

270 lines
8.2 KiB
Go

// Package geodata reads Xray's geosite/geoip .dat databases so the panel can
// browse their categories instead of asking the user to type category names
// from memory.
//
// The databases are protobuf, but decoding them into Go structs is what makes
// them expensive: a 10 MB geosite.dat holds well over a million domains, and
// materialising all of them costs hundreds of megabytes on a panel that often
// runs with 512 MB of RAM. The readers therefore walk the wire format directly
// and allocate only what the caller asked for — category counts for the index,
// one page of values for the browser.
package geodata
import (
"errors"
"os"
"path/filepath"
"strings"
"sync"
)
// MaxFileSize is the largest database the panel will parse. Community rule
// sets are far bigger than the official ones — russia-v2ray-rules-dat ships a
// 70 MB geosite — so the ceiling is set well above them; reading is streaming
// and serialised, so a scan costs about the file's own size once, not per
// request. The limit exists only to keep a stray huge file in the asset folder
// from taking the panel down with it.
const MaxFileSize int64 = 256 << 20
// MaxPageSize caps how many rows a single page may carry, independent of what
// the caller asks for.
const MaxPageSize = 500
var (
// ErrFileTooLarge reports a database above MaxFileSize.
ErrFileTooLarge = errors.New("geodata file is too large to browse")
// ErrInvalidName reports a file name that does not resolve to a .dat file
// directly inside the asset directory.
ErrInvalidName = errors.New("invalid geodata file name")
// ErrUnknownCategory reports a category code missing from the database.
ErrUnknownCategory = errors.New("unknown geodata category")
)
// GeoKind tells apart the two database layouts Xray ships.
type GeoKind string
const (
KindSite GeoKind = "site"
KindIP GeoKind = "ip"
)
// GeoFile describes one .dat database found in the asset directory.
type GeoFile struct {
Name string `json:"name" example:"geosite.dat"`
Kind GeoKind `json:"kind" example:"site"`
Size int64 `json:"size" example:"1467392"`
ModifiedAt int64 `json:"modifiedAt" example:"1769558400000"`
Categories int `json:"categories" example:"1043"`
Error string `json:"error,omitempty" example:""`
}
// GeoCategory is one code inside a database, such as geosite's "google".
type GeoCategory struct {
Code string `json:"code" example:"google"`
Entries int `json:"entries" example:"1284"`
Attributes []string `json:"attributes" example:"[\"ads\",\"cn\"]"`
}
// GeoEntry is a single rule inside a category: a domain rule for geosite
// databases, a CIDR for geoip ones.
type GeoEntry struct {
Kind string `json:"kind" example:"domain"`
Value string `json:"value" example:"google.com"`
}
// GeoCategoryPage is one page of categories plus the unpaged total.
type GeoCategoryPage struct {
Total int `json:"total" example:"1043"`
Items []GeoCategory `json:"items"`
}
// GeoEntryPage is one page of category entries plus the unpaged total.
type GeoEntryPage struct {
Total int `json:"total" example:"1284"`
Items []GeoEntry `json:"items"`
}
type fileKey struct {
name string
size int64
modTime int64
}
type index struct {
kind GeoKind
categories []GeoCategory
byCode map[string]GeoCategory
spans map[string][]byteSpan
err error
}
// Store reads databases from one asset directory. Only the category index is
// cached, for as long as the file on disk is unchanged; entry pages are scanned
// out of the file on demand, which keeps a browsing session's memory close to
// the size of the page being shown rather than the size of the database.
//
// Scans are serialised on purpose. Reading a database allocates on the order of
// its own size, so letting a page's parallel requests — or a scripted caller —
// scan several databases at once is what turns a browsable panel into an
// out-of-memory kill on a small VPS.
type Store struct {
dir string
mu sync.Mutex
indexes map[fileKey]*index
scan sync.Mutex
// hot holds the records of the category being paged through, so a browsing
// session reads them once instead of once per page. Only one category is
// kept: paging is the repeated operation, switching categories is not.
hot hotRecord
}
type hotRecord struct {
key fileKey
code string
records [][]byte
}
// NewStore returns a Store reading databases from dir.
func NewStore(dir string) *Store {
return &Store{dir: dir, indexes: make(map[fileKey]*index)}
}
// ListFiles reports every .dat database in the asset directory. A database that
// cannot be parsed is still listed, with the reason in GeoFile.Error, so the panel
// can show a broken download instead of hiding it.
func (s *Store) ListFiles() ([]GeoFile, error) {
dirEntries, err := os.ReadDir(s.dir)
if err != nil {
return nil, err
}
files := make([]GeoFile, 0, len(dirEntries))
for _, dirEntry := range dirEntries {
if dirEntry.IsDir() || !strings.HasSuffix(strings.ToLower(dirEntry.Name()), ".dat") {
continue
}
info, err := dirEntry.Info()
if err != nil {
continue
}
file := GeoFile{
Name: dirEntry.Name(),
Size: info.Size(),
ModifiedAt: info.ModTime().UnixMilli(),
}
idx, err := s.index(dirEntry.Name())
if err != nil {
file.Error = err.Error()
} else {
file.Kind = idx.kind
file.Categories = len(idx.categories)
}
files = append(files, file)
}
return files, nil
}
// resolve validates a client-supplied file name and stats it through an
// os.Root, so a symlink planted in the asset folder cannot be used to read a
// file from elsewhere on disk.
func (s *Store) resolve(name string) (os.FileInfo, error) {
if name == "" || name != filepath.Base(name) || !strings.HasSuffix(strings.ToLower(name), ".dat") {
return nil, ErrInvalidName
}
root, err := os.OpenRoot(s.dir)
if err != nil {
return nil, err
}
defer root.Close()
info, err := root.Stat(name)
if err != nil {
return nil, err
}
if !info.Mode().IsRegular() {
return nil, ErrInvalidName
}
if info.Size() > MaxFileSize {
return nil, ErrFileTooLarge
}
return info, nil
}
func (s *Store) index(name string) (*index, error) {
info, err := s.resolve(name)
if err != nil {
return nil, err
}
key := fileKey{name: name, size: info.Size(), modTime: info.ModTime().UnixNano()}
if cached, ok := s.cachedIndex(key); ok {
return cached, cached.err
}
s.scan.Lock()
defer s.scan.Unlock()
// Another request may have built this index while this one waited.
if cached, ok := s.cachedIndex(key); ok {
return cached, cached.err
}
idx := buildIndex(s.dir, name)
if idx.err != nil && !isPermanent(idx.err) {
// A transient read failure (out of memory on a large file, too many open
// files) must not latch: the file is fine and the next request should
// try again rather than see it greyed out until it changes on disk.
return nil, idx.err
}
s.mu.Lock()
s.indexes[key] = idx
s.dropStaleIndexesLocked(name, key)
s.mu.Unlock()
return idx, idx.err
}
// isPermanent reports whether an error will repeat for the same bytes, and is
// therefore worth caching instead of re-deriving on every request.
func isPermanent(err error) bool {
return errors.Is(err, ErrUnrecognized) || errors.Is(err, ErrInvalidName) || errors.Is(err, ErrFileTooLarge)
}
func (s *Store) cachedIndex(key fileKey) (*index, bool) {
s.mu.Lock()
defer s.mu.Unlock()
cached, ok := s.indexes[key]
return cached, ok
}
// buildIndex never fails outright: a database that cannot be read is cached as
// a failed index, so a broken download is reported without being re-parsed on
// every request.
func buildIndex(dir, name string) *index {
data, err := readDatabase(dir, name)
if err != nil {
return &index{err: err}
}
kind, scan, err := detectKind(data, name)
if err != nil {
return &index{err: err}
}
idx := &index{
kind: kind,
categories: scan.categories,
byCode: make(map[string]GeoCategory, len(scan.categories)),
spans: scan.spans,
}
for _, category := range scan.categories {
idx.byCode[category.Code] = category
}
return idx
}
func (s *Store) dropStaleIndexesLocked(name string, keep fileKey) {
for key := range s.indexes {
if key.name == name && key != keep {
delete(s.indexes, key)
}
}
}