Compare commits

...

23 Commits

Author SHA1 Message Date
Sanaei f8e89cc848 fix(mtproto): reap orphaned mtg, fix SysLog viewer, mtg log visibility, export remark (#5105) (#5107)
* fix(logs): render journalctl output in the SysLog viewer

The log viewer's parseLogLine only understood the app-log format
(2006/01/02 15:04:05 LEVEL - body). With SysLog ticked the backend
returns journalctl lines (Mon DD HH:MM:SS host ident[pid]: LEVEL - body),
so the parser mistook the journal time for the level and dropped the
body, leaving only timestamps. Detect and strip the journald prefix,
keep the journal timestamp as the stamp, then parse the real level and
body from the remainder.

* feat(mtproto): surface mtg output and add status reporting

mtg's stdout/stderr was captured by a writer that kept only the last
line and showed it nowhere, so the reason a proxy could not reach
Telegram was invisible. Stream mtg output line-by-line into the x-ui
log, tagged per inbound, so it appears in the panel log viewer and
journald.

Also fix mangled log lines: logger.Info uses fmt.Sprint, which drops
the space between adjacent string operands, producing output like
'inbound3on0.0.0.0:8443'. Switch the affected mtproto calls to the
formatted (*f) variants.

Add show_mtproto_status to x-ui.sh so 'x-ui status' reports each
mtproto inbound's mtg process state and bind address.

* fix(logs): parse all journalctl message shapes in SysLog viewer

Real journalctl output mixes four message shapes after the
'Mon DD HH:MM:SS host ident[pid]:' prefix: go-logging 'LEVEL - msg'
(x-ui/xray), Go std-log with an embedded date (net/http, runtime),
telego's '[timestamp] LEVEL msg', and systemd lines. The viewer only
understood the first, so std-log and telego lines — which never contain
' - ' — collapsed to a bare timestamp (e.g. the 8s telego 409 spam).

Extract the parser into a pure, testable module and teach it the other
shapes: strip the redundant Go std-log date, lift the level out of
telego brackets, and always keep the message body. Add a unit test
covering each shape with real captured lines.

* fix(mtproto): reap orphaned mtg sidecars so a stale one can't break new clients

On Linux x-ui does not kill its mtg children when it dies (no kill-on-exit,
unlike the Windows job object). After a crash, OOM, kill -9, or update, a
stale mtg keeps holding the inbound port with an OLD secret, so new clients
fail the FakeTLS handshake and get silently domain-fronted to the fakeTLS
domain instead of proxied to Telegram (a few MB of traffic, never connects).

Sweep orphans at startup: on the first reconcile, before x-ui starts any of
its own mtg, scan /proc and SIGKILL any process whose executable is our
mtg-<goos>-<goarch> binary. x-ui is the sole owner of mtg, so anything alive
then is an orphan. Runs once per process (swept guard), survives the
binary-deleted-during-update case via /proc/<pid>/cmdline, and is a no-op on
Windows (job object) and other platforms.

Also clear stray mtg in update.sh/install.sh after stopping x-ui, anchored to
the 'mtg-linux-<arch> run ' invocation so the pattern can't match unrelated
command lines (e.g. x-ui.sh's own 'grep mtg-linux').

* fix(logs): drop dead body initializer flagged by eslint no-useless-assignment

* fix(mtproto): drop remark fragment from tg://proxy export link

The mtproto export link appended the inbound remark as a URL fragment
(tg://proxy?server=...&port=...&secret=...#remark). Telegram Desktop
rejects a proxy deep link with a trailing fragment as 'This proxy link
is invalid', breaking one-click import, and a remark is meaningless for
proxy links across clients. Stop adding it in both the panel link
(genMtprotoLink) and the subscription service. Fixes #5105.

* fix(x-ui.sh): remove unused check_mtproto_status helper

show_mtproto_status does its own process check, so check_mtproto_status
was dead code. Drop it (per Copilot review on #5107).
2026-06-09 04:01:33 +02:00
Sanaei 9711a9ce22 v3.3.0 2026-06-09 01:49:59 +02:00
Sanaei 9acde8da9d Bump frontend version and deps
Update frontend package.json and refresh dependencies for a new release (frontend version -> 0.3.0). Regenerated lockfile and upgraded multiple JS packages (notably @swagger-api/apidom family, @rc-component packages, codemirror, etc.) and added libc metadata where applicable. Also update Go module dependencies (go.mod and go.sum) as part of routine dependency maintenance.
2026-06-09 01:49:49 +02:00
Rouzbeh† d9ccf157c3 feat: add manual and automatic WARP IP rotation (#5099)
* feat: add manual and automatic WARP IP rotation

* fix: update generated api and frontend schemas

* fix(warp): validate rotation interval, fix auto-update timing, sync editor

- Validate the auto-update interval as an integer and store it via setInt;
  a non-integer value previously broke GetAllSetting for the whole panel.
- Seed warpLastUpdate when the interval is saved and when changing IP
  manually, so auto-update counts from "now" instead of epoch 0 and a
  manual rotation doesn't trigger an immediate scheduled one.
- Guard WarpIpJob: when lastUpdate is unset, establish a baseline and skip
  instead of rotating on the next tick.
- Log WARP license re-apply failures instead of swallowing them.
- After a manual "Change IP", sync the in-memory Xray editor with the keys
  the backend persisted so a later template save can't revert them; only
  toast success when the interval save actually succeeds.
- Add the WARP rotation UI strings to all 13 locales.
- Drop trailing whitespace introduced in entity.go and xray_setting.go.

---------

Co-authored-by: Rqzbeh <Rqzbeh@example.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-06-09 01:43:43 +02:00
Rouzbeh† be8bd4e22c fix: propagate inbound traffic reset to nodes (#5103)
Co-authored-by: Rqzbeh <Rqzbeh@example.com>
2026-06-09 01:26:30 +02:00
Vladimir Avtsenov 5a7de02598 fix(ui): remove pointer cursor from non-interactive elements in cards (#5102) 2026-06-09 01:02:11 +02:00
Rouzbeh† a32c6803da fix: route WARP API requests through panel proxy (#5101)
Co-authored-by: Rqzbeh <Rqzbeh@example.com>
2026-06-09 01:01:25 +02:00
Rouzbeh† 9f31d7d056 feat: synchronize access.log client IPs across nodes (#5098)
* feat: synchronize access.log client IPs across nodes for global fail2ban limits

* fix(nodes): harden cross-node client-IP merge for cluster fail2ban

MergeInboundClientIps inserted new rows with the remote node's primary key,
which collides with the independently auto-incremented local id and rolled
back the whole sync batch — breaking exactly the node-only clients the
feature targets. It also never evicted stale IPs, so the 30-minute cutoff
was defeated cluster-wide (the master pushed its unpruned table back to
nodes, which re-added IPs they had just pruned) and the blobs grew unbounded.

- drop the remote id on create (Id=0) and guard the email-unique race with
  ON CONFLICT DO NOTHING; also fixes a latent Postgres sequence collision
- apply the same 30-minute stale cutoff inside the merge and skip creating
  node-only rows whose IPs are all stale
- throttle the IP fetch/merge/push to ~10s (data only refreshes every 10s)
  instead of running on every 5s traffic tick, cutting SQLite write churn
- log the load error on the push path and tidy the merge response message
- add unit tests for the merge (remote-id, dedup, stale-drop, skips)

---------

Co-authored-by: Rqzbeh <Rqzbeh@example.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-06-09 00:59:50 +02:00
Turan 0d7b6872f7 docs(i18n): refine Turkish translation and network terminology (#5092)
* Add files via upload

* Delete README.md

* Add files via upload

* Delete README_EN.md

* docs(i18n): refine Turkish translation and network terminology
2026-06-08 23:56:13 +02:00
吉姆·塞尔夫 7d908834a8 fix(ui): correct inline style syntax in client counts column on inbounds page (#5097) 2026-06-08 23:54:18 +02:00
Sanaei 1fa51cf0f2 feat(groups): show used traffic per group in groups table
Sum up+down across each group's clients via a LEFT JOIN on email in
ListGroups, expose it as trafficUsed on GroupSummary, and render it as a
new column plus a "Total traffic" summary card. Drops the unused "Empty
groups" card and its translation key.
2026-06-08 23:47:59 +02:00
Sanaei b24b8524b6 fix(inbounds): drop unknown nodeId when importing an inbound
Importing an inbound that was exported from another panel (or whose node
was later deleted) carried a non-zero nodeId referencing a node that does
not exist on the importing panel. AddInbound -> nodePushPlan -> NodeSyncState
looked that node up and returned gorm's "record not found", so the import
failed with "Something went wrong Failed: record not found".

Node IDs are panel-local and not portable, so the import handler now drops a
nodeId that does not exist on this panel (new NodeService.NodeExists helper),
importing it as a local inbound instead of erroring. This mirrors the existing
nodeId==0 normalization in the same handler.
2026-06-08 23:04:19 +02:00
Sanaei 8ce61f3cb0 fix(script): revoke also removes cert files and acme.sh tracking (#5009)
The SSL Certificate Management "Revoke" option only called acme.sh --revoke,
leaving local files under /root/cert/<domain>, acme.sh renewal state under
~/.acme.sh/<domain>, and the panel cert paths in the DB. Renamed to
"Revoke & Remove": it now also drops acme.sh tracking, deletes the local cert
directory and acme.sh state dirs (RSA + ECC), resolves the real IP address(es)
for IP certs so their renewal state is torn down too, and clears the panel cert
paths (x-ui cert -reset) + restarts when the deleted domain was in use.
2026-06-08 22:53:56 +02:00
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
nima1024m e8171ab4f7 fix(xray): sync routing rules when outbound tag is renamed (#5006)
* chore: ignore local .cursor directory

* fix(xray): sync routing rules when outbound tag is renamed

Renaming an outbound in the Outbounds tab only updated the outbound list, leaving routing rules pointing at the old tag. Propagate tag changes to routing rules, balancer selectors, and sockopt dialerProxy references, matching the behavior already used for balancer and WARP/Nord renames.

* test: mock HttpUtil to fix unhandled vitest rejections

* test(frontend): mock axios globally to prevent flaky network errors on CI

* test(frontend): fix eslint any errors in component test setup

---------

Co-authored-by: Rqzbeh <rqzbeh@users.noreply.github.com>
2026-06-08 20:30:41 +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
jq 46684dd164 fix(sub): emit VLESS encryption in Clash configs (#5053)
Co-authored-by: jq <fs187438@gmail.com>
2026-06-08 14:39:54 +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
Sanaei af3c808444 fix: default hysteria tls to no utls fingerprint 2026-06-08 13:15:51 +02:00
126 changed files with 8571 additions and 1334 deletions
+19
View File
@@ -150,6 +150,16 @@ jobs:
wget -q -O geoip_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat
wget -q -O geosite_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat
mv xray xray-linux-${{ matrix.platform }}
# mtg (MTProto sidecar) - only for arches mtg publishes
MTG_VER="2.2.8"
case "${{ matrix.platform }}" in
amd64|arm64|armv7|armv6|386)
wget -q "https://github.com/9seconds/mtg/releases/download/v${MTG_VER}/mtg-${MTG_VER}-linux-${{ matrix.platform }}.tar.gz"
tar -xzf "mtg-${MTG_VER}-linux-${{ matrix.platform }}.tar.gz"
mv "mtg-${MTG_VER}-linux-${{ matrix.platform }}/mtg" "mtg-linux-${{ matrix.platform }}" 2>/dev/null || mv mtg "mtg-linux-${{ matrix.platform }}"
rm -rf "mtg-${MTG_VER}-linux-${{ matrix.platform }}" "mtg-${MTG_VER}-linux-${{ matrix.platform }}.tar.gz"
;;
esac
cd ../..
- name: Package
@@ -258,6 +268,15 @@ jobs:
Invoke-WebRequest -Uri "https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat" -OutFile "geoip_RU.dat"
Invoke-WebRequest -Uri "https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat" -OutFile "geosite_RU.dat"
Rename-Item xray.exe xray-windows-amd64.exe
# Download mtg (MTProto sidecar) for Windows
$MTG_VER = "2.2.8"
Invoke-WebRequest -Uri "https://github.com/9seconds/mtg/releases/download/v$MTG_VER/mtg-$MTG_VER-windows-amd64.zip" -OutFile "mtg-windows-amd64.zip"
Expand-Archive -Path "mtg-windows-amd64.zip" -DestinationPath "mtg-tmp"
$mtgExe = Get-ChildItem -Path "mtg-tmp" -Recurse -Filter "mtg.exe" | Select-Object -First 1
Move-Item $mtgExe.FullName "mtg-windows-amd64.exe"
Remove-Item "mtg-windows-amd64.zip", "mtg-tmp" -Recurse -Force
cd ..
Copy-Item -Path ..\windows_files\* -Destination . -Recurse
cd ..
+1
View File
@@ -1,6 +1,7 @@
# Ignore editor and IDE settings
.idea/
.vscode/
.cursor/
.claude/
.cache/
.sync*
+1 -1
View File
@@ -162,7 +162,7 @@ Locale strings live in `web/translation/<locale>.json`, **not** under `frontend/
| 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/setting/*`, `/panel/xray/*`, and the WebSocket to the Go panel. Because routing is now client-side, new panel routes need no proxy or allowlist changes.
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.
+12
View File
@@ -3,34 +3,46 @@ case $1 in
amd64)
ARCH="64"
FNAME="amd64"
MTG_ARCH="amd64"
;;
i386)
ARCH="32"
FNAME="i386"
MTG_ARCH="386"
;;
armv8 | arm64 | aarch64)
ARCH="arm64-v8a"
FNAME="arm64"
MTG_ARCH="arm64"
;;
armv7 | arm | arm32)
ARCH="arm32-v7a"
FNAME="arm32"
MTG_ARCH="armv7"
;;
armv6)
ARCH="arm32-v6"
FNAME="armv6"
MTG_ARCH="armv6"
;;
*)
ARCH="64"
FNAME="amd64"
MTG_ARCH="amd64"
;;
esac
MTG_VER="2.2.8"
mkdir -p build/bin
cd build/bin
curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.6.1/Xray-linux-${ARCH}.zip"
unzip "Xray-linux-${ARCH}.zip"
rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat
mv xray "xray-linux-${FNAME}"
curl -sfLRO "https://github.com/9seconds/mtg/releases/download/v${MTG_VER}/mtg-${MTG_VER}-linux-${MTG_ARCH}.tar.gz"
tar -xzf "mtg-${MTG_VER}-linux-${MTG_ARCH}.tar.gz"
mv "mtg-${MTG_VER}-linux-${MTG_ARCH}/mtg" "mtg-linux-${FNAME}" 2>/dev/null || mv mtg "mtg-linux-${FNAME}"
rm -rf "mtg-${MTG_VER}-linux-${MTG_ARCH}" "mtg-${MTG_VER}-linux-${MTG_ARCH}.tar.gz"
chmod +x "mtg-linux-${FNAME}"
curl -sfLRO https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat
curl -sfLRO https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat
curl -sfLRo geoip_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat
+1 -1
View File
@@ -1,4 +1,4 @@
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md) | [Türkçe](/README.tr_TR.md)
<p align="center">
<picture>
+47 -47
View File
@@ -17,28 +17,28 @@
<a href="https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3"><img src="https://goreportcard.com/badge/github.com/mhsanaei/3x-ui/v3" alt="Go Report Card"></a>
</p>
**3X-UI**, [Xray-core](https://github.com/XTLS/Xray-core) sunucularını yönetmek için geliştirilmiş, gelişmiş ve açık kaynaklı bir web kontrol panelidir. Tek bir VPS'den çok düğümlü (multi-node) kurulumlara kadar çok çeşitli proxy ve VPN protokollerini kurmak, yapılandırmak ve izlemek için temiz, çok dilli bir arayüz sağlar.
**3X-UI**, [Xray-core](https://github.com/XTLS/Xray-core) sunucularını yönetmek için geliştirilmiş profesyonel, açık kaynaklı bir web kontrol panelidir. Tek bir sanal sunucudan (VPS) çok düğümlü (multi-node) dağıtımlara kadar çok çeşitli proxy ve VPN protokollerini kurmak, yapılandırmak ve izlemek için temiz, çok dilli bir arayüz sağlar.
Orijinal X-UI projesinin geliştirilmiş bir çatalı (fork) olarak inşa edilen 3X-UI; daha geniş protokol desteği, iyileştirilmiş kararlılık, kullanıcı başına (per-client) trafik takibi ve birçok yaşam kalitesi (QoL) özelliği ekler.
Orijinal X-UI projesinin geliştirilmiş bir çatallaması (fork) olarak inşa edilen 3X-UI; çok daha geniş protokol desteği, artırılmış kararlılık, kullanıcı başına trafik hesaplama ve kullanım kolaylığı sağlayan birçok yeni özellik sunar.
> [!IMPORTANT]
> Bu proje yalnızca kişisel kullanım içindir. Lütfen yasa dışı amaçlarla veya üretim (production) ortamında kullanmayın.
> Bu proje yalnızca kişisel kullanım için tasarlanmıştır. Lütfen yasadışı amaçlar için veya üretim (production) ortamında kullanmayın.
## Özellikler
- **Çoklu protokol destekli bağlantı noktaları (Inbounds)** — VLESS, VMess, Trojan, Shadowsocks, WireGuard, Hysteria2, HTTP, SOCKS (Mixed), Dokodemo-door / Tunnel ve TUN.
- **Modern aktarım (Transport) & güvenlik** — TCP (Raw), mKCP, WebSocket, gRPC, HTTPUpgrade ve XHTTP; TLS, XTLS ve REALITY ile güvenli hale getirilmiştir.
- **Yedek bağlantılar (Fallbacks)** — Xray'in fallback desteğini kullanarak tek bir port (örn. 443) üzerinden birden fazla protokol (örn. VLESS ve Trojan) sunma.
- **Kullanıcı bazlı yönetim** — Trafik kotaları, son kullanma tarihleri, IP sınırları, canlı çevrimiçi (online) durumu ve tek tıklamayla paylaşım bağlantıları, QR kodları ve abonelikler.
- **Trafik istatistikleri** — Bağlantı noktası, kullanıcı ve çıkış noktası (outbound) bazında istatistikler ve sıfırlama kontrolleri.
- **Çoklu düğüm (Multi-node) desteği** — Tek bir panelden birden fazla sunucuyu yönetin ve ölçeklendirin.
- **Çıkış noktaları & yönlendirme (Outbound & Routing)** — WARP, NordVPN, özel yönlendirme kuralları, yük dengeleyiciler (load balancers) ve çıkış noktası proxy zincirleme.
- **Dahili abonelik sunucusu** (Birden fazla çıktı formatıyla).
- **Telegram botu** (Uzaktan izleme ve yönetim için).
- **RESTful API** (Panel içi Swagger dokümantasyonu ile).
- **Esnek veritabanı** — SQLite (varsayılan) veya PostgreSQL.
- **13 Kullanıcı Arayüzü (UI) dili** (Karanlık ve aydınlık tema destekli).
- **Fail2ban entegrasyonu** (Kullanıcı bazlı IP sınırlarını zorlamak için).
- **Çoklu protokol destekli gelen bağlantılar (Inbounds)** — VLESS, VMess, Trojan, Shadowsocks, WireGuard, Hysteria2, HTTP, SOCKS (Karma), Dokodemo-door / Tunnel ve TUN.
- **Modern aktarımlar (transports) ve güvenlik** — TCP (Raw), mKCP, WebSocket, gRPC, HTTPUpgrade ve XHTTP; TLS, XTLS ve REALITY ile güvene alınmıştır.
- **Geri Dönüş (Fallbacks)** — Xray'in fallback desteğini kullanarak tek bir port üzerinde birden fazla protokole (ör. 443 üzerinde hem VLESS hem Trojan) hizmet verin.
- **Kullanıcı başına yönetim** — Trafik kotaları, bitiş tarihleri, IP sınırları, canlı çevrimiçi (online) durumu ve tek tıkla paylaşım bağlantıları, QR kodları ve abonelikler.
- **Trafik istatistikleri** — Gelen bağlantı (Inbound), istemci ve giden bağlantı (Outbound) bazında istatistikler ve sıfırlama kontrolleri.
- **Çoklu düğüm (Multi-node) desteği** — Tek bir panel üzerinden birden fazla sunucuyu yönetin ve ölçeklendirin.
- **Giden bağlantı (Outbound) ve yönlendirme** — WARP, NordVPN, özel yönlendirme kuralları, yük dengeleyiciler (load balancers) ve giden bağlantı proxy zincirleme (proxy chaining).
- **Dahili abonelik sunucusu** (Birden fazla çıktı formatı ile).
- Uzaktan izleme ve yönetim için **Telegram botu**.
- Panel içi Swagger dokümantasyonuna sahip **RESTful API**.
- **Esnek depolama** — SQLite (varsayılan) veya PostgreSQL.
- Koyu ve açık tema seçenekleriyle **13 farklı UI dili**.
- Kullanıcı başına IP limitlerini zorunlu kılmak için **Fail2ban entegrasyonu**.
## Ekran Görüntüleri
@@ -52,7 +52,7 @@ Orijinal X-UI projesinin geliştirilmiş bir çatalı (fork) olarak inşa edilen
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/02-add-inbound-dark.png">
<img alt="Bağlantı Noktaları" src="./media/02-add-inbound-light.png">
<img alt="Gelen Bağlantılar (Inbounds)" src="./media/02-add-inbound-light.png">
</picture>
<picture>
@@ -73,7 +73,7 @@ Orijinal X-UI projesinin geliştirilmiş bir çatalı (fork) olarak inşa edilen
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
```
Kurulum sırasında rastgele bir kullanıcı adı, şifre ve erişim yolu (path) oluşturulur. Kurulumdan sonra `x-ui` komutunu çalıştırarak yönetim menüsünü açabilir; buradan hizmeti başlatabilir/durdurabilir, giriş bilgilerinizi görüntüleyebilir veya sıfırlayabilir, SSL sertifikalarını yönetebilir ve daha fazlasını yapabilirsiniz.
Kurulum sırasında rastgele bir kullanıcı adı, şifre ve erişim yolu oluşturulur. Kurulumdan sonra, hizmeti başlatabileceğiniz/durdurabileceğiniz, giriş bilgilerinizi görüntüleyebileceğiniz veya sıfırlayabileceğiniz, SSL sertifikalarını yönetebileceğiniz ve çok daha fazlasını yapabileceğiniz yönetim menüsünü açmak için terminalde `x-ui` komutunu çalıştırın.
Tam dokümantasyon için lütfen [proje Wiki sayfasını](https://github.com/MHSanaei/3x-ui/wiki) ziyaret edin.
@@ -85,93 +85,93 @@ Tam dokümantasyon için lütfen [proje Wiki sayfasını](https://github.com/MHS
## Veritabanı Seçenekleri
3X-UI, kurulum sırasında seçilebilen iki arka uç (backend) destekler:
3X-UI kurulum sırasında seçilebilecek iki arka uç (backend) destekler:
- **SQLite** (varsayılan) — `/etc/x-ui/x-ui.db` konumunda tek bir dosya. Sıfır kurulum gerektirir, küçük ve orta ölçekli dağıtımlar için idealdir.
- **PostgreSQL** — Yüksek kullanıcı sayıları veya çok düğümlü (multi-node) kurulumlar için önerilir. Yükleyici sizin için PostgreSQL'i yerel olarak kurabilir veya mevcut bir sunucuya DSN ile bağlanabilir.
- **SQLite** (varsayılan) — `/etc/x-ui/x-ui.db` konumunda tek bir dosya. Kurulum gerektirmez, küçük ve orta ölçekli dağıtımlar için idealdir.
- **PostgreSQL** — Yüksek kullanıcı sayıları veya çoklu düğüm (multi-node) kurulumları için önerilir. Yükleyici sizin için yerel olarak PostgreSQL kurabilir veya mevcut bir sunucuya DSN bağlantısı kabul edebilir.
Çalışma anında arka uç, ortam değişkenleri (environment variables) aracılığıyla seçilir (yükleyici bunları sizin için `/etc/default/x-ui` dosyasına yazar):
Çalışma anında veritabanı türü ortam değişkenleri (environment variables) ile seçilir (yükleyici bunları sizin için `/etc/default/x-ui` dosyasına yazar):
```
XUI_DB_TYPE=postgres
XUI_DB_DSN=postgres://xui:password@127.0.0.1:5432/xui?sslmode=disable
```
### Mevcut bir SQLite kurulumunu PostgreSQL'e taşıma
### Mevcut bir SQLite Kurulumunu PostgreSQL'e Taşıma
```bash
x-ui migrate-db --dsn "postgres://xui:password@127.0.0.1:5432/xui?sslmode=disable"
# Ardından /etc/default/x-ui dosyasında XUI_DB_TYPE ve XUI_DB_DSN değerlerini ayarlayıp yeniden başlatın:
# ardından /etc/default/x-ui içindeki XUI_DB_TYPE ve XUI_DB_DSN değerlerini ayarlayıp yeniden başlatın:
systemctl restart x-ui
```
Kaynak SQLite dosyasına dokunulmaz; yeni arka ucu doğruladıktan sonra eski dosyayı manuel olarak silebilirsiniz.
Kaynak SQLite dosyasına dokunulmaz; yeni veritabanının düzgün çalıştığını doğruladıktan sonra eski SQLite dosyasını manuel olarak silebilirsiniz.
### Docker
Varsayılan `docker compose up -d` komutu SQLite kullanmaya devam eder. Dahili PostgreSQL hizmetiyle çalıştırmak için `docker-compose.yml` dosyasındaki iki `XUI_DB_*` ortam değişkeni satırının başındaki yorum işaretini kaldırın ve profille başlatın:
Varsayılan `docker compose up -d` komutu SQLite kullanmaya devam eder. Birlikte paketlenmiş PostgreSQL servisi ile çalıştırmak için, `docker-compose.yml` dosyasındaki iki `XUI_DB_*` değişken satırının yorumunu kaldırın ve profille başlatın:
```bash
docker compose --profile postgres up -d
```
İmaj, kullanıcı bazlı **IP sınırlarını** zorlamak için Fail2ban'i (varsayılan olarak etkindir) içerir. Fail2ban, ihlalcileri `iptables` ile engeller ve bu işlem `NET_ADMIN` yetkisi gerektirir. `docker-compose.yml` bunu `cap_add` aracılığıyla zaten sağlar; eğer container'ı bunun yerine `docker run` ile başlatırsanız yetkileri kendiniz eklemelisiniz, aksi takdirde engellemeler sadece günlüğe (log) kaydedilir ancak asla uygulanmaz:
Docker imajı, kullanıcı başına **IP limitlerini** zorunlu kılmak için Fail2ban ile (varsayılan olarak etkindir) paketlenmiştir. Fail2ban, ihlalcileri `iptables` ile engeller ve bunun için `NET_ADMIN` yetkisine ihtiyaç duyar. `docker-compose.yml` bunu zaten `cap_add` üzerinden vermektedir; ancak konteyneri bunun yerine `docker run` ile başlatırsanız bu yetkileri kendiniz eklemelisiniz, aksi takdirde yasaklamalar günlüğe kaydedilir ancak uygulanmaz:
```bash
docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
```
## Ortam Değişkenleri
## Ortam Değişkenleri (Environment Variables)
| Değişken | Açıklama | Varsayılan |
| --- | --- | --- |
| `XUI_DB_TYPE` | Veritabanı arka ucu: `sqlite` veya `postgres` | `sqlite` |
| `XUI_DB_DSN` | PostgreSQL bağlantı dizesi (`XUI_DB_TYPE=postgres` olduğunda) | — |
| `XUI_DB_FOLDER` | SQLite veritabanı dosyası için dizin | `/etc/x-ui` |
| `XUI_DB_TYPE` | Veritabanı türü: `sqlite` veya `postgres` | `sqlite` |
| `XUI_DB_DSN` | PostgreSQL bağlantı dizesi (eğer `XUI_DB_TYPE=postgres` ise) | — |
| `XUI_DB_FOLDER` | SQLite veritabanı dizini | `/etc/x-ui` |
| `XUI_DB_MAX_OPEN_CONNS` | Maksimum açık bağlantı sayısı (PostgreSQL havuzu) | — |
| `XUI_DB_MAX_IDLE_CONNS` | Maksimum boşta bağlantı sayısı (PostgreSQL havuzu) | — |
| `XUI_ENABLE_FAIL2BAN` | Fail2ban tabanlı IP sınırı zorlamasını etkinleştir | `true` |
| `XUI_LOG_LEVEL` | Log detay seviyesi (`debug`, `info`, `warning`, `error`) | `info` |
| `XUI_DB_MAX_IDLE_CONNS` | Maksimum boşta bekleme bağlantısı (PostgreSQL havuzu) | — |
| `XUI_ENABLE_FAIL2BAN` | Fail2ban tabanlı IP limit uygulamasını etkinleştir | `true` |
| `XUI_LOG_LEVEL` | Günlük (Log) ayrıntı seviyesi (`debug`, `info`, `warning`, `error`) | `info` |
| `XUI_DEBUG` | Hata ayıklama (debug) modunu etkinleştir | `false` |
## Desteklenen Diller
Panel kullanıcı arayüzü 13 dilde mevcuttur:
Panel arayüzü 13 farklı dilde mevcuttur:
English · فارسی · العربية · 中文(简体) · 中文(繁體) · Español · Русский · Українська · Türkçe · Tiếng Việt · 日本語 · Bahasa Indonesia · Português (Brasil)
İngilizce · Farsça · Arapça · Çince (Basitleştirilmiş) · Çince (Geleneksel) · İspanyolca · Rusça · Ukraynaca · Türkçe · Vietnamca · Japonca · Endonezce · Portekizce (Brezilya)
## Katkıda Bulunma
Katkılara açığız. Lütfen bir sorun (issue) veya çekme isteği (pull request) açmadan önce [Katkıda Bulunma Rehberi](/CONTRIBUTING.md)'ni okuyun.
Katkılarınızı her zaman bekliyoruz. Bir sorun (issue) açmadan veya pull request (PR) göndermeden önce lütfen [Katkıda Bulunma Kılavuzunu](/CONTRIBUTING.md) okuyun.
## Özel Teşekkürler
- [alireza0](https://github.com/alireza0/)
## Teşekkür
## Teşekkür & Atıf
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (Lisans: **GPL-3.0**): _Dahili İran alan adları ve güvenlik/reklam engelleme odaklı geliştirilmiş v2ray/xray ve v2ray/xray-clients yönlendirme kuralları._
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (Lisans: **GPL-3.0**): _Rusya'daki engellenmiş alan adları ve adreslere dayalı otomatik olarak güncellenen V2Ray yönlendirme kuralları içerir._
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (Lisans: **GPL-3.0**): _Geliştirilmiş v2ray/xray ve v2ray/xray-clients yönlendirme (routing) kuralları; yerleşik İran alan adları ile güvenlik ve reklam engelleme odaklıdır._
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (Lisans: **GPL-3.0**): _Bu depo, Rusya'daki engellenen alan adları ve adreslere dayalı otomatik olarak güncellenen V2Ray yönlendirme kurallarını içerir._
## Topluluk Araçları
3x-ui etrafında topluluk tarafından geliştirilen araçlar ve entegrasyonlar.
3x-ui çevresindeki topluluk tarafından oluşturulmuş araçlar ve entegrasyonlar.
- [terraform-provider-3x-ui](https://github.com/batonogov/terraform-provider-threexui) (Lisans: **MIT**): _Bağlantı noktalarını, kullanıcıları, panel ayarlarını ve Xray yapılandırmasını Terraform / OpenTofu ile kod olarak yönetin._
- [terraform-provider-3x-ui](https://github.com/batonogov/terraform-provider-threexui) (Lisans: **MIT**): _Gelen bağlantılarnı, kullanıcıları, panel ayarlarını ve Xray yapılandırmasını Terraform / OpenTofu ile kod olarak (as code) yönetin._
## Projeyi Destekleyin
**Eğer bu proje sizin için faydalıysa, bir :star2: (yıldız) verebilirsiniz.**
**Eğer bu proje size faydalı olduysa, bir yıldız verebilirsiniz**:star2:
<a href="https://www.buymeacoffee.com/MHSanaei" target="_blank">
<img src="./media/default-yellow.png" alt="Buy Me A Coffee" style="height: 70px !important;width: 277px !important;" >
<img src="./media/default-yellow.png" alt="Bana Bir Kahve Ismarla" style="height: 70px !important;width: 277px !important;" >
</a>
</br>
<a href="https://nowpayments.io/donation/hsanaei" target="_blank" rel="noreferrer noopener">
<img src="./media/donation-button-black.svg" alt="Crypto donation button by NOWPayments">
<img src="./media/donation-button-black.svg" alt="NOWPayments üzerinden Kripto Bağış Butonu">
</a>
## Zaman İçindeki Yıldız Sayısı
## Yıldız Tablosu
[![Zaman İçindeki Yıldız Sayısı](https://starchart.cc/MHSanaei/3x-ui.svg?variant=adaptive)](https://starchart.cc/MHSanaei/3x-ui)
[![Zaman içerisindeki yıldız sayısı](https://starchart.cc/MHSanaei/3x-ui.svg?variant=adaptive)](https://starchart.cc/MHSanaei/3x-ui)
+1 -1
View File
@@ -1 +1 @@
3.2.8
3.3.0
+1
View File
@@ -73,6 +73,7 @@ func initModels() error {
&model.ClientGroup{},
&model.InboundFallback{},
&model.NodeClientTraffic{},
&model.OutboundSubscription{},
}
for _, mdl := range models {
if err := db.AutoMigrate(mdl); err != nil {
+8 -4
View File
@@ -2,9 +2,6 @@ package database
import "fmt"
// JSONClientsFromInbound returns the FROM clause that yields one row per element
// of inbounds.settings -> clients, with a column named `client.value` whose text
// fields can be read with JSONFieldText("client.value", "<key>").
func JSONClientsFromInbound() string {
if IsPostgres() {
return "FROM inbounds, jsonb_array_elements(inbounds.settings::jsonb -> 'clients') AS client(value)"
@@ -22,7 +19,14 @@ func JSONFieldText(expr, key string) string {
func GreatestExpr(a, b string) string {
if IsPostgres() {
return fmt.Sprintf("GREATEST(%s, %s)", a, b)
return fmt.Sprintf("GREATEST(%s::bigint, %s::bigint)", a, b)
}
return fmt.Sprintf("MAX(%s, %s)", a, b)
}
func ClientTrafficEnableMergeExpr() string {
if IsPostgres() {
return "CASE WHEN ?::boolean THEN enable::boolean ELSE false END"
}
return "CASE WHEN ? THEN enable ELSE 0 END"
}
+15 -3
View File
@@ -20,9 +20,20 @@ import (
"gorm.io/gorm/logger"
)
// migrationModels is the FK-aware order in which tables are created and copied.
// Parents come before their children so foreign-key constraints stay satisfied
// even when checks are not explicitly disabled.
// migrationModels is the FK-aware order in which tables are created and copied
// during `x-ui migrate-db --dsn` (SQLite → PostgreSQL data migration) and in
// related tests.
//
// Important: When adding a new top-level model (like OutboundSubscription),
// you must add it here **in addition to** the list in database/db.go:initModels().
// This list is used for:
// - Creating the destination schema during cross-DB migration
// - Truncating tables
// - Copying data row-by-row
// - Resyncing Postgres sequences after bulk insert
//
// DumpSQLite / RestoreSQLite are schema-introspective (they read sqlite_master)
// so they do not need manual updates.
func migrationModels() []any {
return []any{
&model.User{},
@@ -39,6 +50,7 @@ func migrationModels() []any {
&model.ClientInbound{},
&model.InboundFallback{},
&model.NodeClientTraffic{},
&model.OutboundSubscription{},
}
}
+96 -1
View File
@@ -3,6 +3,8 @@ package model
import (
"bytes"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
@@ -29,6 +31,7 @@ const (
Mixed Protocol = "mixed"
WireGuard Protocol = "wireguard"
Hysteria Protocol = "hysteria"
MTProto Protocol = "mtproto"
)
// User represents a user account in the 3x-ui panel.
@@ -56,7 +59,7 @@ type Inbound struct {
// Xray configuration fields
Listen string `json:"listen" form:"listen"`
Port int `json:"port" form:"port" validate:"gte=0,lte=65535" example:"443"`
Protocol Protocol `json:"protocol" form:"protocol" validate:"required,oneof=vmess vless trojan shadowsocks wireguard hysteria http mixed tunnel tun" example:"vless"`
Protocol Protocol `json:"protocol" form:"protocol" validate:"required,oneof=vmess vless trojan shadowsocks wireguard hysteria http mixed tunnel tun mtproto" example:"vless"`
Settings string `json:"settings" form:"settings"`
StreamSettings string `json:"streamSettings" form:"streamSettings"`
Tag string `json:"tag" form:"tag" gorm:"unique" example:"in-443-tcp"`
@@ -366,6 +369,70 @@ func HealShadowsocksClientMethods(settings string) (string, bool) {
return string(out), true
}
// GenerateFakeTLSSecret builds an MTProto FakeTLS secret for the given domain:
// the "ee" FakeTLS marker, 16 random bytes, then the domain encoded as hex.
// This single value is what mtg's config and the client tg:// link both use.
func GenerateFakeTLSSecret(domain string) string {
return "ee" + mtprotoRandomMiddle() + hex.EncodeToString([]byte(domain))
}
func mtprotoRandomMiddle() string {
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
panic(fmt.Errorf("mtproto: crypto/rand read failed: %w", err))
}
return hex.EncodeToString(buf)
}
// mtprotoSecretMiddle returns the 16-byte random middle of an existing secret
// when it is well-formed, otherwise a freshly generated one. Reusing the middle
// keeps the secret stable when only the FakeTLS domain changes.
func mtprotoSecretMiddle(secret string) string {
s := secret
if strings.HasPrefix(s, "ee") || strings.HasPrefix(s, "dd") {
s = s[2:]
}
if len(s) >= 32 {
mid := s[:32]
if _, err := hex.DecodeString(mid); err == nil {
return mid
}
}
return mtprotoRandomMiddle()
}
// HealMtprotoSecret normalises an mtproto inbound's settings JSON before the
// value leaves for the mtg sidecar or a share link: it rebuilds `secret` so it
// is always a valid FakeTLS secret whose trailing domain matches
// `fakeTlsDomain`, generating the random middle when one is missing and
// rewriting the domain suffix when the domain changed. Returns the rewritten
// settings and true when anything changed.
func HealMtprotoSecret(settings string) (string, bool) {
if settings == "" {
return settings, false
}
var parsed map[string]any
if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
return settings, false
}
domain, _ := parsed["fakeTlsDomain"].(string)
domain = strings.TrimSpace(domain)
if domain == "" {
return settings, false
}
secret, _ := parsed["secret"].(string)
expected := "ee" + mtprotoSecretMiddle(secret) + hex.EncodeToString([]byte(domain))
if secret == expected {
return settings, false
}
parsed["secret"] = expected
out, err := json.MarshalIndent(parsed, "", " ")
if err != nil {
return settings, false
}
return string(out), true
}
// Setting stores key-value configuration settings for the 3x-ui panel.
type Setting struct {
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
@@ -411,6 +478,12 @@ type Node struct {
UptimeSecs uint64 `json:"uptimeSecs" example:"86400"`
LastError string `json:"lastError"`
// XrayState and XrayError are captured from the remote node's /panel/api/server/status
// during heartbeats. They let the central panel distinguish "panel API reachable"
// (status=online) from "Xray core itself has failed on the node" for monitoring.
XrayState string `json:"xrayState" gorm:"column:xray_state"`
XrayError string `json:"xrayError" gorm:"column:xray_error"`
ConfigDirty bool `json:"configDirty" gorm:"default:false"`
ConfigDirtyAt int64 `json:"configDirtyAt"`
@@ -447,6 +520,9 @@ type NodeSummary struct {
LatencyMs int `json:"latencyMs"`
PanelVersion string `json:"panelVersion"`
XrayVersion string `json:"xrayVersion"`
// XrayState/XrayError forwarded so masters can surface xray failure on transitive sub-nodes too.
XrayState string `json:"xrayState"`
XrayError string `json:"xrayError,omitempty"`
}
type CustomGeoResource struct {
@@ -638,6 +714,25 @@ type ClientMergeConflict struct {
Kept any
}
type OutboundSubscription struct {
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
Remark string `json:"remark" form:"remark"`
Url string `json:"url" form:"url"`
Enabled bool `json:"enabled" form:"enabled" gorm:"default:true"`
AllowPrivate bool `json:"allowPrivate" form:"allowPrivate" gorm:"default:false"`
TagPrefix string `json:"tagPrefix" form:"tagPrefix"`
UpdateInterval int `json:"updateInterval" form:"updateInterval" gorm:"default:600"` // seconds between refreshes
Priority int `json:"priority" form:"priority" gorm:"default:0"` // order among subscriptions in the merged outbounds (lower = earlier)
Prepend bool `json:"prepend" form:"prepend" gorm:"default:false"` // place this subscription's outbounds before the manual template outbounds
LastUpdated int64 `json:"lastUpdated" form:"lastUpdated"`
LastError string `json:"lastError" form:"lastError"`
LastFetchedOutbounds string `json:"lastFetchedOutbounds" form:"lastFetchedOutbounds" gorm:"type:text"`
LinkIdentities string `json:"-" gorm:"type:text;column:link_identities"`
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"`
OutboundCount int `json:"outboundCount" gorm:"-"`
}
func MergeClientRecord(existing *ClientRecord, incoming *ClientRecord) []ClientMergeConflict {
var conflicts []ClientMergeConflict
keep := func(field string, oldV, newV, kept any) {
+71
View File
@@ -0,0 +1,71 @@
package model
import (
"encoding/hex"
"encoding/json"
"strings"
"testing"
)
func TestGenerateFakeTLSSecret(t *testing.T) {
domain := "www.cloudflare.com"
s := GenerateFakeTLSSecret(domain)
if !strings.HasPrefix(s, "ee") {
t.Fatalf("secret must start with ee, got %q", s)
}
wantSuffix := hex.EncodeToString([]byte(domain))
if !strings.HasSuffix(s, wantSuffix) {
t.Fatalf("secret must end with hex(domain) %q, got %q", wantSuffix, s)
}
if len(s) != 2+32+len(wantSuffix) {
t.Fatalf("unexpected secret length %d", len(s))
}
if _, err := hex.DecodeString(s[2:34]); err != nil {
t.Fatalf("middle is not valid hex: %v", err)
}
}
func TestHealMtprotoSecret(t *testing.T) {
domain := "example.com"
suffix := hex.EncodeToString([]byte(domain))
in := `{"fakeTlsDomain":"example.com","secret":""}`
out, changed := HealMtprotoSecret(in)
if !changed {
t.Fatal("expected heal to populate an empty secret")
}
var parsed map[string]any
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("healed settings not valid json: %v", err)
}
got, _ := parsed["secret"].(string)
if !strings.HasPrefix(got, "ee") || !strings.HasSuffix(got, suffix) {
t.Fatalf("healed secret malformed: %q", got)
}
if _, changed2 := HealMtprotoSecret(out); changed2 {
t.Fatal("expected no change for an already-valid secret")
}
mid := got[2:34]
newDomain := "telegram.org"
in3 := `{"fakeTlsDomain":"telegram.org","secret":"` + got + `"}`
out3, changed3 := HealMtprotoSecret(in3)
if !changed3 {
t.Fatal("expected heal to rewrite the domain suffix")
}
if err := json.Unmarshal([]byte(out3), &parsed); err != nil {
t.Fatalf("healed settings not valid json: %v", err)
}
got3, _ := parsed["secret"].(string)
if got3[2:34] != mid {
t.Fatalf("random middle should be preserved on domain change: %q vs %q", got3[2:34], mid)
}
if !strings.HasSuffix(got3, hex.EncodeToString([]byte(newDomain))) {
t.Fatalf("suffix not updated for new domain: %q", got3)
}
if _, changed4 := HealMtprotoSecret(`{"secret":"ee"}`); changed4 {
t.Fatal("expected no change when fakeTlsDomain is missing")
}
}
+408 -375
View File
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -1,7 +1,7 @@
{
"name": "3x-ui-frontend",
"private": true,
"version": "0.2.7",
"version": "0.3.0",
"type": "module",
"description": "3x-ui panel frontend (React 19 + Ant Design 6 + Vite 8).",
"engines": {
@@ -65,5 +65,12 @@
"react-debounce-input": {
"react": "^19.0.0"
}
},
"allowScripts": {
"@scarf/scarf": false,
"@tree-sitter-grammars/tree-sitter-yaml": false,
"tree-sitter": false,
"core-js-pure": false,
"tree-sitter-json": false
}
}
+410 -1
View File
@@ -243,6 +243,10 @@
"description": "Subscription support URL",
"type": "string"
},
"subThemeDir": {
"description": "Absolute path to a folder containing a custom subscription page template",
"type": "string"
},
"subTitle": {
"description": "Subscription title",
"type": "string"
@@ -321,6 +325,11 @@
"description": "Two-factor authentication token",
"type": "string"
},
"warpUpdateInterval": {
"description": "WARP",
"minimum": 0,
"type": "integer"
},
"webBasePath": {
"description": "Base path for web panel URLs",
"type": "string"
@@ -404,6 +413,7 @@
"subRoutingRules",
"subShowInfo",
"subSupportUrl",
"subThemeDir",
"subTitle",
"subURI",
"subUpdates",
@@ -422,6 +432,7 @@
"trustedProxyCIDRs",
"twoFactorEnable",
"twoFactorToken",
"warpUpdateInterval",
"webBasePath",
"webCertFile",
"webDomain",
@@ -666,6 +677,10 @@
"description": "Subscription support URL",
"type": "string"
},
"subThemeDir": {
"description": "Absolute path to a folder containing a custom subscription page template",
"type": "string"
},
"subTitle": {
"description": "Subscription title",
"type": "string"
@@ -744,6 +759,11 @@
"description": "Two-factor authentication token",
"type": "string"
},
"warpUpdateInterval": {
"description": "WARP",
"minimum": 0,
"type": "integer"
},
"webBasePath": {
"description": "Base path for web panel URLs",
"type": "string"
@@ -833,6 +853,7 @@
"subRoutingRules",
"subShowInfo",
"subSupportUrl",
"subThemeDir",
"subTitle",
"subURI",
"subUpdates",
@@ -851,6 +872,7 @@
"trustedProxyCIDRs",
"twoFactorEnable",
"twoFactorToken",
"warpUpdateInterval",
"webBasePath",
"webCertFile",
"webDomain",
@@ -1341,7 +1363,8 @@
"http",
"mixed",
"tunnel",
"tun"
"tun",
"mtproto"
],
"example": "vless",
"type": "string"
@@ -1655,6 +1678,13 @@
"example": 86400,
"type": "integer"
},
"xrayError": {
"type": "string"
},
"xrayState": {
"description": "XrayState and XrayError are captured from the remote node's /panel/api/server/status\nduring heartbeats. They let the central panel distinguish \"panel API reachable\"\n(status=online) from \"Xray core itself has failed on the node\" for monitoring.",
"type": "string"
},
"xrayVersion": {
"example": "25.10.31",
"type": "string"
@@ -1690,6 +1720,8 @@
"tlsVerifyMode",
"updatedAt",
"uptimeSecs",
"xrayError",
"xrayState",
"xrayVersion"
],
"type": "object"
@@ -1751,6 +1783,13 @@
"example": 86400,
"type": "integer"
},
"xrayError": {
"type": "string"
},
"xrayState": {
"description": "XrayState/XrayError are populated on successful probes even when the node's\nXray core is not healthy. The UI uses them for a distinct \"panel ok, xray failed\" indicator.",
"type": "string"
},
"xrayVersion": {
"example": "25.10.31",
"type": "string"
@@ -1764,6 +1803,8 @@
"panelVersion",
"status",
"uptimeSecs",
"xrayError",
"xrayState",
"xrayVersion"
],
"type": "object"
@@ -4031,6 +4072,79 @@
}
}
},
"/panel/api/server/clientIps": {
"get": {
"tags": [
"Server"
],
"summary": "Fetch the fully aggregated inbound_client_ips database table. Used by nodes to sync recently active IPs across the cluster.",
"operationId": "get_panel_api_server_clientIps",
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {
"type": "array",
"items": {
"$ref": "#/components/schemas/InboundClientIps"
}
}
}
},
"example": {
"success": true,
"obj": [
{
"clientEmail": "",
"id": 0,
"ips": null
}
]
}
}
}
}
}
},
"post": {
"tags": [
"Server"
],
"summary": "Submit a list of recently active IP timestamps. The panel merges them with the existing database to maintain a unified global IP-limit view.",
"operationId": "post_panel_api_server_clientIps",
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {}
}
}
}
}
}
}
}
},
"/panel/api/clients/list": {
"get": {
"tags": [
@@ -5893,6 +6007,8 @@
"transitive": false,
"updatedAt": 1700000000,
"uptimeSecs": 86400,
"xrayError": "",
"xrayState": "",
"xrayVersion": "25.10.31"
}
]
@@ -6253,6 +6369,8 @@
"panelVersion": "v3.x.x",
"status": "online",
"uptimeSecs": 86400,
"xrayError": "",
"xrayState": "",
"xrayVersion": "25.10.31"
}
}
@@ -7551,6 +7669,297 @@
}
}
},
"/panel/api/xray/outbound-subs": {
"get": {
"tags": [
"Xray Settings"
],
"summary": "List all outbound subscriptions (remote URLs that supply additional outbounds), newest first.",
"operationId": "get_panel_api_xray_outbound_subs",
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {}
}
}
}
}
}
}
},
"post": {
"tags": [
"Xray Settings"
],
"summary": "Create an outbound subscription. The URL is fetched, parsed into outbounds with stable tags, and merged additively into the running Xray config.",
"operationId": "post_panel_api_xray_outbound_subs",
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {}
}
}
}
}
}
}
}
},
"/panel/api/xray/outbound-subs/{id}": {
"post": {
"tags": [
"Xray Settings"
],
"summary": "Update an existing outbound subscription by id. Accepts the same form fields as create.",
"operationId": "post_panel_api_xray_outbound_subs_id",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"description": "Subscription id.",
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {}
}
}
}
}
}
}
},
"delete": {
"tags": [
"Xray Settings"
],
"summary": "Delete an outbound subscription by id.",
"operationId": "delete_panel_api_xray_outbound_subs_id",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"description": "Subscription id.",
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {}
}
}
}
}
}
}
}
},
"/panel/api/xray/outbound-subs/{id}/del": {
"post": {
"tags": [
"Xray Settings"
],
"summary": "Delete an outbound subscription by id (POST alias of DELETE for axios-friendly clients).",
"operationId": "post_panel_api_xray_outbound_subs_id_del",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"description": "Subscription id.",
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {}
}
}
}
}
}
}
}
},
"/panel/api/xray/outbound-subs/{id}/refresh": {
"post": {
"tags": [
"Xray Settings"
],
"summary": "Force an immediate re-fetch of the subscription and return the parsed outbounds. Signals Xray to reload.",
"operationId": "post_panel_api_xray_outbound_subs_id_refresh",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"description": "Subscription id.",
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {}
}
}
}
}
}
}
}
},
"/panel/api/xray/outbound-subs/{id}/move": {
"post": {
"tags": [
"Xray Settings"
],
"summary": "Reorder a subscription one step up or down in priority (controls its position in the merged outbounds).",
"operationId": "post_panel_api_xray_outbound_subs_id_move",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"description": "Subscription id.",
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {}
}
}
}
}
}
}
}
},
"/panel/api/xray/outbound-subs/parse": {
"post": {
"tags": [
"Xray Settings"
],
"summary": "Preview a subscription URL: fetch and parse it into outbounds without persisting anything.",
"operationId": "post_panel_api_xray_outbound_subs_parse",
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {}
}
}
}
}
}
}
}
},
"/{subPath}{subid}": {
"get": {
"tags": [
+8
View File
@@ -56,6 +56,7 @@ export const EXAMPLES: Record<string, unknown> = {
"subRoutingRules": "",
"subShowInfo": false,
"subSupportUrl": "",
"subThemeDir": "",
"subTitle": "",
"subURI": "",
"subUpdates": 0,
@@ -74,6 +75,7 @@ export const EXAMPLES: Record<string, unknown> = {
"trustedProxyCIDRs": "",
"twoFactorEnable": false,
"twoFactorToken": "",
"warpUpdateInterval": 0,
"webBasePath": "",
"webCertFile": "",
"webDomain": "",
@@ -143,6 +145,7 @@ export const EXAMPLES: Record<string, unknown> = {
"subRoutingRules": "",
"subShowInfo": false,
"subSupportUrl": "",
"subThemeDir": "",
"subTitle": "",
"subURI": "",
"subUpdates": 0,
@@ -161,6 +164,7 @@ export const EXAMPLES: Record<string, unknown> = {
"trustedProxyCIDRs": "",
"twoFactorEnable": false,
"twoFactorToken": "",
"warpUpdateInterval": 0,
"webBasePath": "",
"webCertFile": "",
"webDomain": "",
@@ -364,6 +368,8 @@ export const EXAMPLES: Record<string, unknown> = {
"transitive": false,
"updatedAt": 1700000000,
"uptimeSecs": 86400,
"xrayError": "",
"xrayState": "",
"xrayVersion": "25.10.31"
},
"OutboundTraffics": {
@@ -381,6 +387,8 @@ export const EXAMPLES: Record<string, unknown> = {
"panelVersion": "v3.x.x",
"status": "online",
"uptimeSecs": 86400,
"xrayError": "",
"xrayState": "",
"xrayVersion": "25.10.31"
},
"Setting": {
+42 -1
View File
@@ -217,6 +217,10 @@ export const SCHEMAS: Record<string, unknown> = {
"description": "Subscription support URL",
"type": "string"
},
"subThemeDir": {
"description": "Absolute path to a folder containing a custom subscription page template",
"type": "string"
},
"subTitle": {
"description": "Subscription title",
"type": "string"
@@ -295,6 +299,11 @@ export const SCHEMAS: Record<string, unknown> = {
"description": "Two-factor authentication token",
"type": "string"
},
"warpUpdateInterval": {
"description": "WARP",
"minimum": 0,
"type": "integer"
},
"webBasePath": {
"description": "Base path for web panel URLs",
"type": "string"
@@ -378,6 +387,7 @@ export const SCHEMAS: Record<string, unknown> = {
"subRoutingRules",
"subShowInfo",
"subSupportUrl",
"subThemeDir",
"subTitle",
"subURI",
"subUpdates",
@@ -396,6 +406,7 @@ export const SCHEMAS: Record<string, unknown> = {
"trustedProxyCIDRs",
"twoFactorEnable",
"twoFactorToken",
"warpUpdateInterval",
"webBasePath",
"webCertFile",
"webDomain",
@@ -640,6 +651,10 @@ export const SCHEMAS: Record<string, unknown> = {
"description": "Subscription support URL",
"type": "string"
},
"subThemeDir": {
"description": "Absolute path to a folder containing a custom subscription page template",
"type": "string"
},
"subTitle": {
"description": "Subscription title",
"type": "string"
@@ -718,6 +733,11 @@ export const SCHEMAS: Record<string, unknown> = {
"description": "Two-factor authentication token",
"type": "string"
},
"warpUpdateInterval": {
"description": "WARP",
"minimum": 0,
"type": "integer"
},
"webBasePath": {
"description": "Base path for web panel URLs",
"type": "string"
@@ -807,6 +827,7 @@ export const SCHEMAS: Record<string, unknown> = {
"subRoutingRules",
"subShowInfo",
"subSupportUrl",
"subThemeDir",
"subTitle",
"subURI",
"subUpdates",
@@ -825,6 +846,7 @@ export const SCHEMAS: Record<string, unknown> = {
"trustedProxyCIDRs",
"twoFactorEnable",
"twoFactorToken",
"warpUpdateInterval",
"webBasePath",
"webCertFile",
"webDomain",
@@ -1315,7 +1337,8 @@ export const SCHEMAS: Record<string, unknown> = {
"http",
"mixed",
"tunnel",
"tun"
"tun",
"mtproto"
],
"example": "vless",
"type": "string"
@@ -1629,6 +1652,13 @@ export const SCHEMAS: Record<string, unknown> = {
"example": 86400,
"type": "integer"
},
"xrayError": {
"type": "string"
},
"xrayState": {
"description": "XrayState and XrayError are captured from the remote node's /panel/api/server/status\nduring heartbeats. They let the central panel distinguish \"panel API reachable\"\n(status=online) from \"Xray core itself has failed on the node\" for monitoring.",
"type": "string"
},
"xrayVersion": {
"example": "25.10.31",
"type": "string"
@@ -1664,6 +1694,8 @@ export const SCHEMAS: Record<string, unknown> = {
"tlsVerifyMode",
"updatedAt",
"uptimeSecs",
"xrayError",
"xrayState",
"xrayVersion"
],
"type": "object"
@@ -1725,6 +1757,13 @@ export const SCHEMAS: Record<string, unknown> = {
"example": 86400,
"type": "integer"
},
"xrayError": {
"type": "string"
},
"xrayState": {
"description": "XrayState/XrayError are populated on successful probes even when the node's\nXray core is not healthy. The UI uses them for a distinct \"panel ok, xray failed\" indicator.",
"type": "string"
},
"xrayVersion": {
"example": "25.10.31",
"type": "string"
@@ -1738,6 +1777,8 @@ export const SCHEMAS: Record<string, unknown> = {
"panelVersion",
"status",
"uptimeSecs",
"xrayError",
"xrayState",
"xrayVersion"
],
"type": "object"
+8
View File
@@ -61,6 +61,7 @@ export interface AllSetting {
subRoutingRules: string;
subShowInfo: boolean;
subSupportUrl: string;
subThemeDir: string;
subTitle: string;
subURI: string;
subUpdates: number;
@@ -79,6 +80,7 @@ export interface AllSetting {
trustedProxyCIDRs: string;
twoFactorEnable: boolean;
twoFactorToken: string;
warpUpdateInterval: number;
webBasePath: string;
webCertFile: string;
webDomain: string;
@@ -149,6 +151,7 @@ export interface AllSettingView {
subRoutingRules: string;
subShowInfo: boolean;
subSupportUrl: string;
subThemeDir: string;
subTitle: string;
subURI: string;
subUpdates: number;
@@ -167,6 +170,7 @@ export interface AllSettingView {
trustedProxyCIDRs: string;
twoFactorEnable: boolean;
twoFactorToken: string;
warpUpdateInterval: number;
webBasePath: string;
webCertFile: string;
webDomain: string;
@@ -371,6 +375,8 @@ export interface Node {
transitive?: boolean;
updatedAt: number;
uptimeSecs: number;
xrayError: string;
xrayState: string;
xrayVersion: string;
}
@@ -390,6 +396,8 @@ export interface ProbeResultUI {
panelVersion: string;
status: string;
uptimeSecs: number;
xrayError: string;
xrayState: string;
xrayVersion: string;
}
+9 -1
View File
@@ -71,6 +71,7 @@ export const AllSettingSchema = z.object({
subRoutingRules: z.string(),
subShowInfo: z.boolean(),
subSupportUrl: z.string(),
subThemeDir: z.string(),
subTitle: z.string(),
subURI: z.string(),
subUpdates: z.number().int().min(0).max(525600),
@@ -89,6 +90,7 @@ export const AllSettingSchema = z.object({
trustedProxyCIDRs: z.string(),
twoFactorEnable: z.boolean(),
twoFactorToken: z.string(),
warpUpdateInterval: z.number().int().min(0),
webBasePath: z.string(),
webCertFile: z.string(),
webDomain: z.string(),
@@ -160,6 +162,7 @@ export const AllSettingViewSchema = z.object({
subRoutingRules: z.string(),
subShowInfo: z.boolean(),
subSupportUrl: z.string(),
subThemeDir: z.string(),
subTitle: z.string(),
subURI: z.string(),
subUpdates: z.number().int().min(0).max(525600),
@@ -178,6 +181,7 @@ export const AllSettingViewSchema = z.object({
trustedProxyCIDRs: z.string(),
twoFactorEnable: z.boolean(),
twoFactorToken: z.string(),
warpUpdateInterval: z.number().int().min(0),
webBasePath: z.string(),
webCertFile: z.string(),
webDomain: z.string(),
@@ -316,7 +320,7 @@ export const InboundSchema = z.object({
nodeId: z.number().int().nullable().optional(),
originNodeGuid: z.string().optional(),
port: z.number().int().min(0).max(65535),
protocol: z.enum(['vmess', 'vless', 'trojan', 'shadowsocks', 'wireguard', 'hysteria', 'http', 'mixed', 'tunnel', 'tun']),
protocol: z.enum(['vmess', 'vless', 'trojan', 'shadowsocks', 'wireguard', 'hysteria', 'http', 'mixed', 'tunnel', 'tun', 'mtproto']),
remark: z.string(),
settings: z.unknown(),
sniffing: z.unknown(),
@@ -398,6 +402,8 @@ export const NodeSchema = z.object({
transitive: z.boolean().optional(),
updatedAt: z.number().int(),
uptimeSecs: z.number().int(),
xrayError: z.string(),
xrayState: z.string(),
xrayVersion: z.string(),
});
export type Node = z.infer<typeof NodeSchema>;
@@ -419,6 +425,8 @@ export const ProbeResultUISchema = z.object({
panelVersion: z.string(),
status: z.string(),
uptimeSecs: z.number().int(),
xrayError: z.string(),
xrayState: z.string(),
xrayVersion: z.string(),
});
export type ProbeResultUI = z.infer<typeof ProbeResultUISchema>;
+64 -32
View File
@@ -51,9 +51,12 @@ export interface UseXraySettingResult {
setOutboundTestUrl: (v: string) => void;
inboundTags: string[];
clientReverseTags: string[];
subscriptionOutbounds: unknown[];
subscriptionOutboundTags: string[];
restartResult: string;
outboundsTraffic: OutboundTrafficRow[];
outboundTestStates: Record<number, OutboundTestState>;
subscriptionTestStates: Record<string, OutboundTestState>;
testingAll: boolean;
fetchAll: () => Promise<void>;
fetchOutboundsTraffic: () => Promise<void>;
@@ -63,6 +66,11 @@ export interface UseXraySettingResult {
outbound: unknown,
mode?: string,
) => Promise<OutboundTestResult | null>;
testSubscriptionOutbound: (
tag: string,
outbound: unknown,
mode?: string,
) => Promise<OutboundTestResult | null>;
testAllOutbounds: (mode?: string) => Promise<void>;
saveAll: () => Promise<void>;
resetToDefault: () => Promise<void>;
@@ -118,8 +126,13 @@ export function useXraySetting(): UseXraySettingResult {
const [outboundTestUrl, setOutboundTestUrlState] = useState(DEFAULT_TEST_URL);
const [inboundTags, setInboundTags] = useState<string[]>([]);
const [clientReverseTags, setClientReverseTags] = useState<string[]>([]);
const [subscriptionOutbounds, setSubscriptionOutbounds] = useState<unknown[]>([]);
const [subscriptionOutboundTags, setSubscriptionOutboundTags] = useState<string[]>([]);
const [restartResult, setRestartResult] = useState('');
const [outboundTestStates, setOutboundTestStates] = useState<Record<number, OutboundTestState>>({});
// Subscription outbounds aren't in templateSettings.outbounds, so their test
// results are keyed by tag rather than by index.
const [subscriptionTestStates, setSubscriptionTestStates] = useState<Record<string, OutboundTestState>>({});
const [testingAll, setTestingAll] = useState(false);
const oldXraySettingRef = useRef('');
@@ -146,6 +159,8 @@ export function useXraySetting(): UseXraySettingResult {
syncingRef.current = false;
setInboundTags(obj.inboundTags || []);
setClientReverseTags(obj.clientReverseTags || []);
setSubscriptionOutbounds(obj.subscriptionOutbounds || []);
setSubscriptionOutboundTags(obj.subscriptionOutboundTags || []);
const nextUrl = obj.outboundTestUrl || DEFAULT_TEST_URL;
setOutboundTestUrlState(nextUrl);
oldOutboundTestUrlRef.current = nextUrl;
@@ -255,6 +270,26 @@ export function useXraySetting(): UseXraySettingResult {
const spinning = saveMut.isPending || restartMut.isPending || resetDefaultMut.isPending;
// Shared POST + parse for a single outbound test. Returns an OutboundTestResult
// (success or a failure-shaped result); callers store it under their own key.
const postOutboundTest = useCallback(
async (outbound: unknown, effMode: string): Promise<OutboundTestResult> => {
try {
const raw = await HttpUtil.post('/panel/api/xray/testOutbound', {
outbound: JSON.stringify(outbound),
allOutbounds: JSON.stringify(templateSettingsRef.current?.outbounds || []),
mode: effMode,
});
const msg = parseMsg(raw, OutboundTestResultSchema, 'xray/testOutbound');
if (msg?.success && msg.obj) return msg.obj;
return { success: false, error: msg?.msg || 'Unknown error', mode: effMode };
} catch (e) {
return { success: false, error: String(e), mode: effMode };
}
},
[],
);
const testOutbound = useCallback(
async (index: number, outbound: unknown, mode = 'tcp'): Promise<OutboundTestResult | null> => {
if (!outbound) return null;
@@ -263,39 +298,28 @@ export function useXraySetting(): UseXraySettingResult {
...prev,
[index]: { testing: true, result: null, mode: effMode },
}));
try {
const raw = await HttpUtil.post('/panel/api/xray/testOutbound', {
outbound: JSON.stringify(outbound),
allOutbounds: JSON.stringify(templateSettingsRef.current?.outbounds || []),
mode: effMode,
});
const msg = parseMsg(raw, OutboundTestResultSchema, 'xray/testOutbound');
if (msg?.success && msg.obj) {
setOutboundTestStates((prev) => ({
...prev,
[index]: { testing: false, result: msg.obj },
}));
return msg.obj;
}
setOutboundTestStates((prev) => ({
...prev,
[index]: {
testing: false,
result: { success: false, error: msg?.msg || 'Unknown error', mode: effMode },
},
}));
} catch (e) {
setOutboundTestStates((prev) => ({
...prev,
[index]: {
testing: false,
result: { success: false, error: String(e), mode: effMode },
},
}));
}
return null;
const result = await postOutboundTest(outbound, effMode);
setOutboundTestStates((prev) => ({ ...prev, [index]: { testing: false, result } }));
return result.success ? result : null;
},
[],
[postOutboundTest],
);
// Test a subscription outbound (not present in templateSettings.outbounds);
// results are keyed by tag in subscriptionTestStates.
const testSubscriptionOutbound = useCallback(
async (tag: string, outbound: unknown, mode = 'tcp'): Promise<OutboundTestResult | null> => {
if (!outbound || !tag) return null;
const effMode = isUdpOutbound(outbound) ? 'http' : mode;
setSubscriptionTestStates((prev) => ({
...prev,
[tag]: { testing: true, result: null, mode: effMode },
}));
const result = await postOutboundTest(outbound, effMode);
setSubscriptionTestStates((prev) => ({ ...prev, [tag]: { testing: false, result } }));
return result.success ? result : null;
},
[postOutboundTest],
);
const testAllOutbounds = useCallback(async (mode = 'tcp') => {
@@ -358,14 +382,18 @@ export function useXraySetting(): UseXraySettingResult {
setOutboundTestUrl,
inboundTags,
clientReverseTags,
subscriptionOutbounds,
subscriptionOutboundTags,
restartResult,
outboundsTraffic,
outboundTestStates,
subscriptionTestStates,
testingAll,
fetchAll,
fetchOutboundsTraffic: fetchOutboundsTrafficCb,
resetOutboundsTraffic,
testOutbound,
testSubscriptionOutbound,
testAllOutbounds,
saveAll,
resetToDefault,
@@ -384,14 +412,18 @@ export function useXraySetting(): UseXraySettingResult {
setOutboundTestUrl,
inboundTags,
clientReverseTags,
subscriptionOutbounds,
subscriptionOutboundTags,
restartResult,
outboundsTraffic,
outboundTestStates,
subscriptionTestStates,
testingAll,
fetchAll,
fetchOutboundsTrafficCb,
resetOutboundsTraffic,
testOutbound,
testSubscriptionOutbound,
testAllOutbounds,
saveAll,
resetToDefault,
+6 -3
View File
@@ -40,7 +40,7 @@ const DONATE_URL = 'https://donate.sanaei.dev/';
const REPO_URL = 'https://github.com/MHSanaei/3x-ui';
const LOGOUT_KEY = '__logout__';
type IconName = 'dashboard' | 'inbound' | 'team' | 'groups' | 'setting' | 'tool' | 'cluster' | 'logout' | 'apidocs';
type IconName = 'dashboard' | 'inbound' | 'team' | 'groups' | 'setting' | 'tool' | 'cluster' | 'logout' | 'apidocs' | 'outbound';
const iconByName: Record<IconName, ComponentType> = {
dashboard: DashboardOutlined,
@@ -52,6 +52,7 @@ const iconByName: Record<IconName, ComponentType> = {
cluster: ClusterOutlined,
logout: LogoutOutlined,
apidocs: ApiOutlined,
outbound: UploadOutlined,
};
function readCollapsed(): boolean {
@@ -137,6 +138,7 @@ export default function AppSidebar() {
{ key: '/clients', icon: 'team', title: t('menu.clients') },
{ key: '/groups', icon: 'groups', title: t('menu.groups') },
{ key: '/nodes', icon: 'cluster', title: t('menu.nodes') },
{ key: '/xray#outbound', icon: 'outbound', title: t('pages.xray.Outbounds') },
{ key: '/settings', icon: 'setting', title: t('menu.settings') },
{ key: '/xray', icon: 'tool', title: t('menu.xray') },
{ key: '/api-docs', icon: 'apidocs', title: t('menu.apiDocs') },
@@ -162,7 +164,6 @@ export default function AppSidebar() {
const xrayChildren = useMemo<NonNullable<MenuProps['items']>>(() => [
{ key: '/xray#basic', icon: <SettingOutlined />, label: t('pages.xray.basicTemplate') },
{ key: '/xray#routing', icon: <SwapOutlined />, label: t('pages.xray.Routings') },
{ key: '/xray#outbound', icon: <UploadOutlined />, label: t('pages.xray.Outbounds') },
{ key: '/xray#balancer', icon: <ClusterOutlined />, label: t('pages.xray.Balancers') },
{ key: '/xray#dns', icon: <DatabaseOutlined />, label: 'DNS' },
{ key: '/xray#advanced', icon: <CodeOutlined />, label: t('pages.xray.advancedTemplate') },
@@ -176,7 +177,9 @@ export default function AppSidebar() {
? `/xray${hash || '#basic'}`
: (pathname === '' ? '/' : pathname);
const openSubmenu = settingsActive ? '/settings' : xrayActive ? '/xray' : null;
// The Outbounds top-level item lives on /xray#outbound, so don't auto-open the
// Xray Configs submenu for it.
const openSubmenu = settingsActive ? '/settings' : xrayActive && hash !== '#outbound' ? '/xray' : null;
const [openKeys, setOpenKeys] = useState<string[]>(() => (openSubmenu ? [openSubmenu] : []));
useEffect(() => {
if (openSubmenu) {
+41 -1
View File
@@ -3,6 +3,7 @@ import { RandomUtil, Wireguard } from '@/utils';
import type { HttpInboundSettings } from '@/schemas/protocols/inbound/http';
import type { HysteriaClient, HysteriaInboundSettings } from '@/schemas/protocols/inbound/hysteria';
import type { MixedInboundSettings } from '@/schemas/protocols/inbound/mixed';
import type { MtprotoInboundSettings } from '@/schemas/protocols/inbound/mtproto';
import type { ShadowsocksClient, ShadowsocksInboundSettings } from '@/schemas/protocols/inbound/shadowsocks';
import type { TrojanClient, TrojanInboundSettings } from '@/schemas/protocols/inbound/trojan';
import type { TunInboundSettings } from '@/schemas/protocols/inbound/tun';
@@ -200,6 +201,43 @@ export function createDefaultMixedInboundSettings(): MixedInboundSettings {
};
}
function domainToHex(domain: string): string {
return Array.from(new TextEncoder().encode(domain))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
// generateMtprotoSecret builds an "ee" FakeTLS secret: the marker, 16 random
// bytes (32 hex chars), then the domain encoded as hex. Mirrors the Go
// model.GenerateFakeTLSSecret; the backend re-derives it on save so this is
// only for immediate display in the form.
export function generateMtprotoSecret(domain: string): string {
return `ee${RandomUtil.randomSeq(32, { type: 'hex' })}${domainToHex(domain)}`;
}
// mtprotoSecretForDomain rewrites only the domain suffix of an existing secret,
// preserving its 16-byte random middle when valid (generating one otherwise).
// Mirrors the Go model.HealMtprotoSecret so editing the FakeTLS domain doesn't
// needlessly rotate the secret's identity.
export function mtprotoSecretForDomain(currentSecret: string, domain: string): string {
let body = currentSecret;
if (body.startsWith('ee') || body.startsWith('dd')) {
body = body.slice(2);
}
const middle = /^[0-9a-f]{32}/i.test(body)
? body.slice(0, 32)
: RandomUtil.randomSeq(32, { type: 'hex' });
return `ee${middle}${domainToHex(domain)}`;
}
export function createDefaultMtprotoInboundSettings(): MtprotoInboundSettings {
const fakeTlsDomain = 'www.cloudflare.com';
return {
fakeTlsDomain,
secret: generateMtprotoSecret(fakeTlsDomain),
};
}
export function createDefaultTunnelInboundSettings(): TunnelInboundSettings {
return {
portMap: {},
@@ -261,7 +299,8 @@ export type AnyInboundSettings =
| MixedInboundSettings
| TunInboundSettings
| TunnelInboundSettings
| WireguardInboundSettings;
| WireguardInboundSettings
| MtprotoInboundSettings;
export function createDefaultInboundSettings(protocol: string): AnyInboundSettings | null {
switch (protocol) {
@@ -275,6 +314,7 @@ export function createDefaultInboundSettings(protocol: string): AnyInboundSettin
case 'tunnel': return createDefaultTunnelInboundSettings();
case 'tun': return createDefaultTunInboundSettings();
case 'wireguard': return createDefaultWireguardInboundSettings();
case 'mtproto': return createDefaultMtprotoInboundSettings();
default: return null;
}
}
+21
View File
@@ -680,6 +680,25 @@ export function genHysteriaLink(input: GenHysteriaLinkInput): string {
return url.toString();
}
export interface GenMtprotoLinkInput {
inbound: Inbound;
address: string;
port?: number;
}
// Builds a Telegram proxy deep link for an mtproto inbound:
export function genMtprotoLink(input: GenMtprotoLinkInput): string {
const { inbound, address, port = inbound.port } = input;
if (inbound.protocol !== 'mtproto') return '';
const secret = inbound.settings.secret ?? '';
if (secret.length === 0) return '';
const url = new URL('tg://proxy');
url.searchParams.set('server', address);
url.searchParams.set('port', String(port));
url.searchParams.set('secret', secret);
return url.toString();
}
export interface GenWireguardLinkInput {
settings: WireguardInboundSettings;
address: string;
@@ -867,6 +886,8 @@ export function genLink(input: GenLinkInput): string {
clientAuth: client.auth ?? '',
externalProxy,
});
case 'mtproto':
return genMtprotoLink({ inbound, address, port });
default:
return '';
}
@@ -0,0 +1,34 @@
import { TlsStreamSettingsSchema } from '@/schemas/protocols/security/tls';
function defaultCertificate(): Record<string, unknown> {
return {
useFile: true,
certificateFile: '',
keyFile: '',
certificate: [],
key: [],
ocspStapling: 3600,
oneTimeLoading: false,
usage: 'encipherment',
buildChain: false,
};
}
export function createTlsSettingsWithDefaultCert(): Record<string, unknown> {
const tls = TlsStreamSettingsSchema.parse({}) as Record<string, unknown>;
tls.certificates = [defaultCertificate()];
return tls;
}
export function createHysteriaTlsSettingsWithDefaultCert(): Record<string, unknown> {
const tls = createTlsSettingsWithDefaultCert();
tls.alpn = ['h3'];
const settings = tls.settings && typeof tls.settings === 'object' && !Array.isArray(tls.settings)
? { ...(tls.settings as Record<string, unknown>) }
: {};
settings.fingerprint = '';
tls.settings = settings;
return tls;
}
@@ -95,6 +95,20 @@ function dropZeroNumbers(obj: Record<string, unknown>, keys: readonly string[]):
}
}
function normalizeTlsForWire(raw: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = { ...raw };
if (out.fingerprint === '') delete out.fingerprint;
const settings = out.settings;
if (isRecord(settings)) {
const settingsOut: Record<string, unknown> = { ...settings };
if (settingsOut.fingerprint === '') delete settingsOut.fingerprint;
out.settings = settingsOut;
}
return out;
}
export function normalizeXhttpForWire(
raw: Record<string, unknown>,
side: StreamWireSide,
@@ -211,6 +225,11 @@ export function normalizeStreamSettingsForWire(
out.xhttpSettings = normalizeXhttpForWire(xhttp, opts.side);
}
const tls = out.tlsSettings;
if (isRecord(tls)) {
out.tlsSettings = normalizeTlsForWire(tls);
}
const sockopt = out.sockopt;
if (isRecord(sockopt)) {
const normalized = normalizeSockoptForWire(sockopt);
+1
View File
@@ -60,6 +60,7 @@ export class AllSetting {
subJsonMux = '';
subJsonRules = '';
subJsonFinalMask = '';
subThemeDir = '';
timeLocation = 'Local';
+83
View File
@@ -442,6 +442,21 @@ export const sections: readonly Section[] = [
body: 'sni=example.com',
response: '{\n "success": true,\n "obj": {\n "echKeySet": "...",\n "echServerKeys": [...],\n "echConfigList": "..."\n }\n}',
},
{
method: 'GET',
path: '/panel/api/server/clientIps',
summary: 'Fetch the fully aggregated inbound_client_ips database table. Used by nodes to sync recently active IPs across the cluster.',
responseSchema: 'InboundClientIps',
responseSchemaArray: true,
},
{
method: 'POST',
path: '/panel/api/server/clientIps',
summary: 'Submit a list of recently active IP timestamps. The panel merges them with the existing database to maintain a unified global IP-limit view.',
params: [
{ name: 'ips', in: 'body (json)', type: 'object[]', desc: 'Array of InboundClientIps to merge.' },
],
},
],
},
@@ -1085,6 +1100,74 @@ export const sections: readonly Section[] = [
],
body: 'outbound={"protocol":"freedom","settings":{}}&mode=tcp',
},
{
method: 'GET',
path: '/panel/api/xray/outbound-subs',
summary: 'List all outbound subscriptions (remote URLs that supply additional outbounds), newest first.',
},
{
method: 'POST',
path: '/panel/api/xray/outbound-subs',
summary: 'Create an outbound subscription. The URL is fetched, parsed into outbounds with stable tags, and merged additively into the running Xray config.',
params: [
{ name: 'remark', in: 'body (form)', type: 'string', desc: 'Optional display label.' },
{ name: 'url', in: 'body (form)', type: 'string', desc: 'Subscription URL (required). Must be a public http(s) address; private/internal targets are blocked unless allowPrivate is true.' },
{ name: 'tagPrefix', in: 'body (form)', type: 'string', desc: 'Prefix for generated outbound tags. Defaults to "sub<id>-".' },
{ name: 'updateInterval', in: 'body (form)', type: 'integer', desc: 'Seconds between auto-refreshes. Default 600.' },
{ name: 'enabled', in: 'body (form)', type: 'boolean', desc: 'Whether the subscription is active. Default true.' },
{ name: 'allowPrivate', in: 'body (form)', type: 'boolean', desc: 'Allow the URL to point at a private/internal/loopback address (localhost/LAN). Default false (SSRF guard blocks private targets).' },
{ name: 'prepend', in: 'body (form)', type: 'boolean', desc: 'Place this subscription\'s outbounds before the manual template outbounds (so one can become the default). Default false.' },
],
},
{
method: 'POST',
path: '/panel/api/xray/outbound-subs/:id',
summary: 'Update an existing outbound subscription by id. Accepts the same form fields as create.',
params: [
{ name: 'id', in: 'path', type: 'integer', desc: 'Subscription id.' },
],
},
{
method: 'DELETE',
path: '/panel/api/xray/outbound-subs/:id',
summary: 'Delete an outbound subscription by id.',
params: [
{ name: 'id', in: 'path', type: 'integer', desc: 'Subscription id.' },
],
},
{
method: 'POST',
path: '/panel/api/xray/outbound-subs/:id/del',
summary: 'Delete an outbound subscription by id (POST alias of DELETE for axios-friendly clients).',
params: [
{ name: 'id', in: 'path', type: 'integer', desc: 'Subscription id.' },
],
},
{
method: 'POST',
path: '/panel/api/xray/outbound-subs/:id/refresh',
summary: 'Force an immediate re-fetch of the subscription and return the parsed outbounds. Signals Xray to reload.',
params: [
{ name: 'id', in: 'path', type: 'integer', desc: 'Subscription id.' },
],
},
{
method: 'POST',
path: '/panel/api/xray/outbound-subs/:id/move',
summary: 'Reorder a subscription one step up or down in priority (controls its position in the merged outbounds).',
params: [
{ name: 'id', in: 'path', type: 'integer', desc: 'Subscription id.' },
{ name: 'dir', in: 'body (form)', type: 'string', desc: '"up" to raise priority, anything else to lower it.' },
],
},
{
method: 'POST',
path: '/panel/api/xray/outbound-subs/parse',
summary: 'Preview a subscription URL: fetch and parse it into outbounds without persisting anything.',
params: [
{ name: 'url', in: 'body (form)', type: 'string', desc: 'Subscription URL to preview (required).' },
],
},
],
},
+13 -5
View File
@@ -41,7 +41,7 @@ import { useTheme } from '@/hooks/useTheme';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { usePageTitle } from '@/hooks/usePageTitle';
import { useClients } from '@/hooks/useClients';
import { HttpUtil } from '@/utils';
import { HttpUtil, SizeFormatter } from '@/utils';
import { setMessageInstance } from '@/utils/messageBus';
import AppSidebar from '@/layouts/AppSidebar';
import { LazyMount } from '@/components/utility';
@@ -161,8 +161,8 @@ export default function GroupsPage() {
() => groups.reduce((acc, g) => acc + (g.clientCount || 0), 0),
[groups],
);
const emptyGroups = useMemo(
() => groups.filter((g) => (g.clientCount || 0) === 0).length,
const totalTraffic = useMemo(
() => groups.reduce((acc, g) => acc + (g.trafficUsed || 0), 0),
[groups],
);
@@ -417,6 +417,13 @@ export default function GroupsPage() {
width: 180,
render: (count: number) => <span>{count || 0}</span>,
},
{
title: t('pages.groups.trafficUsed'),
dataIndex: 'trafficUsed',
key: 'trafficUsed',
width: 160,
render: (bytes: number) => <span>{SizeFormatter.sizeFormat(bytes || 0)}</span>,
},
];
const pageClass = useMemo(() => {
@@ -465,8 +472,9 @@ export default function GroupsPage() {
</Col>
<Col xs={12} sm={8} md={6}>
<Statistic
title={t('pages.groups.emptyGroups')}
value={String(emptyGroups)}
title={t('pages.groups.totalTraffic')}
value={SizeFormatter.sizeFormat(totalTraffic)}
prefix={<RetweetOutlined />}
/>
</Col>
</Row>
@@ -36,7 +36,7 @@ import { antdRule } from '@/utils/zodForm';
import { Protocols } from '@/schemas/primitives';
import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
import { HysteriaStreamSettingsSchema } from '@/schemas/protocols/stream/hysteria';
import { TlsStreamSettingsSchema } from '@/schemas/protocols/security/tls';
import { createHysteriaTlsSettingsWithDefaultCert } from '@/lib/xray/inbound-tls-defaults';
import { SniffingSchema } from '@/schemas/primitives/sniffing';
import { TcpStreamSettingsSchema } from '@/schemas/protocols/stream/tcp';
import { KcpStreamSettingsSchema } from '@/schemas/protocols/stream/kcp';
@@ -54,6 +54,7 @@ import {
HttpFields,
HysteriaFields,
MixedFields,
MtprotoFields,
ShadowsocksFields,
TunFields,
TunnelFields,
@@ -351,22 +352,11 @@ export default function InboundFormModal({
// snap back to TCP so the standard network selector has a valid
// starting point.
if (next === Protocols.HYSTERIA) {
const tls = TlsStreamSettingsSchema.parse({}) as Record<string, unknown>;
tls.certificates = [{
useFile: true,
certificateFile: '',
keyFile: '',
certificate: [],
key: [],
oneTimeLoading: false,
usage: 'encipherment',
buildChain: false,
}];
form.setFieldValue('streamSettings', {
network: 'hysteria',
security: 'tls',
hysteriaSettings: HysteriaStreamSettingsSchema.parse({}),
tlsSettings: tls,
tlsSettings: createHysteriaTlsSettingsWithDefaultCert(),
// Hysteria2 needs an obfs wrapper on the FinalMask side; seed
// it with salamander + a random password so the listener boots
// with a usable default. Re-selecting Hysteria from another
@@ -589,6 +579,8 @@ export default function InboundFormModal({
{protocol === Protocols.HTTP && <HttpFields />}
{protocol === Protocols.MIXED && <MixedFields mixedUdpOn={mixedUdpOn} />}
{protocol === Protocols.MTPROTO && <MtprotoFields />}
{protocol === Protocols.SHADOWSOCKS && <ShadowsocksFields form={form} isSSWith2022={isSSWith2022} />}
{protocol === Protocols.VLESS && <VlessFields saving={saving} selectedVlessAuth={selectedVlessAuth} network={network} security={security} getNewVlessEnc={getNewVlessEnc} clearVlessEnc={clearVlessEnc} />}
@@ -894,6 +886,7 @@ export default function InboundFormModal({
Protocols.TUNNEL,
Protocols.TUN,
Protocols.WIREGUARD,
Protocols.MTPROTO,
] as string[]).includes(protocol) || isFallbackHost
? [{ key: 'protocol', label: t('pages.inbounds.protocol'), children: protocolTab, forceRender: true }]
: []),
@@ -5,4 +5,5 @@ export { default as WireguardFields } from './wireguard';
export { default as HysteriaFields } from './hysteria';
export { default as HttpFields } from './http';
export { default as MixedFields } from './mixed';
export { default as MtprotoFields } from './mtproto';
export { default as VlessFields } from './vless';
@@ -0,0 +1,40 @@
import { useTranslation } from 'react-i18next';
import { Alert, Button, Form, Input, Space } from 'antd';
import { ReloadOutlined } from '@ant-design/icons';
import { generateMtprotoSecret, mtprotoSecretForDomain } from '@/lib/xray/inbound-defaults';
export default function MtprotoFields() {
const { t } = useTranslation();
const form = Form.useFormInstance();
return (
<>
<Form.Item name={['settings', 'fakeTlsDomain']} label={t('pages.inbounds.form.fakeTlsDomain')}>
<Input
placeholder="www.cloudflare.com"
onChange={(e) => {
const current = (form.getFieldValue(['settings', 'secret']) as string) ?? '';
form.setFieldValue(['settings', 'secret'], mtprotoSecretForDomain(current, e.target.value));
}}
/>
</Form.Item>
<Form.Item label={t('pages.inbounds.form.mtprotoSecret')}>
<Space.Compact block>
<Form.Item name={['settings', 'secret']} noStyle>
<Input readOnly style={{ width: 'calc(100% - 32px)' }} />
</Form.Item>
<Button
icon={<ReloadOutlined />}
onClick={() => {
const domain = form.getFieldValue(['settings', 'fakeTlsDomain']);
form.setFieldValue(['settings', 'secret'], generateMtprotoSecret(domain as string));
}}
/>
</Space.Compact>
</Form.Item>
<Form.Item wrapperCol={{ span: 24 }}>
<Alert type="info" showIcon message={t('pages.inbounds.form.mtprotoHint')} />
</Form.Item>
</>
);
}
@@ -5,7 +5,7 @@ import type { MessageInstance } from 'antd/es/message/interface';
import { HttpUtil, RandomUtil } from '@/utils';
import { getRandomRealityTarget } from '@/models/reality-targets';
import { TlsStreamSettingsSchema } from '@/schemas/protocols/security/tls';
import { createTlsSettingsWithDefaultCert } from '@/lib/xray/inbound-tls-defaults';
import { RealityStreamSettingsSchema } from '@/schemas/protocols/security/reality';
import type { InboundFormValues } from '@/schemas/forms/inbound-form';
@@ -160,19 +160,7 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId }: UseS
delete cleaned.tlsSettings;
delete cleaned.realitySettings;
if (next === 'tls') {
const tls = TlsStreamSettingsSchema.parse({}) as Record<string, unknown>;
tls.certificates = [{
useFile: true,
certificateFile: '',
keyFile: '',
certificate: [],
key: [],
ocspStapling: 3600,
oneTimeLoading: false,
usage: 'encipherment',
buildChain: false,
}];
cleaned.tlsSettings = tls;
cleaned.tlsSettings = createTlsSettingsWithDefaultCert();
}
if (next === 'reality') {
const reality = RealityStreamSettingsSchema.parse({}) as Record<string, unknown>;
@@ -625,6 +625,35 @@ export default function InboundInfoModal({
</dl>
)}
{inbound.protocol === Protocols.MTPROTO && inbound.settings && (
<dl className="info-list info-list-block">
<div className="info-row">
<dt>{t('pages.inbounds.form.fakeTlsDomain')}</dt>
<dd><Tag color="green" className="value-tag">{inbound.settings.fakeTlsDomain as string}</Tag></dd>
</div>
<div className="info-row">
<dt>{t('pages.inbounds.form.mtprotoSecret')}</dt>
<dd className="value-block">
<code className="value-code">{inbound.settings.secret as string}</code>
<Tooltip title={t('copy')}>
<Button size="small" className="value-copy" icon={<CopyOutlined />} onClick={() => copyText(inbound.settings.secret as string, t)} />
</Tooltip>
</dd>
</div>
{links.length > 0 && (
<div className="info-row">
<dt>{t('pages.inbounds.copyLink')}</dt>
<dd className="value-block">
<code className="value-code">{links[0].link}</code>
<Tooltip title={t('copy')}>
<Button size="small" className="value-copy" icon={<CopyOutlined />} onClick={() => copyText(links[0].link, t)} />
</Tooltip>
</dd>
</div>
)}
</dl>
)}
{dbInbound.isMixed && inbound.settings && (
<dl className="info-list info-list-block">
<div className="info-row">
@@ -171,7 +171,7 @@ export function useInboundColumns({
</div>
)}
>
<Tag color="green" className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>{cc.active.length}</Tag>
<Tag color="green" className="client-count-tag" style={{ margin: 0, marginRight: 4, padding: '0 2px' }}>{cc.active.length}</Tag>
</Popover>
)}
{cc.deactive.length > 0 && (
@@ -183,7 +183,7 @@ export function useInboundColumns({
</div>
)}
>
<Tag className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>{cc.deactive.length}</Tag>
<Tag className="client-count-tag" style={{ margin: 0, marginRight: 4, padding: '0 2px' }}>{cc.deactive.length}</Tag>
</Popover>
)}
{cc.depleted.length > 0 && (
@@ -195,7 +195,7 @@ export function useInboundColumns({
</div>
)}
>
<Tag color="red" className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>{cc.depleted.length}</Tag>
<Tag color="red" className="client-count-tag" style={{ margin: 0, marginRight: 4, padding: '0 2px' }}>{cc.depleted.length}</Tag>
</Popover>
)}
{cc.online.length > 0 && (
+1 -44
View File
@@ -5,6 +5,7 @@ import { DownloadOutlined, SyncOutlined } from '@ant-design/icons';
import { HttpUtil, FileManager, PromiseUtil } from '@/utils';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { parseLogLine } from './logParse';
import './LogModal.css';
interface LogModalProps {
@@ -12,50 +13,6 @@ interface LogModalProps {
onClose: () => void;
}
interface ParsedLog {
date: string;
time: string;
stamp: string;
levelText: string;
levelClass: string;
service: string;
body: string;
}
const LEVELS = ['DEBUG', 'INFO', 'NOTICE', 'WARNING', 'ERROR'];
const LEVEL_CLASSES = ['level-debug', 'level-info', 'level-notice', 'level-warning', 'level-error'];
function parseLogLine(line: string): ParsedLog {
const [head, ...rest] = (line || '').split(' - ');
const message = rest.join(' - ');
const parts = head.split(' ');
let date = '';
let time = '';
let levelText: string;
if (parts.length >= 3) {
[date, time, levelText] = parts;
} else {
levelText = head;
}
const li = LEVELS.indexOf(levelText);
const levelClass = li >= 0 ? LEVEL_CLASSES[li] : 'level-unknown';
let service = '';
let body = message || '';
if (body.startsWith('XRAY:')) {
service = 'XRAY:';
body = body.slice('XRAY:'.length).trimStart();
} else if (body) {
service = 'X-UI:';
}
const stamp = [date, time].filter(Boolean).join(' ');
return { date, time, stamp, levelText, levelClass, service, body };
}
export default function LogModal({ open, onClose }: LogModalProps) {
const { t } = useTranslation();
const { isMobile } = useMediaQuery();
+113
View File
@@ -0,0 +1,113 @@
// Parser for the panel log viewer. Logs reach the UI in two shapes:
//
// - App log (SysLog off): the in-memory buffer, formatted as
// "2006/01/02 15:04:05 LEVEL - message"
// - SysLog (journalctl -o short): every entry is prefixed with
// "Mon DD HH:MM:SS host ident[pid]: " before the real message, and the
// message itself is one of several shapes depending on which subsystem
// emitted it:
// "INFO - mtproto: ..." go-logging (x-ui + xray)
// "2026/06/08 19:22:22 http: ..." Go std log (net/http, runtime)
// "[Mon Jun 8 23:56:52 UTC 2026] ERROR ..." telego bot
// "Stopping x-ui.service - ..." systemd
//
// parseLogLine normalises all of these into a stamp + level + service + body so
// the viewer renders a readable line instead of a bare timestamp.
export interface ParsedLog {
date: string;
time: string;
stamp: string;
levelText: string;
levelClass: string;
service: string;
body: string;
}
export const LEVELS = ['DEBUG', 'INFO', 'NOTICE', 'WARNING', 'ERROR'];
export const LEVEL_CLASSES = [
'level-debug',
'level-info',
'level-notice',
'level-warning',
'level-error',
];
// "Mon DD HH:MM:SS host ident[pid]: <message>" — captures the journal date,
// time, and the message that follows the syslog identifier.
const SYSLOG_PREFIX = /^([A-Za-z]{3}\s+\d{1,2})\s+(\d{2}:\d{2}:\d{2})\s+\S+\s+\S+?:\s+(.*)$/;
// Redundant Go std-log date prefix ("2006/01/02 15:04:05 ") to strip — the
// journal already carries the timestamp.
const GO_LOG_DATE = /^\d{4}\/\d{2}\/\d{2}\s+\d{2}:\d{2}:\d{2}\s+/;
// telego's own line prefix: "[Mon Jan _2 15:04:05 MST 2006] LEVEL rest".
const TELEGO = /^\[[^\]]+\]\s+([A-Z]+)\s+(.*)$/;
// splitLevelDash pulls a leading "LEVEL - " off a message, returning the level
// and the remainder. Returns null when the message does not start with a level.
function splitLevelDash(message: string): { level: string; rest: string } | null {
const dash = message.indexOf(' - ');
if (dash < 0) return null;
const level = message.slice(0, dash).trim();
if (LEVELS.indexOf(level) < 0) return null;
return { level, rest: message.slice(dash + 3) };
}
export function parseLogLine(line: string): ParsedLog {
const raw = (line || '').trim();
let date = '';
let time = '';
let levelText = '';
let body: string;
const sys = raw.match(SYSLOG_PREFIX);
if (sys) {
date = sys[1];
time = sys[2];
let message = sys[3];
const ld = splitLevelDash(message);
if (ld) {
// go-logging: "LEVEL - message"
levelText = ld.level;
body = ld.rest;
} else {
// Strip the redundant Go std-log date, then try to lift a level out of a
// telego "[timestamp] LEVEL ..." line; otherwise keep the message as-is.
message = message.replace(GO_LOG_DATE, '');
const tg = message.match(TELEGO);
if (tg && LEVELS.indexOf(tg[1]) >= 0) {
levelText = tg[1];
body = tg[2];
} else {
body = message;
}
}
} else {
// App-log format: "2006/01/02 15:04:05 LEVEL - body"
const [head, ...rest] = raw.split(' - ');
const message = rest.join(' - ');
const parts = head.split(' ');
if (parts.length >= 3) {
[date, time, levelText] = parts;
} else {
levelText = head;
}
body = message || '';
}
const li = LEVELS.indexOf(levelText);
const levelClass = li >= 0 ? LEVEL_CLASSES[li] : 'level-unknown';
let service = '';
if (body.startsWith('XRAY:')) {
service = 'XRAY:';
body = body.slice('XRAY:'.length).trimStart();
} else if (body) {
service = 'X-UI:';
}
const stamp = [date, time].filter(Boolean).join(' ');
return { date, time, stamp, levelText, levelClass, service, body };
}
+79 -28
View File
@@ -68,18 +68,63 @@ function badgeStatus(status?: string): BadgeProps['status'] {
}
}
function StatusDot({ status }: { status?: string }) {
if (status === 'online') return <span className="online-dot" />;
interface HealthProps {
status?: string;
xrayState?: string;
xrayError?: string;
}
// Purple: the node's panel API is reachable (status=online) but its Xray core
// has failed or been stopped. Distinct from a normal offline/unknown node.
const XRAY_ERROR_COLOR = '#722ED1';
// True when the panel is online but Xray itself reports error/stop.
function hasXrayProblem(status?: string, xrayState?: string): boolean {
if (status !== 'online') return false;
const xs = (xrayState || '').toLowerCase().trim();
return xs === 'error' || xs === 'stop';
}
// Tooltip text + icon color for the status cell. A real probe error (lastError)
// is a warning and takes precedence; otherwise an Xray-core problem shows purple.
function statusIssue(record: Pick<NodeRecord, 'status' | 'xrayState' | 'xrayError' | 'lastError'>) {
const tip = record.lastError || (hasXrayProblem(record.status, record.xrayState) ? record.xrayError : '') || '';
const iconColor = !record.lastError && hasXrayProblem(record.status, record.xrayState)
? XRAY_ERROR_COLOR
: 'var(--ant-color-warning)';
return { tip, iconColor };
}
function StatusDot({ status, xrayState }: HealthProps) {
if (status === 'online') {
return hasXrayProblem(status, xrayState)
? <span className="xray-error-dot" />
: <span className="online-dot" />;
}
return <Badge status={badgeStatus(status)} />;
}
function StatusLabel({ status }: { status?: string }) {
function StatusLabel({ status, xrayState }: HealthProps) {
const { t } = useTranslation();
return (
<span style={status === 'online' ? { color: 'var(--ant-color-success)' } : undefined}>
{t(`pages.nodes.statusValues.${status || 'unknown'}`)}
</span>
);
if (status === 'online') {
const xs = (xrayState || '').toLowerCase().trim();
if (xs === 'error' || xs === 'stop') {
const detail = xs === 'error'
? t('pages.nodes.statusValues.xrayError')
: t('pages.nodes.statusValues.xrayStopped');
return (
<span style={{ color: XRAY_ERROR_COLOR }}>
{t('pages.nodes.statusValues.online')} ({detail})
</span>
);
}
return (
<span style={{ color: 'var(--ant-color-success)' }}>
{t('pages.nodes.statusValues.online')}
</span>
);
}
return <span>{t(`pages.nodes.statusValues.${status || 'unknown'}`)}</span>;
}
function formatPct(p?: number): string {
@@ -271,17 +316,20 @@ export default function NodeList({
title: t('pages.nodes.status'),
dataIndex: 'status',
align: 'center',
render: (_value, record) => (
<Space size={4}>
<StatusDot status={record.status} />
<StatusLabel status={record.status} />
{record.lastError && (
<Tooltip title={record.lastError}>
<ExclamationCircleOutlined style={{ color: 'var(--ant-color-warning)' }} />
</Tooltip>
)}
</Space>
),
render: (_value, record) => {
const { tip, iconColor } = statusIssue(record);
return (
<Space size={4}>
<StatusDot status={record.status} xrayState={record.xrayState} />
<StatusLabel status={record.status} xrayState={record.xrayState} />
{tip && (
<Tooltip title={tip}>
<ExclamationCircleOutlined style={{ color: iconColor }} />
</Tooltip>
)}
</Space>
);
},
},
{
title: t('pages.nodes.cpu'),
@@ -389,7 +437,7 @@ export default function NodeList({
<div key={String(record.key)} className="node-card" style={{ paddingInlineStart: 16, opacity: 0.85 }}>
<div className="card-head">
<ApartmentOutlined style={{ opacity: 0.6 }} />
<StatusDot status={record.status} />
<StatusDot status={record.status} xrayState={record.xrayState} />
<span className="node-name">{record.name}</span>
<div className="card-actions">
<Tag icon={<ApartmentOutlined />} style={{ margin: 0 }}>{t('pages.nodes.subNode')}</Tag>
@@ -400,7 +448,7 @@ export default function NodeList({
<div key={record.id} className="node-card">
<div className="card-head" onClick={() => toggleExpanded(record.id)}>
<RightOutlined className={`card-expand${expandedIds.has(record.id) ? ' is-expanded' : ''}`} />
<StatusDot status={record.status} />
<StatusDot status={record.status} xrayState={record.xrayState} />
<span className="node-name">{record.name}</span>
<div className="card-actions" onClick={(e) => e.stopPropagation()}>
<Tooltip title={t('info')}>
@@ -494,13 +542,16 @@ export default function NodeList({
</div>
<div className="stat-row">
<span className="stat-label">{t('pages.nodes.status')}</span>
<StatusDot status={statsNode.status} />
<StatusLabel status={statsNode.status} />
{statsNode.lastError && (
<Tooltip title={statsNode.lastError}>
<ExclamationCircleOutlined style={{ color: 'var(--ant-color-warning)' }} />
</Tooltip>
)}
<StatusDot status={statsNode.status} xrayState={statsNode.xrayState} />
<StatusLabel status={statsNode.status} xrayState={statsNode.xrayState} />
{(() => {
const { tip, iconColor } = statusIssue(statsNode);
return tip ? (
<Tooltip title={tip}>
<ExclamationCircleOutlined style={{ color: iconColor }} />
</Tooltip>
) : null;
})()}
</div>
<div className="stat-row">
<span className="stat-label">{t('pages.nodes.cpu')}</span>
+4 -1
View File
@@ -83,12 +83,15 @@ export default function NodesPage() {
const msg = await probe(node.id);
if (msg?.success && msg.obj) {
if (msg.obj.status === 'online') {
// Even if xray is in error/stop on the node we still reached its panel API.
messageApi.success(t('pages.nodes.connectionOk', { ms: msg.obj.latencyMs }));
} else {
messageApi.error(msg.obj.error || t('pages.nodes.toasts.probeFailed'));
}
}
}, [probe, t, messageApi]);
// Refresh the list so the new xrayState / xrayError (if any) appears immediately in the row.
refetch();
}, [probe, t, messageApi, refetch]);
const onToggleEnable = useCallback(async (node: NodeRecord, next: boolean) => {
await setEnable(node.id, next);
@@ -157,6 +157,11 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
onChange={(e) => updateSetting({ subAnnounce: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subThemeDir')} description={t('pages.settings.subThemeDirDesc')}>
<Input value={allSetting.subThemeDir} placeholder="/etc/3x-ui/sub_templates/my-theme/"
onChange={(e) => updateSetting({ subThemeDir: e.target.value })} />
</SettingListItem>
<Divider>Happ</Divider>
<SettingListItem paddings="small" title={t('pages.settings.subEnableRouting')} description={t('pages.settings.subEnableRoutingDesc')}>
+18 -5
View File
@@ -29,6 +29,7 @@ import { JsonEditor } from '@/components/form';
import { setMessageInstance } from '@/utils/messageBus';
import { BasicsTab } from './basics';
import { propagateOutboundTagRename } from './basics/helpers';
import { RoutingTab } from './routing';
import { OutboundsTab } from './outbounds';
import { BalancersTab } from './balancers';
@@ -60,13 +61,17 @@ export default function XrayPage() {
setOutboundTestUrl,
inboundTags,
clientReverseTags,
subscriptionOutbounds,
subscriptionOutboundTags,
restartResult,
outboundsTraffic,
outboundTestStates,
subscriptionTestStates,
testingAll,
fetchAll,
resetOutboundsTraffic,
testOutbound,
testSubscriptionOutbound,
testAllOutbounds,
saveAll,
resetToDefault,
@@ -99,6 +104,11 @@ export default function XrayPage() {
if (outbound) await testOutbound(idx, outbound, mode);
}
async function onTestSubscription(outbound: Record<string, unknown>, mode: string) {
const tag = typeof outbound?.tag === 'string' ? outbound.tag : '';
if (tag) await testSubscriptionOutbound(tag, outbound, mode);
}
function onAddOutbound(outbound: Record<string, unknown>) {
mutate((tt) => {
if (!Array.isArray(tt.outbounds)) tt.outbounds = [];
@@ -109,11 +119,8 @@ export default function XrayPage() {
mutate((tt) => {
if (!tt.outbounds || payload.index < 0) return;
tt.outbounds[payload.index] = payload.outbound as never;
if (payload.oldTag && payload.newTag && payload.oldTag !== payload.newTag) {
const rules = tt.routing?.rules || [];
for (const r of rules) {
if (r?.outboundTag === payload.oldTag) r.outboundTag = payload.newTag;
}
if (payload.oldTag && payload.newTag) {
propagateOutboundTagRename(tt, payload.oldTag, payload.newTag);
}
});
}
@@ -214,6 +221,7 @@ export default function XrayPage() {
setTemplateSettings={setTemplateSettings}
inboundTags={inboundTags}
clientReverseTags={clientReverseTags}
subscriptionOutboundTags={subscriptionOutboundTags}
isMobile={isMobile}
/>
);
@@ -224,14 +232,18 @@ export default function XrayPage() {
setTemplateSettings={setTemplateSettings}
outboundsTraffic={outboundsTraffic}
outboundTestStates={outboundTestStates}
subscriptionTestStates={subscriptionTestStates}
testingAll={testingAll}
inboundTags={inboundTags}
subscriptionOutbounds={subscriptionOutbounds}
isMobile={isMobile}
onResetTraffic={resetOutboundsTraffic}
onTest={onTestOutbound}
onTestSubscription={onTestSubscription}
onTestAll={testAllOutbounds}
onShowWarp={() => setWarpOpen(true)}
onShowNord={() => setNordOpen(true)}
onRefreshXrayData={fetchAll}
/>
);
case 'balancer':
@@ -240,6 +252,7 @@ export default function XrayPage() {
templateSettings={templateSettings}
setTemplateSettings={setTemplateSettings}
clientReverseTags={clientReverseTags}
subscriptionOutboundTags={subscriptionOutboundTags}
isMobile={isMobile}
/>
);
@@ -18,6 +18,7 @@ interface BalancersTabProps {
templateSettings: XraySettingsValue | null;
setTemplateSettings: SetTemplate;
clientReverseTags: string[];
subscriptionOutboundTags?: string[];
isMobile: boolean;
}
@@ -90,6 +91,7 @@ export default function BalancersTab({
templateSettings,
setTemplateSettings,
clientReverseTags,
subscriptionOutboundTags,
isMobile,
}: BalancersTabProps) {
const { t } = useTranslation();
@@ -118,8 +120,11 @@ export default function BalancersTab({
for (const tag of clientReverseTags || []) {
if (tag) tags.add(tag);
}
for (const tag of subscriptionOutboundTags || []) {
if (tag) tags.add(tag);
}
return [...tags];
}, [templateSettings?.outbounds, clientReverseTags]);
}, [templateSettings?.outbounds, clientReverseTags, subscriptionOutboundTags]);
const otherTags = useMemo(() => {
if (editingIndex == null) return rows.map((b) => b.tag).filter(Boolean);
+33
View File
@@ -54,3 +54,36 @@ export function syncOutbound(t: XraySettingsValue, tag: string, settings: Record
if (!haveRules && idx > 0) t.outbounds.splice(idx, 1);
if (haveRules && idx < 0) t.outbounds.push(settings as never);
}
export function propagateOutboundTagRename(
t: XraySettingsValue,
oldTag: string,
newTag: string,
): void {
if (!oldTag || !newTag || oldTag === newTag) return;
const rules = t.routing?.rules;
if (Array.isArray(rules)) {
for (const rule of rules) {
if (rule?.outboundTag === oldTag) rule.outboundTag = newTag;
}
}
const balancers = t.routing?.balancers;
if (Array.isArray(balancers)) {
for (const balancer of balancers) {
if (balancer?.fallbackTag === oldTag) balancer.fallbackTag = newTag;
if (Array.isArray(balancer?.selector)) {
balancer.selector = balancer.selector.map((sel) => (sel === oldTag ? newTag : sel));
}
}
}
if (Array.isArray(t.outbounds)) {
for (const outbound of t.outbounds) {
const sockopt = (outbound as { streamSettings?: { sockopt?: { dialerProxy?: string } } })
?.streamSettings?.sockopt;
if (sockopt?.dialerProxy === oldTag) sockopt.dialerProxy = newTag;
}
}
}
@@ -209,3 +209,17 @@
.outbound-test-popover .dot-fail {
color: #e04141;
}
.subscription-outbounds-head {
margin-bottom: 8px;
}
.subscription-outbounds-title {
font-weight: 600;
margin-bottom: 2px;
}
.subscription-outbounds-desc {
font-size: 12px;
opacity: 0.7;
}
@@ -3,43 +3,82 @@ import { useTranslation } from 'react-i18next';
import {
Button,
Col,
Dropdown,
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Radio,
Row,
Space,
Switch,
Table,
Tag,
Tooltip,
message,
} from 'antd';
import {
PlusOutlined,
CloudOutlined,
ApiOutlined,
MoreOutlined,
RetweetOutlined,
PlayCircleOutlined,
ReloadOutlined,
DeleteOutlined,
EditOutlined,
EyeOutlined,
ArrowUpOutlined,
ArrowDownOutlined,
CheckCircleOutlined,
WarningOutlined,
} from '@ant-design/icons';
import { HttpUtil } from '@/utils';
import OutboundFormModal from './OutboundFormModal';
import { propagateOutboundTagRename } from '../basics/helpers';
import type { XraySettingsValue, SetTemplate, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
import './OutboundsTab.css';
import type { OutboundRow } from './outbounds-tab-types';
import { useOutboundColumns } from './useOutboundColumns';
import OutboundCardList from './OutboundCardList';
import SubscriptionOutbounds from './SubscriptionOutbounds';
interface OutboundSub {
id: number;
remark?: string;
url?: string;
enabled?: boolean;
allowPrivate?: boolean;
prepend?: boolean;
priority?: number;
tagPrefix?: string;
updateInterval?: number;
lastUpdated?: number;
lastError?: string;
outboundCount?: number;
}
interface OutboundsTabProps {
templateSettings: XraySettingsValue | null;
setTemplateSettings: SetTemplate;
outboundsTraffic: OutboundTrafficRow[];
outboundTestStates: Record<number, OutboundTestState>;
subscriptionTestStates: Record<string, OutboundTestState>;
testingAll: boolean;
inboundTags: string[];
subscriptionOutbounds?: unknown[];
isMobile: boolean;
onResetTraffic: (tag: string) => void;
onTest: (index: number, mode: string) => void;
onTestSubscription: (outbound: Record<string, unknown>, mode: string) => void;
onTestAll: (mode: string) => void;
onShowWarp: () => void;
onShowNord: () => void;
onRefreshXrayData?: () => void;
}
export default function OutboundsTab({
@@ -47,23 +86,49 @@ export default function OutboundsTab({
setTemplateSettings,
outboundsTraffic,
outboundTestStates,
subscriptionTestStates,
testingAll,
inboundTags: _inboundTags,
subscriptionOutbounds,
isMobile,
onResetTraffic,
onTest,
onTestSubscription,
onTestAll,
onShowWarp,
onShowNord,
onRefreshXrayData,
}: OutboundsTabProps) {
const { t } = useTranslation();
const [modal, modalContextHolder] = Modal.useModal();
const [messageApi, messageContextHolder] = message.useMessage();
const [testMode, setTestMode] = useState<'tcp' | 'http'>('tcp');
const [modalOpen, setModalOpen] = useState(false);
const [editingOutbound, setEditingOutbound] = useState<Record<string, unknown> | null>(null);
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [existingTags, setExistingTags] = useState<string[]>([]);
// Subscription manager (CRUD + reorder + refresh + preview)
const [subDrawerOpen, setSubDrawerOpen] = useState(false);
const [subs, setSubs] = useState<OutboundSub[]>([]);
const [subsLoading, setSubsLoading] = useState(false);
const [newSub, setNewSub] = useState({ remark: '', url: '', tagPrefix: '', updateInterval: 600, enabled: true, allowPrivate: false, prepend: false });
const [editingSubId, setEditingSubId] = useState<number | null>(null);
const [savingSub, setSavingSub] = useState(false);
const [refreshingId, setRefreshingId] = useState<number | null>(null);
const [refreshingAll, setRefreshingAll] = useState(false);
const [busyId, setBusyId] = useState<number | null>(null);
const [previewing, setPreviewing] = useState(false);
const [previewData, setPreviewData] = useState<{ tag?: string; protocol?: string }[] | null>(null);
// Convenience: expose hours/minutes for the interval input
const intervalHours = Math.floor((newSub.updateInterval || 600) / 3600);
const intervalMinutes = Math.floor(((newSub.updateInterval || 600) % 3600) / 60);
function setIntervalHM(h: number, m: number) {
const secs = Math.max(60, (h || 0) * 3600 + (m || 0) * 60);
setNewSub((prev) => ({ ...prev, updateInterval: secs }));
}
const outbounds = useMemo(
() => (templateSettings?.outbounds || []) as unknown as OutboundRow[],
[templateSettings?.outbounds],
@@ -89,6 +154,11 @@ export default function OutboundsTab({
setExistingTags((templateSettings?.outbounds || []).map((o) => o?.tag).filter((tg): tg is string => !!tg));
setModalOpen(true);
}
function openSubManager() {
setSubDrawerOpen(true);
loadSubs();
}
function openEdit(idx: number) {
setEditingOutbound((templateSettings?.outbounds || [])[idx] as Record<string, unknown>);
setEditingIndex(idx);
@@ -103,11 +173,16 @@ export default function OutboundsTab({
function onConfirm(outbound: Record<string, unknown>) {
mutate((tt) => {
if (!Array.isArray(tt.outbounds)) tt.outbounds = [];
const newTag = typeof outbound.tag === 'string' ? outbound.tag : '';
if (editingIndex == null) {
if (!outbound.tag) return;
if (!newTag) return;
tt.outbounds.push(outbound as never);
} else {
const oldTag = tt.outbounds[editingIndex]?.tag;
tt.outbounds[editingIndex] = outbound as never;
if (oldTag && newTag && oldTag !== newTag) {
propagateOutboundTagRename(tt, oldTag, newTag);
}
}
});
setModalOpen(false);
@@ -147,6 +222,169 @@ export default function OutboundsTab({
});
}
// --- Subscription management (minimal inline UI) ---
async function loadSubs() {
setSubsLoading(true);
try {
const r = await HttpUtil.get('/panel/api/xray/outbound-subs');
if (r?.success) setSubs(Array.isArray(r.obj) ? r.obj : []);
} catch {
messageApi.error(t('pages.xray.outboundSub.toastLoadFailed'));
} finally {
setSubsLoading(false);
}
}
function subBody(src: { remark?: string; url?: string; tagPrefix?: string; updateInterval?: number; enabled?: boolean; allowPrivate?: boolean; prepend?: boolean }) {
return {
remark: src.remark ?? '',
url: src.url ?? '',
tagPrefix: src.tagPrefix ?? '',
updateInterval: src.updateInterval ?? 600,
enabled: src.enabled ?? true,
allowPrivate: src.allowPrivate ?? false,
prepend: src.prepend ?? false,
};
}
function resetSubForm() {
setNewSub({ remark: '', url: '', tagPrefix: '', updateInterval: 600, enabled: true, allowPrivate: false, prepend: false });
setEditingSubId(null);
setPreviewData(null);
}
function openEditSub(sub: OutboundSub) {
setNewSub({
remark: sub.remark ?? '',
url: sub.url ?? '',
tagPrefix: sub.tagPrefix ?? '',
updateInterval: sub.updateInterval ?? 600,
enabled: sub.enabled ?? true,
allowPrivate: sub.allowPrivate ?? false,
prepend: sub.prepend ?? false,
});
setEditingSubId(sub.id);
setPreviewData(null);
}
async function saveSub() {
if (!newSub.url.trim()) {
messageApi.warning(t('pages.xray.outboundSub.toastUrlRequired'));
return;
}
setSavingSub(true);
try {
const url = editingSubId != null
? `/panel/api/xray/outbound-subs/${editingSubId}`
: '/panel/api/xray/outbound-subs';
const r = await HttpUtil.post<OutboundSub>(url, subBody(newSub));
if (r?.success) {
messageApi.success(t(editingSubId != null ? 'pages.xray.outboundSub.toastUpdated' : 'pages.xray.outboundSub.toastAdded'));
const createdId = editingSubId == null ? r.obj?.id : undefined;
resetSubForm();
await loadSubs();
if (createdId) await refreshOne(createdId);
onRefreshXrayData?.();
} else {
messageApi.error(r?.msg || t('pages.xray.outboundSub.toastAddFailed'));
}
} catch {
messageApi.error(t('pages.xray.outboundSub.toastAddFailed'));
} finally {
setSavingSub(false);
}
}
async function previewSub() {
if (!newSub.url.trim()) {
messageApi.warning(t('pages.xray.outboundSub.toastUrlRequired'));
return;
}
setPreviewing(true);
setPreviewData(null);
try {
const r = await HttpUtil.post<{ tag?: string; protocol?: string }[]>('/panel/api/xray/outbound-subs/parse', { url: newSub.url, allowPrivate: newSub.allowPrivate });
if (r?.success && Array.isArray(r.obj)) {
setPreviewData(r.obj);
if (r.obj.length === 0) messageApi.info(t('pages.xray.outboundSub.previewEmpty'));
} else {
messageApi.error(r?.msg || t('pages.xray.outboundSub.previewEmpty'));
}
} catch {
messageApi.error(t('pages.xray.outboundSub.previewEmpty'));
} finally {
setPreviewing(false);
}
}
async function toggleEnabled(sub: OutboundSub) {
setBusyId(sub.id);
try {
const r = await HttpUtil.post(`/panel/api/xray/outbound-subs/${sub.id}`, subBody({ ...sub, enabled: !sub.enabled }));
if (r?.success) {
await loadSubs();
onRefreshXrayData?.();
} else {
messageApi.error(r?.msg || t('pages.xray.outboundSub.toastAddFailed'));
}
} catch {
messageApi.error(t('pages.xray.outboundSub.toastAddFailed'));
} finally {
setBusyId(null);
}
}
async function moveSub(id: number, dir: 'up' | 'down') {
setBusyId(id);
try {
const r = await HttpUtil.post(`/panel/api/xray/outbound-subs/${id}/move`, { dir });
if (r?.success) {
await loadSubs();
onRefreshXrayData?.();
}
} catch {
/* ignore */
} finally {
setBusyId(null);
}
}
async function refreshOne(id: number) {
setRefreshingId(id);
try {
const r = await HttpUtil.post(`/panel/api/xray/outbound-subs/${id}/refresh`);
if (r?.success) {
messageApi.success(t('pages.xray.outboundSub.toastRefreshed'));
await loadSubs();
onRefreshXrayData?.();
} else {
messageApi.error(r?.msg || t('pages.xray.outboundSub.toastRefreshFailed'));
}
} catch {
messageApi.error(t('pages.xray.outboundSub.toastRefreshFailed'));
} finally {
setRefreshingId(null);
}
}
async function refreshAllSubs() {
if (subs.length === 0) return;
setRefreshingAll(true);
try {
for (const s of subs) {
try { await HttpUtil.post(`/panel/api/xray/outbound-subs/${s.id}/refresh`); } catch { /* continue */ }
}
messageApi.success(t('pages.xray.outboundSub.toastRefreshed'));
await loadSubs();
onRefreshXrayData?.();
} finally {
setRefreshingAll(false);
}
}
async function deleteOne(id: number) {
try {
const r = await HttpUtil.post(`/panel/api/xray/outbound-subs/${id}/del`);
if (r?.success) {
messageApi.success(t('pages.xray.outboundSub.toastDeleted'));
await loadSubs();
onRefreshXrayData?.();
}
} catch {
messageApi.error(t('pages.xray.outboundSub.toastDeleteFailed'));
}
}
const columns = useOutboundColumns({
testMode,
rows,
@@ -164,6 +402,7 @@ export default function OutboundsTab({
return (
<>
{modalContextHolder}
{messageContextHolder}
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
<Row gutter={[12, 12]} align="middle" justify="space-between">
<Col xs={24} sm={12}>
@@ -171,12 +410,20 @@ export default function OutboundsTab({
<Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>
{!isMobile && t('pages.xray.Outbounds')}
</Button>
<Button type="primary" icon={<CloudOutlined />} onClick={onShowWarp}>
WARP
</Button>
<Button type="primary" icon={<ApiOutlined />} onClick={onShowNord}>
NordVPN
<Button icon={<CloudOutlined />} onClick={openSubManager}>
{t('pages.xray.outboundSub.manage')}
</Button>
<Dropdown
trigger={['click']}
menu={{
items: [
{ key: 'warp', icon: <CloudOutlined />, label: 'WARP', onClick: onShowWarp },
{ key: 'nord', icon: <ApiOutlined />, label: 'NordVPN', onClick: onShowNord },
],
}}
>
<Button icon={<MoreOutlined />}>{t('more')}</Button>
</Dropdown>
</Space>
</Col>
<Col xs={24} sm={12} className="toolbar-right">
@@ -232,7 +479,182 @@ export default function OutboundsTab({
onClose={() => setModalOpen(false)}
onConfirm={onConfirm}
/>
{/* Subscription outbounds (read-only, merged at runtime) */}
{Array.isArray(subscriptionOutbounds) && subscriptionOutbounds.length > 0 && (
<SubscriptionOutbounds
subscriptionOutbounds={subscriptionOutbounds}
outboundsTraffic={outboundsTraffic}
subscriptionTestStates={subscriptionTestStates}
testMode={testMode}
isMobile={isMobile}
onTestSubscription={onTestSubscription}
/>
)}
</Space>
<Modal
title={t('pages.xray.outboundSub.title')}
open={subDrawerOpen}
onCancel={() => setSubDrawerOpen(false)}
footer={null}
width={isMobile ? '100%' : 640}
destroyOnHidden
>
<Space orientation="vertical" style={{ width: '100%' }} size="large">
<div>
{editingSubId != null && (
<div style={{ marginBottom: 8, display: 'flex', alignItems: 'center', gap: 8 }}>
<Tag color="blue">{t('edit')}</Tag>
<span style={{ fontWeight: 600 }}>{newSub.remark || newSub.url}</span>
</div>
)}
<Form layout="vertical" size="small">
<Form.Item label={t('pages.xray.outboundSub.remark')}>
<Input value={newSub.remark} onChange={(e) => setNewSub({ ...newSub, remark: e.target.value })} placeholder={t('pages.xray.outboundSub.remarkPlaceholder')} />
</Form.Item>
<Form.Item label={t('pages.xray.outboundSub.url')} required>
<Input value={newSub.url} onChange={(e) => setNewSub({ ...newSub, url: e.target.value })} placeholder={t('pages.xray.outboundSub.urlPlaceholder')} />
</Form.Item>
<Form.Item label={t('pages.xray.outboundSub.tagPrefix')}>
<Input value={newSub.tagPrefix} onChange={(e) => setNewSub({ ...newSub, tagPrefix: e.target.value })} placeholder={t('pages.xray.outboundSub.tagPrefixPlaceholder')} />
</Form.Item>
<Form.Item label={t('pages.xray.outboundSub.interval')}>
<Space>
<InputNumber
min={0}
value={intervalHours}
onChange={(v) => setIntervalHM(Number(v) || 0, intervalMinutes)}
style={{ width: 80 }}
/> {t('pages.xray.outboundSub.hours')}
<InputNumber
min={0}
max={59}
value={intervalMinutes}
onChange={(v) => setIntervalHM(intervalHours, Number(v) || 0)}
style={{ width: 80 }}
/> {t('pages.xray.outboundSub.minutes')}
</Space>
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
{t('pages.xray.outboundSub.intervalHint')}
</div>
</Form.Item>
<Form.Item label={t('pages.xray.outboundSub.enabled')}>
<Switch checked={newSub.enabled} onChange={(v) => setNewSub({ ...newSub, enabled: v })} />
</Form.Item>
<Form.Item label={t('pages.xray.outboundSub.allowPrivate')}>
<Switch checked={newSub.allowPrivate} onChange={(v) => setNewSub({ ...newSub, allowPrivate: v })} />
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
{t('pages.xray.outboundSub.allowPrivateHint')}
</div>
</Form.Item>
<Form.Item label={t('pages.xray.outboundSub.prepend')}>
<Switch checked={newSub.prepend} onChange={(v) => setNewSub({ ...newSub, prepend: v })} />
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
{t('pages.xray.outboundSub.prependHint')}
</div>
</Form.Item>
<Space wrap>
<Button type="primary" onClick={saveSub} loading={savingSub} icon={editingSubId != null ? <EditOutlined /> : <PlusOutlined />}>
{editingSubId != null ? t('save') : t('pages.xray.outboundSub.addButton')}
</Button>
<Button onClick={previewSub} loading={previewing} icon={<EyeOutlined />}>
{t('pages.xray.outboundSub.preview')}
</Button>
{editingSubId != null && <Button onClick={resetSubForm}>{t('cancel')}</Button>}
</Space>
{previewData && previewData.length > 0 && (
<div style={{ marginTop: 8 }}>
<div style={{ fontSize: 12, color: '#888', marginBottom: 4 }}>{previewData.length} · {t('pages.xray.Outbounds')}</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, maxHeight: 120, overflow: 'auto' }}>
{previewData.map((o, i) => (
<Tag key={i}>{o?.tag || '—'}{o?.protocol ? ` · ${o.protocol}` : ''}</Tag>
))}
</div>
</div>
)}
</Form>
</div>
<div>
<div style={{ fontWeight: 600, marginBottom: 8, display: 'flex', alignItems: 'center', gap: 8 }}>
{t('pages.xray.outboundSub.active')}
<Button size="small" icon={<ReloadOutlined />} onClick={loadSubs} loading={subsLoading} />
{subs.length > 0 && (
<Button size="small" type="primary" icon={<ReloadOutlined />} onClick={refreshAllSubs} loading={refreshingAll}>
{t('pages.xray.outboundSub.refreshAll')}
</Button>
)}
</div>
{subs.length === 0 ? (
<div style={{ color: '#888' }}>{t('pages.xray.outboundSub.empty')}</div>
) : (
<Table
size="small"
dataSource={subs}
rowKey={(r) => r.id}
pagination={false}
scroll={{ x: true }}
columns={[
{
title: '',
key: 'order',
width: 56,
render: (_: unknown, r: OutboundSub, index: number) => (
<Space size={0}>
<Button type="text" size="small" icon={<ArrowUpOutlined />} disabled={index === 0 || busyId === r.id} onClick={() => moveSub(r.id, 'up')} />
<Button type="text" size="small" icon={<ArrowDownOutlined />} disabled={index === subs.length - 1 || busyId === r.id} onClick={() => moveSub(r.id, 'down')} />
</Space>
),
},
{
title: t('pages.xray.outboundSub.colRemark'),
key: 'remark',
render: (_: unknown, r: OutboundSub) => (
<div>
<div>{r.remark || <em>{t('pages.xray.outboundSub.auto')}</em>}</div>
{r.tagPrefix && <div style={{ fontSize: 11, color: '#888' }}>{r.tagPrefix}</div>}
</div>
),
},
{ title: t('pages.xray.Outbounds'), dataIndex: 'outboundCount', key: 'outboundCount', align: 'center', render: (v) => v ?? 0 },
{
title: t('status'),
key: 'status',
align: 'center',
render: (_: unknown, r: OutboundSub) => (r.lastError
? <Tooltip title={r.lastError}><WarningOutlined style={{ color: '#e04141' }} /></Tooltip>
: <Tooltip title={t('pages.xray.outboundSub.statusOk')}><CheckCircleOutlined style={{ color: '#008771' }} /></Tooltip>),
},
{ title: t('pages.xray.outboundSub.colLastFetch'), dataIndex: 'lastUpdated', key: 'lastUpdated', render: (v: number) => v ? new Date(v * 1000).toLocaleString() : t('pages.xray.outboundSub.never') },
{
title: t('pages.xray.outboundSub.colEnabled'),
key: 'enabled',
align: 'center',
render: (_: unknown, r: OutboundSub) => <Switch size="small" checked={!!r.enabled} loading={busyId === r.id} onChange={() => toggleEnabled(r)} />,
},
{
title: '',
key: 'actions',
render: (_: unknown, r: OutboundSub) => (
<Space>
<Button size="small" icon={<EditOutlined />} onClick={() => openEditSub(r)} title={t('edit')} />
<Button size="small" icon={<ReloadOutlined />} loading={refreshingId === r.id} onClick={() => refreshOne(r.id)} title={t('pages.xray.outboundSub.refreshNow')} />
<Popconfirm title={t('pages.xray.outboundSub.deleteConfirm')} okText={t('delete')} cancelText={t('cancel')} onConfirm={() => deleteOne(r.id)}>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
]}
/>
)}
<div style={{ marginTop: 8, fontSize: 12, color: '#666' }}>
{t('pages.xray.outboundSub.restartHint')}
</div>
</div>
</Space>
</Modal>
</>
);
}
@@ -0,0 +1,207 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Popover, Table, Tag, Tooltip } from 'antd';
import {
ThunderboltOutlined,
CheckCircleFilled,
CloseCircleFilled,
LoadingOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { SizeFormatter } from '@/utils';
import { OutboundProtocols as Protocols } from '@/schemas/primitives';
import { isUdpOutbound } from '@/hooks/useXraySetting';
import type { OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
import type { OutboundRow } from './outbounds-tab-types';
import {
hasBreakdown,
isTesting,
isUntestable,
outboundAddresses,
showSecurity,
testResult,
trafficFor,
} from './outbounds-tab-helpers';
interface SubscriptionOutboundsProps {
subscriptionOutbounds: unknown[];
outboundsTraffic: OutboundTrafficRow[];
subscriptionTestStates: Record<string, OutboundTestState>;
testMode: 'tcp' | 'http';
isMobile: boolean;
onTestSubscription: (outbound: Record<string, unknown>, mode: string) => void;
}
// Read-only view of outbounds imported from active subscriptions. They are not
// part of the editable template (so no edit/delete/move), but traffic is matched
// by tag and they can be latency-tested via the same backend endpoint.
export default function SubscriptionOutbounds({
subscriptionOutbounds,
outboundsTraffic,
subscriptionTestStates,
testMode,
isMobile,
onTestSubscription,
}: SubscriptionOutboundsProps) {
const { t } = useTranslation();
const rows = useMemo<OutboundRow[]>(
() => (subscriptionOutbounds || []).map((o, i) => ({ ...(o as object), key: i }) as OutboundRow),
[subscriptionOutbounds],
);
if (rows.length === 0) return null;
const identityCell = (record: OutboundRow) => (
<div className="identity-cell">
<Tooltip title={record.tag}>
<span className="tag-name">{record.tag || '—'}</span>
</Tooltip>
<div className="protocol-line">
<Tag color="green">{record.protocol}</Tag>
{[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(record.protocol as never) && (
<>
<Tag>{record.streamSettings?.network}</Tag>
{showSecurity(record.streamSettings?.security) && <Tag color="purple">{record.streamSettings?.security}</Tag>}
</>
)}
</div>
</div>
);
const addressCell = (record: OutboundRow) => {
const addrs = outboundAddresses(record);
return (
<div className="address-list">
{addrs.length === 0 ? (
<span className="empty">—</span>
) : (
addrs.map((addr) => (
<Tooltip key={addr} title={addr}>
<span className="address-pill">{addr}</span>
</Tooltip>
))
)}
</div>
);
};
const trafficCell = (record: OutboundRow) => {
const tr = trafficFor(outboundsTraffic, record);
return (
<>
<span className="traffic-up">↑ {SizeFormatter.sizeFormat(tr.up)}</span>
<span className="traffic-sep" />
<span className="traffic-down">↓ {SizeFormatter.sizeFormat(tr.down)}</span>
</>
);
};
const latencyCell = (record: OutboundRow) => {
const key = record.tag || '';
const r = testResult(subscriptionTestStates, key);
if (!r) return isTesting(subscriptionTestStates, key) ? <LoadingOutlined /> : <span className="empty">—</span>;
return (
<Popover
placement="topLeft"
rootClassName="outbound-test-popover"
content={
<div className="timing-breakdown">
<div className={`td-head ${r.success ? 'ok' : 'fail'}`}>
{r.success ? <span>{r.delay} ms</span> : <span>{r.error || 'failed'}</span>}
{r.mode && <span className="mode-badge">{String(r.mode).toUpperCase()}</span>}
</div>
{hasBreakdown(r) && (
<>
{(r.endpoints || []).map((ep) => (
<div key={ep.address} className="endpoint-row">
<span className={ep.success ? 'dot-ok' : 'dot-fail'}>●</span>
<span className="ep-addr">{ep.address}</span>
<span className="ep-meta">{ep.success ? `${ep.delay} ms` : ep.error || 'failed'}</span>
</div>
))}
</>
)}
</div>
}
>
<span className={r.success ? 'pill-ok' : 'pill-fail'}>
{r.success ? <CheckCircleFilled /> : <CloseCircleFilled />}
{r.success ? <span>{r.delay}&nbsp;ms</span> : <span>failed</span>}
</span>
</Popover>
);
};
const testButton = (record: OutboundRow) => {
const key = record.tag || '';
return (
<Tooltip title={`${t('check')} (${(isUdpOutbound(record) ? 'http' : testMode).toUpperCase()})`}>
<Button
type="primary"
shape="circle"
size={isMobile ? 'small' : undefined}
loading={isTesting(subscriptionTestStates, key)}
disabled={!record.tag || isUntestable(record, testMode) || isTesting(subscriptionTestStates, key)}
icon={<ThunderboltOutlined />}
onClick={() => onTestSubscription(record as unknown as Record<string, unknown>, testMode)}
/>
</Tooltip>
);
};
const header = (
<div className="subscription-outbounds-head">
<div className="subscription-outbounds-title">{t('pages.xray.outboundSub.fromSubsTitle')}</div>
<div className="subscription-outbounds-desc">{t('pages.xray.outboundSub.fromSubsDesc')}</div>
</div>
);
if (isMobile) {
return (
<div className="subscription-outbounds" style={{ marginTop: 16 }}>
{header}
{rows.map((record, index) => (
<div key={record.key} className="outbound-card">
<div className="card-head">
<div className="card-identity">
<span className="card-num">{index + 1}</span>
{identityCell(record)}
</div>
{testButton(record)}
</div>
{outboundAddresses(record).length > 0 && addressCell(record)}
<div className="card-foot">
{trafficCell(record)}
<span className="card-test">{latencyCell(record)}</span>
</div>
</div>
))}
</div>
);
}
const columns: ColumnsType<OutboundRow> = [
{
title: '#',
key: 'num',
align: 'center',
width: 60,
render: (_v, _record, index) => <span className="row-index">{index + 1}</span>,
},
{ title: t('pages.xray.outbound.tag'), key: 'identity', align: 'left', render: (_v, record) => identityCell(record) },
{ title: t('pages.inbounds.address'), key: 'address', align: 'left', render: (_v, record) => addressCell(record) },
{ title: t('pages.inbounds.traffic'), key: 'traffic', align: 'left', width: 200, render: (_v, record) => trafficCell(record) },
{ title: t('pages.nodes.latency'), key: 'testResult', align: 'left', width: 140, render: (_v, record) => latencyCell(record) },
{ title: t('check'), key: 'test', align: 'center', width: 80, render: (_v, record) => testButton(record) },
];
return (
<div className="subscription-outbounds" style={{ marginTop: 16 }}>
{header}
<Table columns={columns} dataSource={rows} rowKey={(r) => r.key} pagination={false} size="small" />
</div>
);
}
@@ -53,10 +53,10 @@ export function trafficFor(outboundsTraffic: OutboundTrafficRow[], o: OutboundRo
return { up: tr?.up || 0, down: tr?.down || 0 };
}
export function isTesting(states: Record<number, OutboundTestState>, idx: number): boolean {
export function isTesting<K extends string | number>(states: Record<K, OutboundTestState>, idx: K): boolean {
return !!states?.[idx]?.testing;
}
export function testResult(states: Record<number, OutboundTestState>, idx: number) {
export function testResult<K extends string | number>(states: Record<K, OutboundTestState>, idx: K) {
return states?.[idx]?.result || null;
}
+89 -21
View File
@@ -80,6 +80,7 @@ export default function WarpModal({
const [warpData, setWarpData] = useState<WarpData | null>(null);
const [warpConfig, setWarpConfig] = useState<WarpConfig | null>(null);
const [warpPlus, setWarpPlus] = useState('');
const [updateInterval, setUpdateInterval] = useState<number>(0);
const [licenseError, setLicenseError] = useState('');
const [stagedOutbound, setStagedOutbound] = useState<Record<string, unknown> | null>(null);
@@ -89,24 +90,29 @@ export default function WarpModal({
return list.findIndex((o) => o?.tag === 'warp');
}, [templateSettings?.outbounds]);
const collectConfig = useCallback((data: WarpData | null, config: WarpConfig | null) => {
const cfg = config?.config;
if (!cfg?.peers?.length) return;
const peer = cfg.peers[0];
setStagedOutbound({
tag: 'warp',
protocol: 'wireguard',
settings: {
mtu: 1420,
secretKey: data?.private_key,
address: addressesFor(cfg.interface?.addresses || {}),
reserved: reservedFor(cfg.client_id ?? data?.client_id),
domainStrategy: 'ForceIP',
peers: [{ publicKey: peer.public_key, endpoint: peer.endpoint?.host }],
noKernelTun: false,
},
});
}, []);
const collectConfig = useCallback(
(data: WarpData | null, config: WarpConfig | null): Record<string, unknown> | null => {
const cfg = config?.config;
if (!cfg?.peers?.length) return null;
const peer = cfg.peers[0];
const outbound: Record<string, unknown> = {
tag: 'warp',
protocol: 'wireguard',
settings: {
mtu: 1420,
secretKey: data?.private_key,
address: addressesFor(cfg.interface?.addresses || {}),
reserved: reservedFor(cfg.client_id ?? data?.client_id),
domainStrategy: 'ForceIP',
peers: [{ publicKey: peer.public_key, endpoint: peer.endpoint?.host }],
noKernelTun: false,
},
};
setStagedOutbound(outbound);
return outbound;
},
[],
);
const fetchData = useCallback(async () => {
setLoading(true);
@@ -116,6 +122,10 @@ export default function WarpModal({
const raw = msg.obj;
setWarpData(raw && raw.length > 0 ? JSON.parse(raw) : null);
}
const settingMsg = await HttpUtil.post<Record<string, unknown>>('/panel/api/setting/all');
if (settingMsg?.success && settingMsg.obj) {
setUpdateInterval(Number(settingMsg.obj.warpUpdateInterval) || 0);
}
} finally {
setLoading(false);
}
@@ -159,6 +169,40 @@ export default function WarpModal({
}
}
async function changeIp() {
setLoading(true);
try {
const msg = await HttpUtil.post<string>('/panel/api/xray/warp/changeIp');
if (msg?.success && msg.obj) {
const parsed = JSON.parse(msg.obj);
setWarpData(parsed.data);
setWarpConfig(parsed.config);
const built = collectConfig(parsed.data, parsed.config);
// The backend already persisted the new keys into the saved Xray
// template; keep the in-memory editor in sync so a later template
// save doesn't revert them to the old keys.
if (built && warpOutboundIndex >= 0) {
onResetOutbound({ index: warpOutboundIndex, outbound: built });
}
messageApi.success(t('pages.xray.warp.changeIpSuccess', 'WARP IP changed successfully!'));
}
} finally {
setLoading(false);
}
}
async function saveInterval() {
setLoading(true);
try {
const msg = await HttpUtil.post('/panel/api/xray/warp/interval', { interval: updateInterval });
if (msg?.success) {
messageApi.success(t('pages.setting.toasts.saveSuccess', 'Settings saved successfully'));
}
} finally {
setLoading(false);
}
}
async function updateLicense() {
if (warpPlus.length < 26) return;
setLoading(true);
@@ -281,13 +325,37 @@ export default function WarpModal({
</Form>
),
},
{
key: '2',
label: t('pages.xray.warp.autoUpdateIp', 'Auto Update IP Address'),
children: (
<Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 12 } }}>
<Form.Item label={t('pages.xray.warp.intervalDays', 'Interval (Days)')} extra={t('pages.xray.warp.intervalDesc', '0 to disable. Changes IP address automatically.')}>
<Input
type="number"
min={0}
value={updateInterval}
onChange={(e) => setUpdateInterval(Number(e.target.value))}
/>
<Button className="mt-8" type="primary" loading={loading} onClick={saveInterval}>
{t('save', 'Save')}
</Button>
</Form.Item>
</Form>
),
},
]}
/>
<Divider className="zero-margin">{t('pages.xray.warp.accountInfo')}</Divider>
<Button className="my-8" loading={loading} type="primary" icon={<SyncOutlined />} onClick={getConfig}>
{t('refresh')}
</Button>
<div className="my-8">
<Button loading={loading} type="primary" icon={<SyncOutlined />} onClick={getConfig}>
{t('refresh')}
</Button>
<Button loading={loading} type="primary" className="ml-8" icon={<SyncOutlined />} onClick={changeIp}>
{t('pages.xray.warp.changeIp', 'Change IP')}
</Button>
</div>
{hasConfig && (
<>
@@ -20,6 +20,7 @@ interface RoutingTabProps {
setTemplateSettings: SetTemplate;
inboundTags: string[];
clientReverseTags: string[];
subscriptionOutboundTags?: string[];
isMobile: boolean;
}
@@ -28,6 +29,7 @@ export default function RoutingTab({
setTemplateSettings,
inboundTags,
clientReverseTags,
subscriptionOutboundTags,
isMobile,
}: RoutingTabProps) {
const { t } = useTranslation();
@@ -116,8 +118,11 @@ export default function RoutingTab({
for (const tag of clientReverseTags || []) {
if (tag) out.add(tag);
}
for (const tag of subscriptionOutboundTags || []) {
if (tag) out.add(tag);
}
return [...out];
}, [templateSettings?.outbounds, clientReverseTags]);
}, [templateSettings?.outbounds, clientReverseTags, subscriptionOutboundTags]);
const balancerTagOptions = useMemo(() => {
const out: string[] = [''];
+1
View File
@@ -126,6 +126,7 @@ export const ActiveInboundsByNodeSchema = z
export const GroupSummarySchema = z.object({
name: z.string(),
clientCount: z.number(),
trafficUsed: z.number().nullable().transform((v) => v ?? 0),
});
export const GroupSummaryListSchema = z.array(GroupSummarySchema).nullable().transform((v) => v ?? []);
+8
View File
@@ -23,6 +23,11 @@ export const NodeRecordSchema = z.object({
depletedCount: z.number().optional(),
lastHeartbeat: z.number().optional(),
lastError: z.string().optional(),
// Xray state captured from the remote node's own /panel/api/server/status.
// Lets the nodes list show a distinct indicator when the panel API is reachable
// (status=online) but the Xray core on that node has failed.
xrayState: z.string().optional(),
xrayError: z.string().optional(),
allowPrivateAddress: z.boolean().optional(),
tlsVerifyMode: z.enum(['verify', 'skip', 'pin']).optional(),
pinnedCertSha256: z.string().optional(),
@@ -40,6 +45,9 @@ export const ProbeResultSchema = z.object({
latencyMs: z.number().optional(),
xrayVersion: z.string().optional(),
error: z.string().optional(),
// Present on successful probe; used to surface "connected to panel, but xray failed on node".
xrayState: z.string().optional(),
xrayError: z.string().optional(),
}).loose();
export const NodeFormSchema = z.object({
@@ -11,6 +11,7 @@ export const ProtocolSchema = z.enum([
'mixed',
'tunnel',
'tun',
'mtproto',
]);
export type Protocol = z.infer<typeof ProtocolSchema>;
@@ -31,4 +32,5 @@ export const Protocols = Object.freeze({
MIXED: 'mixed',
TUNNEL: 'tunnel',
TUN: 'tun',
MTPROTO: 'mtproto',
});
@@ -3,6 +3,7 @@ import { z } from 'zod';
import { HttpInboundSettingsSchema } from './http';
import { HysteriaInboundSettingsSchema } from './hysteria';
import { MixedInboundSettingsSchema } from './mixed';
import { MtprotoInboundSettingsSchema } from './mtproto';
import { ShadowsocksInboundSettingsSchema } from './shadowsocks';
import { TrojanInboundSettingsSchema } from './trojan';
import { TunInboundSettingsSchema } from './tun';
@@ -14,6 +15,7 @@ import { WireguardInboundSettingsSchema } from './wireguard';
export * from './http';
export * from './hysteria';
export * from './mixed';
export * from './mtproto';
export * from './shadowsocks';
export * from './trojan';
export * from './tun';
@@ -38,5 +40,6 @@ export const InboundSettingsSchema = z.discriminatedUnion('protocol', [
z.object({ protocol: z.literal('mixed'), settings: MixedInboundSettingsSchema }),
z.object({ protocol: z.literal('tunnel'), settings: TunnelInboundSettingsSchema }),
z.object({ protocol: z.literal('tun'), settings: TunInboundSettingsSchema }),
z.object({ protocol: z.literal('mtproto'), settings: MtprotoInboundSettingsSchema }),
]);
export type InboundSettings = z.infer<typeof InboundSettingsSchema>;
@@ -0,0 +1,10 @@
import { z } from 'zod';
// MTProto (Telegram) inbound. Served by an mtg sidecar process, not Xray, so
// it has no clients and no stream settings. `secret` is the FakeTLS secret
// (ee-prefixed); the backend rebuilds it to match `fakeTlsDomain` on save.
export const MtprotoInboundSettingsSchema = z.object({
fakeTlsDomain: z.string().default('www.cloudflare.com'),
secret: z.string().default(''),
});
export type MtprotoInboundSettings = z.infer<typeof MtprotoInboundSettingsSchema>;
@@ -22,6 +22,9 @@ export const UtlsFingerprintSchema = z.enum([
]);
export type UtlsFingerprint = z.infer<typeof UtlsFingerprintSchema>;
export const TlsFingerprintSchema = z.union([UtlsFingerprintSchema, z.literal('')]);
export type TlsFingerprint = z.infer<typeof TlsFingerprintSchema>;
export const AlpnSchema = z.enum(['h3', 'h2', 'http/1.1']);
export type Alpn = z.infer<typeof AlpnSchema>;
@@ -51,7 +54,7 @@ export const TlsCertSchema = z.union([TlsCertFileSchema, TlsCertInlineSchema]);
export type TlsCert = z.infer<typeof TlsCertSchema>;
export const TlsClientSettingsSchema = z.object({
fingerprint: UtlsFingerprintSchema.default('chrome'),
fingerprint: TlsFingerprintSchema.default('chrome'),
echConfigList: z.string().default(''),
pinnedPeerCertSha256: z.array(z.string()).default([]),
});
+5
View File
@@ -40,6 +40,11 @@ export const XrayConfigPayloadSchema = z.object({
inboundTags: z.array(z.string()).optional(),
clientReverseTags: z.array(z.string()).optional(),
outboundTestUrl: z.string().optional(),
// Subscription outbounds are injected at runtime (not persisted in xraySetting).
// They are provided here so the UI can display them and use their tags in
// balancers / routing rules.
subscriptionOutbounds: z.array(z.unknown()).optional(),
subscriptionOutboundTags: z.array(z.string()).optional(),
}).loose();
export const OutboundTrafficRowSchema = z.object({
+1
View File
@@ -45,6 +45,7 @@
.nodes-page .ant-card.ant-card-hoverable:hover,
.groups-page .ant-card.ant-card-hoverable:hover,
.api-docs-page .ant-card.ant-card-hoverable:hover {
cursor: default;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08);
}
+23
View File
@@ -41,3 +41,26 @@
@media (prefers-reduced-motion: reduce) {
.online-dot { animation: none; }
}
/* Purple indicator for nodes that are reachable via the panel API (status=online)
but have Xray core in "error" or "stop" state. This is the new "xray failed on node"
monitoring state. */
.xray-error-dot {
display: inline-block;
width: 7px;
height: 7px;
border-radius: 50%;
margin-inline-end: 5px;
vertical-align: middle;
background: #722ED1;
animation: xray-error-blink 1.1s ease-in-out infinite;
}
@keyframes xray-error-blink {
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(114, 46, 209, 0.55); }
50% { opacity: 0.35; box-shadow: 0 0 0 4px rgba(114, 46, 209, 0); }
}
@media (prefers-reduced-motion: reduce) {
.xray-error-dot { animation: none; }
}
@@ -504,6 +504,174 @@ exports[`protocol capability predicates > mixed-basic :: xhttp/tls 1`] = `
}
`;
exports[`protocol capability predicates > mtproto-basic :: grpc/none 1`] = `
{
"canEnableReality": false,
"canEnableStream": false,
"canEnableTls": false,
"canEnableTlsFlow": false,
"canEnableVisionSeed": false,
"isSS2022": false,
"isSSMultiUser": true,
}
`;
exports[`protocol capability predicates > mtproto-basic :: grpc/reality 1`] = `
{
"canEnableReality": false,
"canEnableStream": false,
"canEnableTls": false,
"canEnableTlsFlow": false,
"canEnableVisionSeed": false,
"isSS2022": false,
"isSSMultiUser": true,
}
`;
exports[`protocol capability predicates > mtproto-basic :: grpc/tls 1`] = `
{
"canEnableReality": false,
"canEnableStream": false,
"canEnableTls": false,
"canEnableTlsFlow": false,
"canEnableVisionSeed": false,
"isSS2022": false,
"isSSMultiUser": true,
}
`;
exports[`protocol capability predicates > mtproto-basic :: httpupgrade/none 1`] = `
{
"canEnableReality": false,
"canEnableStream": false,
"canEnableTls": false,
"canEnableTlsFlow": false,
"canEnableVisionSeed": false,
"isSS2022": false,
"isSSMultiUser": true,
}
`;
exports[`protocol capability predicates > mtproto-basic :: httpupgrade/tls 1`] = `
{
"canEnableReality": false,
"canEnableStream": false,
"canEnableTls": false,
"canEnableTlsFlow": false,
"canEnableVisionSeed": false,
"isSS2022": false,
"isSSMultiUser": true,
}
`;
exports[`protocol capability predicates > mtproto-basic :: kcp/none 1`] = `
{
"canEnableReality": false,
"canEnableStream": false,
"canEnableTls": false,
"canEnableTlsFlow": false,
"canEnableVisionSeed": false,
"isSS2022": false,
"isSSMultiUser": true,
}
`;
exports[`protocol capability predicates > mtproto-basic :: tcp/none 1`] = `
{
"canEnableReality": false,
"canEnableStream": false,
"canEnableTls": false,
"canEnableTlsFlow": false,
"canEnableVisionSeed": false,
"isSS2022": false,
"isSSMultiUser": true,
}
`;
exports[`protocol capability predicates > mtproto-basic :: tcp/reality 1`] = `
{
"canEnableReality": false,
"canEnableStream": false,
"canEnableTls": false,
"canEnableTlsFlow": false,
"canEnableVisionSeed": false,
"isSS2022": false,
"isSSMultiUser": true,
}
`;
exports[`protocol capability predicates > mtproto-basic :: tcp/tls 1`] = `
{
"canEnableReality": false,
"canEnableStream": false,
"canEnableTls": false,
"canEnableTlsFlow": false,
"canEnableVisionSeed": false,
"isSS2022": false,
"isSSMultiUser": true,
}
`;
exports[`protocol capability predicates > mtproto-basic :: ws/none 1`] = `
{
"canEnableReality": false,
"canEnableStream": false,
"canEnableTls": false,
"canEnableTlsFlow": false,
"canEnableVisionSeed": false,
"isSS2022": false,
"isSSMultiUser": true,
}
`;
exports[`protocol capability predicates > mtproto-basic :: ws/tls 1`] = `
{
"canEnableReality": false,
"canEnableStream": false,
"canEnableTls": false,
"canEnableTlsFlow": false,
"canEnableVisionSeed": false,
"isSS2022": false,
"isSSMultiUser": true,
}
`;
exports[`protocol capability predicates > mtproto-basic :: xhttp/none 1`] = `
{
"canEnableReality": false,
"canEnableStream": false,
"canEnableTls": false,
"canEnableTlsFlow": false,
"canEnableVisionSeed": false,
"isSS2022": false,
"isSSMultiUser": true,
}
`;
exports[`protocol capability predicates > mtproto-basic :: xhttp/reality 1`] = `
{
"canEnableReality": false,
"canEnableStream": false,
"canEnableTls": false,
"canEnableTlsFlow": false,
"canEnableVisionSeed": false,
"isSS2022": false,
"isSSMultiUser": true,
}
`;
exports[`protocol capability predicates > mtproto-basic :: xhttp/tls 1`] = `
{
"canEnableReality": false,
"canEnableStream": false,
"canEnableTls": false,
"canEnableTlsFlow": false,
"canEnableVisionSeed": false,
"isSS2022": false,
"isSSMultiUser": true,
}
`;
exports[`protocol capability predicates > shadowsocks-2022 :: grpc/none 1`] = `
{
"canEnableReality": false,
@@ -59,6 +59,16 @@ exports[`InboundSettingsSchema fixtures > parses mixed-basic byte-stably 1`] = `
}
`;
exports[`InboundSettingsSchema fixtures > parses mtproto-basic byte-stably 1`] = `
{
"protocol": "mtproto",
"settings": {
"fakeTlsDomain": "www.cloudflare.com",
"secret": "ee0123456789abcdef0123456789abcdef7777772e636c6f7564666c6172652e636f6d",
},
}
`;
exports[`InboundSettingsSchema fixtures > parses shadowsocks-2022 byte-stably 1`] = `
{
"protocol": "shadowsocks",
@@ -0,0 +1,7 @@
{
"protocol": "mtproto",
"settings": {
"fakeTlsDomain": "www.cloudflare.com",
"secret": "ee0123456789abcdef0123456789abcdef7777772e636c6f7564666c6172652e636f6d"
}
}
@@ -16,6 +16,7 @@ import {
createDefaultVmessInboundSettings,
createDefaultWireguardInboundSettings,
} from '@/lib/xray/inbound-defaults';
import { createHysteriaTlsSettingsWithDefaultCert } from '@/lib/xray/inbound-tls-defaults';
import { HttpInboundSettingsSchema } from '@/schemas/protocols/inbound/http';
import { HysteriaClientSchema, HysteriaInboundSettingsSchema } from '@/schemas/protocols/inbound/hysteria';
import { MixedInboundSettingsSchema } from '@/schemas/protocols/inbound/mixed';
@@ -147,3 +148,18 @@ describe('createDefault*InboundSettings factories', () => {
expect(WireguardInboundSettingsSchema.parse(s)).toEqual(s);
});
});
describe('createHysteriaTlsSettingsWithDefaultCert', () => {
it('defaults Hysteria TLS to uTLS None and h3 ALPN', () => {
const tls = createHysteriaTlsSettingsWithDefaultCert();
expect(tls.alpn).toEqual(['h3']);
expect((tls.settings as Record<string, unknown>).fingerprint).toBe('');
expect(tls.certificates).toEqual([
expect.objectContaining({
useFile: true,
certificateFile: '',
keyFile: '',
}),
]);
});
});
+80
View File
@@ -0,0 +1,80 @@
import { describe, it, expect } from 'vitest';
import { parseLogLine } from '@/pages/index/logParse';
// Fixtures are real lines captured from `journalctl -u x-ui` on a production
// host (the SysLog view) plus the in-memory app-log format. Each journald entry
// carries a "Mon DD HH:MM:SS host ident[pid]: " prefix that the viewer used to
// mistake for the level, leaving only a bare timestamp on screen.
describe('parseLogLine — SysLog (journalctl) formats', () => {
it('x-ui go-logging line: keeps level, strips prefix, tags X-UI', () => {
const r = parseLogLine(
'Jun 08 23:57:28 ubuntu-4gb-fsn1-1 /usr/local/x-ui/x-ui[72297]: INFO - mtproto: started mtg for inbound 3 on 0.0.0.0:8443',
);
expect(r.stamp).toBe('Jun 08 23:57:28');
expect(r.levelText).toBe('INFO');
expect(r.service).toBe('X-UI:');
expect(r.body).toBe('mtproto: started mtg for inbound 3 on 0.0.0.0:8443');
});
it('xray go-logging line: lifts the XRAY service tag', () => {
const r = parseLogLine(
'Jun 08 23:56:52 ubuntu-4gb-fsn1-1 /usr/local/x-ui/x-ui[72297]: WARNING - XRAY: core: Xray 26.6.1 started',
);
expect(r.stamp).toBe('Jun 08 23:56:52');
expect(r.levelText).toBe('WARNING');
expect(r.service).toBe('XRAY:');
expect(r.body).toBe('core: Xray 26.6.1 started');
});
it('Go std-log line: strips the redundant embedded date, keeps the message', () => {
const r = parseLogLine(
'Jun 08 19:22:22 ubuntu-4gb-fsn1-1 x-ui[1439]: 2026/06/08 19:22:22 http: TLS handshake error from 18.97.5.1:36022: EOF',
);
expect(r.stamp).toBe('Jun 08 19:22:22');
expect(r.levelText).toBe('');
expect(r.body).toBe('http: TLS handshake error from 18.97.5.1:36022: EOF');
});
it('telego bracketed line: lifts the ERROR level out of "[ts] ERROR ..."', () => {
const r = parseLogLine(
'Jun 09 00:14:52 ubuntu-4gb-fsn1-1 x-ui[72297]: [Tue Jun 9 00:14:52 UTC 2026] ERROR Retrying getting updates in 8s...',
);
expect(r.stamp).toBe('Jun 09 00:14:52');
expect(r.levelText).toBe('ERROR');
expect(r.body).toBe('Retrying getting updates in 8s...');
});
it('systemd line: shows the body rather than a bare timestamp', () => {
const r = parseLogLine(
'Jun 08 23:56:47 ubuntu-4gb-fsn1-1 systemd[1]: Stopping x-ui.service - x-ui Service...',
);
expect(r.stamp).toBe('Jun 08 23:56:47');
expect(r.body).toBe('Stopping x-ui.service - x-ui Service...');
});
it('never collapses a journald entry to just its timestamp', () => {
const r = parseLogLine(
'Jun 09 00:15:00 ubuntu-4gb-fsn1-1 x-ui[72297]: [Tue Jun 9 00:15:00 UTC 2026] ERROR Getting updates: telego: getUpdates: api: 409 "Conflict"',
);
expect(r.body.length).toBeGreaterThan(0);
expect(r.body).toContain('Conflict');
});
});
describe('parseLogLine — app-log format (SysLog off)', () => {
it('parses "YYYY/MM/DD HH:MM:SS LEVEL - body"', () => {
const r = parseLogLine('2026/06/09 00:35:09 INFO - mtproto: started mtg for inbound 3 on 0.0.0.0:8443');
expect(r.date).toBe('2026/06/09');
expect(r.time).toBe('00:35:09');
expect(r.levelText).toBe('INFO');
expect(r.service).toBe('X-UI:');
expect(r.body).toBe('mtproto: started mtg for inbound 3 on 0.0.0.0:8443');
});
it('handles an empty line without throwing', () => {
const r = parseLogLine('');
expect(r.stamp).toBe('');
expect(r.body).toBe('');
});
});
@@ -0,0 +1,61 @@
import { describe, it, expect } from 'vitest';
import type { XraySettingsValue } from '@/hooks/useXraySetting';
import { propagateOutboundTagRename } from '@/pages/xray/basics/helpers';
function baseTemplate(): XraySettingsValue {
return {
outbounds: [
{ tag: 'To-External-Proxy', protocol: 'vless' },
{ tag: 'direct', protocol: 'freedom' },
],
routing: {
rules: [
{
type: 'field',
inboundTag: ['iran-in'],
outboundTag: 'To-External-Proxy',
},
],
balancers: [
{
tag: 'lb-1',
selector: ['To-External-Proxy', 'direct'],
fallbackTag: 'To-External-Proxy',
},
],
},
} as XraySettingsValue;
}
describe('propagateOutboundTagRename', () => {
it('updates routing rule outboundTag when outbound is renamed', () => {
const t = baseTemplate();
propagateOutboundTagRename(t, 'To-External-Proxy', 'external-vps');
expect(t.routing?.rules?.[0]?.outboundTag).toBe('external-vps');
});
it('updates balancer selector and fallbackTag', () => {
const t = baseTemplate();
propagateOutboundTagRename(t, 'To-External-Proxy', 'external-vps');
expect(t.routing?.balancers?.[0]?.selector).toEqual(['external-vps', 'direct']);
expect(t.routing?.balancers?.[0]?.fallbackTag).toBe('external-vps');
});
it('updates sockopt dialerProxy references in other outbounds', () => {
const t = baseTemplate();
(t.outbounds![1] as { streamSettings?: { sockopt?: { dialerProxy?: string } } }).streamSettings = {
sockopt: { dialerProxy: 'To-External-Proxy' },
};
propagateOutboundTagRename(t, 'To-External-Proxy', 'external-vps');
const dialerProxy = (t.outbounds![1] as { streamSettings?: { sockopt?: { dialerProxy?: string } } })
.streamSettings?.sockopt?.dialerProxy;
expect(dialerProxy).toBe('external-vps');
});
it('is a no-op when old and new tags are equal', () => {
const t = baseTemplate();
propagateOutboundTagRename(t, 'To-External-Proxy', 'To-External-Proxy');
expect(t.routing?.rules?.[0]?.outboundTag).toBe('To-External-Proxy');
});
});
+16
View File
@@ -74,3 +74,19 @@ afterEach(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
}
});
import { HttpUtil } from '@/utils';
vi.mock('axios', () => {
return {
default: {
get: vi.fn().mockResolvedValue({ data: { success: true, obj: {} } }),
post: vi.fn().mockResolvedValue({ data: { success: true, obj: {} } }),
}
};
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.spyOn(HttpUtil, 'post').mockResolvedValue({ success: true, obj: {} } as any);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.spyOn(HttpUtil, 'get').mockResolvedValue({ success: true, obj: {} } as any);
@@ -9,6 +9,7 @@ import {
normalizeXhttpForWire,
validateRealityTarget,
} from '@/lib/xray/stream-wire-normalize';
import { InboundFormSchema } from '@/schemas/forms/inbound-form';
import type { InboundFormValues } from '@/schemas/forms/inbound-form';
describe('validateRealityTarget', () => {
@@ -150,6 +151,28 @@ describe('normalizeStreamSettingsForWire reality', () => {
});
});
describe('normalizeStreamSettingsForWire tls', () => {
it('drops empty uTLS fingerprints from inbound and outbound TLS shapes', () => {
const out = normalizeStreamSettingsForWire({
network: 'hysteria',
security: 'tls',
tlsSettings: {
fingerprint: '',
settings: {
fingerprint: '',
echConfigList: '',
},
},
}, { side: 'inbound' });
const tls = out.tlsSettings as Record<string, unknown>;
const settings = tls.settings as Record<string, unknown>;
expect(tls).not.toHaveProperty('fingerprint');
expect(settings).not.toHaveProperty('fingerprint');
expect(settings.echConfigList).toBe('');
});
});
describe('inbound formValuesToWirePayload integration', () => {
it('emits lean stream-one xhttp + sockopt on save', () => {
const values = {
@@ -209,6 +232,51 @@ describe('inbound formValuesToWirePayload integration', () => {
const realitySettings = reality.settings as Record<string, unknown>;
expect(realitySettings.publicKey).toBe('pub');
});
it('accepts Hysteria TLS with uTLS None and omits fingerprint on save', () => {
const values = {
remark: 'hy2',
enable: true,
port: 443,
listen: '',
tag: 'hy2-443',
expiryTime: 0,
sniffing: { enabled: false },
up: 0,
down: 0,
total: 0,
trafficReset: 'never',
lastTrafficResetTime: 0,
nodeId: null,
protocol: 'hysteria',
settings: { version: 2, clients: [] },
streamSettings: {
network: 'hysteria',
security: 'tls',
hysteriaSettings: {
version: 2,
auth: 'auth',
udpIdleTimeout: 60,
},
tlsSettings: {
alpn: ['h3'],
settings: {
fingerprint: '',
},
},
},
};
const parsed = InboundFormSchema.safeParse(values);
expect(parsed.success).toBe(true);
if (!parsed.success) throw parsed.error;
const payload = formValuesToWirePayload(parsed.data);
const stream = JSON.parse(payload.streamSettings) as Record<string, unknown>;
const tls = stream.tlsSettings as Record<string, unknown>;
const settings = tls.settings as Record<string, unknown>;
expect(settings).not.toHaveProperty('fingerprint');
});
});
describe('freedom outbound sockopt wire payload', () => {
+14 -14
View File
@@ -23,9 +23,9 @@ require (
github.com/xlzd/gotp v0.1.0
github.com/xtls/xray-core v1.260327.1-0.20260601021109-94ffd50060f1
go.uber.org/atomic v1.11.0
golang.org/x/crypto v0.52.0
golang.org/x/sys v0.45.0
golang.org/x/text v0.37.0
golang.org/x/crypto v0.53.0
golang.org/x/sys v0.46.0
golang.org/x/text v0.38.0
google.golang.org/grpc v1.81.1
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gorm.io/driver/postgres v1.6.0
@@ -34,10 +34,10 @@ require (
)
require (
github.com/pion/dtls/v3 v3.1.2 // indirect
github.com/pion/dtls/v3 v3.1.4 // indirect
github.com/pion/logging v0.2.4 // indirect
github.com/pion/stun/v3 v3.1.2 // indirect
github.com/pion/transport/v4 v4.0.1 // indirect
github.com/pion/stun/v3 v3.1.5 // indirect
github.com/pion/transport/v4 v4.0.2 // indirect
github.com/wlynxg/anet v0.0.5 // indirect
golang.zx2c4.com/wireguard/windows v1.0.1 // indirect
)
@@ -47,7 +47,7 @@ require (
github.com/andybalholm/brotli v1.2.1 // indirect
github.com/apernet/quic-go v0.59.1-0.20260425001925-6c6cc9bcb716 // indirect
github.com/bytedance/gopkg v0.1.4 // indirect
github.com/bytedance/sonic v1.15.1 // indirect
github.com/bytedance/sonic v1.15.2 // indirect
github.com/bytedance/sonic/loader v0.5.1 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/cloudwego/base64x v0.1.7 // indirect
@@ -76,7 +76,7 @@ require (
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mattn/go-sqlite3 v1.14.44 // indirect
github.com/mattn/go-sqlite3 v1.14.45 // indirect
github.com/miekg/dns v1.1.72 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
@@ -84,7 +84,7 @@ require (
github.com/pires/go-proxyproto v0.12.0 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.1 // indirect
github.com/quic-go/quic-go v0.60.0 // indirect
github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af // indirect
github.com/rogpeppe/go-internal v1.15.0 // indirect
github.com/sagernet/sing v0.8.10 // indirect
@@ -101,16 +101,16 @@ require (
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
golang.org/x/arch v0.27.0 // indirect
golang.org/x/exp v0.0.0-20260529124908-c761662dc8c9 // indirect
golang.org/x/mod v0.36.0 // indirect
golang.org/x/arch v0.28.0 // indirect
golang.org/x/exp v0.0.0-20260603202125-055de637280b // indirect
golang.org/x/mod v0.37.0 // indirect
golang.org/x/net v0.55.0
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/time v0.15.0 // indirect
golang.org/x/tools v0.45.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260608224507-4308a22a1bab // indirect
google.golang.org/protobuf v1.36.11 // indirect
gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect
lukechampine.com/blake3 v1.4.1 // indirect
+30 -28
View File
@@ -10,8 +10,8 @@ github.com/apernet/quic-go v0.59.1-0.20260425001925-6c6cc9bcb716 h1:J1O+xpLuJWkd
github.com/apernet/quic-go v0.59.1-0.20260425001925-6c6cc9bcb716/go.mod h1:Npbg8qBtAZlsAB3FWmqwlVh5jtVG6a4DlYsOylUpvzA=
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
github.com/bytedance/sonic v1.15.1 h1:nJD5PmM0vY7J8CT6MxoqbVAAMhkSmV2HgRAUrrpLoOw=
github.com/bytedance/sonic v1.15.1/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA=
github.com/bytedance/sonic v1.15.2 h1:90H+rcF/FwLXwfB1cudOLq/je83n683Utf4Cbp0xHCo=
github.com/bytedance/sonic v1.15.2/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA=
github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
@@ -129,8 +129,8 @@ github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRt
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8=
github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/mattn/go-sqlite3 v1.14.45 h1:6KA/spDguL3KV8rnybG7ezSaE4SeMR3KC9VbUoAQaIk=
github.com/mattn/go-sqlite3 v1.14.45/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -148,24 +148,26 @@ github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3v
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pion/dtls/v3 v3.1.2 h1:gqEdOUXLtCGW+afsBLO0LtDD8GnuBBjEy6HRtyofZTc=
github.com/pion/dtls/v3 v3.1.2/go.mod h1:Hw/igcX4pdY69z1Hgv5x7wJFrUkdgHwAn/Q/uo7YHRo=
github.com/pion/dtls/v3 v3.1.4 h1:QhvtMflMfu9Kf0RcDC5BJBle4caPskByrKQR6uuYqpY=
github.com/pion/dtls/v3 v3.1.4/go.mod h1:cr/qotLISUw/9C1m83ZPNZtj9WnXkYLpfCptPqbkInc=
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
github.com/pion/stun/v3 v3.1.2 h1:86IhD8wFn6IDW4b1/0QzoQS+f5PeA8OHHRn8UZW5ErY=
github.com/pion/stun/v3 v3.1.2/go.mod h1:H7gDic7nNwlUL05pbs6T1dtaBehh/KjupxfWw3ZI7cA=
github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o=
github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM=
github.com/pion/stun/v3 v3.1.5 h1:Y1FHlhaI6+4UoC5i/zQf4F7JvdZtB24/05oyy/GF1x8=
github.com/pion/stun/v3 v3.1.5/go.mod h1:zRUghXSQU32Lx5orJsz3uYMkIihweXb3mu5gIns02fs=
github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk=
github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM=
github.com/pires/go-proxyproto v0.12.0 h1:TTCxD66dU898tahivkqc3hoceZp7P44FnorWyo9d5vM=
github.com/pires/go-proxyproto v0.12.0/go.mod h1:qUvfqUMEoX7T8g0q7TQLDnhMjdTrxnG0hvpMn+7ePNI=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0=
github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk=
github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af h1:er2acxbi3N1nvEq6HXHUAR1nTWEJmQfqiGR8EVT9rfs=
github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
@@ -244,27 +246,27 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
golang.org/x/arch v0.27.0 h1:0WNVcR8u9yFz8j5FvdHpgwNp3FS5U4guYdzHwEiGjoU=
golang.org/x/arch v0.27.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/exp v0.0.0-20260529124908-c761662dc8c9 h1:4d4PbuBNwaxMXkXI8yiIYjydtMU+04RHeuSxJdgKftM=
golang.org/x/exp v0.0.0-20260529124908-c761662dc8c9/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/arch v0.28.0 h1:wVwVdqsTuUbJvhYVCspQYwZXHNYeLSoZnmHD+ggddpQ=
golang.org/x/arch v0.28.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/exp v0.0.0-20260603202125-055de637280b h1:v1uXiEBHo8QA0LiGCo7UgHMzHT4Kdfpl2zmtH5vaP1Q=
golang.org/x/exp v0.0.0-20260603202125-055de637280b/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
@@ -277,8 +279,8 @@ golang.zx2c4.com/wireguard/windows v1.0.1 h1:eOxiDVbywPC+ZQqvdCK7x+ZwWXKbYv50TtH
golang.zx2c4.com/wireguard/windows v1.0.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260608224507-4308a22a1bab h1:cY0oV1VnAqvaim8VsR8ZyEKAudzbRJMRGwD3W/L7yOw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260608224507-4308a22a1bab/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
+14
View File
@@ -1177,6 +1177,11 @@ install_x-ui() {
else
systemctl stop x-ui
fi
# Kill any leftover mtg (MTProto) sidecars. x-ui runs them outside its own
# lifecycle, so on Linux a stale one can survive the stop and keep holding
# an inbound port with an outdated secret, silently breaking new clients.
# The freshly installed panel respawns a clean mtg per inbound on start.
pkill -f 'mtg-linux-[^ ]* run ' > /dev/null 2>&1 || true
rm ${xui_folder}/ -rf
fi
@@ -1192,8 +1197,17 @@ install_x-ui() {
if [[ $(arch) == "armv5" || $(arch) == "armv6" || $(arch) == "armv7" ]]; then
mv bin/xray-linux-$(arch) bin/xray-linux-arm
chmod +x bin/xray-linux-arm
if [[ -f bin/mtg-linux-$(arch) ]]; then
mv bin/mtg-linux-$(arch) bin/mtg-linux-arm
chmod +x bin/mtg-linux-arm
fi
fi
chmod +x x-ui bin/xray-linux-$(arch)
if [[ -f bin/mtg-linux-arm ]]; then
chmod +x bin/mtg-linux-arm
elif [[ -f bin/mtg-linux-$(arch) ]]; then
chmod +x bin/mtg-linux-$(arch)
fi
# Update x-ui cli and se set permission
mv -f /usr/bin/x-ui-temp /usr/bin/x-ui
+367
View File
@@ -0,0 +1,367 @@
package mtproto
import (
"bufio"
"encoding/json"
"fmt"
"net"
"net/http"
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/logger"
)
// Instance is the desired runtime configuration of one mtproto inbound.
type Instance struct {
Id int
Tag string
Listen string
Port int
Secret string
}
func (inst Instance) bindTo() string {
listen := inst.Listen
if listen == "" {
listen = "0.0.0.0"
}
return fmt.Sprintf("%s:%d", listen, inst.Port)
}
func (inst Instance) fingerprint() string {
return fmt.Sprintf("%s|%s", inst.bindTo(), inst.Secret)
}
// Traffic is a per-inbound traffic delta scraped from an mtg metrics endpoint.
type Traffic struct {
Tag string
Up int64
Down int64
}
type managed struct {
proc *Process
tag string
fingerprint string
metricsPort int
lastUp int64
lastDown int64
haveLast bool
}
// Manager owns the set of running mtg processes keyed by inbound id.
type Manager struct {
mu sync.Mutex
procs map[int]*managed
// swept records that the one-time startup cleanup of orphaned mtg
// processes (survivors of a previous x-ui run) has already run.
swept bool
}
var (
managerOnce sync.Once
manager *Manager
)
// GetManager returns the process-wide mtg manager singleton.
func GetManager() *Manager {
managerOnce.Do(func() {
manager = &Manager{procs: map[int]*managed{}}
})
return manager
}
// InstanceFromInbound derives a desired Instance from an mtproto inbound,
// healing the FakeTLS secret so it always matches the configured domain.
// Returns false when the inbound is not a usable mtproto inbound.
func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
if ib == nil || ib.Protocol != model.MTProto {
return Instance{}, false
}
settings := ib.Settings
if healed, ok := model.HealMtprotoSecret(settings); ok {
settings = healed
}
var parsed struct {
Secret string `json:"secret"`
}
if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
return Instance{}, false
}
if parsed.Secret == "" {
return Instance{}, false
}
return Instance{
Id: ib.Id,
Tag: ib.Tag,
Listen: ib.Listen,
Port: ib.Port,
Secret: parsed.Secret,
}, true
}
// Ensure starts the mtg process for an instance, or restarts it when its
// configuration changed. A no-op when the desired process is already running.
func (m *Manager) Ensure(inst Instance) error {
m.mu.Lock()
defer m.mu.Unlock()
m.sweepOrphansLocked()
return m.ensureLocked(inst)
}
// sweepOrphansLocked kills mtg processes left running by a previous x-ui run,
// exactly once per process lifetime and before any of our own mtg are started.
// Because x-ui owns every mtg process, anything alive at this point is an orphan
// that would otherwise keep holding an inbound port with a stale secret.
func (m *Manager) sweepOrphansLocked() {
if m.swept {
return
}
m.swept = true
if n := killStrayMtgProcesses(GetBinaryPath()); n > 0 {
logger.Warningf("mtproto: terminated %d orphaned mtg process(es) from a previous run", n)
}
}
func (m *Manager) ensureLocked(inst Instance) error {
fp := inst.fingerprint()
if cur, ok := m.procs[inst.Id]; ok {
if cur.fingerprint == fp && cur.proc.IsRunning() {
cur.tag = inst.Tag
return nil
}
cur.proc.Stop()
delete(m.procs, inst.Id)
}
metricsPort, err := freeLocalPort()
if err != nil {
return err
}
cfgPath := configPathForID(inst.Id)
if err := writeConfig(cfgPath, inst.Secret, inst.bindTo(), metricsPort); err != nil {
return err
}
proc := newProcess(cfgPath, fmt.Sprintf("inbound %d", inst.Id))
if err := proc.Start(); err != nil {
return err
}
m.procs[inst.Id] = &managed{
proc: proc,
tag: inst.Tag,
fingerprint: fp,
metricsPort: metricsPort,
}
logger.Infof("mtproto: started mtg for inbound %d on %s", inst.Id, inst.bindTo())
return nil
}
// Remove stops and forgets the mtg process for an inbound id.
func (m *Manager) Remove(id int) {
m.mu.Lock()
defer m.mu.Unlock()
if cur, ok := m.procs[id]; ok {
cur.proc.Stop()
delete(m.procs, id)
_ = os.Remove(configPathForID(id))
logger.Infof("mtproto: stopped mtg for inbound %d", id)
}
}
// Reconcile drives the running set toward the desired instances: it stops
// processes that are no longer wanted and (re)starts the rest. Used at boot
// and periodically to recover from crashes.
func (m *Manager) Reconcile(desired []Instance) {
m.mu.Lock()
defer m.mu.Unlock()
m.sweepOrphansLocked()
want := make(map[int]struct{}, len(desired))
for _, inst := range desired {
want[inst.Id] = struct{}{}
}
for id, cur := range m.procs {
if _, ok := want[id]; !ok {
cur.proc.Stop()
delete(m.procs, id)
_ = os.Remove(configPathForID(id))
}
}
for _, inst := range desired {
if err := m.ensureLocked(inst); err != nil {
logger.Warningf("mtproto: reconcile failed for inbound %d: %v", inst.Id, err)
}
}
}
// StopAll stops every managed mtg process. Called on panel shutdown.
func (m *Manager) StopAll() {
m.mu.Lock()
defer m.mu.Unlock()
for id, cur := range m.procs {
_ = cur.proc.Stop()
_ = os.Remove(configPathForID(id))
delete(m.procs, id)
}
}
// CollectTraffic scrapes each running mtg metrics endpoint and returns the
// per-inbound byte deltas since the previous scrape.
func (m *Manager) CollectTraffic() []Traffic {
// Snapshot the state we need under the lock, then release before doing
// network I/O so that Ensure/Reconcile/Remove are not blocked.
type snap struct {
id int
metricsPort int
tag string
haveLast bool
lastUp int64
lastDown int64
}
m.mu.Lock()
snaps := make([]snap, 0, len(m.procs))
for id, cur := range m.procs {
if cur.proc == nil || !cur.proc.IsRunning() {
continue
}
snaps = append(snaps, snap{
id: id,
metricsPort: cur.metricsPort,
tag: cur.tag,
haveLast: cur.haveLast,
lastUp: cur.lastUp,
lastDown: cur.lastDown,
})
}
m.mu.Unlock()
out := make([]Traffic, 0, len(snaps))
for _, s := range snaps {
up, down, ok := scrapeTraffic(s.metricsPort)
if !ok {
continue
}
var du, dd int64
if s.haveLast {
du = up - s.lastUp
dd = down - s.lastDown
if du < 0 {
du = 0
}
if dd < 0 {
dd = 0
}
}
// Re-acquire lock to persist the new baseline, but only if the entry
// still exists (it may have been removed during the scrape).
m.mu.Lock()
if cur, ok := m.procs[s.id]; ok {
cur.lastUp = up
cur.lastDown = down
cur.haveLast = true
}
m.mu.Unlock()
if s.haveLast && (du > 0 || dd > 0) {
out = append(out, Traffic{Tag: s.tag, Up: du, Down: dd})
}
}
return out
}
func freeLocalPort() (int, error) {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return 0, err
}
defer l.Close()
return l.Addr().(*net.TCPAddr).Port, nil
}
func writeConfig(path, secret, bindTo string, metricsPort int) error {
if err := os.MkdirAll(configDir(), 0o750); err != nil {
return err
}
content := fmt.Sprintf("secret = %q\nbind-to = %q\n\n[stats.prometheus]\nenabled = true\nbind-to = \"127.0.0.1:%d\"\nhttp-path = \"/metrics\"\nmetric-prefix = \"mtg\"\n",
secret, bindTo, metricsPort)
return os.WriteFile(path, []byte(content), 0o640)
}
// scrapeTraffic reads the mtg Prometheus metrics endpoint and sums byte
// counters by direction. mtg exposes a traffic counter labelled with a
// direction; "to_telegram" is treated as upload and "to_client" as download.
// Best-effort: an unreachable endpoint or unrecognised format yields ok=false.
func scrapeTraffic(port int) (up int64, down int64, ok bool) {
client := http.Client{Timeout: 3 * time.Second}
resp, err := client.Get(fmt.Sprintf("http://127.0.0.1:%d/metrics", port))
if err != nil {
return 0, 0, false
}
defer resp.Body.Close()
scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
found := false
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || line[0] == '#' || !strings.Contains(line, "traffic") {
continue
}
name, labels, value, perr := parseMetricLine(line)
if perr != nil || !strings.HasPrefix(name, "mtg") {
continue
}
switch labels["direction"] {
case "to_telegram", "egress", "up":
up += int64(value)
case "to_client", "ingress", "down":
down += int64(value)
default:
down += int64(value)
}
found = true
}
if err := scanner.Err(); err != nil {
logger.Debug("mtproto: metrics scan error:", err)
}
return up, down, found
}
func parseMetricLine(line string) (name string, labels map[string]string, value float64, err error) {
labels = map[string]string{}
rest := line
if brace := strings.IndexByte(line, '{'); brace >= 0 {
name = line[:brace]
end := strings.IndexByte(line, '}')
if end < brace {
return "", nil, 0, fmt.Errorf("malformed metric line")
}
for _, kv := range strings.Split(line[brace+1:end], ",") {
eq := strings.IndexByte(kv, '=')
if eq < 0 {
continue
}
labels[strings.TrimSpace(kv[:eq])] = strings.Trim(strings.TrimSpace(kv[eq+1:]), `"`)
}
rest = strings.TrimSpace(line[end+1:])
} else {
fields := strings.Fields(line)
if len(fields) < 2 {
return "", nil, 0, fmt.Errorf("malformed metric line")
}
name = fields[0]
rest = fields[1]
}
valFields := strings.Fields(rest)
if len(valFields) == 0 {
return "", nil, 0, fmt.Errorf("missing metric value")
}
value, err = strconv.ParseFloat(valFields[0], 64)
if err != nil {
return "", nil, 0, err
}
return name, labels, value, nil
}
+56
View File
@@ -0,0 +1,56 @@
package mtproto
import (
"testing"
"github.com/mhsanaei/3x-ui/v3/database/model"
)
func TestParseMetricLine(t *testing.T) {
name, labels, val, err := parseMetricLine(`mtg_traffic{direction="to_client"} 12345`)
if err != nil {
t.Fatal(err)
}
if name != "mtg_traffic" {
t.Fatalf("name=%q", name)
}
if labels["direction"] != "to_client" {
t.Fatalf("labels=%v", labels)
}
if val != 12345 {
t.Fatalf("val=%v", val)
}
name2, _, val2, err2 := parseMetricLine(`mtg_concurrency 7`)
if err2 != nil {
t.Fatal(err2)
}
if name2 != "mtg_concurrency" || val2 != 7 {
t.Fatalf("got %q %v", name2, val2)
}
}
func TestInstanceFromInbound(t *testing.T) {
ib := &model.Inbound{
Id: 3,
Tag: "inbound-3",
Listen: "0.0.0.0",
Port: 8443,
Protocol: model.MTProto,
Settings: `{"fakeTlsDomain":"example.com","secret":""}`,
}
inst, ok := InstanceFromInbound(ib)
if !ok {
t.Fatal("expected a usable instance")
}
if inst.Secret == "" {
t.Fatal("secret should be healed to a non-empty value")
}
if inst.Port != 8443 || inst.Id != 3 {
t.Fatalf("bad instance %+v", inst)
}
if _, ok := InstanceFromInbound(&model.Inbound{Protocol: model.VLESS}); ok {
t.Fatal("non-mtproto inbound should not produce an instance")
}
}
+79
View File
@@ -0,0 +1,79 @@
//go:build linux
package mtproto
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"syscall"
)
// killStrayMtgProcesses terminates orphaned mtg sidecars left over from a
// previous x-ui run and returns how many were killed.
//
// x-ui starts one mtg process per mtproto inbound outside its own lifecycle, and
// on Linux a child is not guaranteed to die with the panel (there is no
// kill-on-exit, unlike the Windows job object). A survivor keeps holding the
// inbound port with a now-stale secret, so new clients are silently
// domain-fronted to the FakeTLS domain instead of proxied to Telegram. x-ui is
// the sole owner of mtg, so any process matching our binary name at startup is
// an orphan and is safe to kill before we start our own.
//
// binaryPath is the configured mtg path (e.g. "bin/mtg-linux-amd64"); matching
// is done on the executable's base name so it is independent of the bin folder
// and still works after an update has deleted the binary (the running process's
// /proc/<pid>/exe then reads as "<path> (deleted)", so argv[0] is used too).
func killStrayMtgProcesses(binaryPath string) int {
base := filepath.Base(binaryPath)
if base == "" || base == "." || base == string(filepath.Separator) {
return 0
}
self := os.Getpid()
entries, err := os.ReadDir("/proc")
if err != nil {
return 0
}
killed := 0
for _, e := range entries {
pid, err := strconv.Atoi(e.Name())
if err != nil || pid == self {
continue
}
if procExeBase(pid) != base && cmdlineArgv0Base(pid) != base {
continue
}
if err := syscall.Kill(pid, syscall.SIGKILL); err == nil {
killed++
}
}
return killed
}
// procExeBase returns the base name of /proc/<pid>/exe, or "" if unreadable.
func procExeBase(pid int) string {
exe, err := os.Readlink(fmt.Sprintf("/proc/%d/exe", pid))
if err != nil {
return ""
}
return filepath.Base(exe)
}
// cmdlineArgv0Base returns the base name of argv[0] from /proc/<pid>/cmdline,
// the reliable fallback when the binary has been replaced or exe is unreadable.
func cmdlineArgv0Base(pid int) string {
data, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid))
if err != nil || len(data) == 0 {
return ""
}
argv0 := data
if i := strings.IndexByte(string(data), 0); i >= 0 {
argv0 = data[:i]
}
if len(argv0) == 0 {
return ""
}
return filepath.Base(string(argv0))
}
+9
View File
@@ -0,0 +1,9 @@
//go:build !linux
package mtproto
// killStrayMtgProcesses is a no-op off Linux. On Windows the kill-on-exit job
// object already terminates mtg together with the panel (see
// attachChildLifetime), so orphans do not arise there; other platforms are not
// a supported deployment target for the mtg sidecar.
func killStrayMtgProcesses(_ string) int { return 0 }
+235
View File
@@ -0,0 +1,235 @@
// Package mtproto manages mtg (github.com/9seconds/mtg) sidecar processes that
// serve MTProto FakeTLS proxies. Xray-core has no mtproto protocol, so mtproto
// inbounds are run as standalone mtg processes — one process per inbound —
// entirely outside the Xray config and lifecycle.
package mtproto
import (
"errors"
"fmt"
"os"
"os/exec"
"runtime"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
"github.com/mhsanaei/3x-ui/v3/config"
"github.com/mhsanaei/3x-ui/v3/logger"
)
// GetBinaryName returns the mtg binary filename for the current OS and arch,
// matching the naming scheme used for the Xray binary. On Windows the ".exe"
// extension is appended so a natural "mtg-windows-amd64.exe" is found.
func GetBinaryName() string {
name := fmt.Sprintf("mtg-%s-%s", runtime.GOOS, runtime.GOARCH)
if runtime.GOOS == "windows" {
name += ".exe"
}
return name
}
// GetBinaryPath returns the full path to the mtg binary, alongside the Xray binary.
func GetBinaryPath() string {
return config.GetBinFolderPath() + "/" + GetBinaryName()
}
func configDir() string {
return config.GetBinFolderPath() + "/mtproto"
}
func configPathForID(id int) string {
return fmt.Sprintf("%s/mtg-%d.toml", configDir(), id)
}
var (
gracefulStopTimeout = 5 * time.Second
forceStopTimeout = 2 * time.Second
)
// procLogWriter consumes the mtg child process's stdout/stderr. It splits the
// stream into lines, forwards each one to the x-ui log — so mtg's own messages,
// including why it cannot reach Telegram, become visible in the panel log viewer
// and journald — and remembers the most recent line for GetResult.
type procLogWriter struct {
mu sync.Mutex
label string
buf string
lastLine string
}
func (w *procLogWriter) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
w.buf += string(p)
for {
i := strings.IndexByte(w.buf, '\n')
if i < 0 {
break
}
line := w.buf[:i]
w.buf = w.buf[i+1:]
w.emitLocked(line)
}
return len(p), nil
}
// Flush emits any buffered partial line; called once the process exits so a
// final un-terminated error line is not lost.
func (w *procLogWriter) Flush() {
w.mu.Lock()
defer w.mu.Unlock()
if w.buf != "" {
line := w.buf
w.buf = ""
w.emitLocked(line)
}
}
func (w *procLogWriter) emitLocked(line string) {
trimmed := strings.TrimSpace(strings.TrimRight(line, "\r"))
if trimmed == "" {
return
}
w.lastLine = trimmed
logger.Infof("mtproto: mtg %s | %s", w.label, trimmed)
}
func (w *procLogWriter) LastLine() string {
w.mu.Lock()
defer w.mu.Unlock()
return w.lastLine
}
// Process wraps a single mtg process invocation for one mtproto inbound.
type Process struct {
cmd *exec.Cmd
done chan struct{}
configPath string
logWriter *procLogWriter
exitErr error
intentionalStop atomic.Bool
}
func newProcess(configPath, label string) *Process {
return &Process{
configPath: configPath,
logWriter: &procLogWriter{label: label},
}
}
// IsRunning reports whether the mtg process is currently running.
func (p *Process) IsRunning() bool {
if p.cmd == nil || p.cmd.Process == nil {
return false
}
if p.done != nil {
select {
case <-p.done:
return false
default:
}
}
if p.cmd.ProcessState == nil {
return true
}
return false
}
// GetResult returns the last log line or the exit error from the mtg process.
func (p *Process) GetResult() string {
if line := p.logWriter.LastLine(); line != "" {
return line
}
if p.exitErr != nil {
return p.exitErr.Error()
}
return ""
}
// Start launches the mtg process against its generated config file.
func (p *Process) Start() error {
if p.IsRunning() {
return errors.New("mtg is already running")
}
cmd := exec.Command(GetBinaryPath(), "run", p.configPath)
cmd.Stdout = p.logWriter
cmd.Stderr = p.logWriter
p.cmd = cmd
p.done = make(chan struct{})
p.exitErr = nil
p.intentionalStop.Store(false)
if err := cmd.Start(); err != nil {
close(p.done)
p.cmd = nil
return err
}
attachChildLifetime(cmd)
go p.wait(cmd)
return nil
}
func (p *Process) wait(cmd *exec.Cmd) {
defer close(p.done)
err := cmd.Wait()
p.logWriter.Flush()
if err == nil || p.intentionalStop.Load() {
return
}
if runtime.GOOS == "windows" {
if strings.Contains(strings.ToLower(err.Error()), "exit status 1") {
p.exitErr = err
return
}
}
logger.Errorf("mtproto: mtg process exited: %v", err)
p.exitErr = err
}
// Stop terminates the running mtg process gracefully, falling back to a kill.
func (p *Process) Stop() error {
if !p.IsRunning() {
return errors.New("mtg is not running")
}
p.intentionalStop.Store(true)
if runtime.GOOS == "windows" {
if err := p.cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
return err
}
return p.waitForExit(forceStopTimeout)
}
if err := p.cmd.Process.Signal(syscall.SIGTERM); err != nil {
if errors.Is(err, os.ErrProcessDone) {
return p.waitForExit(forceStopTimeout)
}
return err
}
if err := p.waitForExit(gracefulStopTimeout); err == nil {
return nil
}
logger.Warning("mtproto: mtg did not stop after SIGTERM, killing process")
if err := p.cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
return err
}
return p.waitForExit(forceStopTimeout)
}
func (p *Process) waitForExit(timeout time.Duration) error {
if p.done == nil {
return nil
}
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case <-p.done:
return nil
case <-timer.C:
return fmt.Errorf("timed out waiting for mtg process to stop after %s", timeout)
}
}
+7
View File
@@ -0,0 +1,7 @@
//go:build !windows
package mtproto
import "os/exec"
func attachChildLifetime(_ *exec.Cmd) {}
+66
View File
@@ -0,0 +1,66 @@
//go:build windows
package mtproto
import (
"os/exec"
"sync"
"unsafe"
"github.com/mhsanaei/3x-ui/v3/logger"
"golang.org/x/sys/windows"
)
var (
killOnExitJobOnce sync.Once
killOnExitJob windows.Handle
killOnExitJobErr error
)
func ensureKillOnExitJob() (windows.Handle, error) {
killOnExitJobOnce.Do(func() {
h, err := windows.CreateJobObject(nil, nil)
if err != nil {
killOnExitJobErr = err
return
}
info := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{
BasicLimitInformation: windows.JOBOBJECT_BASIC_LIMIT_INFORMATION{
LimitFlags: windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
},
}
_, err = windows.SetInformationJobObject(
h,
windows.JobObjectExtendedLimitInformation,
uintptr(unsafe.Pointer(&info)),
uint32(unsafe.Sizeof(info)),
)
if err != nil {
windows.CloseHandle(h)
killOnExitJobErr = err
return
}
killOnExitJob = h
})
return killOnExitJob, killOnExitJobErr
}
func attachChildLifetime(cmd *exec.Cmd) {
if cmd == nil || cmd.Process == nil {
return
}
job, err := ensureKillOnExitJob()
if err != nil {
logger.Warningf("mtproto: kill-on-exit job unavailable: %v", err)
return
}
h, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, uint32(cmd.Process.Pid))
if err != nil {
logger.Warningf("mtproto: OpenProcess for job attach failed: %v", err)
return
}
defer windows.CloseHandle(h)
if err := windows.AssignProcessToJobObject(job, h); err != nil {
logger.Warningf("mtproto: AssignProcessToJobObject failed: %v", err)
}
}
+5 -2
View File
@@ -221,8 +221,11 @@ func (s *SubClashService) buildProxy(inbound *model.Inbound, client model.Client
}
var inboundSettings map[string]any
json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
if encryption, ok := inboundSettings["encryption"].(string); ok && encryption != "" {
proxy["packet-encoding"] = encryption
if encryption, ok := inboundSettings["encryption"].(string); ok {
encryption = strings.TrimSpace(encryption)
if encryption != "" && encryption != "none" {
proxy["encryption"] = encryption
}
}
case model.Trojan:
proxy["type"] = "trojan"
+59
View File
@@ -3,6 +3,8 @@ package sub
import (
"reflect"
"testing"
"github.com/mhsanaei/3x-ui/v3/database/model"
)
func TestEnsureUniqueProxyNames(t *testing.T) {
@@ -113,3 +115,60 @@ func TestApplyTransport_HTTPUpgrade(t *testing.T) {
t.Fatalf("headers.Host = %v, want example.com", headers["Host"])
}
}
func TestBuildProxy_VLESSPostQuantumEncryptionUsesMihomoEncryptionField(t *testing.T) {
svc := &SubClashService{SubService: &SubService{remarkModel: "-i"}}
encryption := "mlkem768x25519plus.native.0rtt.client"
inbound := &model.Inbound{
Listen: "203.0.113.1",
Port: 443,
Protocol: model.VLESS,
Remark: "pq",
Settings: `{"encryption":"` + encryption + `"}`,
}
client := model.Client{ID: "11111111-2222-4333-8444-555555555555"}
stream := map[string]any{
"network": "xhttp",
"xhttpSettings": map[string]any{
"path": "/",
"mode": "auto",
},
"security": "reality",
"realitySettings": map[string]any{
"publicKey": "pub",
"serverName": "example.com",
"shortId": "abcd",
},
}
proxy := svc.buildProxy(inbound, client, stream, "")
if proxy["encryption"] != encryption {
t.Fatalf("encryption = %v, want %q", proxy["encryption"], encryption)
}
}
func TestBuildProxy_VLESSNoneEncryptionOmittedForClash(t *testing.T) {
svc := &SubClashService{SubService: &SubService{remarkModel: "-i"}}
inbound := &model.Inbound{
Listen: "203.0.113.1",
Port: 443,
Protocol: model.VLESS,
Remark: "plain",
Settings: `{"encryption":"none"}`,
}
client := model.Client{ID: "11111111-2222-4333-8444-555555555555"}
stream := map[string]any{
"network": "tcp",
"security": "none",
"tcpSettings": map[string]any{
"header": map[string]any{"type": "none"},
},
}
proxy := svc.buildProxy(inbound, client, stream, "")
if _, ok := proxy["encryption"]; ok {
t.Fatalf("plain vless encryption should be omitted for mihomo: %#v", proxy)
}
}
+111 -21
View File
@@ -5,15 +5,19 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"html/template"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/mhsanaei/3x-ui/v3/web/service"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/mhsanaei/3x-ui/v3/logger"
"github.com/mhsanaei/3x-ui/v3/web/service"
)
// writeSubError translates a service-layer result into an HTTP response.
@@ -28,6 +32,14 @@ func writeSubError(c *gin.Context, err error) {
c.Status(http.StatusInternalServerError)
}
// cachedSubTemplate holds a parsed custom subscription template together with
// the modification time of the file it was parsed from, so the cache can be
// invalidated when an admin edits the template on disk.
type cachedSubTemplate struct {
tmpl *template.Template
modTime time.Time
}
// SUBController handles HTTP requests for subscription links and JSON configurations.
type SUBController struct {
subTitle string
@@ -48,6 +60,9 @@ type SUBController struct {
subJsonService *SubJsonService
subClashService *SubClashService
settingService service.SettingService
subTemplateMu sync.RWMutex
subTemplateCache map[string]*cachedSubTemplate
}
// NewSUBController creates a new subscription controller with the given configuration.
@@ -93,6 +108,8 @@ func NewSUBController(
subService: sub,
subJsonService: NewSubJsonService(jsonMux, jsonRules, jsonFinalMask, sub),
subClashService: NewSubClashService(clashEnableRouting, clashRules, sub),
subTemplateCache: map[string]*cachedSubTemplate{},
}
a.initRouter(g)
return a
@@ -202,25 +219,49 @@ func (a *SUBController) serveSubPage(c *gin.Context, basePath string, page PageD
}
subData := map[string]any{
"sId": page.SId,
"enabled": page.Enabled,
"download": page.Download,
"upload": page.Upload,
"total": page.Total,
"used": page.Used,
"remained": page.Remained,
"expire": page.Expire,
"lastOnline": page.LastOnline,
"downloadByte": page.DownloadByte,
"uploadByte": page.UploadByte,
"totalByte": page.TotalByte,
"subUrl": page.SubUrl,
"subJsonUrl": page.SubJsonUrl,
"subClashUrl": page.SubClashUrl,
"links": page.Result,
"emails": page.Emails,
"datepicker": datepicker,
"sId": page.SId,
"enabled": page.Enabled,
"download": page.Download,
"upload": page.Upload,
"total": page.Total,
"used": page.Used,
"remained": page.Remained,
"expire": page.Expire,
"lastOnline": page.LastOnline,
"downloadByte": page.DownloadByte,
"uploadByte": page.UploadByte,
"totalByte": page.TotalByte,
"subUrl": page.SubUrl,
"subJsonUrl": page.SubJsonUrl,
"subClashUrl": page.SubClashUrl,
"subTitle": page.SubTitle,
"subSupportUrl": page.SubSupportUrl,
"links": page.Result,
"emails": page.Emails,
"datepicker": datepicker,
}
// When an admin has configured a custom subscription theme, render it
// instead of the default SPA. We render into a buffer first so a template
// that fails mid-execution can't leave a partially-written (corrupt)
// response — on any error we log and fall through to the default page.
if themeDir, _ := a.settingService.GetSubThemeDir(); themeDir != "" {
if tmpl, err := a.loadSubTemplate(themeDir); err != nil {
logger.Error("sub: custom template parse failed, using default page:", err)
} else if tmpl == nil {
logger.Warning("sub: subThemeDir set but no usable template found, using default page:", themeDir)
} else {
var buf bytes.Buffer
if execErr := tmpl.Execute(&buf, subData); execErr != nil {
logger.Error("sub: custom template execution failed, using default page:", execErr)
} else {
setNoCacheHeaders(c)
c.Data(http.StatusOK, "text/html; charset=utf-8", buf.Bytes())
return
}
}
}
subDataJSON, err := json.Marshal(subData)
if err != nil {
subDataJSON = []byte("{}")
@@ -243,10 +284,59 @@ func (a *SUBController) serveSubPage(c *gin.Context, basePath string, page PageD
`window.__SUB_PAGE_DATA__=` + string(subDataJSON) + `;</script></head>`)
out := bytes.Replace(body, []byte("</head>"), inject, 1)
setNoCacheHeaders(c)
c.Data(http.StatusOK, "text/html; charset=utf-8", out)
}
// setNoCacheHeaders marks a subscription page response as non-cacheable so VPN
// clients and browsers always fetch fresh traffic/expiry data.
func setNoCacheHeaders(c *gin.Context) {
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
c.Header("Pragma", "no-cache")
c.Header("Expires", "0")
c.Data(http.StatusOK, "text/html; charset=utf-8", out)
}
// loadSubTemplate returns the parsed custom subscription template located in
// themeDir, preferring sub.html over index.html. Parsed templates are cached and
// only re-parsed when the underlying file's modification time changes, so admin
// edits are picked up without paying a disk read + HTML parse on every request.
//
// It returns (nil, nil) when themeDir is not a usable directory or contains no
// template file — the caller should fall back to the default page. A non-nil
// error means a template file exists but failed to parse.
func (a *SUBController) loadSubTemplate(themeDir string) (*template.Template, error) {
info, err := os.Stat(themeDir)
if err != nil || !info.IsDir() {
return nil, nil
}
templatePath := filepath.Join(themeDir, "index.html")
if _, err := os.Stat(filepath.Join(themeDir, "sub.html")); err == nil {
templatePath = filepath.Join(themeDir, "sub.html")
}
fi, err := os.Stat(templatePath)
if err != nil {
return nil, nil
}
modTime := fi.ModTime()
a.subTemplateMu.RLock()
cached := a.subTemplateCache[templatePath]
a.subTemplateMu.RUnlock()
if cached != nil && cached.modTime.Equal(modTime) {
return cached.tmpl, nil
}
tmpl, err := template.ParseFiles(templatePath)
if err != nil {
return nil, err
}
a.subTemplateMu.Lock()
a.subTemplateCache[templatePath] = &cachedSubTemplate{tmpl: tmpl, modTime: modTime}
a.subTemplateMu.Unlock()
return tmpl, nil
}
// subJsons handles HTTP requests for JSON subscription configurations.
+149
View File
@@ -0,0 +1,149 @@
package sub
import (
"bytes"
"os"
"path/filepath"
"testing"
"time"
)
// newTestSUBController builds a controller with just the bits loadSubTemplate
// needs, so the template tests don't require a database.
func newTestSUBController() *SUBController {
return &SUBController{subTemplateCache: map[string]*cachedSubTemplate{}}
}
func writeFile(t *testing.T, path, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
func renderTemplate(t *testing.T, a *SUBController, dir string, data map[string]any) string {
t.Helper()
tmpl, err := a.loadSubTemplate(dir)
if err != nil {
t.Fatalf("loadSubTemplate: unexpected error: %v", err)
}
if tmpl == nil {
t.Fatal("loadSubTemplate: expected a template, got nil")
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
t.Fatalf("execute: %v", err)
}
return buf.String()
}
func TestLoadSubTemplate_RendersIndex(t *testing.T) {
dir := t.TempDir()
writeFile(t, filepath.Join(dir, "index.html"), `<h1>{{ .sId }}</h1>`)
got := renderTemplate(t, newTestSUBController(), dir, map[string]any{"sId": "abc-123"})
if want := `<h1>abc-123</h1>`; got != want {
t.Fatalf("rendered = %q, want %q", got, want)
}
}
func TestLoadSubTemplate_PrefersSubHTML(t *testing.T) {
dir := t.TempDir()
writeFile(t, filepath.Join(dir, "index.html"), `from-index`)
writeFile(t, filepath.Join(dir, "sub.html"), `from-sub`)
got := renderTemplate(t, newTestSUBController(), dir, nil)
if got != "from-sub" {
t.Fatalf("rendered = %q, want %q (sub.html should take precedence)", got, "from-sub")
}
}
func TestLoadSubTemplate_FallbackCases(t *testing.T) {
a := newTestSUBController()
t.Run("missing dir", func(t *testing.T) {
tmpl, err := a.loadSubTemplate(filepath.Join(t.TempDir(), "does-not-exist"))
if tmpl != nil || err != nil {
t.Fatalf("got (%v, %v), want (nil, nil)", tmpl, err)
}
})
t.Run("path is a file not a dir", func(t *testing.T) {
file := filepath.Join(t.TempDir(), "index.html")
writeFile(t, file, `whatever`)
tmpl, err := a.loadSubTemplate(file)
if tmpl != nil || err != nil {
t.Fatalf("got (%v, %v), want (nil, nil)", tmpl, err)
}
})
t.Run("dir without template file", func(t *testing.T) {
tmpl, err := a.loadSubTemplate(t.TempDir())
if tmpl != nil || err != nil {
t.Fatalf("got (%v, %v), want (nil, nil)", tmpl, err)
}
})
}
func TestLoadSubTemplate_MalformedTemplate(t *testing.T) {
dir := t.TempDir()
// Unterminated action — html/template fails to parse this.
writeFile(t, filepath.Join(dir, "index.html"), `<h1>{{ .sId </h1>`)
tmpl, err := newTestSUBController().loadSubTemplate(dir)
if err == nil {
t.Fatal("expected a parse error for a malformed template, got nil")
}
if tmpl != nil {
t.Fatalf("expected nil template on parse error, got %v", tmpl)
}
}
func TestLoadSubTemplate_CacheHitAndInvalidation(t *testing.T) {
a := newTestSUBController()
dir := t.TempDir()
path := filepath.Join(dir, "index.html")
// v1 with a fixed mtime.
writeFile(t, path, `v1`)
t1 := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
if err := os.Chtimes(path, t1, t1); err != nil {
t.Fatalf("chtimes: %v", err)
}
first, err := a.loadSubTemplate(dir)
if err != nil || first == nil {
t.Fatalf("first load: (%v, %v)", first, err)
}
// Same mtime → cache hit returns the identical parsed template.
second, err := a.loadSubTemplate(dir)
if err != nil {
t.Fatalf("second load: %v", err)
}
if second != first {
t.Fatal("expected cache hit to return the same *template.Template pointer")
}
// New content + newer mtime → cache invalidated, fresh content served.
writeFile(t, path, `v2`)
t2 := t1.Add(time.Hour)
if err := os.Chtimes(path, t2, t2); err != nil {
t.Fatalf("chtimes: %v", err)
}
third, err := a.loadSubTemplate(dir)
if err != nil || third == nil {
t.Fatalf("third load: (%v, %v)", third, err)
}
if third == first {
t.Fatal("expected cache invalidation to re-parse the template after mtime change")
}
var buf bytes.Buffer
if err := third.Execute(&buf, nil); err != nil {
t.Fatalf("execute: %v", err)
}
if buf.String() != "v2" {
t.Fatalf("rendered = %q, want %q after edit", buf.String(), "v2")
}
}
+31 -3
View File
@@ -374,10 +374,37 @@ func (s *SubService) GetLink(inbound *model.Inbound, email string) string {
return s.genShadowsocksLink(inbound, email)
case "hysteria":
return s.genHysteriaLink(inbound, email)
case "mtproto":
return s.genMtprotoLink(inbound, email)
}
return ""
}
// genMtprotoLink builds a Telegram proxy deep link for an mtproto inbound:
func (s *SubService) genMtprotoLink(inbound *model.Inbound, _ string) string {
if inbound.Protocol != model.MTProto {
return ""
}
settings := map[string]any{}
json.Unmarshal([]byte(inbound.Settings), &settings)
secret, _ := settings["secret"].(string)
if secret == "" {
if healed, ok := model.HealMtprotoSecret(inbound.Settings); ok {
_ = json.Unmarshal([]byte(healed), &settings)
secret, _ = settings["secret"].(string)
}
}
if secret == "" {
return ""
}
params := map[string]string{
"server": s.resolveInboundAddress(inbound),
"port": fmt.Sprintf("%d", inbound.Port),
"secret": secret,
}
return buildLinkWithParams("tg://proxy", params, "")
}
// Protocol link generators are intentionally ordered as:
// vmess -> vless -> trojan -> shadowsocks -> hysteria.
func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string {
@@ -743,9 +770,10 @@ func (s *SubService) loadNodes() {
}
// resolveInboundAddress picks the host an external client should connect to:
// 1. node-managed inbound -> the node's address
// 2. an explicit, client-reachable bind Listen -> that Listen
// 3. otherwise the subscriber's request host (s.address)
// 1. node-managed inbound -> the node's address
// 2. an explicit, client-reachable bind Listen -> that Listen
// 3. otherwise the subscriber's request host (s.address)
//
// A loopback/wildcard bind or a unix-domain-socket listen is a server-side
// detail and is never advertised; External Proxy remains the way to advertise
// an arbitrary endpoint. Mirrors the frontend's resolveAddr so the panel QR and
+44
View File
@@ -0,0 +1,44 @@
# 3x-ui Custom Subscription Templates
This directory allows you to use custom HTML templates for your users' subscription pages.
## How to use a Custom Template
1. Go to the 3x-ui panel settings.
2. Under **Settings → Subscription → Information**, locate the **Sub Theme Directory** field.
3. Provide the absolute path to the folder containing your template (e.g. `/etc/3x-ui/sub_templates/my-theme/`).
4. Save the settings.
> **Note:** 3x-ui does not ship any templates by default. Create your own template folder anywhere
> on the server, put an `index.html` (or `sub.html`) inside it, and point **Sub Theme Directory** at
> that absolute path. Leave the field empty to use the default built-in page.
## Creating a Template
A custom template must be an HTML file named `index.html` or `sub.html` located within the directory you specified in the settings.
The panel uses standard Go `html/template` to render the subscription page.
### Available Variables
When rendering the template, the following variables are injected into the template context (`{{ .variable }}`):
* `{{ .sId }}`: Subscription ID (UUID).
* `{{ .enabled }}`: Whether the subscription/client is enabled (boolean).
* `{{ .download }}`: Formatted download traffic (e.g. "2.5 GB").
* `{{ .upload }}`: Formatted upload traffic.
* `{{ .total }}`: Formatted total traffic limit.
* `{{ .used }}`: Formatted used traffic (download + upload).
* `{{ .remained }}`: Formatted remaining traffic.
* `{{ .expire }}`: Expiration time as an int64 Unix timestamp in **seconds** (`0` means never). Multiply by 1000 for a JavaScript `Date`.
* `{{ .lastOnline }}`: Last online time as an int64 Unix timestamp in **milliseconds** (`0` means never seen).
* `{{ .downloadByte }}`: Download traffic in exact bytes (int64).
* `{{ .uploadByte }}`: Upload traffic in exact bytes (int64).
* `{{ .totalByte }}`: Total traffic limit in exact bytes (int64).
* `{{ .subUrl }}`: The URL of the subscription page.
* `{{ .subJsonUrl }}`: The URL for the JSON configuration of the subscription.
* `{{ .subClashUrl }}`: The URL for the Clash/Mihomo configuration.
* `{{ .subTitle }}`: The subscription title configured in the panel (Subscription → Information). Useful for page branding/headings. May be empty.
* `{{ .subSupportUrl }}`: The support URL configured in the panel. Useful for a "Contact support" link. May be empty.
* `{{ .links }}`: A list (slice) of string configurations (VMess, VLESS, etc. URLs). You can loop through them using `{{ range .links }} ... {{ end }}`.
* `{{ .emails }}`: A list (slice) of emails related to the subscription.
* `{{ .datepicker }}`: Current calendar format used by the panel (e.g. "gregorian" or "jalali").
+5
View File
@@ -890,6 +890,11 @@ update_x-ui() {
_fail "ERROR: x-ui systemd unit not installed."
fi
fi
# Kill any leftover mtg (MTProto) sidecars. x-ui runs them outside its own
# lifecycle, so on Linux a stale one can survive the stop and keep holding
# an inbound port with an outdated secret, silently breaking new clients.
# The new panel respawns a clean mtg per inbound on next start.
pkill -f 'mtg-linux-[^ ]* run ' > /dev/null 2>&1 || true
echo -e "${green}Removing old x-ui version...${plain}"
rm ${xui_folder} -f > /dev/null 2>&1
rm ${xui_folder}/x-ui.service -f > /dev/null 2>&1
+809
View File
@@ -0,0 +1,809 @@
// Package link provides parsers for VPN share links (vmess://, vless://, etc.)
// and subscription bodies (typically base64-encoded newline lists of such links).
// The output shape matches the wire format used by the panel's Xray template
// outbounds array so that parsed objects can be injected directly.
package link
import (
"encoding/base64"
"encoding/json"
"fmt"
"net/url"
"regexp"
"strconv"
"strings"
)
// Outbound is the minimal shape we emit for each parsed link.
// Extra fields (mux, etc.) are carried inside settings/streamSettings.
type Outbound map[string]any
// ParseResult holds a parsed outbound together with a stable identity string
// that can be used to correlate the same logical server across refreshes
// (even if the remark changes).
type ParseResult struct {
Outbound Outbound
Identity string
}
// ParseSubscriptionBody accepts the raw body returned by a subscription URL.
// It handles the common case where the body is a base64-encoded blob of
// newline-separated links, and also tolerates an already-decoded text body.
// It returns the list of successfully parsed outbounds (in order) and their
// corresponding identities.
func ParseSubscriptionBody(body []byte) ([]Outbound, []string, error) {
text := strings.TrimSpace(string(body))
if text == "" {
return nil, nil, nil
}
// Try base64 decode first (standard and URL-safe variants).
if decoded, ok := tryBase64(text); ok {
text = strings.TrimSpace(decoded)
}
lines := splitLines(text)
var outbounds []Outbound
var identities []string
for _, ln := range lines {
ln = strings.TrimSpace(ln)
if ln == "" || strings.HasPrefix(ln, "#") {
continue
}
res, err := ParseLink(ln)
if err != nil || res == nil {
// Ignore unparseable lines (comments, unsupported protocols, etc.)
continue
}
outbounds = append(outbounds, res.Outbound)
identities = append(identities, res.Identity)
}
return outbounds, identities, nil
}
func tryBase64(s string) (string, bool) {
// Remove whitespace that some providers insert.
clean := strings.Map(func(r rune) rune {
if r == ' ' || r == '\n' || r == '\r' || r == '\t' {
return -1
}
return r
}, s)
// Common padding fix
for len(clean)%4 != 0 {
clean += "="
}
// Standard
if b, err := base64.StdEncoding.DecodeString(clean); err == nil {
return string(b), true
}
// URL-safe (no padding)
if b, err := base64.RawURLEncoding.DecodeString(clean); err == nil {
return string(b), true
}
// URL-safe with padding
if b, err := base64.URLEncoding.DecodeString(clean); err == nil {
return string(b), true
}
return "", false
}
func splitLines(s string) []string {
// Accept \n, \r\n, and also some providers use literal \n in the text.
s = strings.ReplaceAll(s, `\n`, "\n")
return strings.FieldsFunc(s, func(r rune) bool { return r == '\n' || r == '\r' })
}
// ParseLink parses a single share link and returns the outbound object plus
// a stable identity for tag correlation. Supported schemes:
// - vmess://
// - vless://
// - trojan://
// - ss:// (modern and legacy)
// - hysteria2:// (also hy2://)
// - wireguard:// (also wg://)
func ParseLink(link string) (*ParseResult, error) {
link = strings.TrimSpace(link)
switch {
case strings.HasPrefix(link, "vmess://"):
return parseVmess(link)
case strings.HasPrefix(link, "vless://"):
return parseVless(link)
case strings.HasPrefix(link, "trojan://"):
return parseTrojan(link)
case strings.HasPrefix(link, "ss://"):
return parseShadowsocks(link)
case strings.HasPrefix(link, "hysteria2://"), strings.HasPrefix(link, "hy2://"):
return parseHysteria2(link)
case strings.HasPrefix(link, "wireguard://"), strings.HasPrefix(link, "wg://"):
return parseWireguard(link)
default:
return nil, fmt.Errorf("unsupported link scheme")
}
}
// --- vmess ---
func parseVmess(link string) (*ParseResult, error) {
b64 := strings.TrimPrefix(link, "vmess://")
// vmess:// base64(json)
raw, err := base64.StdEncoding.DecodeString(padBase64(b64))
if err != nil {
// Some providers use raw URL-safe
raw, err = base64.RawURLEncoding.DecodeString(b64)
}
if err != nil {
return nil, fmt.Errorf("vmess decode: %w", err)
}
var j map[string]any
if err := json.Unmarshal(raw, &j); err != nil {
return nil, fmt.Errorf("vmess json: %w", err)
}
identity := vmessIdentity(j)
network := getString(j, "net", "tcp")
security := "none"
if tls, _ := j["tls"].(string); tls == "tls" {
security = "tls"
}
stream := buildStream(network, security)
// Map known fields (best effort, matching frontend parser coverage)
switch network {
case "ws":
if host, ok := j["host"].(string); ok {
setWS(stream, host, getString(j, "path", "/"))
}
case "grpc":
svc := getString(j, "path", "")
if auth, ok := j["authority"].(string); ok && auth != "" {
(stream["grpcSettings"].(map[string]any))["authority"] = auth
}
(stream["grpcSettings"].(map[string]any))["serviceName"] = svc
(stream["grpcSettings"].(map[string]any))["multiMode"] = getString(j, "type", "") == "multi"
case "httpupgrade":
setHTTPUpgrade(stream, getString(j, "host", ""), getString(j, "path", "/"))
case "xhttp":
xh := stream["xhttpSettings"].(map[string]any)
xh["host"] = getString(j, "host", "")
xh["path"] = getString(j, "path", "/")
if m := getString(j, "mode", ""); m != "" {
xh["mode"] = m
}
// xhttp advanced keys are passed through if present in the json
for _, k := range []string{"xPaddingBytes", "scMaxEachPostBytes", "scMinPostsIntervalMs"} {
if v, ok := j[k]; ok {
xh[k] = v
}
}
case "tcp":
if getString(j, "type", "") == "http" {
stream["tcpSettings"] = map[string]any{
"header": map[string]any{
"type": "http",
"request": map[string]any{
"version": "1.1",
"method": "GET",
"path": splitComma(getString(j, "path", "/")),
"headers": map[string]any{"Host": splitComma(getString(j, "host", ""))},
},
},
}
}
}
if security == "tls" {
tls := stream["tlsSettings"].(map[string]any)
tls["serverName"] = getString(j, "sni", "")
tls["fingerprint"] = getString(j, "fp", "")
if alpn := getString(j, "alpn", ""); alpn != "" {
tls["alpn"] = splitComma(alpn)
}
}
port := num(j["port"])
ob := Outbound{
"protocol": "vmess",
"tag": getString(j, "ps", ""),
"settings": map[string]any{
"vnext": []any{
map[string]any{
"address": getString(j, "add", ""),
"port": port,
"users": []any{
map[string]any{
"id": getString(j, "id", ""),
"security": getString(j, "scy", "auto"),
},
},
},
},
},
"streamSettings": stream,
}
return &ParseResult{Outbound: ob, Identity: identity}, nil
}
func vmessIdentity(j map[string]any) string {
// Remove ps (remark) for identity
core := map[string]any{}
for k, v := range j {
if k == "ps" {
continue
}
core[k] = v
}
b, _ := json.Marshal(core)
return "vmess:" + string(b)
}
// --- vless / trojan (URL forms) ---
func parseVless(link string) (*ParseResult, error) {
u, err := url.Parse(link)
if err != nil {
return nil, err
}
if u.Scheme != "vless" {
return nil, fmt.Errorf("not vless")
}
id := u.User.Username()
host := u.Hostname()
port := defaultPort(u.Port(), 443)
params := u.Query()
network := params.Get("type")
if network == "" {
network = "tcp"
}
security := params.Get("security")
if security == "" {
security = "none"
}
stream := buildStream(network, security)
applyTransport(stream, params)
applySecurity(stream, params)
applyFinalMask(stream, params)
identity := "vless:" + u.Scheme + "://" + id + "@" + host + ":" + strconv.Itoa(port) + "?" + canonicalQuery(params)
ob := Outbound{
"protocol": "vless",
"tag": decodeHash(u.Fragment),
"settings": map[string]any{
"address": host,
"port": port,
"id": id,
"flow": params.Get("flow"),
"encryption": firstNonEmpty(params.Get("encryption"), "none"),
},
"streamSettings": stream,
}
return &ParseResult{Outbound: ob, Identity: identity}, nil
}
func parseTrojan(link string) (*ParseResult, error) {
u, err := url.Parse(link)
if err != nil {
return nil, err
}
if u.Scheme != "trojan" {
return nil, fmt.Errorf("not trojan")
}
pw := u.User.Username()
host := u.Hostname()
port := defaultPort(u.Port(), 443)
params := u.Query()
network := params.Get("type")
if network == "" {
network = "tcp"
}
security := params.Get("security")
if security == "" {
security = "tls"
}
stream := buildStream(network, security)
applyTransport(stream, params)
applySecurity(stream, params)
applyFinalMask(stream, params)
identity := "trojan:" + u.Scheme + "://" + pw + "@" + host + ":" + strconv.Itoa(port) + "?" + canonicalQuery(params)
ob := Outbound{
"protocol": "trojan",
"tag": decodeHash(u.Fragment),
"settings": map[string]any{
"servers": []any{
map[string]any{"address": host, "port": port, "password": pw},
},
},
"streamSettings": stream,
}
return &ParseResult{Outbound: ob, Identity: identity}, nil
}
// --- shadowsocks ---
func parseShadowsocks(link string) (*ParseResult, error) {
// Two shapes:
// ss://base64(method:pass)@host:port#remark
// ss://base64(method:pass@host:port)#remark
remark := ""
if i := strings.Index(link, "#"); i >= 0 {
remark, _ = url.QueryUnescape(link[i+1:])
link = link[:i]
}
core := strings.TrimPrefix(link, "ss://")
at := strings.Index(core, "@")
if at >= 0 {
// modern
userB64 := core[:at]
hp := core[at+1:]
userInfo, err := base64DecodeFlexible(userB64)
if err != nil {
userInfo = userB64 // not b64, rare
}
colon := strings.LastIndex(hp, ":")
if colon < 0 {
return nil, fmt.Errorf("bad ss host:port")
}
host := hp[:colon]
port, _ := strconv.Atoi(hp[colon+1:])
method, pass := splitMethodPass(userInfo)
identity := "ss:" + method + ":" + pass + "@" + host + ":" + strconv.Itoa(port)
ob := Outbound{
"protocol": "shadowsocks",
"tag": remark,
"settings": map[string]any{
"servers": []any{
map[string]any{"address": host, "port": port, "password": pass, "method": method},
},
},
}
return &ParseResult{Outbound: ob, Identity: identity}, nil
}
// legacy: whole thing b64
dec, err := base64DecodeFlexible(core)
if err != nil {
return nil, err
}
at = strings.Index(dec, "@")
if at < 0 {
return nil, fmt.Errorf("bad legacy ss")
}
userInfo := dec[:at]
hp := dec[at+1:]
colon := strings.LastIndex(hp, ":")
if colon < 0 {
return nil, fmt.Errorf("bad legacy ss hp")
}
host := hp[:colon]
port, _ := strconv.Atoi(hp[colon+1:])
method, pass := splitMethodPass(userInfo)
identity := "ss:" + method + ":" + pass + "@" + host + ":" + strconv.Itoa(port)
ob := Outbound{
"protocol": "shadowsocks",
"tag": remark,
"settings": map[string]any{
"servers": []any{
map[string]any{"address": host, "port": port, "password": pass, "method": method},
},
},
}
return &ParseResult{Outbound: ob, Identity: identity}, nil
}
func splitMethodPass(userInfo string) (string, string) {
colon := strings.Index(userInfo, ":")
if colon < 0 {
return "2022-blake3-aes-128-gcm", userInfo // guess
}
return userInfo[:colon], userInfo[colon+1:]
}
// --- hysteria2 ---
func parseHysteria2(link string) (*ParseResult, error) {
u, err := url.Parse(link)
if err != nil {
return nil, err
}
if u.Scheme != "hysteria2" && u.Scheme != "hy2" {
return nil, fmt.Errorf("not hysteria2")
}
auth := u.User.Username()
host := u.Hostname()
port := defaultPort(u.Port(), 443)
params := u.Query()
stream := map[string]any{
"network": "hysteria",
"security": "tls",
"hysteriaSettings": map[string]any{
"version": 2,
"auth": auth,
"udpIdleTimeout": 60,
},
"tlsSettings": map[string]any{
"serverName": params.Get("sni"),
"alpn": splitCommaOrDefault(params.Get("alpn"), []string{"h3"}),
"fingerprint": params.Get("fp"),
"echConfigList": params.Get("ech"),
"verifyPeerCertByName": "",
"pinnedPeerCertSha256": params.Get("pinSHA256"),
},
}
applyFinalMask(stream, params)
identity := "hysteria2:" + auth + "@" + host + ":" + strconv.Itoa(port) + "?" + canonicalQuery(params)
ob := Outbound{
"protocol": "hysteria",
"tag": decodeHash(u.Fragment),
"settings": map[string]any{"address": host, "port": port, "version": 2},
"streamSettings": stream,
}
return &ParseResult{Outbound: ob, Identity: identity}, nil
}
// --- wireguard ---
func parseWireguard(link string) (*ParseResult, error) {
u, err := url.Parse(link)
if err != nil {
return nil, err
}
if u.Scheme != "wireguard" && u.Scheme != "wg" {
return nil, fmt.Errorf("not wireguard")
}
secret, _ := url.QueryUnescape(u.User.Username())
params := u.Query()
host := u.Hostname()
portStr := u.Port()
endpoint := host
if portStr != "" {
endpoint = host + ":" + portStr
}
addrRaw := firstParam(params, "address", "ip")
allowedRaw := firstParam(params, "allowedips", "allowed_ips")
addrs := splitComma(addrRaw)
if len(addrs) == 0 {
addrs = []string{"0.0.0.0/0", "::/0"}
}
allowed := splitComma(allowedRaw)
if len(allowed) == 0 {
allowed = []string{"0.0.0.0/0", "::/0"}
}
peer := map[string]any{
"publicKey": firstParam(params, "publickey", "publicKey", "public_key", "peerPublicKey"),
"endpoint": endpoint,
"allowedIPs": allowed,
}
if psk := firstParam(params, "presharedkey", "preshared_key", "pre-shared-key", "psk"); psk != "" {
peer["preSharedKey"] = psk
}
if ka := firstParam(params, "keepalive", "persistentkeepalive", "persistent_keepalive"); ka != "" {
if n, err := strconv.Atoi(ka); err == nil {
peer["keepAlive"] = n
}
}
settings := map[string]any{
"secretKey": secret,
"address": addrs,
"peers": []any{peer},
}
if mtu := params.Get("mtu"); mtu != "" {
if n, err := strconv.Atoi(mtu); err == nil {
settings["mtu"] = n
}
}
if res := params.Get("reserved"); res != "" {
parts := splitComma(res)
var iv []int
for _, p := range parts {
if n, err := strconv.Atoi(strings.TrimSpace(p)); err == nil {
iv = append(iv, n)
}
}
if len(iv) > 0 {
settings["reserved"] = iv
}
}
identity := "wireguard:" + secret + "@" + endpoint + "?" + canonicalQuery(params)
ob := Outbound{
"protocol": "wireguard",
"tag": decodeHash(u.Fragment),
"settings": settings,
}
return &ParseResult{Outbound: ob, Identity: identity}, nil
}
// --- helpers ---
func buildStream(network, security string) map[string]any {
stream := map[string]any{"network": network, "security": security}
switch network {
case "tcp":
stream["tcpSettings"] = map[string]any{"header": map[string]any{"type": "none"}}
case "kcp":
stream["kcpSettings"] = map[string]any{
"mtu": 1350, "tti": 20, "uplinkCapacity": 5, "downlinkCapacity": 20,
"cwndMultiplier": 1, "maxSendingWindow": 2097152,
}
case "ws":
stream["wsSettings"] = map[string]any{"path": "/", "host": "", "headers": map[string]any{}, "heartbeatPeriod": 0}
case "grpc":
stream["grpcSettings"] = map[string]any{"serviceName": "", "authority": "", "multiMode": false}
case "httpupgrade":
stream["httpupgradeSettings"] = map[string]any{"path": "/", "host": "", "headers": map[string]any{}}
case "xhttp":
stream["xhttpSettings"] = map[string]any{
"path": "/", "host": "", "mode": "auto", "headers": map[string]any{},
"xPaddingBytes": "100-1000", "scMaxEachPostBytes": "1000000",
}
default:
stream["tcpSettings"] = map[string]any{"header": map[string]any{"type": "none"}}
}
if security == "tls" {
stream["tlsSettings"] = map[string]any{
"serverName": "", "alpn": []any{}, "fingerprint": "",
"echConfigList": "", "verifyPeerCertByName": "", "pinnedPeerCertSha256": "",
}
} else if security == "reality" {
stream["realitySettings"] = map[string]any{
"publicKey": "", "fingerprint": "chrome", "serverName": "",
"shortId": "", "spiderX": "", "mldsa65Verify": "",
}
}
return stream
}
func setWS(stream map[string]any, host, path string) {
ws := stream["wsSettings"].(map[string]any)
ws["host"] = host
ws["path"] = path
}
func setHTTPUpgrade(stream map[string]any, host, path string) {
h := stream["httpupgradeSettings"].(map[string]any)
h["host"] = host
h["path"] = path
}
func applyTransport(stream map[string]any, p url.Values) {
net := stream["network"].(string)
host := p.Get("host")
path := firstNonEmpty(p.Get("path"), "/")
switch net {
case "ws":
setWS(stream, host, path)
case "grpc":
gs := stream["grpcSettings"].(map[string]any)
gs["serviceName"] = firstNonEmpty(p.Get("serviceName"), p.Get("path"))
gs["authority"] = p.Get("authority")
gs["multiMode"] = p.Get("mode") == "multi"
case "httpupgrade":
setHTTPUpgrade(stream, host, path)
case "xhttp":
xh := stream["xhttpSettings"].(map[string]any)
xh["host"] = host
xh["path"] = path
if m := p.Get("mode"); m != "" {
xh["mode"] = m
}
// A few advanced xhttp fields that are commonly carried
for _, k := range []string{"xPaddingBytes", "scMaxEachPostBytes", "scMinPostsIntervalMs", "uplinkChunkSize"} {
if v := p.Get(k); v != "" {
xh[k] = v
}
}
case "tcp":
if p.Get("headerType") == "http" || p.Get("type") == "http" {
stream["tcpSettings"] = map[string]any{
"header": map[string]any{
"type": "http",
"request": map[string]any{
"version": "1.1",
"method": "GET",
"path": splitComma(path),
"headers": map[string]any{"Host": splitComma(host)},
},
},
}
}
}
}
func applySecurity(stream map[string]any, p url.Values) {
sec := stream["security"].(string)
if sec == "tls" {
tls := stream["tlsSettings"].(map[string]any)
tls["serverName"] = p.Get("sni")
tls["fingerprint"] = p.Get("fp")
if alpn := p.Get("alpn"); alpn != "" {
tls["alpn"] = splitComma(alpn)
}
tls["echConfigList"] = p.Get("ech")
tls["pinnedPeerCertSha256"] = p.Get("pcs")
} else if sec == "reality" {
re := stream["realitySettings"].(map[string]any)
re["serverName"] = p.Get("sni")
re["fingerprint"] = firstNonEmpty(p.Get("fp"), "chrome")
re["publicKey"] = p.Get("pbk")
re["shortId"] = p.Get("sid")
re["spiderX"] = p.Get("spx")
re["mldsa65Verify"] = p.Get("pqv")
}
}
func applyFinalMask(stream map[string]any, p url.Values) {
if fm := p.Get("fm"); fm != "" {
var parsed any
if json.Unmarshal([]byte(fm), &parsed) == nil {
stream["finalmask"] = parsed
}
}
}
func firstNonEmpty(a, b string) string {
if a != "" {
return a
}
return b
}
func firstParam(p url.Values, keys ...string) string {
for _, k := range keys {
if v := p.Get(k); v != "" {
return v
}
}
return ""
}
func canonicalQuery(p url.Values) string {
// Sort keys for stable identity
keys := make([]string, 0, len(p))
for k := range p {
keys = append(keys, k)
}
// simple sort
for i := 0; i < len(keys); i++ {
for j := i + 1; j < len(keys); j++ {
if keys[j] < keys[i] {
keys[i], keys[j] = keys[j], keys[i]
}
}
}
parts := make([]string, 0, len(keys))
for _, k := range keys {
for _, v := range p[k] {
parts = append(parts, k+"="+v)
}
}
return strings.Join(parts, "&")
}
func decodeHash(h string) string {
if h == "" {
return ""
}
if dec, err := url.QueryUnescape(h); err == nil {
return dec
}
return h
}
func defaultPort(p string, def int) int {
if p == "" {
return def
}
n, err := strconv.Atoi(p)
if err != nil || n <= 0 {
return def
}
return n
}
func num(v any) int {
switch x := v.(type) {
case float64:
return int(x)
case int:
return x
case int64:
return int(x)
case string:
n, _ := strconv.Atoi(x)
return n
}
return 0
}
func getString(m map[string]any, key, def string) string {
if v, ok := m[key]; ok {
if s, ok := v.(string); ok {
return s
}
}
return def
}
func splitComma(s string) []string {
if s == "" {
return nil
}
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
func splitCommaOrDefault(s string, def []string) []string {
if s == "" {
return def
}
return splitComma(s)
}
func padBase64(s string) string {
for len(s)%4 != 0 {
s += "="
}
return s
}
func base64DecodeFlexible(s string) (string, error) {
s = padBase64(s)
if b, err := base64.StdEncoding.DecodeString(s); err == nil {
return string(b), nil
}
if b, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(s, "=")); err == nil {
return string(b), nil
}
return "", fmt.Errorf("base64 decode failed")
}
// SlugRemark turns a free-form remark into a conservative DNS-ish tag segment.
var slugRe = regexp.MustCompile(`[^a-z0-9]+`)
func SlugRemark(remark string) string {
s := strings.ToLower(strings.TrimSpace(remark))
s = slugRe.ReplaceAllString(s, "-")
s = strings.Trim(s, "-")
if s == "" {
return ""
}
// collapse runs of dashes
for strings.Contains(s, "--") {
s = strings.ReplaceAll(s, "--", "-")
}
return s
}
// SuggestTag builds a tag from a prefix and a remark (or index fallback).
// It is intended for initial assignment; stability is handled by the service layer.
func SuggestTag(prefix, remark string, idx int) string {
base := SlugRemark(remark)
if base == "" {
base = fmt.Sprintf("%d", idx)
}
p := strings.TrimSuffix(prefix, "-")
if p != "" {
return p + "-" + base
}
return base
}
+62
View File
@@ -0,0 +1,62 @@
package link
import (
"strings"
"testing"
)
func TestParseVmessLink(t *testing.T) {
// vmess:// + base64 of:
// {"v":"2","ps":"test","add":"1.2.3.4","port":443,"id":"uuid","aid":"0","net":"ws","type":"","host":"ex.com","path":"/","tls":"tls"}
link := "vmess://eyJ2IjoiMiIsInBzIjoidGVzdCIsImFkZCI6IjEuMi4zLjQiLCJwb3J0Ijo0NDMsImlkIjoidXVpZCIsImFpZCI6IjAiLCJuZXQiOiJ3cyIsInR5cGUiOiIiLCJob3N0IjoiZXguY29tIiwicGF0aCI6Ii8iLCJ0bHMiOiJ0bHMifQ=="
res, err := ParseLink(link)
if err != nil {
t.Fatalf("parse vmess: %v", err)
}
if res.Outbound["protocol"] != "vmess" {
t.Errorf("expected vmess protocol, got %v", res.Outbound["protocol"])
}
if res.Outbound["tag"] != "test" {
t.Errorf("expected tag 'test', got %v", res.Outbound["tag"])
}
}
func TestParseVlessLink(t *testing.T) {
link := "vless://uuid@1.2.3.4:443?type=ws&security=tls&path=/&host=ex.com#node1"
res, err := ParseLink(link)
if err != nil {
t.Fatalf("parse vless: %v", err)
}
if res.Outbound["protocol"] != "vless" {
t.Fatalf("bad protocol")
}
if res.Outbound["tag"] != "node1" {
t.Errorf("tag mismatch: %v", res.Outbound["tag"])
}
}
func TestParseSubscriptionBody_Base64(t *testing.T) {
// base64 of the two joined links:
// vless://u@h:443?type=tcp#A\nvless://u2@h2:443?type=tcp#B
b64 := "dmxlc3M6Ly91QGg6NDQzP3R5cGU9dGNwI0EKdmxlc3M6Ly91MkBoMjo0NDM/dHlwZT10Y3AjQg=="
obs, ids, err := ParseSubscriptionBody([]byte(b64))
if err != nil {
t.Fatalf("parse sub body: %v", err)
}
if len(obs) != 2 {
t.Fatalf("expected 2 outbounds, got %d", len(obs))
}
if !strings.HasPrefix(ids[0], "vless:") || !strings.HasPrefix(ids[1], "vless:") {
t.Errorf("bad identities: %v", ids)
}
}
func TestSlugAndSuggest(t *testing.T) {
if SlugRemark("Hello World!") != "hello-world" {
t.Errorf("slug failed")
}
tag := SuggestTag("hk-", " SG 01 !! ", 0)
if tag != "hk-sg-01" {
t.Errorf("suggest tag got %q", tag)
}
}
+24
View File
@@ -0,0 +1,24 @@
package util
import (
"crypto/rand"
"encoding/base64"
"golang.org/x/crypto/curve25519"
)
// GenerateWireguardKeypair generates a base64 encoded private and public key pair for Wireguard.
func GenerateWireguardKeypair() (privateKey string, publicKey string, err error) {
var priv [32]byte
if _, err := rand.Read(priv[:]); err != nil {
return "", "", err
}
priv[0] &= 248
priv[31] &= 127
priv[31] |= 64
var pub [32]byte
curve25519.ScalarBaseMult(&pub, &priv)
return base64.StdEncoding.EncodeToString(priv[:]), base64.StdEncoding.EncodeToString(pub[:]), nil
}
+10 -2
View File
@@ -350,8 +350,16 @@ func (a *InboundController) importInbound(c *gin.Context) {
user := session.GetLoginUser(c)
inbound.Id = 0
inbound.UserId = user.Id
if inbound.NodeID != nil && *inbound.NodeID == 0 {
inbound.NodeID = nil
// Node IDs are panel-local and not portable across panels. Drop a node
// reference that is zero or that points to a node which doesn't exist on
// this panel, so a cross-panel export imports as a local inbound instead of
// failing with "record not found" when nodePushPlan looks the node up.
if inbound.NodeID != nil {
if *inbound.NodeID == 0 {
inbound.NodeID = nil
} else if exists, err := (&service.NodeService{}).NodeExists(*inbound.NodeID); err == nil && !exists {
inbound.NodeID = nil
}
}
for index := range inbound.ClientStats {
+18
View File
@@ -9,6 +9,7 @@ import (
"time"
"github.com/mhsanaei/3x-ui/v3/database"
"github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/logger"
"github.com/mhsanaei/3x-ui/v3/web/entity"
"github.com/mhsanaei/3x-ui/v3/web/global"
@@ -61,6 +62,7 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
g.GET("/getNewmldsa65", a.getNewmldsa65)
g.GET("/getNewmlkem768", a.getNewmlkem768)
g.GET("/getNewVlessEnc", a.getNewVlessEnc)
g.GET("/clientIps", a.getClientIps)
g.POST("/stopXrayService", a.stopXrayService)
g.POST("/restartXrayService", a.restartXrayService)
@@ -72,6 +74,7 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
g.POST("/xraylogs/:count", a.getXrayLogs)
g.POST("/importDB", a.importDB)
g.POST("/getNewEchCert", a.getNewEchCert)
g.POST("/clientIps", a.setClientIps)
}
// startTask registers the @2s ticker that refreshes server status, samples
@@ -420,3 +423,18 @@ func (a *ServerController) getNewmlkem768(c *gin.Context) {
}
jsonObj(c, out, nil)
}
func (a *ServerController) getClientIps(c *gin.Context) {
ips, err := (&service.InboundService{}).GetAllInboundClientIps()
jsonObj(c, ips, err)
}
func (a *ServerController) setClientIps(c *gin.Context) {
var ips []model.InboundClientIps
if err := c.ShouldBindJSON(&ips); err != nil {
jsonMsg(c, "invalid data", err)
return
}
err := (&service.InboundService{}).MergeInboundClientIps(ips)
jsonMsg(c, "Client IPs merged", err)
}
+195 -7
View File
@@ -2,6 +2,9 @@ package controller
import (
"encoding/json"
"fmt"
"strconv"
"time"
"github.com/mhsanaei/3x-ui/v3/util/common"
"github.com/mhsanaei/3x-ui/v3/web/service"
@@ -11,13 +14,14 @@ import (
// XraySettingController handles Xray configuration and settings operations.
type XraySettingController struct {
XraySettingService service.XraySettingService
SettingService service.SettingService
InboundService service.InboundService
OutboundService service.OutboundService
XrayService service.XrayService
WarpService service.WarpService
NordService service.NordService
XraySettingService service.XraySettingService
SettingService service.SettingService
InboundService service.InboundService
OutboundService service.OutboundService
XrayService service.XrayService
WarpService service.WarpService
NordService service.NordService
OutboundSubscriptionService service.OutboundSubscriptionService
}
// NewXraySettingController creates a new XraySettingController and initializes its routes.
@@ -40,6 +44,16 @@ func (a *XraySettingController) initRouter(g *gin.RouterGroup) {
g.POST("/update", a.updateSetting)
g.POST("/resetOutboundsTraffic", a.resetOutboundsTraffic)
g.POST("/testOutbound", a.testOutbound)
// Outbound subscription (remote outbound lists)
g.GET("/outbound-subs", a.listOutboundSubs)
g.POST("/outbound-subs", a.createOutboundSub)
g.POST("/outbound-subs/:id/refresh", a.refreshOutboundSub)
g.POST("/outbound-subs/:id/move", a.moveOutboundSub)
g.POST("/outbound-subs/:id", a.updateOutboundSub)
g.DELETE("/outbound-subs/:id", a.deleteOutboundSub)
g.POST("/outbound-subs/:id/del", a.deleteOutboundSub) // axios-friendly alias
g.POST("/outbound-subs/parse", a.parseOutboundSubURL) // preview without saving
}
// getXraySetting retrieves the Xray configuration template, inbound tags, and outbound test URL.
@@ -85,6 +99,17 @@ func (a *XraySettingController) getXraySetting(c *gin.Context) {
"clientReverseTags": json.RawMessage(clientReverseTags),
"outboundTestUrl": outboundTestUrl,
}
// Surface subscription outbounds (and their tags) so the frontend can:
// - show them as read-only items in the Outbounds tab
// - let users pick them in balancers and routing rules
// These are not part of the editable template; they are injected at runtime.
if subObs, err := a.OutboundSubscriptionService.AllActiveOutbounds(); err == nil && len(subObs) > 0 {
xrayResponse["subscriptionOutbounds"] = subObs
}
if subTags, err := a.OutboundSubscriptionService.AllActiveOutboundTags(); err == nil && len(subTags) > 0 {
xrayResponse["subscriptionOutboundTags"] = subTags
}
result, err := json.Marshal(xrayResponse)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getSettings"), err)
@@ -142,9 +167,26 @@ func (a *XraySettingController) warp(c *gin.Context) {
skey := c.PostForm("privateKey")
pkey := c.PostForm("publicKey")
resp, err = a.WarpService.RegWarp(skey, pkey)
case "changeIp":
resp, err = a.WarpService.ChangeWarpIP()
if err == nil {
a.XrayService.SetToNeedRestart()
// Restart the auto-update clock so a scheduled rotation
// doesn't fire right after this manual one.
_ = a.SettingService.SetWarpLastUpdate(time.Now().Unix())
}
case "license":
license := c.PostForm("license")
resp, err = a.WarpService.SetWarpLicense(license)
case "interval":
interval, convErr := strconv.Atoi(c.PostForm("interval"))
if convErr != nil || interval < 0 {
err = common.NewError("invalid warp update interval")
} else if err = a.SettingService.SetWarpUpdateInterval(interval); err == nil && interval > 0 {
// Count the interval from now rather than from epoch 0,
// otherwise the job would rotate on its next tick.
_ = a.SettingService.SetWarpLastUpdate(time.Now().Unix())
}
}
jsonObj(c, resp, err)
@@ -227,3 +269,149 @@ func (a *XraySettingController) testOutbound(c *gin.Context) {
jsonObj(c, result, nil)
}
// --- Outbound Subscription handlers ---
func (a *XraySettingController) listOutboundSubs(c *gin.Context) {
list, err := a.OutboundSubscriptionService.List()
if err != nil {
jsonMsg(c, "Failed to list outbound subscriptions", err)
return
}
jsonObj(c, list, nil)
}
func (a *XraySettingController) createOutboundSub(c *gin.Context) {
remark := c.PostForm("remark")
rawURL := c.PostForm("url")
prefix := c.PostForm("tagPrefix")
enabled := c.PostForm("enabled") != "false"
allowPrivate := c.PostForm("allowPrivate") == "true"
prepend := c.PostForm("prepend") == "true"
intervalStr := c.PostForm("updateInterval")
interval := 600
if intervalStr != "" {
if v, err := parseIntSafe(intervalStr); err == nil && v > 0 {
interval = v
}
}
sub, err := a.OutboundSubscriptionService.Create(remark, rawURL, prefix, enabled, interval, allowPrivate, prepend)
if err != nil {
jsonMsg(c, "Failed to create outbound subscription", err)
return
}
jsonObj(c, sub, nil)
}
func (a *XraySettingController) updateOutboundSub(c *gin.Context) {
id := c.Param("id")
var subID int
if _, err := fmt.Sscanf(id, "%d", &subID); err != nil {
jsonMsg(c, "Invalid id", err)
return
}
remark := c.PostForm("remark")
rawURL := c.PostForm("url")
prefix := c.PostForm("tagPrefix")
enabled := c.PostForm("enabled") != "false"
allowPrivate := c.PostForm("allowPrivate") == "true"
prepend := c.PostForm("prepend") == "true"
intervalStr := c.PostForm("updateInterval")
interval := 600
if intervalStr != "" {
if v, err := parseIntSafe(intervalStr); err == nil && v > 0 {
interval = v
}
}
if err := a.OutboundSubscriptionService.Update(subID, remark, rawURL, prefix, enabled, interval, allowPrivate, prepend); err != nil {
jsonMsg(c, "Failed to update outbound subscription", err)
return
}
jsonObj(c, "", nil)
}
func (a *XraySettingController) deleteOutboundSub(c *gin.Context) {
id := c.Param("id")
var subID int
if _, err := fmt.Sscanf(id, "%d", &subID); err != nil {
jsonMsg(c, "Invalid id", err)
return
}
if err := a.OutboundSubscriptionService.Delete(subID); err != nil {
jsonMsg(c, "Failed to delete outbound subscription", err)
return
}
// Signal that xray should drop this subscription's outbounds on next reload.
a.XrayService.SetToNeedRestart()
jsonObj(c, "", nil)
}
func (a *XraySettingController) refreshOutboundSub(c *gin.Context) {
id := c.Param("id")
var subID int
if _, err := fmt.Sscanf(id, "%d", &subID); err != nil {
jsonMsg(c, "Invalid id", err)
return
}
obs, err := a.OutboundSubscriptionService.Refresh(subID)
if err != nil {
jsonMsg(c, "Refresh failed", err)
return
}
// Signal that xray should pick up the new outbounds on next restart/reload
a.XrayService.SetToNeedRestart()
jsonObj(c, obs, nil)
}
func (a *XraySettingController) moveOutboundSub(c *gin.Context) {
id := c.Param("id")
var subID int
if _, err := fmt.Sscanf(id, "%d", &subID); err != nil {
jsonMsg(c, "Invalid id", err)
return
}
up := c.PostForm("dir") == "up"
if err := a.OutboundSubscriptionService.Move(subID, up); err != nil {
jsonMsg(c, "Failed to reorder outbound subscription", err)
return
}
// Order affects the merged outbounds, so xray needs a reload.
a.XrayService.SetToNeedRestart()
jsonObj(c, "", nil)
}
// parseOutboundSubURL is a preview endpoint: it fetches + parses the provided
// URL but does not persist anything. Useful for the "add subscription" flow
// so the user can see the resulting outbounds (and assigned tags) before saving.
func (a *XraySettingController) parseOutboundSubURL(c *gin.Context) {
rawURL := c.PostForm("url")
if rawURL == "" {
jsonMsg(c, "url is required", common.NewError("missing url"))
return
}
allowPrivate := c.PostForm("allowPrivate") == "true"
// Use a throw-away service instance; it only needs the settingService for proxy.
svc := service.OutboundSubscriptionService{}
// We don't have a direct "fetch once" that returns without storing, so we
// temporarily create a disabled row, refresh it, then delete. Cleaner would
// be to expose a pure ParseURL on the service, but this keeps the surface small.
tmp, err := svc.Create("preview", rawURL, "", false, 600, allowPrivate, false)
if err != nil {
jsonMsg(c, "Failed to preview subscription", err)
return
}
obs, err := svc.Refresh(tmp.Id)
// best-effort cleanup
_ = svc.Delete(tmp.Id)
if err != nil {
jsonMsg(c, "Failed to fetch/parse subscription", err)
return
}
jsonObj(c, obs, nil)
}
func parseIntSafe(s string) (int, error) {
var v int
_, err := fmt.Sscanf(s, "%d", &v)
return v, err
}
+4
View File
@@ -88,6 +88,7 @@ type AllSetting struct {
SubJsonMux string `json:"subJsonMux" form:"subJsonMux"` // JSON subscription mux configuration
SubJsonRules string `json:"subJsonRules" form:"subJsonRules"`
SubJsonFinalMask string `json:"subJsonFinalMask" form:"subJsonFinalMask"` // JSON subscription global finalmask (tcp/udp masks + quicParams)
SubThemeDir string `json:"subThemeDir" form:"subThemeDir"` // Absolute path to a folder containing a custom subscription page template
// LDAP settings
LdapEnable bool `json:"ldapEnable" form:"ldapEnable"`
@@ -112,6 +113,9 @@ type AllSetting struct {
LdapDefaultExpiryDays int `json:"ldapDefaultExpiryDays" form:"ldapDefaultExpiryDays" validate:"gte=0"`
LdapDefaultLimitIP int `json:"ldapDefaultLimitIP" form:"ldapDefaultLimitIP" validate:"gte=0"`
// JSON subscription routing rules
// WARP
WarpUpdateInterval int `json:"warpUpdateInterval" form:"warpUpdateInterval" validate:"gte=0"`
}
// AllSettingView is the browser-safe settings read model. Secret values
+5 -1
View File
@@ -242,9 +242,13 @@ func mergeClientIps(old, new []IPWithTimestamp, staleCutoff int64) map[string]in
func partitionLiveIps(ipMap map[string]int64, observedThisScan map[string]bool) (live, historical []IPWithTimestamp) {
live = make([]IPWithTimestamp, 0, len(observedThisScan))
historical = make([]IPWithTimestamp, 0, len(ipMap))
now := time.Now().Unix()
for ip, ts := range ipMap {
entry := IPWithTimestamp{IP: ip, Timestamp: ts}
if observedThisScan[ip] {
// Consider an IP "live" if it was seen locally in this scan, OR if its
// timestamp from the synced database is very recent (e.g. within 2 minutes).
// This ensures cluster-wide limits work even if the IP was seen on another node.
if observedThisScan[ip] || now-ts < 120 {
live = append(live, entry)
} else {
historical = append(historical, entry)
+21
View File
@@ -6,6 +6,7 @@ import (
"reflect"
"runtime"
"testing"
"time"
)
func TestMergeClientIps_EvictsStaleOldEntries(t *testing.T) {
@@ -149,6 +150,26 @@ func TestPartitionLiveIps_EmptyScanLeavesDbIntact(t *testing.T) {
}
}
func TestPartitionLiveIps_RecentSyncedIpIsLive(t *testing.T) {
// Synced IPs from other nodes within 2 minutes should be counted as live
// even if they weren't observed in the local scan.
now := time.Now().Unix()
ipMap := map[string]int64{
"A": now - 30, // synced 30s ago -> live
"B": now - 150, // synced 2m30s ago -> historical
}
observed := map[string]bool{}
live, historical := partitionLiveIps(ipMap, observed)
if got := collectIps(live); !reflect.DeepEqual(got, []string{"A"}) {
t.Fatalf("recent IP should be live\ngot: %v\nwant: [A]", got)
}
if got := collectIps(historical); !reflect.DeepEqual(got, []string{"B"}) {
t.Fatalf("older IP should be historical\ngot: %v\nwant: [B]", got)
}
}
func TestCheckFail2BanInstalled_DisabledEnvSkipsClientProbe(t *testing.T) {
t.Setenv("XUI_ENABLE_FAIL2BAN", "false")
marker := fakeFail2BanClient(t)
+62
View File
@@ -0,0 +1,62 @@
package job
import (
"github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/logger"
"github.com/mhsanaei/3x-ui/v3/mtproto"
"github.com/mhsanaei/3x-ui/v3/web/service"
"github.com/mhsanaei/3x-ui/v3/xray"
)
// MtprotoJob reconciles the running mtg sidecar processes against the enabled
// mtproto inbounds in the database, restarts any that crashed, and folds the
// per-inbound traffic scraped from each mtg metrics endpoint into the usual
// inbound traffic accounting.
type MtprotoJob struct {
inboundService service.InboundService
}
// NewMtprotoJob creates a new mtproto reconcile/traffic job instance.
func NewMtprotoJob() *MtprotoJob {
return new(MtprotoJob)
}
// Run reconciles desired mtproto inbounds with running mtg processes and
// records traffic deltas.
func (j *MtprotoJob) Run() {
inbounds, err := j.inboundService.GetAllInbounds()
if err != nil {
logger.Warning("mtproto job: get inbounds failed:", err)
return
}
var desired []mtproto.Instance
for _, ib := range inbounds {
if ib.Protocol != model.MTProto || !ib.Enable || ib.NodeID != nil {
continue
}
if inst, ok := mtproto.InstanceFromInbound(ib); ok {
desired = append(desired, inst)
}
}
mgr := mtproto.GetManager()
mgr.Reconcile(desired)
deltas := mgr.CollectTraffic()
if len(deltas) == 0 {
return
}
traffics := make([]*xray.Traffic, 0, len(deltas))
for _, d := range deltas {
traffics = append(traffics, &xray.Traffic{
IsInbound: true,
Tag: d.Tag,
Up: d.Up,
Down: d.Down,
})
}
if _, _, err := j.inboundService.AddTraffic(traffics, nil); err != nil {
logger.Warning("mtproto job: add traffic failed:", err)
}
}
+39 -2
View File
@@ -16,6 +16,7 @@ const (
nodeTrafficSyncConcurrency = 8
nodeTrafficSyncRequestTimeout = 4 * time.Second
nodeReconcileTimeout = 30 * time.Second
nodeClientIpSyncInterval = 10 * time.Second
)
type NodeTrafficSyncJob struct {
@@ -25,6 +26,8 @@ type NodeTrafficSyncJob struct {
xrayService service.XrayService
running sync.Mutex
structural atomicBool
ipSyncMu sync.Mutex
lastIpSync int64
}
type atomicBool struct {
@@ -70,6 +73,16 @@ func (j *NodeTrafficSyncJob) Run() {
return
}
// Decide once per tick whether this run also syncs client IPs, and stamp the
// clock before the loop so two back-to-back 5s ticks can't both qualify.
doIpSync := false
j.ipSyncMu.Lock()
if now := time.Now().Unix(); now-j.lastIpSync >= int64(nodeClientIpSyncInterval/time.Second) {
doIpSync = true
j.lastIpSync = now
}
j.ipSyncMu.Unlock()
sem := make(chan struct{}, nodeTrafficSyncConcurrency)
var wg sync.WaitGroup
for _, n := range nodes {
@@ -81,7 +94,7 @@ func (j *NodeTrafficSyncJob) Run() {
go func(n *model.Node) {
defer wg.Done()
defer func() { <-sem }()
j.syncOne(mgr, n)
j.syncOne(mgr, n, doIpSync)
}(n)
}
wg.Wait()
@@ -151,7 +164,7 @@ func (j *NodeTrafficSyncJob) Run() {
}
}
func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node) {
func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSync bool) {
rt, err := mgr.RemoteFor(n)
if err != nil {
logger.Warning("node traffic sync: remote lookup failed for", n.Name, ":", err)
@@ -190,4 +203,28 @@ func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node) {
if changed {
j.structural.set()
}
if !doIpSync {
return
}
nodeIps, err := rt.FetchAllClientIps(ctx)
if err == nil && len(nodeIps) > 0 {
if err := j.inboundService.MergeInboundClientIps(nodeIps); err != nil {
logger.Warning("node traffic sync: merge client ips from", n.Name, "failed:", err)
}
} else if err != nil {
logger.Warning("node traffic sync: fetch client ips from", n.Name, "failed:", err)
}
masterIps, err := j.inboundService.GetAllInboundClientIps()
if err != nil {
logger.Warning("node traffic sync: load client ips for push to", n.Name, "failed:", err)
return
}
if len(masterIps) > 0 {
if err := rt.PushAllClientIps(ctx, masterIps); err != nil {
logger.Warning("node traffic sync: push client ips to", n.Name, "failed:", err)
}
}
}
+48
View File
@@ -0,0 +1,48 @@
package job
import (
"github.com/mhsanaei/3x-ui/v3/logger"
"github.com/mhsanaei/3x-ui/v3/web/service"
"github.com/mhsanaei/3x-ui/v3/web/websocket"
)
// OutboundSubscriptionJob periodically re-fetches enabled outbound subscriptions,
// updates the stored outbounds (with stable tags), and signals that xray
// should be reloaded so the new outbounds take effect.
type OutboundSubscriptionJob struct {
subService *service.OutboundSubscriptionService
xraySvc *service.XrayService
}
// NewOutboundSubscriptionJob creates the job (zero-value services are populated
// on first Run via method calls, same pattern as other jobs).
func NewOutboundSubscriptionJob() *OutboundSubscriptionJob {
return &OutboundSubscriptionJob{
subService: &service.OutboundSubscriptionService{},
xraySvc: &service.XrayService{},
}
}
// Run is invoked by the cron scheduler.
func (j *OutboundSubscriptionJob) Run() {
if j.subService == nil {
j.subService = &service.OutboundSubscriptionService{}
}
if j.xraySvc == nil {
j.xraySvc = &service.XrayService{}
}
count, err := j.subService.RefreshAllEnabled()
if err != nil {
logger.Warning("outbound subscription auto-update error:", err)
return
}
if count > 0 {
logger.Infof("Refreshed %d outbound subscription(s)", count)
// Ask the xray manager to restart/reload on the next 30s check.
j.xraySvc.SetToNeedRestart()
// Also broadcast an invalidate so the UI can refresh the xray setting
// view (new outbounds will be visible after the reload cycle).
websocket.BroadcastInvalidate(websocket.MessageTypeOutbounds)
}
}
+52
View File
@@ -0,0 +1,52 @@
package job
import (
"time"
"github.com/mhsanaei/3x-ui/v3/logger"
"github.com/mhsanaei/3x-ui/v3/web/service"
)
type WarpIpJob struct {
settingService service.SettingService
warpService service.WarpService
xrayService service.XrayService
}
func NewWarpIpJob() *WarpIpJob {
return &WarpIpJob{}
}
func (j *WarpIpJob) Run() {
allSetting, err := j.settingService.GetAllSetting()
if err != nil {
return
}
interval := allSetting.WarpUpdateInterval
if interval <= 0 {
return
}
lastUpdate, _ := j.settingService.GetWarpLastUpdate()
now := time.Now().Unix()
// First run after the feature is enabled (e.g. interval set via direct
// DB edit): establish a baseline instead of rotating immediately.
if lastUpdate == 0 {
_ = j.settingService.SetWarpLastUpdate(now)
return
}
if now-lastUpdate >= int64(interval*24*3600) {
logger.Info("Starting scheduled WARP IP update...")
_, err := j.warpService.ChangeWarpIP()
if err != nil {
logger.Warning("Failed to update WARP IP: ", err)
return
}
_ = j.settingService.SetWarpLastUpdate(now)
j.xrayService.SetToNeedRestart()
logger.Info("Successfully updated WARP IP and scheduled Xray restart")
}
}
+22
View File
@@ -8,6 +8,7 @@ import (
"sync"
"github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/mtproto"
"github.com/mhsanaei/3x-ui/v3/xray"
)
@@ -44,6 +45,13 @@ func (l *Local) withAPI(fn func(api *xray.XrayAPI) error) error {
}
func (l *Local) AddInbound(_ context.Context, ib *model.Inbound) error {
if ib.Protocol == model.MTProto {
inst, ok := mtproto.InstanceFromInbound(ib)
if !ok {
return nil
}
return mtproto.GetManager().Ensure(inst)
}
body, err := json.MarshalIndent(ib.GenXrayInboundConfig(), "", " ")
if err != nil {
return err
@@ -54,6 +62,10 @@ func (l *Local) AddInbound(_ context.Context, ib *model.Inbound) error {
}
func (l *Local) DelInbound(_ context.Context, ib *model.Inbound) error {
if ib.Protocol == model.MTProto {
mtproto.GetManager().Remove(ib.Id)
return nil
}
return l.withAPI(func(api *xray.XrayAPI) error {
return api.DelInbound(ib.Tag)
})
@@ -68,12 +80,18 @@ func (l *Local) UpdateInbound(ctx context.Context, oldIb, newIb *model.Inbound)
}
func (l *Local) AddUser(_ context.Context, ib *model.Inbound, userMap map[string]any) error {
if ib.Protocol == model.MTProto {
return nil
}
return l.withAPI(func(api *xray.XrayAPI) error {
return api.AddUser(string(ib.Protocol), ib.Tag, userMap)
})
}
func (l *Local) RemoveUser(_ context.Context, ib *model.Inbound, email string) error {
if ib.Protocol == model.MTProto {
return nil
}
return l.withAPI(func(api *xray.XrayAPI) error {
return api.RemoveUser(ib.Tag, email)
})
@@ -141,3 +159,7 @@ func (l *Local) ResetClientTraffic(_ context.Context, _ *model.Inbound, _ string
func (l *Local) ResetAllTraffics(_ context.Context) error {
return nil
}
func (l *Local) ResetInboundTraffic(_ context.Context, _ *model.Inbound) error {
return nil
}
+24
View File
@@ -399,6 +399,11 @@ func (r *Remote) ResetAllTraffics(ctx context.Context) error {
return err
}
func (r *Remote) ResetInboundTraffic(ctx context.Context, ib *model.Inbound) error {
_, err := r.do(ctx, http.MethodPost, fmt.Sprintf("panel/api/inbounds/%d/resetTraffic", ib.Id), nil)
return err
}
type TrafficSnapshot struct {
Inbounds []*model.Inbound
OnlineEmails []string
@@ -533,3 +538,22 @@ func isNonEmptySlice(v any) bool {
s, ok := v.([]any)
return ok && len(s) > 0
}
func (r *Remote) FetchAllClientIps(ctx context.Context) ([]model.InboundClientIps, error) {
env, err := r.do(ctx, http.MethodGet, "panel/api/server/clientIps", nil)
if err != nil {
return nil, err
}
var ips []model.InboundClientIps
if len(env.Obj) > 0 {
if err := json.Unmarshal(env.Obj, &ips); err != nil {
return nil, fmt.Errorf("decode client ips: %w", err)
}
}
return ips, nil
}
func (r *Remote) PushAllClientIps(ctx context.Context, ips []model.InboundClientIps) error {
_, err := r.do(ctx, http.MethodPost, "panel/api/server/clientIps", ips)
return err
}
+1
View File
@@ -26,5 +26,6 @@ type Runtime interface {
RestartXray(ctx context.Context) error
ResetClientTraffic(ctx context.Context, ib *model.Inbound, email string) error
ResetInboundTraffic(ctx context.Context, ib *model.Inbound) error
ResetAllTraffics(ctx context.Context) error
}
+4 -1
View File
@@ -101,7 +101,10 @@ func TestAllAPIsPostgresScale(t *testing.T) {
run("GetInboundsSlim", func() error { _, err := inboundSvc.GetInboundsSlim(userId); return err })
run("GetInboundDetail", func() error { _, err := inboundSvc.GetInboundDetail(ib.Id); return err })
run("GetInboundOptions", func() error { _, err := inboundSvc.GetInboundOptions(userId); return err })
run("ListPaged", func() error { _, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{Page: 1, PageSize: 25}); return err })
run("ListPaged", func() error {
_, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{Page: 1, PageSize: 25})
return err
})
run("ListPaged+search", func() error {
_, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{Page: 1, PageSize: 25, Search: "user-0012345"})
return err
+17 -9
View File
@@ -1714,15 +1714,19 @@ func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *Settin
type GroupSummary struct {
Name string `json:"name"`
ClientCount int `json:"clientCount"`
TrafficUsed int64 `json:"trafficUsed"`
}
func (s *ClientService) ListGroups() ([]GroupSummary, error) {
db := database.GetDB()
// email is unique in both clients and client_traffics, so the LEFT JOIN
// never double-counts a client's traffic.
var derived []GroupSummary
if err := db.Model(&model.ClientRecord{}).
Select("group_name AS name, COUNT(*) AS client_count").
Where("group_name <> ''").
Group("group_name").
if err := db.Table("clients AS c").
Select("c.group_name AS name, COUNT(*) AS client_count, COALESCE(SUM(ct.up + ct.down), 0) AS traffic_used").
Joins("LEFT JOIN client_traffics ct ON ct.email = c.email").
Where("c.group_name <> ''").
Group("c.group_name").
Scan(&derived).Error; err != nil {
return nil, err
}
@@ -1730,16 +1734,20 @@ func (s *ClientService) ListGroups() ([]GroupSummary, error) {
if err := db.Find(&stored).Error; err != nil {
return nil, err
}
merged := make(map[string]int, len(derived)+len(stored))
type groupAgg struct {
count int
traffic int64
}
merged := make(map[string]groupAgg, len(derived)+len(stored))
for _, g := range stored {
merged[g.Name] = 0
merged[g.Name] = groupAgg{}
}
for _, g := range derived {
merged[g.Name] = g.ClientCount
merged[g.Name] = groupAgg{count: g.ClientCount, traffic: g.TrafficUsed}
}
out := make([]GroupSummary, 0, len(merged))
for name, count := range merged {
out = append(out, GroupSummary{Name: name, ClientCount: count})
for name, agg := range merged {
out = append(out, GroupSummary{Name: name, ClientCount: agg.count, TrafficUsed: agg.traffic})
}
sort.Slice(out, func(i, j int) bool {
return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name)

Some files were not shown because too many files have changed in this diff Show More