Compare commits

...

18 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
82 changed files with 3379 additions and 1320 deletions
+1
View File
@@ -1,6 +1,7 @@
# Ignore editor and IDE settings # Ignore editor and IDE settings
.idea/ .idea/
.vscode/ .vscode/
.cursor/
.claude/ .claude/
.cache/ .cache/
.sync* .sync*
+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"> <p align="center">
<picture> <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> <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> </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] > [!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 ## Özellikler
- **Çoklu protokol destekli bağlantı noktaları (Inbounds)** — VLESS, VMess, Trojan, Shadowsocks, WireGuard, Hysteria2, HTTP, SOCKS (Mixed), Dokodemo-door / Tunnel ve TUN. - **Çoklu protokol destekli gelen bağlantılar (Inbounds)** — VLESS, VMess, Trojan, Shadowsocks, WireGuard, Hysteria2, HTTP, SOCKS (Karma), 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. - **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.
- **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. - **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ı 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. - **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** — Bağlantı noktası, kullanıcı ve çıkış noktası (outbound) bazında istatistikler ve sıfırlama kontrolleri. - **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 panelden birden fazla sunucuyu yönetin ve ölçeklendirin. - **Çoklu düğüm (Multi-node) desteği** — Tek bir panel üzerinden 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. - **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ıyla). - **Dahili abonelik sunucusu** (Birden fazla çıktı formatı ile).
- **Telegram botu** (Uzaktan izleme ve yönetim için). - Uzaktan izleme ve yönetim için **Telegram botu**.
- **RESTful API** (Panel içi Swagger dokümantasyonu ile). - Panel içi Swagger dokümantasyonuna sahip **RESTful API**.
- **Esnek veritabanı** — SQLite (varsayılan) veya PostgreSQL. - **Esnek depolama** — SQLite (varsayılan) veya PostgreSQL.
- **13 Kullanıcı Arayüzü (UI) dili** (Karanlık ve aydınlık tema destekli). - Koyu ve açık tema seçenekleriyle **13 farklı UI dili**.
- **Fail2ban entegrasyonu** (Kullanıcı bazlı IP sınırlarını zorlamak için). - Kullanıcı başına IP limitlerini zorunlu kılmak için **Fail2ban entegrasyonu**.
## Ekran Görüntüleri ## Ekran Görüntüleri
@@ -52,7 +52,7 @@ Orijinal X-UI projesinin geliştirilmiş bir çatalı (fork) olarak inşa edilen
<picture> <picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/02-add-inbound-dark.png"> <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>
<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) 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. 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 ## 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. - **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 ç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. - **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_TYPE=postgres
XUI_DB_DSN=postgres://xui:password@127.0.0.1:5432/xui?sslmode=disable 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 ```bash
x-ui migrate-db --dsn "postgres://xui:password@127.0.0.1:5432/xui?sslmode=disable" 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 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 ### 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 ```bash
docker compose --profile postgres up -d 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 ```bash
docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui 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 | | Değişken | Açıklama | Varsayılan |
| --- | --- | --- | | --- | --- | --- |
| `XUI_DB_TYPE` | Veritabanı arka ucu: `sqlite` veya `postgres` | `sqlite` | | `XUI_DB_TYPE` | Veritabanı türü: `sqlite` veya `postgres` | `sqlite` |
| `XUI_DB_DSN` | PostgreSQL bağlantı dizesi (`XUI_DB_TYPE=postgres` olduğunda) | — | | `XUI_DB_DSN` | PostgreSQL bağlantı dizesi (eğer `XUI_DB_TYPE=postgres` ise) | — |
| `XUI_DB_FOLDER` | SQLite veritabanı dosyası için dizin | `/etc/x-ui` | | `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_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_DB_MAX_IDLE_CONNS` | Maksimum boşta bekleme bağlantısı (PostgreSQL havuzu) | — |
| `XUI_ENABLE_FAIL2BAN` | Fail2ban tabanlı IP sınırı zorlamasını etkinleştir | `true` | | `XUI_ENABLE_FAIL2BAN` | Fail2ban tabanlı IP limit uygulamasını etkinleştir | `true` |
| `XUI_LOG_LEVEL` | Log detay seviyesi (`debug`, `info`, `warning`, `error`) | `info` | | `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` | | `XUI_DEBUG` | Hata ayıklama (debug) modunu etkinleştir | `false` |
## Desteklenen Diller ## 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ı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 ## Özel Teşekkürler
- [alireza0](https://github.com/alireza0/) - [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ı._ - [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**): _Rusya'daki engellenmiş alan adları ve adreslere dayalı otomatik olarak güncellenen V2Ray yönlendirme kuralları içerir._ - [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ı ## 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 ## 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"> <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> </a>
</br> </br>
<a href="https://nowpayments.io/donation/hsanaei" target="_blank" rel="noreferrer noopener"> <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> </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
-20
View File
@@ -2,9 +2,6 @@ package database
import "fmt" 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 { func JSONClientsFromInbound() string {
if IsPostgres() { if IsPostgres() {
return "FROM inbounds, jsonb_array_elements(inbounds.settings::jsonb -> 'clients') AS client(value)" return "FROM inbounds, jsonb_array_elements(inbounds.settings::jsonb -> 'clients') AS client(value)"
@@ -27,26 +24,9 @@ func GreatestExpr(a, b string) string {
return fmt.Sprintf("MAX(%s, %s)", a, b) return fmt.Sprintf("MAX(%s, %s)", a, b)
} }
// ClientTrafficEnableMergeExpr returns the SQL expression used in the
// node traffic merge to update client_traffics.enable.
//
// The intent is: only allow the remote node to *disable* a client
// (never re-enable one that the central panel has disabled).
//
// We use a dialect-specific expression because:
// - On PostgreSQL we want strict boolean typing and casts to avoid
// "CASE types boolean and integer cannot be matched" errors
// (and similar internal expansions of AND/GREATEST).
// - On SQLite, enable is stored with INTEGER affinity (0/1), there is
// no :: cast syntax, and we must produce a numeric-compatible result.
//
// The expression must be valid SQL for tx.Exec with a boolean parameter
// as the first ?.
func ClientTrafficEnableMergeExpr() string { func ClientTrafficEnableMergeExpr() string {
if IsPostgres() { if IsPostgres() {
return "CASE WHEN ?::boolean THEN enable::boolean ELSE false END" return "CASE WHEN ?::boolean THEN enable::boolean ELSE false END"
} }
// SQLite: no :: casts. Use numeric CASE. 1/0 work as true/false
// thanks to SQLite's affinity and how GORM/drivers bind bools.
return "CASE WHEN ? THEN enable ELSE 0 END" return "CASE WHEN ? THEN enable ELSE 0 END"
} }
+12 -6
View File
@@ -478,6 +478,12 @@ type Node struct {
UptimeSecs uint64 `json:"uptimeSecs" example:"86400"` UptimeSecs uint64 `json:"uptimeSecs" example:"86400"`
LastError string `json:"lastError"` 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"` ConfigDirty bool `json:"configDirty" gorm:"default:false"`
ConfigDirtyAt int64 `json:"configDirtyAt"` ConfigDirtyAt int64 `json:"configDirtyAt"`
@@ -514,6 +520,9 @@ type NodeSummary struct {
LatencyMs int `json:"latencyMs"` LatencyMs int `json:"latencyMs"`
PanelVersion string `json:"panelVersion"` PanelVersion string `json:"panelVersion"`
XrayVersion string `json:"xrayVersion"` 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 { type CustomGeoResource struct {
@@ -713,18 +722,15 @@ type OutboundSubscription struct {
AllowPrivate bool `json:"allowPrivate" form:"allowPrivate" gorm:"default:false"` AllowPrivate bool `json:"allowPrivate" form:"allowPrivate" gorm:"default:false"`
TagPrefix string `json:"tagPrefix" form:"tagPrefix"` TagPrefix string `json:"tagPrefix" form:"tagPrefix"`
UpdateInterval int `json:"updateInterval" form:"updateInterval" gorm:"default:600"` // seconds between refreshes 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) 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 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"` LastUpdated int64 `json:"lastUpdated" form:"lastUpdated"`
LastError string `json:"lastError" form:"lastError"` LastError string `json:"lastError" form:"lastError"`
LastFetchedOutbounds string `json:"lastFetchedOutbounds" form:"lastFetchedOutbounds" gorm:"type:text"` LastFetchedOutbounds string `json:"lastFetchedOutbounds" form:"lastFetchedOutbounds" gorm:"type:text"`
LinkIdentities string `json:"-" gorm:"type:text;column:link_identities"` LinkIdentities string `json:"-" gorm:"type:text;column:link_identities"`
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"` CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"` UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"`
// OutboundCount is a derived count of the last fetched outbounds (not OutboundCount int `json:"outboundCount" gorm:"-"`
// persisted); List populates it so the UI can show how many outbounds a
// subscription produced without shipping the full payload.
OutboundCount int `json:"outboundCount" gorm:"-"`
} }
func MergeClientRecord(existing *ClientRecord, incoming *ClientRecord) []ClientMergeConflict { func MergeClientRecord(existing *ClientRecord, incoming *ClientRecord) []ClientMergeConflict {
+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", "name": "3x-ui-frontend",
"private": true, "private": true,
"version": "0.2.7", "version": "0.3.0",
"type": "module", "type": "module",
"description": "3x-ui panel frontend (React 19 + Ant Design 6 + Vite 8).", "description": "3x-ui panel frontend (React 19 + Ant Design 6 + Vite 8).",
"engines": { "engines": {
@@ -65,5 +65,12 @@
"react-debounce-input": { "react-debounce-input": {
"react": "^19.0.0" "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
} }
} }
+117
View File
@@ -243,6 +243,10 @@
"description": "Subscription support URL", "description": "Subscription support URL",
"type": "string" "type": "string"
}, },
"subThemeDir": {
"description": "Absolute path to a folder containing a custom subscription page template",
"type": "string"
},
"subTitle": { "subTitle": {
"description": "Subscription title", "description": "Subscription title",
"type": "string" "type": "string"
@@ -321,6 +325,11 @@
"description": "Two-factor authentication token", "description": "Two-factor authentication token",
"type": "string" "type": "string"
}, },
"warpUpdateInterval": {
"description": "WARP",
"minimum": 0,
"type": "integer"
},
"webBasePath": { "webBasePath": {
"description": "Base path for web panel URLs", "description": "Base path for web panel URLs",
"type": "string" "type": "string"
@@ -404,6 +413,7 @@
"subRoutingRules", "subRoutingRules",
"subShowInfo", "subShowInfo",
"subSupportUrl", "subSupportUrl",
"subThemeDir",
"subTitle", "subTitle",
"subURI", "subURI",
"subUpdates", "subUpdates",
@@ -422,6 +432,7 @@
"trustedProxyCIDRs", "trustedProxyCIDRs",
"twoFactorEnable", "twoFactorEnable",
"twoFactorToken", "twoFactorToken",
"warpUpdateInterval",
"webBasePath", "webBasePath",
"webCertFile", "webCertFile",
"webDomain", "webDomain",
@@ -666,6 +677,10 @@
"description": "Subscription support URL", "description": "Subscription support URL",
"type": "string" "type": "string"
}, },
"subThemeDir": {
"description": "Absolute path to a folder containing a custom subscription page template",
"type": "string"
},
"subTitle": { "subTitle": {
"description": "Subscription title", "description": "Subscription title",
"type": "string" "type": "string"
@@ -744,6 +759,11 @@
"description": "Two-factor authentication token", "description": "Two-factor authentication token",
"type": "string" "type": "string"
}, },
"warpUpdateInterval": {
"description": "WARP",
"minimum": 0,
"type": "integer"
},
"webBasePath": { "webBasePath": {
"description": "Base path for web panel URLs", "description": "Base path for web panel URLs",
"type": "string" "type": "string"
@@ -833,6 +853,7 @@
"subRoutingRules", "subRoutingRules",
"subShowInfo", "subShowInfo",
"subSupportUrl", "subSupportUrl",
"subThemeDir",
"subTitle", "subTitle",
"subURI", "subURI",
"subUpdates", "subUpdates",
@@ -851,6 +872,7 @@
"trustedProxyCIDRs", "trustedProxyCIDRs",
"twoFactorEnable", "twoFactorEnable",
"twoFactorToken", "twoFactorToken",
"warpUpdateInterval",
"webBasePath", "webBasePath",
"webCertFile", "webCertFile",
"webDomain", "webDomain",
@@ -1656,6 +1678,13 @@
"example": 86400, "example": 86400,
"type": "integer" "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": { "xrayVersion": {
"example": "25.10.31", "example": "25.10.31",
"type": "string" "type": "string"
@@ -1691,6 +1720,8 @@
"tlsVerifyMode", "tlsVerifyMode",
"updatedAt", "updatedAt",
"uptimeSecs", "uptimeSecs",
"xrayError",
"xrayState",
"xrayVersion" "xrayVersion"
], ],
"type": "object" "type": "object"
@@ -1752,6 +1783,13 @@
"example": 86400, "example": 86400,
"type": "integer" "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": { "xrayVersion": {
"example": "25.10.31", "example": "25.10.31",
"type": "string" "type": "string"
@@ -1765,6 +1803,8 @@
"panelVersion", "panelVersion",
"status", "status",
"uptimeSecs", "uptimeSecs",
"xrayError",
"xrayState",
"xrayVersion" "xrayVersion"
], ],
"type": "object" "type": "object"
@@ -4032,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": { "/panel/api/clients/list": {
"get": { "get": {
"tags": [ "tags": [
@@ -5894,6 +6007,8 @@
"transitive": false, "transitive": false,
"updatedAt": 1700000000, "updatedAt": 1700000000,
"uptimeSecs": 86400, "uptimeSecs": 86400,
"xrayError": "",
"xrayState": "",
"xrayVersion": "25.10.31" "xrayVersion": "25.10.31"
} }
] ]
@@ -6254,6 +6369,8 @@
"panelVersion": "v3.x.x", "panelVersion": "v3.x.x",
"status": "online", "status": "online",
"uptimeSecs": 86400, "uptimeSecs": 86400,
"xrayError": "",
"xrayState": "",
"xrayVersion": "25.10.31" "xrayVersion": "25.10.31"
} }
} }
+8
View File
@@ -56,6 +56,7 @@ export const EXAMPLES: Record<string, unknown> = {
"subRoutingRules": "", "subRoutingRules": "",
"subShowInfo": false, "subShowInfo": false,
"subSupportUrl": "", "subSupportUrl": "",
"subThemeDir": "",
"subTitle": "", "subTitle": "",
"subURI": "", "subURI": "",
"subUpdates": 0, "subUpdates": 0,
@@ -74,6 +75,7 @@ export const EXAMPLES: Record<string, unknown> = {
"trustedProxyCIDRs": "", "trustedProxyCIDRs": "",
"twoFactorEnable": false, "twoFactorEnable": false,
"twoFactorToken": "", "twoFactorToken": "",
"warpUpdateInterval": 0,
"webBasePath": "", "webBasePath": "",
"webCertFile": "", "webCertFile": "",
"webDomain": "", "webDomain": "",
@@ -143,6 +145,7 @@ export const EXAMPLES: Record<string, unknown> = {
"subRoutingRules": "", "subRoutingRules": "",
"subShowInfo": false, "subShowInfo": false,
"subSupportUrl": "", "subSupportUrl": "",
"subThemeDir": "",
"subTitle": "", "subTitle": "",
"subURI": "", "subURI": "",
"subUpdates": 0, "subUpdates": 0,
@@ -161,6 +164,7 @@ export const EXAMPLES: Record<string, unknown> = {
"trustedProxyCIDRs": "", "trustedProxyCIDRs": "",
"twoFactorEnable": false, "twoFactorEnable": false,
"twoFactorToken": "", "twoFactorToken": "",
"warpUpdateInterval": 0,
"webBasePath": "", "webBasePath": "",
"webCertFile": "", "webCertFile": "",
"webDomain": "", "webDomain": "",
@@ -364,6 +368,8 @@ export const EXAMPLES: Record<string, unknown> = {
"transitive": false, "transitive": false,
"updatedAt": 1700000000, "updatedAt": 1700000000,
"uptimeSecs": 86400, "uptimeSecs": 86400,
"xrayError": "",
"xrayState": "",
"xrayVersion": "25.10.31" "xrayVersion": "25.10.31"
}, },
"OutboundTraffics": { "OutboundTraffics": {
@@ -381,6 +387,8 @@ export const EXAMPLES: Record<string, unknown> = {
"panelVersion": "v3.x.x", "panelVersion": "v3.x.x",
"status": "online", "status": "online",
"uptimeSecs": 86400, "uptimeSecs": 86400,
"xrayError": "",
"xrayState": "",
"xrayVersion": "25.10.31" "xrayVersion": "25.10.31"
}, },
"Setting": { "Setting": {
+40
View File
@@ -217,6 +217,10 @@ export const SCHEMAS: Record<string, unknown> = {
"description": "Subscription support URL", "description": "Subscription support URL",
"type": "string" "type": "string"
}, },
"subThemeDir": {
"description": "Absolute path to a folder containing a custom subscription page template",
"type": "string"
},
"subTitle": { "subTitle": {
"description": "Subscription title", "description": "Subscription title",
"type": "string" "type": "string"
@@ -295,6 +299,11 @@ export const SCHEMAS: Record<string, unknown> = {
"description": "Two-factor authentication token", "description": "Two-factor authentication token",
"type": "string" "type": "string"
}, },
"warpUpdateInterval": {
"description": "WARP",
"minimum": 0,
"type": "integer"
},
"webBasePath": { "webBasePath": {
"description": "Base path for web panel URLs", "description": "Base path for web panel URLs",
"type": "string" "type": "string"
@@ -378,6 +387,7 @@ export const SCHEMAS: Record<string, unknown> = {
"subRoutingRules", "subRoutingRules",
"subShowInfo", "subShowInfo",
"subSupportUrl", "subSupportUrl",
"subThemeDir",
"subTitle", "subTitle",
"subURI", "subURI",
"subUpdates", "subUpdates",
@@ -396,6 +406,7 @@ export const SCHEMAS: Record<string, unknown> = {
"trustedProxyCIDRs", "trustedProxyCIDRs",
"twoFactorEnable", "twoFactorEnable",
"twoFactorToken", "twoFactorToken",
"warpUpdateInterval",
"webBasePath", "webBasePath",
"webCertFile", "webCertFile",
"webDomain", "webDomain",
@@ -640,6 +651,10 @@ export const SCHEMAS: Record<string, unknown> = {
"description": "Subscription support URL", "description": "Subscription support URL",
"type": "string" "type": "string"
}, },
"subThemeDir": {
"description": "Absolute path to a folder containing a custom subscription page template",
"type": "string"
},
"subTitle": { "subTitle": {
"description": "Subscription title", "description": "Subscription title",
"type": "string" "type": "string"
@@ -718,6 +733,11 @@ export const SCHEMAS: Record<string, unknown> = {
"description": "Two-factor authentication token", "description": "Two-factor authentication token",
"type": "string" "type": "string"
}, },
"warpUpdateInterval": {
"description": "WARP",
"minimum": 0,
"type": "integer"
},
"webBasePath": { "webBasePath": {
"description": "Base path for web panel URLs", "description": "Base path for web panel URLs",
"type": "string" "type": "string"
@@ -807,6 +827,7 @@ export const SCHEMAS: Record<string, unknown> = {
"subRoutingRules", "subRoutingRules",
"subShowInfo", "subShowInfo",
"subSupportUrl", "subSupportUrl",
"subThemeDir",
"subTitle", "subTitle",
"subURI", "subURI",
"subUpdates", "subUpdates",
@@ -825,6 +846,7 @@ export const SCHEMAS: Record<string, unknown> = {
"trustedProxyCIDRs", "trustedProxyCIDRs",
"twoFactorEnable", "twoFactorEnable",
"twoFactorToken", "twoFactorToken",
"warpUpdateInterval",
"webBasePath", "webBasePath",
"webCertFile", "webCertFile",
"webDomain", "webDomain",
@@ -1630,6 +1652,13 @@ export const SCHEMAS: Record<string, unknown> = {
"example": 86400, "example": 86400,
"type": "integer" "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": { "xrayVersion": {
"example": "25.10.31", "example": "25.10.31",
"type": "string" "type": "string"
@@ -1665,6 +1694,8 @@ export const SCHEMAS: Record<string, unknown> = {
"tlsVerifyMode", "tlsVerifyMode",
"updatedAt", "updatedAt",
"uptimeSecs", "uptimeSecs",
"xrayError",
"xrayState",
"xrayVersion" "xrayVersion"
], ],
"type": "object" "type": "object"
@@ -1726,6 +1757,13 @@ export const SCHEMAS: Record<string, unknown> = {
"example": 86400, "example": 86400,
"type": "integer" "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": { "xrayVersion": {
"example": "25.10.31", "example": "25.10.31",
"type": "string" "type": "string"
@@ -1739,6 +1777,8 @@ export const SCHEMAS: Record<string, unknown> = {
"panelVersion", "panelVersion",
"status", "status",
"uptimeSecs", "uptimeSecs",
"xrayError",
"xrayState",
"xrayVersion" "xrayVersion"
], ],
"type": "object" "type": "object"
+8
View File
@@ -61,6 +61,7 @@ export interface AllSetting {
subRoutingRules: string; subRoutingRules: string;
subShowInfo: boolean; subShowInfo: boolean;
subSupportUrl: string; subSupportUrl: string;
subThemeDir: string;
subTitle: string; subTitle: string;
subURI: string; subURI: string;
subUpdates: number; subUpdates: number;
@@ -79,6 +80,7 @@ export interface AllSetting {
trustedProxyCIDRs: string; trustedProxyCIDRs: string;
twoFactorEnable: boolean; twoFactorEnable: boolean;
twoFactorToken: string; twoFactorToken: string;
warpUpdateInterval: number;
webBasePath: string; webBasePath: string;
webCertFile: string; webCertFile: string;
webDomain: string; webDomain: string;
@@ -149,6 +151,7 @@ export interface AllSettingView {
subRoutingRules: string; subRoutingRules: string;
subShowInfo: boolean; subShowInfo: boolean;
subSupportUrl: string; subSupportUrl: string;
subThemeDir: string;
subTitle: string; subTitle: string;
subURI: string; subURI: string;
subUpdates: number; subUpdates: number;
@@ -167,6 +170,7 @@ export interface AllSettingView {
trustedProxyCIDRs: string; trustedProxyCIDRs: string;
twoFactorEnable: boolean; twoFactorEnable: boolean;
twoFactorToken: string; twoFactorToken: string;
warpUpdateInterval: number;
webBasePath: string; webBasePath: string;
webCertFile: string; webCertFile: string;
webDomain: string; webDomain: string;
@@ -371,6 +375,8 @@ export interface Node {
transitive?: boolean; transitive?: boolean;
updatedAt: number; updatedAt: number;
uptimeSecs: number; uptimeSecs: number;
xrayError: string;
xrayState: string;
xrayVersion: string; xrayVersion: string;
} }
@@ -390,6 +396,8 @@ export interface ProbeResultUI {
panelVersion: string; panelVersion: string;
status: string; status: string;
uptimeSecs: number; uptimeSecs: number;
xrayError: string;
xrayState: string;
xrayVersion: string; xrayVersion: string;
} }
+8
View File
@@ -71,6 +71,7 @@ export const AllSettingSchema = z.object({
subRoutingRules: z.string(), subRoutingRules: z.string(),
subShowInfo: z.boolean(), subShowInfo: z.boolean(),
subSupportUrl: z.string(), subSupportUrl: z.string(),
subThemeDir: z.string(),
subTitle: z.string(), subTitle: z.string(),
subURI: z.string(), subURI: z.string(),
subUpdates: z.number().int().min(0).max(525600), subUpdates: z.number().int().min(0).max(525600),
@@ -89,6 +90,7 @@ export const AllSettingSchema = z.object({
trustedProxyCIDRs: z.string(), trustedProxyCIDRs: z.string(),
twoFactorEnable: z.boolean(), twoFactorEnable: z.boolean(),
twoFactorToken: z.string(), twoFactorToken: z.string(),
warpUpdateInterval: z.number().int().min(0),
webBasePath: z.string(), webBasePath: z.string(),
webCertFile: z.string(), webCertFile: z.string(),
webDomain: z.string(), webDomain: z.string(),
@@ -160,6 +162,7 @@ export const AllSettingViewSchema = z.object({
subRoutingRules: z.string(), subRoutingRules: z.string(),
subShowInfo: z.boolean(), subShowInfo: z.boolean(),
subSupportUrl: z.string(), subSupportUrl: z.string(),
subThemeDir: z.string(),
subTitle: z.string(), subTitle: z.string(),
subURI: z.string(), subURI: z.string(),
subUpdates: z.number().int().min(0).max(525600), subUpdates: z.number().int().min(0).max(525600),
@@ -178,6 +181,7 @@ export const AllSettingViewSchema = z.object({
trustedProxyCIDRs: z.string(), trustedProxyCIDRs: z.string(),
twoFactorEnable: z.boolean(), twoFactorEnable: z.boolean(),
twoFactorToken: z.string(), twoFactorToken: z.string(),
warpUpdateInterval: z.number().int().min(0),
webBasePath: z.string(), webBasePath: z.string(),
webCertFile: z.string(), webCertFile: z.string(),
webDomain: z.string(), webDomain: z.string(),
@@ -398,6 +402,8 @@ export const NodeSchema = z.object({
transitive: z.boolean().optional(), transitive: z.boolean().optional(),
updatedAt: z.number().int(), updatedAt: z.number().int(),
uptimeSecs: z.number().int(), uptimeSecs: z.number().int(),
xrayError: z.string(),
xrayState: z.string(),
xrayVersion: z.string(), xrayVersion: z.string(),
}); });
export type Node = z.infer<typeof NodeSchema>; export type Node = z.infer<typeof NodeSchema>;
@@ -419,6 +425,8 @@ export const ProbeResultUISchema = z.object({
panelVersion: z.string(), panelVersion: z.string(),
status: z.string(), status: z.string(),
uptimeSecs: z.number().int(), uptimeSecs: z.number().int(),
xrayError: z.string(),
xrayState: z.string(),
xrayVersion: z.string(), xrayVersion: z.string(),
}); });
export type ProbeResultUI = z.infer<typeof ProbeResultUISchema>; export type ProbeResultUI = z.infer<typeof ProbeResultUISchema>;
+2 -5
View File
@@ -684,13 +684,11 @@ export interface GenMtprotoLinkInput {
inbound: Inbound; inbound: Inbound;
address: string; address: string;
port?: number; port?: number;
remark?: string;
} }
// Builds a Telegram proxy deep link for an mtproto inbound: // Builds a Telegram proxy deep link for an mtproto inbound:
// tg://proxy?server=<addr>&port=<port>&secret=<ee FakeTLS secret>.
export function genMtprotoLink(input: GenMtprotoLinkInput): string { export function genMtprotoLink(input: GenMtprotoLinkInput): string {
const { inbound, address, port = inbound.port, remark = '' } = input; const { inbound, address, port = inbound.port } = input;
if (inbound.protocol !== 'mtproto') return ''; if (inbound.protocol !== 'mtproto') return '';
const secret = inbound.settings.secret ?? ''; const secret = inbound.settings.secret ?? '';
if (secret.length === 0) return ''; if (secret.length === 0) return '';
@@ -698,7 +696,6 @@ export function genMtprotoLink(input: GenMtprotoLinkInput): string {
url.searchParams.set('server', address); url.searchParams.set('server', address);
url.searchParams.set('port', String(port)); url.searchParams.set('port', String(port));
url.searchParams.set('secret', secret); url.searchParams.set('secret', secret);
url.hash = encodeURIComponent(remark);
return url.toString(); return url.toString();
} }
@@ -890,7 +887,7 @@ export function genLink(input: GenLinkInput): string {
externalProxy, externalProxy,
}); });
case 'mtproto': case 'mtproto':
return genMtprotoLink({ inbound, address, port, remark }); return genMtprotoLink({ inbound, address, port });
default: default:
return ''; return '';
} }
+1
View File
@@ -60,6 +60,7 @@ export class AllSetting {
subJsonMux = ''; subJsonMux = '';
subJsonRules = ''; subJsonRules = '';
subJsonFinalMask = ''; subJsonFinalMask = '';
subThemeDir = '';
timeLocation = 'Local'; timeLocation = 'Local';
+15
View File
@@ -442,6 +442,21 @@ export const sections: readonly Section[] = [
body: 'sni=example.com', body: 'sni=example.com',
response: '{\n "success": true,\n "obj": {\n "echKeySet": "...",\n "echServerKeys": [...],\n "echConfigList": "..."\n }\n}', 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.' },
],
},
], ],
}, },
+13 -5
View File
@@ -41,7 +41,7 @@ import { useTheme } from '@/hooks/useTheme';
import { useMediaQuery } from '@/hooks/useMediaQuery'; import { useMediaQuery } from '@/hooks/useMediaQuery';
import { usePageTitle } from '@/hooks/usePageTitle'; import { usePageTitle } from '@/hooks/usePageTitle';
import { useClients } from '@/hooks/useClients'; import { useClients } from '@/hooks/useClients';
import { HttpUtil } from '@/utils'; import { HttpUtil, SizeFormatter } from '@/utils';
import { setMessageInstance } from '@/utils/messageBus'; import { setMessageInstance } from '@/utils/messageBus';
import AppSidebar from '@/layouts/AppSidebar'; import AppSidebar from '@/layouts/AppSidebar';
import { LazyMount } from '@/components/utility'; import { LazyMount } from '@/components/utility';
@@ -161,8 +161,8 @@ export default function GroupsPage() {
() => groups.reduce((acc, g) => acc + (g.clientCount || 0), 0), () => groups.reduce((acc, g) => acc + (g.clientCount || 0), 0),
[groups], [groups],
); );
const emptyGroups = useMemo( const totalTraffic = useMemo(
() => groups.filter((g) => (g.clientCount || 0) === 0).length, () => groups.reduce((acc, g) => acc + (g.trafficUsed || 0), 0),
[groups], [groups],
); );
@@ -417,6 +417,13 @@ export default function GroupsPage() {
width: 180, width: 180,
render: (count: number) => <span>{count || 0}</span>, 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(() => { const pageClass = useMemo(() => {
@@ -465,8 +472,9 @@ export default function GroupsPage() {
</Col> </Col>
<Col xs={12} sm={8} md={6}> <Col xs={12} sm={8} md={6}>
<Statistic <Statistic
title={t('pages.groups.emptyGroups')} title={t('pages.groups.totalTraffic')}
value={String(emptyGroups)} value={SizeFormatter.sizeFormat(totalTraffic)}
prefix={<RetweetOutlined />}
/> />
</Col> </Col>
</Row> </Row>
@@ -171,7 +171,7 @@ export function useInboundColumns({
</div> </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> </Popover>
)} )}
{cc.deactive.length > 0 && ( {cc.deactive.length > 0 && (
@@ -183,7 +183,7 @@ export function useInboundColumns({
</div> </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> </Popover>
)} )}
{cc.depleted.length > 0 && ( {cc.depleted.length > 0 && (
@@ -195,7 +195,7 @@ export function useInboundColumns({
</div> </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> </Popover>
)} )}
{cc.online.length > 0 && ( {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 { HttpUtil, FileManager, PromiseUtil } from '@/utils';
import { useMediaQuery } from '@/hooks/useMediaQuery'; import { useMediaQuery } from '@/hooks/useMediaQuery';
import { parseLogLine } from './logParse';
import './LogModal.css'; import './LogModal.css';
interface LogModalProps { interface LogModalProps {
@@ -12,50 +13,6 @@ interface LogModalProps {
onClose: () => void; 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) { export default function LogModal({ open, onClose }: LogModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { isMobile } = useMediaQuery(); 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 }) { interface HealthProps {
if (status === 'online') return <span className="online-dot" />; 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)} />; return <Badge status={badgeStatus(status)} />;
} }
function StatusLabel({ status }: { status?: string }) { function StatusLabel({ status, xrayState }: HealthProps) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( if (status === 'online') {
<span style={status === 'online' ? { color: 'var(--ant-color-success)' } : undefined}> const xs = (xrayState || '').toLowerCase().trim();
{t(`pages.nodes.statusValues.${status || 'unknown'}`)} if (xs === 'error' || xs === 'stop') {
</span> 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 { function formatPct(p?: number): string {
@@ -271,17 +316,20 @@ export default function NodeList({
title: t('pages.nodes.status'), title: t('pages.nodes.status'),
dataIndex: 'status', dataIndex: 'status',
align: 'center', align: 'center',
render: (_value, record) => ( render: (_value, record) => {
<Space size={4}> const { tip, iconColor } = statusIssue(record);
<StatusDot status={record.status} /> return (
<StatusLabel status={record.status} /> <Space size={4}>
{record.lastError && ( <StatusDot status={record.status} xrayState={record.xrayState} />
<Tooltip title={record.lastError}> <StatusLabel status={record.status} xrayState={record.xrayState} />
<ExclamationCircleOutlined style={{ color: 'var(--ant-color-warning)' }} /> {tip && (
</Tooltip> <Tooltip title={tip}>
)} <ExclamationCircleOutlined style={{ color: iconColor }} />
</Space> </Tooltip>
), )}
</Space>
);
},
}, },
{ {
title: t('pages.nodes.cpu'), 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 key={String(record.key)} className="node-card" style={{ paddingInlineStart: 16, opacity: 0.85 }}>
<div className="card-head"> <div className="card-head">
<ApartmentOutlined style={{ opacity: 0.6 }} /> <ApartmentOutlined style={{ opacity: 0.6 }} />
<StatusDot status={record.status} /> <StatusDot status={record.status} xrayState={record.xrayState} />
<span className="node-name">{record.name}</span> <span className="node-name">{record.name}</span>
<div className="card-actions"> <div className="card-actions">
<Tag icon={<ApartmentOutlined />} style={{ margin: 0 }}>{t('pages.nodes.subNode')}</Tag> <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 key={record.id} className="node-card">
<div className="card-head" onClick={() => toggleExpanded(record.id)}> <div className="card-head" onClick={() => toggleExpanded(record.id)}>
<RightOutlined className={`card-expand${expandedIds.has(record.id) ? ' is-expanded' : ''}`} /> <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> <span className="node-name">{record.name}</span>
<div className="card-actions" onClick={(e) => e.stopPropagation()}> <div className="card-actions" onClick={(e) => e.stopPropagation()}>
<Tooltip title={t('info')}> <Tooltip title={t('info')}>
@@ -494,13 +542,16 @@ export default function NodeList({
</div> </div>
<div className="stat-row"> <div className="stat-row">
<span className="stat-label">{t('pages.nodes.status')}</span> <span className="stat-label">{t('pages.nodes.status')}</span>
<StatusDot status={statsNode.status} /> <StatusDot status={statsNode.status} xrayState={statsNode.xrayState} />
<StatusLabel status={statsNode.status} /> <StatusLabel status={statsNode.status} xrayState={statsNode.xrayState} />
{statsNode.lastError && ( {(() => {
<Tooltip title={statsNode.lastError}> const { tip, iconColor } = statusIssue(statsNode);
<ExclamationCircleOutlined style={{ color: 'var(--ant-color-warning)' }} /> return tip ? (
</Tooltip> <Tooltip title={tip}>
)} <ExclamationCircleOutlined style={{ color: iconColor }} />
</Tooltip>
) : null;
})()}
</div> </div>
<div className="stat-row"> <div className="stat-row">
<span className="stat-label">{t('pages.nodes.cpu')}</span> <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); const msg = await probe(node.id);
if (msg?.success && msg.obj) { if (msg?.success && msg.obj) {
if (msg.obj.status === 'online') { 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 })); messageApi.success(t('pages.nodes.connectionOk', { ms: msg.obj.latencyMs }));
} else { } else {
messageApi.error(msg.obj.error || t('pages.nodes.toasts.probeFailed')); 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) => { const onToggleEnable = useCallback(async (node: NodeRecord, next: boolean) => {
await setEnable(node.id, next); await setEnable(node.id, next);
@@ -157,6 +157,11 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
onChange={(e) => updateSetting({ subAnnounce: e.target.value })} /> onChange={(e) => updateSetting({ subAnnounce: e.target.value })} />
</SettingListItem> </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> <Divider>Happ</Divider>
<SettingListItem paddings="small" title={t('pages.settings.subEnableRouting')} description={t('pages.settings.subEnableRoutingDesc')}> <SettingListItem paddings="small" title={t('pages.settings.subEnableRouting')} description={t('pages.settings.subEnableRoutingDesc')}>
+3 -5
View File
@@ -29,6 +29,7 @@ import { JsonEditor } from '@/components/form';
import { setMessageInstance } from '@/utils/messageBus'; import { setMessageInstance } from '@/utils/messageBus';
import { BasicsTab } from './basics'; import { BasicsTab } from './basics';
import { propagateOutboundTagRename } from './basics/helpers';
import { RoutingTab } from './routing'; import { RoutingTab } from './routing';
import { OutboundsTab } from './outbounds'; import { OutboundsTab } from './outbounds';
import { BalancersTab } from './balancers'; import { BalancersTab } from './balancers';
@@ -118,11 +119,8 @@ export default function XrayPage() {
mutate((tt) => { mutate((tt) => {
if (!tt.outbounds || payload.index < 0) return; if (!tt.outbounds || payload.index < 0) return;
tt.outbounds[payload.index] = payload.outbound as never; tt.outbounds[payload.index] = payload.outbound as never;
if (payload.oldTag && payload.newTag && payload.oldTag !== payload.newTag) { if (payload.oldTag && payload.newTag) {
const rules = tt.routing?.rules || []; propagateOutboundTagRename(tt, payload.oldTag, payload.newTag);
for (const r of rules) {
if (r?.outboundTag === payload.oldTag) r.outboundTag = payload.newTag;
}
} }
}); });
} }
+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.splice(idx, 1);
if (haveRules && idx < 0) t.outbounds.push(settings as never); 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;
}
}
}
@@ -38,6 +38,7 @@ import {
import { HttpUtil } from '@/utils'; import { HttpUtil } from '@/utils';
import OutboundFormModal from './OutboundFormModal'; import OutboundFormModal from './OutboundFormModal';
import { propagateOutboundTagRename } from '../basics/helpers';
import type { XraySettingsValue, SetTemplate, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting'; import type { XraySettingsValue, SetTemplate, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
import './OutboundsTab.css'; import './OutboundsTab.css';
@@ -172,11 +173,16 @@ export default function OutboundsTab({
function onConfirm(outbound: Record<string, unknown>) { function onConfirm(outbound: Record<string, unknown>) {
mutate((tt) => { mutate((tt) => {
if (!Array.isArray(tt.outbounds)) tt.outbounds = []; if (!Array.isArray(tt.outbounds)) tt.outbounds = [];
const newTag = typeof outbound.tag === 'string' ? outbound.tag : '';
if (editingIndex == null) { if (editingIndex == null) {
if (!outbound.tag) return; if (!newTag) return;
tt.outbounds.push(outbound as never); tt.outbounds.push(outbound as never);
} else { } else {
const oldTag = tt.outbounds[editingIndex]?.tag;
tt.outbounds[editingIndex] = outbound as never; tt.outbounds[editingIndex] = outbound as never;
if (oldTag && newTag && oldTag !== newTag) {
propagateOutboundTagRename(tt, oldTag, newTag);
}
} }
}); });
setModalOpen(false); setModalOpen(false);
+89 -21
View File
@@ -80,6 +80,7 @@ export default function WarpModal({
const [warpData, setWarpData] = useState<WarpData | null>(null); const [warpData, setWarpData] = useState<WarpData | null>(null);
const [warpConfig, setWarpConfig] = useState<WarpConfig | null>(null); const [warpConfig, setWarpConfig] = useState<WarpConfig | null>(null);
const [warpPlus, setWarpPlus] = useState(''); const [warpPlus, setWarpPlus] = useState('');
const [updateInterval, setUpdateInterval] = useState<number>(0);
const [licenseError, setLicenseError] = useState(''); const [licenseError, setLicenseError] = useState('');
const [stagedOutbound, setStagedOutbound] = useState<Record<string, unknown> | null>(null); const [stagedOutbound, setStagedOutbound] = useState<Record<string, unknown> | null>(null);
@@ -89,24 +90,29 @@ export default function WarpModal({
return list.findIndex((o) => o?.tag === 'warp'); return list.findIndex((o) => o?.tag === 'warp');
}, [templateSettings?.outbounds]); }, [templateSettings?.outbounds]);
const collectConfig = useCallback((data: WarpData | null, config: WarpConfig | null) => { const collectConfig = useCallback(
const cfg = config?.config; (data: WarpData | null, config: WarpConfig | null): Record<string, unknown> | null => {
if (!cfg?.peers?.length) return; const cfg = config?.config;
const peer = cfg.peers[0]; if (!cfg?.peers?.length) return null;
setStagedOutbound({ const peer = cfg.peers[0];
tag: 'warp', const outbound: Record<string, unknown> = {
protocol: 'wireguard', tag: 'warp',
settings: { protocol: 'wireguard',
mtu: 1420, settings: {
secretKey: data?.private_key, mtu: 1420,
address: addressesFor(cfg.interface?.addresses || {}), secretKey: data?.private_key,
reserved: reservedFor(cfg.client_id ?? data?.client_id), address: addressesFor(cfg.interface?.addresses || {}),
domainStrategy: 'ForceIP', reserved: reservedFor(cfg.client_id ?? data?.client_id),
peers: [{ publicKey: peer.public_key, endpoint: peer.endpoint?.host }], domainStrategy: 'ForceIP',
noKernelTun: false, peers: [{ publicKey: peer.public_key, endpoint: peer.endpoint?.host }],
}, noKernelTun: false,
}); },
}, []); };
setStagedOutbound(outbound);
return outbound;
},
[],
);
const fetchData = useCallback(async () => { const fetchData = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -116,6 +122,10 @@ export default function WarpModal({
const raw = msg.obj; const raw = msg.obj;
setWarpData(raw && raw.length > 0 ? JSON.parse(raw) : null); 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 { } finally {
setLoading(false); 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() { async function updateLicense() {
if (warpPlus.length < 26) return; if (warpPlus.length < 26) return;
setLoading(true); setLoading(true);
@@ -281,13 +325,37 @@ export default function WarpModal({
</Form> </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> <Divider className="zero-margin">{t('pages.xray.warp.accountInfo')}</Divider>
<Button className="my-8" loading={loading} type="primary" icon={<SyncOutlined />} onClick={getConfig}> <div className="my-8">
{t('refresh')} <Button loading={loading} type="primary" icon={<SyncOutlined />} onClick={getConfig}>
</Button> {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 && ( {hasConfig && (
<> <>
+1
View File
@@ -126,6 +126,7 @@ export const ActiveInboundsByNodeSchema = z
export const GroupSummarySchema = z.object({ export const GroupSummarySchema = z.object({
name: z.string(), name: z.string(),
clientCount: z.number(), clientCount: z.number(),
trafficUsed: z.number().nullable().transform((v) => v ?? 0),
}); });
export const GroupSummaryListSchema = z.array(GroupSummarySchema).nullable().transform((v) => v ?? []); 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(), depletedCount: z.number().optional(),
lastHeartbeat: z.number().optional(), lastHeartbeat: z.number().optional(),
lastError: z.string().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(), allowPrivateAddress: z.boolean().optional(),
tlsVerifyMode: z.enum(['verify', 'skip', 'pin']).optional(), tlsVerifyMode: z.enum(['verify', 'skip', 'pin']).optional(),
pinnedCertSha256: z.string().optional(), pinnedCertSha256: z.string().optional(),
@@ -40,6 +45,9 @@ export const ProbeResultSchema = z.object({
latencyMs: z.number().optional(), latencyMs: z.number().optional(),
xrayVersion: z.string().optional(), xrayVersion: z.string().optional(),
error: 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(); }).loose();
export const NodeFormSchema = z.object({ export const NodeFormSchema = z.object({
+1
View File
@@ -45,6 +45,7 @@
.nodes-page .ant-card.ant-card-hoverable:hover, .nodes-page .ant-card.ant-card-hoverable:hover,
.groups-page .ant-card.ant-card-hoverable:hover, .groups-page .ant-card.ant-card-hoverable:hover,
.api-docs-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); box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08);
} }
+23
View File
@@ -41,3 +41,26 @@
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.online-dot { animation: none; } .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; }
}
+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)); 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);
+14 -14
View File
@@ -23,9 +23,9 @@ require (
github.com/xlzd/gotp v0.1.0 github.com/xlzd/gotp v0.1.0
github.com/xtls/xray-core v1.260327.1-0.20260601021109-94ffd50060f1 github.com/xtls/xray-core v1.260327.1-0.20260601021109-94ffd50060f1
go.uber.org/atomic v1.11.0 go.uber.org/atomic v1.11.0
golang.org/x/crypto v0.52.0 golang.org/x/crypto v0.53.0
golang.org/x/sys v0.45.0 golang.org/x/sys v0.46.0
golang.org/x/text v0.37.0 golang.org/x/text v0.38.0
google.golang.org/grpc v1.81.1 google.golang.org/grpc v1.81.1
gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/natefinch/lumberjack.v2 v2.2.1
gorm.io/driver/postgres v1.6.0 gorm.io/driver/postgres v1.6.0
@@ -34,10 +34,10 @@ require (
) )
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/logging v0.2.4 // indirect
github.com/pion/stun/v3 v3.1.2 // indirect github.com/pion/stun/v3 v3.1.5 // indirect
github.com/pion/transport/v4 v4.0.1 // indirect github.com/pion/transport/v4 v4.0.2 // indirect
github.com/wlynxg/anet v0.0.5 // indirect github.com/wlynxg/anet v0.0.5 // indirect
golang.zx2c4.com/wireguard/windows v1.0.1 // 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/andybalholm/brotli v1.2.1 // indirect
github.com/apernet/quic-go v0.59.1-0.20260425001925-6c6cc9bcb716 // indirect github.com/apernet/quic-go v0.59.1-0.20260425001925-6c6cc9bcb716 // indirect
github.com/bytedance/gopkg v0.1.4 // 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/bytedance/sonic/loader v0.5.1 // indirect
github.com/cloudflare/circl v1.6.3 // indirect github.com/cloudflare/circl v1.6.3 // indirect
github.com/cloudwego/base64x v0.1.7 // 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/leodido/go-urn v1.4.0 // indirect
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
github.com/mattn/go-isatty v0.0.22 // 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/miekg/dns v1.1.72 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // 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/pires/go-proxyproto v0.12.0 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // 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/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/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af // indirect
github.com/rogpeppe/go-internal v1.15.0 // indirect github.com/rogpeppe/go-internal v1.15.0 // indirect
github.com/sagernet/sing v0.8.10 // indirect github.com/sagernet/sing v0.8.10 // indirect
@@ -101,16 +101,16 @@ require (
github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
golang.org/x/arch v0.27.0 // indirect golang.org/x/arch v0.28.0 // indirect
golang.org/x/exp v0.0.0-20260529124908-c761662dc8c9 // indirect golang.org/x/exp v0.0.0-20260603202125-055de637280b // indirect
golang.org/x/mod v0.36.0 // indirect golang.org/x/mod v0.37.0 // indirect
golang.org/x/net v0.55.0 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/time v0.15.0 // indirect
golang.org/x/tools v0.45.0 // indirect golang.org/x/tools v0.45.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 // 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 google.golang.org/protobuf v1.36.11 // indirect
gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect
lukechampine.com/blake3 v1.4.1 // 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/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 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4= 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.2 h1:90H+rcF/FwLXwfB1cudOLq/je83n683Utf4Cbp0xHCo=
github.com/bytedance/sonic v1.15.1/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA= 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 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 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/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 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= 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.45 h1:6KA/spDguL3KV8rnybG7ezSaE4SeMR3KC9VbUoAQaIk=
github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= 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 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= 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= 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 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 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= 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.4 h1:QhvtMflMfu9Kf0RcDC5BJBle4caPskByrKQR6uuYqpY=
github.com/pion/dtls/v3 v3.1.2/go.mod h1:Hw/igcX4pdY69z1Hgv5x7wJFrUkdgHwAn/Q/uo7YHRo= 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 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= 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.5 h1:Y1FHlhaI6+4UoC5i/zQf4F7JvdZtB24/05oyy/GF1x8=
github.com/pion/stun/v3 v3.1.2/go.mod h1:H7gDic7nNwlUL05pbs6T1dtaBehh/KjupxfWw3ZI7cA= github.com/pion/stun/v3 v3.1.5/go.mod h1:zRUghXSQU32Lx5orJsz3uYMkIihweXb3mu5gIns02fs=
github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o= github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk=
github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM= 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 h1:TTCxD66dU898tahivkqc3hoceZp7P44FnorWyo9d5vM=
github.com/pires/go-proxyproto v0.12.0/go.mod h1:qUvfqUMEoX7T8g0q7TQLDnhMjdTrxnG0hvpMn+7ePNI= 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= 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 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= 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.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0=
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= 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 h1:er2acxbi3N1nvEq6HXHUAR1nTWEJmQfqiGR8EVT9rfs=
github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= 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= 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= 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 h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= 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.28.0 h1:wVwVdqsTuUbJvhYVCspQYwZXHNYeLSoZnmHD+ggddpQ=
golang.org/x/arch v0.27.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8= golang.org/x/arch v0.28.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/exp v0.0.0-20260529124908-c761662dc8c9 h1:4d4PbuBNwaxMXkXI8yiIYjydtMU+04RHeuSxJdgKftM= golang.org/x/exp v0.0.0-20260603202125-055de637280b h1:v1uXiEBHo8QA0LiGCo7UgHMzHT4Kdfpl2zmtH5vaP1Q=
golang.org/x/exp v0.0.0-20260529124908-c761662dc8c9/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw= golang.org/x/exp v0.0.0-20260603202125-055de637280b/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= 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 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= 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.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= 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-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/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.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.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.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= 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 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= 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= 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 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= 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-20260608224507-4308a22a1bab h1:cY0oV1VnAqvaim8VsR8ZyEKAudzbRJMRGwD3W/L7yOw=
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/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= 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/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
+5
View File
@@ -1177,6 +1177,11 @@ install_x-ui() {
else else
systemctl stop x-ui systemctl stop x-ui
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 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 rm ${xui_folder}/ -rf
fi fi
+23 -4
View File
@@ -58,6 +58,9 @@ type managed struct {
type Manager struct { type Manager struct {
mu sync.Mutex mu sync.Mutex
procs map[int]*managed 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 ( var (
@@ -107,9 +110,24 @@ func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
func (m *Manager) Ensure(inst Instance) error { func (m *Manager) Ensure(inst Instance) error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
m.sweepOrphansLocked()
return m.ensureLocked(inst) 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 { func (m *Manager) ensureLocked(inst Instance) error {
fp := inst.fingerprint() fp := inst.fingerprint()
if cur, ok := m.procs[inst.Id]; ok { if cur, ok := m.procs[inst.Id]; ok {
@@ -128,7 +146,7 @@ func (m *Manager) ensureLocked(inst Instance) error {
if err := writeConfig(cfgPath, inst.Secret, inst.bindTo(), metricsPort); err != nil { if err := writeConfig(cfgPath, inst.Secret, inst.bindTo(), metricsPort); err != nil {
return err return err
} }
proc := newProcess(cfgPath) proc := newProcess(cfgPath, fmt.Sprintf("inbound %d", inst.Id))
if err := proc.Start(); err != nil { if err := proc.Start(); err != nil {
return err return err
} }
@@ -138,7 +156,7 @@ func (m *Manager) ensureLocked(inst Instance) error {
fingerprint: fp, fingerprint: fp,
metricsPort: metricsPort, metricsPort: metricsPort,
} }
logger.Info("mtproto: started mtg for inbound", inst.Id, "on", inst.bindTo()) logger.Infof("mtproto: started mtg for inbound %d on %s", inst.Id, inst.bindTo())
return nil return nil
} }
@@ -150,7 +168,7 @@ func (m *Manager) Remove(id int) {
cur.proc.Stop() cur.proc.Stop()
delete(m.procs, id) delete(m.procs, id)
_ = os.Remove(configPathForID(id)) _ = os.Remove(configPathForID(id))
logger.Info("mtproto: stopped mtg for inbound", id) logger.Infof("mtproto: stopped mtg for inbound %d", id)
} }
} }
@@ -160,6 +178,7 @@ func (m *Manager) Remove(id int) {
func (m *Manager) Reconcile(desired []Instance) { func (m *Manager) Reconcile(desired []Instance) {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
m.sweepOrphansLocked()
want := make(map[int]struct{}, len(desired)) want := make(map[int]struct{}, len(desired))
for _, inst := range desired { for _, inst := range desired {
want[inst.Id] = struct{}{} want[inst.Id] = struct{}{}
@@ -173,7 +192,7 @@ func (m *Manager) Reconcile(desired []Instance) {
} }
for _, inst := range desired { for _, inst := range desired {
if err := m.ensureLocked(inst); err != nil { if err := m.ensureLocked(inst); err != nil {
logger.Warning("mtproto: reconcile failed for inbound", inst.Id, ":", err) logger.Warningf("mtproto: reconcile failed for inbound %d: %v", inst.Id, err)
} }
} }
} }
+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 }
+46 -12
View File
@@ -49,22 +49,55 @@ var (
forceStopTimeout = 2 * time.Second forceStopTimeout = 2 * time.Second
) )
type lastLineWriter struct { // 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 mu sync.Mutex
label string
buf string
lastLine string lastLine string
} }
func (w *lastLineWriter) Write(p []byte) (int, error) { func (w *procLogWriter) Write(p []byte) (int, error) {
line := strings.TrimSpace(string(p)) w.mu.Lock()
if line != "" { defer w.mu.Unlock()
w.mu.Lock() w.buf += string(p)
w.lastLine = line for {
w.mu.Unlock() 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 return len(p), nil
} }
func (w *lastLineWriter) LastLine() string { // 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() w.mu.Lock()
defer w.mu.Unlock() defer w.mu.Unlock()
return w.lastLine return w.lastLine
@@ -75,15 +108,15 @@ type Process struct {
cmd *exec.Cmd cmd *exec.Cmd
done chan struct{} done chan struct{}
configPath string configPath string
logWriter *lastLineWriter logWriter *procLogWriter
exitErr error exitErr error
intentionalStop atomic.Bool intentionalStop atomic.Bool
} }
func newProcess(configPath string) *Process { func newProcess(configPath, label string) *Process {
return &Process{ return &Process{
configPath: configPath, configPath: configPath,
logWriter: &lastLineWriter{}, logWriter: &procLogWriter{label: label},
} }
} }
@@ -141,6 +174,7 @@ func (p *Process) Start() error {
func (p *Process) wait(cmd *exec.Cmd) { func (p *Process) wait(cmd *exec.Cmd) {
defer close(p.done) defer close(p.done)
err := cmd.Wait() err := cmd.Wait()
p.logWriter.Flush()
if err == nil || p.intentionalStop.Load() { if err == nil || p.intentionalStop.Load() {
return return
} }
@@ -150,7 +184,7 @@ func (p *Process) wait(cmd *exec.Cmd) {
return return
} }
} }
logger.Error("mtproto: mtg process exited:", err) logger.Errorf("mtproto: mtg process exited: %v", err)
p.exitErr = err p.exitErr = err
} }
+3 -3
View File
@@ -51,16 +51,16 @@ func attachChildLifetime(cmd *exec.Cmd) {
} }
job, err := ensureKillOnExitJob() job, err := ensureKillOnExitJob()
if err != nil { if err != nil {
logger.Warning("mtproto: kill-on-exit job unavailable:", err) logger.Warningf("mtproto: kill-on-exit job unavailable: %v", err)
return return
} }
h, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, uint32(cmd.Process.Pid)) h, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, uint32(cmd.Process.Pid))
if err != nil { if err != nil {
logger.Warning("mtproto: OpenProcess for job attach failed:", err) logger.Warningf("mtproto: OpenProcess for job attach failed: %v", err)
return return
} }
defer windows.CloseHandle(h) defer windows.CloseHandle(h)
if err := windows.AssignProcessToJobObject(job, h); err != nil { if err := windows.AssignProcessToJobObject(job, h); err != nil {
logger.Warning("mtproto: AssignProcessToJobObject failed:", err) logger.Warningf("mtproto: AssignProcessToJobObject failed: %v", err)
} }
} }
+111 -21
View File
@@ -5,15 +5,19 @@ import (
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"html/template"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
"sync"
"github.com/mhsanaei/3x-ui/v3/web/service" "time"
"github.com/gin-gonic/gin" "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. // 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) 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. // SUBController handles HTTP requests for subscription links and JSON configurations.
type SUBController struct { type SUBController struct {
subTitle string subTitle string
@@ -48,6 +60,9 @@ type SUBController struct {
subJsonService *SubJsonService subJsonService *SubJsonService
subClashService *SubClashService subClashService *SubClashService
settingService service.SettingService settingService service.SettingService
subTemplateMu sync.RWMutex
subTemplateCache map[string]*cachedSubTemplate
} }
// NewSUBController creates a new subscription controller with the given configuration. // NewSUBController creates a new subscription controller with the given configuration.
@@ -93,6 +108,8 @@ func NewSUBController(
subService: sub, subService: sub,
subJsonService: NewSubJsonService(jsonMux, jsonRules, jsonFinalMask, sub), subJsonService: NewSubJsonService(jsonMux, jsonRules, jsonFinalMask, sub),
subClashService: NewSubClashService(clashEnableRouting, clashRules, sub), subClashService: NewSubClashService(clashEnableRouting, clashRules, sub),
subTemplateCache: map[string]*cachedSubTemplate{},
} }
a.initRouter(g) a.initRouter(g)
return a return a
@@ -202,25 +219,49 @@ func (a *SUBController) serveSubPage(c *gin.Context, basePath string, page PageD
} }
subData := map[string]any{ subData := map[string]any{
"sId": page.SId, "sId": page.SId,
"enabled": page.Enabled, "enabled": page.Enabled,
"download": page.Download, "download": page.Download,
"upload": page.Upload, "upload": page.Upload,
"total": page.Total, "total": page.Total,
"used": page.Used, "used": page.Used,
"remained": page.Remained, "remained": page.Remained,
"expire": page.Expire, "expire": page.Expire,
"lastOnline": page.LastOnline, "lastOnline": page.LastOnline,
"downloadByte": page.DownloadByte, "downloadByte": page.DownloadByte,
"uploadByte": page.UploadByte, "uploadByte": page.UploadByte,
"totalByte": page.TotalByte, "totalByte": page.TotalByte,
"subUrl": page.SubUrl, "subUrl": page.SubUrl,
"subJsonUrl": page.SubJsonUrl, "subJsonUrl": page.SubJsonUrl,
"subClashUrl": page.SubClashUrl, "subClashUrl": page.SubClashUrl,
"links": page.Result, "subTitle": page.SubTitle,
"emails": page.Emails, "subSupportUrl": page.SubSupportUrl,
"datepicker": datepicker, "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) subDataJSON, err := json.Marshal(subData)
if err != nil { if err != nil {
subDataJSON = []byte("{}") 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>`) `window.__SUB_PAGE_DATA__=` + string(subDataJSON) + `;</script></head>`)
out := bytes.Replace(body, []byte("</head>"), inject, 1) 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("Cache-Control", "no-cache, no-store, must-revalidate")
c.Header("Pragma", "no-cache") c.Header("Pragma", "no-cache")
c.Header("Expires", "0") 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. // 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")
}
}
+2 -3
View File
@@ -381,8 +381,7 @@ func (s *SubService) GetLink(inbound *model.Inbound, email string) string {
} }
// genMtprotoLink builds a Telegram proxy deep link for an mtproto inbound: // genMtprotoLink builds a Telegram proxy deep link for an mtproto inbound:
// tg://proxy?server=<addr>&port=<port>&secret=<ee FakeTLS secret>. func (s *SubService) genMtprotoLink(inbound *model.Inbound, _ string) string {
func (s *SubService) genMtprotoLink(inbound *model.Inbound, email string) string {
if inbound.Protocol != model.MTProto { if inbound.Protocol != model.MTProto {
return "" return ""
} }
@@ -403,7 +402,7 @@ func (s *SubService) genMtprotoLink(inbound *model.Inbound, email string) string
"port": fmt.Sprintf("%d", inbound.Port), "port": fmt.Sprintf("%d", inbound.Port),
"secret": secret, "secret": secret,
} }
return buildLinkWithParams("tg://proxy", params, s.genRemark(inbound, email, "")) return buildLinkWithParams("tg://proxy", params, "")
} }
// Protocol link generators are intentionally ordered as: // Protocol link generators are intentionally ordered as:
+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." _fail "ERROR: x-ui systemd unit not installed."
fi fi
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}" echo -e "${green}Removing old x-ui version...${plain}"
rm ${xui_folder} -f > /dev/null 2>&1 rm ${xui_folder} -f > /dev/null 2>&1
rm ${xui_folder}/x-ui.service -f > /dev/null 2>&1 rm ${xui_folder}/x-ui.service -f > /dev/null 2>&1
+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) user := session.GetLoginUser(c)
inbound.Id = 0 inbound.Id = 0
inbound.UserId = user.Id inbound.UserId = user.Id
if inbound.NodeID != nil && *inbound.NodeID == 0 { // Node IDs are panel-local and not portable across panels. Drop a node
inbound.NodeID = nil // 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 { for index := range inbound.ClientStats {
+18
View File
@@ -9,6 +9,7 @@ import (
"time" "time"
"github.com/mhsanaei/3x-ui/v3/database" "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/logger"
"github.com/mhsanaei/3x-ui/v3/web/entity" "github.com/mhsanaei/3x-ui/v3/web/entity"
"github.com/mhsanaei/3x-ui/v3/web/global" "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("/getNewmldsa65", a.getNewmldsa65)
g.GET("/getNewmlkem768", a.getNewmlkem768) g.GET("/getNewmlkem768", a.getNewmlkem768)
g.GET("/getNewVlessEnc", a.getNewVlessEnc) g.GET("/getNewVlessEnc", a.getNewVlessEnc)
g.GET("/clientIps", a.getClientIps)
g.POST("/stopXrayService", a.stopXrayService) g.POST("/stopXrayService", a.stopXrayService)
g.POST("/restartXrayService", a.restartXrayService) g.POST("/restartXrayService", a.restartXrayService)
@@ -72,6 +74,7 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
g.POST("/xraylogs/:count", a.getXrayLogs) g.POST("/xraylogs/:count", a.getXrayLogs)
g.POST("/importDB", a.importDB) g.POST("/importDB", a.importDB)
g.POST("/getNewEchCert", a.getNewEchCert) g.POST("/getNewEchCert", a.getNewEchCert)
g.POST("/clientIps", a.setClientIps)
} }
// startTask registers the @2s ticker that refreshes server status, samples // 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) 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)
}
+26 -7
View File
@@ -3,6 +3,8 @@ package controller
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"strconv"
"time"
"github.com/mhsanaei/3x-ui/v3/util/common" "github.com/mhsanaei/3x-ui/v3/util/common"
"github.com/mhsanaei/3x-ui/v3/web/service" "github.com/mhsanaei/3x-ui/v3/web/service"
@@ -12,13 +14,13 @@ import (
// XraySettingController handles Xray configuration and settings operations. // XraySettingController handles Xray configuration and settings operations.
type XraySettingController struct { type XraySettingController struct {
XraySettingService service.XraySettingService XraySettingService service.XraySettingService
SettingService service.SettingService SettingService service.SettingService
InboundService service.InboundService InboundService service.InboundService
OutboundService service.OutboundService OutboundService service.OutboundService
XrayService service.XrayService XrayService service.XrayService
WarpService service.WarpService WarpService service.WarpService
NordService service.NordService NordService service.NordService
OutboundSubscriptionService service.OutboundSubscriptionService OutboundSubscriptionService service.OutboundSubscriptionService
} }
@@ -165,9 +167,26 @@ func (a *XraySettingController) warp(c *gin.Context) {
skey := c.PostForm("privateKey") skey := c.PostForm("privateKey")
pkey := c.PostForm("publicKey") pkey := c.PostForm("publicKey")
resp, err = a.WarpService.RegWarp(skey, pkey) 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": case "license":
license := c.PostForm("license") license := c.PostForm("license")
resp, err = a.WarpService.SetWarpLicense(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) jsonObj(c, resp, err)
+4
View File
@@ -88,6 +88,7 @@ type AllSetting struct {
SubJsonMux string `json:"subJsonMux" form:"subJsonMux"` // JSON subscription mux configuration SubJsonMux string `json:"subJsonMux" form:"subJsonMux"` // JSON subscription mux configuration
SubJsonRules string `json:"subJsonRules" form:"subJsonRules"` SubJsonRules string `json:"subJsonRules" form:"subJsonRules"`
SubJsonFinalMask string `json:"subJsonFinalMask" form:"subJsonFinalMask"` // JSON subscription global finalmask (tcp/udp masks + quicParams) 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 // LDAP settings
LdapEnable bool `json:"ldapEnable" form:"ldapEnable"` LdapEnable bool `json:"ldapEnable" form:"ldapEnable"`
@@ -112,6 +113,9 @@ type AllSetting struct {
LdapDefaultExpiryDays int `json:"ldapDefaultExpiryDays" form:"ldapDefaultExpiryDays" validate:"gte=0"` LdapDefaultExpiryDays int `json:"ldapDefaultExpiryDays" form:"ldapDefaultExpiryDays" validate:"gte=0"`
LdapDefaultLimitIP int `json:"ldapDefaultLimitIP" form:"ldapDefaultLimitIP" validate:"gte=0"` LdapDefaultLimitIP int `json:"ldapDefaultLimitIP" form:"ldapDefaultLimitIP" validate:"gte=0"`
// JSON subscription routing rules // JSON subscription routing rules
// WARP
WarpUpdateInterval int `json:"warpUpdateInterval" form:"warpUpdateInterval" validate:"gte=0"`
} }
// AllSettingView is the browser-safe settings read model. Secret values // 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) { func partitionLiveIps(ipMap map[string]int64, observedThisScan map[string]bool) (live, historical []IPWithTimestamp) {
live = make([]IPWithTimestamp, 0, len(observedThisScan)) live = make([]IPWithTimestamp, 0, len(observedThisScan))
historical = make([]IPWithTimestamp, 0, len(ipMap)) historical = make([]IPWithTimestamp, 0, len(ipMap))
now := time.Now().Unix()
for ip, ts := range ipMap { for ip, ts := range ipMap {
entry := IPWithTimestamp{IP: ip, Timestamp: ts} 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) live = append(live, entry)
} else { } else {
historical = append(historical, entry) historical = append(historical, entry)
+21
View File
@@ -6,6 +6,7 @@ import (
"reflect" "reflect"
"runtime" "runtime"
"testing" "testing"
"time"
) )
func TestMergeClientIps_EvictsStaleOldEntries(t *testing.T) { 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) { func TestCheckFail2BanInstalled_DisabledEnvSkipsClientProbe(t *testing.T) {
t.Setenv("XUI_ENABLE_FAIL2BAN", "false") t.Setenv("XUI_ENABLE_FAIL2BAN", "false")
marker := fakeFail2BanClient(t) marker := fakeFail2BanClient(t)
+39 -2
View File
@@ -16,6 +16,7 @@ const (
nodeTrafficSyncConcurrency = 8 nodeTrafficSyncConcurrency = 8
nodeTrafficSyncRequestTimeout = 4 * time.Second nodeTrafficSyncRequestTimeout = 4 * time.Second
nodeReconcileTimeout = 30 * time.Second nodeReconcileTimeout = 30 * time.Second
nodeClientIpSyncInterval = 10 * time.Second
) )
type NodeTrafficSyncJob struct { type NodeTrafficSyncJob struct {
@@ -25,6 +26,8 @@ type NodeTrafficSyncJob struct {
xrayService service.XrayService xrayService service.XrayService
running sync.Mutex running sync.Mutex
structural atomicBool structural atomicBool
ipSyncMu sync.Mutex
lastIpSync int64
} }
type atomicBool struct { type atomicBool struct {
@@ -70,6 +73,16 @@ func (j *NodeTrafficSyncJob) Run() {
return 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) sem := make(chan struct{}, nodeTrafficSyncConcurrency)
var wg sync.WaitGroup var wg sync.WaitGroup
for _, n := range nodes { for _, n := range nodes {
@@ -81,7 +94,7 @@ func (j *NodeTrafficSyncJob) Run() {
go func(n *model.Node) { go func(n *model.Node) {
defer wg.Done() defer wg.Done()
defer func() { <-sem }() defer func() { <-sem }()
j.syncOne(mgr, n) j.syncOne(mgr, n, doIpSync)
}(n) }(n)
} }
wg.Wait() 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) rt, err := mgr.RemoteFor(n)
if err != nil { if err != nil {
logger.Warning("node traffic sync: remote lookup failed for", n.Name, ":", err) 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 { if changed {
j.structural.set() 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)
}
}
} }
+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")
}
}
+4
View File
@@ -159,3 +159,7 @@ func (l *Local) ResetClientTraffic(_ context.Context, _ *model.Inbound, _ string
func (l *Local) ResetAllTraffics(_ context.Context) error { func (l *Local) ResetAllTraffics(_ context.Context) error {
return nil 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 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 { type TrafficSnapshot struct {
Inbounds []*model.Inbound Inbounds []*model.Inbound
OnlineEmails []string OnlineEmails []string
@@ -533,3 +538,22 @@ func isNonEmptySlice(v any) bool {
s, ok := v.([]any) s, ok := v.([]any)
return ok && len(s) > 0 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 RestartXray(ctx context.Context) error
ResetClientTraffic(ctx context.Context, ib *model.Inbound, email string) error ResetClientTraffic(ctx context.Context, ib *model.Inbound, email string) error
ResetInboundTraffic(ctx context.Context, ib *model.Inbound) error
ResetAllTraffics(ctx context.Context) error ResetAllTraffics(ctx context.Context) error
} }
+17 -9
View File
@@ -1714,15 +1714,19 @@ func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *Settin
type GroupSummary struct { type GroupSummary struct {
Name string `json:"name"` Name string `json:"name"`
ClientCount int `json:"clientCount"` ClientCount int `json:"clientCount"`
TrafficUsed int64 `json:"trafficUsed"`
} }
func (s *ClientService) ListGroups() ([]GroupSummary, error) { func (s *ClientService) ListGroups() ([]GroupSummary, error) {
db := database.GetDB() 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 var derived []GroupSummary
if err := db.Model(&model.ClientRecord{}). if err := db.Table("clients AS c").
Select("group_name AS name, COUNT(*) AS client_count"). Select("c.group_name AS name, COUNT(*) AS client_count, COALESCE(SUM(ct.up + ct.down), 0) AS traffic_used").
Where("group_name <> ''"). Joins("LEFT JOIN client_traffics ct ON ct.email = c.email").
Group("group_name"). Where("c.group_name <> ''").
Group("c.group_name").
Scan(&derived).Error; err != nil { Scan(&derived).Error; err != nil {
return nil, err return nil, err
} }
@@ -1730,16 +1734,20 @@ func (s *ClientService) ListGroups() ([]GroupSummary, error) {
if err := db.Find(&stored).Error; err != nil { if err := db.Find(&stored).Error; err != nil {
return nil, err 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 { for _, g := range stored {
merged[g.Name] = 0 merged[g.Name] = groupAgg{}
} }
for _, g := range derived { 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)) out := make([]GroupSummary, 0, len(merged))
for name, count := range merged { for name, agg := range merged {
out = append(out, GroupSummary{Name: name, ClientCount: count}) out = append(out, GroupSummary{Name: name, ClientCount: agg.count, TrafficUsed: agg.traffic})
} }
sort.Slice(out, func(i, j int) bool { sort.Slice(out, func(i, j int) bool {
return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name) return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name)
+198 -39
View File
@@ -399,6 +399,137 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error)
return out, nil return out, nil
} }
func (s *InboundService) GetAllInboundClientIps() ([]model.InboundClientIps, error) {
db := database.GetDB()
var ips []model.InboundClientIps
err := db.Model(&model.InboundClientIps{}).Find(&ips).Error
return ips, err
}
// clientIpStaleAfterSeconds mirrors job.ipStaleAfterSeconds: client IPs older than
// 30 minutes are evicted. Applying the same cutoff inside the cross-node merge keeps
// the synced blob bounded and stops the master's push-back from resurrecting IPs that
// a node has already pruned (otherwise the merge defeats the eviction cluster-wide).
const clientIpStaleAfterSeconds = int64(30 * 60)
// clientIpEntry is the on-disk shape of each element of InboundClientIps.Ips. Tags
// match job.IPWithTimestamp so the blob round-trips with the access.log scanner.
type clientIpEntry struct {
IP string `json:"ip"`
Timestamp int64 `json:"timestamp"`
}
// mergeClientIpEntries unions old and incoming IP observations, dropping anything
// older than cutoff, keeping the most recent timestamp per IP, and returning the
// result sorted newest-first.
func mergeClientIpEntries(old, incoming []clientIpEntry, cutoff int64) []clientIpEntry {
ipMap := make(map[string]int64, len(old)+len(incoming))
for _, e := range old {
if e.Timestamp < cutoff {
continue
}
ipMap[e.IP] = e.Timestamp
}
for _, e := range incoming {
if e.Timestamp < cutoff {
continue
}
if cur, ok := ipMap[e.IP]; !ok || e.Timestamp > cur {
ipMap[e.IP] = e.Timestamp
}
}
out := make([]clientIpEntry, 0, len(ipMap))
for ip, ts := range ipMap {
out = append(out, clientIpEntry{IP: ip, Timestamp: ts})
}
sort.Slice(out, func(i, j int) bool { return out[i].Timestamp > out[j].Timestamp })
return out
}
// MergeInboundClientIps folds client IPs synced from another node into the local
// inbound_client_ips table without double-counting an IP seen on multiple nodes and
// without resurrecting stale entries. Existing rows are updated in place; brand-new
// clients (typically node-only clients with no local row) are created with a fresh
// local id.
func (s *InboundService) MergeInboundClientIps(incomingIps []model.InboundClientIps) error {
db := database.GetDB()
var currentIps []model.InboundClientIps
if err := db.Model(&model.InboundClientIps{}).Find(&currentIps).Error; err != nil {
return err
}
currentMap := make(map[string]*model.InboundClientIps, len(currentIps))
for i := range currentIps {
currentMap[currentIps[i].ClientEmail] = &currentIps[i]
}
now := time.Now().Unix()
cutoff := now - clientIpStaleAfterSeconds
tx := db.Begin()
defer func() {
if r := recover(); r != nil {
tx.Rollback()
}
}()
for _, incoming := range incomingIps {
if incoming.ClientEmail == "" || incoming.Ips == "" {
continue
}
var incomingEntries []clientIpEntry
_ = json.Unmarshal([]byte(incoming.Ips), &incomingEntries)
current, exists := currentMap[incoming.ClientEmail]
if !exists {
// New client we've never seen locally. Drop stale entries up front and
// skip the row entirely if nothing is fresh, so we don't persist a row
// that is dead on arrival.
fresh := mergeClientIpEntries(nil, incomingEntries, cutoff)
if len(fresh) == 0 {
continue
}
b, _ := json.Marshal(fresh)
incoming.Ips = string(b)
// Never carry the remote node's primary key into the local table: id
// spaces are independent across nodes and the remote id would collide
// with an unrelated local row. OnConflict guards the race where
// check_client_ip_job creates the same brand-new email between the
// snapshot above and this insert.
incoming.Id = 0
if err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "client_email"}},
DoNothing: true,
}).Create(&incoming).Error; err != nil {
tx.Rollback()
return err
}
continue
}
var oldEntries []clientIpEntry
if current.Ips != "" {
_ = json.Unmarshal([]byte(current.Ips), &oldEntries)
}
merged := mergeClientIpEntries(oldEntries, incomingEntries, cutoff)
b, _ := json.Marshal(merged)
mergedStr := string(b)
// A concurrent check_client_ip_job db.Save on the same row can interleave
// with this update (benign last-writer-wins; any dropped IP reappears on the
// next scan/sync), so only write when the blob actually changed.
if current.Ips != mergedStr {
if err := tx.Model(&model.InboundClientIps{}).Where("id = ?", current.Id).Update("ips", mergedStr).Error; err != nil {
tx.Rollback()
return err
}
}
}
return tx.Commit().Error
}
// inboundShadowsocksMethod extracts settings.method for Shadowsocks inbounds so // inboundShadowsocksMethod extracts settings.method for Shadowsocks inbounds so
// the client UI can generate a valid PSK (base64 of the method's key length) // the client UI can generate a valid PSK (base64 of the method's key length)
// for Shadowsocks 2022 ciphers. Returns "" for non-Shadowsocks inbounds. // for Shadowsocks 2022 ciphers. Returns "" for non-Shadowsocks inbounds.
@@ -1665,23 +1796,24 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
continue continue
} }
newIb := model.Inbound{ newIb := model.Inbound{
UserId: defaultUserId, UserId: defaultUserId,
NodeID: &nodeID, NodeID: &nodeID,
OriginNodeGuid: originGuidFor(snapIb), OriginNodeGuid: originGuidFor(snapIb),
Tag: chosenTag, Tag: chosenTag,
Listen: snapIb.Listen, Listen: snapIb.Listen,
Port: snapIb.Port, Port: snapIb.Port,
Protocol: snapIb.Protocol, Protocol: snapIb.Protocol,
Settings: snapIb.Settings, Settings: snapIb.Settings,
StreamSettings: snapIb.StreamSettings, StreamSettings: snapIb.StreamSettings,
Sniffing: snapIb.Sniffing, Sniffing: snapIb.Sniffing,
TrafficReset: snapIb.TrafficReset, TrafficReset: snapIb.TrafficReset,
Enable: snapIb.Enable, LastTrafficResetTime: snapIb.LastTrafficResetTime,
Remark: snapIb.Remark, Enable: snapIb.Enable,
Total: snapIb.Total, Remark: snapIb.Remark,
ExpiryTime: snapIb.ExpiryTime, Total: snapIb.Total,
Up: snapIb.Up, ExpiryTime: snapIb.ExpiryTime,
Down: snapIb.Down, Up: snapIb.Up,
Down: snapIb.Down,
} }
if err := tx.Create(&newIb).Error; err != nil { if err := tx.Create(&newIb).Error; err != nil {
logger.Warningf("setRemoteTraffic: create central inbound for tag %q failed: %v", snapIb.Tag, err) logger.Warningf("setRemoteTraffic: create central inbound for tag %q failed: %v", snapIb.Tag, err)
@@ -1710,6 +1842,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
updates["stream_settings"] = snapIb.StreamSettings updates["stream_settings"] = snapIb.StreamSettings
updates["sniffing"] = snapIb.Sniffing updates["sniffing"] = snapIb.Sniffing
updates["traffic_reset"] = snapIb.TrafficReset updates["traffic_reset"] = snapIb.TrafficReset
updates["last_traffic_reset_time"] = snapIb.LastTrafficResetTime
} }
if !inGrace || (snapIb.Up+snapIb.Down) <= (c.Up+c.Down) { if !inGrace || (snapIb.Up+snapIb.Down) <= (c.Up+c.Down) {
updates["up"] = snapIb.Up updates["up"] = snapIb.Up
@@ -1755,9 +1888,13 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
return false, err return false, err
} }
if len(goneEmails) > 0 { if len(goneEmails) > 0 {
if err := tx.Where("node_id = ? AND email IN ?", nodeID, goneEmails). // Chunk to avoid SQLite bind var limit when a node has many clients
Delete(&model.NodeClientTraffic{}).Error; err != nil { // removed (e.g. after API bulk delete or structural change on node inbound).
return false, err for _, batch := range chunkStrings(goneEmails, sqliteMaxVars) {
if err := tx.Where("node_id = ? AND email IN ?", nodeID, batch).
Delete(&model.NodeClientTraffic{}).Error; err != nil {
return false, err
}
} }
} }
if err := tx.Where("inbound_id = ?", c.Id). if err := tx.Where("inbound_id = ?", c.Id).
@@ -1836,18 +1973,6 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
structuralChange = true structuralChange = true
} }
// Only allow the node to disable a client (cs.Enable=false), never
// to re-enable one the panel has already disabled. A stale snapshot
// from the node arriving after a central disable would otherwise
// overwrite enable=false back to true, letting the client accumulate
// far more traffic than their limit before being disabled again.
//
// We use a dialect-aware expression (see database.ClientTrafficEnableMergeExpr)
// because the old "enable AND ?" form (and naive CASE with :: casts)
// caused type mismatches on PostgreSQL after public API inbound updates
// (which go through updateClientTraffics + SyncInbound and can touch
// client_traffics rows) and would also break on SQLite due to PG-only
// ::boolean syntax.
enableExpr := database.ClientTrafficEnableMergeExpr() enableExpr := database.ClientTrafficEnableMergeExpr()
if err := tx.Exec( if err := tx.Exec(
fmt.Sprintf( fmt.Sprintf(
@@ -3017,15 +3142,41 @@ func (s *InboundService) resetAllTrafficsLocked() error {
return err return err
} }
nodes, err := (&NodeService{}).GetAll()
if err == nil {
for _, node := range nodes {
if rt, err := runtime.GetManager().RuntimeFor(&node.Id); err == nil {
if e := rt.ResetAllTraffics(context.Background()); e != nil {
logger.Warning("ResetAllTraffics: remote propagation to", rt.Name(), "failed:", e)
}
}
}
}
return nil return nil
} }
func (s *InboundService) ResetInboundTraffic(id int) error { func (s *InboundService) ResetInboundTraffic(id int) error {
return submitTrafficWrite(func() error { return submitTrafficWrite(func() error {
db := database.GetDB() db := database.GetDB()
return db.Model(model.Inbound{}). if err := db.Model(model.Inbound{}).
Where("id = ?", id). Where("id = ?", id).
Updates(map[string]any{"up": 0, "down": 0}).Error Updates(map[string]any{"up": 0, "down": 0}).Error; err != nil {
return err
}
inbound, err := s.GetInbound(id)
if err == nil && inbound != nil && inbound.NodeID != nil {
if rt, rterr := s.runtimeFor(inbound); rterr == nil {
if e := rt.ResetInboundTraffic(context.Background(), inbound); e != nil {
logger.Warning("ResetInboundTraffic: remote propagation to", rt.Name(), "failed:", e)
}
} else {
logger.Warning("ResetInboundTraffic: runtime lookup failed:", rterr)
}
}
return nil
}) })
} }
@@ -3697,7 +3848,7 @@ func (s *InboundService) MigrationRequirements() {
var externalProxy []struct { var externalProxy []struct {
Id int Id int
Port int Port int
StreamSettings []byte StreamSettings string // text column on both DBs; safer than []byte for cross-DB scan
} }
externalProxyQuery := `select id, port, stream_settings externalProxyQuery := `select id, port, stream_settings
from inbounds from inbounds
@@ -3719,7 +3870,7 @@ func (s *InboundService) MigrationRequirements() {
for _, ep := range externalProxy { for _, ep := range externalProxy {
var reverses any var reverses any
var stream map[string]any var stream map[string]any
json.Unmarshal(ep.StreamSettings, &stream) json.Unmarshal([]byte(ep.StreamSettings), &stream)
if tlsSettings, ok := stream["tlsSettings"].(map[string]any); ok { if tlsSettings, ok := stream["tlsSettings"].(map[string]any); ok {
if settings, ok := tlsSettings["settings"].(map[string]any); ok { if settings, ok := tlsSettings["settings"].(map[string]any); ok {
if domains, ok := settings["domains"].([]any); ok { if domains, ok := settings["domains"].([]any); ok {
@@ -3741,9 +3892,17 @@ func (s *InboundService) MigrationRequirements() {
tx.Model(model.Inbound{}).Where("id = ?", ep.Id).Update("stream_settings", newStream) tx.Model(model.Inbound{}).Where("id = ?", ep.Id).Update("stream_settings", newStream)
} }
err = tx.Raw(`UPDATE inbounds // Legacy tag cleanup for old auto-generated tags (e.g. "0.0.0.0:443-...").
SET tag = REPLACE(tag, '0.0.0.0:', '') // Must be cross-DB: INSTR/REPLACE work on SQLite; Postgres needs position().
WHERE INSTR(tag, '0.0.0.0:') > 0;`).Error tagCleanup := `UPDATE inbounds
SET tag = REPLACE(tag, '0.0.0.0:', '')
WHERE INSTR(tag, '0.0.0.0:') > 0;`
if database.IsPostgres() {
tagCleanup = `UPDATE inbounds
SET tag = REPLACE(tag, '0.0.0.0:', '')
WHERE position('0.0.0.0:' in tag) > 0;`
}
err = tx.Raw(tagCleanup).Error
if err != nil { if err != nil {
return return
} }
@@ -0,0 +1,211 @@
package service
import (
"encoding/json"
"path/filepath"
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/database"
"github.com/mhsanaei/3x-ui/v3/database/model"
)
// setupClientIpTestDB spins up a throwaway SQLite database (migrations + seeders)
// for a single test, mirroring the harness used by the other service tests.
func setupClientIpTestDB(t *testing.T) {
t.Helper()
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
}
func marshalIps(t *testing.T, entries ...clientIpEntry) string {
t.Helper()
b, err := json.Marshal(entries)
if err != nil {
t.Fatalf("marshal ips: %v", err)
}
return string(b)
}
// readClientIps returns the stored IP entries for an email as a map[ip]timestamp,
// plus whether the row exists at all.
func readClientIps(t *testing.T, email string) (map[string]int64, bool) {
t.Helper()
var row model.InboundClientIps
err := database.GetDB().Where("client_email = ?", email).First(&row).Error
if database.IsNotFound(err) {
return nil, false
}
if err != nil {
t.Fatalf("read client ips for %s: %v", email, err)
}
var entries []clientIpEntry
if row.Ips != "" {
if err := json.Unmarshal([]byte(row.Ips), &entries); err != nil {
t.Fatalf("unmarshal stored ips for %s: %v", email, err)
}
}
out := make(map[string]int64, len(entries))
for _, e := range entries {
out[e.IP] = e.Timestamp
}
return out, true
}
func TestMergeInboundClientIps_CreatesNodeOnlyRowIgnoringRemoteId(t *testing.T) {
setupClientIpTestDB(t)
db := database.GetDB()
now := time.Now().Unix()
// Local client occupies id 1.
local := &model.InboundClientIps{ClientEmail: "local@x", Ips: marshalIps(t, clientIpEntry{IP: "1.1.1.1", Timestamp: now})}
if err := db.Create(local).Error; err != nil {
t.Fatalf("seed local row: %v", err)
}
// Incoming node-only client carries the remote node's id 1, which must not
// collide with the local row.
incoming := []model.InboundClientIps{{
Id: 1,
ClientEmail: "node@x",
Ips: marshalIps(t, clientIpEntry{IP: "2.2.2.2", Timestamp: now}),
}}
if err := (&InboundService{}).MergeInboundClientIps(incoming); err != nil {
t.Fatalf("merge: %v", err)
}
// Local row is untouched.
if ips, ok := readClientIps(t, "local@x"); !ok || ips["1.1.1.1"] != now {
t.Fatalf("local@x changed unexpectedly: %v (exists=%v)", ips, ok)
}
// Node row exists with its own ip and a freshly assigned id (not the remote 1).
var nodeRow model.InboundClientIps
if err := db.Where("client_email = ?", "node@x").First(&nodeRow).Error; err != nil {
t.Fatalf("node@x not created: %v", err)
}
if nodeRow.Id == local.Id {
t.Fatalf("node@x reused local id %d instead of a fresh one", nodeRow.Id)
}
if ips, _ := readClientIps(t, "node@x"); ips["2.2.2.2"] != now {
t.Fatalf("node@x missing expected ip: %v", ips)
}
}
func TestMergeInboundClientIps_DedupKeepsMaxTimestamp(t *testing.T) {
setupClientIpTestDB(t)
db := database.GetDB()
now := time.Now().Unix()
if err := db.Create(&model.InboundClientIps{
ClientEmail: "a@x",
Ips: marshalIps(t, clientIpEntry{IP: "1.1.1.1", Timestamp: now - 100}),
}).Error; err != nil {
t.Fatalf("seed: %v", err)
}
incoming := []model.InboundClientIps{{
ClientEmail: "a@x",
Ips: marshalIps(t,
clientIpEntry{IP: "1.1.1.1", Timestamp: now - 50}, // newer than stored -> wins
clientIpEntry{IP: "2.2.2.2", Timestamp: now - 10},
),
}}
if err := (&InboundService{}).MergeInboundClientIps(incoming); err != nil {
t.Fatalf("merge: %v", err)
}
ips, _ := readClientIps(t, "a@x")
if len(ips) != 2 {
t.Fatalf("want 2 ips, got %v", ips)
}
if ips["1.1.1.1"] != now-50 {
t.Fatalf("1.1.1.1 should keep max timestamp %d, got %d", now-50, ips["1.1.1.1"])
}
if ips["2.2.2.2"] != now-10 {
t.Fatalf("2.2.2.2 missing/incorrect: %d", ips["2.2.2.2"])
}
}
func TestMergeInboundClientIps_DropsStaleIps(t *testing.T) {
setupClientIpTestDB(t)
db := database.GetDB()
now := time.Now().Unix()
if err := db.Create(&model.InboundClientIps{
ClientEmail: "a@x",
Ips: marshalIps(t,
clientIpEntry{IP: "old", Timestamp: now - 3600}, // > 30m -> stale
clientIpEntry{IP: "fresh", Timestamp: now - 60},
),
}).Error; err != nil {
t.Fatalf("seed: %v", err)
}
incoming := []model.InboundClientIps{{
ClientEmail: "a@x",
Ips: marshalIps(t,
clientIpEntry{IP: "incStale", Timestamp: now - 4000}, // > 30m -> stale
clientIpEntry{IP: "incFresh", Timestamp: now - 10},
),
}}
if err := (&InboundService{}).MergeInboundClientIps(incoming); err != nil {
t.Fatalf("merge: %v", err)
}
ips, _ := readClientIps(t, "a@x")
if len(ips) != 2 {
t.Fatalf("want only fresh ips, got %v", ips)
}
if _, ok := ips["old"]; ok {
t.Fatalf("stale local ip not dropped: %v", ips)
}
if _, ok := ips["incStale"]; ok {
t.Fatalf("stale incoming ip not dropped: %v", ips)
}
if ips["fresh"] != now-60 || ips["incFresh"] != now-10 {
t.Fatalf("fresh ips wrong: %v", ips)
}
}
func TestMergeInboundClientIps_SkipsAllStaleCreate(t *testing.T) {
setupClientIpTestDB(t)
now := time.Now().Unix()
incoming := []model.InboundClientIps{{
ClientEmail: "b@x",
Ips: marshalIps(t, clientIpEntry{IP: "1.1.1.1", Timestamp: now - 9999}),
}}
if err := (&InboundService{}).MergeInboundClientIps(incoming); err != nil {
t.Fatalf("merge: %v", err)
}
if _, ok := readClientIps(t, "b@x"); ok {
t.Fatalf("all-stale node-only client should not create a row")
}
}
func TestMergeInboundClientIps_SkipsBlankRows(t *testing.T) {
setupClientIpTestDB(t)
now := time.Now().Unix()
incoming := []model.InboundClientIps{
{ClientEmail: "", Ips: marshalIps(t, clientIpEntry{IP: "1.1.1.1", Timestamp: now})},
{ClientEmail: "c@x", Ips: ""},
}
if err := (&InboundService{}).MergeInboundClientIps(incoming); err != nil {
t.Fatalf("merge: %v", err)
}
var count int64
if err := database.GetDB().Model(&model.InboundClientIps{}).Count(&count).Error; err != nil {
t.Fatalf("count: %v", err)
}
if count != 0 {
t.Fatalf("blank rows should be skipped, but %d row(s) created", count)
}
}
+55 -15
View File
@@ -35,6 +35,11 @@ type HeartbeatPatch struct {
MemPct float64 MemPct float64
UptimeSecs uint64 UptimeSecs uint64
LastError string LastError string
// XrayState and XrayError come from the remote /panel/api/server/status when the
// panel API is reachable. They allow distinguishing panel connectivity from
// Xray core health on the node.
XrayState string
XrayError string
} }
type NodeService struct{} type NodeService struct{}
@@ -221,11 +226,20 @@ func (s *NodeService) GetAll() ([]*model.Node, error) {
for id := range nodeByInbound { for id := range nodeByInbound {
inboundIDs = append(inboundIDs, id) inboundIDs = append(inboundIDs, id)
} }
if err := db.Table("client_traffics"). // Chunk the IN clause to avoid "too many SQL variables" on SQLite
Select("inbound_id, email, enable, total, up, down, expiry_time"). // when there are many node-owned inbounds (common with many nodes).
Where("inbound_id IN ?", inboundIDs). // sqliteMaxVars is defined in this package (inbound.go).
Scan(&trafficRows).Error; err == nil { for _, batch := range chunkInts(inboundIDs, sqliteMaxVars) {
depletedByNode := make(map[int]int) var page []trafficRow
if err := db.Table("client_traffics").
Select("inbound_id, email, enable, total, up, down, expiry_time").
Where("inbound_id IN ?", batch).
Scan(&page).Error; err == nil {
trafficRows = append(trafficRows, page...)
}
}
depletedByNode := make(map[int]int)
if len(trafficRows) > 0 {
for _, row := range trafficRows { for _, row := range trafficRows {
nodeID, ok := nodeByInbound[row.InboundID] nodeID, ok := nodeByInbound[row.InboundID]
if !ok { if !ok {
@@ -237,15 +251,15 @@ func (s *NodeService) GetAll() ([]*model.Node, error) {
depletedByNode[nodeID]++ depletedByNode[nodeID]++
} }
} }
onlineByGuid := s.onlineEmailsByGuid() }
for _, n := range nodes { onlineByGuid := s.onlineEmailsByGuid()
n.InboundCount = len(inboundsByNode[n.Id]) for _, n := range nodes {
n.DepletedCount = depletedByNode[n.Id] n.InboundCount = len(inboundsByNode[n.Id])
// Online is attributed to the node that physically hosts the client n.DepletedCount = depletedByNode[n.Id]
// (by GUID): a client on a sub-node counts under the sub-node, not // Online is attributed to the node that physically hosts the client
// the intermediate node it syncs through (#4983). // (by GUID): a client on a sub-node counts under the sub-node, not
n.OnlineCount = len(onlineByGuid[effectiveNodeGuid(n)]) // the intermediate node it syncs through (#4983).
} n.OnlineCount = len(onlineByGuid[effectiveNodeGuid(n)])
} }
return nodes, nil return nodes, nil
@@ -284,6 +298,20 @@ func (s *NodeService) GetById(id int) (*model.Node, error) {
return n, nil return n, nil
} }
// NodeExists reports whether a node with the given id exists on this panel.
// Used to drop stale, cross-panel node references on inbound import. A Count
// query distinguishes "no such node" (count 0, no error) from a real DB error.
func (s *NodeService) NodeExists(id int) (bool, error) {
if id <= 0 {
return false, nil
}
var count int64
if err := database.GetDB().Model(model.Node{}).Where("id = ?", id).Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
func normalizeBasePath(p string) string { func normalizeBasePath(p string) string {
p = strings.TrimSpace(p) p = strings.TrimSpace(p)
if p == "" { if p == "" {
@@ -474,6 +502,8 @@ func (s *NodeService) UpdateHeartbeat(id int, p HeartbeatPatch) error {
"mem_pct": p.MemPct, "mem_pct": p.MemPct,
"uptime_secs": p.UptimeSecs, "uptime_secs": p.UptimeSecs,
"last_error": p.LastError, "last_error": p.LastError,
"xray_state": p.XrayState,
"xray_error": p.XrayError,
} }
// Only learn the GUID; never clear a known one if an old-build node (or a // Only learn the GUID; never clear a known one if an old-build node (or a
// failed probe) reports none, so the stable identity survives blips. // failed probe) reports none, so the stable identity survives blips.
@@ -607,7 +637,9 @@ func (s *NodeService) Probe(ctx context.Context, n *model.Node) (HeartbeatPatch,
Total uint64 `json:"total"` Total uint64 `json:"total"`
} `json:"mem"` } `json:"mem"`
Xray struct { Xray struct {
Version string `json:"version"` Version string `json:"version"`
State string `json:"state"`
ErrorMsg string `json:"errorMsg"`
} `json:"xray"` } `json:"xray"`
PanelVersion string `json:"panelVersion"` PanelVersion string `json:"panelVersion"`
PanelGuid string `json:"panelGuid"` PanelGuid string `json:"panelGuid"`
@@ -628,6 +660,8 @@ func (s *NodeService) Probe(ctx context.Context, n *model.Node) (HeartbeatPatch,
patch.MemPct = float64(o.Mem.Current) * 100.0 / float64(o.Mem.Total) patch.MemPct = float64(o.Mem.Current) * 100.0 / float64(o.Mem.Total)
} }
patch.XrayVersion = o.Xray.Version patch.XrayVersion = o.Xray.Version
patch.XrayState = o.Xray.State
patch.XrayError = o.Xray.ErrorMsg
patch.PanelVersion = o.PanelVersion patch.PanelVersion = o.PanelVersion
patch.Guid = o.PanelGuid patch.Guid = o.PanelGuid
patch.UptimeSecs = o.Uptime patch.UptimeSecs = o.Uptime
@@ -643,6 +677,10 @@ type ProbeResultUI struct {
MemPct float64 `json:"memPct" example:"45.2"` MemPct float64 `json:"memPct" example:"45.2"`
UptimeSecs uint64 `json:"uptimeSecs" example:"86400"` UptimeSecs uint64 `json:"uptimeSecs" example:"86400"`
Error string `json:"error"` Error string `json:"error"`
// XrayState/XrayError are populated on successful probes even when the node's
// Xray core is not healthy. The UI uses them for a distinct "panel ok, xray failed" indicator.
XrayState string `json:"xrayState"`
XrayError string `json:"xrayError"`
} }
func (p HeartbeatPatch) ToUI(ok bool) ProbeResultUI { func (p HeartbeatPatch) ToUI(ok bool) ProbeResultUI {
@@ -654,6 +692,8 @@ func (p HeartbeatPatch) ToUI(ok bool) ProbeResultUI {
MemPct: p.MemPct, MemPct: p.MemPct,
UptimeSecs: p.UptimeSecs, UptimeSecs: p.UptimeSecs,
Error: FriendlyProbeError(p.LastError), Error: FriendlyProbeError(p.LastError),
XrayState: p.XrayState,
XrayError: p.XrayError,
} }
if ok { if ok {
r.Status = "online" r.Status = "online"
+4
View File
@@ -40,6 +40,8 @@ func (s *NodeService) LocalDescendants() ([]model.NodeSummary, error) {
LatencyMs: n.LatencyMs, LatencyMs: n.LatencyMs,
PanelVersion: n.PanelVersion, PanelVersion: n.PanelVersion,
XrayVersion: n.XrayVersion, XrayVersion: n.XrayVersion,
XrayState: n.XrayState,
XrayError: n.XrayError,
}) })
} }
return out, nil return out, nil
@@ -140,6 +142,8 @@ func (s *NodeService) GetNodeTree() ([]*model.Node, error) {
LatencyMs: sum.LatencyMs, LatencyMs: sum.LatencyMs,
PanelVersion: sum.PanelVersion, PanelVersion: sum.PanelVersion,
XrayVersion: sum.XrayVersion, XrayVersion: sum.XrayVersion,
XrayState: sum.XrayState,
XrayError: sum.XrayError,
Transitive: true, Transitive: true,
}) })
} }
+23
View File
@@ -86,8 +86,10 @@ var defaultValueMap = map[string]string{
"subJsonMux": "", "subJsonMux": "",
"subJsonRules": "", "subJsonRules": "",
"subJsonFinalMask": "", "subJsonFinalMask": "",
"subThemeDir": "",
"datepicker": "gregorian", "datepicker": "gregorian",
"warp": "", "warp": "",
"warpUpdateInterval": "0",
"nord": "", "nord": "",
"externalTrafficInformEnable": "false", "externalTrafficInformEnable": "false",
"externalTrafficInformURI": "", "externalTrafficInformURI": "",
@@ -322,6 +324,22 @@ func (s *SettingService) setInt(key string, value int) error {
return s.setString(key, strconv.Itoa(value)) return s.setString(key, strconv.Itoa(value))
} }
func (s *SettingService) GetWarpLastUpdate() (int64, error) {
val, err := s.getString("warpLastUpdate")
if err != nil || val == "" {
return 0, err
}
return strconv.ParseInt(val, 10, 64)
}
func (s *SettingService) SetWarpLastUpdate(val int64) error {
return s.saveSetting("warpLastUpdate", strconv.FormatInt(val, 10))
}
func (s *SettingService) SetWarpUpdateInterval(val int) error {
return s.setInt("warpUpdateInterval", val)
}
func (s *SettingService) GetXrayConfigTemplate() (string, error) { func (s *SettingService) GetXrayConfigTemplate() (string, error) {
return s.getString("xrayTemplateConfig") return s.getString("xrayTemplateConfig")
} }
@@ -699,6 +717,10 @@ func (s *SettingService) GetSubJsonFinalMask() (string, error) {
return s.getString("subJsonFinalMask") return s.getString("subJsonFinalMask")
} }
func (s *SettingService) GetSubThemeDir() (string, error) {
return s.getString("subThemeDir")
}
func (s *SettingService) GetDatepicker() (string, error) { func (s *SettingService) GetDatepicker() (string, error) {
return s.getString("datepicker") return s.getString("datepicker")
} }
@@ -973,6 +995,7 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) {
"defaultCert": func() (any, error) { return s.GetCertFile() }, "defaultCert": func() (any, error) { return s.GetCertFile() },
"defaultKey": func() (any, error) { return s.GetKeyFile() }, "defaultKey": func() (any, error) { return s.GetKeyFile() },
"tgBotEnable": func() (any, error) { return s.GetTgbotEnabled() }, "tgBotEnable": func() (any, error) { return s.GetTgbotEnabled() },
"subThemeDir": func() (any, error) { return s.GetSubThemeDir() },
"subEnable": func() (any, error) { return s.GetSubEnable() }, "subEnable": func() (any, error) { return s.GetSubEnable() },
"subJsonEnable": func() (any, error) { return s.GetSubJsonEnable() }, "subJsonEnable": func() (any, error) { return s.GetSubJsonEnable() },
"subClashEnable": func() (any, error) { return s.GetSubClashEnable() }, "subClashEnable": func() (any, error) { return s.GetSubClashEnable() },
+46 -7
View File
@@ -9,6 +9,8 @@ import (
"os" "os"
"time" "time"
"github.com/mhsanaei/3x-ui/v3/logger"
"github.com/mhsanaei/3x-ui/v3/util"
"github.com/mhsanaei/3x-ui/v3/util/common" "github.com/mhsanaei/3x-ui/v3/util/common"
) )
@@ -23,8 +25,6 @@ const (
warpClientVer = "a-6.30-3596" warpClientVer = "a-6.30-3596"
) )
var warpHTTPClient = &http.Client{Timeout: 15 * time.Second}
func (s *WarpService) GetWarpData() (string, error) { func (s *WarpService) GetWarpData() (string, error) {
return s.SettingService.GetWarp() return s.SettingService.GetWarp()
} }
@@ -46,7 +46,7 @@ func (s *WarpService) GetWarpConfig() (string, error) {
} }
req.Header.Set("Authorization", "Bearer "+warpData["access_token"]) req.Header.Set("Authorization", "Bearer "+warpData["access_token"])
body, err := doWarpRequest(req) body, err := s.doWarpRequest(req)
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -73,7 +73,7 @@ func (s *WarpService) RegWarp(secretKey string, publicKey string) (string, error
req.Header.Set("CF-Client-Version", warpClientVer) req.Header.Set("CF-Client-Version", warpClientVer)
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
body, err := doWarpRequest(req) body, err := s.doWarpRequest(req)
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -148,7 +148,7 @@ func (s *WarpService) SetWarpLicense(license string) (string, error) {
req.Header.Set("Authorization", "Bearer "+warpData["access_token"]) req.Header.Set("Authorization", "Bearer "+warpData["access_token"])
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
body, err := doWarpRequest(req) body, err := s.doWarpRequest(req)
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -172,6 +172,44 @@ func (s *WarpService) SetWarpLicense(license string) (string, error) {
return string(newWarpData), nil return string(newWarpData), nil
} }
func (s *WarpService) ChangeWarpIP() (string, error) {
warpDataMap, err := s.loadWarpCreds()
if err != nil {
return "", err
}
privKey, pubKey, err := util.GenerateWireguardKeypair()
if err != nil {
return "", err
}
result, err := s.RegWarp(privKey, pubKey)
if err != nil {
return "", err
}
var parsed struct {
Data map[string]string `json:"data"`
Config map[string]interface{} `json:"config"`
}
if err := json.Unmarshal([]byte(result), &parsed); err != nil {
return "", err
}
xraySvc := XraySettingService{}
if err := xraySvc.UpdateWarpXraySetting(parsed.Data, parsed.Config); err != nil {
return "", err
}
if license, ok := warpDataMap["license_key"]; ok && len(license) >= 26 {
if _, licErr := s.SetWarpLicense(license); licErr != nil {
logger.Warning("ChangeWarpIP: failed to re-apply WARP license: ", licErr)
}
}
return result, nil
}
// loadWarpCreds reads the stored warp JSON and ensures access_token + device_id are set. // loadWarpCreds reads the stored warp JSON and ensures access_token + device_id are set.
func (s *WarpService) loadWarpCreds() (map[string]string, error) { func (s *WarpService) loadWarpCreds() (map[string]string, error) {
warp, err := s.SettingService.GetWarp() warp, err := s.SettingService.GetWarp()
@@ -190,8 +228,9 @@ func (s *WarpService) loadWarpCreds() (map[string]string, error) {
// doWarpRequest sends the request and returns the response body on 2xx. // doWarpRequest sends the request and returns the response body on 2xx.
// Non-2xx responses are returned as errors including the status code and body. // Non-2xx responses are returned as errors including the status code and body.
func doWarpRequest(req *http.Request) ([]byte, error) { func (s *WarpService) doWarpRequest(req *http.Request) ([]byte, error) {
resp, err := warpHTTPClient.Do(req) client := s.NewProxiedHTTPClient(15 * time.Second)
resp, err := client.Do(req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
+89
View File
@@ -2,6 +2,7 @@ package service
import ( import (
_ "embed" _ "embed"
"encoding/base64"
"encoding/json" "encoding/json"
"slices" "slices"
@@ -40,6 +41,94 @@ func (s *XraySettingService) CheckXrayConfig(XrayTemplateConfig string) error {
return nil return nil
} }
func (s *XraySettingService) UpdateWarpXraySetting(warpData map[string]string, warpConfig map[string]interface{}) error {
template, err := s.GetXrayConfigTemplate()
if err != nil {
return err
}
var cfg map[string]interface{}
if err := json.Unmarshal([]byte(template), &cfg); err != nil {
return err
}
outbounds, ok := cfg["outbounds"].([]interface{})
if !ok {
return nil
}
updated := false
for _, outIface := range outbounds {
out, ok := outIface.(map[string]interface{})
if !ok {
continue
}
if tag, ok := out["tag"].(string); ok && tag == "warp" {
settings, ok := out["settings"].(map[string]interface{})
if !ok {
continue
}
settings["secretKey"] = warpData["private_key"]
if conf, ok := warpConfig["config"].(map[string]interface{}); ok {
if iface, ok := conf["interface"].(map[string]interface{}); ok {
if addrs, ok := iface["addresses"].(map[string]interface{}); ok {
var addrList []string
if v4, ok := addrs["v4"].(string); ok && v4 != "" {
addrList = append(addrList, v4+"/32")
}
if v6, ok := addrs["v6"].(string); ok && v6 != "" {
addrList = append(addrList, v6+"/128")
}
settings["address"] = addrList
}
}
var clientId string
if id, ok := conf["client_id"].(string); ok {
clientId = id
} else if id, ok := warpData["client_id"]; ok {
clientId = id
}
if clientId != "" {
decoded, _ := base64.StdEncoding.DecodeString(clientId)
var res []int
for _, b := range decoded {
res = append(res, int(b))
}
settings["reserved"] = res
}
if peers, ok := conf["peers"].([]interface{}); ok && len(peers) > 0 {
if peer, ok := peers[0].(map[string]interface{}); ok {
if pSettings, ok := settings["peers"].([]interface{}); ok && len(pSettings) > 0 {
if pSet, ok := pSettings[0].(map[string]interface{}); ok {
pSet["publicKey"] = peer["public_key"]
if endpoint, ok := peer["endpoint"].(map[string]interface{}); ok {
pSet["endpoint"] = endpoint["host"]
}
}
}
}
}
}
updated = true
break
}
}
if updated {
outJSON, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
return s.SaveXraySetting(string(outJSON))
}
return nil
}
// UnwrapXrayTemplateConfig returns the raw xray config JSON from `raw`, // UnwrapXrayTemplateConfig returns the raw xray config JSON from `raw`,
// peeling off any number of `{ "inboundTags": ..., "outboundTestUrl": ..., // peeling off any number of `{ "inboundTags": ..., "outboundTestUrl": ...,
// "xraySetting": <real config> }` response-shaped wrappers that may have // "xraySetting": <real config> }` response-shaped wrappers that may have
+12 -2
View File
@@ -812,7 +812,8 @@
"clientCount": "عملاء في المجموعة", "clientCount": "عملاء في المجموعة",
"totalGroups": "إجمالي المجموعات", "totalGroups": "إجمالي المجموعات",
"totalGroupedClients": "العملاء بمجموعة", "totalGroupedClients": "العملاء بمجموعة",
"emptyGroups": "مجموعات فارغة", "trafficUsed": "حركة المرور المستخدمة",
"totalTraffic": "إجمالي حركة المرور",
"addGroup": "إضافة مجموعة", "addGroup": "إضافة مجموعة",
"createSuccess": "تم إنشاء المجموعة «{name}».", "createSuccess": "تم إنشاء المجموعة «{name}».",
"rename": "إعادة تسمية", "rename": "إعادة تسمية",
@@ -896,7 +897,9 @@
"statusValues": { "statusValues": {
"online": "متصل", "online": "متصل",
"offline": "غير متصل", "offline": "غير متصل",
"unknown": "غير معروف" "unknown": "غير معروف",
"xrayError": "خطأ Xray",
"xrayStopped": "متوقف"
}, },
"toasts": { "toasts": {
"list": "فشل تحميل النودز", "list": "فشل تحميل النودز",
@@ -1009,6 +1012,8 @@
"subProfileUrlDesc": "رابط لموقعك الإلكتروني يظهر في عميل VPN", "subProfileUrlDesc": "رابط لموقعك الإلكتروني يظهر في عميل VPN",
"subAnnounce": "إعلان", "subAnnounce": "إعلان",
"subAnnounceDesc": "نص الإعلان المعروض في عميل VPN", "subAnnounceDesc": "نص الإعلان المعروض في عميل VPN",
"subThemeDir": "مجلد قالب الاشتراك",
"subThemeDirDesc": "المسار المطلق لمجلد يحتوي على قالب مخصص (index.html/sub.html) لصفحة الاشتراك (مثل /etc/3x-ui/sub_templates/my-theme/). اتركه فارغًا لاستخدام الصفحة الافتراضية.",
"subEnableRouting": "تفعيل التوجيه", "subEnableRouting": "تفعيل التوجيه",
"subEnableRoutingDesc": "إعداد عام لتمكين التوجيه (Routing) في عميل VPN. (فقط لـ Happ)", "subEnableRoutingDesc": "إعداد عام لتمكين التوجيه (Routing) في عميل VPN. (فقط لـ Happ)",
"subRoutingRules": "قواعد التوجيه", "subRoutingRules": "قواعد التوجيه",
@@ -1450,6 +1455,11 @@
"outboundUpdated": "تم تحديث صادر NordVPN" "outboundUpdated": "تم تحديث صادر NordVPN"
}, },
"warp": { "warp": {
"changeIp": "تغيير الـ IP",
"changeIpSuccess": "تم تغيير عنوان IP الخاص بـ WARP بنجاح!",
"autoUpdateIp": "التحديث التلقائي لعنوان IP",
"intervalDays": "الفاصل الزمني (أيام)",
"intervalDesc": "0 للتعطيل. يغيّر عنوان IP تلقائيًا.",
"licenseError": "فشل تعيين رخصة WARP.", "licenseError": "فشل تعيين رخصة WARP.",
"fetchFirst": "احصل على تكوين WARP أولاً.", "fetchFirst": "احصل على تكوين WARP أولاً.",
"createAccount": "إنشاء حساب WARP", "createAccount": "إنشاء حساب WARP",
+12 -2
View File
@@ -813,7 +813,8 @@
"clientCount": "Clients in group", "clientCount": "Clients in group",
"totalGroups": "Total groups", "totalGroups": "Total groups",
"totalGroupedClients": "Clients with a group", "totalGroupedClients": "Clients with a group",
"emptyGroups": "Empty groups", "trafficUsed": "Traffic used",
"totalTraffic": "Total traffic",
"addGroup": "Add Group", "addGroup": "Add Group",
"createSuccess": "Group \"{name}\" created.", "createSuccess": "Group \"{name}\" created.",
"rename": "Rename", "rename": "Rename",
@@ -897,7 +898,9 @@
"statusValues": { "statusValues": {
"online": "Online", "online": "Online",
"offline": "Offline", "offline": "Offline",
"unknown": "Unknown" "unknown": "Unknown",
"xrayError": "Xray Error",
"xrayStopped": "Stopped"
}, },
"toasts": { "toasts": {
"list": "Failed to load nodes", "list": "Failed to load nodes",
@@ -1010,6 +1013,8 @@
"subProfileUrlDesc": "A link to your website displayed in the VPN client", "subProfileUrlDesc": "A link to your website displayed in the VPN client",
"subAnnounce": "Announce", "subAnnounce": "Announce",
"subAnnounceDesc": "The announcement text displayed in the VPN client", "subAnnounceDesc": "The announcement text displayed in the VPN client",
"subThemeDir": "Sub Theme Directory",
"subThemeDirDesc": "Absolute path to a folder containing a custom index.html/sub.html subscription page template (e.g. /etc/3x-ui/sub_templates/my-theme/). Leave empty to use the default page.",
"subEnableRouting": "Enable routing", "subEnableRouting": "Enable routing",
"subEnableRoutingDesc": "Global setting to enable routing in the VPN client. (Only for Happ)", "subEnableRoutingDesc": "Global setting to enable routing in the VPN client. (Only for Happ)",
"subRoutingRules": "Routing rules", "subRoutingRules": "Routing rules",
@@ -1451,6 +1456,11 @@
"outboundUpdated": "NordVPN outbound updated" "outboundUpdated": "NordVPN outbound updated"
}, },
"warp": { "warp": {
"changeIp": "Change IP",
"changeIpSuccess": "WARP IP changed successfully!",
"autoUpdateIp": "Auto Update IP Address",
"intervalDays": "Interval (Days)",
"intervalDesc": "0 to disable. Changes IP address automatically.",
"licenseError": "Failed to set WARP license.", "licenseError": "Failed to set WARP license.",
"fetchFirst": "Fetch the WARP config first.", "fetchFirst": "Fetch the WARP config first.",
"createAccount": "Create WARP account", "createAccount": "Create WARP account",
+12 -2
View File
@@ -812,7 +812,8 @@
"clientCount": "Clientes en el grupo", "clientCount": "Clientes en el grupo",
"totalGroups": "Total de grupos", "totalGroups": "Total de grupos",
"totalGroupedClients": "Clientes con grupo", "totalGroupedClients": "Clientes con grupo",
"emptyGroups": "Grupos vacíos", "trafficUsed": "Tráfico usado",
"totalTraffic": "Tráfico total",
"addGroup": "Añadir grupo", "addGroup": "Añadir grupo",
"createSuccess": "Grupo «{name}» creado.", "createSuccess": "Grupo «{name}» creado.",
"rename": "Renombrar", "rename": "Renombrar",
@@ -896,7 +897,9 @@
"statusValues": { "statusValues": {
"online": "En línea", "online": "En línea",
"offline": "Sin conexión", "offline": "Sin conexión",
"unknown": "Desconocido" "unknown": "Desconocido",
"xrayError": "Error de Xray",
"xrayStopped": "Detenido"
}, },
"toasts": { "toasts": {
"list": "Error al cargar los nodos", "list": "Error al cargar los nodos",
@@ -1009,6 +1012,8 @@
"subProfileUrlDesc": "Un enlace a tu sitio web mostrado en el cliente VPN", "subProfileUrlDesc": "Un enlace a tu sitio web mostrado en el cliente VPN",
"subAnnounce": "Anuncio", "subAnnounce": "Anuncio",
"subAnnounceDesc": "El texto del anuncio mostrado en el cliente VPN", "subAnnounceDesc": "El texto del anuncio mostrado en el cliente VPN",
"subThemeDir": "Directorio del tema de suscripción",
"subThemeDirDesc": "Ruta absoluta a una carpeta que contiene una plantilla personalizada (index.html/sub.html) para la página de suscripción (p. ej. /etc/3x-ui/sub_templates/my-theme/). Déjalo vacío para usar la página predeterminada.",
"subEnableRouting": "Habilitar enrutamiento", "subEnableRouting": "Habilitar enrutamiento",
"subEnableRoutingDesc": "Configuración global para habilitar el enrutamiento en el cliente VPN. (Solo para Happ)", "subEnableRoutingDesc": "Configuración global para habilitar el enrutamiento en el cliente VPN. (Solo para Happ)",
"subRoutingRules": "Reglas de enrutamiento", "subRoutingRules": "Reglas de enrutamiento",
@@ -1450,6 +1455,11 @@
"outboundUpdated": "Salida NordVPN actualizada" "outboundUpdated": "Salida NordVPN actualizada"
}, },
"warp": { "warp": {
"changeIp": "Cambiar IP",
"changeIpSuccess": "¡IP de WARP cambiada correctamente!",
"autoUpdateIp": "Actualizar IP automáticamente",
"intervalDays": "Intervalo (días)",
"intervalDesc": "0 para desactivar. Cambia la dirección IP automáticamente.",
"licenseError": "No se pudo establecer la licencia WARP.", "licenseError": "No se pudo establecer la licencia WARP.",
"fetchFirst": "Obtén primero la configuración WARP.", "fetchFirst": "Obtén primero la configuración WARP.",
"createAccount": "Crear cuenta WARP", "createAccount": "Crear cuenta WARP",
+12 -2
View File
@@ -812,7 +812,8 @@
"clientCount": "کاربران در گروه", "clientCount": "کاربران در گروه",
"totalGroups": "تعداد گروه‌ها", "totalGroups": "تعداد گروه‌ها",
"totalGroupedClients": "کاربران دارای گروه", "totalGroupedClients": "کاربران دارای گروه",
"emptyGroups": "گروه‌های خالی", "trafficUsed": "ترافیک مصرف‌شده",
"totalTraffic": "مجموع ترافیک",
"addGroup": "افزودن گروه", "addGroup": "افزودن گروه",
"createSuccess": "گروه «{name}» ایجاد شد.", "createSuccess": "گروه «{name}» ایجاد شد.",
"rename": "تغییر نام", "rename": "تغییر نام",
@@ -896,7 +897,9 @@
"statusValues": { "statusValues": {
"online": "آنلاین", "online": "آنلاین",
"offline": "آفلاین", "offline": "آفلاین",
"unknown": "نامشخص" "unknown": "نامشخص",
"xrayError": "خطای Xray",
"xrayStopped": "متوقف"
}, },
"toasts": { "toasts": {
"list": "بارگذاری نودها ناموفق", "list": "بارگذاری نودها ناموفق",
@@ -1009,6 +1012,8 @@
"subProfileUrlDesc": "لینک وب‌سایت شما که در کلاینت VPN نمایش داده می‌شود", "subProfileUrlDesc": "لینک وب‌سایت شما که در کلاینت VPN نمایش داده می‌شود",
"subAnnounce": "اعلان", "subAnnounce": "اعلان",
"subAnnounceDesc": "متن اعلانی که در کلاینت VPN نمایش داده می‌شود", "subAnnounceDesc": "متن اعلانی که در کلاینت VPN نمایش داده می‌شود",
"subThemeDir": "پوشه قالب صفحه اشتراک",
"subThemeDirDesc": "مسیر مطلق پوشه‌ای که شامل یک قالب سفارشی (index.html/sub.html) برای صفحه اشتراک است (مثلاً /etc/3x-ui/sub_templates/my-theme/). برای استفاده از صفحه پیش‌فرض خالی بگذارید.",
"subEnableRouting": "فعال‌سازی مسیریابی", "subEnableRouting": "فعال‌سازی مسیریابی",
"subEnableRoutingDesc": "تنظیمات سراسری برای فعال‌سازی مسیریابی در کلاینت VPN. (فقط برای Happ)", "subEnableRoutingDesc": "تنظیمات سراسری برای فعال‌سازی مسیریابی در کلاینت VPN. (فقط برای Happ)",
"subRoutingRules": "قوانین مسیریابی", "subRoutingRules": "قوانین مسیریابی",
@@ -1450,6 +1455,11 @@
"outboundUpdated": "خروجی NordVPN به‌روزرسانی شد" "outboundUpdated": "خروجی NordVPN به‌روزرسانی شد"
}, },
"warp": { "warp": {
"changeIp": "تغییر IP",
"changeIpSuccess": "آدرس IP وارپ با موفقیت تغییر کرد!",
"autoUpdateIp": "به‌روزرسانی خودکار آدرس IP",
"intervalDays": "بازه (روز)",
"intervalDesc": "برای غیرفعال‌سازی ۰ بگذارید. آدرس IP را به‌صورت خودکار تغییر می‌دهد.",
"licenseError": "تنظیم لایسنس WARP ناموفق بود.", "licenseError": "تنظیم لایسنس WARP ناموفق بود.",
"fetchFirst": "ابتدا پیکربندی WARP را دریافت کنید.", "fetchFirst": "ابتدا پیکربندی WARP را دریافت کنید.",
"createAccount": "ایجاد حساب WARP", "createAccount": "ایجاد حساب WARP",
+12 -2
View File
@@ -812,7 +812,8 @@
"clientCount": "Klien di grup", "clientCount": "Klien di grup",
"totalGroups": "Total grup", "totalGroups": "Total grup",
"totalGroupedClients": "Klien dengan grup", "totalGroupedClients": "Klien dengan grup",
"emptyGroups": "Grup kosong", "trafficUsed": "Trafik terpakai",
"totalTraffic": "Total trafik",
"addGroup": "Tambah grup", "addGroup": "Tambah grup",
"createSuccess": "Grup «{name}» dibuat.", "createSuccess": "Grup «{name}» dibuat.",
"rename": "Ubah nama", "rename": "Ubah nama",
@@ -896,7 +897,9 @@
"statusValues": { "statusValues": {
"online": "Online", "online": "Online",
"offline": "Offline", "offline": "Offline",
"unknown": "Tidak diketahui" "unknown": "Tidak diketahui",
"xrayError": "Kesalahan Xray",
"xrayStopped": "Berhenti"
}, },
"toasts": { "toasts": {
"list": "Gagal memuat node", "list": "Gagal memuat node",
@@ -1009,6 +1012,8 @@
"subProfileUrlDesc": "Tautan ke situs web Anda yang ditampilkan di klien VPN", "subProfileUrlDesc": "Tautan ke situs web Anda yang ditampilkan di klien VPN",
"subAnnounce": "Pengumuman", "subAnnounce": "Pengumuman",
"subAnnounceDesc": "Teks pengumuman yang ditampilkan di klien VPN", "subAnnounceDesc": "Teks pengumuman yang ditampilkan di klien VPN",
"subThemeDir": "Direktori Tema Langganan",
"subThemeDirDesc": "Path absolut ke folder yang berisi template kustom (index.html/sub.html) untuk halaman langganan (mis. /etc/3x-ui/sub_templates/my-theme/). Biarkan kosong untuk menggunakan halaman default.",
"subEnableRouting": "Aktifkan perutean", "subEnableRouting": "Aktifkan perutean",
"subEnableRoutingDesc": "Pengaturan global untuk mengaktifkan perutean (routing) di klien VPN. (Hanya untuk Happ)", "subEnableRoutingDesc": "Pengaturan global untuk mengaktifkan perutean (routing) di klien VPN. (Hanya untuk Happ)",
"subRoutingRules": "Aturan routing", "subRoutingRules": "Aturan routing",
@@ -1450,6 +1455,11 @@
"outboundUpdated": "Outbound NordVPN diperbarui" "outboundUpdated": "Outbound NordVPN diperbarui"
}, },
"warp": { "warp": {
"changeIp": "Ganti IP",
"changeIpSuccess": "IP WARP berhasil diganti!",
"autoUpdateIp": "Perbarui Alamat IP Otomatis",
"intervalDays": "Interval (Hari)",
"intervalDesc": "0 untuk menonaktifkan. Mengganti alamat IP secara otomatis.",
"licenseError": "Gagal mengatur lisensi WARP.", "licenseError": "Gagal mengatur lisensi WARP.",
"fetchFirst": "Ambil konfig WARP terlebih dahulu.", "fetchFirst": "Ambil konfig WARP terlebih dahulu.",
"createAccount": "Buat akun WARP", "createAccount": "Buat akun WARP",
+12 -2
View File
@@ -812,7 +812,8 @@
"clientCount": "グループ内のクライアント", "clientCount": "グループ内のクライアント",
"totalGroups": "グループ合計", "totalGroups": "グループ合計",
"totalGroupedClients": "グループのあるクライアント", "totalGroupedClients": "グループのあるクライアント",
"emptyGroups": "空のグループ", "trafficUsed": "使用済みトラフィック",
"totalTraffic": "合計トラフィック",
"addGroup": "グループ追加", "addGroup": "グループ追加",
"createSuccess": "グループ「{name}」を作成しました。", "createSuccess": "グループ「{name}」を作成しました。",
"rename": "名前変更", "rename": "名前変更",
@@ -896,7 +897,9 @@
"statusValues": { "statusValues": {
"online": "オンライン", "online": "オンライン",
"offline": "オフライン", "offline": "オフライン",
"unknown": "不明" "unknown": "不明",
"xrayError": "Xray エラー",
"xrayStopped": "停止"
}, },
"toasts": { "toasts": {
"list": "ノードの読み込みに失敗しました", "list": "ノードの読み込みに失敗しました",
@@ -1009,6 +1012,8 @@
"subProfileUrlDesc": "VPNクライアントに表示されるWebサイトへのリンク", "subProfileUrlDesc": "VPNクライアントに表示されるWebサイトへのリンク",
"subAnnounce": "お知らせ", "subAnnounce": "お知らせ",
"subAnnounceDesc": "VPNクライアントに表示されるお知らせのテキスト", "subAnnounceDesc": "VPNクライアントに表示されるお知らせのテキスト",
"subThemeDir": "サブスクリプションテーマディレクトリ",
"subThemeDirDesc": "サブスクリプションページのカスタムテンプレート (index.html/sub.html) を含むフォルダーの絶対パス(例: /etc/3x-ui/sub_templates/my-theme/)。空欄の場合はデフォルトのページを使用します。",
"subEnableRouting": "ルーティングを有効化", "subEnableRouting": "ルーティングを有効化",
"subEnableRoutingDesc": "VPNクライアントでルーティングを有効にするためのグローバル設定。(Happのみ)", "subEnableRoutingDesc": "VPNクライアントでルーティングを有効にするためのグローバル設定。(Happのみ)",
"subRoutingRules": "ルーティングルール", "subRoutingRules": "ルーティングルール",
@@ -1450,6 +1455,11 @@
"outboundUpdated": "NordVPN アウトバウンドを更新しました" "outboundUpdated": "NordVPN アウトバウンドを更新しました"
}, },
"warp": { "warp": {
"changeIp": "IP を変更",
"changeIpSuccess": "WARP の IP を変更しました!",
"autoUpdateIp": "IP アドレスの自動更新",
"intervalDays": "間隔(日)",
"intervalDesc": "0 で無効。IP アドレスを自動的に変更します。",
"licenseError": "WARP ライセンスの設定に失敗しました。", "licenseError": "WARP ライセンスの設定に失敗しました。",
"fetchFirst": "先に WARP 構成を取得してください。", "fetchFirst": "先に WARP 構成を取得してください。",
"createAccount": "WARP アカウントを作成", "createAccount": "WARP アカウントを作成",
+12 -2
View File
@@ -812,7 +812,8 @@
"clientCount": "Clientes no grupo", "clientCount": "Clientes no grupo",
"totalGroups": "Total de grupos", "totalGroups": "Total de grupos",
"totalGroupedClients": "Clientes com grupo", "totalGroupedClients": "Clientes com grupo",
"emptyGroups": "Grupos vazios", "trafficUsed": "Tráfego usado",
"totalTraffic": "Tráfego total",
"addGroup": "Adicionar grupo", "addGroup": "Adicionar grupo",
"createSuccess": "Grupo «{name}» criado.", "createSuccess": "Grupo «{name}» criado.",
"rename": "Renomear", "rename": "Renomear",
@@ -896,7 +897,9 @@
"statusValues": { "statusValues": {
"online": "Online", "online": "Online",
"offline": "Offline", "offline": "Offline",
"unknown": "Desconhecido" "unknown": "Desconhecido",
"xrayError": "Erro do Xray",
"xrayStopped": "Parado"
}, },
"toasts": { "toasts": {
"list": "Falha ao carregar os nós", "list": "Falha ao carregar os nós",
@@ -1009,6 +1012,8 @@
"subProfileUrlDesc": "Um link para o seu site exibido no cliente VPN", "subProfileUrlDesc": "Um link para o seu site exibido no cliente VPN",
"subAnnounce": "Anúncio", "subAnnounce": "Anúncio",
"subAnnounceDesc": "O texto do anúncio exibido no cliente VPN", "subAnnounceDesc": "O texto do anúncio exibido no cliente VPN",
"subThemeDir": "Diretório do tema de assinatura",
"subThemeDirDesc": "Caminho absoluto para uma pasta contendo um modelo personalizado (index.html/sub.html) para a página de assinatura (ex.: /etc/3x-ui/sub_templates/my-theme/). Deixe vazio para usar a página padrão.",
"subEnableRouting": "Ativar roteamento", "subEnableRouting": "Ativar roteamento",
"subEnableRoutingDesc": "Configuração global para habilitar o roteamento no cliente VPN. (Apenas para Happ)", "subEnableRoutingDesc": "Configuração global para habilitar o roteamento no cliente VPN. (Apenas para Happ)",
"subRoutingRules": "Regras de roteamento", "subRoutingRules": "Regras de roteamento",
@@ -1450,6 +1455,11 @@
"outboundUpdated": "Saída NordVPN atualizada" "outboundUpdated": "Saída NordVPN atualizada"
}, },
"warp": { "warp": {
"changeIp": "Alterar IP",
"changeIpSuccess": "IP do WARP alterado com sucesso!",
"autoUpdateIp": "Atualizar endereço IP automaticamente",
"intervalDays": "Intervalo (dias)",
"intervalDesc": "0 para desativar. Altera o endereço IP automaticamente.",
"licenseError": "Falha ao definir licença WARP.", "licenseError": "Falha ao definir licença WARP.",
"fetchFirst": "Obtenha primeiro a configuração WARP.", "fetchFirst": "Obtenha primeiro a configuração WARP.",
"createAccount": "Criar conta WARP", "createAccount": "Criar conta WARP",
+12 -2
View File
@@ -812,7 +812,8 @@
"clientCount": "Клиентов в группе", "clientCount": "Клиентов в группе",
"totalGroups": "Всего групп", "totalGroups": "Всего групп",
"totalGroupedClients": "Клиенты с группой", "totalGroupedClients": "Клиенты с группой",
"emptyGroups": "Пустые группы", "trafficUsed": "Использованный трафик",
"totalTraffic": "Общий трафик",
"addGroup": "Добавить группу", "addGroup": "Добавить группу",
"createSuccess": "Группа «{name}» создана.", "createSuccess": "Группа «{name}» создана.",
"rename": "Переименовать", "rename": "Переименовать",
@@ -896,7 +897,9 @@
"statusValues": { "statusValues": {
"online": "В сети", "online": "В сети",
"offline": "Не в сети", "offline": "Не в сети",
"unknown": "Неизвестно" "unknown": "Неизвестно",
"xrayError": "Ошибка Xray",
"xrayStopped": "Остановлен"
}, },
"toasts": { "toasts": {
"list": "Не удалось загрузить узлы", "list": "Не удалось загрузить узлы",
@@ -1009,6 +1012,8 @@
"subProfileUrlDesc": "Ссылка на ваш сайт, отображаемая в VPN-клиенте", "subProfileUrlDesc": "Ссылка на ваш сайт, отображаемая в VPN-клиенте",
"subAnnounce": "Объявление", "subAnnounce": "Объявление",
"subAnnounceDesc": "Текст объявления, отображаемый в VPN-клиенте", "subAnnounceDesc": "Текст объявления, отображаемый в VPN-клиенте",
"subThemeDir": "Каталог темы подписки",
"subThemeDirDesc": "Абсолютный путь к папке с пользовательским шаблоном (index.html/sub.html) для страницы подписки (например, /etc/3x-ui/sub_templates/my-theme/). Оставьте пустым, чтобы использовать страницу по умолчанию.",
"subEnableRouting": "Включить маршрутизацию", "subEnableRouting": "Включить маршрутизацию",
"subEnableRoutingDesc": "Глобальная настройка для включения маршрутизации в VPN-клиенте. (Только для Happ)", "subEnableRoutingDesc": "Глобальная настройка для включения маршрутизации в VPN-клиенте. (Только для Happ)",
"subRoutingRules": "Правила маршрутизации", "subRoutingRules": "Правила маршрутизации",
@@ -1450,6 +1455,11 @@
"outboundUpdated": "Исходящий NordVPN обновлён" "outboundUpdated": "Исходящий NordVPN обновлён"
}, },
"warp": { "warp": {
"changeIp": "Сменить IP",
"changeIpSuccess": "IP-адрес WARP успешно изменён!",
"autoUpdateIp": "Автоматическое обновление IP-адреса",
"intervalDays": "Интервал (дни)",
"intervalDesc": "0 — отключить. Автоматически меняет IP-адрес.",
"licenseError": "Не удалось установить лицензию WARP.", "licenseError": "Не удалось установить лицензию WARP.",
"fetchFirst": "Сначала получите WARP-конфиг.", "fetchFirst": "Сначала получите WARP-конфиг.",
"createAccount": "Создать аккаунт WARP", "createAccount": "Создать аккаунт WARP",
+567 -557
View File
File diff suppressed because it is too large Load Diff
+12 -2
View File
@@ -812,7 +812,8 @@
"clientCount": "Клієнтів у групі", "clientCount": "Клієнтів у групі",
"totalGroups": "Всього груп", "totalGroups": "Всього груп",
"totalGroupedClients": "Клієнти з групою", "totalGroupedClients": "Клієнти з групою",
"emptyGroups": "Порожні групи", "trafficUsed": "Використаний трафік",
"totalTraffic": "Загальний трафік",
"addGroup": "Додати групу", "addGroup": "Додати групу",
"createSuccess": "Групу «{name}» створено.", "createSuccess": "Групу «{name}» створено.",
"rename": "Перейменувати", "rename": "Перейменувати",
@@ -896,7 +897,9 @@
"statusValues": { "statusValues": {
"online": "У мережі", "online": "У мережі",
"offline": "Не в мережі", "offline": "Не в мережі",
"unknown": "Невідомо" "unknown": "Невідомо",
"xrayError": "Помилка Xray",
"xrayStopped": "Зупинено"
}, },
"toasts": { "toasts": {
"list": "Не вдалося завантажити вузли", "list": "Не вдалося завантажити вузли",
@@ -1009,6 +1012,8 @@
"subProfileUrlDesc": "Посилання на ваш вебсайт, що відображається у VPN-клієнті", "subProfileUrlDesc": "Посилання на ваш вебсайт, що відображається у VPN-клієнті",
"subAnnounce": "Оголошення", "subAnnounce": "Оголошення",
"subAnnounceDesc": "Текст оголошення, що відображається у VPN-клієнті", "subAnnounceDesc": "Текст оголошення, що відображається у VPN-клієнті",
"subThemeDir": "Каталог теми підписки",
"subThemeDirDesc": "Абсолютний шлях до теки з користувацьким шаблоном (index.html/sub.html) для сторінки підписки (наприклад, /etc/3x-ui/sub_templates/my-theme/). Залиште порожнім, щоб використовувати сторінку за замовчуванням.",
"subEnableRouting": "Увімкнути маршрутизацію", "subEnableRouting": "Увімкнути маршрутизацію",
"subEnableRoutingDesc": "Глобальне налаштування для увімкнення маршрутизації у VPN-клієнті. (Тільки для Happ)", "subEnableRoutingDesc": "Глобальне налаштування для увімкнення маршрутизації у VPN-клієнті. (Тільки для Happ)",
"subRoutingRules": "Правила маршрутизації", "subRoutingRules": "Правила маршрутизації",
@@ -1450,6 +1455,11 @@
"outboundUpdated": "Вихідний NordVPN оновлено" "outboundUpdated": "Вихідний NordVPN оновлено"
}, },
"warp": { "warp": {
"changeIp": "Змінити IP",
"changeIpSuccess": "IP-адресу WARP успішно змінено!",
"autoUpdateIp": "Автоматичне оновлення IP-адреси",
"intervalDays": "Інтервал (дні)",
"intervalDesc": "0 — вимкнути. Автоматично змінює IP-адресу.",
"licenseError": "Не вдалося встановити ліцензію WARP.", "licenseError": "Не вдалося встановити ліцензію WARP.",
"fetchFirst": "Спочатку отримайте WARP-конфіг.", "fetchFirst": "Спочатку отримайте WARP-конфіг.",
"createAccount": "Створити акаунт WARP", "createAccount": "Створити акаунт WARP",
+12 -2
View File
@@ -812,7 +812,8 @@
"clientCount": "Client trong nhóm", "clientCount": "Client trong nhóm",
"totalGroups": "Tổng số nhóm", "totalGroups": "Tổng số nhóm",
"totalGroupedClients": "Client có nhóm", "totalGroupedClients": "Client có nhóm",
"emptyGroups": "Nhóm trống", "trafficUsed": "Lưu lượng đã dùng",
"totalTraffic": "Tổng lưu lượng",
"addGroup": "Thêm nhóm", "addGroup": "Thêm nhóm",
"createSuccess": "Đã tạo nhóm «{name}».", "createSuccess": "Đã tạo nhóm «{name}».",
"rename": "Đổi tên", "rename": "Đổi tên",
@@ -896,7 +897,9 @@
"statusValues": { "statusValues": {
"online": "Trực tuyến", "online": "Trực tuyến",
"offline": "Ngoại tuyến", "offline": "Ngoại tuyến",
"unknown": "Không xác định" "unknown": "Không xác định",
"xrayError": "Lỗi Xray",
"xrayStopped": "Đã dừng"
}, },
"toasts": { "toasts": {
"list": "Không tải được danh sách nút", "list": "Không tải được danh sách nút",
@@ -1009,6 +1012,8 @@
"subProfileUrlDesc": "Liên kết đến trang web của bạn hiển thị trong ứng dụng VPN", "subProfileUrlDesc": "Liên kết đến trang web của bạn hiển thị trong ứng dụng VPN",
"subAnnounce": "Thông báo", "subAnnounce": "Thông báo",
"subAnnounceDesc": "Văn bản thông báo hiển thị trong ứng dụng VPN", "subAnnounceDesc": "Văn bản thông báo hiển thị trong ứng dụng VPN",
"subThemeDir": "Thư mục giao diện Đăng ký",
"subThemeDirDesc": "Đường dẫn tuyệt đối đến thư mục chứa mẫu tùy chỉnh (index.html/sub.html) cho trang đăng ký (ví dụ: /etc/3x-ui/sub_templates/my-theme/). Để trống để dùng trang mặc định.",
"subEnableRouting": "Bật định tuyến", "subEnableRouting": "Bật định tuyến",
"subEnableRoutingDesc": "Cài đặt toàn cục để bật định tuyến trong ứng dụng khách VPN. (Chỉ dành cho Happ)", "subEnableRoutingDesc": "Cài đặt toàn cục để bật định tuyến trong ứng dụng khách VPN. (Chỉ dành cho Happ)",
"subRoutingRules": "Quy tắc định tuyến", "subRoutingRules": "Quy tắc định tuyến",
@@ -1450,6 +1455,11 @@
"outboundUpdated": "Đã cập nhật outbound NordVPN" "outboundUpdated": "Đã cập nhật outbound NordVPN"
}, },
"warp": { "warp": {
"changeIp": "Đổi IP",
"changeIpSuccess": "Đã đổi IP WARP thành công!",
"autoUpdateIp": "Tự động cập nhật địa chỉ IP",
"intervalDays": "Khoảng thời gian (ngày)",
"intervalDesc": "0 để tắt. Tự động đổi địa chỉ IP.",
"licenseError": "Không thiết lập được giấy phép WARP.", "licenseError": "Không thiết lập được giấy phép WARP.",
"fetchFirst": "Hãy lấy cấu hình WARP trước.", "fetchFirst": "Hãy lấy cấu hình WARP trước.",
"createAccount": "Tạo tài khoản WARP", "createAccount": "Tạo tài khoản WARP",
+12 -2
View File
@@ -812,7 +812,8 @@
"clientCount": "分组中的客户端", "clientCount": "分组中的客户端",
"totalGroups": "分组总数", "totalGroups": "分组总数",
"totalGroupedClients": "有分组的客户端", "totalGroupedClients": "有分组的客户端",
"emptyGroups": "空分组", "trafficUsed": "已用流量",
"totalTraffic": "总流量",
"addGroup": "添加分组", "addGroup": "添加分组",
"createSuccess": "已创建分组 “{name}”。", "createSuccess": "已创建分组 “{name}”。",
"rename": "重命名", "rename": "重命名",
@@ -896,7 +897,9 @@
"statusValues": { "statusValues": {
"online": "在线", "online": "在线",
"offline": "离线", "offline": "离线",
"unknown": "未知" "unknown": "未知",
"xrayError": "Xray 错误",
"xrayStopped": "已停止"
}, },
"toasts": { "toasts": {
"list": "加载节点失败", "list": "加载节点失败",
@@ -1009,6 +1012,8 @@
"subProfileUrlDesc": "VPN 客户端中显示的网站链接", "subProfileUrlDesc": "VPN 客户端中显示的网站链接",
"subAnnounce": "公告", "subAnnounce": "公告",
"subAnnounceDesc": "VPN 客户端中显示的公告文本", "subAnnounceDesc": "VPN 客户端中显示的公告文本",
"subThemeDir": "订阅主题目录",
"subThemeDirDesc": "包含自定义订阅页面模板 (index.html/sub.html) 的文件夹的绝对路径(例如 /etc/3x-ui/sub_templates/my-theme/)。留空则使用默认页面。",
"subEnableRouting": "启用路由", "subEnableRouting": "启用路由",
"subEnableRoutingDesc": "在 VPN 客户端中启用路由的全局设置。(僅限 Happ)", "subEnableRoutingDesc": "在 VPN 客户端中启用路由的全局设置。(僅限 Happ)",
"subRoutingRules": "路由規則", "subRoutingRules": "路由規則",
@@ -1450,6 +1455,11 @@
"outboundUpdated": "NordVPN 出站已更新" "outboundUpdated": "NordVPN 出站已更新"
}, },
"warp": { "warp": {
"changeIp": "更换 IP",
"changeIpSuccess": "WARP IP 更换成功!",
"autoUpdateIp": "自动更新 IP 地址",
"intervalDays": "间隔(天)",
"intervalDesc": "设为 0 禁用。自动更换 IP 地址。",
"licenseError": "设置 WARP 许可证失败。", "licenseError": "设置 WARP 许可证失败。",
"fetchFirst": "请先获取 WARP 配置。", "fetchFirst": "请先获取 WARP 配置。",
"createAccount": "创建 WARP 账户", "createAccount": "创建 WARP 账户",
+12 -2
View File
@@ -812,7 +812,8 @@
"clientCount": "群組中的客戶端", "clientCount": "群組中的客戶端",
"totalGroups": "群組總數", "totalGroups": "群組總數",
"totalGroupedClients": "有群組的客戶端", "totalGroupedClients": "有群組的客戶端",
"emptyGroups": "空群組", "trafficUsed": "已用流量",
"totalTraffic": "總流量",
"addGroup": "新增群組", "addGroup": "新增群組",
"createSuccess": "已建立群組「{name}」。", "createSuccess": "已建立群組「{name}」。",
"rename": "重新命名", "rename": "重新命名",
@@ -896,7 +897,9 @@
"statusValues": { "statusValues": {
"online": "上線", "online": "上線",
"offline": "離線", "offline": "離線",
"unknown": "未知" "unknown": "未知",
"xrayError": "Xray 錯誤",
"xrayStopped": "已停止"
}, },
"toasts": { "toasts": {
"list": "載入節點失敗", "list": "載入節點失敗",
@@ -1009,6 +1012,8 @@
"subProfileUrlDesc": "VPN 用戶端中顯示的網站連結", "subProfileUrlDesc": "VPN 用戶端中顯示的網站連結",
"subAnnounce": "公告", "subAnnounce": "公告",
"subAnnounceDesc": "VPN 用戶端中顯示的公告文字", "subAnnounceDesc": "VPN 用戶端中顯示的公告文字",
"subThemeDir": "訂閱主題目錄",
"subThemeDirDesc": "包含自訂訂閱頁面範本 (index.html/sub.html) 的資料夾的絕對路徑(例如 /etc/3x-ui/sub_templates/my-theme/)。留空則使用預設頁面。",
"subEnableRouting": "啟用路由", "subEnableRouting": "啟用路由",
"subEnableRoutingDesc": "在 VPN 用戶端中啟用路由的全域設定。(僅限 Happ)", "subEnableRoutingDesc": "在 VPN 用戶端中啟用路由的全域設定。(僅限 Happ)",
"subRoutingRules": "路由規則", "subRoutingRules": "路由規則",
@@ -1450,6 +1455,11 @@
"outboundUpdated": "NordVPN 出站已更新" "outboundUpdated": "NordVPN 出站已更新"
}, },
"warp": { "warp": {
"changeIp": "更換 IP",
"changeIpSuccess": "WARP IP 更換成功!",
"autoUpdateIp": "自動更新 IP 位址",
"intervalDays": "間隔(天)",
"intervalDesc": "設為 0 停用。自動更換 IP 位址。",
"licenseError": "設定 WARP 授權失敗。", "licenseError": "設定 WARP 授權失敗。",
"fetchFirst": "請先取得 WARP 設定。", "fetchFirst": "請先取得 WARP 設定。",
"createAccount": "建立 WARP 帳號", "createAccount": "建立 WARP 帳號",
+3 -4
View File
@@ -299,6 +299,7 @@ func (s *Server) startTask(restartXray bool) {
// check client ips from log file every day // check client ips from log file every day
s.cron.AddJob("@daily", job.NewClearLogsJob()) s.cron.AddJob("@daily", job.NewClearLogsJob())
s.cron.AddJob("@hourly", job.NewWarpIpJob())
// Inbound traffic reset jobs // Inbound traffic reset jobs
// Run every hour // Run every hour
@@ -358,9 +359,8 @@ func (s *Server) Start() (err error) {
return s.start(true, true) return s.start(true, true)
} }
// StartPanelOnly initializes the panel during an in-process panel restart without cycling Xray.
func (s *Server) StartPanelOnly() (err error) { func (s *Server) StartPanelOnly() (err error) {
return s.start(false, false) return s.start(false, true)
} }
func (s *Server) start(restartXray bool, startTgBot bool) (err error) { func (s *Server) start(restartXray bool, startTgBot bool) (err error) {
@@ -465,9 +465,8 @@ func (s *Server) Stop() error {
return s.stop(true, true) return s.stop(true, true)
} }
// StopPanelOnly stops only panel-owned HTTP/background resources for an in-process panel restart.
func (s *Server) StopPanelOnly() error { func (s *Server) StopPanelOnly() error {
return s.stop(false, false) return s.stop(false, true)
} }
func (s *Server) stop(stopXray bool, stopTgBot bool) error { func (s *Server) stop(stopXray bool, stopTgBot bool) error {
+57 -4
View File
@@ -864,6 +864,7 @@ show_status() {
;; ;;
esac esac
show_xray_status show_xray_status
show_mtproto_status
} }
show_enable_status() { show_enable_status() {
@@ -897,6 +898,34 @@ show_xray_status() {
fi fi
} }
# show_mtproto_status reports each mtproto inbound's mtg sidecar (one process per
# inbound, run outside xray). Silent when no mtproto inbound is configured.
show_mtproto_status() {
local cfg_dir="${xui_folder}/bin/mtproto"
local cfgs=()
if [[ -d "${cfg_dir}" ]]; then
for f in "${cfg_dir}"/mtg-*.toml; do
[[ -e "$f" ]] && cfgs+=("$f")
done
fi
[[ ${#cfgs[@]} -eq 0 ]] && return
local running
running=$(ps -ef | grep "mtg-linux" | grep -v "grep" | grep -oE 'mtg-[0-9]+\.toml')
for f in "${cfgs[@]}"; do
local name id bind
name=$(basename "$f")
id=$(echo "${name}" | sed -E 's/mtg-([0-9]+)\.toml/\1/')
bind=$(grep -E '^[[:space:]]*bind-to' "$f" | head -1 | cut -d'"' -f2)
if echo "${running}" | grep -qx "${name}"; then
echo -e "mtproto inbound ${id} (${bind}): ${green}Running${plain}"
else
echo -e "mtproto inbound ${id} (${bind}): ${red}Not Running${plain}"
fi
done
echo -e " ${yellow}mtg logs:${plain} journalctl -u x-ui --no-pager -n 200 | grep -i mtproto"
}
firewall_menu() { firewall_menu() {
echo -e "${green}\t1.${plain} ${green}Install${plain} Firewall" echo -e "${green}\t1.${plain} ${green}Install${plain} Firewall"
echo -e "${green}\t2.${plain} Port List [numbered]" echo -e "${green}\t2.${plain} Port List [numbered]"
@@ -1181,7 +1210,7 @@ install_acme() {
ssl_cert_issue_main() { ssl_cert_issue_main() {
echo -e "${green}\t1.${plain} Get SSL (Domain)" echo -e "${green}\t1.${plain} Get SSL (Domain)"
echo -e "${green}\t2.${plain} Revoke" echo -e "${green}\t2.${plain} Revoke & Remove"
echo -e "${green}\t3.${plain} Force Renew" echo -e "${green}\t3.${plain} Force Renew"
echo -e "${green}\t4.${plain} Show Existing Domains" echo -e "${green}\t4.${plain} Show Existing Domains"
echo -e "${green}\t5.${plain} Set Cert paths for the panel" echo -e "${green}\t5.${plain} Set Cert paths for the panel"
@@ -1204,10 +1233,34 @@ ssl_cert_issue_main() {
else else
echo "Existing domains:" echo "Existing domains:"
echo "$domains" echo "$domains"
read -rp "Please enter a domain from the list to revoke the certificate: " domain read -rp "Please enter a domain from the list to revoke and remove the certificate: " domain
if echo "$domains" | grep -qw "$domain"; then if echo "$domains" | grep -qw "$domain"; then
~/.acme.sh/acme.sh --revoke -d ${domain} # The IP-cert flow (option 6) stores files under /root/cert/ip, but acme.sh
LOGI "Certificate revoked for domain: $domain" # tracks the cert under the actual IP address(es). Resolve those so renewal
# state is torn down too; otherwise the acme.sh cron re-creates the deleted cert.
local acme_ids="${domain}"
if [[ "${domain}" == "ip" ]]; then
acme_ids=$(~/.acme.sh/acme.sh --list 2> /dev/null | awk 'NR>1 {print $1}' | grep -E '^([0-9]{1,3}\.){3}[0-9]{1,3}$|:')
fi
for id in ${acme_ids}; do
# Best-effort revoke at the CA, then drop acme.sh renewal tracking.
~/.acme.sh/acme.sh --revoke -d "${id}" 2> /dev/null
~/.acme.sh/acme.sh --remove -d "${id}" 2> /dev/null
# --remove leaves the cert files on disk, so delete the state dirs (RSA + ECC).
rm -rf ~/.acme.sh/"${id}" ~/.acme.sh/"${id}_ecc"
done
# Delete the local certificate files for this domain.
rm -rf "/root/cert/${domain}"
LOGI "Certificate revoked and removed for domain: ${domain}"
# If the panel currently serves this domain's cert, clear the stored paths
# so it stops loading the now-deleted files, then restart.
local existing_cert=$(${xui_folder}/x-ui setting -getCert true | grep 'cert:' | awk -F': ' '{print $2}' | tr -d '[:space:]')
if [[ "${existing_cert}" == "/root/cert/${domain}/"* ]]; then
${xui_folder}/x-ui cert -reset
LOGI "Cleared panel certificate paths referencing ${domain}; restarting panel."
restart
fi
else else
echo "Invalid domain entered." echo "Invalid domain entered."
fi fi