diff --git a/CLAUDE.md b/CLAUDE.md index 050fcd146..c8431ded2 100644 --- a/CLAUDE.md +++ b/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, diff --git a/docs/architecture.md b/docs/architecture.md index 84cc93fb0..a9f63ee3f 100644 --- a/docs/architecture.md +++ b/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` | diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index 6985f262a..9f2b4e957 100644 --- a/frontend/public/openapi.json +++ b/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": [ diff --git a/frontend/src/api/queries/useGeodata.ts b/frontend/src/api/queries/useGeodata.ts new file mode 100644 index 000000000..e5cc0d203 --- /dev/null +++ b/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 { + 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 { + 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 { + 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({ + 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 : []; + }, + }); +} diff --git a/frontend/src/api/queryKeys.ts b/frontend/src/api/queryKeys.ts index abcb9b1bd..f68d85197 100644 --- a/frontend/src/api/queryKeys.ts +++ b/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; diff --git a/frontend/src/components/geodata/GeoBrowserModal.css b/frontend/src/components/geodata/GeoBrowserModal.css new file mode 100644 index 000000000..fd7782aa8 --- /dev/null +++ b/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; +} diff --git a/frontend/src/components/geodata/GeoBrowserModal.stories.tsx b/frontend/src/components/geodata/GeoBrowserModal.stories.tsx new file mode 100644 index 000000000..aeba7cfde --- /dev/null +++ b/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; + +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 { + 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 {children}; +} + +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 = { + 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 = { + 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 = { + 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, + attributes: Record = {}, +): 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 }> = { + '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 ( + + + + ); + }; +} + +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 ( + + + + {value || 'no rule yet'} + + { + setValue(next); + setOpen(false); + }} + onClose={() => setOpen(false)} + /> + + ); +} + +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) => , +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +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: '' }, +}; diff --git a/frontend/src/components/geodata/GeoBrowserModal.tsx b/frontend/src/components/geodata/GeoBrowserModal.tsx new file mode 100644 index 000000000..612c29245 --- /dev/null +++ b/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(undefined); + const [categoryQuery, setCategoryQuery] = useState(''); + const [activeCode, setActiveCode] = useState(undefined); + const [entryQuery, setEntryQuery] = useState(''); + const [entryFilter, setEntryFilter] = useState(''); + const [entryPage, setEntryPage] = useState(1); + const [selected, setSelected] = useState([]); + + const knownRef = useRef>(new Set()); + const seededFilesRef = useRef>(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 = useMemo( + () => [ + { + title: t('pages.xray.geoBrowser.searchCategory'), + dataIndex: 'code', + render: (code: string, category: GeoCategory) => ( + + {code} + {category.attributes?.length > 0 && ( + + {category.attributes.map((attribute) => ( + + @{attribute} + + ))} + + )} + + ), + }, + { + dataIndex: 'entries', + align: 'right', + width: 90, + render: (entries: number) => {entries.toLocaleString()}, + }, + ], + [t], + ); + + const entryColumns: ColumnsType = useMemo( + () => [ + { + dataIndex: 'kind', + width: 88, + render: (entryKind: string) => ( + + {entryKind} + + ), + }, + { + dataIndex: 'value', + render: (entryValue: string) => {entryValue}, + }, + ], + [], + ); + + 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 ( + onApply(mergeSelection(value, selected, knownRef.current))} + okText={t('pages.xray.geoBrowser.apply')} + cancelText={t('close')} + className="geo-browser-modal" + > + {filesQuery.isError && } + + {!filesQuery.isError && !filesQuery.isLoading && files.length === 0 ? ( + + {t('pages.xray.geoBrowser.noFiles')} +
+ {t('pages.xray.geoBrowser.noFilesHint')} + + } + /> + ) : ( + <> +
+ setEntryQuery(event.target.value)} + placeholder={t('pages.xray.geoBrowser.searchEntries')} + allowClear + className="geo-entry-filter" + /> +
+
+ `${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} + /> + +
+ + t('pages.xray.geoBrowser.shownRange', { + from: range[0].toLocaleString(), + to: range[1].toLocaleString(), + total: total.toLocaleString(), + }) + } + /> +
+ + ) : ( +
+ {t('pages.xray.geoBrowser.pickCategory')} +
+ )} + + + +
+ {selected.length === 0 ? ( + {t('pages.xray.geoBrowser.emptySelection')} + ) : ( + <> + + {selected.map((token) => ( + setSelected((previous) => previous.filter((item) => item !== token))} + > + {token} + + ))} + + + {t('pages.xray.geoBrowser.selected', { count: selected.length })} + + + + )} +
+ + )} + + ); +} + +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'); +} diff --git a/frontend/src/components/geodata/GeoTokenInput.stories.tsx b/frontend/src/components/geodata/GeoTokenInput.stories.tsx new file mode 100644 index 000000000..522f9298f --- /dev/null +++ b/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; + +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 { + 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 {children}; +} + +const domain = (value: string): GeoEntry => ({ kind: 'domain', value }); +const cidr = (value: string): GeoEntry => ({ kind: 'cidr', value }); + +const SITE_ENTRIES: Record = { + '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 = { + 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 = { + google: ['ads', 'cn'], + youtube: ['ads'], +}; + +function categoriesOf( + entries: Record, + attributes: Record = {}, +): GeoCategory[] { + return Object.keys(entries) + .sort() + .map((code) => ({ code, entries: entries[code].length, attributes: attributes[code] ?? [] })); +} + +const DATASETS: Record }> = { + '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 ( + + + + ); +}; + +function ControlledTokenInput({ value = '', id = 'geo-rule', ...rest }: GeoTokenInputProps) { + const [current, setCurrent] = useState(value); + useEffect(() => setCurrent(value), [value]); + return ( + + + + + ); +} + +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) => , +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +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(); + }, +}; diff --git a/frontend/src/components/geodata/GeoTokenInput.tsx b/frontend/src/components/geodata/GeoTokenInput.tsx new file mode 100644 index 000000000..9cbd4e5dd --- /dev/null +++ b/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 = { + 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; +} + +export default function GeoTokenInput({ value = '', onChange, onBlur, kind, placeholder, id, ref }: GeoTokenInputProps) { + const { t } = useTranslation(); + const [browsing, setBrowsing] = useState(false); + const [issues, setIssues] = useState([]); + 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 ( + <> + onChange?.(event.target.value)} + onBlur={onBlur} + addonAfter={ + +