# Repository context for the Claude bot

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 code-review skill in `.github/workflows/claude-pr-review.yml` is
briefed with `CLAUDE.md`, `REVIEW.md` and `.github/claude/review-job.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.

## The three link implementations

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`.
