feat(xray): autocomplete geosite/geoip categories in the routing rule editor

Writing a routing rule today means remembering exact geosite:/geoip:/
ext:file:code syntax by hand, with no way to discover what categories
actually exist in the .dat files sitting in the bin folder -- including
custom ones like geosite_roscom.dat added via the Geodata auto-update
feature. The Domain/IP fields in the rule editor now suggest categories
as you type (e.g. "you" -> "geosite:youtube"), built live from whatever
.dat files are actually on disk, while still accepting any free-typed
value exactly as before.

Backend: GET /panel/api/xray/getGeodataCategories scans the bin folder,
parses matched geosite*/geoip*.dat files via xray-core's own exported
protobuf types, and formats each category as the exact rule syntax
xray-core's parser accepts -- geosite:/geoip: for the default files,
ext:<file>:<code> for anything else (there's no shorthand for custom
files). Cached in memory keyed by each file's (name, size, modTime) so
a request-time scan is cheap until a file actually changes.

Frontend: the Domain/IP inputs become Select "tags" fields fed by a new
useGeodataCategories() query hook, with an explicit substring filter so
"you" matches "geosite:youtube" (not a prefix). The array<->CSV-string
adapter lives entirely at the FormField transform boundary, so the
underlying form schema and saved rule shape are unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kuzz007
2026-07-26 17:13:07 +03:00
parent c282c17b5a
commit 2736f9beb3
13 changed files with 692 additions and 2 deletions
+10
View File
@@ -41,6 +41,7 @@ func (a *XraySettingController) initRouter(g *gin.RouterGroup) {
g.GET("/getDefaultJsonConfig", a.getDefaultXrayConfig)
g.GET("/getOutboundsTraffic", a.getOutboundsTraffic)
g.GET("/getXrayResult", a.getXrayResult)
g.GET("/getGeodataCategories", a.getGeodataCategories)
g.POST("/", a.getXraySetting)
g.POST("/warp/:action", a.warp)
@@ -168,6 +169,15 @@ func (a *XraySettingController) getXrayResult(c *gin.Context) {
jsonObj(c, a.XrayService.GetXrayResult(), nil)
}
// getGeodataCategories returns every geosite/geoip category found in the
// .dat files currently present in the Xray bin folder (including any custom
// files the admin added via the Geodata auto-update feature), formatted as
// ready-to-use routing-rule suggestion strings for the routing rule editor's
// Domain/IP autocomplete.
func (a *XraySettingController) getGeodataCategories(c *gin.Context) {
jsonObj(c, a.XraySettingService.GetGeodataCategories(), nil)
}
// warp handles Warp-related operations based on the action parameter.
func (a *XraySettingController) warp(c *gin.Context) {
action := c.Param("action")
@@ -0,0 +1,64 @@
package controller
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/xtls/xray-core/common/geodata"
"google.golang.org/protobuf/proto"
)
// TestGetGeodataCategoriesEndpoint exercises the real HTTP route (gin
// routing, controller wiring, JSON envelope) rather than just the
// underlying service function -- a genuine end-to-end check of
// GET /panel/api/xray/getGeodataCategories given no DB is available in
// this environment (go-sqlite3 needs cgo) to run the full panel binary.
func TestGetGeodataCategoriesEndpoint(t *testing.T) {
dir := t.TempDir()
t.Setenv("XUI_BIN_FOLDER", dir)
writeFixture(t, filepath.Join(dir, "geosite.dat"), &geodata.GeoSiteList{
Entry: []*geodata.GeoSite{{Code: "YOUTUBE"}},
})
writeFixture(t, filepath.Join(dir, "geosite_roscom.dat"), &geodata.GeoSiteList{
Entry: []*geodata.GeoSite{{Code: "SOME-CODE"}},
})
writeFixture(t, filepath.Join(dir, "geoip.dat"), &geodata.GeoIPList{
Entry: []*geodata.GeoIP{{Code: "PRIVATE"}},
})
gin.SetMode(gin.TestMode)
router := gin.New()
NewXraySettingController(router.Group("/panel/api"))
req := httptest.NewRequest(http.MethodGet, "/panel/api/xray/getGeodataCategories", nil)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
}
body := resp.Body.String()
for _, want := range []string{`"success":true`, `"geosite:youtube"`, `"ext:geosite_roscom.dat:some-code"`, `"geoip:private"`} {
if !strings.Contains(body, want) {
t.Errorf("response body %s does not contain %s", body, want)
}
}
}
func writeFixture(t *testing.T, path string, msg proto.Message) {
t.Helper()
data, err := proto.Marshal(msg)
if err != nil {
t.Fatalf("marshal fixture %s: %v", path, err)
}
if err := os.WriteFile(path, data, 0o644); err != nil {
t.Fatalf("write fixture %s: %v", path, err)
}
}
+8
View File
@@ -7,6 +7,7 @@ import (
"slices"
"strconv"
"strings"
"sync"
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
@@ -16,6 +17,13 @@ import (
// It handles validation and storage of Xray template configurations.
type XraySettingService struct {
SettingService
// geodataMu/geodataCache back GetGeodataCategories' in-memory cache (see
// xray_setting_geodata.go), keyed by the (name, size, modTime)
// fingerprint of the geosite*/geoip*.dat files currently present in
// config.GetBinFolderPath().
geodataMu sync.Mutex
geodataCache *geodataCategoryCache
}
const (
@@ -0,0 +1,260 @@
package service
import (
"os"
"path/filepath"
"slices"
"sort"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/config"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/xtls/xray-core/common/geodata"
"google.golang.org/protobuf/proto"
)
// GeodataCategories lists every geosite/geoip category found in the .dat
// files currently present in the Xray bin folder, already formatted as
// ready-to-use xray-core routing-rule values (see formatGeodataSuggestion).
// Returned by XraySettingService.GetGeodataCategories and served as
// GET /panel/api/xray/getGeodataCategories for the routing rule editor's
// Domain/IP autocomplete.
type GeodataCategories struct {
Domain []string `json:"domain"`
IP []string `json:"ip"`
}
// geodataFileKind distinguishes a geosite-shaped .dat file (parsed as a
// geodata.GeoSiteList) from a geoip-shaped one (geodata.GeoIPList).
type geodataFileKind int
const (
geositeFile geodataFileKind = iota
geoipFile
)
// geodataFileEntry is one matched .dat file plus the (size, modTime)
// fingerprint used to invalidate the parsed-category cache.
type geodataFileEntry struct {
name string // base filename, e.g. "geosite_roscom.dat"
path string
size int64
modTime time.Time
kind geodataFileKind
}
// geodataFileFingerprint is the comparable, cacheable projection of one
// geodataFileEntry used to detect whether a re-parse is needed.
type geodataFileFingerprint struct {
name string
size int64
modTime int64 // UnixNano
}
// geodataCategoryCache holds the last computed result plus the exact file
// fingerprint it was computed from.
type geodataCategoryCache struct {
fingerprint []geodataFileFingerprint
result GeodataCategories
}
// GetGeodataCategories scans config.GetBinFolderPath() for geosite*.dat /
// geoip*.dat files -- whatever is actually on disk right now, including any
// custom files the admin added via the Geodata auto-update feature -- and
// returns every category code they contain as a suggestion string:
// "geosite:<code>"/"geoip:<code>" for the default file, "ext:<file>:<code>"
// for any other file (xray-core's rule parser has no shorthand for those;
// see common/geodata/rule_parser.go in the vendored xray-core module).
//
// The result is cached in memory keyed by the (name, size, modTime)
// fingerprint of the matched files, so repeated calls are cheap until a
// file actually changes -- e.g. because xray-core's own geodata auto-update
// downloaded a new one. A file that fails to parse (e.g. an interrupted
// download) is skipped with a logged warning; it never fails the request.
func (s *XraySettingService) GetGeodataCategories() GeodataCategories {
dir := config.GetBinFolderPath()
entries := scanGeodataFiles(dir)
fingerprint := geodataFingerprintOf(entries)
s.geodataMu.Lock()
if s.geodataCache != nil && slices.Equal(s.geodataCache.fingerprint, fingerprint) {
result := s.geodataCache.result
s.geodataMu.Unlock()
return result
}
s.geodataMu.Unlock()
result := buildGeodataCategories(entries)
s.geodataMu.Lock()
s.geodataCache = &geodataCategoryCache{fingerprint: fingerprint, result: result}
s.geodataMu.Unlock()
return result
}
// scanGeodataFiles lists dir for files matched by name: geosite*.dat parses
// as a geodata.GeoSiteList, geoip*.dat as a geodata.GeoIPList. Matching is
// case-insensitive on the prefix/extension -- every real filename observed
// so far (geoip.dat, geosite_IR.dat, geosite_roscom.dat, ...) is
// lowercase-prefixed, but nothing enforces that when an admin drops a file
// in by hand, so this stays tolerant. A missing or unreadable bin folder
// yields an empty scan (logged, not an error) rather than failing the
// endpoint.
func scanGeodataFiles(dir string) []geodataFileEntry {
entries, err := os.ReadDir(dir)
if err != nil {
if !os.IsNotExist(err) {
logger.Warning("geodata categories: failed to read bin folder:", err)
}
return nil
}
out := make([]geodataFileEntry, 0, len(entries))
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
lower := strings.ToLower(name)
if !strings.HasSuffix(lower, ".dat") {
continue
}
var kind geodataFileKind
switch {
case strings.HasPrefix(lower, "geosite"):
kind = geositeFile
case strings.HasPrefix(lower, "geoip"):
kind = geoipFile
default:
continue
}
info, err := e.Info()
if err != nil {
continue // vanished between ReadDir and Info; skip
}
out = append(out, geodataFileEntry{
name: name,
path: filepath.Join(dir, name),
size: info.Size(),
modTime: info.ModTime(),
kind: kind,
})
}
return out
}
// geodataFingerprintOf reduces a file list to a sorted, comparable slice so
// GetGeodataCategories can detect "nothing changed" with slices.Equal
// regardless of os.ReadDir's ordering.
func geodataFingerprintOf(entries []geodataFileEntry) []geodataFileFingerprint {
out := make([]geodataFileFingerprint, len(entries))
for i, e := range entries {
out[i] = geodataFileFingerprint{name: e.name, size: e.size, modTime: e.modTime.UnixNano()}
}
sort.Slice(out, func(i, j int) bool { return out[i].name < out[j].name })
return out
}
// buildGeodataCategories parses every matched file and collects its category
// codes into the Domain/IP suggestion lists, de-duplicating within each list
// and skipping (with a logged warning) any file that fails to parse.
func buildGeodataCategories(entries []geodataFileEntry) GeodataCategories {
var result GeodataCategories
seenDomain := make(map[string]struct{})
seenIP := make(map[string]struct{})
for _, entry := range entries {
codes, err := parseGeodataFile(entry)
if err != nil {
logger.Warningf("geodata categories: skipping %s: %v", entry.name, err)
continue
}
for _, code := range codes {
suggestion := formatGeodataSuggestion(entry, code)
switch entry.kind {
case geositeFile:
if _, dup := seenDomain[suggestion]; dup {
continue
}
seenDomain[suggestion] = struct{}{}
result.Domain = append(result.Domain, suggestion)
case geoipFile:
if _, dup := seenIP[suggestion]; dup {
continue
}
seenIP[suggestion] = struct{}{}
result.IP = append(result.IP, suggestion)
}
}
}
sort.Strings(result.Domain)
sort.Strings(result.IP)
return result
}
// parseGeodataFile fully unmarshals one geosite*/geoip*.dat file and returns
// every category Code it contains. xray-core's own loaders (loadSite/loadIP
// in common/geodata/geodat_loader.go) stream a single named category out of
// a file via an unexported, custom varint-prefixed scanner; enumerating
// *every* category instead needs a full-file proto.Unmarshal into the
// package's exported GeoSiteList/GeoIPList message types.
func parseGeodataFile(entry geodataFileEntry) ([]string, error) {
data, err := os.ReadFile(entry.path)
if err != nil {
return nil, err
}
switch entry.kind {
case geositeFile:
var list geodata.GeoSiteList
if err := proto.Unmarshal(data, &list); err != nil {
return nil, err
}
codes := make([]string, 0, len(list.GetEntry()))
for _, site := range list.GetEntry() {
if code := site.GetCode(); code != "" {
codes = append(codes, code)
}
}
return codes, nil
case geoipFile:
var list geodata.GeoIPList
if err := proto.Unmarshal(data, &list); err != nil {
return nil, err
}
codes := make([]string, 0, len(list.GetEntry()))
for _, ip := range list.GetEntry() {
if code := ip.GetCode(); code != "" {
codes = append(codes, code)
}
}
return codes, nil
}
return nil, nil
}
// formatGeodataSuggestion builds the exact rule value xray-core's rule
// parser (common/geodata/rule_parser.go) accepts for one category code from
// one file. The default file gets the short "geosite:"/"geoip:" form; every
// other file -- including every custom file the admin added -- MUST use the
// "ext:<file>:<code>" form since there is no shorthand for non-default
// files. Code casing never matters to xray-core (it upcases internally,
// rule_parser.go), but lowercase reads better and matches this panel's
// existing convention (frontend/src/pages/xray/basics/constants.ts).
func formatGeodataSuggestion(entry geodataFileEntry, code string) string {
lowerCode := strings.ToLower(code)
switch entry.kind {
case geositeFile:
if entry.name == geodata.DefaultGeoSiteDat {
return "geosite:" + lowerCode
}
case geoipFile:
if entry.name == geodata.DefaultGeoIPDat {
return "geoip:" + lowerCode
}
}
return "ext:" + entry.name + ":" + lowerCode
}
@@ -0,0 +1,212 @@
package service
import (
"os"
"path/filepath"
"slices"
"sort"
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/config"
"github.com/xtls/xray-core/common/geodata"
"google.golang.org/protobuf/proto"
)
func writeGeoSiteFixture(t *testing.T, dir, name string, codes ...string) {
t.Helper()
list := &geodata.GeoSiteList{}
for _, c := range codes {
list.Entry = append(list.Entry, &geodata.GeoSite{Code: c})
}
data, err := proto.Marshal(list)
if err != nil {
t.Fatalf("marshal fixture geosite list: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, name), data, 0o644); err != nil {
t.Fatalf("write fixture %s: %v", name, err)
}
}
func writeGeoIPFixture(t *testing.T, dir, name string, codes ...string) {
t.Helper()
list := &geodata.GeoIPList{}
for _, c := range codes {
list.Entry = append(list.Entry, &geodata.GeoIP{Code: c})
}
data, err := proto.Marshal(list)
if err != nil {
t.Fatalf("marshal fixture geoip list: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, name), data, 0o644); err != nil {
t.Fatalf("write fixture %s: %v", name, err)
}
}
func TestScanGeodataFiles(t *testing.T) {
dir := t.TempDir()
writeGeoSiteFixture(t, dir, "geosite.dat", "CN")
writeGeoSiteFixture(t, dir, "geosite_roscom.dat", "SOME-CODE")
writeGeoIPFixture(t, dir, "geoip.dat", "PRIVATE")
writeGeoIPFixture(t, dir, "GEOIP_RU.DAT", "RU") // case-insensitive match
if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte("{}"), 0o644); err != nil {
t.Fatalf("write unrelated file: %v", err)
}
if err := os.Mkdir(filepath.Join(dir, "geosite_dir.dat"), 0o755); err != nil {
t.Fatalf("mkdir geosite_dir.dat: %v", err)
}
entries := scanGeodataFiles(dir)
names := make([]string, 0, len(entries))
for _, e := range entries {
names = append(names, e.name)
}
sort.Strings(names)
want := []string{"GEOIP_RU.DAT", "geoip.dat", "geosite.dat", "geosite_roscom.dat"}
sort.Strings(want)
if !slices.Equal(names, want) {
t.Fatalf("scanGeodataFiles names = %v, want %v", names, want)
}
for _, e := range entries {
switch e.name {
case "geosite.dat", "geosite_roscom.dat":
if e.kind != geositeFile {
t.Errorf("%s: kind = %v, want geositeFile", e.name, e.kind)
}
case "geoip.dat", "GEOIP_RU.DAT":
if e.kind != geoipFile {
t.Errorf("%s: kind = %v, want geoipFile", e.name, e.kind)
}
}
}
}
func TestScanGeodataFilesMissingDir(t *testing.T) {
entries := scanGeodataFiles(filepath.Join(t.TempDir(), "does-not-exist"))
if len(entries) != 0 {
t.Fatalf("expected no entries for a missing dir, got %v", entries)
}
}
func TestParseGeodataFile(t *testing.T) {
dir := t.TempDir()
writeGeoSiteFixture(t, dir, "geosite.dat", "CN", "YOUTUBE")
writeGeoIPFixture(t, dir, "geoip_rosip.dat", "RU")
siteCodes, err := parseGeodataFile(geodataFileEntry{
name: "geosite.dat", path: filepath.Join(dir, "geosite.dat"), kind: geositeFile,
})
if err != nil {
t.Fatalf("parseGeodataFile(geosite.dat): %v", err)
}
if !slices.Equal(siteCodes, []string{"CN", "YOUTUBE"}) {
t.Fatalf("parseGeodataFile(geosite.dat) = %v, want [CN YOUTUBE]", siteCodes)
}
ipCodes, err := parseGeodataFile(geodataFileEntry{
name: "geoip_rosip.dat", path: filepath.Join(dir, "geoip_rosip.dat"), kind: geoipFile,
})
if err != nil {
t.Fatalf("parseGeodataFile(geoip_rosip.dat): %v", err)
}
if !slices.Equal(ipCodes, []string{"RU"}) {
t.Fatalf("parseGeodataFile(geoip_rosip.dat) = %v, want [RU]", ipCodes)
}
}
func TestFormatGeodataSuggestion(t *testing.T) {
tests := []struct {
name string
kind geodataFileKind
code string
want string
}{
{name: "geosite.dat", kind: geositeFile, code: "CN", want: "geosite:cn"},
{name: "geoip.dat", kind: geoipFile, code: "PRIVATE", want: "geoip:private"},
{name: "geosite_roscom.dat", kind: geositeFile, code: "SOME-CODE", want: "ext:geosite_roscom.dat:some-code"},
{name: "geoip_rosip.dat", kind: geoipFile, code: "RU", want: "ext:geoip_rosip.dat:ru"},
}
for _, tt := range tests {
entry := geodataFileEntry{name: tt.name, kind: tt.kind}
if got := formatGeodataSuggestion(entry, tt.code); got != tt.want {
t.Errorf("formatGeodataSuggestion(%q, %q) = %q, want %q", tt.name, tt.code, got, tt.want)
}
}
}
func TestGeodataFingerprintOf(t *testing.T) {
a := []geodataFileEntry{
{name: "geoip.dat", size: 100, modTime: time.Unix(1, 0)},
{name: "geosite.dat", size: 200, modTime: time.Unix(2, 0)},
}
b := []geodataFileEntry{ // same content, different order
{name: "geosite.dat", size: 200, modTime: time.Unix(2, 0)},
{name: "geoip.dat", size: 100, modTime: time.Unix(1, 0)},
}
if !slices.Equal(geodataFingerprintOf(a), geodataFingerprintOf(b)) {
t.Fatal("fingerprints should be equal regardless of input order")
}
c := []geodataFileEntry{
{name: "geoip.dat", size: 999, modTime: time.Unix(1, 0)}, // size changed
{name: "geosite.dat", size: 200, modTime: time.Unix(2, 0)},
}
if slices.Equal(geodataFingerprintOf(a), geodataFingerprintOf(c)) {
t.Fatal("fingerprints should differ when a file's size changes")
}
}
func TestGetGeodataCategories_SkipsMalformedFile(t *testing.T) {
dir := t.TempDir()
writeGeoSiteFixture(t, dir, "geosite.dat", "CN")
// An unterminated varint (continuation bit set on every byte) is
// guaranteed to fail proto.Unmarshal, unlike an arbitrary text string
// which might accidentally parse as protobuf garbage.
if err := os.WriteFile(filepath.Join(dir, "geosite_broken.dat"), []byte{0xFF, 0xFF, 0xFF}, 0o644); err != nil {
t.Fatalf("write broken fixture: %v", err)
}
entries := scanGeodataFiles(dir)
result := buildGeodataCategories(entries)
if !slices.Contains(result.Domain, "geosite:cn") {
t.Fatalf("expected the valid file's category to survive, got %v", result.Domain)
}
}
func TestGetGeodataCategories_EndToEnd(t *testing.T) {
dir := t.TempDir()
t.Setenv("XUI_BIN_FOLDER", dir)
if config.GetBinFolderPath() != dir {
t.Fatalf("XUI_BIN_FOLDER override not respected: got %q, want %q", config.GetBinFolderPath(), dir)
}
writeGeoSiteFixture(t, dir, "geosite.dat", "CN")
writeGeoIPFixture(t, dir, "geoip.dat", "PRIVATE")
writeGeoSiteFixture(t, dir, "geosite_roscom.dat", "SOME-CODE")
svc := &XraySettingService{}
result := svc.GetGeodataCategories()
if !slices.Contains(result.Domain, "geosite:cn") {
t.Errorf("Domain = %v, want to contain geosite:cn", result.Domain)
}
if !slices.Contains(result.Domain, "ext:geosite_roscom.dat:some-code") {
t.Errorf("Domain = %v, want to contain ext:geosite_roscom.dat:some-code", result.Domain)
}
if !slices.Contains(result.IP, "geoip:private") {
t.Errorf("IP = %v, want to contain geoip:private", result.IP)
}
// Cache must reflect a file that appears after the first call.
writeGeoIPFixture(t, dir, "geoip_rosip.dat", "RU")
result = svc.GetGeodataCategories()
if !slices.Contains(result.IP, "ext:geoip_rosip.dat:ru") {
t.Errorf("IP after adding a new file = %v, want to contain ext:geoip_rosip.dat:ru", result.IP)
}
}