Parcourir la source

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 <[email protected]>
Grigoriy il y a 5 heures
Parent
commit
d7698ec7aa
43 fichiers modifiés avec 5433 ajouts et 6 suppressions
  1. 2 0
      CLAUDE.md
  2. 8 3
      docs/architecture.md
  3. 366 0
      frontend/public/openapi.json
  4. 102 0
      frontend/src/api/queries/useGeodata.ts
  5. 7 0
      frontend/src/api/queryKeys.ts
  6. 221 0
      frontend/src/components/geodata/GeoBrowserModal.css
  7. 457 0
      frontend/src/components/geodata/GeoBrowserModal.stories.tsx
  8. 413 0
      frontend/src/components/geodata/GeoBrowserModal.tsx
  9. 247 0
      frontend/src/components/geodata/GeoTokenInput.stories.tsx
  10. 126 0
      frontend/src/components/geodata/GeoTokenInput.tsx
  11. 4 0
      frontend/src/components/geodata/index.ts
  12. 48 0
      frontend/src/generated/examples.ts
  13. 151 0
      frontend/src/generated/schemas.ts
  14. 38 0
      frontend/src/generated/types.ts
  15. 46 0
      frontend/src/generated/zod.ts
  16. 77 0
      frontend/src/lib/xray/geoTokens.ts
  17. 38 0
      frontend/src/pages/api-docs/endpoints.ts
  18. 4 3
      frontend/src/pages/xray/routing/RuleFormModal.tsx
  19. 207 0
      frontend/src/test/geo-browser-selection.test.tsx
  20. 215 0
      frontend/src/test/geo-tokens.test.ts
  21. 278 0
      internal/web/controller/geodata_test.go
  22. 73 0
      internal/web/controller/xray_setting.go
  23. 156 0
      internal/web/service/geodata.go
  24. 29 0
      internal/web/translation/ar-EG.json
  25. 29 0
      internal/web/translation/en-US.json
  26. 29 0
      internal/web/translation/es-ES.json
  27. 29 0
      internal/web/translation/fa-IR.json
  28. 29 0
      internal/web/translation/id-ID.json
  29. 29 0
      internal/web/translation/ja-JP.json
  30. 29 0
      internal/web/translation/pt-BR.json
  31. 29 0
      internal/web/translation/ru-RU.json
  32. 29 0
      internal/web/translation/tr-TR.json
  33. 29 0
      internal/web/translation/uk-UA.json
  34. 29 0
      internal/web/translation/vi-VN.json
  35. 29 0
      internal/web/translation/zh-CN.json
  36. 29 0
      internal/web/translation/zh-TW.json
  37. 269 0
      internal/xray/geodata/geodata.go
  38. 558 0
      internal/xray/geodata/geodata_test.go
  39. 128 0
      internal/xray/geodata/query.go
  40. 487 0
      internal/xray/geodata/reader.go
  41. 143 0
      internal/xray/geodata/token.go
  42. 175 0
      internal/xray/geodata/token_test.go
  43. 12 0
      tools/openapigen/main.go

+ 2 - 0
CLAUDE.md

@@ -38,6 +38,8 @@ file locations when it can answer in one hop.
   Inbound, Client, Setting, User are the core), inbound Protocol enum,
   AutoMigrate + hand-written migrations in `db.go`.
 - `internal/xray/` — Xray child-process lifecycle, config generation, gRPC API.
+- `internal/xray/geodata/` — streaming geosite/geoip `.dat` reader (cached
+  category index + paged entries) and `geosite:`/`geoip:`/`ext:` token parsing.
 - `internal/mtproto/` — MTProto inbounds via the bundled `mtg-multi` binary.
 - `internal/sub/` — subscription server (raw / JSON / Clash).
 - `internal/eventbus/` — in-process pub/sub (outbound/node health, xray.crash,

+ 8 - 3
docs/architecture.md

@@ -147,7 +147,9 @@ node heartbeat every 5s, periodic traffic resets (hourly/daily/weekly/monthly).
 │   │   ├── inbound.go          # Inbound JSON shaping
 │   │   ├── client_traffic.go   # ClientTraffic model (persisted as client_traffics)
 │   │   ├── traffic.go          # Traffic type helpers
-│   │   └── log_writer.go       # Pipe Xray stdout/stderr into the panel logger
+│   │   ├── log_writer.go       # Pipe Xray stdout/stderr into the panel logger
+│   │   └── geodata/            # Browse geosite/geoip .dat: streaming protowire reader,
+│   │                           #   cached category index, routing-token parsing (token.go)
 │   │
 │   ├── web/                    # The panel server
 │   │   ├── web.go              # ⭐ Server bootstrap: initRouter (all routes) + startTask (all cron jobs)
@@ -159,7 +161,7 @@ node heartbeat every 5s, periodic traffic resets (hourly/daily/weekly/monthly).
 │   │   │   ├── host.go         #   /panel/api/hosts   (per-inbound subscription host overrides)
 │   │   │   ├── server.go       #   /panel/api/server  (status, xray version, certs, logs, DB import/export)
 │   │   │   ├── setting.go      #   /panel/api/setting (settings + API tokens)
-│   │   │   ├── xray_setting.go #   /panel/api/xray    (raw Xray config editor, WARP/Nord)
+│   │   │   ├── xray_setting.go #   /panel/api/xray    (raw Xray config editor, WARP/Nord, geodata)
 │   │   │   ├── api.go          #   /panel/api gateway (token auth, envelope + CSRF wiring)
 │   │   │   ├── index.go        #   login/logout/csrf/2FA
 │   │   │   ├── spa.go          #   SPA fallback for /panel UI routes
@@ -189,6 +191,7 @@ node heartbeat every 5s, periodic traffic resets (hourly/daily/weekly/monthly).
 │   │   │   ├── traffic_writer.go       # Batched persistence of traffic deltas to the DB
 │   │   │   ├── xray.go                 # ⭐ XrayService: config gen + restart/hot-apply (~1.2k lines)
 │   │   │   ├── xray_setting.go         # Raw Xray config persistence
+│   │   │   ├── geodata.go              # Geo database browsing + routing-token validation
 │   │   │   ├── xray_metrics.go         # Xray observability metrics
 │   │   │   ├── metric_history.go       # Historical system/xray metrics
 │   │   │   ├── reality_scan.go         # REALITY target scanner
@@ -265,7 +268,7 @@ node heartbeat every 5s, periodic traffic resets (hourly/daily/weekly/monthly).
 │       │   └── queries/      #   TanStack Query hooks (useNodesQuery, useStatusQuery, …)
 │       ├── schemas/          # Zod schemas: protocols, forms, api, primitives
 │       ├── generated/        # ⚠️ GENERATED from Go (see §5.5): schemas.ts, types.ts, zod.ts, examples.ts
-│       ├── components/       # Reusable UI (clients/ form/ ui/ viz/ feedback/ utility/)
+│       ├── components/       # Reusable UI (clients/ form/ geodata/ ui/ viz/ feedback/ utility/)
 │       ├── lib/              # Frontend domain logic (xray/ inbounds/ clients/)
 │       ├── hooks/, models/, layouts/, i18n/, utils/, styles/
 │       └── test/             # Vitest + golden fixtures (config-generation snapshot tests)
@@ -484,6 +487,8 @@ for AutoMigrate in `internal/database/db.go`.
 | **API tokens** | `service/panel/api_token.go`, `controller/setting.go` | model `ApiToken` |
 | **Port conflict** on inbound add | `service/port_conflict.go` | `controller/inbound.go` |
 | **Fallbacks** (shared 443, SNI routing) | `service/fallback.go`, `controller/inbound.go` | model `InboundFallback` |
+| **Geo category browser** empty / won't open | `xray/geodata/` (`Store`, `reader.go`), `service/geodata.go` | `controller/xray_setting.go` (`/panel/api/xray/geodata/*`), asset dir = `config.GetBinFolderPath()` |
+| **`geosite:`/`geoip:` token** reported unknown in a routing rule | `xray/geodata/token.go`, `service/geodata.go` (`Validate`) | `frontend/src/lib/xray/geoTokens.ts`, `frontend/src/components/geodata/` |
 | **Telegram bot** commands | `service/tgbot/` | `job/stats_notify_job.go` |
 | **Email notifications** | `service/email/` | `internal/eventbus/` (consumers) |
 | **CPU / memory alerts** not firing | `job/check_cpu_usage.go`, `job/check_memory_usage.go` | `internal/eventbus/`, notifier settings in `service/setting.go` |

+ 366 - 0
frontend/public/openapi.json

@@ -1408,6 +1408,157 @@
         ],
         "type": "object"
       },
+      "GeoCategory": {
+        "description": "GeoCategory is one code inside a database, such as geosite's \"google\".",
+        "properties": {
+          "attributes": {
+            "example": [
+              "ads",
+              "cn"
+            ],
+            "items": {
+              "type": "string"
+            },
+            "type": "array"
+          },
+          "code": {
+            "example": "google",
+            "type": "string"
+          },
+          "entries": {
+            "example": 1284,
+            "type": "integer"
+          }
+        },
+        "required": [
+          "attributes",
+          "code",
+          "entries"
+        ],
+        "type": "object"
+      },
+      "GeoCategoryPage": {
+        "description": "GeoCategoryPage is one page of categories plus the unpaged total.",
+        "properties": {
+          "items": {
+            "items": {
+              "$ref": "#/components/schemas/GeoCategory"
+            },
+            "type": "array"
+          },
+          "total": {
+            "example": 1043,
+            "type": "integer"
+          }
+        },
+        "required": [
+          "items",
+          "total"
+        ],
+        "type": "object"
+      },
+      "GeoEntry": {
+        "description": "GeoEntry is a single rule inside a category: a domain rule for geosite\ndatabases, a CIDR for geoip ones.",
+        "properties": {
+          "kind": {
+            "example": "domain",
+            "type": "string"
+          },
+          "value": {
+            "example": "google.com",
+            "type": "string"
+          }
+        },
+        "required": [
+          "kind",
+          "value"
+        ],
+        "type": "object"
+      },
+      "GeoEntryPage": {
+        "description": "GeoEntryPage is one page of category entries plus the unpaged total.",
+        "properties": {
+          "items": {
+            "items": {
+              "$ref": "#/components/schemas/GeoEntry"
+            },
+            "type": "array"
+          },
+          "total": {
+            "example": 1284,
+            "type": "integer"
+          }
+        },
+        "required": [
+          "items",
+          "total"
+        ],
+        "type": "object"
+      },
+      "GeoFile": {
+        "description": "GeoFile describes one .dat database found in the asset directory.",
+        "properties": {
+          "categories": {
+            "example": 1043,
+            "type": "integer"
+          },
+          "error": {
+            "type": "string"
+          },
+          "kind": {
+            "example": "site",
+            "type": "string"
+          },
+          "modifiedAt": {
+            "example": 1769558400000,
+            "format": "int64",
+            "type": "integer"
+          },
+          "name": {
+            "example": "geosite.dat",
+            "type": "string"
+          },
+          "size": {
+            "example": 1467392,
+            "format": "int64",
+            "type": "integer"
+          }
+        },
+        "required": [
+          "categories",
+          "kind",
+          "modifiedAt",
+          "name",
+          "size"
+        ],
+        "type": "object"
+      },
+      "GeodataTokenIssue": {
+        "description": "GeodataTokenIssue reports a routing token the running core would reject,\nor would silently match nothing against.",
+        "properties": {
+          "code": {
+            "example": "blabla",
+            "type": "string"
+          },
+          "file": {
+            "example": "geosite.dat",
+            "type": "string"
+          },
+          "reason": {
+            "example": "categoryMissing",
+            "type": "string"
+          },
+          "token": {
+            "example": "geosite:blabla",
+            "type": "string"
+          }
+        },
+        "required": [
+          "reason",
+          "token"
+        ],
+        "type": "object"
+      },
       "HistoryOfSeeders": {
         "description": "HistoryOfSeeders tracks which database seeders have been executed to prevent re-running.",
         "properties": {
@@ -11013,6 +11164,221 @@
         }
       }
     },
