Files
3x-ui/CONTRIBUTING.md
T
Rouzbeh† 0daedd3db9 feat: add support for subscription-based outbounds with auto-update (#5037)
* feat: add support for subscription-based outbounds with auto-update

- New OutboundSubscription model (full support on both SQLite and PostgreSQL)
- Go subscription link parser (vmess/vless/trojan/ss/hysteria2/wireguard) matching frontend behavior
- Stable tag assignment across refreshes (designed for balancer + routing use)
- Runtime merge of subscription outbounds into Xray config (additive only)
- Full CRUD + manual refresh + preview API
- Background auto-update job (per-subscription interval)
- Frontend management UI in Outbounds tab (Subscriptions drawer) + tag integration in balancers/routing rules
- Proper dual-database support including CLI migration path

Review & hardening notes:
- Fixed merge logic bug that could drop manual outbounds
- Added SSRF/private-IP protection on subscription URLs using SanitizePublicHTTPURL
- Improved update interval UX (hours + minutes)
- Auto-fetch on first subscription creation
- Added detailed comments on tag stability strategy and balancer implications when servers are added/removed/rotated
- Updated migrationModels() for CLI migrate-db support

* fix: resolve frontend lint/type errors and Go build break

Frontend (eslint + tsc clean):
- Destructure subscriptionOutboundTags prop in RoutingTab and
  BalancersTab. It was declared in the interface and used in useMemo
  but never destructured, so it resolved as an unresolved global
  (react-hooks warning + tsc "Cannot find name"). The prop is passed
  by XrayPage, so the feature was silently inert.
- OutboundsTab: remove unused useEffect import, add an OutboundSub
  type to replace any[] state and the any/any table render signature,
  type the subscriptionOutbounds cast, and replace unused catch (e)
  bindings with parameter-less catch. Also type HttpUtil.post as
  OutboundSub so r.obj?.id type-checks.

Backend (go build clean):
- outbound_subscription_job: websocket.MessageTypeXray is undefined;
  use the existing MessageTypeOutbounds since the job refreshes
  outbound subscriptions.

* fix(xray): make outbound subscription creation work end-to-end

- Correct API paths from /panel/xray/outbound-subs to
  /panel/api/xray/outbound-subs. The controller is mounted under
  /panel/api, so the old paths hit the SPA page route (GET-only)
  and 404'd on POST.
- Send the create-subscription body as a plain object instead of
  URLSearchParams. The axios request interceptor serializes bodies
  with qs.stringify, which can't read URLSearchParams' internal
  storage and produced an empty body, so the backend rejected it
  with "subscription URL is required".
- Use message.useMessage() + context holder instead of the static
  antd message API (resolves the "Static function can not consume
  context" warning), matching XrayPage's pattern.
- Migrate the subscriptions Drawer to antd v6 props: width -> size,
  destroyOnClose -> destroyOnHidden, and Space direction -> orientation.

* feat(xray): show traffic/test for subscription outbounds; harden + test the feature

Display (the reported issue):
- Replace the flat read-only pills with a proper read-only table (desktop)
  and cards (mobile) in a new SubscriptionOutbounds component, showing
  Address, Protocol, Traffic (matched by tag — already collected by Xray),
  and a Test button with Latency. No edit/delete/move (read-only).
- Test subscription outbounds via the existing /testOutbound endpoint, with
  results keyed by tag (subscriptionTestStates + testSubscriptionOutbound in
  useXraySetting, wired through XrayPage). Generalize isTesting/testResult to
  a string|number key so the same helpers serve index- and tag-keyed states.

i18n:
- Replace all hardcoded English subscription strings with t() calls and add
  pages.xray.outboundSub.* keys to en-US.json (other locales fall back).

Backend hardening + tests:
- xray.go: drop the tautological `subSvc != nil` check.
- outbound_subscription: re-validate every redirect hop against private/
  internal addresses (CheckRedirect) and cap the redirect chain, closing an
  SSRF gap where only the initial host was checked.
- Extract assignStableTags as a pure function and add unit tests for tag
  stability and SSRF rejection (the feature previously had no tests).

Misc:
- gofmt util/link/outbound.go (it was not gofmt-clean).

* fix(xray): make outbound-subs feature pass CI (test compile, route docs, openapi)

- outbound_test.go: remove unused `inner`/`lines` variables that broke the
  `util/link` test build (declared and not used).
- Document the 7 outbound-subscription routes in endpoints.ts (list, create,
  update, delete, del alias, refresh, parse) so TestAPIRoutesDocumented passes.
- Regenerate frontend/public/openapi.json (npm run gen) to include the new
  endpoints, satisfying the codegen freshness check.

* feat(xray): per-subscription allow-private, gap-filled tags, UI tweaks, delete refresh

Backend:
- Add a per-subscription AllowPrivate flag (default off). Create/Update/refresh
  and the redirect check sanitize the URL with it, so localhost/LAN sources work
  only when explicitly opted in; the SSRF guard still blocks private targets by
  default. Controller reads the allowPrivate form field on create/update/parse.
- Default outbound tag prefix now uses the smallest free "subN-" number instead
  of the auto-increment id, so deleting a subscription frees its number for reuse
  (a fresh start gives sub1) while staying stable per subscription. Extracted a
  pure defaultPrefixNumber() with unit tests.
- deleteOutboundSub now signals SetToNeedRestart so xray drops the outbounds.

Frontend:
- "Allow private address" toggle in the add form (sends allowPrivate).
- Delete now refreshes the xray view immediately (no manual page reload).
- Subscriptions manager opens as a centered Modal instead of a right-side Drawer.
- Move Outbounds to a top-level sidebar item under Nodes (out of Xray Configs).
- Collapse WARP/NordVPN into a "more" dropdown.
- Document the allowPrivate param in endpoints.ts.

* i18n(xray): translate outbound-subscription UI into all locales

- Translate the pages.xray.outboundSub.* strings (and allowPrivate label/hint)
  into all 12 non-English locales, matching each file's existing terminology.
- Remove the unused outboundSub.add ("Add subscription") key from every locale.

* feat(xray): subscription manager — edit, reorder/priority, status, preview, refresh-all

Backend:
- Per-subscription Priority + Prepend: subscriptions are ordered by Priority and
  placed before (Prepend) or after the manual template outbounds in the merge, so
  a subscription server can become the default. New Move(up/down) endpoint
  re-normalizes priorities; merge split into prepend/template/append.
- List now returns a derived OutboundCount and orders by priority, and strips the
  heavy LastFetchedOutbounds/LinkIdentities blobs from the list payload.
- Create/Update accept the prepend flag; new subs append at the end of priority.

Frontend (Outbound Subscriptions modal):
- Edit existing subscriptions (reuses the form + Update endpoint).
- Inline enable/disable Switch, Status column (OK / error tooltip), Outbounds
  count column, per-row refresh spinner, "Refresh all" button.
- Reorder (move up/down) controls + a "Before manual outbounds" toggle.
- Preview button: fetch+parse a URL via /parse without saving.
- Document the move route + prepend param in endpoints.ts; regenerate openapi.json.

* i18n(xray): translate new subscription-manager strings into all locales

Add the prepend/prependHint, preview/previewEmpty, refreshAll, statusOk and
toastUpdated keys to all 12 non-English locales, matching each file's terminology.

---------

Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
2026-06-08 18:09:53 +02:00

16 KiB

Contributing

Thanks for taking the time to contribute to 3x-ui. This guide gets a development panel running locally and explains the conventions the project follows so changes land cleanly.

Prerequisites

  • Go 1.26+ (the version pinned in go.mod)
  • Node.js 22+ and npm 10+ (for the React frontend)
  • Git
  • A C compiler — required by the CGo SQLite driver (github.com/mattn/go-sqlite3). Linux and macOS already ship one; for Windows see below.

Windows: MinGW-w64

go build on Windows fails with cgo: C compiler "gcc" not found until a GCC toolchain is installed. Two options — pick whichever fits.

Option A — standalone zip (fastest, no package manager)

  1. Download the latest build from https://github.com/niXman/mingw-builds-binaries/releases. For most setups, pick a release named:
    x86_64-<version>-release-posix-seh-ucrt-rt_<n>-rev<m>.7z
    
    (64-bit, POSIX threads, SEH exceptions, UCRT runtime — matches modern Windows defaults.)
  2. Extract it somewhere stable, e.g. C:\mingw64\.
  3. Add C:\mingw64\bin to the Windows PATH (System Properties → Environment Variables → Path → New).
  4. Open a fresh terminal and confirm:
    gcc --version
    

Option B — MSYS2 (when a Unix shell is also useful)

  1. Install MSYS2 from https://www.msys2.org/.
  2. Open the MSYS2 UCRT64 shell from the Start menu and update once:
    pacman -Syu
    
  3. Install the UCRT64 toolchain:
    pacman -S --needed mingw-w64-ucrt-x86_64-gcc mingw-w64-ucrt-x86_64-pkg-config
    
  4. Add C:\msys64\ucrt64\bin to the Windows PATH.
  5. Verify with gcc --version in a fresh terminal.

After either path, go build ./... and go run . work normally.

Why MinGW-w64 over MSVC: mattn/go-sqlite3 officially supports GCC, builds are faster on Windows, and the toolchain does not require a Visual Studio install. If Visual Studio Build Tools are already present that works too — just make sure CC=cl is not set in the environment.

Cross-building the Linux SQLite target from Windows (or vice versa) requires a separate cross-compiler and is out of scope here; build natively on the target OS.

First-time setup

git clone https://github.com/MHSanaei/3x-ui.git
cd 3x-ui

cp .env.example .env

mkdir x-ui

go mod download

cd frontend
npm install
npm run build
cd ..

.env.example ships with defaults that keep the database, logs, and xray binary inside the local x-ui/ folder so nothing escapes the project directory:

XUI_DEBUG=true
XUI_DB_FOLDER=x-ui
XUI_LOG_FOLDER=x-ui
XUI_BIN_FOLDER=x-ui

Drop the xray binary (xray-windows-amd64.exe on Windows, xray-linux-amd64 on Linux, etc.) plus the matching geoip.dat and geosite.dat files into x-ui/. The easiest source is a released Xray-core build. On Windows, wintun.dll is also required for testing TUN inbounds.

Running

go run .

Open http://localhost:2053 and log in with admin / admin. Credentials must be changed on first login.

Inside VS Code

The repo checks in two VS Code launch profiles in .vscode/launch.json: Run 3x-ui (Debug) for the default SQLite setup, and Run 3x-ui (Postgres) which points XUI_DB_TYPE/XUI_DB_DSN at a local PostgreSQL. The Postgres profile also prepends the PostgreSQL bin to PATH so the panel can find pg_dump/pg_restore (the postgresql-client tools used for DB backup/restore) — adjust the DSN and that path to your machine:

{
  "$schema": "vscode://schemas/launch",
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Run 3x-ui (Debug)",
      "type": "go",
      "request": "launch",
      "mode": "auto",
      "program": "${workspaceFolder}",
      "cwd": "${workspaceFolder}",
      "env": {
        "XUI_DEBUG": "true",
        "XUI_DB_FOLDER": "x-ui",
        "XUI_LOG_FOLDER": "x-ui",
        "XUI_BIN_FOLDER": "x-ui"
      },
      "console": "integratedTerminal"
    },
    {
      "name": "Run 3x-ui (Postgres)",
      "type": "go",
      "request": "launch",
      "mode": "auto",
      "program": "${workspaceFolder}",
      "cwd": "${workspaceFolder}",
      "env": {
        "XUI_DEBUG": "true",
        "XUI_LOG_FOLDER": "x-ui",
        "XUI_BIN_FOLDER": "x-ui",
        "XUI_DB_TYPE": "postgres",
        "XUI_DB_DSN": "postgres://xui:xuipass@127.0.0.1:5432/xui?sslmode=disable",
        "PATH": "C:\\Program Files\\PostgreSQL\\18\\bin;${env:PATH}"
      },
      "console": "integratedTerminal"
    }
  ]
}

