Files
3x-ui/.github/claude/repo-context.md
T
Sanaei f6bfcfe759 refactor(ci): make the Claude workflow review pull requests and nothing else
claude-bot.yml ran three jobs: the pull-request review, an @claude mention
responder, and a conflict resolver that committed and pushed to contributor
branches. Only the review is wanted, so the other two are gone and the file
is renamed to say what is left.

Consequences worth knowing:

- secrets.CLAUDE_BOT_PAT is no longer referenced by any workflow. It was the
  only push credential handed to an agent in this repository and can now be
  deleted from the repository settings.
- @claude goes unanswered everywhere. claude-issue-analyst.yml deliberately
  excludes mentions (!contains(body, '@claude')) so the two jobs would not
  both reply; with the mention job gone, only `@claude review` on a pull
  request still reaches anything. Dropping that clause from the analyst would
  restore mention answering on issues.
- The workflow display name changes, so a branch protection rule keyed on
  "Claude Bot / review" has to become "Claude PR Review / review". The job
  name, which is what statusCheckRollup reports, is unchanged.

The review job itself is byte-identical. The workflow-level permission drops
to issues: read, which is all the remaining job needs - it already declares
its own.
2026-09-04 02:09:50 +02:00

199 lines
11 KiB
Markdown

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