* 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>
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)
- Download the latest build from https://github.com/niXman/mingw-builds-binaries/releases. For most setups, pick a release named:
(64-bit, POSIX threads, SEH exceptions, UCRT runtime — matches modern Windows defaults.)
x86_64-<version>-release-posix-seh-ucrt-rt_<n>-rev<m>.7z - Extract it somewhere stable, e.g.
C:\mingw64\. - Add
C:\mingw64\binto the WindowsPATH(System Properties → Environment Variables → Path → New). - Open a fresh terminal and confirm:
gcc --version
Option B — MSYS2 (when a Unix shell is also useful)
- Install MSYS2 from https://www.msys2.org/.
- Open the MSYS2 UCRT64 shell from the Start menu and update once:
pacman -Syu - Install the UCRT64 toolchain:
pacman -S --needed mingw-w64-ucrt-x86_64-gcc mingw-w64-ucrt-x86_64-pkg-config - Add
C:\msys64\ucrt64\binto the WindowsPATH. - Verify with
gcc --versionin a fresh terminal.
After either path, go build ./... and go run . work normally.
Why MinGW-w64 over MSVC:
mattn/go-sqlite3officially 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 sureCC=clis 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.tsxmounts areact-routercreateBrowserRouter(seesrc/routes.tsx) under the/panelbasename; every route (/panel,/panel/inbounds,/panel/clients,/panel/groups,/panel/nodes,/panel/settings,/panel/xray,/panel/api-docs) is lazy-loaded inside a sharedPanelLayout(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(QueryProviderinsrc/main.tsx, keys insrc/api/queryKeys.ts); responses are cached and invalidated on mutation rather than blindly re-fetched, and WebSocket pushes feed back into the cache viasrc/api/websocketBridge.ts. - Local UI state stays in the page (
useState); shared concerns go through contexts and hooks insrc/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 withz.infer— never hand-written. Go-side types are mirrored intosrc/generated/bynpm 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
HttpUtilinsrc/utils/index.ts, a thin Axios wrapper that handles CSRF, response toasts, and asilent: trueopt-out for bulk operations that would otherwise spam toasts. The Axios setup itself lives insrc/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=truegotcha — in debug mode the panel serves HTML from the embedded FS (frozen at the lastgo build/go run) but JS/CSS off disk. Re-runningnpm run buildwithout restarting Go leaves the embedded HTML pointing at the old hashed asset names, producing a blank page with 404s in the console. Always restartgo run .after a frontend rebuild.
Adding a new page
Most new screens are admin-panel routes and need no new HTML or Vite entry:
- Create the page component under
src/pages/<page>/<Page>.tsx(kebab-case folder, PascalCase component). - Register it in
src/routes.tsxunder the/paneltree (lazy-import it like the others). - Add a sidebar link in
src/layouts/AppSidebar.tsxif 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. Runnpm run typecheck(tsc --noEmit) before pushing. The path alias@/*resolves tosrc/*. - 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 insrc/schemas/;@typescript-eslint/no-explicit-anyis an error and production schemas use no.loose(). Validate form fields withantdRule(Schema.shape.field, t)rather than inlinez.string()in rules. - Document new endpoints. Every new
g.POST/g.GETinweb/controller/needs a matching entry insrc/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 — runnpm run testafter 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→ backendGetSubs); exercise both. - Vite is pinned to an exact version (no
^) infrontend/package.json— currently8.0.16— so local, CI, and release builds resolve identically. Bump it deliberately and verify bothnpm run devandnpm run buildafterward.
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
- Branch off
main(e.g.feat/short-description). - Keep the diff focused — separate refactors from feature work.
- 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)
- 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. - Open the PR against
mainwith 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
- Bug reports and feature requests: GitHub 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.