mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-20 18:11:00 +00:00
92fb94d856
* chore(frontend,docs): move to TypeScript 7 and replace ESLint with oxlint
TypeScript 7 is the native Go port and ships no programmatic compiler
API, so typescript-eslint cannot run at all: it peer-pins
typescript >=4.8.4 <6.1.0 (canary too) and hard-crashes with
"typescript-eslint does not support TS 7.0". Upstream support is
tracked in typescript-eslint#10940 and targets TS >=7.1.
Rather than wait, or carry Microsoft's side-by-side alias (which keeps
a second TS 6 install alive purely to feed the linter), both projects
move to oxlint, which never depended on the TypeScript API.
Typecheck drops from ~9.7s to ~2.2s and 167 packages leave frontend/.
oxlint has no no-restricted-syntax, so the #6121/#6127 cleared-
InputNumber guard is reimplemented as a JS plugin in
frontend/tools/oxlint/. It was verified to still fire in
pages/settings/** and pages/xray/** and to stay exempt in *Modal.tsx.
The type-aware @deprecated sweep survives too, as
`npm run lint:deprecated`: oxlint's type-aware mode runs on
oxlint-tsgolint, which drives the TS 7 typescript-go checker, so the
TS 7 move is what makes it possible.
Behaviour is preserved rather than tightened. jsx-a11y/prefer-tag-over-role
is off in both configs because it was never part of the recommended sets
ESLint actually ran, and oxlint honours the existing eslint-disable
comments, so no source churn was needed.
Two real fixes fell out of the stricter linting:
- outbound-link-parser.test.ts used `out?.streamSettings` behind an `as`
cast, which hid the optional chain from ESLint and would throw on a
null parse; the rest of the file already used `out!`.
- InputAddon's conditional role/tabIndex/onKeyDown is genuinely
accessible but oxlint cannot evaluate it, so it gets a scoped disable.
* chore(docs): replace Prettier with oxfmt
oxfmt is the oxc project's Prettier-compatible formatter, so this pairs
with the oxlint move and drops the last JS-based tool from the docs
toolchain.
The swap is behaviour-preserving. Running Prettier and oxfmt over the
same files, with the existing .prettierrc.json settings migrated via
`oxfmt --migrate=prettier`, produces byte-identical output on every
file. (Comparing them outside the project directory is misleading:
Prettier silently falls back to its defaults when it cannot find its
config, which looks like a mismatch but is not one.)
The 18 files reformatted here were already failing `pnpm format:check`
before this change — Prettier wanted the exact same edits. The check is
not part of docs-ci.yml, which is why the drift went unnoticed.
.prettierignore becomes ignorePatterns in .oxfmtrc.json, keeping the
deliberate MDX exclusion: reflowing MDX prose merges headings into
paragraphs and collapses lists inside Steps/Callout components. Both
that and the generated fumadocs-openapi reference output were verified
untouched.
oxfmt is pinned to 0.63.0 rather than latest. pnpm 11's built-in
minimumReleaseAge policy rejects same-day releases, and 0.64.0 would
have made pnpm silently append 20 waiver lines to pnpm-workspace.yaml.
* style(frontend): adopt oxfmt and format src
frontend/ has never had a formatter, so this reformats 344 of 497 files
in src/. The change is purely whitespace, quoting and line wrapping —
no logic is touched. It is kept in its own commit so it does not bury
the TypeScript 7 / oxlint migration or the git blame for the code
itself.
Settings match docs/ and the code as it was already written: single
quotes, semicolons, trailing commas, 2-space indent, 100 columns. That
was measured rather than assumed — src/ was already uniformly
single-quoted and 2-space indented, with p90 line length at 75.
Formatting is scoped to src/ (mirroring `oxlint src`) and
.oxfmtrc.json ignores src/generated. Both matter: `make gen-check`
compares src/generated and public/openapi.json, and
`make msw-worker-check` byte-compares public/mockServiceWorker.js
against the installed MSW runtime, so reformatting any of them breaks
the gate.
Reflowing also moves `eslint-disable-next-line` comments off the line
they guard, which broke two suppressions that had been silently
correct before:
- clone-inbound-modal.test.tsx: the object literal became multi-line,
leaving `} as any;` four lines below its no-explicit-any disable.
- ClientsPage.tsx: the useMemo dependency array moved onto its own
line, out from under its exhaustive-deps disable.
Both comments were relocated onto the line they actually guard, and
verified to still suppress by removing them and watching the errors
return.
* ci: enforce formatting in CI and make verify
Adding oxfmt in the previous two commits gave both projects a formatter
but nothing that checks it, which is how docs/ had already drifted to 18
unformatted files: docs-ci.yml runs typecheck, lint, test and build, but
never format:check, so Prettier's complaints were only ever visible to
whoever ran it by hand.
Wire `format:check` into the frontend job in ci.yml and the docs job in
docs-ci.yml, and add a `format-check` target to `make verify` so the
local gate keeps mirroring CI as the Makefile header promises.
Verified the step actually bites rather than passing vacuously: adding
a badly formatted line to a source file in each project makes both
`make format-check` and `pnpm format:check` fail, and reverting it makes
them pass again.
No workflow referenced ESLint or Prettier by name — they all invoke the
package scripts — so the tooling swap needed no other CI changes.
* ci: trigger CI on Makefile changes
The path filters listed **.go, go.mod, go.sum, frontend/**, .nvmrc and
ci.yml itself, but not the Makefile — so a change to the canonical task
runner that ci.yml is meant to mirror could land without any job
running. The previous commit, which edits both, only triggers because
it happens to touch ci.yml too.
* fix(frontend): replace deprecated Ant Design 6 APIs in the geo components
`npm run lint:deprecated` reported five uses of props Ant Design 6 has
deprecated. All five are gone, and the matching runtime warnings no
longer appear in the test output.
Tag `bordered={false}` becomes `variant="filled"` and Space `direction`
becomes `orientation`; both are the one-to-one replacements named in
antd's own deprecation messages, and `direction`/`orientation` share the
same Orientation type.
Input `addonAfter` is the one that is not a rename. It becomes a
`Space.Compact block` wrapping the Input and the browse Button, which is
antd's documented migration. `block` keeps the field filling its form
row as the addon did. Note this is a deliberate visual change: the
button used to be a borderless `type="text"` icon sitting inside the
addon's grey box, and is now a regular button whose border joins the
input. The tooltip, aria-label, ref, id and onBlur wiring are unchanged,
so the react-hook-form binding in RuleFormModal and the existing tests
still address it the same way.
Only these five were deprecated. The other `bordered` props in the tree
sit on QRCode, Table, Descriptions and Alert, where the prop is not
deprecated, and these were the only two Space `direction` uses in the
codebase.
* fix(frontend): restore lint rules lost in the oxlint migration, and test the guard
Addresses the review on #6262.
The frontend config re-enabled only no-explicit-any and no-unused-vars
and left the rest of tseslint's recommended set to oxlint's correctness
category. It does not cover all of it. Confirmed by linting one probe
file against both configs: docs/ (which enumerates the rules) reports
all nine, frontend/ reported four. So ban-ts-comment,
no-empty-object-type, no-namespace, no-require-imports and
no-unsafe-function-type had silently stopped being enforced — a `//
@ts-ignore` or a `namespace` block would have landed unflagged. The ten
rules are now mirrored from docs/.oxlintrc.json, and src/ still passes.
The #6121/#6127 guard was 57 lines of hand-written AST walking with no
test. It now has one: fixtures for the three banned shapes plus an
onNumber()-wrapped control, asserting the rule fires three times and
that .oxlintrc.json still wires it to the right paths. Verified it fails
for the right reason by making walk() enumerate nothing, which is the
silent-death mode the review described — the traversal depends on
Object.keys() seeing AST children as own enumerable properties.
The fixtures deliberately violate the rule, so their oxlint config is
named guard.oxlintrc.json rather than .oxlintrc.json: oxlint discovers
nested configs by directory, which would otherwise turn the fixtures
into three lint errors. The test passes it explicitly with -c.
Also from the review:
- lint and format now cover tools/ as well as src/, so the one piece of
hand-written lint logic in the repo is no longer the least covered
file in it.
- lint-staged runs oxfmt before oxlint --fix. Formatting became a hard
CI gate in this PR while the hook only ran the linter, so a commit
could pass the hook and fail CI on formatting alone.
- .oxfmtrc.json ignores public/, so the artefacts that make gen-check
and make msw-worker-check byte-compare stay safe even if oxfmt is
invoked without a path argument.
- The MDX and generated-reference rationales that .prettierignore
carried are back as comments in docs/.oxfmtrc.json — oxlint and oxfmt
both accept JSONC, so relocating them was unnecessary.
Not applied: the review also suggested restoring ../internal/web/dist to
the ignore lists. Both tools reject `..` patterns outright ("patterns
are resolved within the config file's directory"), and being outside
frontend/ it is unreachable anyway.
599 lines
52 KiB
Markdown
599 lines
52 KiB
Markdown
# 3x-ui — Architecture & Code Map
|
|
|
|
> Navigation map for contributors and AI coding agents (referenced from `CLAUDE.md`).
|
|
> Goal: jump to the right file in one hop instead of grepping the whole tree.
|
|
> Tracks the `main` branch — paths reflect the latest changes, so verify against the live
|
|
> tree rather than a pinned release (Go module `github.com/mhsanaei/3x-ui/v3`).
|
|
>
|
|
> **How to use this file:** read "Mental model" + "Request lifecycle" first, then
|
|
> use the **Symptom → File index** to locate work. Respect the **Layering rules**
|
|
> when adding code. Verify with the commands in **Build / Test / Lint**.
|
|
|
|
---
|
|
|
|
## 1. Mental model (the 30-second version)
|
|
|
|
3x-ui is a **web control panel for [Xray-core](https://github.com/XTLS/Xray-core)**. The Go
|
|
backend is the source of truth: it stores inbounds/clients/settings in a DB, renders an
|
|
Xray JSON config from that state, supervises the Xray child process, and exposes a REST +
|
|
WebSocket API. A React SPA (built by Vite, embedded into the Go binary) is the UI. A second,
|
|
separate HTTP server serves **subscription links** to end users.
|
|
|
|
The panel supervises **two managed child processes**: Xray-core itself and — when MTProto
|
|
inbounds exist — the `mtg-multi` Telegram-proxy binary (`github.com/mhsanaei/mtg-multi`, a
|
|
multi-secret fork built from source; `internal/mtproto/`). One process per inbound serves
|
|
every attached client's FakeTLS secret through the fork's `[secrets]` section, plus optional
|
|
per-client sponsored-channel ad-tags via `[secret-ad-tags]`. A client or ad-tag edit is
|
|
hot-applied via the fork's management API (`PUT /secrets`, guarded by a per-process bearer
|
|
token), with a process restart as the fallback on older binaries.
|
|
|
|
Servers and processes, all launched from `main.go`:
|
|
|
|
| Server / process | Package | Purpose | Default port |
|
|
| ---------------- | --------------------------------- | ------------------------------------------------------------------ | ----------------- |
|
|
| **Panel** | `internal/web` | Admin REST/WS API + serves the embedded SPA | 2053 |
|
|
| **Subscription** | `internal/sub` | Public endpoint that hands out client configs (raw / JSON / Clash) | `subPort` setting |
|
|
| **Xray-core** | supervised via `internal/xray` | The actual proxy engine; a child process, not Go code | `inbounds[].port` |
|
|
| **mtg-multi** | supervised via `internal/mtproto` | MTProto proxy child process for MTProto inbounds (multi-secret) | per inbound |
|
|
|
|
Two key ideas that explain most of the complexity:
|
|
|
|
1. **The DB → Xray config pipeline.** Inbounds/clients live in the DB. On every change the
|
|
backend regenerates the Xray config and applies it — preferring a _hot diff_ (live gRPC
|
|
API mutation) over a full process restart. See §5.1.
|
|
2. **The Runtime abstraction (multi-node).** A panel can manage remote "nodes" (other 3x-ui
|
|
instances). Every state-changing inbound/client operation is dispatched through a
|
|
`runtime.Runtime` interface that is either **`Local`** (this box's Xray gRPC API) or
|
|
**`Remote`** (HTTPS call to a child node, with `verify`/`skip`/`pin`/`mtls` TLS modes).
|
|
This is the single most important abstraction in the project. See §5.2.
|
|
|
|
---
|
|
|
|
## 2. Tech stack
|
|
|
|
**Backend (Go 1.26):**
|
|
|
|
- Web framework: **Gin** (`gin-gonic/gin`) + sessions (cookie store), gzip.
|
|
- ORM: **GORM** with **SQLite** (default) or **PostgreSQL** (`XUI_DB_TYPE=postgres`).
|
|
- Scheduler: **robfig/cron/v3** (seconds-precision) for all background jobs.
|
|
- Xray: **xtls/xray-core** vendored as a library; the panel talks to the running core over
|
|
its **gRPC API** and also shells out to manage the process.
|
|
- Telegram bot: **mymmrac/telego**. i18n: **nicksnyder/go-i18n**.
|
|
- Misc: gorilla/websocket, gopsutil (system stats), go-qrcode, gotp (2FA TOTP).
|
|
|
|
**Frontend (`frontend/`):**
|
|
|
|
- **React 19** + **Ant Design 6** + **Vite 8** + **TypeScript**.
|
|
- Data layer: **TanStack Query** (`@tanstack/react-query`) over the native **Fetch API**; **Zod 4** schemas.
|
|
- Router: **react-router 8**. Charts: **uPlot** (`frontend/src/components/viz/Sparkline.tsx`). Editor: **CodeMirror 6**.
|
|
- **Build output goes to `internal/web/dist/`** (see `vite.config.js` → `outDir`) and is
|
|
embedded into the Go binary with `go:embed`. Three HTML entries: `index.html` (panel SPA),
|
|
`login.html`, `subpage.html`. The Go server serves the SPA; there is no separate frontend
|
|
deployment.
|
|
|
|
**Important:** the legacy Go-template UI and `web/assets/` are **gone**. All HTML/JS comes
|
|
from the embedded Vite `dist/`. Don't look for `.html` templates in `internal/web`.
|
|
|
|
---
|
|
|
|
## 3. Request lifecycle (follow the data)
|
|
|
|
### 3.1 Admin API request (e.g. "add a client")
|
|
|
|
```
|
|
Browser (React, fetch)
|
|
→ POST {basePath}/panel/api/...
|
|
→ Gin engine (internal/web/web.go: initRouter)
|
|
→ middleware chain: SecurityHeaders → MaxBodyBytes (10 MiB; importDB exempt)
|
|
→ [DomainValidator, if webDomain set] → gzip → sessions("3x-ui")
|
|
→ base-path/cache-control context → Localizer
|
|
→ API routes add: ConfigEnvelope (zstd + SHA-256) → CSRF
|
|
→ Controller (internal/web/controller/*.go) // HTTP concerns only: bind, validate, respond
|
|
→ Service (internal/web/service/*.go) // business logic + transactions
|
|
→ GORM → DB (internal/database) // persistence
|
|
→ runtime.Runtime dispatch // apply to Xray (Local) or node (Remote)
|
|
→ Local: internal/xray (gRPC API or config regen + restart)
|
|
→ Remote: internal/web/runtime/remote.go → HTTPS → child node's API
|
|
```
|
|
|
|
The controller layer is thin. **Business logic lives in services.** When something is wrong
|
|
with _behavior_, the bug is almost always in a service file, not a controller.
|
|
|
|
### 3.2 Subscription request (end-user fetching their config)
|
|
|
|
```
|
|
End user → GET {subPath}/{subId} (separate server, internal/sub)
|
|
→ internal/sub/controller.go (routes: raw / JSON / Clash variants, feature-flagged)
|
|
→ internal/sub/service.go (~2.5k lines — the link/config builder)
|
|
→ reads inbounds+clients+hosts from DB, renders per-protocol share links /
|
|
Clash YAML / JSON (Host rows can override address/SNI/path per inbound)
|
|
```
|
|
|
|
### 3.3 Background work (cron jobs)
|
|
|
|
Scheduled in `internal/web/web.go` → `startTask()`. Each job is a struct in
|
|
`internal/web/job/`. Examples: poll Xray traffic every 5s, check client IP limits every 10s,
|
|
node heartbeat every 5s, periodic traffic resets (hourly/daily/weekly/monthly). See §5.4.
|
|
|
|
---
|
|
|
|
## 4. Directory map (what lives where)
|
|
|
|
```
|
|
3x-ui/
|
|
├── main.go # Entry point: CLI (run / migrate / migrate-db / setting / cert),
|
|
│ # bootstrap, signal handling, restart loop
|
|
├── go.mod / go.sum # Go deps (module path ends in /v3)
|
|
│
|
|
├── internal/ # ALL backend Go code (private packages)
|
|
│ ├── config/ # Env-var config: paths, DB kind/DSN, log level, version
|
|
│ │ # Every XUI_* env var is read here (config.go)
|
|
│ ├── database/
|
|
│ │ ├── db.go # InitDB: connect, AutoMigrate, seeders (~1.4k lines). DB hotspot.
|
|
│ │ ├── migrate_data.go # Data migrations (seeders/normalizers beyond AutoMigrate)
|
|
│ │ ├── dialect.go # SQLite vs Postgres SQL differences
|
|
│ │ ├── dump_sqlite.go # DB export/backup
|
|
│ │ └── model/ # **ALL GORM models** (model.go ~1.1k lines + siblings:
|
|
│ │ # node_client_traffic.go, node_client_ip.go,
|
|
│ │ # client_global_traffic.go). ⭐ Start here for data shape.
|
|
│ ├── eventbus/ # In-process pub/sub (buffered channel): outbound.down|up,
|
|
│ │ # xray.crash, node.down|up, cpu.high, memory.high, login.attempt
|
|
│ ├── tunnelmonitor/ # Optional tunnel health probe (XUI_TUNNEL_HEALTH_* env vars):
|
|
│ │ # HTTP probe (default Cloudflare trace); repeated failures
|
|
│ │ # trigger an Xray restart hook. Independent of panel settings.
|
|
│ ├── xray/ # Xray-core integration (the proxy engine wrapper)
|
|
│ │ ├── process.go # Spawn/supervise the Xray child process (~750 lines)
|
|
│ │ ├── api.go # gRPC client to a running Xray (add/remove user, stats) (~800 lines)
|
|
│ │ ├── hot_diff.go # ⭐ Compute minimal live changes to avoid full restart (~500 lines)
|
|
│ │ ├── config.go # Xray config object model
|
|
│ │ ├── 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
|
|
│ │ └── 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)
|
|
│ │ ├── controller/ # HTTP handlers (thin). One file per resource:
|
|
│ │ │ ├── inbound.go # /panel/api/inbounds
|
|
│ │ │ ├── client.go # /panel/api/clients (CRUD + bulk + ips + onlines)
|
|
│ │ │ ├── group.go # client-group endpoints
|
|
│ │ │ ├── node.go # /panel/api/nodes (multi-node management)
|
|
│ │ │ ├── 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, 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
|
|
│ │ │ └── websocket.go # WS upgrade endpoint
|
|
│ │ ├── service/ # ⭐⭐ Business logic. This is where most real work happens.
|
|
│ │ │ ├── inbound.go # Inbound CRUD core (~1.4k lines)
|
|
│ │ │ ├── inbound_node.go # ⭐ Node sync for inbounds: reconcile, traffic merge (~1.1k lines)
|
|
│ │ │ ├── inbound_traffic.go # Per-client traffic accounting (~1.1k lines)
|
|
│ │ │ ├── inbound_clients.go # Client-within-inbound operations
|
|
│ │ │ ├── inbound_sublink.go # Inbound-level subscription link helpers
|
|
│ │ │ ├── inbound_migration.go # Inbound schema/format migrations
|
|
│ │ │ ├── client_crud.go # Client create/read/update/delete
|
|
│ │ │ ├── client_bulk.go # Bulk client ops (~1.6k lines)
|
|
│ │ │ ├── client_inbound_apply.go # ⭐ Apply client changes to runtime (Local/Remote) (~1.2k lines)
|
|
│ │ │ ├── client_groups.go # Client grouping
|
|
│ │ │ ├── client_link.go # Per-client share-link generation
|
|
│ │ │ ├── client_external_link.go # External links attached to clients
|
|
│ │ │ ├── client_wireguard.go # WireGuard client specifics
|
|
│ │ │ ├── client_paging.go # Server-side pagination/sort/filter for client lists
|
|
│ │ │ ├── node.go # ⭐ NodeService: CRUD, probe, heartbeat, dirty-tracking (~1.1k lines)
|
|
│ │ │ ├── node_mtls.go # Node mTLS certificate management (master side)
|
|
│ │ │ ├── node_tree.go # Node hierarchy / descendants
|
|
│ │ │ ├── host.go # Host rows (subscription output overrides)
|
|
│ │ │ ├── server.go # ServerService: status, certs, xray install, DB ops (~2.2k lines)
|
|
│ │ │ ├── setting.go # SettingService: all panel settings + defaults (~1.3k lines)
|
|
│ │ │ ├── setting_mtls.go # mTLS settings (node hardening)
|
|
│ │ │ ├── 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
|
|
│ │ │ ├── url_safety.go # Outbound URL validation (SSRF guards)
|
|
│ │ │ ├── outbound_subscription.go# Outbound subscription (e.g. Warp/Nord provider configs)
|
|
│ │ │ ├── port_conflict.go # Detect inbound port collisions
|
|
│ │ │ ├── fallback.go # Xray fallback (SNI/ALPN routing on shared port)
|
|
│ │ │ ├── email/ # Email notification service (SMTP)
|
|
│ │ │ ├── integration/ # External providers: warp.go (Cloudflare WARP), nord.go (NordVPN)
|
|
│ │ │ ├── outbound/ # Outbound config service
|
|
│ │ │ ├── panel/ # Cross-cutting panel services:
|
|
│ │ │ │ ├── panel.go # panel-level helpers
|
|
│ │ │ │ ├── user.go # admin user auth (bcrypt)
|
|
│ │ │ │ ├── api_token.go # API token CRUD (SHA-256 hashed)
|
|
│ │ │ │ └── websocket.go # WS hub / push service
|
|
│ │ │ └── tgbot/ # Telegram bot command handlers
|
|
│ │ ├── runtime/ # ⭐⭐ The Local/Remote node abstraction (see §5.2)
|
|
│ │ │ ├── runtime.go # the Runtime interface (the contract)
|
|
│ │ │ ├── local.go # Local impl → this box's Xray gRPC API
|
|
│ │ │ ├── remote.go # Remote impl → HTTPS calls to a child node
|
|
│ │ │ ├── tls_client.go # per-node HTTP client: verify / skip / pin / mtls
|
|
│ │ │ └── manager.go # RuntimeFor(nodeID) → picks Local or Remote
|
|
│ │ ├── job/ # Cron job structs (one file per job — see §5.4)
|
|
│ │ ├── middleware/ # Gin middleware: security.go (headers/HSTS), bodylimit.go,
|
|
│ │ │ # domainValidator.go, validate.go (CSRF), config_envelope.go
|
|
│ │ ├── global/ # Global singletons: web server + sub server handles, restart hook
|
|
│ │ ├── network/ # Custom net listeners (e.g. proxy-protocol aware)
|
|
│ │ ├── session/ # Session/cookie helpers
|
|
│ │ ├── websocket/ # WS hub implementation
|
|
│ │ ├── locale/ + translation/ # i18n middleware + 13 locale JSON catalogs
|
|
│ │ ├── entity/ # Shared request/response DTOs
|
|
│ │ └── dist/ # ⚠️ Vite build output, embedded via go:embed (generated — do not hand-edit)
|
|
│ │
|
|
│ ├── sub/ # The subscription server (separate from panel)
|
|
│ │ ├── sub.go # server bootstrap
|
|
│ │ ├── controller.go # routes for raw / JSON / Clash subscription formats
|
|
│ │ ├── service.go # ⭐ The link/config builder (~2.5k lines — share-link logic lives here)
|
|
│ │ ├── json_service.go # JSON subscription format
|
|
│ │ ├── clash_service.go # Clash/Mihomo YAML format
|
|
│ │ ├── clash_external.go # external Clash config integration
|
|
│ │ ├── external_subscription.go / external_config.go # external sub import/aggregation
|
|
│ │ ├── host_sub.go # Host-row overrides applied to subscription output
|
|
│ │ ├── endpoint.go # subscription endpoint configuration
|
|
│ │ ├── vless_route.go # VLESS route shaping
|
|
│ │ ├── remark_vars.go # remark variable expansion
|
|
│ │ └── links.go # link helpers
|
|
│ │
|
|
│ ├── mtproto/ # Embedded MTProto (Telegram) proxy: manager.go + per-OS
|
|
│ │ # process supervision + orphan cleanup
|
|
│ ├── logger/ # App logger (op/go-logging + lumberjack rotation)
|
|
│ └── util/ # Leaf helpers (no business logic):
|
|
│ ├── common/ # errors, misc
|
|
│ ├── crypto/ # key/cert generation (x25519, ML-KEM/ML-DSA, ECH)
|
|
│ ├── link/ # outbound share-link building primitives
|
|
│ ├── wirecodec/ + wireguard/ # WireGuard codec + integration helpers
|
|
│ └── random/, json_util/, reflect_util/, sys/, netproxy/, netsafe/, ldap/
|
|
│
|
|
├── frontend/ # React SPA (built into internal/web/dist)
|
|
│ ├── vite.config.js # ⭐ Build config: outDir → ../internal/web/dist, dev on :5173
|
|
│ │ # (strict) proxying to :2053, entries index/login/subpage.html
|
|
│ ├── package.json # scripts: dev / build / preview / lint / typecheck / test / gen
|
|
│ └── src/
|
|
│ ├── main.tsx / routes.tsx / queryClient.ts # SPA entry, router, query client
|
|
│ ├── entries/ # Extra HTML entry points: login.tsx, subpage.tsx
|
|
│ ├── pages/ # ⭐ Route screens. Mirrors the panel's feature areas:
|
|
│ │ ├── inbounds/ # inbound list + the big inbound form (protocols/security/transport)
|
|
│ │ ├── clients/ # client management screens
|
|
│ │ ├── nodes/ # multi-node UI
|
|
│ │ ├── hosts/ # subscription host-override UI
|
|
│ │ ├── xray/ # raw Xray config UI (routing, dns, outbounds, balancers, overrides)
|
|
│ │ ├── index/ # dashboard/home
|
|
│ │ └── settings/, groups/, sub/, login/, api-docs/
|
|
│ ├── api/ # ⭐ Data layer: http-init, QueryProvider, queryKeys, websocket bridge
|
|
│ │ └── 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/ 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)
|
|
│
|
|
├── tools/openapigen/ # ⭐ Go program that emits frontend/src/generated/* from Go types (§5.5)
|
|
├── docs/ # Markdown docs (this file, custom-subscription-templates.md, …)
|
|
├── media/ # README images
|
|
│
|
|
├── Dockerfile / docker-compose.yml / DockerEntrypoint.sh / DockerInit.sh # Container build/run
|
|
├── install.sh / update.sh / x-ui.sh # VPS install + management CLI
|
|
├── x-ui.service.* / x-ui.rc # systemd units (debian/rhel/arch) + rc script
|
|
├── windows_files/ # Windows service support
|
|
└── .github/workflows/ # CI: ci.yml, codeql.yml, docker.yml, release.yml, smoke.yml,
|
|
# mutation.yml, cleanup_caches.yml, claude-bot.yml
|
|
```
|
|
|
|
---
|
|
|
|
## 5. Cross-cutting subsystems (the parts that span many files)
|
|
|
|
### 5.1 DB → Xray config pipeline (config generation & application)
|
|
|
|
The panel never edits Xray's running config directly from controllers. The flow is:
|
|
|
|
1. A service mutates DB state (inbound/client/setting).
|
|
2. `XrayService` (`service/xray.go`) builds a fresh `xray.Config` from DB state
|
|
(`GetXrayConfig`).
|
|
3. It tries a **hot apply** (`tryHotApply` → `xray/hot_diff.go`): diff old vs new config and
|
|
push only the deltas over the Xray gRPC API (add/remove inbound, add/remove user) — **no
|
|
process restart**, so live connections survive.
|
|
4. If the diff isn't hot-applicable (structural change), it falls back to a **full restart**
|
|
of the Xray process (`xray/process.go`).
|
|
|
|
Restart is debounced via an atomic "need restart" flag (`SetToNeedRestart` /
|
|
`IsNeedRestartAndSetFalse`), consumed by a `@every 30s` cron task registered in `startTask()`
|
|
— any number of mutations inside the window causes at most one restart.
|
|
|
|
**Key files:** `service/xray.go` (orchestration), `xray/hot_diff.go` (the diff algorithm),
|
|
`xray/process.go` (process lifecycle), `xray/api.go` (gRPC calls), `xray/config.go` (config model).
|
|
|
|
### 5.2 Runtime abstraction — Local vs Remote (multi-node) ⭐ most important
|
|
|
|
A "node" (`model.Node`) is another 3x-ui instance this panel controls. Every state-changing
|
|
inbound/client operation goes through the `runtime.Runtime` interface so the _same service
|
|
code_ works whether the target is the local Xray or a remote node.
|
|
|
|
- **Interface:** `internal/web/runtime/runtime.go` — `Name`, `AddInbound`, `DelInbound`,
|
|
`UpdateInbound`, `AddUser`, `RemoveUser`, `UpdateUser`, `DeleteUser`, `AddClient`,
|
|
`RestartXray`, `ResetClientTraffic`, `ResetInboundTraffic`, `ResetAllTraffics`.
|
|
- **`Local`** (`local.go`): calls this box's Xray gRPC API directly.
|
|
- **`Remote`** (`remote.go`): serializes the operation and sends it over HTTPS to the child
|
|
node's API.
|
|
- **TLS modes** (`tls_client.go`, per-node `TlsVerifyMode`):
|
|
`verify` (system CAs, default) / `skip` (no validation) / `pin` (leaf cert SHA-256 must
|
|
match `PinnedCertSha256`) / `mtls` (master presents a client certificate; node cert checked
|
|
against system roots; API token optional). Master-side cert management:
|
|
`service/node_mtls.go` + `service/setting_mtls.go`.
|
|
- **Dispatch:** `manager.go` → `Manager.RuntimeFor(nodeID *int)`; `nil` nodeID → `Local`,
|
|
otherwise a cached/lazy-loaded `Remote`. `InvalidateNode(id)` drops a cached remote client.
|
|
|
|
**Node identity & attribution (the hard part).** Inbounds carry a `NodeID` _and_ an
|
|
`OriginNodeGuid`. Because inbounds can be pushed across hops, the panel attributes traffic and
|
|
online clients back to the originating panel using **stable GUIDs** rather than local IDs.
|
|
Relevant logic: `service/inbound_node.go` (`ReconcileNode`, `SetRemoteTraffic`, GUID merge,
|
|
`synthNodeGuid`, `panelGuid`) and `service/node.go` (`effectiveNodeGuid`, heartbeat, dirty
|
|
tracking). Node "dirty" flags drive an **anti-entropy reconciliation** so an offline node's
|
|
inbound edits converge once it reconnects.
|
|
|
|
**Where to look for node bugs:**
|
|
|
|
- Operation not reaching a node → `runtime/remote.go` + `runtime/manager.go`.
|
|
- Wrong traffic/online attribution across hops → `service/inbound_node.go` (GUID merge paths).
|
|
- Node shown offline / stale status → `job/node_heartbeat_job.go` + `service/node.go` (`Probe`, `UpdateHeartbeat`).
|
|
- Edits to an offline node not applying on reconnect → dirty/reconcile logic in `service/inbound_node.go` + `service/node.go` (`MarkNodeDirty`/`ClearNodeDirty`/`NodeSyncState`).
|
|
- TLS/mTLS handshake failures → `runtime/tls_client.go`, `service/node_mtls.go`, `service/node.go` (`FetchCertFingerprint`).
|
|
|
|
### 5.3 Traffic accounting
|
|
|
|
Per-client and per-inbound up/down counters originate from Xray's stats API and are persisted
|
|
to the DB. The Xray traffic job polls the core; node traffic is pulled from child nodes and
|
|
merged with GUID-based baselines to avoid double counting after resets.
|
|
|
|
**Key files:** `service/inbound_traffic.go`, `service/traffic_writer.go`,
|
|
`job/xray_traffic_job.go`, `job/node_traffic_sync_job.go`, `service/inbound_node.go`
|
|
(`SetRemoteTraffic` / `upsertNodeBaseline`), models `xray.ClientTraffic`,
|
|
`model.NodeClientTraffic`, `model.ClientGlobalTraffic` (cross-master totals).
|
|
Periodic resets: `job/periodic_traffic_reset_job.go` (keyed off `Inbound.TrafficReset`).
|
|
|
|
### 5.4 Background jobs (cron)
|
|
|
|
All registered in `web.go` → `startTask()`. Each is a struct with a `Run()` method in `internal/web/job/`:
|
|
|
|
| Schedule | Job | Purpose / condition |
|
|
| ------------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------- |
|
|
| `@every 1s` | `check_xray_running_job` | Restart Xray if it died (2 consecutive down checks) |
|
|
| `@every 30s` | (inline func in `startTask`) | Debounced Xray restart — consumes the "need restart" flag (§5.1) |
|
|
| `@every 5s` | `xray_traffic_job` | Pull traffic stats from Xray (5s start delay) |
|
|
| `@every 5s` | `node_heartbeat_job` | Probe child nodes (online/offline) |
|
|
| `@every 5s` | `node_traffic_sync_job` | Pull + merge node traffic; push reconciliation |
|
|
| `@every 10s` | `check_client_ip_job` | Enforce per-client IP limits |
|
|
| `@every 10s` | `mtproto_job` | Reconcile `mtg` sidecars against enabled MTProto inbounds |
|
|
| `@every 5m` | `outbound_subscription_job` | Refresh outbound provider configs |
|
|
| `@every 10m` | `clear_logs_job` (`PruneXrayLogsJob`) | Truncate Xray access/error logs once either exceeds 64 MiB |
|
|
| `@hourly` | `warp_ip_job`, `periodic_traffic_reset_job("hourly")` | WARP IP rotation; traffic resets |
|
|
| `@daily` | `clear_logs_job`, `periodic_traffic_reset_job("daily")`, `periodic_traffic_reset_job("monthly")` | IP-limit and Xray access/error log cleanup; daily resets and due monthly resets |
|
|
| `@weekly` | `periodic_traffic_reset_job("weekly")` | Weekly traffic resets |
|
|
| default `@every 1m` | `ldap_sync_job` | Only if LDAP enabled; schedule configurable |
|
|
| default `@daily` | `stats_notify_job` | Only if TG bot enabled; schedule configurable |
|
|
| `@every 2m` | `check_hash_storage` | Only if TG bot enabled; expires bot callback hashes |
|
|
| `@every 1m` | `check_cpu_usage` | Only if a CPU alarm is configured (TG or email); publishes `cpu.high` |
|
|
| `@every 1m` | `check_memory_usage` | Only if a memory alarm is configured; publishes `memory.high` |
|
|
| configurable | `free_os_memory` | Only if `sys.MemoryReleaseIntervalMinutes() > 0`; returns heap to OS |
|
|
|
|
To change _when_ something runs, edit `startTask()`. To change _what_ it does, edit the job file.
|
|
|
|
### 5.5 Type generation (Go → TypeScript) ⚠️ don't hand-edit generated files
|
|
|
|
The Go backend is the schema source of truth. `tools/openapigen` (a Go program, with a
|
|
`StructAllow` allowlist of exported types) emits
|
|
`frontend/src/generated/{schemas,types,zod,examples}.ts`. The frontend build runs this first:
|
|
|
|
- `npm run gen:zod` → `go run ./tools/openapigen` (regenerate from Go)
|
|
- `npm run gen:api` → builds the OpenAPI doc (`scripts/build-openapi.mjs`, driven by the
|
|
hand-maintained endpoint registry `src/pages/api-docs/endpoints.ts`)
|
|
- `npm run build` runs `gen:api` then `vite build`.
|
|
|
|
**Implication:** if you change a Go model/DTO that crosses the API boundary, regenerate the
|
|
frontend types (`cd frontend && npm run gen`) instead of editing `src/generated/` by hand.
|
|
|
|
### 5.6 Share-link / subscription generation
|
|
|
|
Two distinct code paths produce client configs:
|
|
|
|
- **Per-client links in the panel** (the "copy link" / QR in the UI): `service/client_link.go`
|
|
- `util/link/outbound.go`.
|
|
- **Subscription endpoint** (what a client app polls): `internal/sub/service.go` (raw links),
|
|
`internal/sub/json_service.go` (JSON), `internal/sub/clash_service.go` (Clash YAML).
|
|
**`Host` rows** (`model.Host`, edited under /panel/api/hosts) override address/SNI/path/
|
|
security per inbound in subscription output — applied in `sub/host_sub.go`.
|
|
|
|
Both paths must agree per protocol. A malformed link for a specific protocol/transport combo
|
|
(e.g. XHTTP + Reality) is usually a field-lookup mismatch in **`internal/sub/service.go`** (and
|
|
its tests `service_test.go` / golden fixtures), or in `util/link/outbound.go`. The frontend
|
|
also has protocol schemas under `frontend/src/schemas/protocols/` and `frontend/src/lib/xray/`.
|
|
|
|
### 5.7 Event bus (in-process pub/sub)
|
|
|
|
`internal/eventbus/` is a minimal buffered-channel pub/sub. Producers call a non-blocking
|
|
`Publish(Event)`; all subscribers receive every event. Event types: `outbound.down|up`,
|
|
`xray.crash`, `node.down|up`, `cpu.high`, `memory.high`, `login.attempt`, with structured
|
|
payloads (OutboundHealthData, NodeHealthData, LoginEventData, SystemMetricData). Producers
|
|
include the CPU/memory jobs, node heartbeat, and login handling; consumers include the
|
|
Telegram bot and the email notifier (`service/email/`). Use it for cross-cutting
|
|
notifications instead of importing notification services into producers.
|
|
|
|
### 5.8 Tunnel health monitor
|
|
|
|
`internal/tunnelmonitor/` is an optional watchdog configured **only via env vars**
|
|
(`XUI_TUNNEL_HEALTH_*`, read in `internal/config/`), deliberately independent of panel
|
|
settings so it can be enabled from a systemd `EnvironmentFile` even when the panel is
|
|
unreachable. It periodically probes an HTTP URL (default: Cloudflare trace endpoint) through
|
|
the tunnel; after N successive failures (default 3) it fires a recovery callback wired to an
|
|
Xray restart.
|
|
|
|
---
|
|
|
|
## 6. Data model cheat-sheet
|
|
|
|
GORM models in `internal/database/model/` (main file `model.go` + siblings); all registered
|
|
for AutoMigrate in `internal/database/db.go`.
|
|
|
|
| Model | Table role | Notable fields |
|
|
| ------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
| `User` | Admin login | bcrypt password, `LoginEpoch` (invalidates sessions) |
|
|
| `Inbound` | An Xray inbound | `Tag` (unique), `Port`, `Protocol`, `Settings`/`StreamSettings`/`Sniffing` (JSON), `Enable`, `TrafficReset`, `NodeID`, **`OriginNodeGuid`**, `ClientStats` (assoc) |
|
|
| `Client` | In-memory client view | UUID/email/flow/limits (parsed from inbound JSON; not persisted) |
|
|
| `ClientRecord` | Persisted client (`clients`) | `Email` (unique), `SubID`, `UUID`, `TotalGB`, `ExpiryTime`, `LimitIP`, `Group`, `Reset` |
|
|
| `ClientGroup` / `ClientInbound` | Grouping + client↔inbound join | many-to-many wiring, `FlowOverride` |
|
|
| `ClientExternalLink` | Extra links attached to a client | `Kind`, `Value`, `Remark`, `SortIndex` |
|
|
| `Host` | Subscription host overrides (per inbound) | `Address`, `Port`, `Sni`, `Path`, `Security`, `Fingerprint`, `SortOrder`, visibility/exclusion flags |
|
|
| `Node` | A managed child panel | `Guid`, `Address`, `Status`, `TlsVerifyMode`, `PinnedCertSha256`, `ConfigDirty`, version/heartbeat/metric fields |
|
|
| `NodeClientTraffic` | Per-node client traffic baseline | cross-node merge (anti-double-count) |
|
|
| `NodeClientIp` | Per-node client IP attribution | `NodeGuid`, `Email`, `Ips` |
|
|
| `ClientGlobalTraffic` | Cross-master usage totals | `MasterGuid`, `Email`, `Up`, `Down` |
|
|
| `xray.ClientTraffic` | Per-client counters (`client_traffics`) | `Email`, `Up`, `Down`, `Total`, `ExpiryTime`, `LastOnline` |
|
|
| `InboundClientIps` | IP set per client email | drives IP-limit enforcement |
|
|
| `OutboundTraffics` | Outbound counters | per outbound tag |
|
|
| `OutboundSubscription` | External provider subs | Warp/Nord style |
|
|
| `Setting` | Key/value panel settings | everything configurable |
|
|
| `ApiToken` | REST API tokens | SHA-256 hash (plaintext shown once) |
|
|
| `InboundFallback` | Fallback routing on a shared port | SNI/ALPN/path → dest |
|
|
| `HistoryOfSeeders` | Seeder bookkeeping | prevents re-running one-off migrations |
|
|
|
|
---
|
|
|
|
## 7. Symptom → File index (start here when debugging)
|
|
|
|
| Symptom / task | Primary file(s) | Then check |
|
|
| --------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
|
| Add/modify an **API endpoint** | `controller/<resource>.go` (route registration at top of each file) | corresponding `service/*.go`, `frontend/src/pages/api-docs/endpoints.ts` |
|
|
| **Inbound** create/update/delete behavior | `service/inbound.go`, `service/inbound_clients.go` | `runtime/*`, `service/xray.go` |
|
|
| **Client** CRUD / limits / expiry | `service/client_crud.go`, `service/client_inbound_apply.go` | model `ClientRecord`, `service/inbound_traffic.go` |
|
|
| **Bulk** client operations slow/wrong | `service/client_bulk.go` | `service/client_paging.go` |
|
|
| Xray **won't apply** a config change | `service/xray.go` (`RestartXray`, `tryHotApply`) | `xray/hot_diff.go`, `xray/process.go` |
|
|
| Xray **restarts when it shouldn't** (kills connections) | `xray/hot_diff.go` (diff not classified as hot) | `service/xray.go` |
|
|
| **Traffic** counts wrong / reset behavior | `service/inbound_traffic.go`, `job/xray_traffic_job.go` | `service/traffic_writer.go`, `job/periodic_traffic_reset_job.go` |
|
|
| **Node** operation not propagating | `runtime/remote.go`, `runtime/manager.go` | `service/inbound_node.go` |
|
|
| **Multi-hop / cross-node attribution** (traffic or online clients on wrong panel) | `service/inbound_node.go` (GUID merge, `synthNodeGuid`, `effectiveNodeGuid`) | `service/node.go`, model `OriginNodeGuid`/`Node.Guid` |
|
|
| Node stuck **offline / stale** | `job/node_heartbeat_job.go`, `service/node.go` (`Probe`, `UpdateHeartbeat`) | `runtime/tls_client.go` (TLS verify) |
|
|
| Node **TLS / mTLS** auth failures | `runtime/tls_client.go`, `service/node_mtls.go`, `service/setting_mtls.go` | `service/node.go` (`FetchCertFingerprint`) |
|
|
| Offline node edits **not reconciling** on reconnect | `service/inbound_node.go` (`ReconcileNode`, dirty flags) | `service/node.go` (`MarkNodeDirty`/`NodeSyncState`) |
|
|
| **Share link / QR** malformed (per protocol) | `service/client_link.go`, `util/link/outbound.go` | `frontend/src/lib/xray/`, `frontend/src/schemas/protocols/` |
|
|
| **Subscription** output wrong (raw/JSON/Clash) | `internal/sub/service.go` | `sub/json_service.go`, `sub/clash_service.go`, sub golden tests |
|
|
| Subscription **host overrides** not applied | `service/host.go`, `sub/host_sub.go` | model `Host`, `frontend/src/pages/hosts/` |
|
|
| **External subscription** import/aggregation | `sub/external_subscription.go`, `sub/external_config.go` | `sub/clash_external.go` |
|
|
| **Settings** not saving / defaults | `service/setting.go`, `controller/setting.go` | model `Setting` |
|
|
| **Login / 2FA / sessions / CSRF** | `controller/index.go`, `service/panel/user.go`, `middleware/` | `session/` |
|
|
| **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` |
|
|
| Xray auto-restart on **dead tunnel** | `internal/tunnelmonitor/` | `XUI_TUNNEL_HEALTH_*` in `internal/config/` |
|
|
| **WARP / Nord** outbound integration | `service/integration/warp.go` / `nord.go` | `service/outbound_subscription.go` |
|
|
| **MTProto** proxy issues | `internal/mtproto/manager.go`, `mtproto/process*.go` | `job/mtproto_job.go` |
|
|
| **DB migration** / new column | `internal/database/db.go` (AutoMigrate list), `migrate_data.go` | `model/model.go` |
|
|
| **Cron schedule** changes | `web.go` → `startTask()` | the specific `job/*.go` |
|
|
| **CORS / security headers / HTTPS** | `middleware/`, `web.go` (`initRouter`, TLS setup) | `config/` (env) |
|
|
| **Env vars / paths / DB type** | `internal/config/config.go` | `.env.example` |
|
|
| **Frontend route / screen** | `frontend/src/pages/<area>/`, `frontend/src/routes.tsx` | `frontend/src/api/queries/` |
|
|
| **Frontend ↔ backend type mismatch** | regenerate: `cd frontend && npm run gen` (`tools/openapigen`) | `frontend/src/generated/` |
|
|
| **System status / CPU / metrics** | `service/server.go`, `service/xray_metrics.go`, `service/metric_history.go` | `controller/server.go`, gopsutil |
|
|
|
|
---
|
|
|
|
## 8. Layering rules (where new code belongs)
|
|
|
|
1. **Controllers are thin.** Only: bind/validate input, call one service, shape the HTTP
|
|
response. No DB queries, no Xray calls, no business rules in `controller/`.
|
|
2. **Services own the logic and transactions.** All business rules, DB access, and decisions
|
|
about applying changes live in `service/`. If you're tempted to query GORM from a
|
|
controller, move it to a service.
|
|
3. **Never touch Xray's running state from a controller or job directly.** Go through
|
|
`XrayService` / the `runtime.Runtime` interface so local vs node dispatch stays correct.
|
|
4. **Any state-changing inbound/client op must dispatch through `runtime.Runtime`**, not
|
|
straight to `xray/api.go` — otherwise node deployments silently break.
|
|
5. **`internal/util/*` is leaf-only** (no imports of `service`/`controller`/`database`). Keep
|
|
helpers pure.
|
|
6. **Don't hand-edit generated files:** `frontend/src/generated/*` and `internal/web/dist/*`.
|
|
Regenerate instead.
|
|
7. **Models are the contract.** Changing a model field that crosses the API boundary means:
|
|
update `model.go` → handle migration in `db.go`/`migrate_data.go` → regenerate frontend types.
|
|
8. **Two servers, two concerns.** Admin features go in `internal/web`; anything an _end user_
|
|
fetches goes in `internal/sub`. Don't blur them.
|
|
9. **Cross-cutting notifications go through `internal/eventbus/`** — publish an event instead
|
|
of importing the Telegram/email services into producers.
|
|
|
|
---
|
|
|
|
## 9. Build / Test / Lint (verify your changes)
|
|
|
|
The canonical gate is the **Makefile** (mirrors CI): `make verify`. Also: `make gen`
|
|
(regenerate Zod/OpenAPI), `make lint` (Go + frontend), `make test` (Go `-shuffle=on` +
|
|
frontend), `make race`, `make build`. Run `make help` for everything. Raw commands:
|
|
|
|
**Backend (Go):**
|
|
|
|
```bash
|
|
go build ./... # compile everything
|
|
go test ./... # run all Go tests (many *_test.go alongside sources)
|
|
go test ./internal/web/service/... # focused: service-layer tests
|
|
go test ./internal/xray/... # hot-diff / process / api tests
|
|
go test ./internal/sub/... # subscription + golden link tests
|
|
go vet ./... # static checks
|
|
golangci-lint run # full lint (gofumpt + goimports formatting)
|
|
go run main.go # run the panel locally (serves embedded dist if built)
|
|
```
|
|
|
|
**Frontend (`cd frontend`, Node 24 — see `.nvmrc`):**
|
|
|
|
```bash
|
|
npm install
|
|
npm run dev # Vite dev server on :5173; proxies API to Go backend on :2053 (run `go run main.go` too)
|
|
npm run typecheck # tsc --noEmit
|
|
npm run lint # oxlint src
|
|
npm run test # vitest (incl. golden config-generation snapshots)
|
|
npm run gen # regenerate src/generated/* from Go (gen:zod + gen:api)
|
|
npm run build # gen:api + vite build → outputs to internal/web/dist (then rebuild Go binary to embed)
|
|
```
|
|
|
|
**Full local loop:** `cd frontend && npm run build` (refresh embedded `dist/`) → back to repo
|
|
root → `go build ./...` / `go run main.go`.
|
|
|
|
**Docker:** `docker compose up -d` (uses `Dockerfile` + `DockerEntrypoint.sh`).
|
|
|
|
**CI** (`.github/workflows/`): `ci.yml` (build/test/lint), `codeql.yml` (security scan),
|
|
`smoke.yml` (smoke tests), `mutation.yml` (mutation testing), `docker.yml` + `release.yml`
|
|
(multi-arch image + release builds), `cleanup_caches.yml`, `claude-bot.yml` (issue bot).
|
|
|
|
---
|
|
|
|
## 10. Gotchas & conventions
|
|
|
|
- **Module path is `.../v3`.** Internal imports use `github.com/mhsanaei/3x-ui/v3/internal/...`.
|
|
- **SQLite vs Postgres.** Default is SQLite at `{XUI_DB_FOLDER}/x-ui.db`. Postgres via
|
|
`XUI_DB_TYPE=postgres` + `XUI_DB_DSN`. Some SQL paths are dialect-aware (`database/dialect.go`);
|
|
test both when touching raw queries (there are `*_scale_postgres_test.go` suites).
|
|
- **`Inbound.Settings` / `StreamSettings` / `Sniffing` are raw JSON strings**, not structured
|
|
columns. Parsing/validation happens in services and the `xray` package, not in GORM.
|
|
- **Hot-reload is the default; full restart is the fallback.** Changes that look config-only
|
|
but cause a restart usually mean the diff in `xray/hot_diff.go` didn't recognize them as hot.
|
|
- **Node TLS:** remote calls honor `TlsVerifyMode` (`verify`/`skip`/`pin`/`mtls`). "Works on
|
|
skip, fails on verify/pin/mtls" → cert/fingerprint handling in `service/node.go`
|
|
(`FetchCertFingerprint`), `service/node_mtls.go`, and `runtime/tls_client.go`.
|
|
- **Restart is signal-driven.** `main.go` traps SIGHUP to restart panel+sub servers; the
|
|
in-process restart hook (`global.SetRestartHook`) funnels into the same path.
|
|
- **i18n:** backend catalogs in `internal/web/translation/` (13 locales, shared with the
|
|
frontend); frontend wiring in `frontend/src/i18n/`. Persian (`fa_IR`) is a first-class
|
|
locale (Jalali calendar via `persian-calendar-suite`).
|
|
- **Tests live next to code** (`foo.go` ↔ `foo_test.go`), plus golden snapshots in
|
|
`frontend/src/test/golden/fixtures/` for config generation — update fixtures intentionally,
|
|
not blindly, when output changes.
|