mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-06 18:27:14 +00:00
feat(xray): autocomplete geosite/geoip categories in the routing rule editor
Writing a routing rule today means remembering exact geosite:/geoip:/ ext:file:code syntax by hand, with no way to discover what categories actually exist in the .dat files sitting in the bin folder -- including custom ones like geosite_roscom.dat added via the Geodata auto-update feature. The Domain/IP fields in the rule editor now suggest categories as you type (e.g. "you" -> "geosite:youtube"), built live from whatever .dat files are actually on disk, while still accepting any free-typed value exactly as before. Backend: GET /panel/api/xray/getGeodataCategories scans the bin folder, parses matched geosite*/geoip*.dat files via xray-core's own exported protobuf types, and formats each category as the exact rule syntax xray-core's parser accepts -- geosite:/geoip: for the default files, ext:<file>:<code> for anything else (there's no shorthand for custom files). Cached in memory keyed by each file's (name, size, modTime) so a request-time scan is cheap until a file actually changes. Frontend: the Domain/IP inputs become Select "tags" fields fed by a new useGeodataCategories() query hook, with an explicit substring filter so "you" matches "geosite:youtube" (not a prefix). The array<->CSV-string adapter lives entirely at the FormField transform boundary, so the underlying form schema and saved rule shape are unchanged. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -10452,6 +10452,49 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/panel/api/xray/getGeodataCategories": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Xray Settings"
|
||||||
|
],
|
||||||
|
"summary": "Return every geosite/geoip category found in the .dat files currently present in the Xray bin folder (including custom files added via the Geodata auto-update feature), formatted as ready-to-use routing rule values, e.g. \"geosite:youtube\" or \"ext:geosite_roscom.dat:some-code\".",
|
||||||
|
"operationId": "get_panel_api_xray_getGeodataCategories",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"msg": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"obj": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"success": true,
|
||||||
|
"obj": {
|
||||||
|
"domain": [
|
||||||
|
"geosite:cn",
|
||||||
|
"geosite:youtube"
|
||||||
|
],
|
||||||
|
"ip": [
|
||||||
|
"geoip:cn",
|
||||||
|
"geoip:private"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/panel/api/xray/update": {
|
"/panel/api/xray/update": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { HttpUtil } from '@/utils';
|
||||||
|
import { parseMsg } from '@/utils/zodValidate';
|
||||||
|
import { keys } from '@/api/queryKeys';
|
||||||
|
import { GeodataCategoriesSchema, type GeodataCategories } from '@/schemas/routing';
|
||||||
|
|
||||||
|
const EMPTY_CATEGORIES: GeodataCategories = { domain: [], ip: [] };
|
||||||
|
|
||||||
|
async function fetchGeodataCategories(): Promise<GeodataCategories> {
|
||||||
|
const msg = await HttpUtil.get('/panel/api/xray/getGeodataCategories', undefined, { silent: true });
|
||||||
|
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch geodata categories');
|
||||||
|
const validated = parseMsg(msg, GeodataCategoriesSchema, 'xray/getGeodataCategories');
|
||||||
|
return validated.obj ?? EMPTY_CATEGORIES;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deliberately not staleTime: Infinity like useInboundOptions: geodata .dat
|
||||||
|
// files can change from xray-core's own unattended geodata-update cron,
|
||||||
|
// which has no invalidation hook into the panel. Inheriting the app's
|
||||||
|
// global default staleTime lets a long-open tab pick up newly downloaded
|
||||||
|
// categories on refocus, at near-zero backend cost thanks to the
|
||||||
|
// mtime/size cache in GetGeodataCategories.
|
||||||
|
export function useGeodataCategories() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: keys.xray.geodataCategories(),
|
||||||
|
queryFn: fetchGeodataCategories,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -37,5 +37,6 @@ export const keys = {
|
|||||||
root: () => ['xray'] as const,
|
root: () => ['xray'] as const,
|
||||||
config: () => ['xray', 'config'] as const,
|
config: () => ['xray', 'config'] as const,
|
||||||
outboundsTraffic: () => ['xray', 'outboundsTraffic'] as const,
|
outboundsTraffic: () => ['xray', 'outboundsTraffic'] as const,
|
||||||
|
geodataCategories: () => ['xray', 'geodataCategories'] as const,
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export type ProcessState = string;
|
|||||||
export type Protocol = string;
|
export type Protocol = string;
|
||||||
export type SubLinkProvider = unknown;
|
export type SubLinkProvider = unknown;
|
||||||
export type ensureAction = number;
|
export type ensureAction = number;
|
||||||
|
export type geodataFileKind = number;
|
||||||
export type staticEgressResolver = string;
|
export type staticEgressResolver = string;
|
||||||
export type transportBits = number;
|
export type transportBits = number;
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ export type SubLinkProvider = z.infer<typeof SubLinkProviderSchema>;
|
|||||||
export const ensureActionSchema = z.number().int();
|
export const ensureActionSchema = z.number().int();
|
||||||
export type ensureAction = z.infer<typeof ensureActionSchema>;
|
export type ensureAction = z.infer<typeof ensureActionSchema>;
|
||||||
|
|
||||||
|
export const geodataFileKindSchema = z.number().int();
|
||||||
|
export type geodataFileKind = z.infer<typeof geodataFileKindSchema>;
|
||||||
|
|
||||||
export const staticEgressResolverSchema = z.string();
|
export const staticEgressResolverSchema = z.string();
|
||||||
export type staticEgressResolver = z.infer<typeof staticEgressResolverSchema>;
|
export type staticEgressResolver = z.infer<typeof staticEgressResolverSchema>;
|
||||||
|
|
||||||
|
|||||||
@@ -1277,6 +1277,12 @@ export const sections: readonly Section[] = [
|
|||||||
path: '/panel/api/xray/getXrayResult',
|
path: '/panel/api/xray/getXrayResult',
|
||||||
summary: 'Return the most recent Xray process stdout/stderr output. Useful to check for startup errors or runtime warnings.',
|
summary: 'Return the most recent Xray process stdout/stderr output. Useful to check for startup errors or runtime warnings.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/panel/api/xray/getGeodataCategories',
|
||||||
|
summary: 'Return every geosite/geoip category found in the .dat files currently present in the Xray bin folder (including custom files added via the Geodata auto-update feature), formatted as ready-to-use routing rule values, e.g. "geosite:youtube" or "ext:geosite_roscom.dat:some-code".',
|
||||||
|
response: '{\n "success": true,\n "obj": {\n "domain": ["geosite:cn", "geosite:youtube"],\n "ip": ["geoip:cn", "geoip:private"]\n }\n}',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/panel/api/xray/update',
|
path: '/panel/api/xray/update',
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { FormProvider, useForm, useWatch } from 'react-hook-form';
|
|||||||
import { InputAddon } from '@/components/ui';
|
import { InputAddon } from '@/components/ui';
|
||||||
import { FormField } from '@/components/form/rhf';
|
import { FormField } from '@/components/form/rhf';
|
||||||
import { useInboundOptions } from '@/api/queries/useInboundOptions';
|
import { useInboundOptions } from '@/api/queries/useInboundOptions';
|
||||||
|
import { useGeodataCategories } from '@/api/queries/useGeodataCategories';
|
||||||
import { RuleFormSchema, type RuleFormValues } from '@/schemas/xray';
|
import { RuleFormSchema, type RuleFormValues } from '@/schemas/xray';
|
||||||
import { buildRemarkByTag, formatInboundTag, isApiRule } from './helpers';
|
import { buildRemarkByTag, formatInboundTag, isApiRule } from './helpers';
|
||||||
|
|
||||||
@@ -63,6 +64,24 @@ function csv(value: string): string[] {
|
|||||||
return value.split(',').map((s) => s.trim()).filter(Boolean);
|
return value.split(',').map((s) => s.trim()).filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Domain/IP are stored as a comma-joined string (RuleFormSchema.domain/ip),
|
||||||
|
// same as every other csv-backed field on this form, but rendered as a
|
||||||
|
// Select "tags" input so geosite/geoip suggestions can be picked alongside
|
||||||
|
// free-typed values. These adapt between the two shapes at the FormField
|
||||||
|
// transform boundary only -- the stored form value never becomes an array.
|
||||||
|
function toTagsArray(value: unknown): string[] {
|
||||||
|
return csv(typeof value === 'string' ? value : '');
|
||||||
|
}
|
||||||
|
function fromTagsArray(value: unknown): string {
|
||||||
|
return Array.isArray(value) ? value.join(',') : '';
|
||||||
|
}
|
||||||
|
// Explicit substring match: typing "you" must match the suggestion
|
||||||
|
// "geosite:youtube", which isn't a prefix match since it starts with
|
||||||
|
// "geosite:". AntD's default filterOption behavior isn't relied on.
|
||||||
|
function filterBySubstring(input: string, option?: { value?: string }): boolean {
|
||||||
|
return typeof option?.value === 'string' && option.value.toLowerCase().includes(input.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
export default function RuleFormModal({
|
export default function RuleFormModal({
|
||||||
open,
|
open,
|
||||||
rule,
|
rule,
|
||||||
@@ -79,6 +98,16 @@ export default function RuleFormModal({
|
|||||||
const { data: inboundOptions } = useInboundOptions();
|
const { data: inboundOptions } = useInboundOptions();
|
||||||
const remarkByTag = useMemo(() => buildRemarkByTag(inboundOptions || []), [inboundOptions]);
|
const remarkByTag = useMemo(() => buildRemarkByTag(inboundOptions || []), [inboundOptions]);
|
||||||
|
|
||||||
|
const { data: geodataCategories } = useGeodataCategories();
|
||||||
|
const domainOptions = useMemo(
|
||||||
|
() => (geodataCategories?.domain ?? []).map((value) => ({ value, label: value })),
|
||||||
|
[geodataCategories],
|
||||||
|
);
|
||||||
|
const ipOptions = useMemo(
|
||||||
|
() => (geodataCategories?.ip ?? []).map((value) => ({ value, label: value })),
|
||||||
|
[geodataCategories],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
if (rule) {
|
if (rule) {
|
||||||
@@ -252,8 +281,15 @@ export default function RuleFormModal({
|
|||||||
IP <QuestionCircleOutlined aria-hidden="true" />
|
IP <QuestionCircleOutlined aria-hidden="true" />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
}
|
}
|
||||||
|
transform={{ input: toTagsArray, output: fromTagsArray }}
|
||||||
>
|
>
|
||||||
<Input placeholder="0.0.0.0/8, fc00::/7, geoip:ir" />
|
<Select
|
||||||
|
mode="tags"
|
||||||
|
options={ipOptions}
|
||||||
|
tokenSeparators={[',']}
|
||||||
|
filterOption={filterBySubstring}
|
||||||
|
placeholder="0.0.0.0/8, fc00::/7, geoip:ir"
|
||||||
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
@@ -263,8 +299,15 @@ export default function RuleFormModal({
|
|||||||
{t('domainName')} <QuestionCircleOutlined aria-hidden="true" />
|
{t('domainName')} <QuestionCircleOutlined aria-hidden="true" />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
}
|
}
|
||||||
|
transform={{ input: toTagsArray, output: fromTagsArray }}
|
||||||
>
|
>
|
||||||
<Input placeholder="google.com, geosite:cn" />
|
<Select
|
||||||
|
mode="tags"
|
||||||
|
options={domainOptions}
|
||||||
|
tokenSeparators={[',']}
|
||||||
|
filterOption={filterBySubstring}
|
||||||
|
placeholder="google.com, geosite:cn"
|
||||||
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
|
|||||||
@@ -39,6 +39,17 @@ export const RuleObjectSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type RuleObject = z.infer<typeof RuleObjectSchema>;
|
export type RuleObject = z.infer<typeof RuleObjectSchema>;
|
||||||
|
|
||||||
|
// Response shape of GET /panel/api/xray/getGeodataCategories: every
|
||||||
|
// geosite/geoip category found in the .dat files currently present in the
|
||||||
|
// Xray bin folder, already formatted as ready-to-use rule values (e.g.
|
||||||
|
// "geosite:youtube", "ext:geosite_roscom.dat:some-code") for the routing
|
||||||
|
// rule editor's Domain/IP autocomplete.
|
||||||
|
export const GeodataCategoriesSchema = z.object({
|
||||||
|
domain: z.array(z.string()).nullable().transform((v) => v ?? []),
|
||||||
|
ip: z.array(z.string()).nullable().transform((v) => v ?? []),
|
||||||
|
});
|
||||||
|
export type GeodataCategories = z.infer<typeof GeodataCategoriesSchema>;
|
||||||
|
|
||||||
export const BalancerStrategyTypeSchema = z.enum([
|
export const BalancerStrategyTypeSchema = z.enum([
|
||||||
'random',
|
'random',
|
||||||
'roundRobin',
|
'roundRobin',
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ func (a *XraySettingController) initRouter(g *gin.RouterGroup) {
|
|||||||
g.GET("/getDefaultJsonConfig", a.getDefaultXrayConfig)
|
g.GET("/getDefaultJsonConfig", a.getDefaultXrayConfig)
|
||||||
g.GET("/getOutboundsTraffic", a.getOutboundsTraffic)
|
g.GET("/getOutboundsTraffic", a.getOutboundsTraffic)
|
||||||
g.GET("/getXrayResult", a.getXrayResult)
|
g.GET("/getXrayResult", a.getXrayResult)
|
||||||
|
g.GET("/getGeodataCategories", a.getGeodataCategories)
|
||||||
|
|
||||||
g.POST("/", a.getXraySetting)
|
g.POST("/", a.getXraySetting)
|
||||||
g.POST("/warp/:action", a.warp)
|
g.POST("/warp/:action", a.warp)
|
||||||
@@ -168,6 +169,15 @@ func (a *XraySettingController) getXrayResult(c *gin.Context) {
|
|||||||
jsonObj(c, a.XrayService.GetXrayResult(), nil)
|
jsonObj(c, a.XrayService.GetXrayResult(), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getGeodataCategories returns every geosite/geoip category found in the
|
||||||
|
// .dat files currently present in the Xray bin folder (including any custom
|
||||||
|
// files the admin added via the Geodata auto-update feature), formatted as
|
||||||
|
// ready-to-use routing-rule suggestion strings for the routing rule editor's
|
||||||
|
// Domain/IP autocomplete.
|
||||||
|
func (a *XraySettingController) getGeodataCategories(c *gin.Context) {
|
||||||
|
jsonObj(c, a.XraySettingService.GetGeodataCategories(), nil)
|
||||||
|
}
|
||||||
|
|
||||||
// warp handles Warp-related operations based on the action parameter.
|
// warp handles Warp-related operations based on the action parameter.
|
||||||
func (a *XraySettingController) warp(c *gin.Context) {
|
func (a *XraySettingController) warp(c *gin.Context) {
|
||||||
action := c.Param("action")
|
action := c.Param("action")
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/xtls/xray-core/common/geodata"
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestGetGeodataCategoriesEndpoint exercises the real HTTP route (gin
|
||||||
|
// routing, controller wiring, JSON envelope) rather than just the
|
||||||
|
// underlying service function -- a genuine end-to-end check of
|
||||||
|
// GET /panel/api/xray/getGeodataCategories given no DB is available in
|
||||||
|
// this environment (go-sqlite3 needs cgo) to run the full panel binary.
|
||||||
|
func TestGetGeodataCategoriesEndpoint(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("XUI_BIN_FOLDER", dir)
|
||||||
|
|
||||||
|
writeFixture(t, filepath.Join(dir, "geosite.dat"), &geodata.GeoSiteList{
|
||||||
|
Entry: []*geodata.GeoSite{{Code: "YOUTUBE"}},
|
||||||
|
})
|
||||||
|
writeFixture(t, filepath.Join(dir, "geosite_roscom.dat"), &geodata.GeoSiteList{
|
||||||
|
Entry: []*geodata.GeoSite{{Code: "SOME-CODE"}},
|
||||||
|
})
|
||||||
|
writeFixture(t, filepath.Join(dir, "geoip.dat"), &geodata.GeoIPList{
|
||||||
|
Entry: []*geodata.GeoIP{{Code: "PRIVATE"}},
|
||||||
|
})
|
||||||
|
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
router := gin.New()
|
||||||
|
NewXraySettingController(router.Group("/panel/api"))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/panel/api/xray/getGeodataCategories", nil)
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
body := resp.Body.String()
|
||||||
|
for _, want := range []string{`"success":true`, `"geosite:youtube"`, `"ext:geosite_roscom.dat:some-code"`, `"geoip:private"`} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("response body %s does not contain %s", body, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFixture(t *testing.T, path string, msg proto.Message) {
|
||||||
|
t.Helper()
|
||||||
|
data, err := proto.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal fixture %s: %v", path, err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||||
|
t.Fatalf("write fixture %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||||
@@ -16,6 +17,13 @@ import (
|
|||||||
// It handles validation and storage of Xray template configurations.
|
// It handles validation and storage of Xray template configurations.
|
||||||
type XraySettingService struct {
|
type XraySettingService struct {
|
||||||
SettingService
|
SettingService
|
||||||
|
|
||||||
|
// geodataMu/geodataCache back GetGeodataCategories' in-memory cache (see
|
||||||
|
// xray_setting_geodata.go), keyed by the (name, size, modTime)
|
||||||
|
// fingerprint of the geosite*/geoip*.dat files currently present in
|
||||||
|
// config.GetBinFolderPath().
|
||||||
|
geodataMu sync.Mutex
|
||||||
|
geodataCache *geodataCategoryCache
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -0,0 +1,260 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||||
|
|
||||||
|
"github.com/xtls/xray-core/common/geodata"
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GeodataCategories lists every geosite/geoip category found in the .dat
|
||||||
|
// files currently present in the Xray bin folder, already formatted as
|
||||||
|
// ready-to-use xray-core routing-rule values (see formatGeodataSuggestion).
|
||||||
|
// Returned by XraySettingService.GetGeodataCategories and served as
|
||||||
|
// GET /panel/api/xray/getGeodataCategories for the routing rule editor's
|
||||||
|
// Domain/IP autocomplete.
|
||||||
|
type GeodataCategories struct {
|
||||||
|
Domain []string `json:"domain"`
|
||||||
|
IP []string `json:"ip"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// geodataFileKind distinguishes a geosite-shaped .dat file (parsed as a
|
||||||
|
// geodata.GeoSiteList) from a geoip-shaped one (geodata.GeoIPList).
|
||||||
|
type geodataFileKind int
|
||||||
|
|
||||||
|
const (
|
||||||
|
geositeFile geodataFileKind = iota
|
||||||
|
geoipFile
|
||||||
|
)
|
||||||
|
|
||||||
|
// geodataFileEntry is one matched .dat file plus the (size, modTime)
|
||||||
|
// fingerprint used to invalidate the parsed-category cache.
|
||||||
|
type geodataFileEntry struct {
|
||||||
|
name string // base filename, e.g. "geosite_roscom.dat"
|
||||||
|
path string
|
||||||
|
size int64
|
||||||
|
modTime time.Time
|
||||||
|
kind geodataFileKind
|
||||||
|
}
|
||||||
|
|
||||||
|
// geodataFileFingerprint is the comparable, cacheable projection of one
|
||||||
|
// geodataFileEntry used to detect whether a re-parse is needed.
|
||||||
|
type geodataFileFingerprint struct {
|
||||||
|
name string
|
||||||
|
size int64
|
||||||
|
modTime int64 // UnixNano
|
||||||
|
}
|
||||||
|
|
||||||
|
// geodataCategoryCache holds the last computed result plus the exact file
|
||||||
|
// fingerprint it was computed from.
|
||||||
|
type geodataCategoryCache struct {
|
||||||
|
fingerprint []geodataFileFingerprint
|
||||||
|
result GeodataCategories
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGeodataCategories scans config.GetBinFolderPath() for geosite*.dat /
|
||||||
|
// geoip*.dat files -- whatever is actually on disk right now, including any
|
||||||
|
// custom files the admin added via the Geodata auto-update feature -- and
|
||||||
|
// returns every category code they contain as a suggestion string:
|
||||||
|
// "geosite:<code>"/"geoip:<code>" for the default file, "ext:<file>:<code>"
|
||||||
|
// for any other file (xray-core's rule parser has no shorthand for those;
|
||||||
|
// see common/geodata/rule_parser.go in the vendored xray-core module).
|
||||||
|
//
|
||||||
|
// The result is cached in memory keyed by the (name, size, modTime)
|
||||||
|
// fingerprint of the matched files, so repeated calls are cheap until a
|
||||||
|
// file actually changes -- e.g. because xray-core's own geodata auto-update
|
||||||
|
// downloaded a new one. A file that fails to parse (e.g. an interrupted
|
||||||
|
// download) is skipped with a logged warning; it never fails the request.
|
||||||
|
func (s *XraySettingService) GetGeodataCategories() GeodataCategories {
|
||||||
|
dir := config.GetBinFolderPath()
|
||||||
|
entries := scanGeodataFiles(dir)
|
||||||
|
fingerprint := geodataFingerprintOf(entries)
|
||||||
|
|
||||||
|
s.geodataMu.Lock()
|
||||||
|
if s.geodataCache != nil && slices.Equal(s.geodataCache.fingerprint, fingerprint) {
|
||||||
|
result := s.geodataCache.result
|
||||||
|
s.geodataMu.Unlock()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
s.geodataMu.Unlock()
|
||||||
|
|
||||||
|
result := buildGeodataCategories(entries)
|
||||||
|
|
||||||
|
s.geodataMu.Lock()
|
||||||
|
s.geodataCache = &geodataCategoryCache{fingerprint: fingerprint, result: result}
|
||||||
|
s.geodataMu.Unlock()
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanGeodataFiles lists dir for files matched by name: geosite*.dat parses
|
||||||
|
// as a geodata.GeoSiteList, geoip*.dat as a geodata.GeoIPList. Matching is
|
||||||
|
// case-insensitive on the prefix/extension -- every real filename observed
|
||||||
|
// so far (geoip.dat, geosite_IR.dat, geosite_roscom.dat, ...) is
|
||||||
|
// lowercase-prefixed, but nothing enforces that when an admin drops a file
|
||||||
|
// in by hand, so this stays tolerant. A missing or unreadable bin folder
|
||||||
|
// yields an empty scan (logged, not an error) rather than failing the
|
||||||
|
// endpoint.
|
||||||
|
func scanGeodataFiles(dir string) []geodataFileEntry {
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
if !os.IsNotExist(err) {
|
||||||
|
logger.Warning("geodata categories: failed to read bin folder:", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]geodataFileEntry, 0, len(entries))
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := e.Name()
|
||||||
|
lower := strings.ToLower(name)
|
||||||
|
if !strings.HasSuffix(lower, ".dat") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var kind geodataFileKind
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(lower, "geosite"):
|
||||||
|
kind = geositeFile
|
||||||
|
case strings.HasPrefix(lower, "geoip"):
|
||||||
|
kind = geoipFile
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info, err := e.Info()
|
||||||
|
if err != nil {
|
||||||
|
continue // vanished between ReadDir and Info; skip
|
||||||
|
}
|
||||||
|
out = append(out, geodataFileEntry{
|
||||||
|
name: name,
|
||||||
|
path: filepath.Join(dir, name),
|
||||||
|
size: info.Size(),
|
||||||
|
modTime: info.ModTime(),
|
||||||
|
kind: kind,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// geodataFingerprintOf reduces a file list to a sorted, comparable slice so
|
||||||
|
// GetGeodataCategories can detect "nothing changed" with slices.Equal
|
||||||
|
// regardless of os.ReadDir's ordering.
|
||||||
|
func geodataFingerprintOf(entries []geodataFileEntry) []geodataFileFingerprint {
|
||||||
|
out := make([]geodataFileFingerprint, len(entries))
|
||||||
|
for i, e := range entries {
|
||||||
|
out[i] = geodataFileFingerprint{name: e.name, size: e.size, modTime: e.modTime.UnixNano()}
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].name < out[j].name })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildGeodataCategories parses every matched file and collects its category
|
||||||
|
// codes into the Domain/IP suggestion lists, de-duplicating within each list
|
||||||
|
// and skipping (with a logged warning) any file that fails to parse.
|
||||||
|
func buildGeodataCategories(entries []geodataFileEntry) GeodataCategories {
|
||||||
|
var result GeodataCategories
|
||||||
|
seenDomain := make(map[string]struct{})
|
||||||
|
seenIP := make(map[string]struct{})
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
codes, err := parseGeodataFile(entry)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warningf("geodata categories: skipping %s: %v", entry.name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, code := range codes {
|
||||||
|
suggestion := formatGeodataSuggestion(entry, code)
|
||||||
|
switch entry.kind {
|
||||||
|
case geositeFile:
|
||||||
|
if _, dup := seenDomain[suggestion]; dup {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenDomain[suggestion] = struct{}{}
|
||||||
|
result.Domain = append(result.Domain, suggestion)
|
||||||
|
case geoipFile:
|
||||||
|
if _, dup := seenIP[suggestion]; dup {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenIP[suggestion] = struct{}{}
|
||||||
|
result.IP = append(result.IP, suggestion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(result.Domain)
|
||||||
|
sort.Strings(result.IP)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseGeodataFile fully unmarshals one geosite*/geoip*.dat file and returns
|
||||||
|
// every category Code it contains. xray-core's own loaders (loadSite/loadIP
|
||||||
|
// in common/geodata/geodat_loader.go) stream a single named category out of
|
||||||
|
// a file via an unexported, custom varint-prefixed scanner; enumerating
|
||||||
|
// *every* category instead needs a full-file proto.Unmarshal into the
|
||||||
|
// package's exported GeoSiteList/GeoIPList message types.
|
||||||
|
func parseGeodataFile(entry geodataFileEntry) ([]string, error) {
|
||||||
|
data, err := os.ReadFile(entry.path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch entry.kind {
|
||||||
|
case geositeFile:
|
||||||
|
var list geodata.GeoSiteList
|
||||||
|
if err := proto.Unmarshal(data, &list); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
codes := make([]string, 0, len(list.GetEntry()))
|
||||||
|
for _, site := range list.GetEntry() {
|
||||||
|
if code := site.GetCode(); code != "" {
|
||||||
|
codes = append(codes, code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return codes, nil
|
||||||
|
case geoipFile:
|
||||||
|
var list geodata.GeoIPList
|
||||||
|
if err := proto.Unmarshal(data, &list); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
codes := make([]string, 0, len(list.GetEntry()))
|
||||||
|
for _, ip := range list.GetEntry() {
|
||||||
|
if code := ip.GetCode(); code != "" {
|
||||||
|
codes = append(codes, code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return codes, nil
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatGeodataSuggestion builds the exact rule value xray-core's rule
|
||||||
|
// parser (common/geodata/rule_parser.go) accepts for one category code from
|
||||||
|
// one file. The default file gets the short "geosite:"/"geoip:" form; every
|
||||||
|
// other file -- including every custom file the admin added -- MUST use the
|
||||||
|
// "ext:<file>:<code>" form since there is no shorthand for non-default
|
||||||
|
// files. Code casing never matters to xray-core (it upcases internally,
|
||||||
|
// rule_parser.go), but lowercase reads better and matches this panel's
|
||||||
|
// existing convention (frontend/src/pages/xray/basics/constants.ts).
|
||||||
|
func formatGeodataSuggestion(entry geodataFileEntry, code string) string {
|
||||||
|
lowerCode := strings.ToLower(code)
|
||||||
|
switch entry.kind {
|
||||||
|
case geositeFile:
|
||||||
|
if entry.name == geodata.DefaultGeoSiteDat {
|
||||||
|
return "geosite:" + lowerCode
|
||||||
|
}
|
||||||
|
case geoipFile:
|
||||||
|
if entry.name == geodata.DefaultGeoIPDat {
|
||||||
|
return "geoip:" + lowerCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "ext:" + entry.name + ":" + lowerCode
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"sort"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||||
|
|
||||||
|
"github.com/xtls/xray-core/common/geodata"
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeGeoSiteFixture(t *testing.T, dir, name string, codes ...string) {
|
||||||
|
t.Helper()
|
||||||
|
list := &geodata.GeoSiteList{}
|
||||||
|
for _, c := range codes {
|
||||||
|
list.Entry = append(list.Entry, &geodata.GeoSite{Code: c})
|
||||||
|
}
|
||||||
|
data, err := proto.Marshal(list)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal fixture geosite list: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, name), data, 0o644); err != nil {
|
||||||
|
t.Fatalf("write fixture %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeGeoIPFixture(t *testing.T, dir, name string, codes ...string) {
|
||||||
|
t.Helper()
|
||||||
|
list := &geodata.GeoIPList{}
|
||||||
|
for _, c := range codes {
|
||||||
|
list.Entry = append(list.Entry, &geodata.GeoIP{Code: c})
|
||||||
|
}
|
||||||
|
data, err := proto.Marshal(list)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal fixture geoip list: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, name), data, 0o644); err != nil {
|
||||||
|
t.Fatalf("write fixture %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScanGeodataFiles(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeGeoSiteFixture(t, dir, "geosite.dat", "CN")
|
||||||
|
writeGeoSiteFixture(t, dir, "geosite_roscom.dat", "SOME-CODE")
|
||||||
|
writeGeoIPFixture(t, dir, "geoip.dat", "PRIVATE")
|
||||||
|
writeGeoIPFixture(t, dir, "GEOIP_RU.DAT", "RU") // case-insensitive match
|
||||||
|
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte("{}"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write unrelated file: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.Mkdir(filepath.Join(dir, "geosite_dir.dat"), 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir geosite_dir.dat: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries := scanGeodataFiles(dir)
|
||||||
|
|
||||||
|
names := make([]string, 0, len(entries))
|
||||||
|
for _, e := range entries {
|
||||||
|
names = append(names, e.name)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
|
||||||
|
want := []string{"GEOIP_RU.DAT", "geoip.dat", "geosite.dat", "geosite_roscom.dat"}
|
||||||
|
sort.Strings(want)
|
||||||
|
if !slices.Equal(names, want) {
|
||||||
|
t.Fatalf("scanGeodataFiles names = %v, want %v", names, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, e := range entries {
|
||||||
|
switch e.name {
|
||||||
|
case "geosite.dat", "geosite_roscom.dat":
|
||||||
|
if e.kind != geositeFile {
|
||||||
|
t.Errorf("%s: kind = %v, want geositeFile", e.name, e.kind)
|
||||||
|
}
|
||||||
|
case "geoip.dat", "GEOIP_RU.DAT":
|
||||||
|
if e.kind != geoipFile {
|
||||||
|
t.Errorf("%s: kind = %v, want geoipFile", e.name, e.kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScanGeodataFilesMissingDir(t *testing.T) {
|
||||||
|
entries := scanGeodataFiles(filepath.Join(t.TempDir(), "does-not-exist"))
|
||||||
|
if len(entries) != 0 {
|
||||||
|
t.Fatalf("expected no entries for a missing dir, got %v", entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseGeodataFile(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeGeoSiteFixture(t, dir, "geosite.dat", "CN", "YOUTUBE")
|
||||||
|
writeGeoIPFixture(t, dir, "geoip_rosip.dat", "RU")
|
||||||
|
|
||||||
|
siteCodes, err := parseGeodataFile(geodataFileEntry{
|
||||||
|
name: "geosite.dat", path: filepath.Join(dir, "geosite.dat"), kind: geositeFile,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseGeodataFile(geosite.dat): %v", err)
|
||||||
|
}
|
||||||
|
if !slices.Equal(siteCodes, []string{"CN", "YOUTUBE"}) {
|
||||||
|
t.Fatalf("parseGeodataFile(geosite.dat) = %v, want [CN YOUTUBE]", siteCodes)
|
||||||
|
}
|
||||||
|
|
||||||
|
ipCodes, err := parseGeodataFile(geodataFileEntry{
|
||||||
|
name: "geoip_rosip.dat", path: filepath.Join(dir, "geoip_rosip.dat"), kind: geoipFile,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseGeodataFile(geoip_rosip.dat): %v", err)
|
||||||
|
}
|
||||||
|
if !slices.Equal(ipCodes, []string{"RU"}) {
|
||||||
|
t.Fatalf("parseGeodataFile(geoip_rosip.dat) = %v, want [RU]", ipCodes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatGeodataSuggestion(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
kind geodataFileKind
|
||||||
|
code string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "geosite.dat", kind: geositeFile, code: "CN", want: "geosite:cn"},
|
||||||
|
{name: "geoip.dat", kind: geoipFile, code: "PRIVATE", want: "geoip:private"},
|
||||||
|
{name: "geosite_roscom.dat", kind: geositeFile, code: "SOME-CODE", want: "ext:geosite_roscom.dat:some-code"},
|
||||||
|
{name: "geoip_rosip.dat", kind: geoipFile, code: "RU", want: "ext:geoip_rosip.dat:ru"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
entry := geodataFileEntry{name: tt.name, kind: tt.kind}
|
||||||
|
if got := formatGeodataSuggestion(entry, tt.code); got != tt.want {
|
||||||
|
t.Errorf("formatGeodataSuggestion(%q, %q) = %q, want %q", tt.name, tt.code, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGeodataFingerprintOf(t *testing.T) {
|
||||||
|
a := []geodataFileEntry{
|
||||||
|
{name: "geoip.dat", size: 100, modTime: time.Unix(1, 0)},
|
||||||
|
{name: "geosite.dat", size: 200, modTime: time.Unix(2, 0)},
|
||||||
|
}
|
||||||
|
b := []geodataFileEntry{ // same content, different order
|
||||||
|
{name: "geosite.dat", size: 200, modTime: time.Unix(2, 0)},
|
||||||
|
{name: "geoip.dat", size: 100, modTime: time.Unix(1, 0)},
|
||||||
|
}
|
||||||
|
if !slices.Equal(geodataFingerprintOf(a), geodataFingerprintOf(b)) {
|
||||||
|
t.Fatal("fingerprints should be equal regardless of input order")
|
||||||
|
}
|
||||||
|
|
||||||
|
c := []geodataFileEntry{
|
||||||
|
{name: "geoip.dat", size: 999, modTime: time.Unix(1, 0)}, // size changed
|
||||||
|
{name: "geosite.dat", size: 200, modTime: time.Unix(2, 0)},
|
||||||
|
}
|
||||||
|
if slices.Equal(geodataFingerprintOf(a), geodataFingerprintOf(c)) {
|
||||||
|
t.Fatal("fingerprints should differ when a file's size changes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetGeodataCategories_SkipsMalformedFile(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeGeoSiteFixture(t, dir, "geosite.dat", "CN")
|
||||||
|
// An unterminated varint (continuation bit set on every byte) is
|
||||||
|
// guaranteed to fail proto.Unmarshal, unlike an arbitrary text string
|
||||||
|
// which might accidentally parse as protobuf garbage.
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "geosite_broken.dat"), []byte{0xFF, 0xFF, 0xFF}, 0o644); err != nil {
|
||||||
|
t.Fatalf("write broken fixture: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries := scanGeodataFiles(dir)
|
||||||
|
result := buildGeodataCategories(entries)
|
||||||
|
|
||||||
|
if !slices.Contains(result.Domain, "geosite:cn") {
|
||||||
|
t.Fatalf("expected the valid file's category to survive, got %v", result.Domain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetGeodataCategories_EndToEnd(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("XUI_BIN_FOLDER", dir)
|
||||||
|
if config.GetBinFolderPath() != dir {
|
||||||
|
t.Fatalf("XUI_BIN_FOLDER override not respected: got %q, want %q", config.GetBinFolderPath(), dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeGeoSiteFixture(t, dir, "geosite.dat", "CN")
|
||||||
|
writeGeoIPFixture(t, dir, "geoip.dat", "PRIVATE")
|
||||||
|
writeGeoSiteFixture(t, dir, "geosite_roscom.dat", "SOME-CODE")
|
||||||
|
|
||||||
|
svc := &XraySettingService{}
|
||||||
|
result := svc.GetGeodataCategories()
|
||||||
|
|
||||||
|
if !slices.Contains(result.Domain, "geosite:cn") {
|
||||||
|
t.Errorf("Domain = %v, want to contain geosite:cn", result.Domain)
|
||||||
|
}
|
||||||
|
if !slices.Contains(result.Domain, "ext:geosite_roscom.dat:some-code") {
|
||||||
|
t.Errorf("Domain = %v, want to contain ext:geosite_roscom.dat:some-code", result.Domain)
|
||||||
|
}
|
||||||
|
if !slices.Contains(result.IP, "geoip:private") {
|
||||||
|
t.Errorf("IP = %v, want to contain geoip:private", result.IP)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache must reflect a file that appears after the first call.
|
||||||
|
writeGeoIPFixture(t, dir, "geoip_rosip.dat", "RU")
|
||||||
|
result = svc.GetGeodataCategories()
|
||||||
|
if !slices.Contains(result.IP, "ext:geoip_rosip.dat:ru") {
|
||||||
|
t.Errorf("IP after adding a new file = %v, want to contain ext:geoip_rosip.dat:ru", result.IP)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user