mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-16 00:01:02 +00:00
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>
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
package geodata
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
xraygeodata "github.com/xtls/xray-core/common/geodata"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
func writeSiteDB(t *testing.T, dir, name string, sites ...*xraygeodata.GeoSite) string {
|
||||
t.Helper()
|
||||
data, err := proto.Marshal(&xraygeodata.GeoSiteList{Entry: sites})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal geosite list: %v", err)
|
||||
}
|
||||
return writeFile(t, dir, name, data)
|
||||
}
|
||||
|
||||
func writeIPDB(t *testing.T, dir, name string, geoips ...*xraygeodata.GeoIP) string {
|
||||
t.Helper()
|
||||
data, err := proto.Marshal(&xraygeodata.GeoIPList{Entry: geoips})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal geoip list: %v", err)
|
||||
}
|
||||
return writeFile(t, dir, name, data)
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, dir, name string, data []byte) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", name, err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func site(code string, domains ...*xraygeodata.Domain) *xraygeodata.GeoSite {
|
||||
return &xraygeodata.GeoSite{Code: code, Domain: domains}
|
||||
}
|
||||
|
||||
func domain(domainType xraygeodata.Domain_Type, value string, attributes ...string) *xraygeodata.Domain {
|
||||
d := &xraygeodata.Domain{Type: domainType, Value: value}
|
||||
for _, attribute := range attributes {
|
||||
d.Attribute = append(d.Attribute, &xraygeodata.Domain_Attribute{
|
||||
Key: attribute,
|
||||
TypedValue: &xraygeodata.Domain_Attribute_BoolValue{BoolValue: true},
|
||||
})
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func geoip(code string, prefixes ...string) *xraygeodata.GeoIP {
|
||||
entry := &xraygeodata.GeoIP{Code: code}
|
||||
for _, raw := range prefixes {
|
||||
prefix := netip.MustParsePrefix(raw)
|
||||
entry.Cidr = append(entry.Cidr, &xraygeodata.CIDR{
|
||||
Ip: prefix.Addr().AsSlice(),
|
||||
Prefix: uint32(prefix.Bits()),
|
||||
})
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
func sampleSiteDB(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
writeSiteDB(t, dir, "geosite.dat",
|
||||
site("google",
|
||||
domain(xraygeodata.Domain_Domain, "google.com"),
|
||||
domain(xraygeodata.Domain_Full, "ads.google.com", "ads"),
|
||||
domain(xraygeodata.Domain_Substr, "googlevideo", "cn"),
|
||||
domain(xraygeodata.Domain_Regex, `^g.*\.cn$`),
|
||||
),
|
||||
site("CN",
|
||||
domain(xraygeodata.Domain_Domain, "baidu.com"),
|
||||
domain(xraygeodata.Domain_Domain, "qq.com"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func TestListFilesReportsKindAndCategories(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sampleSiteDB(t, dir)
|
||||
writeIPDB(t, dir, "geoip.dat", geoip("cn", "1.0.1.0/24"), geoip("private", "10.0.0.0/8", "fc00::/7"))
|
||||
|
||||
files, err := NewStore(dir).ListFiles()
|
||||
if err != nil {
|
||||
t.Fatalf("ListFiles() error = %v", err)
|
||||
}
|
||||
if len(files) != 2 {
|
||||
t.Fatalf("ListFiles() returned %d files, want 2", len(files))
|
||||
}
|
||||
|
||||
byName := make(map[string]GeoFile, len(files))
|
||||
for _, file := range files {
|
||||
byName[file.Name] = file
|
||||
}
|
||||
|
||||
geosite := byName["geosite.dat"]
|
||||
if geosite.Kind != KindSite {
|
||||
t.Errorf("geosite.dat kind = %q, want %q", geosite.Kind, KindSite)
|
||||
}
|
||||
if geosite.Categories != 2 {
|
||||
t.Errorf("geosite.dat categories = %d, want 2", geosite.Categories)
|
||||
}
|
||||
if geosite.Error != "" {
|
||||
t.Errorf("geosite.dat error = %q, want empty", geosite.Error)
|
||||
}
|
||||
|
||||
geoipFile := byName["geoip.dat"]
|
||||
if geoipFile.Kind != KindIP {
|
||||
t.Errorf("geoip.dat kind = %q, want %q", geoipFile.Kind, KindIP)
|
||||
}
|
||||
if geoipFile.Categories != 2 {
|
||||
t.Errorf("geoip.dat categories = %d, want 2", geoipFile.Categories)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKindDetectedFromContentsNotName(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeSiteDB(t, dir, "my_ip_rules.dat", site("corp", domain(xraygeodata.Domain_Domain, "intranet.corp.local")))
|
||||
writeIPDB(t, dir, "custom_sites.dat", geoip("office", "192.168.7.0/24"))
|
||||
|
||||
store := NewStore(dir)
|
||||
|
||||
sitePage, err := store.Categories("my_ip_rules.dat", "", 0, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("Categories(my_ip_rules.dat) error = %v", err)
|
||||
}
|
||||
if sitePage.Total != 1 || sitePage.Items[0].Code != "corp" {
|
||||
t.Fatalf("Categories(my_ip_rules.dat) = %+v, want single category corp", sitePage)
|
||||
}
|
||||
|
||||
entries, err := store.Entries("custom_sites.dat", "office", "", 0, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("Entries(custom_sites.dat) error = %v", err)
|
||||
}
|
||||
if len(entries.Items) != 1 {
|
||||
t.Fatalf("Entries(custom_sites.dat) returned %d items, want 1", len(entries.Items))
|
||||
}
|
||||
if got := entries.Items[0]; got.Kind != "cidr" || got.Value != "192.168.7.0/24" {
|
||||
t.Errorf("entry = %+v, want cidr 192.168.7.0/24", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntriesMapDomainTypesAndAttributes(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sampleSiteDB(t, dir)
|
||||
store := NewStore(dir)
|
||||
|
||||
page, err := store.Entries("geosite.dat", "google", "", 0, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("Entries() error = %v", err)
|
||||
}
|
||||
want := []GeoEntry{
|
||||
{Kind: "domain", Value: "google.com"},
|
||||
{Kind: "full", Value: "ads.google.com"},
|
||||
{Kind: "keyword", Value: "googlevideo"},
|
||||
{Kind: "regexp", Value: `^g.*\.cn$`},
|
||||
}
|
||||
if page.Total != len(want) {
|
||||
t.Fatalf("Entries() total = %d, want %d", page.Total, len(want))
|
||||
}
|
||||
for i, entry := range want {
|
||||
if page.Items[i] != entry {
|
||||
t.Errorf("entry %d = %+v, want %+v", i, page.Items[i], entry)
|
||||
}
|
||||
}
|
||||
|
||||
category, err := store.Lookup("geosite.dat", "google")
|
||||
if err != nil {
|
||||
t.Fatalf("Lookup() error = %v", err)
|
||||
}
|
||||
if len(category.Attributes) != 2 || category.Attributes[0] != "ads" || category.Attributes[1] != "cn" {
|
||||
t.Errorf("attributes = %v, want [ads cn]", category.Attributes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCategoriesWithoutAttributesMarshalAsEmptyArray(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sampleSiteDB(t, dir)
|
||||
|
||||
page, err := NewStore(dir).Categories("geosite.dat", "cn", 0, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("Categories() error = %v", err)
|
||||
}
|
||||
if page.Items[0].Attributes == nil {
|
||||
t.Fatal("attributes are nil, want an empty slice so the JSON stays an array")
|
||||
}
|
||||
encoded, err := json.Marshal(page.Items[0])
|
||||
if err != nil {
|
||||
t.Fatalf("marshal category: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(encoded), `"attributes":[]`) {
|
||||
t.Errorf("encoded category = %s, want an empty attributes array", encoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCategoryCodesAreLowercasedAndSorted(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sampleSiteDB(t, dir)
|
||||
|
||||
page, err := NewStore(dir).Categories("geosite.dat", "", 0, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("Categories() error = %v", err)
|
||||
}
|
||||
if page.Items[0].Code != "cn" || page.Items[1].Code != "google" {
|
||||
t.Errorf("codes = %q, %q; want cn, google", page.Items[0].Code, page.Items[1].Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchFilters(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sampleSiteDB(t, dir)
|
||||
store := NewStore(dir)
|
||||
|
||||
categories, err := store.Categories("geosite.dat", "OOG", 0, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("Categories() error = %v", err)
|
||||
}
|
||||
if categories.Total != 1 || categories.Items[0].Code != "google" {
|
||||
t.Errorf("Categories(OOG) = %+v, want only google", categories)
|
||||
}
|
||||
|
||||
entries, err := store.Entries("geosite.dat", "google", "ADS.", 0, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("Entries() error = %v", err)
|
||||
}
|
||||
if entries.Total != 1 || entries.Items[0].Value != "ads.google.com" {
|
||||
t.Errorf("Entries(ADS.) = %+v, want only ads.google.com", entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPagination(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
domains := make([]*xraygeodata.Domain, 0, 250)
|
||||
for i := range 250 {
|
||||
domains = append(domains, domain(xraygeodata.Domain_Domain, "host"+strconv.Itoa(i)+".example.com"))
|
||||
}
|
||||
writeSiteDB(t, dir, "geosite.dat", site("bulk", domains...))
|
||||
store := NewStore(dir)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
offset int
|
||||
limit int
|
||||
wantCount int
|
||||
wantFirst string
|
||||
}{
|
||||
{name: "first page", offset: 0, limit: 10, wantCount: 10, wantFirst: "host0.example.com"},
|
||||
{name: "middle page", offset: 20, limit: 5, wantCount: 5, wantFirst: "host20.example.com"},
|
||||
{name: "negative offset clamps to start", offset: -5, limit: 3, wantCount: 3, wantFirst: "host0.example.com"},
|
||||
{name: "tail shorter than limit", offset: 245, limit: 50, wantCount: 5, wantFirst: "host245.example.com"},
|
||||
{name: "offset past end", offset: 900, limit: 10, wantCount: 0},
|
||||
{name: "limit above cap", offset: 0, limit: 5000, wantCount: 250, wantFirst: "host0.example.com"},
|
||||
{name: "zero limit uses cap", offset: 0, limit: 0, wantCount: 250, wantFirst: "host0.example.com"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
page, err := store.Entries("geosite.dat", "bulk", "", tt.offset, tt.limit)
|
||||
if err != nil {
|
||||
t.Fatalf("Entries() error = %v", err)
|
||||
}
|
||||
if page.Total != 250 {
|
||||
t.Errorf("total = %d, want 250", page.Total)
|
||||
}
|
||||
if len(page.Items) != tt.wantCount {
|
||||
t.Fatalf("items = %d, want %d", len(page.Items), tt.wantCount)
|
||||
}
|
||||
if tt.wantFirst != "" && page.Items[0].Value != tt.wantFirst {
|
||||
t.Errorf("first item = %q, want %q", page.Items[0].Value, tt.wantFirst)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCategoriesReturnEverythingWithoutLimit(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sites := make([]*xraygeodata.GeoSite, 0, MaxPageSize+20)
|
||||
for i := range MaxPageSize + 20 {
|
||||
sites = append(sites, site("cat"+strconv.Itoa(i), domain(xraygeodata.Domain_Domain, "example.com")))
|
||||
}
|
||||
writeSiteDB(t, dir, "geosite.dat", sites...)
|
||||
store := NewStore(dir)
|
||||
|
||||
all, err := store.Categories("geosite.dat", "", 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Categories() error = %v", err)
|
||||
}
|
||||
if len(all.Items) != MaxPageSize+20 {
|
||||
t.Errorf("items without a limit = %d, want %d", len(all.Items), MaxPageSize+20)
|
||||
}
|
||||
|
||||
capped, err := store.Categories("geosite.dat", "", 0, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("Categories() error = %v", err)
|
||||
}
|
||||
if len(capped.Items) != 10 || capped.Total != MaxPageSize+20 {
|
||||
t.Errorf("explicit limit gave %d items with total %d, want 10 and %d", len(capped.Items), capped.Total, MaxPageSize+20)
|
||||
}
|
||||
|
||||
entries, err := store.Entries("geosite.dat", "cat0", "", 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Entries() error = %v", err)
|
||||
}
|
||||
if len(entries.Items) != 1 {
|
||||
t.Errorf("entries = %d, want 1", len(entries.Items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrors(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sampleSiteDB(t, dir)
|
||||
writeFile(t, dir, "broken.dat", []byte("this is not a protobuf message at all"))
|
||||
store := NewStore(dir)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
call func() error
|
||||
want error
|
||||
}{
|
||||
{
|
||||
name: "unknown category",
|
||||
call: func() error { _, err := store.Entries("geosite.dat", "nope", "", 0, 10); return err },
|
||||
want: ErrUnknownCategory,
|
||||
},
|
||||
{
|
||||
name: "lookup of unknown category",
|
||||
call: func() error { _, err := store.Lookup("geosite.dat", "nope"); return err },
|
||||
want: ErrUnknownCategory,
|
||||
},
|
||||
{
|
||||
name: "path traversal",
|
||||
call: func() error { _, err := store.Categories("../geosite.dat", "", 0, 10); return err },
|
||||
want: ErrInvalidName,
|
||||
},
|
||||
{
|
||||
name: "non dat extension",
|
||||
call: func() error { _, err := store.Categories("x-ui.db", "", 0, 10); return err },
|
||||
want: ErrInvalidName,
|
||||
},
|
||||
{
|
||||
name: "empty name",
|
||||
call: func() error { _, err := store.Categories("", "", 0, 10); return err },
|
||||
want: ErrInvalidName,
|
||||
},
|
||||
{
|
||||
name: "unparsable file",
|
||||
call: func() error { _, err := store.Categories("broken.dat", "", 0, 10); return err },
|
||||
want: ErrUnrecognized,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if err := tt.call(); !errors.Is(err, tt.want) {
|
||||
t.Errorf("error = %v, want %v", err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrokenFileIsListedWithReason(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeFile(t, dir, "broken.dat", []byte("not a database"))
|
||||
|
||||
files, err := NewStore(dir).ListFiles()
|
||||
if err != nil {
|
||||
t.Fatalf("ListFiles() error = %v", err)
|
||||
}
|
||||
if len(files) != 1 {
|
||||
t.Fatalf("ListFiles() returned %d files, want 1", len(files))
|
||||
}
|
||||
if !strings.HasPrefix(files[0].Error, ErrUnrecognized.Error()) {
|
||||
t.Errorf("error = %q, want it to start with %q", files[0].Error, ErrUnrecognized.Error())
|
||||
}
|
||||
if files[0].Kind != "" {
|
||||
t.Errorf("kind = %q, want empty", files[0].Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileAboveSizeLimitIsRejected(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := writeFile(t, dir, "huge.dat", []byte("x"))
|
||||
if err := os.Truncate(path, MaxFileSize+1); err != nil {
|
||||
t.Fatalf("truncate: %v", err)
|
||||
}
|
||||
|
||||
store := NewStore(dir)
|
||||
if _, err := store.Categories("huge.dat", "", 0, 10); !errors.Is(err, ErrFileTooLarge) {
|
||||
t.Errorf("error = %v, want %v", err, ErrFileTooLarge)
|
||||
}
|
||||
|
||||
files, err := store.ListFiles()
|
||||
if err != nil {
|
||||
t.Fatalf("ListFiles() error = %v", err)
|
||||
}
|
||||
if len(files) != 1 || files[0].Error != ErrFileTooLarge.Error() {
|
||||
t.Errorf("ListFiles() = %+v, want the file listed with a too-large error", files)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndexCacheInvalidatedWhenFileChanges(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sampleSiteDB(t, dir)
|
||||
store := NewStore(dir)
|
||||
|
||||
before, err := store.Categories("geosite.dat", "", 0, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("Categories() error = %v", err)
|
||||
}
|
||||
if before.Total != 2 {
|
||||
t.Fatalf("total before rewrite = %d, want 2", before.Total)
|
||||
}
|
||||
|
||||
path := writeSiteDB(t, dir, "geosite.dat",
|
||||
site("google", domain(xraygeodata.Domain_Domain, "google.com")),
|
||||
site("cn", domain(xraygeodata.Domain_Domain, "baidu.com")),
|
||||
site("telegram", domain(xraygeodata.Domain_Domain, "t.me")),
|
||||
)
|
||||
touch(t, path, time.Now().Add(time.Second))
|
||||
|
||||
after, err := store.Categories("geosite.dat", "", 0, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("Categories() after rewrite error = %v", err)
|
||||
}
|
||||
if after.Total != 3 {
|
||||
t.Errorf("total after rewrite = %d, want 3", after.Total)
|
||||
}
|
||||
if len(store.indexes) != 1 {
|
||||
t.Errorf("cached indexes = %d, want 1 after the stale entry is dropped", len(store.indexes))
|
||||
}
|
||||
}
|
||||
|
||||
func touch(t *testing.T, path string, when time.Time) {
|
||||
t.Helper()
|
||||
if err := os.Chtimes(path, when, when); err != nil {
|
||||
t.Fatalf("chtimes %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultRouteCIDRSurvives(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeIPDB(t, dir, "geoip.dat", geoip("any", "0.0.0.0/0", "::/0"), geoip("cn", "1.0.1.0/24"))
|
||||
|
||||
page, err := NewStore(dir).Entries("geoip.dat", "any", "", 0, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("Entries() error = %v", err)
|
||||
}
|
||||
if page.Total != 2 {
|
||||
t.Fatalf("total = %d, want 2 — a zero prefix is omitted by proto3 and must not be dropped", page.Total)
|
||||
}
|
||||
if page.Items[0].Value != "0.0.0.0/0" || page.Items[1].Value != "::/0" {
|
||||
t.Errorf("items = %+v, want the two default routes", page.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrokenFileIsParsedOnlyOnce(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeFile(t, dir, "broken.dat", []byte("not a database"))
|
||||
store := NewStore(dir)
|
||||
|
||||
for range 3 {
|
||||
if _, err := store.Categories("broken.dat", "", 0, 10); !errors.Is(err, ErrUnrecognized) {
|
||||
t.Fatalf("error = %v, want %v", err, ErrUnrecognized)
|
||||
}
|
||||
}
|
||||
if len(store.indexes) != 1 {
|
||||
t.Errorf("cached indexes = %d, want the failure cached once", len(store.indexes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentReadsAreConsistent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sampleSiteDB(t, dir)
|
||||
writeIPDB(t, dir, "geoip.dat", geoip("private", "10.0.0.0/8"))
|
||||
store := NewStore(dir)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := range 24 {
|
||||
wg.Add(1)
|
||||
go func(worker int) {
|
||||
defer wg.Done()
|
||||
switch worker % 3 {
|
||||
case 0:
|
||||
page, err := store.Categories("geosite.dat", "", 0, 0)
|
||||
if err != nil || page.Total != 2 {
|
||||
t.Errorf("Categories() = %+v, err = %v; want 2 categories", page, err)
|
||||
}
|
||||
case 1:
|
||||
page, err := store.Entries("geosite.dat", "google", "", 0, 10)
|
||||
if err != nil || page.Total != 4 {
|
||||
t.Errorf("Entries() = %+v, err = %v; want 4 entries", page, err)
|
||||
}
|
||||
default:
|
||||
files, err := store.ListFiles()
|
||||
if err != nil || len(files) != 2 {
|
||||
t.Errorf("ListFiles() = %d files, err = %v; want 2 files", len(files), err)
|
||||
}
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestLookupDoesNotForgiveStraySpaces(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sampleSiteDB(t, dir)
|
||||
store := NewStore(dir)
|
||||
|
||||
if _, err := store.Lookup("geosite.dat", "google"); err != nil {
|
||||
t.Fatalf("Lookup(google) error = %v", err)
|
||||
}
|
||||
for _, code := range []string{" google", "google ", "goo gle"} {
|
||||
if _, err := store.Lookup("geosite.dat", code); !errors.Is(err, ErrUnknownCategory) {
|
||||
t.Errorf("Lookup(%q) error = %v, want %v — the core does not trim either", code, err, ErrUnknownCategory)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSymlinkOutOfTheAssetFolderIsRefused(t *testing.T) {
|
||||
outside := t.TempDir()
|
||||
secret := filepath.Join(outside, "secret.dat")
|
||||
if err := os.WriteFile(secret, []byte("not yours"), 0o644); err != nil {
|
||||
t.Fatalf("write secret: %v", err)
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
sampleSiteDB(t, dir)
|
||||
if err := os.Symlink(secret, filepath.Join(dir, "escape.dat")); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
|
||||
store := NewStore(dir)
|
||||
if _, err := store.Categories("escape.dat", "", 0, 10); err == nil {
|
||||
t.Error("Categories() read through a symlink pointing outside the asset folder")
|
||||
}
|
||||
if _, err := store.Entries("escape.dat", "google", "", 0, 10); err == nil {
|
||||
t.Error("Entries() read through a symlink pointing outside the asset folder")
|
||||
}
|
||||
|
||||
files, err := store.ListFiles()
|
||||
if err != nil {
|
||||
t.Fatalf("ListFiles() error = %v", err)
|
||||
}
|
||||
for _, file := range files {
|
||||
if file.Name == "escape.dat" && file.Error == "" {
|
||||
t.Error("ListFiles() reported an escaping symlink as a usable database")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package geodata
|
||||
|
||||
import "strings"
|
||||
|
||||
// Categories returns the database's categories, filtered by a case-insensitive
|
||||
// substring of the category code. A non-positive limit returns all of them:
|
||||
// the category index is small even for the largest databases, and the panel
|
||||
// filters it client-side so typing in the search box costs no requests.
|
||||
func (s *Store) Categories(name, query string, offset, limit int) (GeoCategoryPage, error) {
|
||||
idx, err := s.index(name)
|
||||
if err != nil {
|
||||
return GeoCategoryPage{}, err
|
||||
}
|
||||
matched := idx.categories
|
||||
if query = strings.ToLower(strings.TrimSpace(query)); query != "" {
|
||||
matched = make([]GeoCategory, 0, len(idx.categories))
|
||||
for _, category := range idx.categories {
|
||||
if strings.Contains(category.Code, query) {
|
||||
matched = append(matched, category)
|
||||
}
|
||||
}
|
||||
}
|
||||
page := GeoCategoryPage{Total: len(matched), Items: []GeoCategory{}}
|
||||
from, to := categoryBounds(len(matched), offset, limit)
|
||||
page.Items = append(page.Items, matched[from:to]...)
|
||||
return page, nil
|
||||
}
|
||||
|
||||
// Entries returns one page of a category's rules, filtered by a
|
||||
// case-insensitive substring of the rule value. The page is scanned out of the
|
||||
// file on each call: a category such as geosite's category-ads-all holds well
|
||||
// over a hundred thousand rules, and holding those in memory to serve one
|
||||
// screenful of them is what the panel cannot afford.
|
||||
func (s *Store) Entries(name, code, query string, offset, limit int) (GeoEntryPage, error) {
|
||||
idx, err := s.index(name)
|
||||
if err != nil {
|
||||
return GeoEntryPage{}, err
|
||||
}
|
||||
code = strings.ToLower(strings.TrimSpace(code))
|
||||
if _, ok := idx.byCode[code]; !ok {
|
||||
return GeoEntryPage{}, ErrUnknownCategory
|
||||
}
|
||||
if _, err := s.resolve(name); err != nil {
|
||||
return GeoEntryPage{}, err
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
if limit <= 0 || limit > MaxPageSize {
|
||||
limit = MaxPageSize
|
||||
}
|
||||
|
||||
spans := idx.spans[code]
|
||||
if len(spans) == 0 {
|
||||
return GeoEntryPage{}, ErrUnknownCategory
|
||||
}
|
||||
|
||||
s.scan.Lock()
|
||||
defer s.scan.Unlock()
|
||||
records, err := s.recordsLocked(name, code, spans)
|
||||
if err != nil {
|
||||
return GeoEntryPage{}, err
|
||||
}
|
||||
return scanEntries(records, idx.kind, code, strings.ToLower(strings.TrimSpace(query)), offset, limit)
|
||||
}
|
||||
|
||||
// Lookup reports whether a category exists in the database, without paying for
|
||||
// the entry data. The code is matched verbatim apart from case: this backs the
|
||||
// routing-token validator, and the core does not forgive a stray space either,
|
||||
// so trimming one here would hide the very typo the validator exists to find.
|
||||
func (s *Store) Lookup(name, code string) (GeoCategory, error) {
|
||||
idx, err := s.index(name)
|
||||
if err != nil {
|
||||
return GeoCategory{}, err
|
||||
}
|
||||
category, ok := idx.byCode[strings.ToLower(code)]
|
||||
if !ok {
|
||||
return GeoCategory{}, ErrUnknownCategory
|
||||
}
|
||||
return category, nil
|
||||
}
|
||||
|
||||
func categoryBounds(total, offset, limit int) (int, int) {
|
||||
if limit <= 0 {
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
if offset > total {
|
||||
offset = total
|
||||
}
|
||||
return offset, total
|
||||
}
|
||||
return sliceBounds(total, offset, limit)
|
||||
}
|
||||
|
||||
func sliceBounds(total, offset, limit int) (int, int) {
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
if offset > total {
|
||||
offset = total
|
||||
}
|
||||
if limit <= 0 || limit > MaxPageSize {
|
||||
limit = MaxPageSize
|
||||
}
|
||||
to := offset + limit
|
||||
if to > total {
|
||||
to = total
|
||||
}
|
||||
return offset, to
|
||||
}
|
||||
|
||||
func (s *Store) recordsLocked(name, code string, spans []byteSpan) ([][]byte, error) {
|
||||
info, err := s.resolve(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key := fileKey{name: name, size: info.Size(), modTime: info.ModTime().UnixNano()}
|
||||
if s.hot.key == key && s.hot.code == code {
|
||||
return s.hot.records, nil
|
||||
}
|
||||
records, err := readSpans(s.dir, name, spans)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.hot = hotRecord{key: key, code: code, records: records}
|
||||
return records, nil
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package geodata
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
xraygeodata "github.com/xtls/xray-core/common/geodata"
|
||||
)
|
||||
|
||||
// DefaultSiteFile and DefaultIPFile are the databases the geosite: and geoip:
|
||||
// shorthands expand to.
|
||||
const (
|
||||
DefaultSiteFile = xraygeodata.DefaultGeoSiteDat
|
||||
DefaultIPFile = xraygeodata.DefaultGeoIPDat
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrInvalidToken reports a routing token that names a database but does not
|
||||
// resolve to a file and category.
|
||||
ErrInvalidToken = errors.New("invalid geodata routing token")
|
||||
// ErrWrongKind reports a token carrying the other rule kind's prefix, such
|
||||
// as geoip: typed into a domain field.
|
||||
ErrWrongKind = errors.New("geodata token belongs to the other rule kind")
|
||||
)
|
||||
|
||||
var (
|
||||
sitePrefixes = []string{"ext:", "ext-domain:", "ext-site:"}
|
||||
ipPrefixes = []string{"ext:", "ext-ip:"}
|
||||
)
|
||||
|
||||
// Reference is the database file and category a routing token points at.
|
||||
// An empty File means a plain domain or CIDR, which needs no database.
|
||||
type Reference struct {
|
||||
File string
|
||||
Code string
|
||||
Attributes []string
|
||||
Reverse bool
|
||||
}
|
||||
|
||||
// ParseReference resolves a routing token the way xray-core does, expanding the
|
||||
// geosite:/geoip: shorthands to their ext: form. The core's own parser is not
|
||||
// reusable here: it opens the database from disk as part of parsing, which
|
||||
// would both duplicate this package's cache and fail whenever the file is
|
||||
// merely absent — exactly the case the panel needs to report.
|
||||
func ParseReference(token string, kind GeoKind) (Reference, error) {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return Reference{}, ErrInvalidToken
|
||||
}
|
||||
|
||||
var reference Reference
|
||||
if kind == KindIP {
|
||||
// The core strips one "!" before the prefix and another before the code,
|
||||
// each flipping the match, so "!!geoip:cn" is an ordinary geoip:cn.
|
||||
token, reference.Reverse = cutNegation(token)
|
||||
}
|
||||
|
||||
shorthand, defaultFile, prefixes := "geosite:", DefaultSiteFile, sitePrefixes
|
||||
if kind == KindIP {
|
||||
shorthand, defaultFile, prefixes = "geoip:", DefaultIPFile, ipPrefixes
|
||||
}
|
||||
if rest, found := strings.CutPrefix(token, shorthand); found {
|
||||
token = "ext:" + defaultFile + ":" + rest
|
||||
}
|
||||
|
||||
// A geoip: token in a domain field (or the reverse) parses as a plain
|
||||
// domain and would be waved through, yet the core cannot resolve it as one.
|
||||
// The field knows its own kind, so say so instead.
|
||||
if strings.HasPrefix(token, otherShorthand(kind)) {
|
||||
return Reference{}, ErrWrongKind
|
||||
}
|
||||
|
||||
rest, matched := "", false
|
||||
for _, prefix := range prefixes {
|
||||
if trimmed, found := strings.CutPrefix(token, prefix); found {
|
||||
rest, matched = trimmed, true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
return Reference{}, nil
|
||||
}
|
||||
if rest == "" {
|
||||
return Reference{}, ErrInvalidToken
|
||||
}
|
||||
|
||||
file, code, found := strings.Cut(rest, ":")
|
||||
if !found || file == "" {
|
||||
return Reference{}, ErrInvalidToken
|
||||
}
|
||||
if kind == KindIP {
|
||||
var negated bool
|
||||
code, negated = cutNegation(code)
|
||||
reference.Reverse = reference.Reverse != negated
|
||||
}
|
||||
|
||||
reference.File = file
|
||||
// Attribute filters exist for domain rules only; in an ip rule the core
|
||||
// treats "@" as part of the category code and fails to resolve it, so the
|
||||
// panel must not quietly strip it either.
|
||||
// Whitespace inside the token is significant: the core matches the code and
|
||||
// the attributes verbatim, so "geosite: cn" and "geosite:cn@ ads" are its
|
||||
// problems to report, not ours to silently repair. Only the case is folded,
|
||||
// which the core does too.
|
||||
if kind == KindSite {
|
||||
parts := strings.Split(code, "@")
|
||||
code = parts[0]
|
||||
for _, attribute := range parts[1:] {
|
||||
// The core rejects an empty attribute outright ("geosite:cn@"),
|
||||
// so accepting it here would hide a config it will not start with.
|
||||
if attribute == "" {
|
||||
return Reference{}, ErrInvalidToken
|
||||
}
|
||||
reference.Attributes = append(reference.Attributes, strings.ToLower(attribute))
|
||||
}
|
||||
}
|
||||
reference.Code = strings.ToLower(code)
|
||||
if reference.Code == "" {
|
||||
return Reference{}, ErrInvalidToken
|
||||
}
|
||||
return reference, nil
|
||||
}
|
||||
|
||||
// cutNegation strips leading "!" markers, reporting whether an odd number of
|
||||
// them was present — the core folds a double negation back into a plain match.
|
||||
func cutNegation(value string) (string, bool) {
|
||||
negated := false
|
||||
for {
|
||||
rest, found := strings.CutPrefix(value, "!")
|
||||
if !found {
|
||||
return value, negated
|
||||
}
|
||||
value = rest
|
||||
negated = !negated
|
||||
}
|
||||
}
|
||||
|
||||
func otherShorthand(kind GeoKind) string {
|
||||
if kind == KindIP {
|
||||
return "geosite:"
|
||||
}
|
||||
return "geoip:"
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package geodata
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseReference(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
kind GeoKind
|
||||
want Reference
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "geosite shorthand",
|
||||
token: "geosite:google",
|
||||
kind: KindSite,
|
||||
want: Reference{File: "geosite.dat", Code: "google"},
|
||||
},
|
||||
{
|
||||
name: "geosite code is case insensitive",
|
||||
token: "geosite:GOOGLE",
|
||||
kind: KindSite,
|
||||
want: Reference{File: "geosite.dat", Code: "google"},
|
||||
},
|
||||
{
|
||||
name: "geosite with attribute",
|
||||
token: "geosite:google@ads",
|
||||
kind: KindSite,
|
||||
want: Reference{File: "geosite.dat", Code: "google", Attributes: []string{"ads"}},
|
||||
},
|
||||
{
|
||||
name: "geosite with several attributes",
|
||||
token: "geosite:google@ads@cn",
|
||||
kind: KindSite,
|
||||
want: Reference{File: "geosite.dat", Code: "google", Attributes: []string{"ads", "cn"}},
|
||||
},
|
||||
{
|
||||
name: "ext form",
|
||||
token: "ext:my_rules.dat:corp",
|
||||
kind: KindSite,
|
||||
want: Reference{File: "my_rules.dat", Code: "corp"},
|
||||
},
|
||||
{
|
||||
name: "ext-site form",
|
||||
token: "ext-site:my_rules.dat:corp",
|
||||
kind: KindSite,
|
||||
want: Reference{File: "my_rules.dat", Code: "corp"},
|
||||
},
|
||||
{
|
||||
name: "ext-domain form",
|
||||
token: "ext-domain:my_rules.dat:corp",
|
||||
kind: KindSite,
|
||||
want: Reference{File: "my_rules.dat", Code: "corp"},
|
||||
},
|
||||
{
|
||||
name: "surrounding spaces",
|
||||
token: " geosite:google ",
|
||||
kind: KindSite,
|
||||
want: Reference{File: "geosite.dat", Code: "google"},
|
||||
},
|
||||
{
|
||||
name: "plain domain needs no database",
|
||||
token: "google.com",
|
||||
kind: KindSite,
|
||||
want: Reference{},
|
||||
},
|
||||
{
|
||||
name: "domain keyword rule needs no database",
|
||||
token: "keyword:google",
|
||||
kind: KindSite,
|
||||
want: Reference{},
|
||||
},
|
||||
{
|
||||
name: "geoip shorthand",
|
||||
token: "geoip:private",
|
||||
kind: KindIP,
|
||||
want: Reference{File: "geoip.dat", Code: "private"},
|
||||
},
|
||||
{
|
||||
name: "geoip reverse before the prefix",
|
||||
token: "!geoip:cn",
|
||||
kind: KindIP,
|
||||
want: Reference{File: "geoip.dat", Code: "cn", Reverse: true},
|
||||
},
|
||||
{
|
||||
name: "geoip reverse before the code",
|
||||
token: "geoip:!cn",
|
||||
kind: KindIP,
|
||||
want: Reference{File: "geoip.dat", Code: "cn", Reverse: true},
|
||||
},
|
||||
{
|
||||
name: "ext-ip form",
|
||||
token: "ext-ip:my_ips.dat:office",
|
||||
kind: KindIP,
|
||||
want: Reference{File: "my_ips.dat", Code: "office"},
|
||||
},
|
||||
{
|
||||
name: "plain cidr needs no database",
|
||||
token: "10.0.0.0/8",
|
||||
kind: KindIP,
|
||||
want: Reference{},
|
||||
},
|
||||
{name: "empty token", token: " ", kind: KindSite, wantErr: ErrInvalidToken},
|
||||
{name: "ext without code", token: "ext:geosite.dat", kind: KindSite, wantErr: ErrInvalidToken},
|
||||
{name: "ext with empty code", token: "ext:geosite.dat:", kind: KindSite, wantErr: ErrInvalidToken},
|
||||
{name: "ext with empty file", token: "ext::google", kind: KindSite, wantErr: ErrInvalidToken},
|
||||
{name: "geosite without code", token: "geosite:", kind: KindSite, wantErr: ErrInvalidToken},
|
||||
{name: "bare ext prefix", token: "ext:", kind: KindSite, wantErr: ErrInvalidToken},
|
||||
{name: "geoip token in a domain field", token: "geoip:cn", kind: KindSite, wantErr: ErrWrongKind},
|
||||
{name: "geosite token in an ip field", token: "geosite:cn", kind: KindIP, wantErr: ErrWrongKind},
|
||||
{name: "empty attribute", token: "geosite:cn@", kind: KindSite, wantErr: ErrInvalidToken},
|
||||
{
|
||||
name: "a space inside the code stays part of the code",
|
||||
token: "geosite: cn",
|
||||
kind: KindSite,
|
||||
want: Reference{File: "geosite.dat", Code: " cn"},
|
||||
},
|
||||
{
|
||||
name: "a space inside an attribute stays part of the attribute",
|
||||
token: "geosite:cn@ ads",
|
||||
kind: KindSite,
|
||||
want: Reference{File: "geosite.dat", Code: "cn", Attributes: []string{" ads"}},
|
||||
},
|
||||
{name: "empty attribute between two others", token: "geosite:cn@@ads", kind: KindSite, wantErr: ErrInvalidToken},
|
||||
{
|
||||
name: "double negation folds back to a plain match",
|
||||
token: "!!geoip:cn",
|
||||
kind: KindIP,
|
||||
want: Reference{File: "geoip.dat", Code: "cn"},
|
||||
},
|
||||
{
|
||||
name: "negation on both sides of the prefix cancels out",
|
||||
token: "!geoip:!cn",
|
||||
kind: KindIP,
|
||||
want: Reference{File: "geoip.dat", Code: "cn"},
|
||||
},
|
||||
{name: "bare ext-ip prefix", token: "ext-ip:", kind: KindIP, wantErr: ErrInvalidToken},
|
||||
{
|
||||
name: "an attribute suffix is part of the code for ip rules",
|
||||
token: "geoip:cn@x",
|
||||
kind: KindIP,
|
||||
want: Reference{File: "geoip.dat", Code: "cn@x"},
|
||||
},
|
||||
{name: "attribute only", token: "geosite:@ads", kind: KindSite, wantErr: ErrInvalidToken},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := ParseReference(tt.token, tt.kind)
|
||||
if tt.wantErr != nil {
|
||||
if !errors.Is(err, tt.wantErr) {
|
||||
t.Fatalf("error = %v, want %v", err, tt.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got.File != tt.want.File || got.Code != tt.want.Code || got.Reverse != tt.want.Reverse {
|
||||
t.Errorf("reference = %+v, want %+v", got, tt.want)
|
||||
}
|
||||
if len(got.Attributes) != len(tt.want.Attributes) {
|
||||
t.Fatalf("attributes = %v, want %v", got.Attributes, tt.want.Attributes)
|
||||
}
|
||||
for i, attribute := range tt.want.Attributes {
|
||||
if got.Attributes[i] != attribute {
|
||||
t.Errorf("attribute %d = %q, want %q", i, got.Attributes[i], attribute)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user