Files
3x-ui/.github/claude/issue-analyst-context.md
T
Sanaei 20d7f91c65 refactor(ci): add an adversarial pass and name the analyst briefing
REVIEW.md told the reviewer which repository rules to check but never to try
breaking the change, so the conditions this panel actually meets went
unexamined. "Try to break it" adds six, each tied to a mechanism here rather
than to a generic checklist: an upgrade over an operator's existing rows and
the rollback that reads them again, a restart that drops in-memory state under
the cron jobs, a sub-node racing the master on the same row, an operation
applied twice, an inbound or client at the empty and the thousand end, and a
dependency that is down. It closes with the gate that running a case is not
reporting it - each one still has to clear the verification bar below it, so
the section cannot become a licence for hypotheticals.

repo-context.md said nothing about which bot reads it. Only the issue analyst
does, since the review job's briefing moved inline in acf3603d, so it becomes
issue-analyst-context.md and its title names the analyst instead of "the Claude
bot". bot_context_test.go pins that path in a constant, so the rename carries
through the constant, the four test names and the two comments that named the
old file - one of which still said "the bot prompts", plural.

Backticks come off mtg-multi in the new section: the same test file reads any
hyphenated backticked token in REVIEW.md as a CI job name, and fails on one
ci.yml does not define.
2026-09-08 21:29:21 +02:00

11 KiB

Repository context for the issue analyst

Briefing for the issue analyst in .github/workflows/claude-issue-analyst.yml. It exists so these facts live in ONE place next to the code instead of being restated in the prompt, where they went stale silently. (Pull-request review is separate: the reviewer in .github/workflows/claude-pr-review.yml is briefed by its own prompt, CLAUDE.md and REVIEW.md, not this.)

CLAUDE.md, frontend/CLAUDE.md and docs/architecture.md outrank this file. Where they disagree with it, they win and this file is the thing to fix. docs/architecture.md carries a "Symptom -> File" index and the cron-job table, which answer "which file owns X" in one hop; grepping blind wastes turns on a question it already answers.

Stack

3x-ui is an open-source web control panel for managing Xray-core servers.

  • Backend: Go 1.27, module github.com/mhsanaei/3x-ui/v3, Gin and GORM.
  • It runs Xray-core as a managed child process (internal/xray/process.go) and imports github.com/xtls/xray-core for config types and the gRPC stats/handler/router API. The release the panel BUNDLES is pinned in DockerInit.sh; the version it COMPILES against is pinned in go.mod, and the two are not always the same.
  • MTProto inbounds run a SECOND managed child, the mtg-multi binary (a multi-secret mtg fork, panel-side code in internal/mtproto/), one process per inbound. Client, ad-tag and quota/expiry edits are hot-applied through the fork's management API (PUT /secrets) so connections survive, with a process restart as the fallback on older binaries.
  • AmneziaWG inbounds run IN-PROCESS, not as a child: internal/amneziawgnet/ drives an amneziawg-go device over a gVisor userspace netstack and relays into a loopback SOCKS5 Xray inbound. internal/amneziawg/ derives the instance and peers from an inbound and generates + validates the 3.1 obfuscation parameters.
  • Storage: SQLite by default (/etc/x-ui/x-ui.db on Linux, the executable directory on Windows) or PostgreSQL (XUI_DB_TYPE / XUI_DB_DSN). The SQLite driver is CGo, so CGO_ENABLED=0 builds fail.
  • Frontend: React 19 + Ant Design 6 + Vite 8 + TypeScript in frontend/, built into internal/web/dist/ (gitignored) and embedded with embed.FS.

Where things live