+    "/panel/api/xray/geodata/files": {
+      "get": {
+        "tags": [
+          "Xray Settings"
+        ],
+        "summary": "List the geo databases (.dat files) in the Xray asset folder, with the layout detected from their contents, size, modification time and category count. A database that fails to parse is still listed, with the reason in \"error\".",
+        "operationId": "get_panel_api_xray_geodata_files",
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/panel/api/xray/geodata/categories": {
+      "get": {
+        "tags": [
+          "Xray Settings"
+        ],
+        "summary": "One page of a database's categories, each with its entry count and the attributes its domains carry (e.g. \"ads\", \"cn\").",
+        "operationId": "get_panel_api_xray_geodata_categories",
+        "parameters": [
+          {
+            "name": "file",
+            "in": "query",
+            "required": true,
+            "description": "Database file name inside the asset folder, e.g. geosite.dat (required).",
+            "schema": {
+              "type": "string"
+            }
+          },
+          {
+            "name": "q",
+            "in": "query",
+            "required": false,
+            "description": "Case-insensitive substring filter on the category code.",
+            "schema": {
+              "type": "string"
+            }
+          },
+          {
+            "name": "offset",
+            "in": "query",
+            "required": false,
+            "description": "Rows to skip. Defaults to 0.",
+            "schema": {
+              "type": "integer"
+            }
+          },
+          {
+            "name": "limit",
+            "in": "query",
+            "required": false,
+            "description": "Rows to return, capped at 500. Omit it to return every category — the index is small and the panel filters it client-side.",
+            "schema": {
+              "type": "integer"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/panel/api/xray/geodata/entries": {
+      "get": {
+        "tags": [
+          "Xray Settings"
+        ],
+        "summary": "One page of the rules inside a category — domain rules typed as domain/full/keyword/regexp for geosite databases, CIDRs for geoip ones.",
+        "operationId": "get_panel_api_xray_geodata_entries",
+        "parameters": [
+          {
+            "name": "file",
+            "in": "query",
+            "required": true,
+            "description": "Database file name inside the asset folder (required).",
+            "schema": {
+              "type": "string"
+            }
+          },
+          {
+            "name": "code",
+            "in": "query",
+            "required": true,
+            "description": "Category code, case-insensitive, e.g. google (required).",
+            "schema": {
+              "type": "string"
+            }
+          },
+          {
+            "name": "q",
+            "in": "query",
+            "required": false,
+            "description": "Case-insensitive substring filter on the rule value.",
+            "schema": {
+              "type": "string"
+            }
+          },
+          {
+            "name": "offset",
+            "in": "query",
+            "required": false,
+            "description": "Rows to skip. Defaults to 0.",
+            "schema": {
+              "type": "integer"
+            }
+          },
+          {
+            "name": "limit",
+            "in": "query",
+            "required": false,
+            "description": "Rows to return, capped at 500. Defaults to the cap.",
+            "schema": {
+              "type": "integer"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/panel/api/xray/geodata/validate": {
+      "post": {
+        "tags": [
+          "Xray Settings"
+        ],
+        "summary": "Check routing tokens against the databases on disk and return only the ones that do not resolve. Plain domains and CIDRs are ignored. Each issue carries a reason: syntax, fileMissing or categoryMissing.",
+        "operationId": "post_panel_api_xray_geodata_validate",
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object"
+              }
+            }
+          }
+        },
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/panel/api/xray/outbound-subs": {
       "get": {
         "tags": [

+ 102 - 0
frontend/src/api/queries/useGeodata.ts

@@ -0,0 +1,102 @@
+import { keepPreviousData, useMutation, useQuery } from '@tanstack/react-query';
+import { z } from 'zod';
+
+import { keys } from '@/api/queryKeys';
+import { GeoCategoryPageSchema, GeoEntryPageSchema, GeoFileSchema, GeodataTokenIssueSchema } from '@/generated/zod';
+import type { GeoCategoryPage, GeoEntryPage, GeoFile, GeodataTokenIssue } from '@/generated/types';
+import { HttpUtil } from '@/utils';
+import { parseMsg } from '@/utils/zodValidate';
+
+const GeoFileListSchema = z.array(GeoFileSchema);
+const GeodataTokenIssueListSchema = z.array(GeodataTokenIssueSchema);
+
+const EMPTY_CATEGORY_PAGE: GeoCategoryPage = { total: 0, items: [] };
+const EMPTY_ENTRY_PAGE: GeoEntryPage = { total: 0, items: [] };
+
+export type GeoTokenKind = 'ip' | 'domain';
+
+export interface ValidateGeoTokensInput {
+  tokens: string[];
+  kind: GeoTokenKind;
+}
+
+async function fetchGeodataFiles(): Promise<GeoFile[]> {
+  const msg = await HttpUtil.get('/panel/api/xray/geodata/files', undefined, { silent: true });
+  if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch geodata files');
+  const validated = parseMsg(msg, GeoFileListSchema, 'xray/geodata/files');
+  return Array.isArray(validated.obj) ? validated.obj : [];
+}
+
+async function fetchGeodataCategories(file: string, query: string): Promise<GeoCategoryPage> {
+  const msg = await HttpUtil.get('/panel/api/xray/geodata/categories', { file, q: query }, { silent: true });
+  if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch geodata categories');
+  const validated = parseMsg(msg, GeoCategoryPageSchema, 'xray/geodata/categories');
+  return validated.obj ?? EMPTY_CATEGORY_PAGE;
+}
+
+async function fetchGeodataEntries(
+  file: string,
+  code: string,
+  query: string,
+  offset: number,
+  limit: number,
+): Promise<GeoEntryPage> {
+  const msg = await HttpUtil.get(
+    '/panel/api/xray/geodata/entries',
+    { file, code, q: query, offset, limit },
+    { silent: true },
+  );
+  if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch geodata entries');
+  const validated = parseMsg(msg, GeoEntryPageSchema, 'xray/geodata/entries');
+  return validated.obj ?? EMPTY_ENTRY_PAGE;
+}
+
+export function useGeodataFiles(enabled: boolean) {
+  return useQuery({
+    queryKey: keys.xray.geodata.files(),
+    queryFn: fetchGeodataFiles,
+    enabled,
+    staleTime: 5 * 60 * 1000,
+  });
+}
+
+export function useGeodataCategories(file: string | undefined, query: string, enabled: boolean) {
+  return useQuery({
+    queryKey: keys.xray.geodata.categories(file ?? '', query),
+    queryFn: () => fetchGeodataCategories(file ?? '', query),
+    enabled: enabled && !!file,
+    staleTime: 5 * 60 * 1000,
+    placeholderData: keepPreviousData,
+  });
+}
+
+export function useGeodataEntries(
+  file: string | undefined,
+  code: string | undefined,
+  query: string,
+  offset: number,
+  limit: number,
+  enabled: boolean,
+) {
+  return useQuery({
+    queryKey: keys.xray.geodata.entries(file ?? '', code ?? '', query, offset, limit),
+    queryFn: () => fetchGeodataEntries(file ?? '', code ?? '', query, offset, limit),
+    enabled: enabled && !!file && !!code,
+    placeholderData: keepPreviousData,
+  });
+}
+
+export function useValidateGeoTokens() {
+  return useMutation<GeodataTokenIssue[], Error, ValidateGeoTokensInput>({
+    mutationFn: async ({ tokens, kind }) => {
+      const msg = await HttpUtil.post(
+        '/panel/api/xray/geodata/validate',
+        { tokens: tokens.join(','), kind },
+        { silent: true },
+      );
+      if (!msg?.success) throw new Error(msg?.msg || 'Failed to validate geodata tokens');
+      const validated = parseMsg(msg, GeodataTokenIssueListSchema, 'xray/geodata/validate');
+      return Array.isArray(validated.obj) ? validated.obj : [];
+    },
+  });
+}

+ 7 - 0
frontend/src/api/queryKeys.ts

@@ -38,5 +38,12 @@ export const keys = {
     root: () => ['xray'] as const,
     config: () => ['xray', 'config'] as const,
     outboundsTraffic: () => ['xray', 'outboundsTraffic'] as const,
+    geodata: {
+      root: () => ['xray', 'geodata'] as const,
+      files: () => ['xray', 'geodata', 'files'] as const,
+      categories: (file: string, query: string) => ['xray', 'geodata', 'categories', file, query] as const,
+      entries: (file: string, code: string, query: string, offset: number, limit: number) =>
+        ['xray', 'geodata', 'entries', file, code, query, offset, limit] as const,
+    },
   },
 } as const;

+ 221 - 0
frontend/src/components/geodata/GeoBrowserModal.css

@@ -0,0 +1,221 @@
+.geo-browser-modal .geo-toolbar {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  flex-wrap: wrap;
+  margin-bottom: 12px;
+}
+
+.geo-browser-modal .geo-toolbar .ant-input-search {
+  flex: 1;
+  min-width: 180px;
+}
+
+.geo-browser-modal .geo-meta {
+  margin-inline-start: auto;
+  font-size: 12px;
+  color: var(--ant-color-text-tertiary);
+  font-variant-numeric: tabular-nums;
+}
+
+.geo-browser-modal .geo-columns {
+  display: grid;
+  grid-template-columns: minmax(240px, 340px) minmax(0, 1fr);
+  gap: 12px;
+  height: 440px;
+}
+
+/* Both panes are the same fixed height, and the pager sits on the pane's floor
+   rather than under the last row, so neither the dialog nor its controls move
+   as the user steps between categories with wildly different rule counts. */
+.geo-browser-modal .geo-panel {
+  height: 100%;
+  min-height: 0;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+  border: 1px solid var(--ant-color-border-secondary);
+  border-radius: 8px;
+}
+
+.geo-browser-modal .geo-panel .ant-table-wrapper,
+.geo-browser-modal .geo-panel .ant-spin-nested-loading,
+.geo-browser-modal .geo-panel .ant-spin-container {
+  display: flex;
+  flex-direction: column;
+  flex: 1;
+  min-height: 0;
+  width: 100%;
+}
+
+.geo-browser-modal .geo-panel .ant-table {
+  flex: 1;
+  min-height: 0;
+}
+
+/* The rules table fills whatever is left between the header and the pager
+   instead of carrying a hardcoded scroll height, so there is no dead strip
+   above the pager and short categories do not scroll needlessly. */
+.geo-browser-modal .geo-preview-body {
+  flex: 1;
+  min-height: 0;
+  overflow-y: auto;
+}
+
+.geo-browser-modal .geo-pager {
+  margin-top: auto;
+  display: flex;
+  align-items: center;
+  justify-content: flex-end;
+  flex-wrap: wrap;
+  padding: 6px 12px;
+  border-top: 1px solid var(--ant-color-border-secondary);
+  font-variant-numeric: tabular-nums;
+}
+
+.geo-browser-modal .geo-pager .ant-pagination-total-text {
+  font-size: 12px;
+  color: var(--ant-color-text-tertiary);
+}
+
+.geo-browser-modal .geo-categories .ant-table-row {
+  cursor: pointer;
+}
+
+.geo-browser-modal .geo-row-active > td {
+  background: var(--ant-color-primary-bg);
+}
+
+.geo-browser-modal .geo-category {
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+  min-width: 0;
+}
+
+.geo-browser-modal .geo-code,
+.geo-browser-modal .geo-entry-value,
+.geo-browser-modal .geo-preview-title {
+  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+  font-size: 13px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.geo-browser-modal .geo-attrs .ant-tag {
+  font-size: 10px;
+  line-height: 16px;
+  margin-inline-end: 4px;
+  padding-inline: 4px;
+}
+
+.geo-browser-modal .geo-count {
+  font-variant-numeric: tabular-nums;
+  color: var(--ant-color-text-tertiary);
+  font-size: 12px;
+}
+
+.geo-browser-modal .geo-preview-head {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 8px 12px;
+  border-bottom: 1px solid var(--ant-color-border-secondary);
+}
+
+/* The title is the part that gives way: without min-width it refuses to
+   shrink, and the filter is pushed onto a second line instead of the long
+   category name being clipped. */
+.geo-browser-modal .geo-preview-title {
+  flex: 0 1 auto;
+  min-width: 0;
+}
+
+.geo-browser-modal .geo-preview-head .ant-typography {
+  flex: none;
+  white-space: nowrap;
+}
+
+.geo-browser-modal .geo-entry-filter {
+  flex: none;
+  width: 200px;
+  margin-inline-start: auto;
+}
+
+@media (max-width: 520px) {
+  .geo-browser-modal .geo-preview-head {
+    flex-wrap: wrap;
+  }
+
+  .geo-browser-modal .geo-entry-filter {
+    width: 100%;
+  }
+}
+
+.geo-browser-modal .geo-kind {
+  font-size: 10px;
+  text-transform: uppercase;
+  letter-spacing: 0.04em;
+}
+
+.geo-browser-modal .geo-kind-full {
+  color: var(--ant-color-success);
+}
+
+.geo-browser-modal .geo-kind-keyword {
+  color: var(--ant-color-warning);
+}
+
+.geo-browser-modal .geo-kind-regexp {
+  color: var(--ant-color-primary);
+}
+
+.geo-browser-modal .geo-placeholder {
+  flex: 1;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 40px 20px;
+  text-align: center;
+}
+
+.geo-browser-modal .geo-footer {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  flex-wrap: wrap;
+  margin-top: 12px;
+  padding-top: 12px;
+  border-top: 1px solid var(--ant-color-border-secondary);
+}
+
+.geo-browser-modal .geo-chips {
+  flex: 1;
+  max-height: 76px;
+  overflow-y: auto;
+}
+
+.geo-browser-modal .geo-selected-count {
+  font-size: 12px;
+  color: var(--ant-color-text-tertiary);
+  font-variant-numeric: tabular-nums;
+  white-space: nowrap;
+}
+
+@media (max-width: 720px) {
+  .geo-browser-modal .geo-columns {
+    grid-template-columns: minmax(0, 1fr);
+    height: auto;
+  }
+
+  .geo-browser-modal .geo-panel {
+    height: 320px;
+  }
+}
+
+.geo-unknown-hint {
+  display: block;
+  margin-top: 4px;
+  font-size: 12px;
+}

+ 457 - 0
frontend/src/components/geodata/GeoBrowserModal.stories.tsx

@@ -0,0 +1,457 @@
+import { useEffect, useState, type ReactNode } from 'react';
+import type { Decorator, Meta, StoryObj } from '@storybook/react-vite';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { expect, within } from 'storybook/test';
+import { Button, Space, Typography } from 'antd';
+
+import type { GeoCategory, GeoEntry, GeoFile } from '@/generated/types';
+
+import GeoBrowserModal, { type GeoBrowserModalProps } from './GeoBrowserModal';
+
+type GeoResponder = (query: URLSearchParams) => unknown;
+type GeoRoutes = Record<string, GeoResponder>;
+
+const realFetch = window.fetch.bind(window);
+let activeRoutes: GeoRoutes = {};
+
+function requestUrl(input: RequestInfo | URL): URL {
+  if (typeof input === 'string') return new URL(input, window.location.origin);
+  if (input instanceof URL) return input;
+  return new URL(input.url, window.location.origin);
+}
+
+function geoFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
+  const url = requestUrl(input);
+  const responder = activeRoutes[url.pathname];
+  if (!responder) return realFetch(input, init);
+  const body = JSON.stringify({ success: true, msg: '', obj: responder(url.searchParams) });
+  return Promise.resolve(
+    new Response(body, { status: 200, headers: { 'content-type': 'application/json' } }),
+  );
+}
+
+function activate(routes: GeoRoutes): void {
+  activeRoutes = routes;
+  window.fetch = geoFetch;
+}
+
+function deactivate(routes: GeoRoutes): void {
+  if (activeRoutes === routes) activeRoutes = {};
+}
+
+function GeoApi({ routes, children }: { routes: GeoRoutes; children: ReactNode }) {
+  const [client] = useState(() => {
+    activate(routes);
+    return new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
+  });
+  useEffect(() => {
+    activate(routes);
+    return () => deactivate(routes);
+  }, [routes]);
+  return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
+}
+
+const domain = (value: string): GeoEntry => ({ kind: 'domain', value });
+const full = (value: string): GeoEntry => ({ kind: 'full', value });
+const keyword = (value: string): GeoEntry => ({ kind: 'keyword', value });
+const regexp = (value: string): GeoEntry => ({ kind: 'regexp', value });
+const cidr = (value: string): GeoEntry => ({ kind: 'cidr', value });
+
+const cross = (names: string[], suffixes: string[]): GeoEntry[] =>
+  names.flatMap((name) => suffixes.map((suffix) => domain(`${name}.${suffix}`)));
+
+const CC_TLDS = [
+  'ae', 'al', 'am', 'at', 'az', 'ba', 'be', 'bg', 'bi', 'bj', 'ca', 'cat', 'cd', 'cf', 'cg', 'ch',
+  'ci', 'cl', 'cm', 'co.id', 'co.il', 'co.in', 'co.jp', 'co.ke', 'co.kr', 'co.ma', 'co.nz', 'co.th',
+  'co.uk', 'co.uz', 'co.ve', 'co.za', 'com.ar', 'com.au', 'com.bd', 'com.br', 'com.co', 'com.cu',
+  'com.eg', 'com.gt', 'com.hk', 'com.mx', 'com.my', 'com.ng', 'com.pe', 'com.ph', 'com.pk',
+  'com.sa', 'com.sg', 'com.tr', 'com.tw', 'com.ua', 'com.uy', 'com.vn', 'cz', 'de', 'dj', 'dk',
+  'dz', 'ee', 'es', 'fi', 'fr', 'ga', 'ge', 'gl', 'gm', 'gr', 'hn', 'hr', 'ht', 'hu', 'ie', 'iq',
+  'is', 'it', 'je', 'jo', 'kg', 'kz', 'la', 'li', 'lk', 'lt', 'lu', 'lv', 'ly', 'md', 'me', 'mg',
+  'mk', 'ml', 'mn', 'mu', 'mv', 'mw', 'ne', 'nl', 'no', 'nu', 'pl', 'pt', 'ro', 'rs', 'ru', 'rw',
+  'se', 'sh', 'si', 'sk', 'sm', 'sn', 'so', 'sr', 'st', 'td', 'tg', 'tk', 'tl', 'tm', 'tn', 'to',
+  'tt', 'vg', 'vu', 'ws',
+];
+
+const AD_HOSTS = [
+  'adform', 'adnxs', 'adroll', 'adsrvr', 'amplitude', 'appsflyer', 'bluekai', 'branch',
+  'casalemedia', 'criteo', 'flurry', 'moatads', 'mopub', 'openx', 'outbrain', 'pubmatic',
+  'quantserve', 'rubiconproject', 'scorecardresearch', 'sharethrough', 'smartadserver', 'taboola',
+  'teads', 'yieldmo', 'zemanta',
+];
+
+const CN_BRANDS = [
+  '58', 'alibaba', 'alipay', 'aliyun', 'baidu', 'bilibili', 'cnblogs', 'csdn', 'ctrip', 'douban',
+  'gitee', 'huawei', 'iqiyi', 'jd', 'kuaishou', 'meituan', 'netease', 'pinduoduo', 'qq', 'sina',
+  'sohu', 'taobao', 'tencent', 'tmall', 'toutiao', 'weibo', 'xiaomi', 'youku', 'zhihu',
+];
+
+const SITE_ENTRIES: Record<string, GeoEntry[]> = {
+  amazon: [
+    domain('amazon.com'), domain('amazonaws.com'), domain('media-amazon.com'),
+    domain('ssl-images-amazon.com'), domain('primevideo.com'), domain('awsstatic.com'),
+    domain('cloudfront.net'), full('www.amazon.co.jp'),
+  ],
+  apple: [
+    domain('apple.com'), domain('icloud.com'), domain('cdn-apple.com'), domain('mzstatic.com'),
+    domain('apple-cloudkit.com'), domain('itunes.com'), domain('me.com'), domain('appstore.com'),
+  ],
+  'category-ads': [
+    domain('adcolony.com'), domain('applovin.com'), domain('chartboost.com'),
+    domain('inmobi.com'), domain('unityads.unity3d.com'), keyword('banner-ad'),
+  ],
+  'category-ads-all': [
+    domain('doubleclick.net'), domain('googleadservices.com'), domain('googlesyndication.com'),
+    domain('adservice.google.com'), full('ads.yahoo.com'), keyword('adservice'),
+    keyword('advertising'), regexp('^ad[0-9]{1,3}\\.'), ...cross(AD_HOSTS, ['com', 'net', 'io', 'ru']),
+  ],
+  cloudflare: [
+    domain('cloudflare.com'), domain('cloudflare-dns.com'), domain('cloudflareinsights.com'),
+    domain('workers.dev'), domain('pages.dev'), domain('cf-ipfs.com'),
+  ],
+  cn: [full('www.gov.cn'), keyword('chinanet'), ...cross(CN_BRANDS, ['com', 'cn', 'com.cn'])],
+  discord: [
+    domain('discord.com'), domain('discord.gg'), domain('discordapp.com'),
+    domain('discordapp.net'), domain('discord.media'),
+  ],
+  facebook: [
+    domain('facebook.com'), domain('fbcdn.net'), domain('fb.com'), domain('messenger.com'),
+    domain('fbsbx.com'), domain('facebook.net'), full('m.facebook.com'),
+  ],
+  'geolocation-!cn': [
+    keyword('proxy'), regexp('.*\\.onion$'), domain('wikipedia.org'), domain('bbc.com'),
+    domain('nytimes.com'), domain('reuters.com'), domain('medium.com'), domain('reddit.com'),
+  ],
+  'geolocation-cn': [
+    domain('gov.cn'), domain('edu.cn'), domain('org.cn'), domain('net.cn'),
+    ...cross(CN_BRANDS.slice(0, 18), ['cn']),
+  ],
+  github: [
+    domain('github.com'), domain('githubusercontent.com'), domain('githubassets.com'),
+    domain('github.io'), domain('ghcr.io'), domain('git.io'),
+  ],
+  google: [
+    domain('google.com'), domain('googleapis.com'), domain('gstatic.com'),
+    domain('googleusercontent.com'), domain('google-analytics.com'), domain('googletagmanager.com'),
+    domain('ggpht.com'), domain('withgoogle.com'), domain('android.com'), domain('chromium.org'),
+    domain('abc.xyz'), full('dl.google.com'), ...CC_TLDS.map((tld) => domain(`google.${tld}`)),
+  ],
+  instagram: [domain('instagram.com'), domain('cdninstagram.com'), domain('ig.me')],
+  microsoft: [
+    domain('microsoft.com'), domain('live.com'), domain('office.com'), domain('office365.com'),
+    domain('windows.net'), domain('windowsupdate.com'), domain('msn.com'), domain('azure.com'),
+    domain('sharepoint.com'), domain('skype.com'), domain('bing.com'),
+  ],
+  netflix: [
+    domain('netflix.com'), domain('netflix.net'), domain('nflximg.com'), domain('nflximg.net'),
+    domain('nflxvideo.net'), domain('nflxso.net'), domain('nflxext.com'), full('fast.com'),
+  ],
+  openai: [
+    domain('openai.com'), domain('chatgpt.com'), domain('oaistatic.com'),
+    domain('oaiusercontent.com'), domain('sora.com'),
+  ],
+  spotify: [
+    domain('spotify.com'), domain('scdn.co'), domain('spotifycdn.com'), domain('spoti.fi'),
+    domain('spotifycdn.net'),
+  ],
+  steam: [
+    domain('steampowered.com'), domain('steamcommunity.com'), domain('steamstatic.com'),
+    domain('steamcontent.com'), domain('valvesoftware.com'),
+  ],
+  telegram: [
+    domain('telegram.org'), domain('telegram.me'), domain('t.me'), domain('telesco.pe'),
+    domain('tdesktop.com'), domain('telegra.ph'), domain('cdn-telegram.org'),
+    full('comments.app'), keyword('telegram'),
+  ],
+  tiktok: [
+    domain('tiktok.com'), domain('tiktokcdn.com'), domain('tiktokv.com'),
+    domain('byteoversea.com'), domain('ibytedtos.com'), domain('musical.ly'),
+  ],
+  twitch: [domain('twitch.tv'), domain('ttvnw.net'), domain('jtvnw.net'), domain('twitchcdn.net')],
+  twitter: [
+    domain('twitter.com'), domain('x.com'), domain('t.co'), domain('twimg.com'),
+    domain('periscope.tv'),
+  ],
+  whatsapp: [domain('whatsapp.com'), domain('whatsapp.net'), domain('wa.me')],
+  youtube: [
+    domain('youtube.com'), domain('youtu.be'), domain('ytimg.com'), domain('googlevideo.com'),
+    domain('youtube-nocookie.com'), domain('yt.be'),
+  ],
+};
+
+const SITE_ATTRIBUTES: Record<string, string[]> = {
+  amazon: ['ads'],
+  apple: ['cn'],
+  facebook: ['ads'],
+  google: ['ads', 'cn'],
+  instagram: ['ads'],
+  microsoft: ['cn'],
+  tiktok: ['ads', 'cn'],
+  twitter: ['ads'],
+  youtube: ['ads'],
+};
+
+const CN_BLOCKS = [
+  '1.0.1.0/24', '1.0.2.0/23', '1.0.8.0/21', '14.0.12.0/22', '27.0.128.0/21', '36.0.0.0/22',
+  '39.0.0.0/24', '42.0.0.0/22', '58.14.0.0/15', '59.32.0.0/11', '61.128.0.0/10', '101.16.0.0/12',
+  '103.1.8.0/22', '106.0.0.0/10', '110.6.0.0/15', '111.0.0.0/10', '112.0.0.0/10', '113.0.0.0/9',
+  '114.28.0.0/16', '116.0.0.0/9', '117.8.0.0/13', '118.24.0.0/15', '119.0.0.0/9', '120.0.0.0/10',
+  '121.0.0.0/8', '124.0.0.0/8', '125.32.0.0/11', '139.196.0.0/14', '140.75.0.0/16', '175.0.0.0/12',
+  '180.76.0.0/16', '182.16.0.0/12', '183.0.0.0/10', '202.0.0.0/12', '203.0.0.0/12', '210.0.0.0/12',
+  '211.64.0.0/11', '218.0.0.0/9', '219.72.0.0/14', '220.112.0.0/12', '221.0.0.0/9', '222.16.0.0/12',
+  '2001:250::/35', '2400:3200::/32', '2408:8000::/20',
+];
+
+const CN_EXTRA_BLOCKS = Array.from({ length: 96 }, (_, index) =>
+  `${39 + Math.floor(index / 16)}.${(index % 16) * 16}.0.0/12`,
+);
+
+const IP_ENTRIES: Record<string, GeoEntry[]> = {
+  cloudflare: [
+    '103.21.244.0/22', '103.22.200.0/22', '103.31.4.0/22', '104.16.0.0/13', '104.24.0.0/14',
+    '108.162.192.0/18', '131.0.72.0/22', '141.101.64.0/18', '162.158.0.0/15', '172.64.0.0/13',
+    '173.245.48.0/20', '188.114.96.0/20', '190.93.240.0/20', '197.234.240.0/22', '198.41.128.0/17',
+    '2400:cb00::/32', '2606:4700::/32',
+  ].map(cidr),
+  cn: [...CN_BLOCKS, ...CN_EXTRA_BLOCKS].map(cidr),
+  facebook: [
+    '31.13.24.0/21', '31.13.64.0/18', '66.220.144.0/20', '69.63.176.0/20', '69.171.224.0/19',
+    '157.240.0.0/16', '179.60.192.0/22', '185.60.216.0/22', '2a03:2880::/32',
+  ].map(cidr),
+  google: [
+    '8.8.4.0/24', '8.8.8.0/24', '34.64.0.0/10', '35.184.0.0/13', '64.233.160.0/19', '66.102.0.0/20',
+    '72.14.192.0/18', '74.125.0.0/16', '108.177.8.0/21', '142.250.0.0/15', '172.217.0.0/16',
+    '216.58.192.0/19', '2404:6800::/32', '2607:f8b0::/32',
+  ].map(cidr),
+  ir: [
+    '2.144.0.0/14', '5.22.0.0/17', '31.2.128.0/17', '37.32.0.0/19', '46.32.0.0/19', '78.38.0.0/15',
+    '80.191.0.0/16', '85.15.0.0/18', '91.98.0.0/15', '178.22.72.0/21', '185.8.172.0/22',
+    '188.34.0.0/17', '217.218.0.0/15',
+  ].map(cidr),
+  netflix: [
+    '23.246.0.0/18', '37.77.184.0/21', '45.57.0.0/17', '64.120.128.0/17', '66.197.128.0/17',
+    '108.175.32.0/20', '185.2.220.0/22', '192.173.64.0/18', '198.38.96.0/19', '198.45.48.0/20',
+  ].map(cidr),
+  private: [
+    '0.0.0.0/8', '10.0.0.0/8', '100.64.0.0/10', '127.0.0.0/8', '169.254.0.0/16', '172.16.0.0/12',
+    '192.0.0.0/24', '192.0.2.0/24', '192.168.0.0/16', '198.18.0.0/15', '198.51.100.0/24',
+    '203.0.113.0/24', '224.0.0.0/4', '240.0.0.0/4', '255.255.255.255/32', '::1/128', 'fc00::/7',
+    'fe80::/10',
+  ].map(cidr),
+  ru: [
+    '2.60.0.0/14', '5.8.0.0/19', '31.6.0.0/17', '37.9.0.0/19', '46.16.0.0/21', '62.76.0.0/18',
+    '77.37.128.0/17', '78.24.216.0/21', '79.104.0.0/15', '80.64.128.0/19', '81.16.96.0/19',
+    '82.140.128.0/18', '85.113.0.0/16', '87.226.0.0/16', '91.77.0.0/16', '93.157.0.0/17',
+    '94.19.0.0/16', '95.24.0.0/13', '178.176.0.0/13', '188.128.0.0/13', '213.87.0.0/16',
+    '217.66.152.0/21', '2a00:1148::/32',
+  ].map(cidr),
+  telegram: [
+    '91.108.4.0/22', '91.108.8.0/22', '91.108.12.0/22', '91.108.16.0/22', '91.108.20.0/22',
+    '91.108.56.0/22', '149.154.160.0/20', '2001:67c:4e8::/48', '2001:b28:f23d::/48',
+    '2001:b28:f23f::/48',
+  ].map(cidr),
+  us: [
+    '3.0.0.0/9', '12.0.0.0/8', '23.192.0.0/11', '34.192.0.0/10', '50.16.0.0/14', '52.0.0.0/10',
+    '63.64.0.0/11', '65.0.0.0/10', '68.32.0.0/11', '71.0.0.0/11', '96.0.0.0/9', '128.0.0.0/10',
+    '199.0.0.0/12', '208.64.0.0/12', '2600:1f00::/24',
+  ].map(cidr),
+};
+
+function categoriesOf(
+  entries: Record<string, GeoEntry[]>,
+  attributes: Record<string, string[]> = {},
+): GeoCategory[] {
+  return Object.keys(entries)
+    .sort()
+    .map((code) => ({ code, entries: entries[code].length, attributes: attributes[code] ?? [] }));
+}
+
+const SITE_CATEGORIES = categoriesOf(SITE_ENTRIES, SITE_ATTRIBUTES);
+const IP_CATEGORIES = categoriesOf(IP_ENTRIES);
+
+const UPDATED_AT = Date.UTC(2026, 6, 24, 3, 12);
+
+const GEOSITE_FILE: GeoFile = {
+  name: 'geosite.dat',
+  kind: 'site',
+  size: 4_812_544,
+  modifiedAt: UPDATED_AT,
+  categories: SITE_CATEGORIES.length,
+};
+
+const GEOIP_FILE: GeoFile = {
+  name: 'geoip.dat',
+  kind: 'ip',
+  size: 8_694_272,
+  modifiedAt: UPDATED_AT,
+  categories: IP_CATEGORIES.length,
+};
+
+const DAMAGED_FILE: GeoFile = {
+  name: 'geosite-custom.dat',
+  kind: 'site',
+  size: 262_144,
+  modifiedAt: Date.UTC(2026, 5, 2, 19, 45),
+  categories: 0,
+  error: 'proto: cannot parse invalid wire-format data',
+};
+
+const OVERSIZED_FILE: GeoFile = {
+  name: 'geoip-full.dat',
+  kind: 'ip',
+  size: 96_468_992,
+  modifiedAt: Date.UTC(2026, 6, 20, 8, 5),
+  categories: 0,
+  error: 'geodata file is too large to browse',
+};
+
+const DATASETS: Record<string, { categories: GeoCategory[]; entries: Record<string, GeoEntry[]> }> = {
+  'geosite.dat': { categories: SITE_CATEGORIES, entries: SITE_ENTRIES },
+  'geoip.dat': { categories: IP_CATEGORIES, entries: IP_ENTRIES },
+};
+
+function routesFor(files: GeoFile[]): GeoRoutes {
+  return {
+    '/panel/api/xray/geodata/files': () => files,
+    '/panel/api/xray/geodata/categories': (query) => {
+      const dataset = DATASETS[query.get('file') ?? ''];
+      const needle = (query.get('q') ?? '').trim().toLowerCase();
+      const items = (dataset?.categories ?? []).filter((category) => category.code.includes(needle));
+      return { total: items.length, items };
+    },
+    '/panel/api/xray/geodata/entries': (query) => {
+      const dataset = DATASETS[query.get('file') ?? ''];
+      const needle = (query.get('q') ?? '').trim().toLowerCase();
+      const matched = (dataset?.entries[query.get('code') ?? ''] ?? []).filter((entry) =>
+        entry.value.toLowerCase().includes(needle),
+      );
+      const offset = Number(query.get('offset') ?? 0);
+      const limit = Number(query.get('limit') ?? 100);
+      return { total: matched.length, items: matched.slice(offset, offset + limit) };
+    },
+  };
+}
+
+function withFiles(files: GeoFile[]): Decorator {
+  const routes = routesFor(files);
+  return function GeodataBackend(Story) {
+    return (
+      <GeoApi routes={routes}>
+        <Story />
+      </GeoApi>
+    );
+  };
+}
+
+const withDatabases = withFiles([GEOSITE_FILE, GEOIP_FILE]);
+
+function BrowserDemo(props: GeoBrowserModalProps) {
+  const [open, setOpen] = useState(props.open);
+  const [value, setValue] = useState(props.value);
+  useEffect(() => setOpen(props.open), [props.open]);
+  useEffect(() => setValue(props.value), [props.value]);
+  return (
+    <Space direction="vertical" size={12}>
+      <Space size={8}>
+        <Button onClick={() => setOpen(true)}>Open geo browser</Button>
+        <Typography.Text code>{value || 'no rule yet'}</Typography.Text>
+      </Space>
+      <GeoBrowserModal
+        {...props}
+        open={open}
+        value={value}
+        onApply={(next) => {
+          setValue(next);
+          setOpen(false);
+        }}
+        onClose={() => setOpen(false)}
+      />
+    </Space>
+  );
+}
+
+const meta = {
+  title: 'Geodata/GeoBrowserModal',
+  component: GeoBrowserModal,
+  tags: ['autodocs'],
+  parameters: {
+    layout: 'padded',
+    a11y: {
+      config: {
+        rules: [{ id: 'color-contrast', enabled: false }],
+      },
+    },
+    docs: {
+      description: {
+        component:
+          'Browser for the geosite/geoip `.dat` databases Xray resolves `geosite:` and `geoip:` routing tokens against: pick a database, search its categories, tick the ones a rule needs, and preview the domains or CIDRs inside the highlighted category. Applying merges the ticked categories back into the rule string, keeping hand-typed domains untouched. The stories serve `/panel/api/xray/geodata/*` from an in-memory fixture, so search, paging and selection all work without a panel backend.',
+      },
+    },
+  },
+  args: {
+    open: true,
+    kind: 'site',
+    value: '',
+    onApply: () => undefined,
+    onClose: () => undefined,
+  },
+  argTypes: {
+    open: { description: 'Whether the modal is visible.' },
+    kind: {
+      description: 'Which database layout the rule targets: `site` for domain rules, `ip` for CIDR rules. Decides the preselected database and the token prefix.',
+      control: 'inline-radio',
+      options: ['site', 'ip'],
+    },
+    value: {
+      description: 'Current rule string, comma separated. Tokens that match a category in the opened database come back preselected.',
+    },
+    onApply: { description: 'Called with the merged rule string when Apply is pressed.' },
+    onClose: { description: 'Called when the modal is dismissed.' },
+  },
+  render: (args) => <BrowserDemo {...args} />,
+} satisfies Meta<typeof GeoBrowserModal>;
+
+export default meta;
+
+type Story = StoryObj<typeof meta>;
+
+export const SiteDatabase: Story = {
+  decorators: [withDatabases],
+  args: { kind: 'site', value: 'geosite:google, geosite:telegram, ads.example.com' },
+};
+
+export const CategoryPreview: Story = {
+  decorators: [withDatabases],
+  args: { kind: 'site', value: 'geosite:google' },
+  parameters: {
+    a11y: {
+      config: {
+        rules: [
+          { id: 'color-contrast', enabled: false },
+          { id: 'scrollable-region-focusable', enabled: false },
+        ],
+      },
+    },
+  },
+  play: async ({ canvasElement, userEvent }) => {
+    const body = within(canvasElement.ownerDocument.body);
+    await userEvent.type(await body.findByPlaceholderText('Search category'), 'telegram');
+    await userEvent.click(await body.findByText('telegram'));
+    await expect(await body.findByText('t.me')).toBeVisible();
+  },
+};
+
+export const IpDatabase: Story = {
+  decorators: [withDatabases],
+  args: { kind: 'ip', value: 'geoip:private, 10.0.0.0/8' },
+};
+
+export const NoDatabases: Story = {
+  decorators: [withFiles([])],
+  args: { kind: 'site', value: 'geosite:google' },
+};
+
+export const DamagedDatabase: Story = {
+  decorators: [withFiles([GEOSITE_FILE, DAMAGED_FILE, OVERSIZED_FILE])],
+  args: { kind: 'site', value: '' },
+};

+ 413 - 0
frontend/src/components/geodata/GeoBrowserModal.tsx

@@ -0,0 +1,413 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Alert, Button, Empty, Input, Modal, Pagination, Select, Space, Table, Tag, Tooltip, Typography } from 'antd';
+import type { ColumnsType } from 'antd/es/table';
+
+import { useGeodataCategories, useGeodataEntries, useGeodataFiles } from '@/api/queries/useGeodata';
+import { canonicalToken, mergeSelection, selectionFromValue, tokenFor } from '@/lib/xray/geoTokens';
+import { SizeFormatter } from '@/utils';
+import type { GeoCategory, GeoEntry, GeoFile, GeoKind } from '@/generated/types';
+
+import './GeoBrowserModal.css';
+
+const ENTRY_PAGE_SIZE = 100;
+const CATEGORY_SCROLL_HEIGHT = 438;
+const ENTRY_FILTER_DELAY = 500;
+
+export interface GeoBrowserModalProps {
+  open: boolean;
+  kind: GeoKind;
+  value: string;
+  onApply: (value: string) => void;
+  onClose: () => void;
+}
+
+// A geosite category inside an ip rule (or the reverse) is a config Xray will
+// reject, so a field only ever offers databases of its own kind.
+function databasesFor(files: GeoFile[], kind: GeoKind): GeoFile[] {
+  return files.filter((file) => file.kind === kind || (file.error && namePrefersKind(file.name, kind)));
+}
+
+function namePrefersKind(name: string, kind: GeoKind): boolean {
+  return name.toLowerCase().includes('ip') === (kind === 'ip');
+}
+
+function preferredFile(files: GeoFile[], kind: GeoKind): string | undefined {
+  const usable = databasesFor(files, kind).filter((file) => !file.error);
+  const preferredName = kind === 'ip' ? 'geoip.dat' : 'geosite.dat';
+  return usable.find((file) => file.name === preferredName)?.name ?? usable[0]?.name;
+}
+
+export default function GeoBrowserModal({ open, kind, value, onApply, onClose }: GeoBrowserModalProps) {
+  const { t } = useTranslation();
+  const [file, setFile] = useState<string | undefined>(undefined);
+  const [categoryQuery, setCategoryQuery] = useState('');
+  const [activeCode, setActiveCode] = useState<string | undefined>(undefined);
+  const [entryQuery, setEntryQuery] = useState('');
+  const [entryFilter, setEntryFilter] = useState('');
+  const [entryPage, setEntryPage] = useState(1);
+  const [selected, setSelected] = useState<string[]>([]);
+
+  const knownRef = useRef<Set<string>>(new Set());
+  const seededFilesRef = useRef<Set<string>>(new Set());
+
+  const filesQuery = useGeodataFiles(open);
+  const files = useMemo(() => databasesFor(filesQuery.data ?? [], kind), [filesQuery.data, kind]);
+  const activeFile = files.find((candidate) => candidate.name === file);
+  const fileKind: GeoKind = activeFile?.kind ?? kind;
+
+  const categoriesQuery = useGeodataCategories(file, '', open && !!file);
+  // While a newly picked database loads, the query still serves the previous
+  // one's categories; seeding or filtering against those would attribute one
+  // database's codes to another.
+  const categoriesLoaded = !categoriesQuery.isPlaceholderData && !categoriesQuery.isLoading;
+  const categories = useMemo(
+    () => (categoriesLoaded ? (categoriesQuery.data?.items ?? []) : []),
+    [categoriesLoaded, categoriesQuery.data],
+  );
+
+  // Only the settled filter reaches the query key: every request rescans the
+  // whole .dat file server-side, so a per-keystroke fetch would be one full
+  // scan per character while the box itself stays instant.
+  const entriesQuery = useGeodataEntries(
+    file,
+    activeCode,
+    entryFilter,
+    (entryPage - 1) * ENTRY_PAGE_SIZE,
+    ENTRY_PAGE_SIZE,
+    open && !!file && !!activeCode,
+  );
+
+  // Resets clear both halves at once so a switch of database or category never
+  // renders with the previous filter still in the key, which would fire the
+  // very request the debounce exists to avoid.
+  const clearEntryFilter = useCallback(() => {
+    setEntryQuery('');
+    setEntryFilter('');
+    setEntryPage(1);
+  }, []);
+
+  useEffect(() => {
+    if (entryQuery === entryFilter) return;
+    const handle = window.setTimeout(() => {
+      setEntryFilter(entryQuery);
+      setEntryPage(1);
+    }, ENTRY_FILTER_DELAY);
+    return () => window.clearTimeout(handle);
+  }, [entryQuery, entryFilter]);
+
+  useEffect(() => {
+    if (!open) return;
+    knownRef.current = new Set();
+    seededFilesRef.current = new Set();
+    setCategoryQuery('');
+    setEntryQuery('');
+    setEntryFilter('');
+    setActiveCode(undefined);
+    setEntryPage(1);
+    setSelected([]);
+  }, [open]);
+
+  useEffect(() => {
+    if (!open || file || files.length === 0) return;
+    setFile(preferredFile(files, kind));
+  }, [open, file, files, kind]);
+
+  useEffect(() => {
+    if (!open || !file || categories.length === 0 || seededFilesRef.current.has(file)) return;
+    const tokens = categories.map((category) => tokenFor(file, category.code, fileKind));
+    for (const token of tokens) knownRef.current.add(token);
+    seededFilesRef.current.add(file);
+    const fromValue = selectionFromValue(value, new Set(tokens));
+    if (fromValue.length > 0) {
+      setSelected((previous) => [...previous, ...fromValue.filter((token) => !previous.includes(token))]);
+    }
+  }, [open, file, categories, fileKind, value]);
+
+  const visibleCategories = useMemo(() => {
+    const query = categoryQuery.trim().toLowerCase();
+    if (!query) return categories;
+    return categories.filter((category) => category.code.includes(query));
+  }, [categories, categoryQuery]);
+
+  // Comparisons run through the canonical form: a field may hold the long
+  // ext:geosite.dat:cn spelling or a different case, and those name the same
+  // category as the geosite:cn this modal generates.
+  const selectedCodes = useMemo(() => {
+    if (!file) return [];
+    const chosen = new Set(selected.map(canonicalToken));
+    return categories
+      .filter((category) => chosen.has(canonicalToken(tokenFor(file, category.code, fileKind))))
+      .map((category) => category.code);
+  }, [categories, file, fileKind, selected]);
+
+  const toggle = useCallback(
+    (codes: string[]) => {
+      if (!file) return;
+      const chosen = new Set(codes.map((code) => tokenFor(file, code, fileKind)));
+      const chosenCanonical = new Set([...chosen].map(canonicalToken));
+      // The table reports keys for the rows it currently shows, so a selection
+      // made before the search box was narrowed must survive untouched.
+      const shown = new Set(
+        visibleCategories.map((category) => canonicalToken(tokenFor(file, category.code, fileKind))),
+      );
+      setSelected((previous) => {
+        const kept = previous.filter((token) => {
+          const canonical = canonicalToken(token);
+          return !shown.has(canonical) || chosenCanonical.has(canonical);
+        });
+        const keptCanonical = new Set(kept.map(canonicalToken));
+        return [...kept, ...[...chosen].filter((token) => !keptCanonical.has(canonicalToken(token)))];
+      });
+    },
+    [visibleCategories, file, fileKind],
+  );
+
+  const categoryColumns: ColumnsType<GeoCategory> = useMemo(
+    () => [
+      {
+        title: t('pages.xray.geoBrowser.searchCategory'),
+        dataIndex: 'code',
+        render: (code: string, category: GeoCategory) => (
+          <span className="geo-category">
+            <span className="geo-code">{code}</span>
+            {category.attributes?.length > 0 && (
+              <span className="geo-attrs">
+                {category.attributes.map((attribute) => (
+                  <Tag key={attribute} bordered={false}>
+                    @{attribute}
+                  </Tag>
+                ))}
+              </span>
+            )}
+          </span>
+        ),
+      },
+      {
+        dataIndex: 'entries',
+        align: 'right',
+        width: 90,
+        render: (entries: number) => <span className="geo-count">{entries.toLocaleString()}</span>,
+      },
+    ],
+    [t],
+  );
+
+  const entryColumns: ColumnsType<GeoEntry> = useMemo(
+    () => [
+      {
+        dataIndex: 'kind',
+        width: 88,
+        render: (entryKind: string) => (
+          <Tag bordered={false} className={`geo-kind geo-kind-${entryKind}`}>
+            {entryKind}
+          </Tag>
+        ),
+      },
+      {
+        dataIndex: 'value',
+        render: (entryValue: string) => <span className="geo-entry-value">{entryValue}</span>,
+      },
+    ],
+    [],
+  );
+
+  const fileOptions = files.map((candidate) => ({
+    value: candidate.name,
+    label: candidate.error ? `${candidate.name} — ${describeFileError(candidate.error, t)}` : candidate.name,
+    disabled: !!candidate.error,
+  }));
+
+  const meta = activeFile
+    ? t('pages.xray.geoBrowser.fileMeta', {
+        count: activeFile.categories.toLocaleString(),
+        size: SizeFormatter.sizeFormat(activeFile.size),
+        date: new Date(activeFile.modifiedAt).toLocaleString(),
+      })
+    : '';
+
+  const entriesTotal = entriesQuery.data?.total ?? 0;
+  const activeCategory = categories.find((category) => category.code === activeCode);
+  const countLabel = activeCategory
+    ? t(fileKind === 'ip' ? 'pages.xray.geoBrowser.subnetsCount' : 'pages.xray.geoBrowser.entriesCount', {
+        count: activeCategory.entries.toLocaleString(),
+      })
+    : '';
+
+  return (
+    <Modal
+      open={open}
+      title={t('pages.xray.geoBrowser.title')}
+      width={880}
+      onCancel={onClose}
+      onOk={() => onApply(mergeSelection(value, selected, knownRef.current))}
+      okText={t('pages.xray.geoBrowser.apply')}
+      cancelText={t('close')}
+      className="geo-browser-modal"
+    >
+      {filesQuery.isError && <Alert type="error" showIcon title={t('pages.xray.geoBrowser.loadFailed')} className="mb-12" />}
+
+      {!filesQuery.isError && !filesQuery.isLoading && files.length === 0 ? (
+        <Empty
+          description={
+            <span>
+              {t('pages.xray.geoBrowser.noFiles')}
+              <br />
+              <Typography.Text type="secondary">{t('pages.xray.geoBrowser.noFilesHint')}</Typography.Text>
+            </span>
+          }
+        />
+      ) : (
+        <>
+          <div className="geo-toolbar">
+            <Select
+              value={file}
+              options={fileOptions}
+              onChange={(next) => {
+                setFile(next);
+                setActiveCode(undefined);
+                setCategoryQuery('');
+                clearEntryFilter();
+              }}
+              style={{ minWidth: 200 }}
+              aria-label={t('pages.xray.geoBrowser.database')}
+            />
+            <Input.Search
+              value={categoryQuery}
+              onChange={(event) => setCategoryQuery(event.target.value)}
+              placeholder={t('pages.xray.geoBrowser.searchCategory')}
+              allowClear
+            />
+            <Button
+              onClick={() => toggle([...new Set([...selectedCodes, ...visibleCategories.map((c) => c.code)])])}
+              disabled={visibleCategories.length === 0}
+            >
+              {`${t('pages.xray.geoBrowser.selectFound')} (${visibleCategories.length.toLocaleString()})`}
+            </Button>
+            <span className="geo-meta">{meta}</span>
+          </div>
+
+          <div className="geo-columns">
+            <div className="geo-panel geo-categories">
+              <Table
+                size="small"
+                virtual
+                showHeader={false}
+                rowKey="code"
+                columns={categoryColumns}
+                dataSource={visibleCategories}
+                loading={filesQuery.isLoading || categoriesQuery.isLoading || categoriesQuery.isPlaceholderData}
+                pagination={false}
+                scroll={{ y: CATEGORY_SCROLL_HEIGHT }}
+                locale={{ emptyText: t('pages.xray.geoBrowser.noMatches') }}
+                rowSelection={{
+                  columnWidth: 42,
+                  preserveSelectedRowKeys: true,
+                  selectedRowKeys: selectedCodes,
+                  onChange: (keys) => toggle(keys as string[]),
+                }}
+                onRow={(category) => ({
+                  onClick: (event) => {
+                    if ((event.target as HTMLElement).closest('.ant-table-selection-column')) return;
+                    setActiveCode(category.code);
+                    clearEntryFilter();
+                  },
+                })}
+                rowClassName={(category) => (category.code === activeCode ? 'geo-row-active' : '')}
+              />
+            </div>
+
+            <div className="geo-panel geo-preview">
+              {activeCode ? (
+                <>
+                  <div className="geo-preview-head">
+                    <Tooltip title={file ? tokenFor(file, activeCode, fileKind) : activeCode}>
+                      <span className="geo-preview-title">{activeCode}</span>
+                    </Tooltip>
+                    <Typography.Text type="secondary">{countLabel}</Typography.Text>
+                    <Input
+                      value={entryQuery}
+                      onChange={(event) => setEntryQuery(event.target.value)}
+                      placeholder={t('pages.xray.geoBrowser.searchEntries')}
+                      allowClear
+                      className="geo-entry-filter"
+                    />
+                  </div>
+                  <div className="geo-preview-body">
+                    <Table
+                      size="small"
+                      showHeader={false}
+                      rowKey={(entry, index) => `${entry.value}-${index}`}
+                      columns={entryColumns}
+                      dataSource={entriesQuery.data?.items ?? []}
+                      loading={entriesQuery.isLoading}
+                      locale={{
+                        emptyText: entriesQuery.isError
+                          ? t('pages.xray.geoBrowser.loadFailed')
+                          : t('pages.xray.geoBrowser.noMatches'),
+                      }}
+                      pagination={false}
+                    />
+                  </div>
+                  <div className="geo-pager">
+                    <Pagination
+                      current={entryPage}
+                      pageSize={ENTRY_PAGE_SIZE}
+                      total={entriesTotal}
+                      size="small"
+                      showSizeChanger={false}
+                      onChange={setEntryPage}
+                      showTotal={(total, range) =>
+                        t('pages.xray.geoBrowser.shownRange', {
+                          from: range[0].toLocaleString(),
+                          to: range[1].toLocaleString(),
+                          total: total.toLocaleString(),
+                        })
+                      }
+                    />
+                  </div>
+                </>
+              ) : (
+                <div className="geo-placeholder">
+                  <Typography.Text type="secondary">{t('pages.xray.geoBrowser.pickCategory')}</Typography.Text>
+                </div>
+              )}
+            </div>
+          </div>
+
+          <div className="geo-footer">
+            {selected.length === 0 ? (
+              <Typography.Text type="secondary">{t('pages.xray.geoBrowser.emptySelection')}</Typography.Text>
+            ) : (
+              <>
+                <Space size={4} wrap className="geo-chips">
+                  {selected.map((token) => (
+                    <Tag
+                      key={token}
+                      closable
+                      color="processing"
+                      onClose={() => setSelected((previous) => previous.filter((item) => item !== token))}
+                    >
+                      {token}
+                    </Tag>
+                  ))}
+                </Space>
+                <span className="geo-selected-count">
+                  {t('pages.xray.geoBrowser.selected', { count: selected.length })}
+                </span>
+                <Button type="link" size="small" onClick={() => setSelected([])}>
+                  {t('pages.xray.geoBrowser.clearAll')}
+                </Button>
+              </>
+            )}
+          </div>
+        </>
+      )}
+    </Modal>
+  );
+}
+
+function describeFileError(error: string, t: (key: string) => string): string {
+  if (error.includes('too large')) return t('pages.xray.geoBrowser.tooLarge');
+  return t('pages.xray.geoBrowser.parseFailed');
+}

+ 247 - 0
frontend/src/components/geodata/GeoTokenInput.stories.tsx

@@ -0,0 +1,247 @@
+import { useEffect, useState, type ReactNode } from 'react';
+import type { Decorator, Meta, StoryObj } from '@storybook/react-vite';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { expect, within } from 'storybook/test';
+import { Space } from 'antd';
+
+import { parseTokens } from '@/lib/xray/geoTokens';
+import type { GeoCategory, GeoEntry, GeoFile, GeodataTokenIssue } from '@/generated/types';
+
+import GeoTokenInput, { type GeoTokenInputProps } from './GeoTokenInput';
+
+type GeoResponder = (query: URLSearchParams, body: URLSearchParams) => unknown;
+type GeoRoutes = Record<string, GeoResponder>;
+
+const realFetch = window.fetch.bind(window);
+let activeRoutes: GeoRoutes = {};
+
+function requestUrl(input: RequestInfo | URL): URL {
+  if (typeof input === 'string') return new URL(input, window.location.origin);
+  if (input instanceof URL) return input;
+  return new URL(input.url, window.location.origin);
+}
+
+function geoFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
+  const url = requestUrl(input);
+  const responder = activeRoutes[url.pathname];
+  if (!responder) return realFetch(input, init);
+  const form = new URLSearchParams(typeof init?.body === 'string' ? init.body : '');
+  const body = JSON.stringify({ success: true, msg: '', obj: responder(url.searchParams, form) });
+  return Promise.resolve(
+    new Response(body, { status: 200, headers: { 'content-type': 'application/json' } }),
+  );
+}
+
+function activate(routes: GeoRoutes): void {
+  activeRoutes = routes;
+  window.fetch = geoFetch;
+}
+
+function deactivate(routes: GeoRoutes): void {
+  if (activeRoutes === routes) activeRoutes = {};
+}
+
+function GeoApi({ routes, children }: { routes: GeoRoutes; children: ReactNode }) {
+  const [client] = useState(() => {
+    activate(routes);
+    return new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
+  });
+  useEffect(() => {
+    activate(routes);
+    return () => deactivate(routes);
+  }, [routes]);
+  return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
+}
+
+const domain = (value: string): GeoEntry => ({ kind: 'domain', value });
+const cidr = (value: string): GeoEntry => ({ kind: 'cidr', value });
+
+const SITE_ENTRIES: Record<string, GeoEntry[]> = {
+  'category-ads-all': [
+    domain('doubleclick.net'), domain('googleadservices.com'), domain('googlesyndication.com'),
+    domain('criteo.com'), domain('taboola.com'), domain('outbrain.com'),
+  ],
+  cn: [domain('baidu.com'), domain('qq.com'), domain('taobao.com'), domain('weibo.com'), domain('bilibili.com')],
+  google: [
+    domain('google.com'), domain('googleapis.com'), domain('gstatic.com'),
+    domain('googleusercontent.com'), domain('ggpht.com'), domain('android.com'),
+  ],
+  netflix: [domain('netflix.com'), domain('nflximg.net'), domain('nflxvideo.net'), domain('fast.com')],
+  telegram: [domain('telegram.org'), domain('t.me'), domain('telesco.pe'), domain('telegra.ph')],
+  youtube: [domain('youtube.com'), domain('youtu.be'), domain('ytimg.com'), domain('googlevideo.com')],
+};
+
+const IP_ENTRIES: Record<string, GeoEntry[]> = {
+  cloudflare: ['104.16.0.0/13', '172.64.0.0/13', '2606:4700::/32'].map(cidr),
+  cn: ['1.0.1.0/24', '36.0.0.0/22', '116.0.0.0/9', '2408:8000::/20'].map(cidr),
+  private: [
+    '10.0.0.0/8', '127.0.0.0/8', '169.254.0.0/16', '172.16.0.0/12', '192.168.0.0/16',
+    '::1/128', 'fc00::/7', 'fe80::/10',
+  ].map(cidr),
+  telegram: ['91.108.4.0/22', '149.154.160.0/20', '2001:b28:f23d::/48'].map(cidr),
+};
+
+const SITE_ATTRIBUTES: Record<string, string[]> = {
+  google: ['ads', 'cn'],
+  youtube: ['ads'],
+};
+
+function categoriesOf(
+  entries: Record<string, GeoEntry[]>,
+  attributes: Record<string, string[]> = {},
+): GeoCategory[] {
+  return Object.keys(entries)
+    .sort()
+    .map((code) => ({ code, entries: entries[code].length, attributes: attributes[code] ?? [] }));
+}
+
+const DATASETS: Record<string, { categories: GeoCategory[]; entries: Record<string, GeoEntry[]> }> = {
+  'geosite.dat': { categories: categoriesOf(SITE_ENTRIES, SITE_ATTRIBUTES), entries: SITE_ENTRIES },
+  'geoip.dat': { categories: categoriesOf(IP_ENTRIES), entries: IP_ENTRIES },
+};
+
+const UPDATED_AT = Date.UTC(2026, 6, 24, 3, 12);
+
+const FILES: GeoFile[] = [
+  {
+    name: 'geosite.dat',
+    kind: 'site',
+    size: 4_812_544,
+    modifiedAt: UPDATED_AT,
+    categories: DATASETS['geosite.dat'].categories.length,
+  },
+  {
+    name: 'geoip.dat',
+    kind: 'ip',
+    size: 8_694_272,
+    modifiedAt: UPDATED_AT,
+    categories: DATASETS['geoip.dat'].categories.length,
+  },
+];
+
+function referenceOf(token: string, isIP: boolean): { file: string; code: string } | null {
+  const [prefix, ...rest] = token.split(':');
+  const code = (value: string) => value.split('@')[0].toLowerCase();
+  if (prefix === 'geosite') return { file: 'geosite.dat', code: code(rest.join(':')) };
+  if (prefix === 'geoip') return { file: 'geoip.dat', code: code(rest.join(':')) };
+  if (prefix === 'ext') return { file: rest[0] ?? '', code: code(rest.slice(1).join(':')) };
+  return isIP && prefix === 'ext-ip' ? { file: rest[0] ?? '', code: code(rest.slice(1).join(':')) } : null;
+}
+
+function validate(tokens: string[], isIP: boolean): GeodataTokenIssue[] {
+  const issues: GeodataTokenIssue[] = [];
+  for (const token of tokens) {
+    const reference = referenceOf(token, isIP);
+    if (!reference) continue;
+    const dataset = DATASETS[reference.file];
+    if (!dataset) {
+      issues.push({ token, reason: 'fileMissing', file: reference.file, code: reference.code });
+      continue;
+    }
+    if (!dataset.categories.some((category) => category.code === reference.code)) {
+      issues.push({ token, reason: 'categoryMissing', file: reference.file, code: reference.code });
+    }
+  }
+  return issues;
+}
+
+const routes: GeoRoutes = {
+  '/csrf-token': () => 'storybook-csrf-token',
+  '/panel/api/xray/geodata/files': () => FILES,
+  '/panel/api/xray/geodata/categories': (query) => {
+    const dataset = DATASETS[query.get('file') ?? ''];
+    const needle = (query.get('q') ?? '').trim().toLowerCase();
+    const items = (dataset?.categories ?? []).filter((category) => category.code.includes(needle));
+    return { total: items.length, items };
+  },
+  '/panel/api/xray/geodata/entries': (query) => {
+    const dataset = DATASETS[query.get('file') ?? ''];
+    const needle = (query.get('q') ?? '').trim().toLowerCase();
+    const matched = (dataset?.entries[query.get('code') ?? ''] ?? []).filter((entry) =>
+      entry.value.toLowerCase().includes(needle),
+    );
+    const offset = Number(query.get('offset') ?? 0);
+    const limit = Number(query.get('limit') ?? 100);
+    return { total: matched.length, items: matched.slice(offset, offset + limit) };
+  },
+  '/panel/api/xray/geodata/validate': (_query, form) =>
+    validate(parseTokens(form.get('tokens') ?? ''), form.get('kind') === 'ip'),
+};
+
+const withGeodata: Decorator = function GeodataBackend(Story) {
+  return (
+    <GeoApi routes={routes}>
+      <Story />
+    </GeoApi>
+  );
+};
+
+function ControlledTokenInput({ value = '', id = 'geo-rule', ...rest }: GeoTokenInputProps) {
+  const [current, setCurrent] = useState(value);
+  useEffect(() => setCurrent(value), [value]);
+  return (
+    <Space direction="vertical" size={4} style={{ width: 460 }}>
+      <label htmlFor={id}>{rest.kind === 'ip' ? 'Target IP' : 'Target domain'}</label>
+      <GeoTokenInput {...rest} id={id} value={current} onChange={setCurrent} />
+    </Space>
+  );
+}
+
+const meta = {
+  title: 'Geodata/GeoTokenInput',
+  component: GeoTokenInput,
+  tags: ['autodocs'],
+  parameters: {
+    layout: 'padded',
+    a11y: {
+      config: {
+        rules: [{ id: 'color-contrast', enabled: false }],
+      },
+    },
+    docs: {
+      description: {
+        component:
+          'Routing rule field for the xray rule editor: a comma separated list of domains/CIDRs and `geosite:` / `geoip:` tokens, with a database button in the addon that opens the geo category browser. Typed tokens are validated against the databases on disk after a short pause, and anything the running core would not resolve is called out under the field. The stories answer `/panel/api/xray/geodata/*` from an in-memory fixture, so validation and the browser both work without a panel backend.',
+      },
+    },
+  },
+  decorators: [withGeodata],
+  args: { kind: 'domain' },
+  argTypes: {
+    value: { description: 'Comma separated rule string held by the parent form.' },
+    onChange: { description: 'Called with the full rule string on every edit and on Apply from the browser.' },
+    onBlur: { description: 'Forwarded to the input; used by React Hook Form to mark the field touched.' },
+    kind: {
+      description: 'Which database the tokens are validated against: `domain` for geosite, `ip` for geoip.',
+      control: 'inline-radio',
+      options: ['domain', 'ip'],
+    },
+    placeholder: { description: 'Placeholder shown while the field is empty.' },
+    id: { description: 'Input id, linked to the label rendered by the surrounding form field.' },
+  },
+  render: (args) => <ControlledTokenInput {...args} />,
+} satisfies Meta<typeof GeoTokenInput>;
+
+export default meta;
+
+type Story = StoryObj<typeof meta>;
+
+export const Empty: Story = {
+  args: { kind: 'domain', value: '', placeholder: 'geosite:google, example.com' },
+};
+
+export const DomainTokens: Story = {
+  args: { kind: 'domain', value: 'geosite:google, google.com' },
+};
+
+export const IpTokens: Story = {
+  args: { kind: 'ip', value: 'geoip:private' },
+};
+
+export const UnknownCategory: Story = {
+  args: { kind: 'domain', value: 'geosite:blabla, geosite:google' },
+  play: async ({ canvasElement }) => {
+    const canvas = within(canvasElement);
+    await expect(await canvas.findByText(/geosite:blabla/, undefined, { timeout: 3000 })).toBeVisible();
+  },
+};

+ 126 - 0
frontend/src/components/geodata/GeoTokenInput.tsx

@@ -0,0 +1,126 @@
+import { useEffect, useState } from 'react';
+import type { Ref } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Button, Input, Tooltip, Typography } from 'antd';
+import type { InputRef } from 'antd';
+import { DatabaseOutlined } from '@ant-design/icons';
+
+import { useValidateGeoTokens, type GeoTokenKind } from '@/api/queries/useGeodata';
+import { parseTokens } from '@/lib/xray/geoTokens';
+import type { GeodataTokenIssue, GeoKind } from '@/generated/types';
+
+import GeoBrowserModal from './GeoBrowserModal';
+
+const VALIDATION_DELAY = 600;
+
+// Each reason needs its own wording: a missing database is fixed under Geodata,
+// a missing category by picking another one, and a bad token by editing it.
+const REASON_KEYS: Record<string, string> = {
+  fileMissing: 'pages.xray.geoBrowser.missingDatabase',
+  categoryMissing: 'pages.xray.geoBrowser.unknownCategories',
+  attributeMissing: 'pages.xray.geoBrowser.unknownAttribute',
+  syntax: 'pages.xray.geoBrowser.invalidToken',
+  wrongKind: 'pages.xray.geoBrowser.wrongKind',
+};
+
+export interface GeoTokenInputProps {
+  value?: string;
+  onChange?: (value: string) => void;
+  onBlur?: () => void;
+  kind: GeoTokenKind;
+  placeholder?: string;
+  id?: string;
+  ref?: Ref<InputRef>;
+}
+
+export default function GeoTokenInput({ value = '', onChange, onBlur, kind, placeholder, id, ref }: GeoTokenInputProps) {
+  const { t } = useTranslation();
+  const [browsing, setBrowsing] = useState(false);
+  const [issues, setIssues] = useState<GeodataTokenIssue[]>([]);
+  const [checkFailed, setCheckFailed] = useState(false);
+  const validate = useValidateGeoTokens();
+  const { mutateAsync } = validate;
+
+  useEffect(() => {
+    const tokens = parseTokens(value);
+    if (tokens.length === 0) {
+      setIssues([]);
+      setCheckFailed(false);
+      return;
+    }
+    let cancelled = false;
+    const timer = setTimeout(() => {
+      mutateAsync({ tokens, kind })
+        .then((found) => {
+          if (cancelled) return;
+          setIssues(found);
+          setCheckFailed(false);
+        })
+        // A rejected check says nothing about the tokens, so the warnings are
+        // dropped but replaced by a notice — silence here reads as "all valid".
+        .catch(() => {
+          if (cancelled) return;
+          setIssues([]);
+          setCheckFailed(true);
+        });
+    }, VALIDATION_DELAY);
+    return () => {
+      cancelled = true;
+      clearTimeout(timer);
+    };
+  }, [value, kind, mutateAsync]);
+
+  return (
+    <>
+      <Input
+        ref={ref}
+        id={id}
+        value={value}
+        placeholder={placeholder}
+        onChange={(event) => onChange?.(event.target.value)}
+        onBlur={onBlur}
+        addonAfter={
+          <Tooltip title={t('pages.xray.geoBrowser.openTooltip')}>
+            <Button
+              type="text"
+              size="small"
+              icon={<DatabaseOutlined />}
+              aria-label={t('pages.xray.geoBrowser.openTooltip')}
+              onClick={() => setBrowsing(true)}
+            />
+          </Tooltip>
+        }
+      />
+      {groupByReason(issues).map(([reason, tokens]) => (
+        <Typography.Text key={reason} type="warning" className="geo-unknown-hint">
+          {t(REASON_KEYS[reason] ?? REASON_KEYS.categoryMissing, { tokens: tokens.join(', ') })}
+        </Typography.Text>
+      ))}
+      {checkFailed && (
+        <Typography.Text type="secondary" className="geo-unknown-hint">
+          {t('pages.xray.geoBrowser.checkFailed')}
+        </Typography.Text>
+      )}
+      <GeoBrowserModal
+        open={browsing}
+        kind={(kind === 'ip' ? 'ip' : 'site') as GeoKind}
+        value={value}
+        onApply={(next) => {
+          onChange?.(next);
+          setBrowsing(false);
+        }}
+        onClose={() => setBrowsing(false)}
+      />
+    </>
+  );
+}
+
+function groupByReason(issues: GeodataTokenIssue[]): Array<[string, string[]]> {
+  const grouped = new Map<string, string[]>();
+  for (const issue of issues) {
+    const tokens = grouped.get(issue.reason) ?? [];
+    tokens.push(issue.token);
+    grouped.set(issue.reason, tokens);
+  }
+  return [...grouped];
+}

+ 4 - 0
frontend/src/components/geodata/index.ts

@@ -0,0 +1,4 @@
+export { default as GeoBrowserModal } from './GeoBrowserModal';
+export type { GeoBrowserModalProps } from './GeoBrowserModal';
+export { default as GeoTokenInput } from './GeoTokenInput';
+export type { GeoTokenInputProps } from './GeoTokenInput';

+ 48 - 0
frontend/src/generated/examples.ts

@@ -321,6 +321,54 @@ export const EXAMPLES: Record<string, unknown> = {
     "masterId": 0,
     "path": ""
   },
+  "GeoCategory": {
+    "attributes": [
+      "ads",
+      "cn"
+    ],
+    "code": "google",
+    "entries": 1284
+  },
+  "GeoCategoryPage": {
+    "items": [
+      {
+        "attributes": [
+          "ads",
+          "cn"
+        ],
+        "code": "google",
+        "entries": 1284
+      }
+    ],
+    "total": 1043
+  },
+  "GeoEntry": {
+    "kind": "domain",
+    "value": "google.com"
+  },
+  "GeoEntryPage": {
+    "items": [
+      {
+        "kind": "domain",
+        "value": "google.com"
+      }
+    ],
+    "total": 1284
+  },
+  "GeoFile": {
+    "categories": 1043,
+    "error": "",
+    "kind": "site",
+    "modifiedAt": 1769558400000,
+    "name": "geosite.dat",
+    "size": 1467392
+  },
+  "GeodataTokenIssue": {
+    "code": "blabla",
+    "file": "geosite.dat",
+    "reason": "categoryMissing",
+    "token": "geosite:blabla"
+  },
   "HistoryOfSeeders": {
     "id": 0,
     "seederName": ""

+ 151 - 0
frontend/src/generated/schemas.ts

@@ -1382,6 +1382,157 @@ export const SCHEMAS: Record<string, unknown> = {
     ],
     "type": "object"
   },
+  "GeoCategory": {
+    "description": "GeoCategory is one code inside a database, such as geosite's \"google\".",
+    "properties": {
+      "attributes": {
+        "example": [
+          "ads",
+          "cn"
+        ],
+        "items": {
+          "type": "string"
+        },
+        "type": "array"
+      },
+      "code": {
+        "example": "google",
+        "type": "string"
+      },
+      "entries": {
+        "example": 1284,
+        "type": "integer"
+      }
+    },
+    "required": [
+      "attributes",
+      "code",
+      "entries"
+    ],
+    "type": "object"
+  },
+  "GeoCategoryPage": {
+    "description": "GeoCategoryPage is one page of categories plus the unpaged total.",
+    "properties": {
+      "items": {
+        "items": {
+          "$ref": "#/components/schemas/GeoCategory"
+        },
+        "type": "array"
+      },
+      "total": {
+        "example": 1043,
+        "type": "integer"
+      }
+    },
+    "required": [
+      "items",
+      "total"
+    ],
+    "type": "object"
+  },
+  "GeoEntry": {
+    "description": "GeoEntry is a single rule inside a category: a domain rule for geosite\ndatabases, a CIDR for geoip ones.",
+    "properties": {
+      "kind": {
+        "example": "domain",
+        "type": "string"
+      },
+      "value": {
+        "example": "google.com",
+        "type": "string"
+      }
+    },
+    "required": [
+      "kind",
+      "value"
+    ],
+    "type": "object"
+  },
+  "GeoEntryPage": {
+    "description": "GeoEntryPage is one page of category entries plus the unpaged total.",
+    "properties": {
+      "items": {
+        "items": {
+          "$ref": "#/components/schemas/GeoEntry"
+        },
+        "type": "array"
+      },
+      "total": {
+        "example": 1284,
+        "type": "integer"
+      }
+    },
+    "required": [
+      "items",
+      "total"
+    ],
+    "type": "object"
+  },
+  "GeoFile": {
+    "description": "GeoFile describes one .dat database found in the asset directory.",
+    "properties": {
+      "categories": {
+        "example": 1043,
+        "type": "integer"
+      },
+      "error": {
+        "type": "string"
+      },
+      "kind": {
+        "example": "site",
+        "type": "string"
+      },
+      "modifiedAt": {
+        "example": 1769558400000,
+        "format": "int64",
+        "type": "integer"
+      },
+      "name": {
+        "example": "geosite.dat",
+        "type": "string"
+      },
+      "size": {
+        "example": 1467392,
+        "format": "int64",
+        "type": "integer"
+      }
+    },
+    "required": [
+      "categories",
+      "kind",
+      "modifiedAt",
+      "name",
+      "size"
+    ],
+    "type": "object"
+  },
+  "GeodataTokenIssue": {
+    "description": "GeodataTokenIssue reports a routing token the running core would reject,\nor would silently match nothing against.",
+    "properties": {
+      "code": {
+        "example": "blabla",
+        "type": "string"
+      },
+      "file": {
+        "example": "geosite.dat",
+        "type": "string"
+      },
+      "reason": {
+        "example": "categoryMissing",
+        "type": "string"
+      },
+      "token": {
+        "example": "geosite:blabla",
+        "type": "string"
+      }
+    },
+    "required": [
+      "reason",
+      "token"
+    ],
+    "type": "object"
+  },
   "HistoryOfSeeders": {
     "description": "HistoryOfSeeders tracks which database seeders have been executed to prevent re-running.",
     "properties": {

+ 38 - 0
frontend/src/generated/types.ts

@@ -1,4 +1,5 @@
 // Code generated by tools/openapigen. DO NOT EDIT.
+export type GeoKind = string;
 export type OnlineAPISupport = number;
 export type ProcessState = string;
 export type Protocol = string;
@@ -336,6 +337,43 @@ export interface FallbackParentInfo {
   path?: string;
 }
 
+export interface GeoCategory {
+  attributes: string[];
+  code: string;
+  entries: number;
+}
+
+export interface GeoCategoryPage {
+  items: GeoCategory[];
+  total: number;
+}
+
+export interface GeoEntry {
+  kind: string;
+  value: string;
+}
+
+export interface GeoEntryPage {
+  items: GeoEntry[];
+  total: number;
+}
+
+export interface GeoFile {
+  categories: number;
+  error?: string;
+  kind: GeoKind;
+  modifiedAt: number;
+  name: string;
+  size: number;
+}
+
+export interface GeodataTokenIssue {
+  code?: string;
+  file?: string;
+  reason: string;
+  token: string;
+}
+
 export interface HistoryOfSeeders {
   id: number;
   seederName: string;

+ 46 - 0
frontend/src/generated/zod.ts

@@ -1,5 +1,8 @@
 // Code generated by tools/openapigen. DO NOT EDIT.
 import { z } from 'zod';
+export const GeoKindSchema = z.string();
+export type GeoKind = z.infer<typeof GeoKindSchema>;
+
 export const OnlineAPISupportSchema = z.number().int();
 export type OnlineAPISupport = z.infer<typeof OnlineAPISupportSchema>;
 
@@ -360,6 +363,49 @@ export const FallbackParentInfoSchema = z.object({
 });
 export type FallbackParentInfo = z.infer<typeof FallbackParentInfoSchema>;
 
+export const GeoCategorySchema = z.object({
+  attributes: z.array(z.string()),
+  code: z.string(),
+  entries: z.number().int(),
+});
+export type GeoCategory = z.infer<typeof GeoCategorySchema>;
+
+export const GeoCategoryPageSchema = z.object({
+  items: z.array(z.lazy(() => GeoCategorySchema)),
+  total: z.number().int(),
+});
+export type GeoCategoryPage = z.infer<typeof GeoCategoryPageSchema>;
+
+export const GeoEntrySchema = z.object({
+  kind: z.string(),
+  value: z.string(),
+});
+export type GeoEntry = z.infer<typeof GeoEntrySchema>;
+
+export const GeoEntryPageSchema = z.object({
+  items: z.array(z.lazy(() => GeoEntrySchema)),
+  total: z.number().int(),
+});
+export type GeoEntryPage = z.infer<typeof GeoEntryPageSchema>;
+
+export const GeoFileSchema = z.object({
+  categories: z.number().int(),
+  error: z.string().optional(),
+  kind: z.lazy(() => GeoKindSchema),
+  modifiedAt: z.number().int(),
+  name: z.string(),
+  size: z.number().int(),
+});
+export type GeoFile = z.infer<typeof GeoFileSchema>;
+
+export const GeodataTokenIssueSchema = z.object({
+  code: z.string().optional(),
+  file: z.string().optional(),
+  reason: z.string(),
+  token: z.string(),
+});
+export type GeodataTokenIssue = z.infer<typeof GeodataTokenIssueSchema>;
+
 export const HistoryOfSeedersSchema = z.object({
   id: z.number().int(),
   seederName: z.string(),

+ 77 - 0
frontend/src/lib/xray/geoTokens.ts

@@ -0,0 +1,77 @@
+import type { GeoKind } from '@/generated/types';
+
+const DEFAULT_SITE_FILE = 'geosite.dat';
+const DEFAULT_IP_FILE = 'geoip.dat';
+
+const LONG_FORMS: Array<[RegExp, string]> = [
+  [/^ext(?:-domain|-site)?:geosite\.dat:/, 'geosite:'],
+  [/^ext(?:-ip)?:geoip\.dat:/, 'geoip:'],
+];
+
+export function parseTokens(value: string): string[] {
+  return value
+    .split(',')
+    .map((token) => token.trim())
+    .filter((token) => token !== '');
+}
+
+export function formatTokens(tokens: string[]): string {
+  return tokens.join(', ');
+}
+
+export function tokenFor(file: string, code: string, kind: GeoKind): string {
+  if (kind === 'ip' && file === DEFAULT_IP_FILE) return `geoip:${code}`;
+  if (kind === 'site' && file === DEFAULT_SITE_FILE) return `geosite:${code}`;
+  return `ext:${file}:${code}`;
+}
+
+/**
+ * Xray treats category codes case-insensitively and accepts both the
+ * `geosite:cn` shorthand and its `ext:geosite.dat:cn` long form, so tokens are
+ * compared through this normal form. Only comparison uses it — whatever the
+ * user typed is what stays in the rule.
+ */
+export function canonicalToken(token: string): string {
+  const lowered = token.trim().toLowerCase();
+  for (const [pattern, shorthand] of LONG_FORMS) {
+    if (pattern.test(lowered)) return lowered.replace(pattern, shorthand);
+  }
+  return lowered;
+}
+
+export function selectionFromValue(value: string, known: ReadonlySet<string>): string[] {
+  const canonicalKnown = new Set([...known].map(canonicalToken));
+  const selection: string[] = [];
+  const seen = new Set<string>();
+  for (const token of parseTokens(value)) {
+    const canonical = canonicalToken(token);
+    if (!canonicalKnown.has(canonical) || seen.has(canonical)) continue;
+    seen.add(canonical);
+    selection.push(token);
+  }
+  return selection;
+}
+
+export function mergeSelection(value: string, selected: string[], known: ReadonlySet<string>): string {
+  const canonicalKnown = new Set([...known].map(canonicalToken));
+  const kept = new Set(
+    selected.map((token) => canonicalToken(token)).filter((token) => token !== ''),
+  );
+  const merged: string[] = [];
+  const seen = new Set<string>();
+  const append = (token: string) => {
+    const canonical = canonicalToken(token);
+    if (canonical === '' || seen.has(canonical)) return;
+    seen.add(canonical);
+    merged.push(token);
+  };
+  for (const token of parseTokens(value)) {
+    const canonical = canonicalToken(token);
+    if (canonicalKnown.has(canonical) && !kept.has(canonical)) continue;
+    append(token);
+  }
+  for (const token of selected) {
+    append(token.trim());
+  }
+  return formatTokens(merged);
+}

+ 38 - 0
frontend/src/pages/api-docs/endpoints.ts

@@ -1420,6 +1420,44 @@ export const sections: readonly Section[] = [
         ],
         body: 'domain=example.com&port=443&network=tcp',
       },
+      {
+        method: 'GET',
+        path: '/panel/api/xray/geodata/files',
+        summary: 'List the geo databases (.dat files) in the Xray asset folder, with the layout detected from their contents, size, modification time and category count. A database that fails to parse is still listed, with the reason in "error".',
+      },
+      {
+        method: 'GET',
+        path: '/panel/api/xray/geodata/categories',
+        summary: 'One page of a database\'s categories, each with its entry count and the attributes its domains carry (e.g. "ads", "cn").',
+        params: [
+          { name: 'file', in: 'query', type: 'string', desc: 'Database file name inside the asset folder, e.g. geosite.dat (required).' },
+          { name: 'q', in: 'query', type: 'string', optional: true, desc: 'Case-insensitive substring filter on the category code.' },
+          { name: 'offset', in: 'query', type: 'integer', optional: true, desc: 'Rows to skip. Defaults to 0.' },
+          { name: 'limit', in: 'query', type: 'integer', optional: true, desc: 'Rows to return, capped at 500. Omit it to return every category — the index is small and the panel filters it client-side.' },
+        ],
+      },
+      {
+        method: 'GET',
+        path: '/panel/api/xray/geodata/entries',
+        summary: 'One page of the rules inside a category — domain rules typed as domain/full/keyword/regexp for geosite databases, CIDRs for geoip ones.',
+        params: [
+          { name: 'file', in: 'query', type: 'string', desc: 'Database file name inside the asset folder (required).' },
+          { name: 'code', in: 'query', type: 'string', desc: 'Category code, case-insensitive, e.g. google (required).' },
+          { name: 'q', in: 'query', type: 'string', optional: true, desc: 'Case-insensitive substring filter on the rule value.' },
+          { name: 'offset', in: 'query', type: 'integer', optional: true, desc: 'Rows to skip. Defaults to 0.' },
+          { name: 'limit', in: 'query', type: 'integer', optional: true, desc: 'Rows to return, capped at 500. Defaults to the cap.' },
+        ],
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/xray/geodata/validate',
+        summary: 'Check routing tokens against the databases on disk and return only the ones that do not resolve. Plain domains and CIDRs are ignored. Each issue carries a reason: syntax, fileMissing or categoryMissing.',
+        params: [
+          { name: 'tokens', in: 'body (form)', type: 'string', desc: 'Comma-separated routing tokens, e.g. "geosite:google,geosite:blabla". Max 500 per request.' },
+          { name: 'kind', in: 'body (form)', type: 'string', desc: '"ip" to parse the tokens as IP rules (geoip:, ext-ip:, leading !). Anything else parses them as domain rules (geosite:, ext-site:).' },
+        ],
+        body: 'kind=domain&tokens=geosite:google,geosite:blabla',
+      },
       {
         method: 'GET',
         path: '/panel/api/xray/outbound-subs',

+ 4 - 3
frontend/src/pages/xray/routing/RuleFormModal.tsx

@@ -4,6 +4,7 @@ import { Button, Form, Input, Modal, Select, Space, Switch, Tooltip } from 'antd
 import { PlusOutlined, MinusOutlined, QuestionCircleOutlined } from '@ant-design/icons';
 import { FormProvider, useForm, useWatch } from 'react-hook-form';
 import { InputAddon } from '@/components/ui';
+import { GeoTokenInput } from '@/components/geodata';
 import { FormField } from '@/components/form/rhf';
 import { useInboundOptions } from '@/api/queries/useInboundOptions';
 import { RuleFormSchema, type RuleFormValues } from '@/schemas/xray';
@@ -173,7 +174,7 @@ export default function RuleFormModal({
               </Tooltip>
             }
           >
-            <Input placeholder="0.0.0.0/8, fc00::/7, geoip:ir" />
+            <GeoTokenInput kind="ip" placeholder="0.0.0.0/8, fc00::/7, geoip:ir" />
           </FormField>
 
           <FormField
@@ -253,7 +254,7 @@ export default function RuleFormModal({
               </Tooltip>
             }
           >
-            <Input placeholder="0.0.0.0/8, fc00::/7, geoip:ir" />
+            <GeoTokenInput kind="ip" placeholder="0.0.0.0/8, fc00::/7, geoip:ir" />
           </FormField>
 
           <FormField
@@ -264,7 +265,7 @@ export default function RuleFormModal({
               </Tooltip>
             }
           >
-            <Input placeholder="google.com, geosite:cn" />
+            <GeoTokenInput kind="domain" placeholder="google.com, geosite:cn" />
           </FormField>
 
           <FormField

+ 207 - 0
frontend/src/test/geo-browser-selection.test.tsx

@@ -0,0 +1,207 @@
+import type { ReactNode } from 'react';
+import { render, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { QueryClientProvider } from '@tanstack/react-query';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import GeoBrowserModal from '@/components/geodata/GeoBrowserModal';
+import GeoTokenInput from '@/components/geodata/GeoTokenInput';
+import { makeTestQueryClient } from '@/test/test-utils';
+import { HttpUtil, Msg } from '@/utils';
+
+afterEach(() => {
+  vi.restoreAllMocks();
+});
+
+const FILES = [{ name: 'geosite.dat', kind: 'site', size: 1024, modifiedAt: 1785428467270, categories: 3 }];
+
+const IP_FILE = { name: 'geoip.dat', kind: 'ip', size: 2048, modifiedAt: 1785428467270, categories: 1 };
+
+const IP_CATEGORIES = { total: 1, items: [{ code: 'private', entries: 1, attributes: [] }] };
+
+const CATEGORIES = {
+  total: 3,
+  items: [
+    { code: 'cn', entries: 2, attributes: [] },
+    { code: 'google', entries: 2, attributes: ['ads'] },
+    { code: 'telegram', entries: 1, attributes: [] },
+  ],
+};
+
+function mockGeodata(files: unknown[] = FILES) {
+  const get = vi.spyOn(HttpUtil, 'get').mockImplementation(async (url: string, params?: unknown) => {
+    const requestedFile = (params as { file?: string } | undefined)?.file;
+    if (url.includes('/geodata/files')) return new Msg(true, '', files);
+    if (url.includes('/geodata/categories')) {
+      return new Msg(true, '', requestedFile === 'geoip.dat' ? IP_CATEGORIES : CATEGORIES);
+    }
+    if (url.includes('/geodata/entries')) return new Msg(true, '', { total: 0, items: [] });
+    return new Msg(true, '', null);
+  });
+  vi.spyOn(HttpUtil, 'post').mockImplementation(async () => new Msg(true, '', []));
+  return get;
+}
+
+type GetSpy = ReturnType<typeof mockGeodata>;
+
+function entryFilters(get: GetSpy): string[] {
+  return get.mock.calls
+    .filter(([url]) => String(url).includes('/geodata/entries'))
+    .map(([, params]) => (params as { q?: string } | undefined)?.q ?? '');
+}
+
+function wrapper({ children }: { children: ReactNode }) {
+  return <QueryClientProvider client={makeTestQueryClient()}>{children}</QueryClientProvider>;
+}
+
+async function checkboxFor(code: string) {
+  const cell = await screen.findByText(code);
+  const row = cell.closest('.ant-table-row');
+  if (!row) throw new Error(`row for ${code} not found`);
+  return within(row as HTMLElement).getByRole('checkbox') as HTMLInputElement;
+}
+
+describe('GeoBrowserModal selection', () => {
+  it('seeds the selection from the field every time it opens', async () => {
+    mockGeodata();
+    const view = render(
+      <GeoBrowserModal open kind="site" value="geosite:google" onApply={vi.fn()} onClose={vi.fn()} />,
+      { wrapper },
+    );
+
+    await waitFor(async () => expect((await checkboxFor('google')).checked).toBe(true));
+
+    view.rerender(
+      <GeoBrowserModal open={false} kind="site" value="geosite:google" onApply={vi.fn()} onClose={vi.fn()} />,
+    );
+    view.rerender(
+      <GeoBrowserModal open kind="site" value="geosite:google" onApply={vi.fn()} onClose={vi.fn()} />,
+    );
+
+    await waitFor(async () => expect((await checkboxFor('google')).checked).toBe(true));
+    expect((await checkboxFor('cn')).checked).toBe(false);
+  });
+
+  it('keeps selections that the search box has filtered out of view', async () => {
+    mockGeodata();
+    const user = userEvent.setup();
+    const onApply = vi.fn();
+    render(<GeoBrowserModal open kind="site" value="" onApply={onApply} onClose={vi.fn()} />, { wrapper });
+
+    await user.click(await checkboxFor('google'));
+    await user.type(screen.getByPlaceholderText(/search category|поиск категории/i), 'cn');
+    await waitFor(() => expect(screen.queryByText('google')).toBeNull());
+    await user.click(await checkboxFor('cn'));
+
+    await user.click(screen.getByRole('button', { name: /apply|применить/i }));
+
+    expect(onApply).toHaveBeenCalledTimes(1);
+    const applied = String(onApply.mock.calls[0][0]);
+    expect(applied.split(',').map((token) => token.trim()).sort()).toEqual(['geosite:cn', 'geosite:google']);
+  });
+
+  it('drops a category from the field when its checkbox is cleared', async () => {
+    mockGeodata();
+    const user = userEvent.setup();
+    const onApply = vi.fn();
+    render(
+      <GeoBrowserModal open kind="site" value="google.com, geosite:google, geosite:blabla" onApply={onApply} onClose={vi.fn()} />,
+      { wrapper },
+    );
+
+    await waitFor(async () => expect((await checkboxFor('google')).checked).toBe(true));
+    await user.click(await checkboxFor('google'));
+    await user.click(screen.getByRole('button', { name: /apply|применить/i }));
+
+    expect(onApply).toHaveBeenCalledWith('google.com, geosite:blabla');
+  });
+
+  it('offers only databases matching the field kind', async () => {
+    mockGeodata([...FILES, IP_FILE]);
+    render(<GeoBrowserModal open kind="ip" value="" onApply={vi.fn()} onClose={vi.fn()} />, { wrapper });
+
+    await screen.findByText('private');
+    expect(screen.getByTitle('geoip.dat')).toBeTruthy();
+    expect(screen.queryByText('google')).toBeNull();
+  });
+
+  it('does not seed one database from another database categories', async () => {
+    mockGeodata([...FILES, IP_FILE]);
+    const user = userEvent.setup();
+    render(
+      <GeoBrowserModal open kind="site" value="geosite:cn" onApply={vi.fn()} onClose={vi.fn()} />,
+      { wrapper },
+    );
+
+    await waitFor(async () => expect((await checkboxFor('cn')).checked).toBe(true));
+    await user.click(await checkboxFor('google'));
+    await waitFor(async () => expect((await checkboxFor('google')).checked).toBe(true));
+    expect(screen.queryByText('private')).toBeNull();
+  });
+
+  it('waits for the entry filter to settle instead of querying every keystroke', async () => {
+    const get = mockGeodata();
+    const user = userEvent.setup({ delay: null });
+    render(<GeoBrowserModal open kind="site" value="" onApply={vi.fn()} onClose={vi.fn()} />, { wrapper });
+
+    await user.click(await screen.findByText('cn'));
+    await waitFor(() => expect(entryFilters(get)).toEqual(['']));
+
+    await user.type(screen.getByPlaceholderText('Filter inside category'), 'abcd');
+    expect(entryFilters(get)).toEqual(['']);
+
+    await waitFor(() => expect(entryFilters(get)).toEqual(['', 'abcd']), { timeout: 3000 });
+  });
+
+  it('drops the pending filter when another category is opened', async () => {
+    const get = mockGeodata();
+    const user = userEvent.setup({ delay: null });
+    render(<GeoBrowserModal open kind="site" value="" onApply={vi.fn()} onClose={vi.fn()} />, { wrapper });
+
+    await user.click(await screen.findByText('cn'));
+    await user.type(screen.getByPlaceholderText('Filter inside category'), 'abcd');
+    await user.click(screen.getByText('telegram'));
+
+    await new Promise((resolve) => setTimeout(resolve, 800));
+    expect(entryFilters(get)).toEqual(['', '']);
+  });
+
+  it('ticks and unticks a category written in its long ext form', async () => {
+    mockGeodata();
+    const user = userEvent.setup();
+    const onApply = vi.fn();
+    render(
+      <GeoBrowserModal
+        open
+        kind="site"
+        value="ext:geosite.dat:cn, google.com"
+        onApply={onApply}
+        onClose={vi.fn()}
+      />,
+      { wrapper },
+    );
+
+    await waitFor(async () => expect((await checkboxFor('cn')).checked).toBe(true));
+    await user.click(await checkboxFor('cn'));
+    await user.click(screen.getByRole('button', { name: /apply|применить/i }));
+
+    expect(onApply).toHaveBeenCalledWith('google.com');
+  });
+});
+
+describe('GeoTokenInput validation feedback', () => {
+  it('says the check failed instead of dropping the warnings silently', async () => {
+    vi.spyOn(HttpUtil, 'get').mockResolvedValue(new Msg(true, '', []));
+    vi.spyOn(HttpUtil, 'post')
+      .mockResolvedValueOnce(new Msg(true, '', [{ token: 'geosite:nope', reason: 'categoryMissing' }]))
+      .mockResolvedValue(new Msg(false, 'too many tokens'));
+
+    const view = render(<GeoTokenInput kind="domain" value="geosite:nope" />, { wrapper });
+    await screen.findByText(/Not in the database/, {}, { timeout: 3000 });
+
+    view.rerender(<GeoTokenInput kind="domain" value="geosite:nope, geosite:other" />);
+
+    await screen.findByText('Could not check these values against the geo databases', {}, { timeout: 3000 });
+    expect(screen.queryByText(/Not in the database/)).toBeNull();
+  });
+});

+ 215 - 0
frontend/src/test/geo-tokens.test.ts

@@ -0,0 +1,215 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+  formatTokens,
+  mergeSelection,
+  parseTokens,
+  selectionFromValue,
+  tokenFor,
+} from '@/lib/xray/geoTokens';
+
+const siteKnown = new Set(['geosite:google', 'geosite:google@ads', 'geosite:cn', 'ext:my_rules.dat:corp']);
+const ipKnown = new Set(['geoip:cn', 'geoip:private', 'ext:my_ips.dat:office']);
+
+describe('parseTokens / formatTokens', () => {
+  const cases: Array<[string, string, string[]]> = [
+    ['empty value', '', []],
+    ['single token', 'geosite:google', ['geosite:google']],
+    ['trims and drops blanks', ' geosite:google , , google.com ,', ['geosite:google', 'google.com']],
+    ['keeps negation', '!geoip:cn, 10.0.0.0/8', ['!geoip:cn', '10.0.0.0/8']],
+  ];
+
+  it.each(cases)('%s', (_name, value, expected) => {
+    expect(parseTokens(value)).toEqual(expected);
+  });
+
+  it('joins with a comma and a space', () => {
+    expect(formatTokens(['geosite:google', 'google.com'])).toBe('geosite:google, google.com');
+    expect(formatTokens([])).toBe('');
+  });
+});
+
+describe('tokenFor', () => {
+  const cases: Array<[string, string, string, 'site' | 'ip', string]> = [
+    ['default site database uses the geosite shorthand', 'geosite.dat', 'google', 'site', 'geosite:google'],
+    ['default ip database uses the geoip shorthand', 'geoip.dat', 'cn', 'ip', 'geoip:cn'],
+    ['custom site database falls back to ext', 'my_rules.dat', 'corp', 'site', 'ext:my_rules.dat:corp'],
+    ['custom ip database falls back to ext', 'my_ips.dat', 'office', 'ip', 'ext:my_ips.dat:office'],
+    ['ip kind on the site database is not shorthand', 'geosite.dat', 'cn', 'ip', 'ext:geosite.dat:cn'],
+    ['site kind on the ip database is not shorthand', 'geoip.dat', 'cn', 'site', 'ext:geoip.dat:cn'],
+  ];
+
+  it.each(cases)('%s', (_name, file, code, kind, expected) => {
+    expect(tokenFor(file, code, kind)).toBe(expected);
+  });
+});
+
+describe('selectionFromValue', () => {
+  const cases: Array<[string, string, ReadonlySet<string>, string[]]> = [
+    ['empty value selects nothing', '', siteKnown, []],
+    ['plain values are not selectable', 'google.com, keyword:ads', siteKnown, []],
+    ['picks known tokens only', 'google.com, geosite:google, geosite:blabla', siteKnown, ['geosite:google']],
+    [
+      'keeps the value order',
+      'geosite:cn, google.com, geosite:google',
+      siteKnown,
+      ['geosite:cn', 'geosite:google'],
+    ],
+    ['drops duplicates', 'geosite:google, geosite:google', siteKnown, ['geosite:google']],
+    ['attributes are distinct tokens', 'geosite:google@ads', siteKnown, ['geosite:google@ads']],
+    ['ext tokens are selectable', 'ext:my_rules.dat:corp, ext:other.dat:x', siteKnown, ['ext:my_rules.dat:corp']],
+    ['negated ip tokens stay unselected', '!geoip:cn, geoip:private', ipKnown, ['geoip:private']],
+  ];
+
+  it.each(cases)('%s', (_name, value, known, expected) => {
+    expect(selectionFromValue(value, known)).toEqual(expected);
+  });
+});
+
+describe('mergeSelection', () => {
+  const cases: Array<[string, string, string[], ReadonlySet<string>, string]> = [
+    ['adds to an empty field', '', ['geosite:google'], siteKnown, 'geosite:google'],
+    [
+      'adds after a plain domain',
+      'google.com',
+      ['geosite:cn'],
+      siteKnown,
+      'google.com, geosite:cn',
+    ],
+    [
+      'keeps plain and unknown tokens when a category is unchecked',
+      'google.com, geosite:google, geosite:blabla',
+      [],
+      siteKnown,
+      'google.com, geosite:blabla',
+    ],
+    [
+      'unchecking one known token leaves the other known token',
+      'geosite:google, geosite:cn',
+      ['geosite:cn'],
+      siteKnown,
+      'geosite:cn',
+    ],
+    [
+      'preserves the original order of surviving tokens',
+      'geosite:cn, google.com, geosite:google',
+      ['geosite:google', 'geosite:cn'],
+      siteKnown,
+      'geosite:cn, google.com, geosite:google',
+    ],
+    [
+      'appends new selections in selection order',
+      'google.com',
+      ['geosite:cn', 'geosite:google'],
+      siteKnown,
+      'google.com, geosite:cn, geosite:google',
+    ],
+    [
+      'never duplicates an already present token',
+      'geosite:google, google.com',
+      ['geosite:google'],
+      siteKnown,
+      'geosite:google, google.com',
+    ],
+    [
+      'collapses duplicates already in the field',
+      'google.com, google.com, geosite:google',
+      ['geosite:google'],
+      siteKnown,
+      'google.com, geosite:google',
+    ],
+    [
+      'handles ext tokens like shorthand ones',
+      'ext:my_rules.dat:corp, google.com',
+      [],
+      siteKnown,
+      'google.com',
+    ],
+    [
+      'adds an ext token from a custom database',
+      '10.0.0.0/8',
+      ['ext:my_ips.dat:office'],
+      ipKnown,
+      '10.0.0.0/8, ext:my_ips.dat:office',
+    ],
+    [
+      'leaves a negated ip token untouched while dropping a plain one',
+      '!geoip:cn, geoip:private, 192.168.0.0/16',
+      [],
+      ipKnown,
+      '!geoip:cn, 192.168.0.0/16',
+    ],
+    [
+      'adds a geoip token next to an existing negation',
+      '!geoip:cn',
+      ['geoip:private'],
+      ipKnown,
+      '!geoip:cn, geoip:private',
+    ],
+    [
+      'ignores whitespace around field tokens',
+      '  google.com ,  geosite:google  ',
+      ['geosite:google'],
+      siteKnown,
+      'google.com, geosite:google',
+    ],
+    ['clearing every known token can empty the field', 'geosite:google', [], siteKnown, ''],
+  ];
+
+  it.each(cases)('%s', (_name, value, selected, known, expected) => {
+    expect(mergeSelection(value, selected, known)).toBe(expected);
+  });
+
+  it('round-trips with selectionFromValue', () => {
+    const value = mergeSelection('google.com, geosite:blabla', ['geosite:google', 'geosite:cn'], siteKnown);
+    expect(value).toBe('google.com, geosite:blabla, geosite:google, geosite:cn');
+    expect(selectionFromValue(value, siteKnown)).toEqual(['geosite:google', 'geosite:cn']);
+  });
+});
+
+describe('token matching tolerates the spellings Xray accepts', () => {
+  const cases: Array<[string, string, string[], string]> = [
+    [
+      'an uppercase token is recognised instead of duplicated',
+      'GEOSITE:GOOGLE',
+      ['geosite:google'],
+      'GEOSITE:GOOGLE',
+    ],
+    [
+      'the long ext form of a default database is the same token as its shorthand',
+      'ext:geosite.dat:google',
+      ['geosite:google'],
+      'ext:geosite.dat:google',
+    ],
+    [
+      'clearing a category written in its long form removes it',
+      'google.com, ext:geosite.dat:google',
+      [],
+      'google.com',
+    ],
+    [
+      'clearing a category written in uppercase removes it',
+      'GEOSITE:GOOGLE, google.com',
+      [],
+      'google.com',
+    ],
+    [
+      'a token from a database that was never opened survives untouched',
+      'ext:other.dat:x, geosite:google',
+      ['geosite:cn'],
+      'ext:other.dat:x, geosite:cn',
+    ],
+    ['a value of separators alone collapses to empty', ',,, ,', [], ''],
+  ];
+
+  it.each(cases)('%s', (_name, value, selected, expected) => {
+    expect(mergeSelection(value, selected, siteKnown)).toBe(expected);
+  });
+
+  it('seeds the selection from tokens written in another spelling', () => {
+    expect(selectionFromValue('GEOSITE:GOOGLE, ext:geosite.dat:cn', siteKnown)).toEqual([
+      'GEOSITE:GOOGLE',
+      'ext:geosite.dat:cn',
+    ]);
+  });
+});

+ 278 - 0
internal/web/controller/geodata_test.go

@@ -0,0 +1,278 @@
+package controller
+
+import (
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"net/netip"
+	"net/url"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"github.com/gin-gonic/gin"
+	"github.com/op/go-logging"
+	xraygeodata "github.com/xtls/xray-core/common/geodata"
+	"google.golang.org/protobuf/proto"
+
+	xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray/geodata"
+)
+
+func newGeodataEngine(t *testing.T) *gin.Engine {
+	t.Helper()
+	xuilogger.InitLogger(logging.ERROR)
+	gin.SetMode(gin.TestMode)
+
+	dir := t.TempDir()
+	t.Setenv("XUI_BIN_FOLDER", dir)
+	writeGeositeDB(t, dir)
+	writeGeoipDB(t, dir)
+
+	engine := gin.New()
+	NewXraySettingController(engine.Group("/panel/api"))
+	return engine
+}
+
+func writeGeositeDB(t *testing.T, dir string) {
+	t.Helper()
+	data, err := proto.Marshal(&xraygeodata.GeoSiteList{Entry: []*xraygeodata.GeoSite{
+		{Code: "google", Domain: []*xraygeodata.Domain{
+			{Type: xraygeodata.Domain_Domain, Value: "google.com"},
+			{Type: xraygeodata.Domain_Full, Value: "ads.google.com", Attribute: []*xraygeodata.Domain_Attribute{
+				{Key: "ads", TypedValue: &xraygeodata.Domain_Attribute_BoolValue{BoolValue: true}},
+			}},
+		}},
+		{Code: "cn", Domain: []*xraygeodata.Domain{{Type: xraygeodata.Domain_Domain, Value: "baidu.com"}}},
+	}})
+	if err != nil {
+		t.Fatalf("marshal geosite: %v", err)
+	}
+	if err := os.WriteFile(filepath.Join(dir, "geosite.dat"), data, 0o644); err != nil {
+		t.Fatalf("write geosite.dat: %v", err)
+	}
+}
+
+func writeGeoipDB(t *testing.T, dir string) {
+	t.Helper()
+	prefix := netip.MustParsePrefix("10.0.0.0/8")
+	data, err := proto.Marshal(&xraygeodata.GeoIPList{Entry: []*xraygeodata.GeoIP{
+		{Code: "private", Cidr: []*xraygeodata.CIDR{{Ip: prefix.Addr().AsSlice(), Prefix: uint32(prefix.Bits())}}},
+	}})
+	if err != nil {
+		t.Fatalf("marshal geoip: %v", err)
+	}
+	if err := os.WriteFile(filepath.Join(dir, "geoip.dat"), data, 0o644); err != nil {
+		t.Fatalf("write geoip.dat: %v", err)
+	}
+}
+
+type geodataEnvelope struct {
+	Success bool            `json:"success"`
+	Msg     string          `json:"msg"`
+	Obj     json.RawMessage `json:"obj"`
+}
+
+func doGeodataGet(t *testing.T, engine *gin.Engine, path string) geodataEnvelope {
+	t.Helper()
+	return doGeodataReq(t, engine, httptest.NewRequest(http.MethodGet, path, nil))
+}
+
+func doGeodataPost(t *testing.T, engine *gin.Engine, path string, form url.Values) geodataEnvelope {
+	t.Helper()
+	req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	return doGeodataReq(t, engine, req)
+}
+
+func doGeodataReq(t *testing.T, engine *gin.Engine, req *http.Request) geodataEnvelope {
+	t.Helper()
+	w := httptest.NewRecorder()
+	engine.ServeHTTP(w, req)
+	if w.Code != http.StatusOK {
+		t.Fatalf("%s %s: status %d, body=%s", req.Method, req.URL, w.Code, w.Body.String())
+	}
+	var env geodataEnvelope
+	if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
+		t.Fatalf("decode envelope: %v body=%s", err, w.Body.String())
+	}
+	return env
+}
+
+func TestGeodataFiles(t *testing.T) {
+	engine := newGeodataEngine(t)
+
+	env := doGeodataGet(t, engine, "/panel/api/xray/geodata/files")
+	if !env.Success {
+		t.Fatalf("files not successful: %s", env.Msg)
+	}
+	var files []geodata.GeoFile
+	if err := json.Unmarshal(env.Obj, &files); err != nil {
+		t.Fatalf("decode files: %v", err)
+	}
+	if len(files) != 2 {
+		t.Fatalf("files = %+v, want 2 entries", files)
+	}
+	byName := make(map[string]geodata.GeoFile, len(files))
+	for _, file := range files {
+		byName[file.Name] = file
+	}
+	if got := byName["geosite.dat"]; got.Kind != geodata.KindSite || got.Categories != 2 {
+		t.Errorf("geosite.dat = %+v, want kind site with 2 categories", got)
+	}
+	if got := byName["geoip.dat"]; got.Kind != geodata.KindIP || got.Categories != 1 {
+		t.Errorf("geoip.dat = %+v, want kind ip with 1 category", got)
+	}
+}
+
+func TestGeodataCategoriesAndEntries(t *testing.T) {
+	engine := newGeodataEngine(t)
+
+	env := doGeodataGet(t, engine, "/panel/api/xray/geodata/categories?file=geosite.dat&q=goo&limit=10")
+	var categories geodata.GeoCategoryPage
+	if err := json.Unmarshal(env.Obj, &categories); err != nil {
+		t.Fatalf("decode categories: %v", err)
+	}
+	if categories.Total != 1 || categories.Items[0].Code != "google" {
+		t.Fatalf("categories = %+v, want only google", categories)
+	}
+
+	env = doGeodataGet(t, engine, "/panel/api/xray/geodata/entries?file=geosite.dat&code=google&limit=1&offset=1")
+	var entries geodata.GeoEntryPage
+	if err := json.Unmarshal(env.Obj, &entries); err != nil {
+		t.Fatalf("decode entries: %v", err)
+	}
+	if entries.Total != 2 {
+		t.Errorf("entries total = %d, want 2", entries.Total)
+	}
+	if len(entries.Items) != 1 || entries.Items[0].Value != "ads.google.com" || entries.Items[0].Kind != "full" {
+		t.Errorf("entries items = %+v, want the second entry ads.google.com", entries.Items)
+	}
+}
+
+func TestGeodataRejectsBadRequests(t *testing.T) {
+	engine := newGeodataEngine(t)
+
+	tests := []struct {
+		name string
+		path string
+	}{
+		{name: "missing code", path: "/panel/api/xray/geodata/entries?file=geosite.dat"},
+		{name: "unknown category", path: "/panel/api/xray/geodata/entries?file=geosite.dat&code=nope"},
+		{name: "path traversal", path: "/panel/api/xray/geodata/categories?file=../../etc/passwd.dat"},
+		{name: "non dat file", path: "/panel/api/xray/geodata/categories?file=x-ui.db"},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if env := doGeodataGet(t, engine, tt.path); env.Success {
+				t.Errorf("request succeeded, want failure: %s", env.Obj)
+			}
+		})
+	}
+}
+
+func TestGeodataValidate(t *testing.T) {
+	engine := newGeodataEngine(t)
+
+	tests := []struct {
+		name       string
+		kind       string
+		tokens     string
+		wantTokens []string
+		wantReason string
+	}{
+		{name: "known categories pass", kind: "domain", tokens: "geosite:google,geosite:cn,google.com"},
+		{name: "attribute filter passes", kind: "domain", tokens: "geosite:google@ads"},
+		{
+			name:       "attribute the category does not carry",
+			kind:       "domain",
+			tokens:     "geosite:google@typo",
+			wantTokens: []string{"geosite:google@typo"},
+			wantReason: "attributeMissing",
+		},
+		{
+			name:       "empty attribute is a syntax error",
+			kind:       "domain",
+			tokens:     "geosite:google@",
+			wantTokens: []string{"geosite:google@"},
+			wantReason: "syntax",
+		},
+		{
+			name:       "missing category",
+			kind:       "domain",
+			tokens:     "geosite:google,geosite:blabla",
+			wantTokens: []string{"geosite:blabla"},
+			wantReason: "categoryMissing",
+		},
+		{
+			name:       "missing database",
+			kind:       "domain",
+			tokens:     "ext:absent.dat:corp",
+			wantTokens: []string{"ext:absent.dat:corp"},
+			wantReason: "fileMissing",
+		},
+		{
+			name:       "a geoip token in a domain field is reported",
+			kind:       "domain",
+			tokens:     "geoip:cn",
+			wantTokens: []string{"geoip:cn"},
+			wantReason: "wrongKind",
+		},
+		{name: "plain cidr passes", kind: "ip", tokens: "10.0.0.0/8,geoip:private"},
+		{
+			name:       "missing ip category",
+			kind:       "ip",
+			tokens:     "geoip:nowhere",
+			wantTokens: []string{"geoip:nowhere"},
+			wantReason: "categoryMissing",
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			env := doGeodataPost(t, engine, "/panel/api/xray/geodata/validate", url.Values{
+				"kind":   {tt.kind},
+				"tokens": {tt.tokens},
+			})
+			if !env.Success {
+				t.Fatalf("validate not successful: %s", env.Msg)
+			}
+			var issues []service.GeodataTokenIssue
+			if err := json.Unmarshal(env.Obj, &issues); err != nil {
+				t.Fatalf("decode issues: %v", err)
+			}
+			if len(issues) != len(tt.wantTokens) {
+				t.Fatalf("issues = %+v, want %d", issues, len(tt.wantTokens))
+			}
+			for i, wantToken := range tt.wantTokens {
+				if issues[i].Token != wantToken {
+					t.Errorf("issue %d token = %q, want %q", i, issues[i].Token, wantToken)
+				}
+				if issues[i].Reason != tt.wantReason {
+					t.Errorf("issue %d reason = %q, want %q", i, issues[i].Reason, tt.wantReason)
+				}
+			}
+		})
+	}
+}
+
+func TestGeodataFollowsXrayAssetLocation(t *testing.T) {
+	engine := newGeodataEngine(t)
+
+	shared := t.TempDir()
+	writeGeositeDB(t, shared)
+	t.Setenv("XRAY_LOCATION_ASSET", shared)
+
+	env := doGeodataGet(t, engine, "/panel/api/xray/geodata/files")
+	var files []geodata.GeoFile
+	if err := json.Unmarshal(env.Obj, &files); err != nil {
+		t.Fatalf("decode files: %v", err)
+	}
+	if len(files) != 1 || files[0].Name != "geosite.dat" {
+		t.Fatalf("files = %+v, want only the database from XRAY_LOCATION_ASSET", files)
+	}
+	if files[0].Categories != 2 {
+		t.Errorf("categories = %d, want 2 — the shared asset folder should be read", files[0].Categories)
+	}
+}

+ 73 - 0
internal/web/controller/xray_setting.go

@@ -26,6 +26,7 @@ type XraySettingController struct {
 	WarpService                 integration.WarpService
 	NordService                 integration.NordService
 	OutboundSubscriptionService service.OutboundSubscriptionService
+	GeodataService              service.GeodataService
 }
 
 // NewXraySettingController creates a new XraySettingController and initializes its routes.
@@ -53,6 +54,11 @@ func (a *XraySettingController) initRouter(g *gin.RouterGroup) {
 	g.POST("/balancerOverride", a.balancerOverride)
 	g.POST("/routeTest", a.routeTest)
 
+	g.GET("/geodata/files", a.geodataFiles)
+	g.GET("/geodata/categories", a.geodataCategories)
+	g.GET("/geodata/entries", a.geodataEntries)
+	g.POST("/geodata/validate", a.geodataValidate)
+
 	// Outbound subscription (remote outbound lists)
 	g.GET("/outbound-subs", a.listOutboundSubs)
 	g.POST("/outbound-subs", a.createOutboundSub)
@@ -391,6 +397,73 @@ func (a *XraySettingController) routeTest(c *gin.Context) {
 	jsonObj(c, result, nil)
 }
 
+// maxGeodataTokens bounds one validation request; a routing rule listing more
+// categories than this is not something the panel needs to answer for.
+const maxGeodataTokens = 500
+
+// geodataFiles lists the geo databases Xray resolves geosite:/geoip: tokens
+// against, including ones that failed to parse.
+func (a *XraySettingController) geodataFiles(c *gin.Context) {
+	files, err := a.GeodataService.Files()
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	jsonObj(c, files, nil)
+}
+
+// geodataCategories returns one page of a database's categories.
+func (a *XraySettingController) geodataCategories(c *gin.Context) {
+	offset, limit := geodataPaging(c)
+	page, err := a.GeodataService.Categories(c.Query("file"), c.Query("q"), offset, limit)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	jsonObj(c, page, nil)
+}
+
+// geodataEntries returns one page of the domains or CIDRs inside a category.
+func (a *XraySettingController) geodataEntries(c *gin.Context) {
+	code := c.Query("code")
+	if code == "" {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), common.NewError("code is required"))
+		return
+	}
+	offset, limit := geodataPaging(c)
+	page, err := a.GeodataService.Entries(c.Query("file"), code, c.Query("q"), offset, limit)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	jsonObj(c, page, nil)
+}
+
+// geodataValidate reports which routing tokens do not resolve against the
+// databases on disk.
+func (a *XraySettingController) geodataValidate(c *gin.Context) {
+	// Split with a bound rather than splitting first: a 10 MB body of commas
+	// would otherwise allocate millions of strings before the limit is checked.
+	tokens := strings.SplitN(c.PostForm("tokens"), ",", maxGeodataTokens+1)
+	if len(tokens) > maxGeodataTokens {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), common.NewErrorf("too many tokens: over %d", maxGeodataTokens))
+		return
+	}
+	jsonObj(c, a.GeodataService.Validate(c.PostForm("kind") == "ip", tokens), nil)
+}
+
+func geodataPaging(c *gin.Context) (int, int) {
+	offset, err := strconv.Atoi(c.Query("offset"))
+	if err != nil {
+		offset = 0
+	}
+	limit, err := strconv.Atoi(c.Query("limit"))
+	if err != nil {
+		limit = 0
+	}
+	return offset, limit
+}
+
 // --- Outbound Subscription handlers ---
 
 func (a *XraySettingController) listOutboundSubs(c *gin.Context) {

+ 156 - 0
internal/web/service/geodata.go

@@ -0,0 +1,156 @@
+package service
+
+import (
+	"errors"
+	"os"
+	"strings"
+	"sync"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/config"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray/geodata"
+)
+
+// GeodataTokenIssue reports a routing token the running core would reject,
+// or would silently match nothing against.
+type GeodataTokenIssue struct {
+	Token  string `json:"token" example:"geosite:blabla"`
+	Reason string `json:"reason" example:"categoryMissing"`
+	File   string `json:"file,omitempty" example:"geosite.dat"`
+	Code   string `json:"code,omitempty" example:"blabla"`
+}
+
+const (
+	geodataReasonSyntax           = "syntax"
+	geodataReasonFileMissing      = "fileMissing"
+	geodataReasonCategoryMissing  = "categoryMissing"
+	geodataReasonAttributeMissing = "attributeMissing"
+	geodataReasonWrongKind        = "wrongKind"
+)
+
+// geodataStores keys the cache by asset directory rather than holding a single
+// store, so a changed XUI_BIN_FOLDER is picked up instead of being pinned to
+// whatever the first call saw.
+var geodataStores sync.Map
+
+func assetStore() *geodata.Store {
+	dir := assetDir()
+	if cached, ok := geodataStores.Load(dir); ok {
+		return cached.(*geodata.Store)
+	}
+	store, _ := geodataStores.LoadOrStore(dir, geodata.NewStore(dir))
+	return store.(*geodata.Store)
+}
+
+// assetDir resolves the folder the running core reads its databases from,
+// with the same precedence the core itself uses (see ensureXrayAssetLocation
+// in internal/xray). An install that points XRAY_LOCATION_ASSET at a shared
+// asset directory would otherwise have the panel browsing an empty bin folder
+// and reporting perfectly valid geosite:/geoip: tokens as missing.
+func assetDir() string {
+	for _, key := range [...]string{"XRAY_LOCATION_ASSET", "xray.location.asset"} {
+		if dir := os.Getenv(key); dir != "" {
+			return dir
+		}
+	}
+	return config.GetBinFolderPath()
+}
+
+// GeodataService browses the geosite/geoip databases Xray resolves its
+// geosite:/geoip: routing tokens against.
+type GeodataService struct{}
+
+// Files lists the databases available in the Xray asset folder.
+func (s *GeodataService) Files() ([]geodata.GeoFile, error) {
+	return assetStore().ListFiles()
+}
+
+// Categories returns one page of a database's categories.
+func (s *GeodataService) Categories(file, query string, offset, limit int) (geodata.GeoCategoryPage, error) {
+	return assetStore().Categories(file, query, offset, limit)
+}
+
+// Entries returns one page of the rules inside a category.
+func (s *GeodataService) Entries(file, code, query string, offset, limit int) (geodata.GeoEntryPage, error) {
+	return assetStore().Entries(file, code, query, offset, limit)
+}
+
+// Validate reports which of the given routing tokens do not resolve against the
+// databases on disk. Plain domains and CIDRs are left alone — only tokens that
+// name a database are looked up.
+func (s *GeodataService) Validate(isIP bool, tokens []string) []GeodataTokenIssue {
+	kind := geodata.KindSite
+	if isIP {
+		kind = geodata.KindIP
+	}
+	issues := make([]GeodataTokenIssue, 0)
+	for _, token := range tokens {
+		token = strings.TrimSpace(token)
+		if token == "" {
+			continue
+		}
+		reference, err := geodata.ParseReference(token, kind)
+		if err != nil {
+			reason := geodataReasonSyntax
+			if errors.Is(err, geodata.ErrWrongKind) {
+				reason = geodataReasonWrongKind
+			}
+			issues = append(issues, GeodataTokenIssue{Token: token, Reason: reason})
+			continue
+		}
+		if reference.File == "" {
+			continue
+		}
+		category, err := assetStore().Lookup(reference.File, reference.Code)
+		if err != nil {
+			issues = append(issues, GeodataTokenIssue{
+				Token:  token,
+				Reason: geodataIssueReason(err),
+				File:   reference.File,
+				Code:   reference.Code,
+			})
+			continue
+		}
+		if missing := unknownAttributes(category, reference.Attributes); missing != "" {
+			issues = append(issues, GeodataTokenIssue{
+				Token:  token,
+				Reason: geodataReasonAttributeMissing,
+				File:   reference.File,
+				Code:   missing,
+			})
+		}
+	}
+	return issues
+}
+
+// unknownAttributes returns the first attribute the category does not carry.
+// The core accepts such a token, but no domain can satisfy the filter, so the
+// rule silently matches nothing — worth reporting even though Xray will start.
+// A leading "!" is accepted either way: some databases ship the negated key
+// verbatim, and the panel must not guess which convention a database follows.
+func unknownAttributes(category geodata.GeoCategory, wanted []string) string {
+	if len(wanted) == 0 {
+		return ""
+	}
+	present := make(map[string]struct{}, len(category.Attributes))
+	for _, attribute := range category.Attributes {
+		present[attribute] = struct{}{}
+		present[strings.TrimPrefix(attribute, "!")] = struct{}{}
+	}
+	for _, attribute := range wanted {
+		if _, ok := present[attribute]; ok {
+			continue
+		}
+		if _, ok := present[strings.TrimPrefix(attribute, "!")]; ok {
+			continue
+		}
+		return attribute
+	}
+	return ""
+}
+
+func geodataIssueReason(err error) string {
+	if errors.Is(err, geodata.ErrUnknownCategory) {
+		return geodataReasonCategoryMissing
+	}
+	return geodataReasonFileMissing
+}

+ 29 - 0
internal/web/translation/ar-EG.json

@@ -1446,6 +1446,35 @@
       "routing": {
         "dragToReorder": "اسحب لإعادة الترتيب"
       },
+      "geoBrowser": {
+        "title": "فئات قواعد geo",
+        "openTooltip": "استعراض فئات geo",
+        "database": "قاعدة البيانات",
+        "searchCategory": "بحث عن فئة",
+        "searchEntries": "تصفية داخل الفئة",
+        "selectFound": "تحديد النتائج",
+        "selected": "المحدد: {count}",
+        "clearAll": "مسح الكل",
+        "apply": "تطبيق",
+        "emptySelection": "حدّد الفئات لتتحول إلى عناصر في قاعدة التوجيه",
+        "pickCategory": "اختر فئة من القائمة لعرض محتواها",
+        "noMatches": "لم يتم العثور على شيء",
+        "noFiles": "لا توجد قواعد geo في مجلد Xray",
+        "noFilesHint": "ستظهر بعد أن ينزّل Xray ملفي geosite.dat و geoip.dat",
+        "fileMeta": "{count} فئة · {size} · تم التحديث {date}",
+        "entriesCount": "{count} إدخال",
+        "subnetsCount": "{count} شبكة فرعية",
+        "shownRange": "عرض {from}–{to} من {total}",
+        "loadFailed": "تعذر تحميل قواعد geo",
+        "checkFailed": "تعذر التحقق من هذه القيم مقابل قواعد geo",
+        "parseFailed": "الملف تالف أو ليس قاعدة geosite/geoip",
+        "tooLarge": "الملف أكبر من أن يتم استعراضه",
+        "unknownCategories": "غير موجود في القاعدة: {tokens}",
+        "missingDatabase": "ملف القاعدة غير موجود: {tokens}. أضِفه من قسم Geodata",
+        "unknownAttribute": "السمة غير موجودة، ولن تطابق القاعدة أي نطاق: {tokens}",
+        "invalidToken": "لن يقبل Xray هذه الصيغة: {tokens}",
+        "wrongKind": "نوع قاعدة البيانات غير مناسب لهذا الحقل: {tokens}"
+      },
       "ruleForm": {
         "sourceIps": "IPs المصدر",
         "sourcePort": "منفذ المصدر",

+ 29 - 0
internal/web/translation/en-US.json

@@ -1563,6 +1563,35 @@
       "routing": {
         "dragToReorder": "Drag to reorder"
       },
+      "geoBrowser": {
+        "title": "Geo categories",
+        "openTooltip": "Browse geo categories",
+        "database": "Database",
+        "searchCategory": "Search category",
+        "searchEntries": "Filter inside category",
+        "selectFound": "Select found",
+        "selected": "Selected {count}",
+        "clearAll": "Clear all",
+        "apply": "Apply",
+        "emptySelection": "Tick categories — they become rule tokens",
+        "pickCategory": "Pick a category on the left to see what it contains",
+        "noMatches": "Nothing found",
+        "noFiles": "No geo databases in the Xray folder",
+        "noFilesHint": "They appear after Xray downloads geosite.dat and geoip.dat",
+        "fileMeta": "{count} categories · {size} · updated {date}",
+        "entriesCount": "{count} entries",
+        "subnetsCount": "{count} subnets",
+        "shownRange": "Showing {from}–{to} of {total}",
+        "loadFailed": "Could not load geo databases",
+        "checkFailed": "Could not check these values against the geo databases",
+        "parseFailed": "Damaged or not a geosite/geoip database",
+        "tooLarge": "Too large to browse",
+        "unknownCategories": "Not in the database: {tokens}",
+        "missingDatabase": "Database file not found: {tokens} — add it under Geodata",
+        "unknownAttribute": "Attribute not found, the rule would match nothing: {tokens}",
+        "invalidToken": "Xray will not accept this: {tokens}",
+        "wrongKind": "Wrong database kind for this field: {tokens}"
+      },
       "ruleForm": {
         "sourceIps": "Source IPs",
         "sourcePort": "Source port",

+ 29 - 0
internal/web/translation/es-ES.json

@@ -1446,6 +1446,35 @@
       "routing": {
         "dragToReorder": "Arrastra para reordenar"
       },
+      "geoBrowser": {
+        "title": "Categorías geo",
+        "openTooltip": "Explorar categorías geo",
+        "database": "Base de datos",
+        "searchCategory": "Buscar categoría",
+        "searchEntries": "Filtrar dentro de la categoría",
+        "selectFound": "Seleccionar encontradas",
+        "selected": "Seleccionadas: {count}",
+        "clearAll": "Limpiar todo",
+        "apply": "Aplicar",
+        "emptySelection": "Marca categorías: se convertirán en tokens de la regla",
+        "pickCategory": "Elige una categoría a la izquierda para ver su contenido",
+        "noMatches": "No se encontró nada",
+        "noFiles": "No hay bases geo en la carpeta de Xray",
+        "noFilesHint": "Aparecerán cuando Xray descargue geosite.dat y geoip.dat",
+        "fileMeta": "{count} categorías · {size} · actualizado {date}",
+        "entriesCount": "{count} entradas",
+        "subnetsCount": "{count} subredes",
+        "shownRange": "Mostrando {from}–{to} de {total}",
+        "loadFailed": "No se pudieron cargar las bases geo",
+        "checkFailed": "No se pudieron verificar estos valores con las bases geo",
+        "parseFailed": "Archivo dañado o no es una base geosite/geoip",
+        "tooLarge": "Demasiado grande para explorarlo",
+        "unknownCategories": "No están en la base: {tokens}",
+        "missingDatabase": "No se encontró el archivo de la base: {tokens} — añádelo en la sección Geodata",
+        "unknownAttribute": "Atributo no encontrado, la regla no coincidirá con nada: {tokens}",
+        "invalidToken": "Xray no aceptará esta entrada: {tokens}",
+        "wrongKind": "Tipo de base incorrecto para este campo: {tokens}"
+      },
       "ruleForm": {
         "sourceIps": "IPs de origen",
         "sourcePort": "Puerto de origen",

+ 29 - 0
internal/web/translation/fa-IR.json

@@ -1446,6 +1446,35 @@
       "routing": {
         "dragToReorder": "برای تغییر ترتیب بکشید"
       },
+      "geoBrowser": {
+        "title": "دسته‌های پایگاه geo",
+        "openTooltip": "مرور دسته‌های geo",
+        "database": "پایگاه داده",
+        "searchCategory": "جستجوی دسته",
+        "searchEntries": "فیلتر درون دسته",
+        "selectFound": "انتخاب موارد یافت‌شده",
+        "selected": "انتخاب‌شده: {count}",
+        "clearAll": "پاک کردن همه",
+        "apply": "اعمال",
+        "emptySelection": "دسته‌ها را علامت بزنید تا به مقادیر قانون تبدیل شوند",
+        "pickCategory": "برای دیدن محتوا، یک دسته را از فهرست انتخاب کنید",
+        "noMatches": "چیزی یافت نشد",
+        "noFiles": "در پوشه Xray هیچ پایگاه geo وجود ندارد",
+        "noFilesHint": "پس از آنکه Xray فایل‌های geosite.dat و geoip.dat را دانلود کند، نمایش داده می‌شوند",
+        "fileMeta": "{count} دسته · {size} · به‌روزرسانی {date}",
+        "entriesCount": "{count} مورد",
+        "subnetsCount": "{count} زیرشبکه",
+        "shownRange": "نمایش {from}–{to} از {total}",
+        "loadFailed": "بارگذاری پایگاه‌های geo ناموفق بود",
+        "checkFailed": "بررسی این مقادیر در برابر پایگاه‌های geo ممکن نشد",
+        "parseFailed": "فایل خراب است یا پایگاه geosite/geoip نیست",
+        "tooLarge": "فایل برای مرور بسیار بزرگ است",
+        "unknownCategories": "در پایگاه داده وجود ندارد: {tokens}",
+        "missingDatabase": "فایل پایگاه داده وجود ندارد: {tokens}. آن را در بخش Geodata اضافه کنید",
+        "unknownAttribute": "ویژگی یافت نشد و قانون با هیچ چیزی مطابقت نخواهد کرد: {tokens}",
+        "invalidToken": "Xray چنین مقداری را نمی‌پذیرد: {tokens}",
+        "wrongKind": "نوع پایگاه داده برای این فیلد نادرست است: {tokens}"
+      },
       "ruleForm": {
         "sourceIps": "IPهای مبدا",
         "sourcePort": "پورت مبدا",

+ 29 - 0
internal/web/translation/id-ID.json

@@ -1446,6 +1446,35 @@
       "routing": {
         "dragToReorder": "Seret untuk mengurutkan ulang"
       },
+      "geoBrowser": {
+        "title": "Kategori geo",
+        "openTooltip": "Jelajahi kategori geo",
+        "database": "Basis data",
+        "searchCategory": "Cari kategori",
+        "searchEntries": "Filter di dalam kategori",
+        "selectFound": "Pilih hasil pencarian",
+        "selected": "Dipilih {count}",
+        "clearAll": "Hapus semua",
+        "apply": "Terapkan",
+        "emptySelection": "Centang kategori — semuanya menjadi token aturan",
+        "pickCategory": "Pilih kategori di sebelah kiri untuk melihat isinya",
+        "noMatches": "Tidak ada yang ditemukan",
+        "noFiles": "Tidak ada basis data geo di folder Xray",
+        "noFilesHint": "Akan muncul setelah Xray mengunduh geosite.dat dan geoip.dat",
+        "fileMeta": "{count} kategori · {size} · diperbarui {date}",
+        "entriesCount": "{count} entri",
+        "subnetsCount": "{count} subnet",
+        "shownRange": "Menampilkan {from}–{to} dari {total}",
+        "loadFailed": "Gagal memuat basis data geo",
+        "checkFailed": "Tidak dapat memeriksa nilai ini terhadap basis data geo",
+        "parseFailed": "Berkas rusak atau bukan basis data geosite/geoip",
+        "tooLarge": "Terlalu besar untuk ditelusuri",
+        "unknownCategories": "Tidak ada di basis data: {tokens}",
+        "missingDatabase": "Berkas basis data tidak ditemukan: {tokens} — tambahkan di bagian Geodata",
+        "unknownAttribute": "Atribut tidak ditemukan, aturan tidak akan cocok dengan apa pun: {tokens}",
+        "invalidToken": "Xray tidak akan menerima entri ini: {tokens}",
+        "wrongKind": "Jenis basis data salah untuk kolom ini: {tokens}"
+      },
       "ruleForm": {
         "sourceIps": "IP sumber",
         "sourcePort": "Port sumber",

+ 29 - 0
internal/web/translation/ja-JP.json

@@ -1446,6 +1446,35 @@
       "routing": {
         "dragToReorder": "ドラッグして並べ替え"
       },
+      "geoBrowser": {
+        "title": "geo カテゴリ",
+        "openTooltip": "geo カテゴリを参照",
+        "database": "データベース",
+        "searchCategory": "カテゴリを検索",
+        "searchEntries": "カテゴリ内を絞り込み",
+        "selectFound": "検索結果を選択",
+        "selected": "選択中 {count} 件",
+        "clearAll": "すべてクリア",
+        "apply": "適用",
+        "emptySelection": "カテゴリにチェックを入れると、ルールのトークンになります",
+        "pickCategory": "左のカテゴリを選ぶと内容が表示されます",
+        "noMatches": "見つかりませんでした",
+        "noFiles": "Xray フォルダーに geo データベースがありません",
+        "noFilesHint": "Xray が geosite.dat と geoip.dat をダウンロードすると表示されます",
+        "fileMeta": "{count} カテゴリ · {size} · 更新 {date}",
+        "entriesCount": "{count} 件",
+        "subnetsCount": "{count} サブネット",
+        "shownRange": "{total} 件中 {from}–{to} を表示",
+        "loadFailed": "geo データベースを読み込めませんでした",
+        "checkFailed": "これらの値を geo データベースと照合できませんでした",
+        "parseFailed": "ファイルが破損しているか、geosite/geoip データベースではありません",
+        "tooLarge": "サイズが大きすぎて参照できません",
+        "unknownCategories": "データベースに存在しません: {tokens}",
+        "missingDatabase": "データベースファイルが見つかりません: {tokens} — Geodata から追加してください",
+        "unknownAttribute": "属性が見つからないため、ルールは何にも一致しません: {tokens}",
+        "invalidToken": "Xray はこの記述を受け付けません: {tokens}",
+        "wrongKind": "このフィールドには合わないデータベース種別です: {tokens}"
+      },
       "ruleForm": {
         "sourceIps": "送信元 IP",
         "sourcePort": "送信元ポート",

+ 29 - 0
internal/web/translation/pt-BR.json

@@ -1446,6 +1446,35 @@
       "routing": {
         "dragToReorder": "Arraste para reordenar"
       },
+      "geoBrowser": {
+        "title": "Categorias geo",
+        "openTooltip": "Explorar categorias geo",
+        "database": "Base de dados",
+        "searchCategory": "Pesquisar categoria",
+        "searchEntries": "Filtrar dentro da categoria",
+        "selectFound": "Selecionar encontradas",
+        "selected": "Selecionadas: {count}",
+        "clearAll": "Limpar tudo",
+        "apply": "Aplicar",
+        "emptySelection": "Marque as categorias — elas viram tokens da regra",
+        "pickCategory": "Escolha uma categoria à esquerda para ver o conteúdo",
+        "noMatches": "Nada encontrado",
+        "noFiles": "Nenhuma base geo na pasta do Xray",
+        "noFilesHint": "Elas aparecem depois que o Xray baixa geosite.dat e geoip.dat",
+        "fileMeta": "{count} categorias · {size} · atualizado em {date}",
+        "entriesCount": "{count} entradas",
+        "subnetsCount": "{count} sub-redes",
+        "shownRange": "Mostrando {from}–{to} de {total}",
+        "loadFailed": "Não foi possível carregar as bases geo",
+        "checkFailed": "Não foi possível verificar estes valores nas bases geo",
+        "parseFailed": "Arquivo corrompido ou não é uma base geosite/geoip",
+        "tooLarge": "Grande demais para navegar",
+        "unknownCategories": "Não estão na base: {tokens}",
+        "missingDatabase": "Arquivo da base não encontrado: {tokens} — adicione-o na seção Geodata",
+        "unknownAttribute": "Atributo não encontrado, a regra não corresponderá a nada: {tokens}",
+        "invalidToken": "O Xray não aceitará esta entrada: {tokens}",
+        "wrongKind": "Tipo de base incorreto para este campo: {tokens}"
+      },
       "ruleForm": {
         "sourceIps": "IPs de origem",
         "sourcePort": "Porta de origem",

+ 29 - 0
internal/web/translation/ru-RU.json

@@ -1446,6 +1446,35 @@
       "routing": {
         "dragToReorder": "Перетащите для изменения порядка"
       },
+      "geoBrowser": {
+        "title": "Категории geo-баз",
+        "openTooltip": "Открыть браузер geo-категорий",
+        "database": "База",
+        "searchCategory": "Поиск категории",
+        "searchEntries": "Фильтр внутри категории",
+        "selectFound": "Отметить найденные",
+        "selected": "Выбрано {count}",
+        "clearAll": "Снять всё",
+        "apply": "Применить",
+        "emptySelection": "Отметьте категории — они станут токенами правила",
+        "pickCategory": "Выберите категорию слева, чтобы посмотреть её содержимое",
+        "noMatches": "Ничего не найдено",
+        "noFiles": "В каталоге Xray нет geo-баз",
+        "noFilesHint": "Они появятся после того, как Xray скачает geosite.dat и geoip.dat",
+        "fileMeta": "{count} категорий · {size} · обновлено {date}",
+        "entriesCount": "{count} записей",
+        "subnetsCount": "{count} подсетей",
+        "shownRange": "Показано {from}–{to} из {total}",
+        "loadFailed": "Не удалось загрузить geo-базы",
+        "checkFailed": "Не удалось проверить эти значения по geo-базам",
+        "parseFailed": "Файл повреждён или это не база geosite/geoip",
+        "tooLarge": "Слишком большой файл для просмотра",
+        "unknownCategories": "Нет в базе: {tokens}",
+        "missingDatabase": "Файла базы нет: {tokens} — добавьте её в разделе Geodata",
+        "unknownAttribute": "Атрибут не найден, правило ничего не сматчит: {tokens}",
+        "invalidToken": "Xray не примет такую запись: {tokens}",
+        "wrongKind": "База не того типа для этого поля: {tokens}"
+      },
       "ruleForm": {
         "sourceIps": "IP источника",
         "sourcePort": "Порт источника",

+ 29 - 0
internal/web/translation/tr-TR.json

@@ -1446,6 +1446,35 @@
       "routing": {
         "dragToReorder": "Yeniden sıralamak için sürükleyin"
       },
+      "geoBrowser": {
+        "title": "Geo kategorileri",
+        "openTooltip": "Geo kategorilerine göz at",
+        "database": "Veritabanı",
+        "searchCategory": "Kategori ara",
+        "searchEntries": "Kategori içinde filtrele",
+        "selectFound": "Bulunanları seç",
+        "selected": "Seçili: {count}",
+        "clearAll": "Tümünü Temizle",
+        "apply": "Uygula",
+        "emptySelection": "Kategorileri işaretleyin — kural belirteçlerine dönüşürler",
+        "pickCategory": "İçeriğini görmek için soldan bir kategori seçin",
+        "noMatches": "Hiçbir şey bulunamadı",
+        "noFiles": "Xray klasöründe geo veritabanı yok",
+        "noFilesHint": "Xray, geosite.dat ve geoip.dat dosyalarını indirdikten sonra görünürler",
+        "fileMeta": "{count} kategori · {size} · güncellendi {date}",
+        "entriesCount": "{count} kayıt",
+        "subnetsCount": "{count} alt ağ",
+        "shownRange": "{total} kayıttan {from}–{to} arası gösteriliyor",
+        "loadFailed": "Geo veritabanları yüklenemedi",
+        "checkFailed": "Bu değerler geo veritabanlarıyla doğrulanamadı",
+        "parseFailed": "Dosya bozuk veya geosite/geoip veritabanı değil",
+        "tooLarge": "Göz atmak için fazla büyük",
+        "unknownCategories": "Veritabanında yok: {tokens}",
+        "missingDatabase": "Veritabanı dosyası bulunamadı: {tokens} — Geodata bölümünden ekleyin",
+        "unknownAttribute": "Öznitelik bulunamadı, kural hiçbir şeyle eşleşmez: {tokens}",
+        "invalidToken": "Xray böyle bir kaydı kabul etmez: {tokens}",
+        "wrongKind": "Bu alan için yanlış veritabanı türü: {tokens}"
+      },
       "ruleForm": {
         "sourceIps": "Kaynak IP'ler",
         "sourcePort": "Kaynak Port",

+ 29 - 0
internal/web/translation/uk-UA.json

@@ -1446,6 +1446,35 @@
       "routing": {
         "dragToReorder": "Перетягніть для зміни порядку"
       },
+      "geoBrowser": {
+        "title": "Категорії geo-баз",
+        "openTooltip": "Відкрити браузер geo-категорій",
+        "database": "База",
+        "searchCategory": "Пошук категорії",
+        "searchEntries": "Фільтр усередині категорії",
+        "selectFound": "Позначити знайдені",
+        "selected": "Вибрано {count}",
+        "clearAll": "Зняти все",
+        "apply": "Застосувати",
+        "emptySelection": "Позначте категорії — вони стануть токенами правила",
+        "pickCategory": "Виберіть категорію ліворуч, щоб переглянути її вміст",
+        "noMatches": "Нічого не знайдено",
+        "noFiles": "У теці Xray немає geo-баз",
+        "noFilesHint": "Вони з’являться після того, як Xray завантажить geosite.dat і geoip.dat",
+        "fileMeta": "{count} категорій · {size} · оновлено {date}",
+        "entriesCount": "{count} записів",
+        "subnetsCount": "{count} підмереж",
+        "shownRange": "Показано {from}–{to} з {total}",
+        "loadFailed": "Не вдалося завантажити geo-бази",
+        "checkFailed": "Не вдалося перевірити ці значення за geo-базами",
+        "parseFailed": "Файл пошкоджено або це не база geosite/geoip",
+        "tooLarge": "Завеликий файл для перегляду",
+        "unknownCategories": "Немає в базі: {tokens}",
+        "missingDatabase": "Файлу бази немає: {tokens} — додайте її в розділі Geodata",
+        "unknownAttribute": "Атрибут не знайдено, правило ні з чим не збігатиметься: {tokens}",
+        "invalidToken": "Xray не прийме такий запис: {tokens}",
+        "wrongKind": "База не того типу для цього поля: {tokens}"
+      },
       "ruleForm": {
         "sourceIps": "IP джерела",
         "sourcePort": "Порт джерела",

+ 29 - 0
internal/web/translation/vi-VN.json

@@ -1446,6 +1446,35 @@
       "routing": {
         "dragToReorder": "Kéo để sắp xếp lại"
       },
+      "geoBrowser": {
+        "title": "Danh mục geo",
+        "openTooltip": "Duyệt danh mục geo",
+        "database": "Cơ sở dữ liệu",
+        "searchCategory": "Tìm danh mục",
+        "searchEntries": "Lọc trong danh mục",
+        "selectFound": "Chọn các kết quả",
+        "selected": "Đã chọn {count}",
+        "clearAll": "Xóa tất cả",
+        "apply": "Áp dụng",
+        "emptySelection": "Đánh dấu danh mục — chúng sẽ trở thành token của quy tắc",
+        "pickCategory": "Chọn một danh mục ở bên trái để xem nội dung",
+        "noMatches": "Không tìm thấy gì",
+        "noFiles": "Không có cơ sở dữ liệu geo trong thư mục Xray",
+        "noFilesHint": "Chúng sẽ xuất hiện sau khi Xray tải geosite.dat và geoip.dat",
+        "fileMeta": "{count} danh mục · {size} · cập nhật {date}",
+        "entriesCount": "{count} mục",
+        "subnetsCount": "{count} dải mạng",
+        "shownRange": "Hiển thị {from}–{to} trong {total}",
+        "loadFailed": "Không thể tải cơ sở dữ liệu geo",
+        "checkFailed": "Không thể kiểm tra các giá trị này với cơ sở dữ liệu geo",
+        "parseFailed": "Tệp bị hỏng hoặc không phải cơ sở dữ liệu geosite/geoip",
+        "tooLarge": "Tệp quá lớn để duyệt",
+        "unknownCategories": "Không có trong cơ sở dữ liệu: {tokens}",
+        "missingDatabase": "Không tìm thấy tệp cơ sở dữ liệu: {tokens} — hãy thêm trong mục Geodata",
+        "unknownAttribute": "Không tìm thấy thuộc tính, quy tắc sẽ không khớp với bất kỳ thứ gì: {tokens}",
+        "invalidToken": "Xray sẽ không chấp nhận mục này: {tokens}",
+        "wrongKind": "Sai loại cơ sở dữ liệu cho trường này: {tokens}"
+      },
       "ruleForm": {
         "sourceIps": "IP nguồn",
         "sourcePort": "Cổng nguồn",

+ 29 - 0
internal/web/translation/zh-CN.json

@@ -1446,6 +1446,35 @@
       "routing": {
         "dragToReorder": "拖动以重新排序"
       },
+      "geoBrowser": {
+        "title": "geo 分类",
+        "openTooltip": "浏览 geo 分类",
+        "database": "数据库",
+        "searchCategory": "搜索分类",
+        "searchEntries": "在分类内筛选",
+        "selectFound": "选中搜索结果",
+        "selected": "已选 {count} 项",
+        "clearAll": "全部清除",
+        "apply": "应用",
+        "emptySelection": "勾选分类,它们将成为规则中的条目",
+        "pickCategory": "在左侧选择一个分类以查看其内容",
+        "noMatches": "未找到任何内容",
+        "noFiles": "Xray 目录中没有 geo 数据库",
+        "noFilesHint": "Xray 下载 geosite.dat 和 geoip.dat 后即会出现",
+        "fileMeta": "{count} 个分类 · {size} · 更新于 {date}",
+        "entriesCount": "{count} 条记录",
+        "subnetsCount": "{count} 个网段",
+        "shownRange": "显示第 {from}–{to} 项,共 {total} 项",
+        "loadFailed": "无法加载 geo 数据库",
+        "checkFailed": "无法根据 geo 数据库校验这些值",
+        "parseFailed": "文件已损坏或不是 geosite/geoip 数据库",
+        "tooLarge": "文件过大,无法浏览",
+        "unknownCategories": "数据库中不存在:{tokens}",
+        "missingDatabase": "未找到数据库文件:{tokens} — 请在 Geodata 中添加",
+        "unknownAttribute": "未找到该属性,规则不会匹配任何内容:{tokens}",
+        "invalidToken": "Xray 无法接受此写法:{tokens}",
+        "wrongKind": "该字段的数据库类型不匹配:{tokens}"
+      },
       "ruleForm": {
         "sourceIps": "源 IP",
         "sourcePort": "源端口",

+ 29 - 0
internal/web/translation/zh-TW.json

@@ -1446,6 +1446,35 @@
       "routing": {
         "dragToReorder": "拖曳以重新排序"
       },
+      "geoBrowser": {
+        "title": "geo 分類",
+        "openTooltip": "瀏覽 geo 分類",
+        "database": "資料庫",
+        "searchCategory": "搜尋分類",
+        "searchEntries": "在分類內篩選",
+        "selectFound": "勾選搜尋結果",
+        "selected": "已選 {count} 項",
+        "clearAll": "全部清除",
+        "apply": "套用",
+        "emptySelection": "勾選分類,它們將成為規則中的項目",
+        "pickCategory": "在左側選擇一個分類以查看其內容",
+        "noMatches": "找不到任何內容",
+        "noFiles": "Xray 目錄中沒有 geo 資料庫",
+        "noFilesHint": "Xray 下載 geosite.dat 與 geoip.dat 後即會出現",
+        "fileMeta": "{count} 個分類 · {size} · 更新於 {date}",
+        "entriesCount": "{count} 筆記錄",
+        "subnetsCount": "{count} 個網段",
+        "shownRange": "顯示第 {from}–{to} 項,共 {total} 項",
+        "loadFailed": "無法載入 geo 資料庫",
+        "checkFailed": "無法依據 geo 資料庫檢查這些值",
+        "parseFailed": "檔案已損毀或不是 geosite/geoip 資料庫",
+        "tooLarge": "檔案過大,無法瀏覽",
+        "unknownCategories": "資料庫中不存在:{tokens}",
+        "missingDatabase": "找不到資料庫檔案:{tokens} — 請在 Geodata 中新增",
+        "unknownAttribute": "找不到該屬性,規則不會比對到任何內容:{tokens}",
+        "invalidToken": "Xray 無法接受此寫法:{tokens}",
+        "wrongKind": "此欄位的資料庫類型不符:{tokens}"
+      },
       "ruleForm": {
         "sourceIps": "來源 IP",
         "sourcePort": "來源連接埠",

+ 269 - 0
internal/xray/geodata/geodata.go

@@ -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)
+		}
+	}
+}

+ 558 - 0
internal/xray/geodata/geodata_test.go

@@ -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")
+		}
+	}
+}

+ 128 - 0
internal/xray/geodata/query.go

@@ -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
+}

+ 487 - 0
internal/xray/geodata/reader.go

@@ -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
+}

+ 143 - 0
internal/xray/geodata/token.go

@@ -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:"
+}

+ 175 - 0
internal/xray/geodata/token_test.go

@@ -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)
+				}
+			}
+		})
+	}
+}

+ 12 - 0
tools/openapigen/main.go

@@ -74,6 +74,17 @@ func run(root, outDir string) error {
 				"ClientTraffic",
 			),
 		},
+		{
+			Path: resolveRel(root, "internal/xray/geodata"),
+			StructAllow: setOf(
+				"GeoFile",
+				"GeoCategory",
+				"GeoEntry",
+				"GeoCategoryPage",
+				"GeoEntryPage",
+			),
+			AliasAllow: setOf("GeoKind"),
+		},
 		{
 			Path: resolveRel(root, "internal/web/service"),
 			StructAllow: setOf(
@@ -82,6 +93,7 @@ func run(root, outDir string) error {
 				"NodeView",
 				"ProbeResultUI",
 				"RealityScanResult",
+				"GeodataTokenIssue",
 			),
 		},
 		{