* feat(xray): browse geosite/geoip categories from routing rules Routing rules made you type category names from memory: nothing showed which categories a database actually contains, what is inside one, or whether a name resolves at all — a typo only surfaced when Xray refused the config. The panel now reads Xray's .dat databases itself and exposes them over four endpoints: databases in the asset folder, a database's categories, one page of a category's rules, and validation of the tokens already in a rule. The reader walks the protobuf wire format directly rather than decoding into Go structs, because a 10 MB geosite.dat holds well over a million domains and materialising them costs ~284 MB where streaming costs ~19 MB. Only the category index is cached, entry pages are scanned on demand, and scans are serialised, so twenty concurrent requests peak at 87 MB instead of 1 GB. A database's type is decided by its contents, not its file name, since custom .dat files are named freely. In the rule form, the source-IP, IP and domain fields gain a database button opening the browser: search over categories, a preview of what a category holds, and a multi-select that merges into the field. Plain domains, CIDRs and categories the panel does not know are left untouched; categories already present come back ticked, and unticking one removes it from the rule. * fix(xray): read geo databases through os.Root and match codes verbatim CodeQL flagged the database read as a path built from a user-supplied value, and it was right about the shape of it. The file name arrives in a request; resolve() rejects traversal and stats the file through an os.Root, but the read itself went through a joined path with os.ReadFile. That left the symlink defence incomplete: the stat could pass while the read followed a link planted — or swapped in — afterwards. Reads now go through the same root, so a request-supplied name never becomes a path this code resolves on its own, and the size limit is applied to the opened file rather than to a separate stat of it. Lookup no longer trims the category code either. It backs the routing-token validator, and the core matches codes verbatim: "geosite: cn" will not start Xray, so repairing that space here hid exactly the typo the validator exists to report. * fix(xray): address review findings on the geo category browser Asset folder. The browser read config.GetBinFolderPath() unconditionally, but the core honours a preset XRAY_LOCATION_ASSET and only falls back to the bin folder (ensureXrayAssetLocation). On an install pointing at a shared asset directory the panel listed an empty folder and reported perfectly valid geosite:/geoip: tokens as missing — the validator warning about a correct config. The directory is now resolved with the core's precedence. Paging. Serving one page read and rescanned the whole database, so walking category-ads-all re-read it per page. The index now records each category's byte range and a page reads only that record through the os.Root handle, with the current category's records held for the duration of a paging session. Profiling that also showed the real cost was not the read but the slice of payload pointers built per call — a category holds a hundred thousand of them — so records are now walked with a callback instead. Ten pages over category-ads-all: 239 MB allocated, now 4.3 MB. Cached failures. Any error from reading a file was latched under the file's size+mtime, so a transient ENOMEM or EMFILE marked a healthy database as damaged until it changed on disk. Only deterministic failures are cached. Wrong kind. A geoip: token typed into a domain field parsed as a plain domain and was waved through, though the core cannot resolve it as one. It is now reported, with its own reason and wording. Frontend. The category filter fed the query key on every keystroke, so each character triggered a request that re-scanned the database; it is debounced now. GeoTokenInput accepts and forwards a ref, so React Hook Form can focus these three fields on a validation error again. A failed validation shows that it failed instead of rendering the same empty state as "no issues". Also drops an unreachable branch in the token-count guard and corrects the categories endpoint docs, where limit is unbounded by default. --------- Co-authored-by: STRENCH0 <17428017+STRENCH0@users.noreply.github.com>
10 KiB
CLAUDE.md
Operational guide for AI agents working in this repo. Long-form human docs:
CONTRIBUTING.md (setup, testing philosophy) and frontend/README.md.
Read those before large changes. This file is the short, must-follow version.
For a deep navigation map (request lifecycle, cron-job table, symptom → file
index, layering rules), read docs/architecture.md on demand — do not guess
file locations when it can answer in one hop.
Stack
- Backend: Go 1.26 (
module github.com/mhsanaei/3x-ui/v3), Gin, GORM. Runs Xray-core as a managed child process (internal/xray/process.go) and importsgithub.com/xtls/xray-corefor config types + gRPC stats/handler/router API. MTProto inbounds run a second managed child — themtg-multibinary (a multi-secret mtg fork — NOT a Go dependency; its prebuilt release binary is fetched at image/release build time byDockerInit.sh+release.yml, panel-side code ininternal/mtproto/) — outside Xray, one process per inbound serving each client's FakeTLS secret via the fork's[secrets]section (plus per-client ad-tags via[secret-ad-tags]and per-client data quota / expiry via[secret-limits], mapped from the client'stotalGB/expiryTime). Client, ad-tag and quota/expiry edits are hot-applied through the fork's management API (PUT /secrets, bearer-token guarded) so connections survive; the manager falls back to a process restart on older binaries. A client's panel-side traffic reset also callsPOST /secrets/{name}/reset-quotaso a renewed client is not re-blocked by the sidecar's quota counter. - Storage: SQLite by default (
/etc/x-ui/x-ui.dbon Linux; the executable dir on Windows), PostgreSQL optional (XUI_DB_TYPE/XUI_DB_DSN). The CGo SQLite driver (mattn/go-sqlite3) needs a C compiler —CGO_ENABLED=0builds fail. - Frontend: React 19 + Ant Design 6 + Vite 8 + TypeScript in
frontend/, built intointernal/web/dist/(gitignored) and embedded viaembed.FS.
Repo map
main.go— entry point +x-uiCLI (run, migrate, migrate-db, setting, cert).internal/config/— env parsing (XUI_DEBUG, XUI_LOG_LEVEL, XUI_LOG_FOLDER, XUI_BIN_FOLDER, XUI_SKIP_HSTS, XUI_PORT, XUI_DB_*).internal/database/+internal/database/model/— GORM schema (~24 models; Inbound, Client, Setting, User are the core), inbound Protocol enum, AutoMigrate + hand-written migrations indb.go.internal/xray/— Xray child-process lifecycle, config generation, gRPC API.internal/xray/geodata/— streaming geosite/geoip.datreader (cached category index + paged entries) andgeosite:/geoip:/ext:token parsing.internal/mtproto/— MTProto inbounds via the bundledmtg-multibinary.internal/sub/— subscription server (raw / JSON / Clash).internal/eventbus/— in-process pub/sub (outbound/node health, xray.crash, cpu.high, memory.high, login.attempt).internal/logger/,internal/util/(link, crypto, sys, ldap, …),internal/tunnelmonitor/— shared infrastructure.internal/web/— Gin server (embedsdist/+translation/).controller/— panel + REST API handlers; OpenAPI at /panel/api/openapi.json.service/— business logic (InboundService, SettingService, XrayService, node sync); subpackages tgbot/, email/, outbound/, panel/, integration/.job/— 17 cron jobs (traffic, fail2ban IP-limit, node heartbeat/sync, LDAP, CPU/memory watchdogs, …); full table indocs/architecture.md§5.4.middleware/,entity/,global/,session/(CSRF),network/,runtime/(master/sub-node over mTLS),websocket/.locale/+translation/— i18n, 13 embedded locale JSON files.
frontend/— React + TS source (seefrontend/CLAUDE.md).tools/openapigen/— Go generator that emits frontend types + Zod/JSON schemas intofrontend/src/generated/from Go structs. The OpenAPI doc itself (frontend/public/openapi.json) is assembled from those +endpoints.tsbyfrontend/scripts/build-openapi.mjs. (tools/seedperf/is a separate seeding /load helper.)docs/— separate Next.js/Fumadocs site (pnpm, own CI indocs-ci.yml, outsidemake verify). Holds a THIRD independent implementation of link/subscription generation indocs/lib/xray/— check it whenever share-link or install-command output changes.
Hard rules (non-negotiable)
- Fix size must match bug size. Find the root cause, then make the SMALLEST change that removes it — a one-line guard beats a new subsystem. A small bug does not earn new columns, jobs, abstractions, config knobs or helper layers. If a fix genuinely needs new architecture, say so and get agreement first; never ship it unasked next to the fix.
- Comments in committed Go/TS: 2 lines MAX per comment block. Make the name
carry the meaning first and rename rather than annotate; spend the 2 lines on
the why a name cannot hold — an invariant, an issue number, a non-obvious
constraint. Exempt:
//go:build,//go:generate, and other directives. HTML<!-- -->is fine. (A linter cannot enforce this — you must.) - New
g.POST/g.GETininternal/web/controller/REQUIRES a matching entry infrontend/src/pages/api-docs/endpoints.ts, thenmake gen(orcd frontend && npm run gen). Hand-maintained but pinned both ways byTestRouteRegistryContract(internal/web/routes_contract_test.go): a missing OR stale entry failsmake test-go. Scope:/panel/api/*+ a few session routes; sub-server routes are exempt. - Response examples come from Go struct
example:tags viatools/openapigen— never hand-write them. A new struct must be added to openapigen'sStructAllowallowlist (tools/openapigen/main.go) or it is silently omitted from schemas/examples (andbuild-openapi.mjsthen fails on the missing schema). - A new or renamed endpoint has a FOURTH step nothing checks: copy
frontend/public/openapi.json→docs/public/openapi.json, thencd docs && pnpm gen:apito refresh the MDX underdocs/content/docs/en/reference/api/.docs-ci.ymlfires only ondocs/**. - A new English i18n key goes in EVERY locale JSON in
internal/web/translation/(13 files) AND must be referenced fromfrontend/srcor Go in the SAME commit —frontend/src/test/i18n-dead-keys.test.tsfails both ways. It is a frontend test, so runnpm test, not justmake test-go. At runtime the frontend falls back to en-US; Go (internal/web/locale/) returns "" for an unknown key. - DB / model changes require a migration in
internal/database/db.go. - Every state-changing inbound/client op dispatches through
runtime.Runtime(internal/web/runtime/) — never straight tointernal/xray/api.go, never from a controller or cron job. A direct call passes every local test and silently breaks every multi-node deployment. Other layering rules:docs/architecture.md§8. - Conventional commits:
type(area): short imperative summary, then a body explaining the why. Types in use:fix,feat,chore,refactor,perf,docs,style.
Go conventions
- Stdlib
testingonly (no testify). Table-driven,t.Runsubtests,t.Helper()on helpers. Assert the exact value / typed error / emitted string, never justerr != nil. Prefer real deps over mocks: throwaway DB viadatabase.InitDB(filepath.Join(t.TempDir(), "x-ui.db"))+t.Cleanup(func() { _ = database.CloseDB() });httptestfor HTTP.internal/sub'sinitSubDB(t)is the template. - A test must fail without its fix. Write it, revert the fix, watch it go red, restore. A test that passes either way is worse than no test: it certifies nothing and then gets cited as proof the fix works.
- Test what can actually break. No test for a getter, a constant, a rename, a pure map lookup, or inputs the function can never receive. One real test that drives the bug through the actual code path beats five that restate the code.
- Code must pass
golangci-lint run(gofumpt + goimports formatting):make lint. - Postgres, xray-gRPC-e2e and scale tests
t.SkipunlessXUI_TEST_PG_DSN,XUI_DB_TYPE+XUI_DB_DSN,XRAY_E2E_BINARYorXUI_SCALE_TESTis set — a greengo test ./...does not mean those paths ran.
Frontend conventions (summary; full version in frontend/CLAUDE.md)
- Ant Design 6 only — no Tailwind/shadcn. Targeted tweaks, not rewrites.
- TS strict;
@typescript-eslint/no-explicit-anyis an error. Zod schemas insrc/schemas/are the source of truth; infer types withz.infer, never hand-write. Do not editsrc/generated/. - Node 24 (
.nvmrc) —make genimports.tsdirectly and needs its type stripping; Node 22 dies withERR_UNKNOWN_FILE_EXTENSION.npm testincludes a headless-Chromium Storybook project, so runnpx playwright install --with-deps chromiumonce ormake verifyfails. - Editing
frontend/srcdoes NOT change what users see until the Vite build is regenerated intointernal/web/dist/. InXUI_DEBUG=true, HTML is served from the frozen embedded FS but JS/CSS off disk — afternpm run buildyou MUST restartgo run .or you get a blank page with 404s. - After touching share-link logic (
src/lib/xray/), runnpm run test(golden fixtures); regenerate snapshots (npx vitest run -u) only for intentional output changes, never to make a red test green.
Build, test, verify
A fresh clone has no internal/web/dist/, so a bare go build ./... dies with
pattern all:dist: no matching files found while ~35 other packages pass — it
reads as a broken repo, not a missing step. Run make dist-stub once; every
make Go target already depends on it, which is why make test-go beats
go test ./.... Run make help for all targets. The local gate:
make verify # gen-check + lint + typecheck + test + build + build-storybook
That is the fast gate, not all of CI. ci.yml also runs make race,
make vulncheck, a live-Postgres job (where a SKIP counts as a failure) and a
30s fuzz smoke on FuzzParseLink/FuzzDecodeCertPin — run those locally when
you touch DB/dialect or parser code.
Common targets: make gen (regenerate Zod/OpenAPI), make lint (Go + frontend),
make test (Go -shuffle=on + frontend), make race, make build. See Makefile.
Definition of done (before opening a PR)
make verifypasses — itsgen-checkalready runsmake genand fails on a dirtyfrontend/src/generated/frontend/public/openapi.json.- Diff is focused; refactors are separate from feature work.