Commit Graph

1541 Commits

Author SHA1 Message Date
Sanaei 3d6ff2b60c fix(tgbot): apply bot settings on panel restart without full service restart
The Telegram bot's config (enable flag, token, proxy, API server) was read
only during a full server.Start(). The in-process "Restart Panel" path
(StopPanelOnly/StartPanelOnly -> stop/start with the bot flag false) skipped
the bot entirely, so enabling the bot or changing its proxy did not take
effect until a full OS-level `systemctl restart x-ui`.

Cycle the Telegram bot in the panel-only restart path while still leaving
Xray and the traffic writer untouched (preserving the #4265 freeze fix), so
"Restart Panel" reconciles the bot with the latest settings.

Fixes #5033
2026-06-08 22:37:37 +02:00
Rouzbeh† abf6b8799e feat: customizable subscription page templates (#5079)
* 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: add custom subscription page template support

Allow panel admins to use a custom HTML template for the subscription
page instead of the default React-based SPA.

Changes
-------

Backend
- web/service/setting.go: Add subThemeDir setting (default: empty)
  with a getter GetSubThemeDir().
- web/entity/entity.go: Add SubThemeDir field to AllSetting.
- sub/subController.go: In serveSubPage, before falling back to the
  embedded SPA, check if subThemeDir is set and the directory exists.
  Look for sub.html first, then index.html. Parse with Go html/template
  and execute, injecting all standard page variables as template context.
  On any parse/execute error, log and fall through to the default page.

  Two backward-compat aliases added to the template data map:
  - result  = links    (for tx-ui v2 templates using {{ range .result }})
  - jsonUrl = subJsonUrl

Frontend
- frontend/src/models/setting.ts: Add subThemeDir = '' to AllSetting.
- frontend/src/pages/settings/SubscriptionGeneralTab.tsx: Add a Sub
  Theme Directory input in Subscription settings.

Templates
- sub_templates/README.md: Full authoring guide with all variables.
- sub_templates/tx-ui/index.html: The tx-ui subscription page template
  migrated from v2 to v3 data shape.

Credits
-------
Bundled tx-ui template from AghayeCoder: https://github.com/AghayeCoder/tx-ui

* chore: regenerate OpenAPI schemas and types for custom sub-template feature

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

* refactor(sub): harden custom template rendering, drop bundled tx-ui template

Builds on the custom subscription page template feature.

Rendering hardening (sub/subController.go):
- Render the custom template into a buffer and only write the response on
  success. Previously template.Execute wrote straight to the ResponseWriter,
  so a mid-render failure left a partially-written body and then fell through
  to the default page, corrupting the response (superfluous WriteHeader).
- Cache parsed templates keyed by path, invalidated by file mtime, so each
  subscription page load no longer re-reads and re-parses the file from disk;
  admin edits are still picked up automatically.
- Verify the configured path is a directory (IsDir) and log a Warning when it
  is set but unusable / an Error when a template fails to parse, instead of
  silently falling back.
- Expose two new template variables: subTitle and subSupportUrl.

Cleanup:
- Remove the bundled tx-ui template and all tx-ui / AghayeCoder references
  (including the result/jsonUrl v2-compat aliases); use a generic my-theme
  example path in docs/UI/translation.
- i18n the "Sub Theme Directory" setting (en-US subThemeDir/subThemeDirDesc)
  instead of hardcoded English.
- Fix README: expire is seconds (not ms), lastOnline is ms; correct the
  settings tab name; note templates are admin-provided, not bundled/deployed.

Tests:
- Add sub/subController_test.go covering loadSubTemplate: render, sub.html
  precedence, fallback cases, malformed template, and mtime cache invalidation.

Verified end-to-end in Docker: custom template renders with all variables,
all fallback paths return the clean default page (no corruption), and the
mtime cache reflects live edits.

* i18n(settings): translate subThemeDir into all locales

Add the subThemeDir / subThemeDirDesc keys (Sub Theme Directory setting) to
all 12 non-English locales, matching each file's existing terminology. They
previously fell back to en-US.

---------

Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
Co-authored-by: Rqzbeh <rqzbeh@users.noreply.github.com>
2026-06-08 22:04:47 +02:00
Rouzbeh† 94b8196e84 fix(db): additional cross-DB and node traffic edge cases (migration scan + node reset time) (#5045)
* fix(db): additional cross-DB and node traffic edge cases

- ExternalProxy migration: change StreamSettings scan from []byte
  to string (text column on both SQLite and Postgres). Use []byte()
  for json.Unmarshal. Avoids potential scan/encoding differences
  in migration of old multi-domain data on PG.

- Node mirror inbound creation and updates in SetRemoteTraffic:
  now copy LastTrafficResetTime from the node's reported snapshot.
  Previously, resets done on the node (or via API that affects
  node-owned inbounds) would not update the grace period tracking
  on the central mirror. This improves traffic reset + node traffic
  combining accuracy when using the public API to manage node
  inbounds or when nodes perform resets.

These are independent additional issues around node traffic
combining, creating mirrored node inbounds from snapshots,
and migration code that can affect Postgres (or mixed) setups
after API changes or node operations. They do not depend on the
previous enable-merge or tag/sub fixes.

Base: upstream/main (separate PR).

* fix(db): even more node/SQLite edge cases (chunked IN for node stats, tag cleanup)

- In NodeService.GetAll (used for node list/stats): the load of client_traffics
  for node inbound IDs used a direct "IN ?" with all IDs. On SQLite this
  can hit the bind var limit ("too many SQL variables") when there are
  many nodes/inbounds. Chunked using the existing chunkInts + sqliteMaxVars
  (same pattern as other large IN queries in the package). This is a
  specific scale issue for "node" setups on SQLite (PG is fine with large IN).

- Tag cleanup raw in MigrationRequirements (always runs at startup):
  was using SQLite-only INSTR. Fixed to use position() on PG (same as
  the previous tag fix on the main branch). Prevents startup crash on PG
  after node/inbound API changes that leave old tags.

These are additional specific cases around node traffic/stats combining,
node inbound counts, and startup migrations that can affect Postgres
users or large SQLite node deployments. They are independent of the
enable/traffic core fixes and the prior additional ones.

Added to the clean additional-issues branch for the separate PR.

* fix(db): even more for node traffic merge on 5045 (dialect enable expr + chunk gone deletes)

- Full dialect-safe client enable merge in setRemoteTrafficLocked:
  - Added ClientTrafficEnableMergeExpr() helper (PG CASE with ::boolean
    casts to avoid type errors; SQLite numeric for affinity).
  - Updated GreatestExpr with ::bigint casts on PG.
  - Switched the merge UPDATE from "enable AND ?" to the helper.
  This completes the node traffic sync safety for the "only node can
  disable" logic across DBs (core of the original symptom after API
  inbound updates on nodes).

- Chunked the NodeClientTraffic delete for "goneEmails" (when a node's
  snapshot no longer includes clients previously attached to a mirrored
  inbound). The "email IN ?" could exceed SQLite bind limit for nodes
  with many clients (after API deletes, bulk ops, or structural changes).
  Uses chunkStrings + sqliteMaxVars (consistent with the node stats chunk
  we added earlier).

These are direct extensions of node traffic combining, mirrored inbound
lifecycle, and API-driven changes that affect client_traffics / NodeClientTraffic
for nodes. Stayed on the clean 5045 branch as requested.

Pushed to update https://github.com/MHSanaei/3x-ui/pull/5045

---------

Co-authored-by: Rqzbeh <rqzbeh@users.noreply.github.com>
2026-06-08 20:39:40 +02:00
Rouzbeh† 1c74b995c3 feat(nodes): add distinct purple indicator when panel is online but Xray core failed (#5040)
* feat(nodes): add distinct purple indicator when panel is online but Xray core failed

Currently nodes only show binary online/offline based on panel API reachability.

This adds a third state:
- Green: panel reachable + Xray healthy
- Purple pulsing dot + "Online (Xray Error)": panel API works (management actions still available) but the node Xray process is in error or stopped. Tooltip shows the remote xrayError.
- Red: unreachable (unchanged)

Backend now captures xray.state + xray.errorMsg from /panel/api/server/status heartbeats and probes.
New fields on Node + NodeSummary, forwarded for transitive nodes.
Frontend Zod + NodeList rendering + dedicated .xray-error-dot CSS (color #722ED1) + i18n key.

Color chosen purple per feedback after initial implementation.

Refs: worktree xray-failed-in-nodes

* fix: remove invalid JSON comment causing CI failures

* chore: regenerate OpenAPI schemas and types for xray error indicators

* chore: regenerate examples and schemas for xray error indicators

* chore: regenerate missing openapi.json examples

* fix

---------

Co-authored-by: Rqzbeh <rqzbeh@users.noreply.github.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-06-08 20:24:00 +02:00
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
Rouzbeh† 21e01cc1e6 fix(postgres): make node traffic sync robust after public API inbound updates (#5038)
* fix(postgres): make node traffic sync robust after public API inbound updates

The background NodeTrafficSyncJob (every 5s) started failing after a
successful POST /panel/api/inbounds/update/{id} (including flows that
inject streamSettings.externalProxy) with:

  node traffic sync: merge for <node> failed:
  ERROR: CASE types boolean and integer cannot be matched (SQLSTATE 42804)

Root cause:
- The merge lives in setRemoteTrafficLocked (called from SetRemoteTraffic).
- The client_traffics delta path used a dialect-sensitive expression:
    enable = enable AND ?
    last_online = GREATEST(last_online, ?)
- On PostgreSQL, GREATEST / AND / COALESCE are implemented with internal
  CASE expressions. When "enable" columns (client_traffics, inbounds, ...)
  were INTEGER (common after SQLite → PG data migrations, older
  AutoMigrate, or mixed write paths) and the right-hand side was a
  boolean parameter (from snapshot ClientStats or form-bound API payload),
  PG rejected the expression at plan time.
- The public API update path (unlike the internal remote wire path)
  always runs updateClientTraffics + UpdateClientStat + SyncInbound.
  This touches client_traffics.enable rows for any inbound that has
  clients.
- SQLite tolerated 0/1 numeric bools; PG is strict.

Fix:
- Use an explicit CASE with ::boolean casts in the critical enable
  expression so the result type is always boolean.
- Make GreatestExpr emit safe casts on Postgres.
- Add a one-time normalization step in MigrationRequirements (runs on
  startup + xray restarts) that forces the relevant enable/enabled
  columns to boolean on Postgres using an idempotent DO block + USING
  cast. This cleans up pre-existing skew without a full re-migration.

This branch is based on upstream/main (original mhsanaei/3x-ui main).

The node traffic sync now survives arbitrary public-API inbound
updates on PostgreSQL.

* fix: make client traffic enable merge expression safe on SQLite too

The previous commit introduced an explicit CASE for the "only node
can disable" logic in the node traffic sync merge to fix the PG
"CASE types boolean and integer cannot be matched" error after
public API inbound updates.

That expression used PostgreSQL-only `::boolean` casts:

    CASE WHEN ?::boolean THEN enable::boolean ELSE false END

This is invalid syntax on SQLite (and would break the merge when
the client_traffics delta UPDATE runs — which is commonly triggered
right after an API /inbounds/update because that path calls
updateClientTraffics + SyncInbound and touches client_traffics rows).

Extracted the expression to a new dialect-aware helper
`ClientTrafficEnableMergeExpr()` (following the same pattern as
GreatestExpr, JSONClientsFromInbound, etc.).

- On Postgres: keeps the strict boolean-typed CASE with casts.
- On SQLite: uses a numeric-compatible form
  `CASE WHEN ? THEN enable ELSE 0 END` that produces the expected
  0/1 result matching the column affinity.

The logical behavior ("node may only force-disable, never re-enable")
is preserved on both databases.

This is a follow-up commit on the same branch so that one PR
contains both the original Postgres fix and the SQLite compatibility
fix.

Builds directly on top of 91643f68.

* fix

---------

Co-authored-by: Rqzbeh <rqzbeh@users.noreply.github.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-06-08 14:54:53 +02:00
Sanaei 1ca5924a44 feat(mtproto): add MTProto (FakeTLS) protocol via managed mtg sidecar (#5076)
* feat(mtproto): add MTProto (FakeTLS) protocol via managed mtg sidecar

Xray-core has no mtproto proxy, so mtproto inbounds run as standalone
mtg (9seconds/mtg) sidecar processes managed by the panel — one per
inbound — and are excluded from the generated Xray config entirely.

- model: MTProto protocol constant, validator, and FakeTLS secret
  helpers (GenerateFakeTLSSecret/HealMtprotoSecret)
- mtproto package: per-inbound mtg process manager with reconcile,
  graceful stop, and best-effort Prometheus traffic scraping
- runtime: delegate mtproto inbounds to the mtg manager instead of the
  Xray gRPC API; skip mtproto when building the Xray config
- web: boot reconcile + StopAll wiring, periodic reconcile/traffic job,
  port-conflict transport, secret healing on inbound add/update
- sub: tg:// proxy share-link generation
- frontend: protocol option, Zod schema, Protocol tab (FakeTLS domain +
  regenerable secret), info-modal link, and i18n
- provisioning: fetch mtg v2.2.8 in install.sh, DockerInit.sh, and the
  Linux + Windows release workflows

* fix

* fix

* fix: address Copilot review comments on mtproto PR

- web/web.go: create NewMtprotoJob once and reuse for cron + initial run
- mtproto/manager.go: StopAll cleans up per-inbound config files on shutdown
- mtproto/manager.go: CollectTraffic releases mutex before HTTP scrapes to
  avoid blocking Ensure/Reconcile/Remove during network I/O
- database/model/model.go: panic on crypto/rand failure in mtprotoRandomMiddle
  instead of silently producing a weak all-zero secret
- install.sh: fix chmod to handle renamed bin/mtg-linux-arm on armv5/v6/v7
2026-06-08 14:28:19 +02:00
Turan b0fe21c804 i18n(tr): Improve Turkish translation consistency and terminology (#5066)
Thank you for this great project!

I've made a comprehensive revision of the Turkish translation to improve consistency, grammatical accuracy, and natural flow for Turkish-speaking network administrators.

**Key Improvements:**
- **Unified "Client" Terminology:** Consistently translated as "Kullanıcı" (User) for human accounts and "İstemci" (Client) for software applications throughout the UI and Telegram Bot.
- **Inbounds & Outbounds:** Replaced the literal translations with professional networking terms: "Bağlantı Noktaları" (Inbounds) and "Çıkış Noktaları" (Outbounds).
- **Vowel Harmony Fixes:** Corrected several Turkish grammatical vowel harmony issues (e.g., *kullanıcısi* → *kullanıcısı*, *kullanıcılarini* → *kullanıcılarını*).
- **Capitalization & Phrasing:** Fixed capitalization inconsistencies (e.g., "Son Çevrimiçi") and improved phrasing for terms like "camouflage" → "Maskeleme" and "transport" → "Aktarım".

Technical English terms (SNI, TLS, REALITY, grpc, Vision, etc.) are intentionally kept in English as they are the standard in network engineering. 

Hope this helps the Turkish community!
2026-06-08 09:55:14 +02:00
MHSanaei c6f15cd53f refactor(api)!: move /panel/setting and /panel/xray under /panel/api
Settings and Xray config endpoints now live at /panel/api/setting/* and /panel/api/xray/*, registered under the existing /panel/api group so they inherit the same Bearer-or-session auth (checkAPIAuth) as the rest of the API. An API token is a full-admin credential, so this just makes the surface consistent. The SPA page routes /panel/settings and /panel/xray are unchanged.

BREAKING CHANGE: the old /panel/setting/* and /panel/xray/* paths are removed. External callers must switch to the /panel/api/ prefix. Frontend call sites, API docs, the dev proxy, and the route-documentation test are updated to match.
2026-06-06 16:22:41 +02:00
MHSanaei e56f6c63f6 fix(api-docs): target the panel base path in OpenAPI servers
ServeOpenAPISpec shipped servers:[{url:"/"}], so Swagger UI "Try it out" and external generators hit the origin root and ignored a non-root webBasePath. Inject the runtime base path into the single servers entry at serve time, touching only that field via json.RawMessage so the rest of the spec is preserved verbatim.
2026-06-06 16:22:08 +02:00
MHSanaei 83799d71b0 feat(api-docs): generate response examples from Go structs; fix SS2022 PSK regen (#4996)
Stop hand-writing OpenAPI response examples, which kept drifting from the real payloads (clients/traffic missing fields, inbounds/list exposing userId which is json:"-", the fictional inbound-443 tag instead of the real in-<port>-<transport> form).

tools/openapigen now emits frontend/src/generated/examples.ts: a per-struct example instance built from type defaults, validate oneof/min bounds, and example: struct tags, with nested-ref expansion and a cycle guard. build-openapi.mjs composes the {success,obj} envelope from it for any endpoint annotated with responseSchema (+ responseSchemaArray for lists); the hand-written response is dropped for those. Service DTOs InboundOption/ApiTokenView/ProbeResultUI are added to the walker.

#4996: client password regeneration now produces a valid Shadowsocks 2022 PSK (correct base64 length per cipher) when an SS2022 inbound is attached, in both the single and bulk client forms; backend surfaces ssMethod on /inbounds/options so the UI can pick the right length.

Also: Swagger UI persists the Authorization token across reloads (persistAuthorization).
2026-06-06 14:58:15 +02:00
MHSanaei 1b2a17f7e3 i18n: translate #4988 sockopt/REALITY-target/Freedom strings for all locales
Commit 6ed6f57b (#4988) added tcpWindowClampHint, the four realityTarget* keys, and the three FreedomHappyEyeballs* keys to en-US only. Fill in the other 12 locales so the new sockopt hint, REALITY target validation messages, and Freedom Happy Eyeballs options are localized. Technical tokens (REALITY, Xray-core, IPv4/IPv6, Happy Eyeballs, port examples, ms) are kept literal.
2026-06-06 12:42:30 +02:00
Sanaei e6c1ce9aa9 feat(nodes): multi-hop node attribution for chained sub-nodes (#4983) (#5005)
* feat(nodes): add stable panel GUID identity (multi-hop phase 0)

Per-panel autoincrement node ids are meaningless one hop away, so in a chained topology (Node1 -> Node2 -> Node3) the master cannot attribute online clients or inbounds to the physical node that hosts them (#4983).

Introduce a stable self-identifier: each panel generates and persists a panelGuid (settings table, mirroring GetSecret), returns it in panel/api/server/status, and the master learns it per node via the heartbeat into a new Node.Guid column. Guarded so an old-build node or a failed probe never clears a known GUID. No behavior change yet - this is the identity foundation Phases 1-2 key on.

Refs #4983

* feat(nodes): attribute inbounds to their origin node by GUID (multi-hop phase 1)

Add Inbound.OriginNodeGuid: the GUID of the panel that physically hosts an inbound. Empty means this panel's own xray; set means it was synced from a node. SetRemoteTraffic now fills it per synced inbound - keeping a non-empty value the node forwarded from its own sub-node (so a transitive inbound stays attributed to the deepest node across hops), and otherwise attributing the node's own local inbounds to that node's GUID. Empty (old-build node without a GUID) leaves the existing node_id-based attribution untouched.

The field rides the existing inbound JSON, so /list propagates it up the chain with no serve-side change. Phase 2 will key per-node online off this instead of the panel-local node_id.

Refs #4983

* feat(nodes): key online status by node GUID end-to-end (multi-hop phase 2)

Replace the panel-local node-id keying of per-node online status with the stable panelGuid, so a client several hops down a node chain is attributed to the node that physically hosts it instead of the intermediate node it syncs through (#4983).

xray/process.go stores each direct node's reported GUID-keyed subtree and merges them (correct at any depth); the service assembles GetOnlineClientsByGuid (own clients under this panel's GUID + every node under its GUID). FetchTrafficSnapshot fetches the new /clients/onlinesByGuid, falling back to the flat /onlines for old-build nodes (keyed under the node's GUID or a master-local synthetic id). The node rollup, the WS onlineByGuid/activeInbounds fields, and the inbounds-page rollup all scope by GUID; local inbounds get their OriginNodeGuid filled with the panel's GUID at serve time so the frontend keys uniformly.

Old-build nodes degrade to the prior flat behaviour via the synthetic node:<id> key. Refs #4983

Refs #4983

* feat(nodes): surface transitive sub-nodes on the master (multi-hop phase 3a)

Each panel publishes read-only summaries of the nodes it manages via GET /panel/api/server/descendants (node API token). The heartbeat job caches each direct node's summaries; GetNodeTree merges them as transitive model.Node projections (Id 0, Transitive=true, ParentGuid = their parent node's GUID) and recomputes InboundCount/OnlineCount/DepletedCount per origin GUID so a direct node shows only its own inbounds and each sub-node shows its own (#4983).

The Nodes-page list endpoint and the heartbeat broadcast now return the tree; GetAll stays direct-only for probing/syncing. One transitive level is surfaced (covers Node1->Node2->Node3); deeper recursion is a follow-up. Backend only - the Nodes-page nested UI lands next.

Refs #4983

* feat(nodes): render transitive sub-nodes nested + read-only on the Nodes page (multi-hop phase 3b)

The Nodes page now shows a node's downstream sub-nodes (learned via the descendants tree) as indented, read-only rows ordered right under their parent: no enable toggle, probe, edit, delete, update, selection, or history expander - just a 'Sub-node' tag whose tooltip names the parent it is reached through. Desktop table and mobile cards both handle it. Transitive rows are keyed by GUID (their Id is 0) so they don't collide with real nodes (#4983).

Rows nest by parentGuid rather than AntD tree-children to avoid clashing with the existing per-row history expander. New labels added to en-US (other locales fall back until translated). Refs #4983

Refs #4983

* i18n(nodes): translate subNode/subNodeTip across all locales

Phase 3b added these two Nodes-page keys (read-only sub-node tag + tooltip) only to en-US; fill in the other 12 locales so the multi-hop sub-node UI is fully localized. The {parent} placeholder is preserved in every translation.

Refs #4983
2026-06-06 12:33:39 +02:00
nima1024m 6ed6f57b5c fix(panel): normalize XHTTP/sockopt/Reality wire output and validate REALITY target (#4988)
* fix(panel): normalize XHTTP/sockopt/Reality wire output and validate REALITY target

Strip mode-specific XHTTP fields for stream-one, reset harmful sockopt defaults
to 0, split server/client Reality fields on save, validate target host:port in
the inbound form, and expose Happy Eyeballs for the direct freedom outbound.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(panel): keep REALITY public key on the wire, guard freedom noises

The REALITY server/client wire split deleted realitySettings.settings on save, but the panel stores the REALITY public key there and every share-link / subscription generator reads it back from that path (frontend inbound-link.ts, Go subService/subJsonService/subClashService). Stripping it produced empty pbk= links, breaking client connectivity after save+reload.

Revert the reality normalization (drop normalizeRealityForWire and the key sets), restore the inbound REALITY form fields (uTLS, spiderX, publicKey, mldsa65Verify) while keeping the new validated target field, and restore the mldsa65Verify clear handler.

Also guard freedomToWire against undefined noises/finalRules (same defensive treatment as the existing fragment guard, issue #4686) which the new freedom-outbound test surfaced as a crash. Tests now assert the public key is preserved.

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
2026-06-06 02:40:32 +02:00
MHSanaei e409bc305d fix(iplimit): skip stale access-log emails after client rename/delete
The IP-limit job scrapes the Xray access log, which keeps lines tagged with a client's old email for up to a log-rotation cycle after a rename or delete. For each such email getInboundByEmail (settings LIKE %email%) found nothing, so the job logged 'failed to fetch inbound settings: record not found' every run and recreated an inbound_client_ips row for the dead email (rows reappeared even after manual deletion).

processLogFile now resolves the inbound once per email: if it maps to no inbound (gorm.ErrRecordNotFound) it logs at Debug, drops any orphan tracking row, and skips - so stale entries self-heal instead of spamming ERROR. The resolved inbound is passed into updateInboundClientIps, removing its internal lookup. updateClientTraffics also calls DelClientIPs alongside DelClientStat so a full inbound edit that drops an email doesn't leave a ghost row.

Closes #4963
2026-06-06 02:20:39 +02:00
MHSanaei 75bc6e8076 fix(inbound-form): wrap long labels and shorten RU pinned-cert label
Long TLS-tab labels overflowed their field in locales with wider strings (e.g. Russian 'Pinned Peer Cert SHA-256'). Add AntD labelWrap to the inbound and outbound form modals so any over-long label wraps onto a second line instead of overflowing, and shorten the Russian pinnedPeerCertSha256 label to fit.

Closes #4986
2026-06-06 01:53:46 +02:00
MHSanaei eeb19b7240 fix(node-sync): merge client enable with boolean AND for PostgreSQL
The per-client traffic merge built enable = CASE WHEN ? = 0 THEN 0 ELSE enable END, mixing an integer literal with the boolean enable column. PostgreSQL rejects this with SQLSTATE 42804, aborting every node traffic merge transaction every 5s and freezing all up/down/last_online accounting on Postgres main panels. Replace with enable AND ?, which is type-safe on Postgres (boolean AND boolean) and identical in semantics on SQLite: the node may only disable a client, never re-enable one the panel already disabled.

Closes #4964
2026-06-06 01:46:55 +02:00
MHSanaei a8d5d0dfab fix(external-proxy): relabel "Host" as "Address", add per-entry ECH (#4935)
The external proxy "Host" field was bound to dest (the connection address that becomes the link host) but labeled "Host", misleading users into thinking it set a transport host header. Relabel it to "Address" to match what it actually controls.

Add per-entry ECH (echConfigList) to the external proxy schema, form (shown under Force TLS = TLS), the TS link generator, and the Go sub services: ech is emitted on share links and vmess objects, and written into the stream so the JSON subscription picks it up via the existing tlsData reader.
2026-06-05 10:40:11 +02:00
MHSanaei b40f869f2a fix(node): keep client/inbound edits working when a node is offline (#4923, #4931)
Node-backed client and inbound edits no longer hard-fail when the backing node is offline or disabled. Edits commit to the panel DB immediately and reconcile to the node when it reconnects (eventual consistency); the panel is the single source of truth for desired config.

- Add Node.ConfigDirty/ConfigDirtyAt; mark a node dirty when an edit commits without reaching it (cleared via CAS on ConfigDirtyAt after a full reconcile).
- nodePushPlan() reads node state fresh from the DB, skips the push for offline/disabled nodes (no 10s hang), and treats push failures as non-fatal across every mutation path (client add/update/del + bulk + attach/detach; inbound add/update/del/toggle/resetTraffic).
- ReconcileNode() pushes the panel's desired config to a node on reconnect (refreshing the remote tag cache first) and prunes node-side orphans; runs before the traffic pull in the node sync job.
- While a node is dirty the traffic pull applies only up/down deltas and node-initiated disables, never overwriting desired config from a stale node snapshot.
- Surface a non-blocking 'saved; will sync on reconnect' warning to the UI.

Validated with a two-panel Docker E2E: client delete/update, attach/detach, and inbound add/delete all reconcile correctly offline -> reconnect.
2026-06-05 02:26:57 +02:00
MHSanaei e08456269b fix(traffic): count local traffic for clients whose shared row is node-owned (#4921)
client_traffics is keyed by email (one shared row per client across every
inbound it is attached to). addClientTraffic filtered with
`inbound_id NOT IN (node inbounds)`, so when a client was attached to both a
node inbound and the mother inbound and the node inbound was attached first,
the shared row carried the node inbound's id (AddClientStat uses OnConflict
DoNothing and never refreshes it) and the local xray's traffic for that client
was dropped entirely. The client showed online but its usage stayed at zero
unless the mother inbound happened to be attached first.

Match purely by email instead. The reported emails come only from the local
xray, which only knows local-attached clients, so the query is still correctly
scoped, and this also repairs already-broken rows that a per-row AddClientStat
fix alone could not.
2026-06-05 00:24:01 +02:00
Hamed d6d2085d60 fix: restart remote xray after disabling a client to kill active sessions (#4918)
* fix(node-traffic): restart remote xray after disabling clients to kill active sessions

When a client's traffic limit is reached on a remote node, the panel pushes
enable=false to that node via UpdateInbound. The node calls RemoveUser on its
local xray, which blocks new connections but leaves any already-established TCP
session alive. The user could continue browsing/downloading until they
disconnected voluntarily.

Fix: after successfully pushing a client disable to a remote node, call
RestartXray on that node. This mirrors what already happens for the local node
when the "Restart Xray on client disable" setting is enabled (default: on),
and ensures active sessions are terminated immediately on all nodes where the
client was disabled.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(node): restart remote xray after tx commit, not inside it

Move the remote RestartXray calls out of the addTraffic write
transaction. disableInvalidClients now returns the affected remote
node IDs instead of restarting their xray while the SQLite write lock
is held; AddTraffic performs the restart after the transaction commits
via restartRemoteNodesOnDisable. Avoids holding the serialized write
lock across slow per-node restart RPCs.

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-06-05 00:02:52 +02:00
Hamed 12d84c2a46 fix(node-traffic): prevent stale node snapshot from re-enabling disabled client (#4917)
When a remote node syncs traffic back to the panel, the UPDATE in
setRemoteTrafficLocked wrote cs.Enable directly into client_traffics.enable.
If a snapshot carrying enable=true arrived after the central panel had already
set enable=false (due to the client reaching their traffic limit), it silently
re-enabled the client — letting them consume 2-3x their allotted quota before
the next disable cycle caught up.

Fix: replace the unconditional SET enable = ? with a CASE expression that only
allows a disable (0->0), never a re-enable (0->1). The central panel remains
the sole authority for turning a client back on.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-06-04 23:54:29 +02:00
biohazardous-man 97f88fb1a9 feat(sub): modern xray JSON format with unified finalmask editor (#4912)
* feat(sub): add finalmask support to JSON subscriptions

* feat(sub): modern xray JSON format with unified finalmask editor

Drop the legacy JSON subscription format entirely and always emit the
modern xray shape:

- Flatten proxy outbounds (no vnext/servers) for vless/vmess/trojan/
  shadowsocks; hysteria was already flat.
- Express fragment/noise via streamSettings.finalmask instead of the
  legacy direct_out freedom dialer + dialerProxy sockopt.

The global finalmask (tcp/udp masks + quicParams) is stored as a single
setting (subJsonFinalMask) and merged into every generated stream,
replacing the separate subJsonFragment/subJsonNoises/subJsonQuicParams
settings.

Reuse the existing FinalMaskForm (used by inbound/outbound) for the
settings UI via a small bridge component; add a showAll prop so all
TCP/UDP/QUIC sections render for the global case. This supersedes the
hand-rolled Fragment/Noises/quicParams tabs with the full mask editor
(all mask types).

Note: this is a breaking change — JSON subscriptions now require a
recent xray client on the consumer side.

* fix

---------

Co-authored-by: biohazardous-man <biohazardous-man@users.noreply.github.com>
Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
2026-06-04 23:51:48 +02:00
Misfit-s f947fbd6c6 feat(Clash): Add routing rules and enable routing option for Clash subscriptions (#4904)
* feat(clash): add routing rules and enable routing option for Clash/Mihomo subscriptions

Allows adding custom YAML blocks and placeholders to Clash exports.

Why: Shifting routing to the client prevents server IP exposure for
DIRECT traffic and reduces unnecessary server bandwidth/CPU usage.

* fix

---------

Co-authored-by: Misfit-s <>
2026-06-04 21:55:51 +02:00
康厚超 73ce11508e fix(tgbot): ignore commands for other bots (#4894)
Telegram group chats can contain multiple bots. Commands addressed to another bot, such as /status@other_bot, should not be handled by the 3x-ui bot.

Closes #4893
2026-06-04 21:45:44 +02:00
MHSanaei d3db828b46 perf(clients): scale-audit remaining client/inbound endpoints to 200k
Drive every client/inbound/group endpoint at 100k-200k clients on PostgreSQL and fix the latent issues found in previously-unbenchmarked paths:

- enrichClientStats: chunk the email IN lookup (was an unchunked bind that crashed past 65535 clients without traffic rows, taking down GetInbounds/GetInboundDetail/GetAllInbounds)

- GetOnlineClients: add the missing nil-process guard its siblings already have, so ListPaged no longer panics before xray starts

- GetClientTrafficByEmail: read UUID/subId from the indexed clients table instead of parsing the inbound's full settings JSON (439ms to ~1.5ms, flat in N)

- BulkResetTraffic: replace the per-email serialized loop with one chunked bulk UPDATE in a single transaction

- DelDepleted: delegate to the already-batched BulkDelete instead of deleting each depleted client one by one

Adds a postgres-gated full endpoint sweep plus an A/B benchmark, and SQLite correctness tests for the changed methods.
2026-06-04 21:32:15 +02:00
MHSanaei d1e733b9e9 perf(clients): chunk IN queries and de-quadratic bulk delete/group/list
Bulk client operations bound their entire working set in a single
WHERE x IN (...) clause, which exceeds PostgreSQL's 65535-parameter limit
(and SQLite's 32766) and gives the planner a pathological query, so they
failed outright on inbounds/selections larger than the limit. Every such
query is now chunked at 400 items:

- BulkDelete / delete-all-clients: six IN queries chunked, and the
  per-row delete tombstone (which swept the whole in-memory map on every
  call, O(N^2)) replaced with a single bulk sweep.
- BulkAdjust: record and inbound-mapping lookups chunked.
- AddToGroup / RemoveFromGroup (bulk add/remove to group): three IN
  queries chunked.
- replaceGroupValue (rename/delete group): inbound-mapping lookup chunked.
- List (all-clients listing): link and traffic lookups chunked.

Measured on PostgreSQL 16: delete-all-clients on a 100k-client inbound
now completes in ~7s (previously crashed at the parameter limit); bulk
add/remove to group ~6s and full client list ~1s at 100k.

sync_scale_postgres_test.go adds skip-gated benchmarks for delete-all,
group add/remove, and list.
2026-06-04 20:35:30 +02:00
MHSanaei f185d3315c perf(clients): scale add/delete and bulk client operations
Follow-up to the SyncInbound bulk rewrite, fixing the remaining O(M*N)
and O(M)-round-trip behaviour in the add/delete and bulk paths that made
them time out on large inbounds (worst case minutes), especially on
PostgreSQL.

- compactOrphans: chunk the "email IN (...)" lookup (400/batch) instead
  of binding every email at once. A single huge IN exceeded PostgreSQL's
  65535-parameter limit (and SQLite's) and made the planner pathological,
  so add/delete failed outright past ~100k clients.

- emailsUsedByOtherInbounds: new batched form used by delInboundClients
  (BulkDetach) and bulkDelInboundClients (BulkDelete), replacing a
  per-email global JSON scan (O(M*N)) with one scan, and skipped entirely
  when keepTraffic is set.

- BulkCreate: rewritten to validate/dedup in one pass, then group clients
  by inbound and add them in a single addInboundClient call per inbound
  (one getAllEmailSubIDs, one settings rewrite, one SyncInbound) instead
  of running the full single-create pipeline per client.

- Bulk delete/adjust: batch DelClientStat/DelClientIPs with IN deletes
  and wrap the settings Save + SyncInbound in one transaction, so the
  per-row writes share a single fsync instead of one per row.

Measured on PostgreSQL 16 (one inbound, M=2000 affected clients):
  - create: 8m35s (M=500) -> ~1-5s
  - detach: 52s -> ~4s (flat in N)
  - delete: ~16s -> ~1-4s
  - adjust: ~20s -> ~7-10s
add/delete of a single client on a 200k-client inbound stays in seconds.

sync_scale_postgres_test.go adds skip-gated benchmarks (XUI_DB_TYPE=
postgres) for the single add/delete and the five bulk operations.
2026-06-04 19:41:00 +02:00
MHSanaei 756746dbca perf(clients): make SyncInbound bulk to fix large-inbound timeouts (#4885)
Every client mutation funnels through SyncInbound, which ran O(n) DB
round-trips per call: one SELECT per client, a Save+UpdateColumn per
client, and a per-row junction INSERT. Toggling a single client on a
large inbound issued thousands of queries and timed out, badly so on
PostgreSQL where each round-trip pays TCP latency.

SyncInbound now:
- loads existing records with a single chunked SELECT ... email IN (...)
  instead of one query per client
- writes only the records that actually changed (skips no-op Saves), so
  toggling/editing one client writes one row, not all of them
- batch-creates new records and batch-inserts the junction rows

Merge and sticky-field semantics are unchanged. Measured on PostgreSQL
16: a single-client toggle on a 50k-client inbound drops from ~8m54s to
~0.9s, and seeding 50k clients from ~2m48s to ~1.6s; 200k clients sync
in seconds.

A skip-gated benchmark (web/service/sync_scale_postgres_test.go, run
with XUI_DB_TYPE=postgres) reproduces and verifies the scaling.
2026-06-04 18:14:25 +02:00
MHSanaei db86007ab8 fix(multi-node): scope remote client update/delete to one inbound (#4892)
UpdateUser and DeleteUser hit the node's email-based full-client endpoints, which fanned out to every inbound the client had on the node: editing a client wiped flow on the node's other inbounds, and detaching one node inbound deleted the client from all of them.

Make both inbound-scoped, mirroring AddClient. DeleteUser now detaches the resolved remote inbound id; UpdateUser passes an inboundIds scope so the node updates only that inbound.
2026-06-04 16:45:40 +02:00
MHSanaei a07c7b7f4e feat(migrate-db): SQLite <-> .dump conversion and Download Migration in Overview
Binary: extend the migrate-db subcommand with --dump and --restore so a
SQLite database can be exported to a portable SQL text dump and rebuilt from
one, alongside the existing --dsn PostgreSQL copy. Implemented in Go via the
bundled sqlite driver (new database/dump_sqlite.go); no external sqlite3 client
is required. Add ExportPostgresToSQLite (reverse of MigrateData) to build a
SQLite .db from live PostgreSQL data, reusing the shared copyAllModels helper.

Overview: add a "Download Migration" item to Backup & Restore plus a
getMigration endpoint/service that returns a .dump on SQLite or a .db on
PostgreSQL, so the data can seed a panel on the other backend. Document the
endpoint in api-docs and translate the three new strings across all locales.

Tests: cover the destination-side copy (AutoMigrate + copyTable into SQLite)
and the dump/restore round-trip including quoted values. Ignore *.dump.

The x-ui.sh helper that drives this from the CLI is in PR #4910.
2026-06-04 15:32:22 +02:00
MHSanaei 4813a2fe00 fix(api-token): hash tokens at rest and show plaintext only once
Store API tokens as SHA-256 hashes instead of plaintext and return the token value only in the create response. List no longer exposes the token, and the UI drops the Show/Copy buttons in favor of a one-time reveal modal at creation.

Match hashes the presented bearer token before the constant-time compare, and a migration hashes any pre-existing plaintext rows in place so existing tokens keep authenticating. Docs and translations updated.
2026-06-03 22:57:50 +02:00
MHSanaei 7a72aeda7a i18n: translate connection-limit strings for all languages
Adds connectionLimits/connIdle/bufferSize/seconds keys to the remaining 11 locales (ar, es, id, ja, pt, ru, tr, uk, vi, zh-CN, zh-TW); en-US and fa-IR shipped with the feature.
2026-06-03 21:59:40 +02:00
MHSanaei ceef413dc4 feat(xray): add connIdle and bufferSize policy controls
Expose level-0 connection policies in the panel's Basics tab: idle timeout (connIdle) and per-connection buffer size (bufferSize). Empty fields delete the key so Xray falls back to its own defaults. Adds en-US/fa-IR strings and types policy.levels in the Zod schema.
2026-06-03 21:52:37 +02:00
MHSanaei 55d6729955 fix(nodes): Set Cert from Panel uses the node's own web cert for node inbounds
For an inbound deployed to a node, the button read the central panel's webCertFile/webKeyFile and inserted paths that don't exist on the node, crashing the node's Xray on startup.

Add a token-accessible GET /panel/api/server/getWebCertFiles that returns a panel's own web cert/key paths, Remote.GetWebCertFiles to fetch it from a node, and GET /panel/api/nodes/webCert/:id to proxy it. setCertFromPanel now calls the node endpoint for a node-assigned inbound and the local settings otherwise, warning instead of inserting wrong paths on error/empty.

Fixes #4854
2026-06-03 16:41:02 +02:00
MHSanaei ef8882a5c0 fix(online): scope per-inbound online to inbounds that carried traffic
Multi-inbound clients showed online on every inbound they were attached to. Xray's user-level traffic stat aggregates across all inbounds a client belongs to, so the email signal alone can't say which inbound was used.

Pair it with the inbound-level traffic signal under the same 20s grace and gate the per-inbound rollup on it: a client only shows online on inbounds that actually moved bytes this window. Remote nodes report no per-inbound activity and stay ungated (no regression). Adds GetActiveInboundsByNode, the activeInbounds WS field and POST /panel/api/clients/activeInbounds.

Fixes #4859
2026-06-03 16:19:00 +02:00
MHSanaei 039d05a743 fix(ci): bump Go to 1.26.4 and exempt /panel/groups SPA route from api-docs test
- Bump go directive to 1.26.4 to pick up stdlib security fixes in
  crypto/x509, mime and net/textproto flagged by govulncheck
- Add /panel/groups to the api_docs_test SPA-page allowlist so the
  UI page route is not treated as an undocumented API endpoint
- go.sum carries pgx/v5 v5.10.0 bump
2026-06-03 15:38:44 +02:00
MHSanaei db5ce06256 fix(panel-proxy): route custom geo and http(s) Telegram through panelProxy
Custom geosite/geoip downloads built their own ssrfSafeTransport and never used the configured Panel Network Proxy, so geo updates failed on servers where GitHub is filtered. Route all custom-geo HTTP (startup probes + downloads) through panelProxy when set, falling back to the direct SSRF-guarded transport otherwise; the target URL stays SSRF-validated.

The Telegram bot only honored a socks5:// panel proxy and silently rejected http(s)://, despite the setting advertising both. Branch the fasthttp dialer (FasthttpHTTPDialer for http(s), FasthttpSocksDialer for socks5) and accept all three schemes in the fallback and NewBot validation.

Add tests proving the panel proxy is used by custom geo and that the bot dialer speaks HTTP CONNECT vs SOCKS5 per scheme.
2026-06-03 14:57:49 +02:00
MHSanaei e7c11c913a feat(inbounds): per-proxy Pinned Peer Cert SHA-256 + labeled External Proxy form
Redesign the Add Inbound -> Stream External Proxy section into labeled per-entry cards (Force TLS / Host / Port / Remark and, under TLS, SNI / Fingerprint / ALPN) and add a Pinned Peer Cert SHA-256 field with a generate-random-hash button to each entry.

The pin flows end to end into share links: pcs for vmess/vless/trojan/ss (stripped when a proxy forces security off) and the hex-normalized pinSHA256 for Hysteria. JSON and Clash subscriptions emit the native pinnedPeerCertSha256 / pin-sha256 via the cloned stream. Adds the forceTls label across all 13 locales plus frontend and Go tests.
2026-06-03 13:46:54 +02:00
MHSanaei df7ccd3a64 fix(clients): use client_inbounds link to resolve inbound, not stale id
client_traffics.inbound_id is a legacy single-inbound pointer that goes stale when an inbound is deleted and recreated: the email-keyed traffic row survives but references a missing inbound. Code that resolved the owning inbound from it broke several client operations.

- adjustTraffics: 'Start After First Use' (negative expiry) never converted to an absolute deadline on first traffic, so the countdown never started. Now resolves inbounds via the client_inbounds link and computes the new expiry once per email so multi-inbound clients stay consistent.

- GetClientInboundByEmail / GetClientInboundByTrafficID: fall back to client_inbounds when the pointer is dead, fixing reset traffic ('record not found'), client info, and Telegram set-tgId.

- autoRenewClients: resolve renew targets via client_inbounds so scheduled renews are not silently skipped.

- clients page: allow resetting a client with no inbound attachment (the backend already zeroes counters by email).

Add regression test for the delayed-start conversion under a stale inbound_id.
2026-06-03 13:42:32 +02:00
MHSanaei d4c020f365 feat(dashboard): more System History metrics, persistence & localized labels
- Sample swap %, TCP/UDP connection counts and disk-usage % on the host ticker
- System History: Swap overlaid on the RAM tab, plus new Connections and Disk Usage tabs
- Persist the host time-series across restarts: gob snapshot beside the DB, written on a timer and at shutdown, restored on boot
- Live-refresh the open chart (2s for short ranges, 10s for longer)
- Localize CPU/RAM/Swap and the new tab/chart titles across all 13 languages and route legend series names through i18n
2026-06-03 12:16:31 +02:00
MHSanaei 4b11c54206 feat(dashboard): richer System History & Xray Metrics charts
- Collect disk read/write and network packet-rate metrics on the host sampler
- Sparkline: optional 2nd/3rd overlaid series with a colored legend
- System History: merge Bandwidth (up/down), Disk I/O (read/write) and Load (1m/5m/15m) into single multi-line tabs
- Add a descriptive per-chart title and mobile-only tab icons to both modals
- Localize every chart title and tab label across all 13 languages
2026-06-03 11:25:45 +02:00
MHSanaei e63cde8fcb feat(settings): move the remark model control to the subscription tab
Relocate Remark Model & Separation Character from the General/Panel tab to the Subscription tab's Information section, beside Show Info and Email in Remark, since it only governs how share-link remarks are composed. The sample preview uses concrete example values and renders the separator literally.

Also drop the port from the subscription page link rows so each row shows just the inbound remark; the port still appears in the client QR modal and the client info modal.
2026-06-03 02:45:16 +02:00
MHSanaei ccfd04219b fix(panel): register /groups SPA route so hard refresh returns index.html
The frontend has a groups page route and sidebar entry, but the backend
never registered a GET handler for /panel/groups. A hard browser refresh
on that page fell through to the 404 handler. Add the missing panelSPA
registration alongside the other page routes.

Fixes #4837
2026-06-03 02:17:56 +02:00
MHSanaei b08fc0c963 fix(clients): keep reverse tag clearable and preserve flow on attach
Two multi-inbound client bugs from issue #4834:

- Clearing a client's reverse tag never persisted: SyncInbound keeps a non-empty sticky guard on reverse (shared with node-sync/rename), so the cleared value never reached the canonical clients.reverse column the edit form reads. Update now writes that column authoritatively from the submitted client, matching how it already writes email/updated_at directly.

- Attaching a new inbound reset xtls-rprx-vision: Attach seeded its wire client from the canonical clients.flow column, which a non-flow inbound can zero during the preceding update. It now derives the flow from EffectiveFlow (the per-inbound flow_override), so flow-capable targets keep the flow and others stay empty.

Adds service tests for both paths and a guard test confirming node-snapshot sync still preserves a stored reverse tag.
2026-06-02 23:47:03 +02:00
MHSanaei 6ee462ac8e fix(links): use configured domain for panel copy/QR links on loopback
The panel's copy/QR share links are built client-side and fell back to window.location.hostname, so reaching the panel over an SSH tunnel (127.0.0.1/localhost) leaked localhost into the links - unlike the backend subscription path, which falls back to the configured Sub/Web Domain (issue #4829).

Expose webDomain/subDomain via /defaultSettings and add preferPublicHost: when the browser host is loopback, prefer the configured Sub Domain (then Web Domain) for share/QR links. An explicit node override or per-inbound listen still wins; a routable browser host is kept as-is.

Closes #4829
2026-06-02 22:52:44 +02:00
MHSanaei fcc6787a64 fix(settings): fall back to defaults for empty/NULL setting values
A setting row whose value column is empty or NULL (seen on some migrated databases) was parsed directly, so getInt/getBool and the GetAllSetting reflection path crashed with 'strconv.Atoi: parsing "": invalid syntax'. This made the Inbounds page (/defaultSettings -> GetPageSize) and the Settings page fail to load.

Treat an empty stored value the same as a missing row and fall back to the built-in default at the int/bool parse sites. String getters are unchanged, so legitimately-empty string settings stay empty.

Closes #4830
2026-06-02 22:26:22 +02:00
MHSanaei 3af2da0142 fix(online): scope online status per node instead of a global union
The inbounds page and Nodes page checked each client's email against a
single deduped union of every node's online clients, so a client connected
to one node showed as online on every inbound across every node. The local
online set was also derived from the email-keyed client_traffics.last_online
column, which remote-node syncs bump too, leaking remote-only clients onto
local inbounds.

Track online clients per node: the local panel's own xray clients under key
0 (derived from live traffic-poll deltas via RefreshLocalOnline, kept in
memory and independent of the shared last_online column) and each remote
node under its id. Add GetOnlineClientsByNode plus a /clients/onlinesByNode
endpoint and onlineByNode WS field; node.go and the inbounds rollup now scope
online by node. The flat GetOnlineClients union is kept for client-centric and
total-count views (Clients page, dashboard, telegram).

Closes #4809
2026-06-02 18:33:21 +02:00
MHSanaei 8f5a7b9434 fix(xray): default freedom finalRules to allow-all so reverse egress works
xray-core >=26.5 makes the freedom finalRules context-aware: reverse-proxy traffic defaults to "block all targets". The template seeded finalRules with only allow geoip:private, so a bridge could not exit to WAN and reverse proxy silently broke

Switch the default direct freedom to a no-condition allow rule, the documented way to restore pre-policy behavior. Unlike an ip-based rule (0.0.0.0/0 or !geoip:private), it does not force per-connection OS DNS resolution under domainStrategy AsIs, so happyEyeballs/AsIs pass-through stay intact. LAN is still blocked by the geoip:private->blocked routing rule, and removing that rule still regains LAN access
Note: only affects new configs; existing installs keep their stored finalRules until reset or a follow-up migration.
2026-06-02 15:58:48 +02:00
MHSanaei 1e3c186b2c fix(clients): derive edit-form flow from per-inbound override
SyncInbound runs once per inbound and unconditionally overwrites the canonical clients.Flow column. A non-flow inbound (Hysteria, WS, gRPC) strips flow to "", so when it syncs after a VLESS Reality inbound the column is wiped, and the hydrate endpoint returned that empty value — the edit form loaded a blank flow for multi-inbound clients (#4792).

Derive the hydrate flow from the first flow-capable client_inbounds.flow_override instead, which is always correct and order-independent. A non-empty guard in SyncInbound was rejected because it would make flow impossible to clear.

Closes #4792
2026-06-02 15:32:48 +02:00