area path
entry point + x-ui CLI main.go
env parsing internal/config/
schema, migrations internal/database/, internal/database/model/
Xray child process + config internal/xray/
MTProto inbounds internal/mtproto/
AmneziaWG shape + embedded runtime internal/amneziawg/, internal/amneziawgnet/
PIA WireGuard client internal/pia/
subscription server internal/sub/
HTTP handlers internal/web/controller/
business logic internal/web/service/
cron jobs (schedules in web.go startTask()) internal/web/job/
master/sub-node over mTLS internal/web/runtime/
i18n internal/web/locale/, internal/web/translation/
UI source frontend/src/
install / upgrade install.sh, x-ui.sh, DockerInit.sh

Hard rules a change must respect

  • Dispatch through runtime.Runtime. Every state-changing inbound or client operation goes through the interface in internal/web/runtime/, never straight to internal/xray/api.go. A direct call passes every local test and silently breaks every multi-node deployment; it is invisible in a single-box reading of a diff.
  • Layering. Controllers are thin — bind, validate, respond — with no GORM queries, no Xray calls and no business rules. internal/util/* is leaf-only and must not import service, controller or database. internal/web/dist/ and frontend/src/generated/ are generated; a hand-edit is a violation.
  • Comments in committed Go/TS/TSX: 2 lines MAX per block, spent on the why a name cannot hold — an invariant, an issue number, a non-obvious constraint. Exempt, never flag: //go:build, //go:generate, //nolint:, // Code generated ... DO NOT EDIT.. HTML <!-- --> is fine.
  • The route contract chain, which breaks in four distinct places:
    1. a new g.POST/g.GET in internal/web/controller/ needs a matching entry in frontend/src/pages/api-docs/endpoints.ts — pinned BOTH ways by TestRouteRegistryContract in internal/web/routes_contract_test.go, so a renamed or removed route that leaves a stale entry fails too;
    2. generated artefacts must be regenerated with make gen, or CI's codegen job fails on a dirty frontend/src/generated or frontend/public/openapi.json;
    3. a NEW struct crossing the API boundary must be added to the StructAllow allowlist in tools/openapigen/main.go, or it is SILENTLY dropped from the schemas and frontend/scripts/build-openapi.mjs then fails — a guaranteed CI break, not a style nit;
    4. the step NOTHING checks — frontend/public/openapi.json must be copied to docs/public/openapi.json and the MDX regenerated with cd docs && pnpm gen:api, because docs-ci.yml fires only on docs/**. Step 4 is the one that reaches production wrong.
  • i18n. A new English key goes in EVERY locale JSON in internal/web/translation/ (13 files) AND must be referenced from frontend/src or Go in the SAME change. frontend/src/test/i18n-dead-keys.test.ts fails on a missing locale file and on an orphan key alike.
  • Migrations. Schema changes are GORM AutoMigrate PLUS hand-written migrations in internal/database/db.go. There are no migration files and no down-migrations, and everything has to work on SQLite AND PostgreSQL.
  • Tests. Stdlib testing only (no testify), table-driven with t.Run subtests and t.Helper() on helpers. An assertion must pin the exact value, typed error or emitted string — err != nil and len(x) > 0 are findings, not nits. Prefer real dependencies: a throwaway DB via database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")) with t.Cleanup, and httptest for HTTP. internal/sub's initSubDB(t) is the template. A test must FAIL without its fix; one that passes either way certifies nothing and then gets cited as proof the fix works.

Link and subscription generation is implemented three times, independently:

language path what it feeds
Go internal/util/link/, internal/sub/ what the panel serves
TS frontend/src/lib/xray/ what the panel UI shows
TS docs/lib/xray/ what the docs site shows

A change to share-link or subscription output that touches one and not the others is how they drift apart.

AmneziaWG's 3.1 obfuscation parameters are a second such pair: generated in Go by GenerateObfuscation31 (internal/amneziawg/params.go) and in TS by generateAwgObfuscation (frontend/src/lib/xray/amneziawg-obfuscation.ts). Changing one without the other is how the panel and the UI hand out different configs for the same inbound.

Downstream programs that must accept what the panel emits

  • XTLS/Xray-core — the Xray config the panel generates, and the VLESS/VMess transport and security fields.
  • MetaCubeX/mihomo — consumes the Clash YAML from internal/sub/.
  • SagerNet/sing-box — parses the share links the panel emits.
  • amnezia-vpn/amneziawg-go — the obfuscation parameters the panel generates (Jc/Jmin/Jmax, S1-S4, H1-H4, I1-I5). Its device/uapi.go is the symbol that decides which keys are accepted.
  • mhsanaei/mtg-multi — the MTProto sidecar whose TOML ([secrets], [secret-ad-tags], [secret-limits]) and management API (PUT /secrets, POST /secrets/{name}/reset-quota) internal/mtproto/ writes and calls.

What CI runs

.github/workflows/ci.yml, on every pull request touching Go or frontend code. It is paths-filtered, so a docs-only or workflow-only change produces no run.

job what it proves
go-test go test -shuffle=on -count=1 over every package except frontend/node_modules
race the same set under -race -shuffle=on
postgres-durable-first live PostgreSQL 16: the PostgresCommitFailure tests plus TestHostAutoMigrateCreatesColumns_Postgres and TestMigrate_Postgres. Both steps COUNT passes rather than assert on SKIP, so a renamed or deleted test fails the job
govulncheck known vulnerabilities
golangci golangci-lint
fuzz-smoke 30s each on FuzzParseLink and FuzzDecodeCertPin
codegen npm run gen then git diff --exit-code on the generated files
frontend MSW worker drift, lint, format:check, typecheck, npm test (Vitest + headless-Chromium Storybook), build, build-storybook, npm audit

What CI does NOT prove. These test families t.Skip unless an environment variable is set, and CI sets only the PostgreSQL ones above:

gate covers
XUI_TEST_PG_DSN PostgreSQL-specific paths
XUI_DB_TYPE + XUI_DB_DSN dialect-dependent behaviour
XRAY_E2E_BINARY the Xray gRPC end-to-end tests in internal/xray/
XUI_SCALE_TEST scale tests in internal/sub/, internal/web/job/, internal/web/service/

Mutation testing (mutation.yml) runs nightly and never on a pull request, so a test that cannot fail is invisible to CI. make verify is the local gate.

Support facts reporters get wrong

  • Linux install: bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
  • Install generates a RANDOM username, password and web base path — never admin/admin. The x-ui menu on the server shows or resets them.
  • The installer service environment file is DISTRO-DEPENDENT: /etc/default/x-ui (Debian/Ubuntu), /etc/conf.d/x-ui (Arch), /etc/sysconfig/x-ui (RHEL/Fedora). Naming the wrong one means the reporter's edit is silently never read by systemd — a common cause of "I set the variable and nothing happened".
  • Windows is supported. There the database sits next to the executable, not in /etc — never quote the Linux path to a Windows user.
  • SQLite to PostgreSQL: x-ui migrate-db --dsn "postgres://...", then set XUI_DB_TYPE/XUI_DB_DSN in that file and systemctl restart x-ui. The source SQLite file is left in place.
  • Docker image ghcr.io/mhsanaei/3x-ui; PostgreSQL profile docker compose --profile postgres up -d. Fail2ban IP-limit enforcement needs NET_ADMIN + NET_RAW (compose grants them; a bare docker run must add --cap-add=NET_ADMIN --cap-add=NET_RAW).
  • Never state that a XUI_* variable does not exist without grepping internal/config/ and internal/tunnelmonitor/ first. The XUI_TUNNEL_HEALTH_* family is the usual answer to "the panel restarts Xray every few minutes".
  • Security per inbound is none / tls / reality. XTLS is a VLESS flow (xtls-rprx-vision), not a security setting — never tell anyone to pick XTLS in the security dropdown.
  • Never hardcode a version. For "is this already fixed" use gh release list -L 10, gh search commits, and git log -S.