Working on the frontend

The panel UI is a React 19 + Ant Design 6 + TypeScript app under frontend/, built with Vite 8. The sections below cover the architecture, the conventions, and the two dev workflows.

Architecture

The frontend ships three Vite bundles, each emitted into web/dist/ and embedded into the Go binary at compile time via embed.FS:

  • index.html — the admin panel, a single-page app. src/main.tsx mounts a react-router createBrowserRouter (see src/routes.tsx) under the /panel basename; every route (/panel, /panel/inbounds, /panel/clients, /panel/groups, /panel/nodes, /panel/settings, /panel/xray, /panel/api-docs) is lazy-loaded inside a shared PanelLayout (sidebar + header + <Outlet>).
  • login.html — the login + 2FA screen (src/entries/login.tsx), a standalone bundle.
  • subpage.html — the public subscription viewer (src/entries/subpage.tsx), a standalone bundle.

Panel navigation happens client-side through React Router, and per-route code is lazy-split so the initial panel load stays small. login and subpage stay separate documents because they are reached without an authenticated panel session.

State and data flow

  • Server state via TanStack Query. API reads go through @tanstack/react-query (QueryProvider in src/main.tsx, keys in src/api/queryKeys.ts); responses are cached and invalidated on mutation rather than blindly re-fetched, and WebSocket pushes feed back into the cache via src/api/websocketBridge.ts.
  • Local UI state stays in the page (useState); shared concerns go through contexts and hooks in src/hooks/ (useTheme, useWebSocket, useClients, useDatepicker, …). Prefer extending an existing hook over introducing a new global.
  • Zod is the single source of truth. Schemas in src/schemas/ define the xray config model; every API response is parsed through them, every form field validates against them, and TypeScript types are inferred with z.infer — never hand-written. Go-side types are mirrored into src/generated/ by npm run gen:zod (do not hand-edit that folder).
  • xray domain logic — link generation, protocol defaults, form ⇄ wire adapters — lives as pure functions in src/lib/xray/. src/models/ keeps only thin legacy types still being migrated onto schemas.
  • HTTP goes through HttpUtil in src/utils/index.ts, a thin Axios wrapper that handles CSRF, response toasts, and a silent: true opt-out for bulk operations that would otherwise spam toasts. The Axios setup itself lives in src/api/axios-init.ts.

i18n

Locale strings live in web/translation/<locale>.json, not under frontend/. The Go binary embeds the same JSON and serves it to both backend templates and react-i18next (initialized in src/i18n/react.ts). When a new English key is added it must also land in every non-English locale — missing keys do not break the build, they just render the raw key in the UI.

Two dev workflows

Goal Command
Iterate on UI changes with HMR cd frontend && npm run dev (Vite on :5173, proxies /panel/* and the WebSocket to the Go panel on :2053). Start the Go panel first.
Verify what end users actually see cd frontend && npm run build, then go run .. The Go binary serves the built bundle — embedded in release mode, off disk in debug mode.

The Vite dev proxy serves the admin SPA for any /panel/* URL — bypassMigratedRoute in vite.config.js rewrites those requests to index.html and lets React Router take over — while forwarding /panel/api/*, /panel/api/setting/*, /panel/api/xray/*, and the WebSocket to the Go panel. Because routing is now client-side, new panel routes need no proxy or allowlist changes.

XUI_DEBUG=true gotcha — in debug mode the panel serves HTML from the embedded FS (frozen at the last go build / go run) but JS/CSS off disk. Re-running npm run build without restarting Go leaves the embedded HTML pointing at the old hashed asset names, producing a blank page with 404s in the console. Always restart go run . after a frontend rebuild.

Adding a new page

Most new screens are admin-panel routes and need no new HTML or Vite entry:

  1. Create the page component under src/pages/<page>/<Page>.tsx (kebab-case folder, PascalCase component).
  2. Register it in src/routes.tsx under the /panel tree (lazy-import it like the others).
  3. Add a sidebar link in src/layouts/AppSidebar.tsx if it should be reachable from the nav.

Only a genuinely standalone bundle (like login or subpage, reachable without the panel shell) needs the full entry treatment: add frontend/<page>.html, a src/entries/<page>.tsx bootstrap, register it in rollupOptions.input inside vite.config.js, and wire a Go controller route that calls serveDistPage(c, "<page>.html") to serve the embedded HTML in production.

Conventions

  • TypeScript strict mode — all new code in .ts / .tsx. Run npm run typecheck (tsc --noEmit) before pushing. The path alias @/* resolves to src/*.
  • Ant Design 6 is the only UI kit — no Tailwind, no shadcn. A previous attempt to migrate was rolled back. Small, targeted UX tweaks beat sweeping rewrites; raise broader visual changes for discussion before implementing.
  • Function components + hooks everywhere. No class components.
  • No // line comments in committed JS/TS/Vue/Go. HTML <!-- ... --> is fine for template structure. Names should carry the meaning; rename rather than annotate. Comments are reserved for the why, and only when the reason is surprising.
  • RTL is a first-class concern. Persian and Arabic users matter — RTL is enabled through AntD's ConfigProvider direction="rtl". When writing Persian text in toasts or labels, isolate code identifiers on their own lines so RTL reading flows.
  • Schemas over any. New config shapes go in src/schemas/; @typescript-eslint/no-explicit-any is an error and production schemas use no .loose(). Validate form fields with antdRule(Schema.shape.field, t) rather than inline z.string() in rules.
  • Document new endpoints. Every new g.POST/g.GET in web/controller/ needs a matching entry in src/pages/api-docs/endpoints.ts — it drives both the in-panel API docs and the generated OpenAPI/Zod (npm run gen:api / gen:zod).
  • Do not break link generation. Share-link logic lives in src/lib/xray/ (inbound-link.ts, outbound-link-parser.ts, …) and is round-tripped by the golden fixture suite — run npm run test after any change to URL generation, defaults, or TLS/Reality handling, and regenerate snapshots (npx vitest run -u) only for intentional changes. Two runtime paths consume it: the inbounds page and the clients page subscription links (/panel/api/clients/subLinks/:subId → backend GetSubs); exercise both.
  • Vite is pinned to an exact version (no ^) in frontend/package.json — currently 8.0.16 — so local, CI, and release builds resolve identically. Bump it deliberately and verify both npm run dev and npm run build afterward.

Project layout

frontend/
├── index.html             — admin panel SPA entry
├── login.html             — login + 2FA entry
├── subpage.html           — public subscription viewer entry
├── tsconfig.json          — strict, jsx: "react-jsx", paths "@/*" → "src/*"
├── eslint.config.js       — ESLint flat config (@eslint/js + typescript-eslint + react-hooks)
├── vite.config.js
├── vitest.config.ts
├── scripts/               — build-openapi.mjs (endpoints.ts → openapi.json)
└── src/
    ├── main.tsx           — admin SPA bootstrap (router + providers)
    ├── routes.tsx         — react-router routes mounted under /panel
    ├── entries/           — bootstrap for the standalone bundles (login, subpage)
    ├── layouts/           — PanelLayout + AppSidebar
    ├── pages/             — one folder per route (index, inbounds, clients, groups, nodes, settings, xray, api-docs) plus login, sub
    ├── components/        — cross-page React components
    ├── hooks/             — reusable hooks (useTheme, useWebSocket, useClients, useDatepicker, …)
    ├── api/               — Axios + CSRF interceptor, TanStack Query provider/keys, WebSocket client
    ├── i18n/              — react-i18next bootstrap (JSON lives in web/translation/)
    ├── lib/xray/          — pure xray logic: link generation, defaults, form ⇄ wire adapters
    ├── schemas/           — Zod source of truth for the xray config model
    ├── generated/         — code-generated Zod + TS types from Go (do not hand-edit)
    ├── models/            — thin legacy types still being migrated
    ├── styles/            — shared CSS (page-cards, …)
    ├── test/              — Vitest specs + golden fixtures
    └── utils/             — HttpUtil, ClipboardManager, SizeFormatter, …

For deeper notes on the frontend toolchain see frontend/README.md.

Project layout

Path Contents
main.go Process entry point, CLI subcommands, signal handling
web/ Gin HTTP server, controllers, services, embedded frontend assets
frontend/ React + Ant Design 6 + TypeScript source for the panel UI
database/ GORM models, migrations, seeders (SQLite / PostgreSQL)
xray/ Xray-core process lifecycle and gRPC API client
sub/ Subscription endpoints (raw, JSON, Clash)
config/ Environment-variable helpers, paths, defaults
x-ui/ Runtime data — db, logs, xray binary, geo files (gitignored)

Sending a pull request

  1. Branch off main (e.g. feat/short-description).
  2. Keep the diff focused — separate refactors from feature work.
  3. Run the relevant checks before pushing:
    • go build ./...
    • go test ./... (when Go code changed)
    • cd frontend && npm run typecheck && npm run lint && npm run test && npm run build (when the frontend changed; CI runs this same set on every PR via .github/workflows/ci.yml)
  4. Commit messages follow the existing pattern in git log<area>: short imperative summary, then a body explaining the why. Conventional-commit prefixes (feat, fix, refactor, chore, style, docs) are encouraged.
  5. Open the PR against main with a brief description of what changed and how to test it.

Useful environment variables

Variable Default Purpose
XUI_DEBUG false Verbose logs + Gin debug mode + serve /assets from disk
XUI_LOG_LEVEL info debug / info / notice / warning / error
XUI_DB_FOLDER platform default Where x-ui.db lives
XUI_LOG_FOLDER platform default Where 3xui.log lives
XUI_BIN_FOLDER bin Where the xray binary, geo files, and xray config.json live
XUI_DB_TYPE sqlite Set to postgres to use PostgreSQL via XUI_DB_DSN
XUI_DB_DSN PostgreSQL DSN when XUI_DB_TYPE=postgres

Issues

Before filing a bug, include the OS, Go version, panel version (/panel/api/server/status or the dashboard footer), and the relevant excerpt from x-ui/3xui.log.