Compare commits

...

61 Commits

Author SHA1 Message Date
MHSanaei
e7035b56fe fix: sync advancedJson before tab switch in convertLink 2026-05-14 11:46:07 +02:00
dependabot[bot]
5f526e5201 build(deps): bump actions/setup-node from 5 to 6 (#4368)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 5 to 6.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-14 11:11:12 +02:00
MHSanaei
bd8d33980f fix: ignore duplicate column errors during AutoMigrate on upgraded DBs
SQLite raises 'duplicate column name: <col>' when GORM tries to ADD a
column that already exists in an older schema (seen: allow_private_address,
node_id on the nodes table). This caused database initialisation to fail on
every restart after an upgrade.

The new isIgnorableDuplicateColumnErr helper skips the error only when:
  1. The error message matches 'duplicate column name: <col>'
  2. Migrator().HasColumn confirms the column is already present in the DB

Fresh databases and all other error types are unaffected.
2026-05-14 11:10:38 +02:00
MHSanaei
5dc02a9af3 v3.0.2 2026-05-14 10:27:33 +02:00
Abdalrahman
033c5993e0 feat: add API token to install output (#4322)
* feat: add API token to install output

Add -getApiToken flag to the setting subcommand so shell scripts
can retrieve the panel API token. Include the token in the
install.sh completion banner for automation/deployment use.

* fix(install): adapt -getApiToken CLI to multi-token service

settingService.GetApiToken was removed when API tokens moved to a
multi-row ApiTokenService. Switch the install-time CLI to list tokens
and create one named "install" if none exist, preserving the
`apiToken: <value>` output the install.sh grep depends on.

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-05-14 10:24:23 +02:00
MHSanaei
2204c8231d Adjust QR panel sizing and collapse JSON subscription by default 2026-05-14 10:23:27 +02:00
Abdalrahman
01a7dc807b fix(sub): include xhttp mode in extra JSON for karing compatibility (#4365)
- Add mode to buildXhttpExtra() so clients reading xtra param
  (karing, etc.) receive the xhttp mode alongside other bidirectional
  SplitHTTP fields. Previously mode was only a flat URL param and was
  silently dropped when xtra was present.
- Add xhttp case to streamData() to strip acceptProxyProtocol and
  server-only fields (noSSEHeader, scMaxBufferedPosts,
  scStreamUpServerSecs, serverMaxHeaderBytes) from JSON sub configs.
- Sync frontend buildXhttpExtra() with the same mode addition.

Closes #4364
2026-05-14 10:02:45 +02:00
Farhad H. P. Shirvan
6bf4a2c4f0 fix(docker): update port mapping for 3xui service in docker-compose (#4362) 2026-05-14 10:00:09 +02:00
MHSanaei
21058eb63c fix(routing): make rule drag-and-drop work on mobile cards
The pointermove handler looked up the drop target via
el.closest('tr[data-row-key]'). That selector only matches the
desktop a-table rows; the mobile branch renders each rule as a
<div class="rule-card" data-row-key>, so on phones the lookup
always returned null, dropTargetIndex stayed pinned to the start
index, and the eventual drop was a no-op. Loosened the selector
to [data-row-key] so both DOM shapes resolve.
2026-05-14 02:04:05 +02:00
Black
194de8869e feat(panel): add 'Edit' button to tables and enhance layout (#4355)
- Move 'Edit' button from dropdown to the table since it's the most used action. Only for desktop.
- Increase column widths for action keys in Inbounds, Balancers, Outbounds and Routing tables.
- Slightly enhance layout for consistency.
2026-05-14 01:55:00 +02:00
MHSanaei
26accfd8f7 fix(qr): lock QR code modules to black-on-white across all themes
AntD's <a-qrcode> defaults the module color to the active theme's
text token. Under the dark and ultra-dark themes that text is a light
gray, so the QR rendered low-contrast on the white canvas background
and phones could not lock onto it. Pinned color="#000000" and
bg-color="#ffffff" on every <a-qrcode> usage (share links in
QrPanel, 2FA enrollment in TwoFactorModal, sub/json/clash codes on
SubPage) so the contrast stays high regardless of panel theme.
2026-05-14 01:45:00 +02:00
MHSanaei
2551a673c3 fix(inbounds): refresh client rows live over websocket
Two bugs combined to leave per-client traffic / remained / all-time
columns stuck at stale numbers while only the inbound-level row and
the online badge refreshed:

1. Backend (xray + node sync traffic jobs) only included the per-client
   array in the client_stats broadcast when activeEmails / touched
   was non-empty. Cycles with no client deltas — or any node sync that
   failed to fetch a snapshot — shipped only the inbound summary, so
   the frontend had nothing to merge for clients. Replaced both code
   paths with a single GetAllClientTraffics() snapshot per cycle; the
   broadcast now always carries the full client list.

2. Frontend mutated dbInbound.clientStats[i] in place. DBInbound is a
   plain class instance (not wrapped in reactive()), so Vue could not
   see the field-level changes and ClientRowTable's statsMap computed
   stayed cached forever. Added a statsVersion tick bumped on every
   merge and read inside statsMap so the computed re-evaluates and the
   template pulls fresh up/down/allTime/expiryTime each push.

Removed the now-dead emailSet helper from node_traffic_sync_job and
the activeEmails filter from xray_traffic_job.
2026-05-14 01:31:49 +02:00
MHSanaei
ce4c42e09c feat(json): swap raw textareas for a CodeMirror 6 JsonEditor
A new JsonEditor.vue component wraps CodeMirror 6 + lang-json with
line numbers, JSON syntax highlighting, bracket matching, code
folding, search (Ctrl+F), undo/redo, lint (red squiggle and gutter
icon on invalid JSON), tab indent, and line wrapping. It is wired
into the four raw-JSON spots that previously used <a-textarea
class="json-editor">: the Xray Advanced Template tab, the Outbound
JSON tab, the Balancer Observatory pane, and the Inbound Advanced
tab (settings / streamSettings / sniffing).

Chrome colors are driven by EditorView.theme so they win the
specificity fight cleanly against CodeMirror's own injected styles.
A single buildDarkTheme() factory yields a Dark+ palette (#1e1e1e
background, #252526 active line, #2d2d30 panels) for the regular
dark mode and a near-black variant (#0a0a0a / #141414 / #1f1f1f
border) for ultra-dark — both pair with oneDarkHighlightStyle for
the syntax colors. Light mode stays on basicSetup's default.

CodeMirror lazy-loads as a ~17 kB gzipped chunk that only appears
on the Xray/Inbounds bundles.
2026-05-14 00:02:59 +02:00
MHSanaei
18614bd6ea feat(tabs): collapse settings and xray tab bars to evenly-spread icons
On phones the five Settings tabs and six Xray tabs overflowed the
viewport. Now the tab labels are stripped (v-if="!isMobile"), the
nav-list stretches to full width via display:flex + width:100%, and
each tab claims an equal share with flex:1 1 0 so the icons spread
across the row instead of bunching. Icons bumped to 18px with a
tooltip carrying the original label for discoverability.
2026-05-13 23:33:50 +02:00
MHSanaei
e564c9283d feat(nodes): mobile card list, info modal, and tighter summary layout
NodeList now branches on isMobile: a vertical card list mirrors the
inbound mobile redesign — status dot + name + an Info icon that opens
an a-modal with the full per-node stats (address, status, CPU/mem,
xray version, uptime, latency, last heartbeat). The card head expands
to surface NodeHistoryPanel inline (parity with the desktop expandable
row), and the more-dropdown carries probe/edit/delete.

NodesPage also gets two layout fixes: an 8px vertical gutter between
the summary card and the node list on mobile (was 0), and a 2x2 grid
for the four summary statistics on phones via :xs="12" plus a 16px
inner vertical gutter, so Total/Online/Offline/Avg Latency no longer
crowd each other.
2026-05-13 23:14:56 +02:00
MHSanaei
933567d423 feat(inbounds): collapse mobile cards to id/email + info button
Mobile inbound cards now show only #id and remark; mobile client cards
show only the status badge and email. The full stat grid (protocol,
port, node, traffic, all-time, clients, expiry — and per-client
remained/online/expiry) moves behind a new info icon that opens an
a-modal, so the list stays scannable on small screens.
2026-05-13 23:03:19 +02:00
MHSanaei
771bc7c8ef feat(inbounds): align tunnel, tun, and hysteria UI with Xray docs
* tunnel: rename settings to Xray's current schema (address →
  rewriteAddress, port → rewritePort, network → allowedNetwork) in
  the model, form modal, info modal, and the bundled API inbound
  template; expose portMap so per-port forwarding can be configured
  from the panel.
* tun: add the full TUN protocol form and read-only info blocks
  (name, mtu, gateway, dns, userLevel, autoSystemRoutingTable,
  autoOutboundsInterface) — previously the protocol was selectable
  but the form rendered blank.
* hysteria: surface the stream-level version, obfs password, and
  udpIdleTimeout fields that the model already supported.

Refs https://xtls.github.io/config/inbounds/tunnel.html
Refs https://xtls.github.io/config/inbounds/tun.html
Refs https://xtls.github.io/config/transports/hysteria.html
2026-05-13 22:44:08 +02:00
MHSanaei
61ab602887 fix(iplog): parse xray access-log timestamps in local time
Xray writes access-log timestamps in the server's local timezone, but
time.Parse interpreted them as UTC, shifting the stored unix epoch by
the host offset. The panel rendered the epoch back to local time, so
CST users saw IP-log times 8 hours in the future. Parse the log
timestamp with time.ParseInLocation(time.Local) so it round-trips.

Fixes #4147
2026-05-13 21:50:16 +02:00
MHSanaei
adc262a238 fix(warp): set license against Cloudflare API and surface errors inline
The license update was always failing because the Cloudflare response has
no `success` field — the check rejected every successful PUT. On real
errors (e.g. "Too many connected devices."), the toast leaked the raw URL
+ JSON body. Now the WARP API's error envelope is parsed into a clean
message and shown inline next to the Update button.
2026-05-13 21:13:16 +02:00
Vladislav Kasperov
67b098dfd3 Add possibility to remove client email from sub (#4297) 2026-05-13 19:04:17 +02:00
MHSanaei
5543466fcc fix(forms): validate JSON tabs before applying or saving
InboundFormModal: switching out of the Advanced tab now parses the three
JSON textareas and rebuilds the structured Inbound via Inbound.fromJson,
so the Basic tab reflects what was pasted. Invalid JSON keeps the user
on Advanced with a specific parse error.

XrayPage: Save now parses xraySetting upfront and snaps the user back to
the Advanced tab on invalid JSON instead of letting the backend reject a
generic blob.
2026-05-13 19:01:12 +02:00
MHSanaei
b10a9f1de7 fix(inbounds): hide node UI when no enabled node exists
The Deploy-to selector, node column, node stat row, and node filter all
appeared whenever a node row existed in the DB. Local-only deployments
with no nodes (or only disabled nodes) saw a dropdown that only had
"Local Panel" and a filter that did nothing.

useNodeList now exposes hasActive (any node with enable === true).
Inbounds form and list gate node UI on hasActive instead of map size.
2026-05-13 17:42:40 +02:00
Amirmohammad Sadat Shokouhi
4399fe2a85 add log rotate to 3xui.log file to avoid disk space consumption (#4277)
* add log rotate to 3xui.log file to avoid disk space consumption
2026-05-13 17:03:56 +02:00
MHSanaei
6c6b40e063 fix(outbound): accept JSON-only configs and sync JSON to basic form on tab switch
Pasting a JSON config and clicking OK failed with "Something went wrong"
because validation read the empty form-side tag input instead of the
JSON's tag. Switching from the JSON tab to Basic also discarded any
JSON the user had pasted.

- onOk now validates and submits from the JSON tab using the parsed JSON
- Tab switch JSON→Basic deserializes the JSON back into the structured form
- Invalid JSON keeps the user on the JSON tab with a clear parse error
- Empty form-tag / duplicate-tag errors are now specific, not generic
2026-05-13 16:48:16 +02:00
MHSanaei
b97ff40ad6 feat(api-tokens): manage multiple named tokens; add tab/section anchor URLs
Replace the single regenerable API token with a named-token list:
- New ApiToken model + service with constant-time auth matching
- Seeder migrates the legacy `apiToken` setting into a "default" row
- Security tab gets create/enable/delete UI; api-docs page links to it
- Dedicated "API Tokens" section in the in-panel docs

URL anchors now reflect the active tab/section on Settings, Xray, and
API Docs pages, so deep links like `/panel/settings#security` work.

Translations for the 8 new SecurityTab strings added across all locales.
2026-05-13 16:34:31 +02:00
MHSanaei
46b6f8c66c feat(routing): drag-reorder rules, split balancer column, mobile card layout
- Grip-handle drag-and-drop on the # cell to reorder rules, built on
  Pointer Events so the same code works for mouse, touch, and pen
  (HTML5 drag doesn't fire from touch on iOS Safari). 5px threshold
  keeps quick taps from triggering a reorder; up/down arrow menu
  items stay as a keyboard/a11y fallback. Drop indicator is a 2px
  blue line on the target edge; dragged row fades to 40%.
- Split the old combined target column into Outbounds and Balancer
  columns. Each row now has exactly one populated cell — green
  outbound tag or purple balancer tag.
- Mobile drops the a-table (520px+ of column widths overflowed every
  phone) for a stacked card layout: # + grip + actions on top, an
  "Inbound → Outbound/Balancer" flow row in the middle, and criteria
  chips (domain, IP, port, src IP/port, L4, protocol, user, VLESS)
  below for whichever fields are actually set. Multi-value chips
  collapse to "first +N" with full value on hover.
2026-05-13 15:30:25 +02:00
Abdalrahman
102df7a290 style(api-docs): redesign TOC, section icons, endpoint rows, and code blocks with ultra-dark support (#4332)
* style(api-docs): redesign TOC, section icons, endpoint rows, and code blocks with ultra-dark support

* style(api-docs): rename visibleSections to visibleEndpoints, drop dead toc-stuck CSS

- visibleSections counted endpoints, not sections — rename matches
  the displayed "X / Y endpoints" label.
- .toc-nav.toc-stuck was never toggled by any code path.

* docs(api): add missing POST /panel/api/inbounds/:id/resetTraffic entry

This route was added in #4334/#4338 but endpoints.js wasn't updated,
breaking TestAPIRoutesDocumented (91 routes in source, 90 documented).

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-05-13 15:05:23 +02:00
Abdalrahman
f29c8a5e29 fix: single inbound traffic reset resets all inbounds (#4334) (#4338) 2026-05-13 14:49:54 +02:00
Abdalrahman
ad81649c16 fix: strip main-panel TLS cert file paths when sending inbound to remote node (#4339)
When the main panel creates an inbound assigned to a remote node,
the wireInbound helper sends StreamSettings as-is, including
certificateFile/keyFile paths that only exist on the main panel's
filesystem. The remote node's Xray then fails to load them and crashes.

This adds sanitizeStreamSettingsForRemote() which strips file-based
cert paths before forwarding to a remote node. Inline certificate
content (certificate/key) is preserved unchanged.

Closes #4335
2026-05-13 14:47:09 +02:00
Abdalrahman
b47f794ed0 fix: reality random target/sni buttons not working (#4337) (#4340) 2026-05-13 14:42:20 +02:00
MHSanaei
4e1b597914 feat(ui): use the host as the browser tab title prefix 2026-05-13 14:23:57 +02:00
MHSanaei
bbefe91011 fix(auth): invalidate sessions when 2FA is enabled, fix dev 401 loop
Add UserService.BumpLoginEpoch and call it from updateSetting when
TwoFactorEnable flips false → true. Existing cookies (issued under
the looser no-2FA policy) get a 401 on their next request and are
forced through the login flow. Disabling 2FA is a relaxation and
does not bump the epoch — sessions stay valid.

Also fix the dev-mode 401 redirect: targeting `${basePath}login.html`
breaks when basePath isn't "/" (Vite has no file at e.g.
"/test/login.html"; the SPA fallback loops the 401). Navigate to
basePath instead — Vite's bypassMigratedRoute and Go's index
handler both serve login.html for that path.

Strip stale doc-comment from netsafe and IndexController.logout
in line with the project's no-inline-comments convention.
2026-05-13 14:08:16 +02:00
MHSanaei
e40554a7d5 fix(inbound): require email when adding or updating a client
AddInboundClient and UpdateInboundClient previously accepted an
empty Email field for every protocol except shadowsocks (where
email doubles as the client ID). Empty emails break downstream
features that key off email — IP-limit logging, traffic stats,
client-online tracking, subscription remarks.

Reject empty/whitespace-only emails at the service layer so the
API surface (POST /panel/api/inbounds/addClient and
/updateClient/:id) returns a clear error instead of persisting
an unidentifiable client.

Also drop the stale `len(Email) > 0` guard in UpdateInboundClient
that became dead code once empty emails are rejected.
2026-05-13 13:45:31 +02:00
MHSanaei
3569b1be73 ci(codeql): run on push to main 2026-05-13 13:39:32 +02:00
MHSanaei
38da210ded fix(security): SSRF-guard node and remote HTTP clients
The Node.Probe and Remote.do paths built outbound URLs by string-
formatting admin-controlled fields (Scheme/Address/Port/BasePath)
straight into requests, then dialed the result with the default
transport. CodeQL flagged this as go/request-forgery — an admin
(or anyone who compromises the admin account) could point a node
at internal infrastructure (cloud metadata, RFC1918 ranges, etc.)
and the panel would dutifully fetch it.

Add util/netsafe with a shared TOCTOU-safe DialContext that
resolves the host, rejects private/internal IPs unless the
per-request context whitelists them (per-node AllowPrivateAddress
flag, plumbed through context.Value), and dials the resolved IP
directly so the IP that passed the check is the IP we connect to.
This closes the DNS-rebinding window where a hostname could
resolve to a public IP at check time and a private one at dial.

Also tighten address validation (NormalizeHost rejects anything
that isn't a bare hostname or IP literal — no embedded paths,
userinfo, schemes) and switch URL construction from fmt.Sprintf to
url.URL{} + net.JoinHostPort so admin-supplied values can't smuggle
URL components.

custom_geo.go's isBlockedIP now delegates to netsafe so there's
one source of truth.
2026-05-13 13:33:53 +02:00
MHSanaei
9fc47b3d41 ci: gate workflows on relevant source paths
- ci.yml: only run on Go/frontend source and lockfiles.
- codeql.yml: scope push/PR triggers to Go and JS/TS sources;
  weekly cron still does a full scan.
- release.yml: add matching paths allowlist to pull_request so
  doc/workflow-only PRs don't kick off the multi-arch build.

Skips workflow runs on changes to docs, translations, GitHub
configs, and unrelated scripts.
2026-05-13 13:21:26 +02:00
MHSanaei
210c25cf13 Bump Go module dependency versions
Routine update of Go module dependencies and tidy: bump indirect deps (github.com/quic-go/quic-go v0.59.0→v0.59.1, github.com/sagernet/sing v0.8.9→v0.8.10, github.com/tklauser/go-sysconf v0.3.16→v0.4.0, github.com/tklauser/numcpus v0.11.0→v0.12.0), and update several golang.org/x modules (arch, exp, mod, net, tools) and google.golang.org/genproto. Removed duplicate require entries (x/crypto, x/sys, x/text) and updated go.sum to match the new versions.
2026-05-13 13:04:44 +02:00
dependabot[bot]
5dd7e44594 build(deps): bump golang.org/x/text from 0.36.0 to 0.37.0 (#4345)
Bumps [golang.org/x/text](https://github.com/golang/text) from 0.36.0 to 0.37.0.
- [Release notes](https://github.com/golang/text/releases)
- [Commits](https://github.com/golang/text/compare/v0.36.0...v0.37.0)

---
updated-dependencies:
- dependency-name: golang.org/x/text
  dependency-version: 0.37.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-05-13 13:00:02 +02:00
dependabot[bot]
4e4a8e9ff7 build(deps): bump golang.org/x/crypto from 0.50.0 to 0.51.0 (#4344)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.50.0 to 0.51.0.
- [Commits](https://github.com/golang/crypto/compare/v0.50.0...v0.51.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.51.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-05-13 12:58:42 +02:00
dependabot[bot]
23970e72a7 build(deps): bump golang.org/x/sys from 0.43.0 to 0.44.0 (#4343)
Bumps [golang.org/x/sys](https://github.com/golang/sys) from 0.43.0 to 0.44.0.
- [Commits](https://github.com/golang/sys/compare/v0.43.0...v0.44.0)

---
updated-dependencies:
- dependency-name: golang.org/x/sys
  dependency-version: 0.44.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-13 12:57:07 +02:00
dependabot[bot]
8bdb093d6e build(deps): bump actions/setup-node from 5 to 6 (#4342)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 5 to 6.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-05-13 12:56:51 +02:00
dependabot[bot]
3b0bcb910e build(deps): bump actions/checkout from 5 to 6 (#4341)
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-13 12:56:08 +02:00
Farhad H. P. Shirvan
428f1333ac Security hardening: sessions, SSRF, CSP nonce, CSRF logout, trusted proxies (#4275)
* refactor(session): store user ID in session instead of full struct

Replaces storing the full User object in the session cookie with just
the user ID. GetLoginUser now re-fetches the user from the database on
every request so credential/permission changes take effect immediately
without requiring a re-login. Includes a backward-compatible migration
path for existing sessions that still carry the old struct payload.

* feat(auth): block panel with default admin/admin credentials and guide credential change

checkLogin middleware now detects default admin/admin credentials and
redirects every panel route to /panel/settings until they are changed.
The settings page auto-opens the Authentication tab, shows a
non-dismissible error banner, and lists 'Default credentials' first in
the security checklist. Login response includes mustChangeCredentials
so the login page can redirect directly. Logout is now POST-only.
Password must be at least 10 characters and cannot be admin/admin.

* feat(settings): redact secrets in AllSettingView and add TrustedProxyCIDRs

Introduces AllSettingView which strips tgBotToken, twoFactorToken,
ldapPassword, apiToken and warp/nord secrets before sending them to
the browser, replacing them with boolean hasFoo presence flags. A new
/panel/setting/secret endpoint allows updating individual secrets by
key. Secrets that arrive blank on a save are preserved from the DB
rather than overwritten. Adds TrustedProxyCIDRs as a configurable
setting (defaults to localhost CIDRs). URL fields are validated before
save.

* fix(security): SSRF prevention, trusted-proxy header gating, CSP nonce, HTTP timeouts

Adds SanitizeHTTPURL / SanitizePublicHTTPURL to reject private-range
and loopback targets before any outbound HTTP request (node probe,
xray download, outbound test, external traffic inform, tgbot API
server, panel updater). Forwarded headers (X-Real-IP, X-Forwarded-For,
X-Forwarded-Host) are now only trusted when the direct connection
arrives from a CIDR in TrustedProxyCIDRs. CSP policy is tightened with
a per-request nonce. HTTP server gains read/write/idle timeouts. Panel
updater downloads the script to a temp file instead of piping curl into
shell. Xray archive download adds a size cap and response-code check.
backuptotgbot is changed from GET to POST.

* feat(nodes): add allow-private-address toggle per node

Adds AllowPrivateAddress to the Node model (DB default false). When
enabled it bypasses the SSRF private-range check for that node's probe
URL, allowing nodes hosted on RFC-1918 or loopback addresses (e.g.
a private VPN or LAN setup).

* chore: frontend UX improvements, CI pipeline, and dev tooling

- AppSidebar: logout via POST /logout instead of navigating to GET
- InboundList: persist filter state (search, protocol, node) to
  localStorage across page reloads; add protocol and node filter dropdowns
- IndexPage: add health status strip (Xray, CPU, Memory, Update) with
  quick-action buttons
- dependabot: weekly go mod and npm update schedule
- ci.yml: add GitHub Actions workflow for build and vet
- .nvmrc: pin Node 22 for local development
- frontend: bump package.json and package-lock.json
- SubPage, DnsPresetsModal, api-docs: minor fixes

* fix(ci): stub web/dist before go list to satisfy go:embed at compile time

* chore(ui): remove health-strip bar from dashboard top

* Revert "feat(auth): block panel with default admin/admin credentials and guide credential change"

This reverts commit 56ce6073ce.

* fix(auth): make logout POST+CSRF and propagate session loss to other tabs

- Switch /logout from GET to POST with CSRFMiddleware so it matches the
  SPA's existing HttpUtil.post('/logout') call (previously 404'd silently)
  and blocks GET-based logout via image tags or link prefetchers. Handler
  now returns JSON; the SPA already navigates client-side.
- Return 401 (instead of 404) from /panel/api/* when the caller is a
  browser XHR (X-Requested-With: XMLHttpRequest) so the axios interceptor
  redirects to the login page on logout-in-another-tab, cookie expiry,
  and server restart. Anonymous callers still get 404 to keep endpoints
  hidden from casual scanners.
- One-shot the 401 redirect in axios-init.js and hang the rejected
  promise so queued polls don't stack reloads or surface error toasts
  while the browser is navigating away.
- Add the CSP nonce to the runtime-injected <script> in dist.go so the
  panel loads under the existing script-src 'nonce-...' policy.
- Update api-docs endpoints.js: GET /logout doc entry was missing.

* fix(settings): POST /logout after credential change

* fix(auth): invalidate other sessions when credentials change

When the admin changes username/password from one machine, sessions
on every other machine kept working until they manually logged out
because session storage is a signed client-side cookie — there is
no server-side session list to revoke.

Add a per-user LoginEpoch counter stamped into the session at login
and re-verified on every authenticated request. UpdateUser and
UpdateFirstUser bump the epoch (UpdateUser via gorm.Expr so a single
update statement is atomic), so any cookie issued before the change
no longer matches the user's current epoch and GetLoginUser returns
nil — the SPA's 401 interceptor then redirects to the login page.

Backward compatible: the column defaults to 0 and missing cookie
values are treated as 0, so sessions issued before this change
remain valid until the first credential update.

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-05-13 12:52:52 +02:00
MHSanaei
406cb6dbc0 fix(api-docs): resolve no-useless-escape lint errors
- endpoints.js: replace `\"` with `\\"` in xray response example so the
  rendered docs actually show escaped JSON-in-JSON (the original
  single-quoted `\"` collapsed to a bare `"` and produced malformed output).
- CodeBlock.vue: drop the unnecessary `\[` inside the regex character
  class `[{}\[\]]`; `[` does not need escaping inside `[...]`.
2026-05-13 11:31:34 +02:00
Aleksandr
5fb36d34c9 fix(fail2ban): escape percent signs in 3x-ipl datepattern (#4328)
* Update DockerEntrypoint.sh

fix(fail2ban): escape percent signs in Docker datepattern

* Update x-ui.sh

fix(fail2ban): escape percent signs in x-ui datepattern
2026-05-13 01:49:09 +02:00
Abdalrahman
4884a2972a fix(graphs): increase y-axis paddingLeft from 32 to 56 to prevent clipped labels (#4309) 2026-05-13 01:47:54 +02:00
Abdalrahman
6e12329d9d feat(api-docs): enhance in-panel API documentation (#4312)
* feat(api-docs): enhance API documentation with missing endpoints, search, collapse, and route sync test

- Add 29 undocumented routes across 4 new sections (Settings, Xray Settings,
  Subscription Server, WebSocket) plus 4 missing Server API endpoints
- Fix inaccuracies: history metric keys, node metric keys, VLESS enc description
- Add response schemas to 15+ key endpoints
- Add search bar and expand/collapse all controls to the docs page
- Add collapsible endpoint sections with endpoint count
- Add Go test (TestAPIRoutesDocumented) to verify all Go routes are documented

* feat(api-docs): add JSON syntax highlighting and top-right copy button to code blocks

* fix(api-docs): use distinct colors for JSON syntax highlighting (green strings, amber numbers)

* feat(api-docs): add request body examples, error responses, WebSocket message types, and subscription response headers

* fix(api-docs): use ClipboardManager.copyText instead of copy to fix API token copy button
2026-05-13 01:47:09 +02:00
Abdalrahman
9f7e8178d4 fix: delete button missing after searching for a user (#4315)
When searching for a user, the projected DBInbound only contains the
matching clients, so isRemovable evaluated to alse (since a single
match made clients.value.length === 1), hiding the Delete button.

Pass the original total client count from the parent's clientCount
prop and use it in the isRemovable check instead of the projected
clients array length.
2026-05-13 01:27:10 +02:00
Abdalrahman
60e6b12f4c fix(hysteria2): restore missing masquerade config in inbound form (#4316)
* fix(hysteria2): restore missing masquerade config in inbound form

Fixes #4303

The Hysteria2 Masquerade option was missing from the Stream settings
tab after the v3.0.0 form rewrite. Added the UI form and ensured the
masquerade block is passed through in subscription JSON generation.
2026-05-13 01:25:00 +02:00
Abdalrahman
0dbadf82c0 fix: auto-renew must re-enable client in inbound settings JSON (#4317)
Since v2.9.4, disableInvalidClients sets c['enable']=false in inbound settings JSON when a client hits its limit. autoRenewClients only updated client_traffics.enable - never flipped the JSON field back. The Xray config generator checks both, so client stayed excluded after renewal.
2026-05-13 01:15:52 +02:00
Abdalrahman
48e90bba51 fix: show UDP tag for Hysteria and fix client count spacing (#4318) 2026-05-13 01:12:25 +02:00
Abdalrahman
6de9b24229 fix: preserve space between date and time in log modal (#4326)
Vue 3's whitespace: condense strips bare whitespace text nodes and
trailing whitespace inside elements, causing the &lt;template&gt; trick
to fail. Use mustache interpolations (which compile to _createTextVNode)
for all spacing between fields so they survive compilation.
2026-05-13 01:02:48 +02:00
MHSanaei
07bc74a521 feat(nodes): blur address column with eye-toggle, mirroring IndexPage IP card 2026-05-12 12:38:38 +02:00
MHSanaei
f570b991e7 fix(api-docs): copy API token button 2026-05-12 12:34:22 +02:00
MHSanaei
80031e67cc feat(inbounds): restore copy-clients-between-inbounds modal
The menu item, backend endpoint (POST /panel/api/inbounds/:id/copyClients),
and i18n keys were already in place after the Vue3 migration, but the modal
itself was never ported — clicking the menu just toasted "coming soon".

Adds CopyClientsModal.vue: source inbound dropdown (multi-user inbounds
except the target), per-client checkbox selection via a-table row-selection,
optional Flow override when the target supports TLS flow, and result toasts
for added/skipped/errors.
2026-05-12 12:30:07 +02:00
Farhad H. P. Shirvan
fdaa65ad7e Feat: clarify VLESS encryption auth selection (#4271)
* feat(traffic_writer): enhance traffic writer with concurrency safety and state management

* Revert "feat(traffic_writer): enhance traffic writer with concurrency safety and state management"

This reverts commit e6760ae396.

* feat(vless): clarify VLESS encryption auth selection and enhance parsing logic
2026-05-12 11:39:28 +02:00
Farhad H. P. Shirvan
d86e87ed30 Fix: traffic writer restart freeze (#4265)
* feat(traffic_writer): enhance traffic writer with concurrency safety and state management

* Revert "feat(traffic_writer): enhance traffic writer with concurrency safety and state management"

This reverts commit e6760ae396.

* feat(traffic_writer): enhance traffic writer with concurrency safety and state management

* feat(web): implement panel-only start/stop methods for in-process restarts
2026-05-12 11:36:05 +02:00
Abdalrahman
89a8f549f2 feat: sortable inbounds table columns (#4300) 2026-05-12 11:29:32 +02:00
MHSanaei
355bb4c9c0 feat(panel): xray metrics dashboard with observatory probe history
Polls xray's /debug/vars on the 2s status tick, stores memstats and per-outbound observatory delay in the metric history ring buffer, and exposes them through a new XrayMetricsModal opened from the Charts card. Restructures the dashboard to consolidate uptime, usage, version, and Telegram link into stat-style or action-style cards consistent with the existing AntD aesthetic.
2026-05-12 02:17:45 +02:00
MHSanaei
9feeccffc0 fix(node): normalize base path during probe so missing trailing slash doesn't break status checks 2026-05-12 00:27:49 +02:00
MHSanaei
cb962175c2 update translation 2026-05-11 20:47:49 +02:00
129 changed files with 7236 additions and 1283 deletions

View File

@@ -9,3 +9,11 @@ updates:
directory: "/" # Location of package manifests
schedule:
interval: "weekly"
- package-ecosystem: "gomod"
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/frontend"
schedule:
interval: "weekly"

91
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,91 @@
name: CI
on:
pull_request:
paths:
- "**.go"
- "go.mod"
- "go.sum"
- "**.js"
- "**.mjs"
- "**.cjs"
- "**.ts"
- "**.vue"
- "**.html"
- "**.css"
- "frontend/package.json"
- "frontend/package-lock.json"
- ".nvmrc"
push:
branches:
- main
paths:
- "**.go"
- "go.mod"
- "go.sum"
- "**.js"
- "**.mjs"
- "**.cjs"
- "**.ts"
- "**.vue"
- "**.html"
- "**.css"
- "frontend/package.json"
- "frontend/package-lock.json"
- ".nvmrc"
permissions:
contents: read
jobs:
go-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: Stub web/dist for go:embed
run: mkdir -p web/dist && touch web/dist/.gitkeep
- name: Test
run: |
go list ./... | grep -v '/frontend/node_modules/' > /tmp/go-packages.txt
go test $(cat /tmp/go-packages.txt)
govulncheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: Stub web/dist for go:embed
run: mkdir -p web/dist && touch web/dist/.gitkeep
- name: Install govulncheck
run: go install golang.org/x/vuln/cmd/govulncheck@latest
- name: Run govulncheck
run: govulncheck ./...
frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install
run: npm ci
working-directory: frontend
- name: Lint
run: npm run lint
working-directory: frontend
- name: Build
run: npm run build
working-directory: frontend
- name: Audit
run: npm audit --audit-level=high
working-directory: frontend

View File

@@ -2,9 +2,31 @@ name: "CodeQL Advanced"
on:
push:
branches:
- main
tags-ignore:
- "v*"
paths:
- "**.go"
- "go.mod"
- "go.sum"
- "**.js"
- "**.mjs"
- "**.cjs"
- "**.ts"
- "**.vue"
- "frontend/package-lock.json"
pull_request:
paths:
- "**.go"
- "go.mod"
- "go.sum"
- "**.js"
- "**.mjs"
- "**.cjs"
- "**.ts"
- "**.vue"
- "frontend/package-lock.json"
schedule:
- cron: "18 2 * * 2"
@@ -35,9 +57,6 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v6
# The Go binary embeds web/dist/ via //go:embed all:dist (web/web.go).
# web/dist/ is .gitignored, so CodeQL's autobuild for Go will fail with
# "pattern all:dist: no matching files found" unless vite emits it first.
- name: Setup Node.js
if: matrix.language == 'go'
uses: actions/setup-node@v6

View File

@@ -19,6 +19,17 @@ on:
- "x-ui.service.arch"
- "x-ui.service.rhel"
pull_request:
paths:
- "**.js"
- "**.css"
- "**.html"
- "**.sh"
- "**.go"
- "go.mod"
- "go.sum"
- "x-ui.service.debian"
- "x-ui.service.arch"
- "x-ui.service.rhel"
jobs:
build:

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
22

View File

@@ -22,7 +22,7 @@ EOF
cat > /etc/fail2ban/filter.d/3x-ipl.conf << 'EOF'
[Definition]
datepattern = ^%Y/%m/%d %H:%M:%S
datepattern = ^%%Y/%%m/%%d %%H:%%M:%%S
failregex = \[LIMIT_IP\]\s*Email\s*=\s*<F-USER>.+</F-USER>\s*\|\|\s*Disconnecting OLD IP\s*=\s*<ADDR>\s*\|\|\s*Timestamp\s*=\s*\d+
ignoreregex =
EOF

View File

@@ -1 +1 @@
3.0.1
3.0.2

View File

@@ -10,6 +10,7 @@ import (
"os"
"path"
"slices"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v3/config"
@@ -40,9 +41,14 @@ func initModels() error {
&model.HistoryOfSeeders{},
&model.CustomGeoResource{},
&model.Node{},
&model.ApiToken{},
}
for _, model := range models {
if err := db.AutoMigrate(model); err != nil {
for _, mdl := range models {
if err := db.AutoMigrate(mdl); err != nil {
if isIgnorableDuplicateColumnErr(err, mdl) {
log.Printf("Ignoring duplicate column during auto migration for %T: %v", mdl, err)
continue
}
log.Printf("Error auto migrating model: %v", err)
return err
}
@@ -50,6 +56,27 @@ func initModels() error {
return nil
}
func isIgnorableDuplicateColumnErr(err error, mdl any) bool {
if err == nil {
return false
}
errMsg := strings.ToLower(err.Error())
const dupPrefix = "duplicate column name:"
if !strings.Contains(errMsg, dupPrefix) {
return false
}
idx := strings.Index(errMsg, dupPrefix)
if idx < 0 {
return false
}
col := strings.TrimSpace(errMsg[idx+len(dupPrefix):])
col = strings.Trim(col, "`\"[]")
if col == "" {
return false
}
return db != nil && db.Migrator().HasColumn(mdl, col)
}
// initUser creates a default admin user if the users table is empty.
func initUser() error {
empty, err := isTableEmpty("users")
@@ -86,43 +113,80 @@ func runSeeders(isUsersEmpty bool) error {
hashSeeder := &model.HistoryOfSeeders{
SeederName: "UserPasswordHash",
}
return db.Create(hashSeeder).Error
} else {
var seedersHistory []string
if err := db.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &seedersHistory).Error; err != nil {
log.Printf("Error fetching seeder history: %v", err)
if err := db.Create(hashSeeder).Error; err != nil {
return err
}
return seedApiTokens()
}
var seedersHistory []string
if err := db.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &seedersHistory).Error; err != nil {
log.Printf("Error fetching seeder history: %v", err)
return err
}
if !slices.Contains(seedersHistory, "UserPasswordHash") && !isUsersEmpty {
var users []model.User
if err := db.Find(&users).Error; err != nil {
log.Printf("Error fetching users for password migration: %v", err)
return err
}
if !slices.Contains(seedersHistory, "UserPasswordHash") && !isUsersEmpty {
var users []model.User
if err := db.Find(&users).Error; err != nil {
log.Printf("Error fetching users for password migration: %v", err)
for _, user := range users {
hashedPassword, err := crypto.HashPasswordAsBcrypt(user.Password)
if err != nil {
log.Printf("Error hashing password for user '%s': %v", user.Username, err)
return err
}
for _, user := range users {
hashedPassword, err := crypto.HashPasswordAsBcrypt(user.Password)
if err != nil {
log.Printf("Error hashing password for user '%s': %v", user.Username, err)
return err
}
if err := db.Model(&user).Update("password", hashedPassword).Error; err != nil {
log.Printf("Error updating password for user '%s': %v", user.Username, err)
return err
}
if err := db.Model(&user).Update("password", hashedPassword).Error; err != nil {
log.Printf("Error updating password for user '%s': %v", user.Username, err)
return err
}
}
hashSeeder := &model.HistoryOfSeeders{
SeederName: "UserPasswordHash",
}
return db.Create(hashSeeder).Error
hashSeeder := &model.HistoryOfSeeders{
SeederName: "UserPasswordHash",
}
if err := db.Create(hashSeeder).Error; err != nil {
return err
}
}
if !slices.Contains(seedersHistory, "ApiTokensTable") {
if err := seedApiTokens(); err != nil {
return err
}
}
return nil
}
// seedApiTokens copies the legacy `apiToken` setting into the new
// api_tokens table as a row named "default" so existing central panels
// keep working after the upgrade. Idempotent — records itself in
// history_of_seeders and only runs when api_tokens is empty.
func seedApiTokens() error {
empty, err := isTableEmpty("api_tokens")
if err != nil {
return err
}
if empty {
var legacy model.Setting
err := db.Model(model.Setting{}).Where("key = ?", "apiToken").First(&legacy).Error
if err == nil && legacy.Value != "" {
row := &model.ApiToken{
Name: "default",
Token: legacy.Value,
Enabled: true,
}
if err := db.Create(row).Error; err != nil {
log.Printf("Error migrating legacy apiToken: %v", err)
return err
}
}
}
return db.Create(&model.HistoryOfSeeders{SeederName: "ApiTokensTable"}).Error
}
// isTableEmpty returns true if the named table contains zero rows.
func isTableEmpty(tableName string) (bool, error) {
var count int64

View File

@@ -21,12 +21,8 @@ const (
Shadowsocks Protocol = "shadowsocks"
Mixed Protocol = "mixed"
WireGuard Protocol = "wireguard"
// UI stores Hysteria v1 and v2 both as "hysteria" and uses
// settings.version to discriminate. Imports from outside the panel
// can carry the literal "hysteria2" string, so IsHysteria below
// accepts both.
Hysteria Protocol = "hysteria"
Hysteria2 Protocol = "hysteria2"
Hysteria Protocol = "hysteria"
Hysteria2 Protocol = "hysteria2"
)
// IsHysteria returns true for both "hysteria" and "hysteria2".
@@ -38,9 +34,10 @@ func IsHysteria(p Protocol) bool {
// User represents a user account in the 3x-ui panel.
type User struct {
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
Username string `json:"username"`
Password string `json:"password"`
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
Username string `json:"username"`
Password string `json:"password"`
LoginEpoch int64 `json:"-" gorm:"default:0"`
}
// Inbound represents an Xray inbound configuration with traffic statistics and settings.
@@ -66,12 +63,7 @@ type Inbound struct {
StreamSettings string `json:"streamSettings" form:"streamSettings"`
Tag string `json:"tag" form:"tag" gorm:"unique"`
Sniffing string `json:"sniffing" form:"sniffing"`
// NodeID points at the remote panel (Node) where this inbound's xray
// actually runs. NULL means the inbound runs on the local xray (the
// pre-multi-node behaviour). Existing rows migrate to NULL with no
// backfill.
NodeID *int `json:"nodeId,omitempty" form:"nodeId" gorm:"index"`
NodeID *int `json:"nodeId,omitempty" form:"nodeId" gorm:"index"`
}
// OutboundTraffics tracks traffic statistics for Xray outbound connections.
@@ -96,6 +88,14 @@ type HistoryOfSeeders struct {
SeederName string `json:"seederName"`
}
type ApiToken struct {
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
Name string `json:"name" gorm:"uniqueIndex;not null"`
Token string `json:"token" gorm:"not null"`
Enabled bool `json:"enabled" gorm:"default:true"`
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime"`
}
// GenXrayInboundConfig generates an Xray inbound configuration from the Inbound model.
func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig {
listen := i.Listen
@@ -128,15 +128,16 @@ type Setting struct {
// endpoint over HTTP using the per-node ApiToken to populate the runtime
// status fields below.
type Node struct {
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
Name string `json:"name" form:"name" gorm:"uniqueIndex"`
Remark string `json:"remark" form:"remark"`
Scheme string `json:"scheme" form:"scheme"`
Address string `json:"address" form:"address"`
Port int `json:"port" form:"port"`
BasePath string `json:"basePath" form:"basePath"`
ApiToken string `json:"apiToken" form:"apiToken"`
Enable bool `json:"enable" form:"enable" gorm:"default:true"`
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
Name string `json:"name" form:"name" gorm:"uniqueIndex"`
Remark string `json:"remark" form:"remark"`
Scheme string `json:"scheme" form:"scheme"`
Address string `json:"address" form:"address"`
Port int `json:"port" form:"port"`
BasePath string `json:"basePath" form:"basePath"`
ApiToken string `json:"apiToken" form:"apiToken"`
Enable bool `json:"enable" form:"enable" gorm:"default:true"`
AllowPrivateAddress bool `json:"allowPrivateAddress" form:"allowPrivateAddress" gorm:"default:false"`
// Heartbeat-updated fields. UpdatedAt advances on every probe even when
// the row is otherwise unchanged so the UI's "last seen" tooltip is

View File

@@ -12,5 +12,6 @@ services:
XRAY_VMESS_AEAD_FORCED: "false"
XUI_ENABLE_FAIL2BAN: "true"
tty: true
network_mode: host
ports:
- "2053:2053"
restart: unless-stopped

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>3x-ui · API Docs</title>
<title>API Docs</title>
</head>
<body>
<div id="message"></div>

View File

@@ -19,9 +19,6 @@ export default [
globals: {
...globals.browser,
...globals.node,
// Legacy script tags inject a couple of helpers on window before
// the SPA boots; declared here so no-undef stops flagging them.
getRandomRealityTarget: 'readonly',
},
},
rules: {

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>3x-ui · Inbounds</title>
<title>Inbounds</title>
</head>
<body>
<div id="message"></div>

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>3x-ui</title>
<title>Overview</title>
</head>
<body>
<div id="message"></div>

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex,nofollow" />
<title>3x-ui — Sign in</title>
<title>Sign in</title>
</head>
<body>
<div id="message"></div>

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>3x-ui · Nodes</title>
<title>Nodes</title>
</head>
<body>
<div id="message"></div>

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,13 @@
{
"name": "3x-ui-frontend",
"private": true,
"version": "0.0.2",
"version": "0.0.3",
"type": "module",
"description": "3x-ui panel frontend (Vue 3 + Ant Design Vue 4 + Vite 8).",
"engines": {
"node": ">=22.0.0",
"npm": ">=10.0.0"
},
"scripts": {
"dev": "vite",
"build": "vite build",
@@ -12,8 +16,11 @@
},
"dependencies": {
"@ant-design/icons-vue": "^7.0.1",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/theme-one-dark": "^6.1.3",
"ant-design-vue": "^4.2.6",
"axios": "^1.7.9",
"codemirror": "^6.0.2",
"dayjs": "^1.11.20",
"otpauth": "^9.5.1",
"qs": "^6.13.1",
@@ -33,4 +40,4 @@
"overrides": {
"moment-jalaali": "^0.10.4"
}
}
}

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>3x-ui · Settings</title>
<title>Settings</title>
</head>
<body>
<div id="message"></div>

View File

@@ -2,24 +2,16 @@ import axios from 'axios';
import qs from 'qs';
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE']);
// Public CSRF endpoint — works pre-login (the panel-scoped
// /panel/csrf-token sits behind checkLogin and would 401 a fresh
// login page that hasn't authenticated yet).
const CSRF_TOKEN_PATH = '/csrf-token';
// Cached session CSRF token. The legacy panel injects it via a
// <meta name="csrf-token"> tag rendered by Go; the new SPA pages
// fetch it once from /panel/csrf-token instead. Module-level so
// every axios POST sees the latest value.
let csrfToken = null;
let csrfFetchPromise = null;
let sessionExpired = false;
function readMetaToken() {
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || null;
}
// Fetch the token via a bare fetch() (not axios) so the call doesn't
// recurse through this same interceptor.
async function fetchCsrfToken() {
try {
const basePath = window.X_UI_BASE_PATH;
@@ -91,19 +83,12 @@ export function setupAxios() {
async (error) => {
const status = error.response?.status;
if (status === 401) {
// 401 → session is gone. In production, the panel routes
// are gated by Go's checkLogin which redirects to base_path
// serving the login page; a reload is enough. In dev, Vite
// serves /index.html directly at "/", so a reload would put
// the user right back on the dashboard and the interceptor
// would loop. Navigate to the dev login entry instead.
if (import.meta.env.DEV) {
if (!sessionExpired) {
sessionExpired = true;
const basePath = window.X_UI_BASE_PATH || '/';
window.location.href = `${basePath}login.html`;
} else {
window.location.reload();
window.location.replace(basePath);
}
return Promise.reject(error);
return new Promise(() => { });
}
// 403 with a stale/missing CSRF token: drop the cache, re-fetch, retry once.
const cfg = error.config;

View File

@@ -14,6 +14,7 @@ import {
} from '@ant-design/icons-vue';
import { theme, currentTheme, toggleTheme, toggleUltra, pauseAnimationsUntilLeave } from '@/composables/useTheme.js';
import { HttpUtil } from '@/utils';
const { t } = useI18n();
@@ -45,7 +46,7 @@ const tabs = computed(() => [
{ key: `${prefix}panel/settings`, icon: 'setting', title: t('menu.settings') },
{ key: `${prefix}panel/xray`, icon: 'tool', title: t('menu.xray') },
{ key: `${prefix}panel/api-docs`, icon: 'apidocs', title: t('menu.apiDocs') },
{ key: `${prefix}logout`, icon: 'logout', title: t('logout') },
{ key: 'logout', icon: 'logout', title: t('logout') },
]);
const navTabs = computed(() => tabs.value.filter((tab) => tab.icon !== 'logout'));
@@ -55,7 +56,12 @@ const drawerOpen = ref(false);
const collapsed = ref(JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY) || 'false'));
const drawerWidth = 'min(82vw, 320px)';
function openLink(key) {
async function openLink(key) {
if (key === 'logout') {
await HttpUtil.post('/logout');
window.location.href = props.basePath || '/';
return;
}
if (key.startsWith('http')) {
window.open(key);
} else {

View File

@@ -0,0 +1,185 @@
<script setup>
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { EditorView, basicSetup } from 'codemirror';
import { EditorState, Compartment } from '@codemirror/state';
import { json, jsonParseLinter } from '@codemirror/lang-json';
import { lintGutter, linter } from '@codemirror/lint';
import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
import { syntaxHighlighting } from '@codemirror/language';
import { keymap } from '@codemirror/view';
import { indentWithTab } from '@codemirror/commands';
import { theme as themeState } from '@/composables/useTheme.js';
const props = defineProps({
value: { type: String, default: '' },
minHeight: { type: String, default: '320px' },
maxHeight: { type: String, default: '600px' },
readonly: { type: Boolean, default: false },
});
const emit = defineEmits(['update:value', 'change']);
const host = ref(null);
let view = null;
const themeCompartment = new Compartment();
const readonlyCompartment = new Compartment();
function buildDarkTheme({ bg, panelBg, activeBg, border, selection }) {
return EditorView.theme(
{
'&': { color: '#dcdcdc', backgroundColor: bg },
'.cm-content': { caretColor: '#dcdcdc' },
'.cm-cursor, .cm-dropCursor': { borderLeftColor: '#dcdcdc' },
'.cm-gutters': {
backgroundColor: bg,
borderRight: `1px solid ${border}`,
color: '#6a6a6a',
},
'.cm-activeLine': { backgroundColor: activeBg },
'.cm-activeLineGutter': { backgroundColor: activeBg, color: '#dcdcdc' },
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection':
{ backgroundColor: selection },
'.cm-panels': { backgroundColor: panelBg, color: '#dcdcdc' },
'.cm-panels.cm-panels-top': { borderBottom: `1px solid ${border}` },
'.cm-panels.cm-panels-bottom': { borderTop: `1px solid ${border}` },
'.cm-tooltip': {
backgroundColor: panelBg,
border: `1px solid ${border}`,
color: '#dcdcdc',
},
},
{ dark: true },
);
}
const darkTheme = buildDarkTheme({
bg: '#1e1e1e',
panelBg: '#2d2d30',
activeBg: '#252526',
border: '#3a3a3c',
selection: '#3a3a3c',
});
const ultraDarkTheme = buildDarkTheme({
bg: '#0a0a0a',
panelBg: '#141414',
activeBg: '#141414',
border: '#1f1f1f',
selection: '#2a2a2a',
});
function themeExtension() {
if (!themeState.isDark) return [];
const chrome = themeState.isUltra ? ultraDarkTheme : darkTheme;
return [chrome, syntaxHighlighting(oneDarkHighlightStyle)];
}
function readonlyExtension() {
return EditorState.readOnly.of(props.readonly);
}
onMounted(() => {
const updateListener = EditorView.updateListener.of((u) => {
if (!u.docChanged) return;
const next = u.state.doc.toString();
if (next === props.value) return;
emit('update:value', next);
emit('change', next);
});
view = new EditorView({
parent: host.value,
state: EditorState.create({
doc: props.value || '',
extensions: [
basicSetup,
keymap.of([indentWithTab]),
json(),
linter(jsonParseLinter()),
lintGutter(),
EditorView.lineWrapping,
updateListener,
themeCompartment.of(themeExtension()),
readonlyCompartment.of(readonlyExtension()),
EditorView.theme({
'&': { height: '100%' },
'.cm-scroller': {
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
fontSize: '12px',
minHeight: props.minHeight,
maxHeight: props.maxHeight,
},
}),
],
}),
});
});
watch(() => props.value, (next) => {
if (!view) return;
const current = view.state.doc.toString();
if (next === current) return;
view.dispatch({
changes: { from: 0, to: current.length, insert: next || '' },
});
});
watch(
[() => themeState.isDark, () => themeState.isUltra],
() => {
if (!view) return;
view.dispatch({ effects: themeCompartment.reconfigure(themeExtension()) });
},
);
watch(
() => props.readonly,
() => {
if (!view) return;
view.dispatch({ effects: readonlyCompartment.reconfigure(readonlyExtension()) });
},
);
onBeforeUnmount(() => {
view?.destroy();
view = null;
});
defineExpose({
focus: () => view?.focus(),
});
</script>
<template>
<div ref="host" class="json-editor-host" />
</template>
<style scoped>
.json-editor-host {
border: 1px solid var(--ant-color-border, #d9d9d9);
border-radius: 6px;
overflow: hidden;
background: var(--ant-color-bg-container, #fff);
}
.json-editor-host :deep(.cm-editor),
.json-editor-host :deep(.cm-editor.cm-focused) {
outline: none;
}
.json-editor-host:focus-within {
border-color: var(--ant-color-primary, #1677ff);
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
}
:global(body.dark) .json-editor-host {
border-color: #3a3a3c;
background: #1e1e1e;
}
:global(html[data-theme="ultra-dark"]) .json-editor-host {
border-color: #1f1f1f;
background: #0a0a0a;
}
</style>

View File

@@ -17,7 +17,7 @@ const props = defineProps({
showAxes: { type: Boolean, default: false },
yTickStep: { type: Number, default: 25 },
tickCountX: { type: Number, default: 4 },
paddingLeft: { type: Number, default: 32 },
paddingLeft: { type: Number, default: 56 },
paddingRight: { type: Number, default: 6 },
paddingTop: { type: Number, default: 6 },
paddingBottom: { type: Number, default: 20 },

View File

@@ -36,7 +36,9 @@ export function useNodeList() {
return n != null && n.enable && n.status === 'online';
}
const hasActive = computed(() => nodes.value.some((n) => n.enable));
onMounted(refresh);
return { nodes, fetched, refresh, byId, nameFor, isOnline };
return { nodes, fetched, refresh, byId, nameFor, isOnline, hasActive };
}

View File

@@ -5,9 +5,11 @@ import 'ant-design-vue/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js';
import '@/composables/useTheme.js';
import { i18n } from '@/i18n/index.js';
import { applyDocumentTitle } from '@/utils';
import ApiDocsPage from '@/pages/api-docs/ApiDocsPage.vue';
setupAxios();
applyDocumentTitle();
const messageContainer = document.getElementById('message');
if (messageContainer) {

View File

@@ -5,9 +5,11 @@ import 'ant-design-vue/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js';
import '@/composables/useTheme.js';
import { i18n } from '@/i18n/index.js';
import { applyDocumentTitle } from '@/utils';
import InboundsPage from '@/pages/inbounds/InboundsPage.vue';
setupAxios();
applyDocumentTitle();
const messageContainer = document.getElementById('message');
if (messageContainer) {

View File

@@ -7,9 +7,11 @@ import { setupAxios } from '@/api/axios-init.js';
// stored theme to <body>/<html> before Vue mounts.
import '@/composables/useTheme.js';
import { i18n } from '@/i18n/index.js';
import { applyDocumentTitle } from '@/utils';
import IndexPage from '@/pages/index/IndexPage.vue';
setupAxios();
applyDocumentTitle();
const messageContainer = document.getElementById('message');
if (messageContainer) {

View File

@@ -7,9 +7,11 @@ import { setupAxios } from '@/api/axios-init.js';
// stored theme to <body>/<html> before Vue renders anything.
import '@/composables/useTheme.js';
import { i18n } from '@/i18n/index.js';
import { applyDocumentTitle } from '@/utils';
import LoginPage from '@/pages/login/LoginPage.vue';
setupAxios();
applyDocumentTitle();
// Toasts attach to a #message div the page provides — keeps theme
// styling in sync with the rest of the panel.

View File

@@ -5,9 +5,11 @@ import 'ant-design-vue/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js';
import '@/composables/useTheme.js';
import { i18n } from '@/i18n/index.js';
import { applyDocumentTitle } from '@/utils';
import NodesPage from '@/pages/nodes/NodesPage.vue';
setupAxios();
applyDocumentTitle();
const messageContainer = document.getElementById('message');
if (messageContainer) {

View File

@@ -7,9 +7,11 @@ import { setupAxios } from '@/api/axios-init.js';
// stored theme to <body>/<html> before Vue mounts.
import '@/composables/useTheme.js';
import { i18n } from '@/i18n/index.js';
import { applyDocumentTitle } from '@/utils';
import SettingsPage from '@/pages/settings/SettingsPage.vue';
setupAxios();
applyDocumentTitle();
const messageContainer = document.getElementById('message');
if (messageContainer) {

View File

@@ -5,9 +5,11 @@ import 'ant-design-vue/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js';
import '@/composables/useTheme.js';
import { i18n } from '@/i18n/index.js';
import { applyDocumentTitle } from '@/utils';
import XrayPage from '@/pages/xray/XrayPage.vue';
setupAxios();
applyDocumentTitle();
const messageContainer = document.getElementById('message');
if (messageContainer) {

View File

@@ -70,6 +70,10 @@ export class DBInbound {
return this.protocol === Protocols.WIREGUARD;
}
get isHysteria() {
return this.protocol === Protocols.HYSTERIA;
}
get address() {
let address = location.hostname;
if (!ObjectUtil.isEmpty(this.listen) && this.listen !== "0.0.0.0") {

View File

@@ -1,5 +1,6 @@
import dayjs from 'dayjs';
import { ObjectUtil, RandomUtil, Base64, NumberFormatter, SizeFormatter, Wireguard } from '@/utils';
import { getRandomRealityTarget } from '@/models/reality-targets';
export const Protocols = {
VMESS: 'vmess',
@@ -687,8 +688,9 @@ export class HysteriaMasquerade extends XrayCommonClass {
}
static fromJson(json = {}) {
const type = ['proxy', 'file', 'string'].includes(json.type) ? json.type : 'proxy';
return new HysteriaMasquerade(
json.type,
type,
json.dir,
json.url,
json.rewriteHost,
@@ -896,9 +898,7 @@ export class RealityStreamSettings extends XrayCommonClass {
super();
// If target/serverNames are not provided, use random values
if (!target && !serverNames) {
const randomTarget = typeof getRandomRealityTarget !== 'undefined'
? getRandomRealityTarget()
: { target: 'www.amazon.com:443', sni: 'www.amazon.com,amazon.com' };
const randomTarget = getRandomRealityTarget();
target = randomTarget.target;
serverNames = randomTarget.sni;
}
@@ -1595,6 +1595,10 @@ export class Inbound extends XrayCommonClass {
});
}
if (typeof xhttp.mode === 'string' && xhttp.mode.length > 0) {
extra.mode = xhttp.mode;
}
const stringFields = [
"sessionPlacement", "sessionKey",
"seqPlacement", "seqKey",
@@ -2967,37 +2971,45 @@ Inbound.HysteriaSettings.Hysteria = class extends Inbound.ClientBase {
Inbound.TunnelSettings = class extends Inbound.Settings {
constructor(
protocol,
address,
port,
rewriteAddress,
rewritePort,
portMap = [],
network = 'tcp,udp',
allowedNetwork = 'tcp,udp',
followRedirect = false
) {
super(protocol);
this.address = address;
this.port = port;
this.rewriteAddress = rewriteAddress;
this.rewritePort = rewritePort;
this.portMap = portMap;
this.network = network;
this.allowedNetwork = allowedNetwork;
this.followRedirect = followRedirect;
}
addPortMap(port = '', target = '') {
this.portMap.push({ name: port, value: target });
}
removePortMap(index) {
this.portMap.splice(index, 1);
}
static fromJson(json = {}) {
return new Inbound.TunnelSettings(
Protocols.TUNNEL,
json.address,
json.port,
json.rewriteAddress,
json.rewritePort,
XrayCommonClass.toHeaders(json.portMap),
json.network,
json.allowedNetwork,
json.followRedirect,
);
}
toJson() {
return {
address: this.address,
port: this.port,
rewriteAddress: this.rewriteAddress,
rewritePort: this.rewritePort,
portMap: XrayCommonClass.toV2Headers(this.portMap, false),
network: this.network,
allowedNetwork: this.allowedNetwork,
followRedirect: this.followRedirect,
};
}

View File

@@ -15,6 +15,7 @@ export class AllSetting {
this.webKeyFile = "";
this.webBasePath = "/";
this.sessionMaxAge = 360;
this.trustedProxyCIDRs = "127.0.0.1/32,::1/128";
this.pageSize = 25;
this.expireDiff = 0;
this.trafficDiff = 0;
@@ -56,6 +57,7 @@ export class AllSetting {
this.subUpdates = 12;
this.subEncrypt = true;
this.subShowInfo = true;
this.subEmailInRemark = true;
this.subURI = "";
this.subJsonURI = "";
this.subClashURI = "";
@@ -87,6 +89,12 @@ export class AllSetting {
this.ldapDefaultTotalGB = 0;
this.ldapDefaultExpiryDays = 0;
this.ldapDefaultLimitIP = 0;
this.hasTgBotToken = false;
this.hasTwoFactorToken = false;
this.hasLdapPassword = false;
this.hasApiToken = false;
this.hasWarpSecret = false;
this.hasNordSecret = false;
if (data == null) {
return
@@ -97,4 +105,4 @@ export class AllSetting {
equals(other) {
return ObjectUtil.equals(this, other);
}
}
}

View File

@@ -1,80 +1,143 @@
<script setup>
import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { Modal, message } from 'ant-design-vue';
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import {
KeyOutlined,
ReloadOutlined,
CopyOutlined,
EyeOutlined,
EyeInvisibleOutlined,
SearchOutlined,
ExpandOutlined,
CompressOutlined,
ApiOutlined,
SafetyCertificateOutlined,
CloudServerOutlined,
ClusterOutlined,
GlobalOutlined,
SaveOutlined,
SettingOutlined,
WifiOutlined,
LinkOutlined,
NodeIndexOutlined,
} from '@ant-design/icons-vue';
import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js';
import AppSidebar from '@/components/AppSidebar.vue';
import { HttpUtil, ClipboardManager } from '@/utils/index.js';
import { sections } from './endpoints.js';
import { sections as allSections } from './endpoints.js';
import EndpointSection from './EndpointSection.vue';
const { t } = useI18n();
import CodeBlock from './CodeBlock.vue';
const basePath = window.X_UI_BASE_PATH || '';
const requestUri = window.location.pathname;
const settingsHref = `${basePath}panel/settings#security`;
const apiToken = ref('');
const tokenLoading = ref(false);
const tokenRotating = ref(false);
const tokenVisible = ref(false);
const searchQuery = ref('');
const collapsedSections = ref(new Set());
const activeSection = ref('');
const sectionIcons = {
authentication: SafetyCertificateOutlined,
inbounds: NodeIndexOutlined,
server: CloudServerOutlined,
nodes: ClusterOutlined,
'custom-geo': GlobalOutlined,
backup: SaveOutlined,
settings: SettingOutlined,
'api-tokens': KeyOutlined,
'xray-settings': WifiOutlined,
subscription: LinkOutlined,
websocket: ApiOutlined,
};
const curlExample = `curl -X GET \\
-H "Authorization: Bearer YOUR_API_TOKEN" \\
-H "Accept: application/json" \\
https://your-panel.example.com/panel/api/inbounds/list`;
async function loadApiToken() {
tokenLoading.value = true;
try {
const msg = await HttpUtil.get('/panel/setting/getApiToken');
if (msg?.success) apiToken.value = msg.obj || '';
} finally {
tokenLoading.value = false;
}
const sections = computed(() => {
const q = searchQuery.value.toLowerCase().trim();
if (!q) return allSections;
return allSections
.map(s => {
const matching = s.endpoints.filter(e =>
e.path.toLowerCase().includes(q) ||
e.summary?.toLowerCase().includes(q) ||
e.method.toLowerCase().includes(q)
);
return { ...s, endpoints: matching };
})
.filter(s => s.endpoints.length > 0);
});
const endpointCount = computed(() =>
allSections.reduce((sum, s) => sum + s.endpoints.length, 0)
);
const visibleEndpoints = computed(() =>
sections.value.reduce((sum, s) => sum + s.endpoints.length, 0)
);
function isCollapsed(id) {
return collapsedSections.value.has(id);
}
function regenerateApiToken() {
Modal.confirm({
title: t('pages.nodes.regenerateConfirm'),
okText: t('confirm'),
cancelText: t('cancel'),
okType: 'danger',
onOk: async () => {
tokenRotating.value = true;
try {
const msg = await HttpUtil.post('/panel/setting/regenerateApiToken');
if (msg?.success) {
apiToken.value = msg.obj || '';
message.success(t('success'));
}
} finally {
tokenRotating.value = false;
}
},
});
function toggleSection(id) {
const s = new Set(collapsedSections.value);
if (s.has(id)) s.delete(id); else s.add(id);
collapsedSections.value = s;
}
async function copyApiToken() {
if (!apiToken.value) return;
const ok = await ClipboardManager.copy(apiToken.value);
if (ok) message.success(t('success'));
function expandAll() {
collapsedSections.value = new Set();
}
function collapseAll() {
collapsedSections.value = new Set(allSections.map(s => s.id));
}
function scrollToSection(id) {
const el = document.getElementById(id);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
if (!el) return;
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
if (window.location.hash !== `#${id}`) {
history.replaceState(null, '', `#${id}`);
}
}
function scrollToHash() {
const id = window.location.hash.slice(1);
if (!id) return;
const el = document.getElementById(id);
if (el) el.scrollIntoView({ behavior: 'auto', block: 'start' });
}
let scrollObserver = null;
function onScroll() {
const toc = document.querySelector('.toc-nav');
const tocHeight = toc ? toc.offsetHeight : 56;
let current = '';
for (const s of sections.value) {
const el = document.getElementById(s.id);
if (!el) continue;
const rect = el.getBoundingClientRect();
if (rect.top <= tocHeight + 20) {
current = s.id;
}
}
activeSection.value = current;
}
onMounted(() => {
loadApiToken();
scrollObserver = onScroll;
window.addEventListener('scroll', scrollObserver, { passive: true });
window.addEventListener('hashchange', scrollToHash);
requestAnimationFrame(() => {
scrollToHash();
onScroll();
});
});
onBeforeUnmount(() => {
if (scrollObserver) {
window.removeEventListener('scroll', scrollObserver);
}
window.removeEventListener('hashchange', scrollToHash);
});
</script>
@@ -93,60 +156,81 @@ onMounted(() => {
cookie, or with the <code>Authorization: Bearer &lt;token&gt;</code> header below. Every endpoint
returns a uniform <code>{ success, msg, obj }</code> envelope unless otherwise noted.
</p>
</header>
<a-card class="token-card" size="small">
<div class="token-card-head">
<div class="token-card-title">
<KeyOutlined />
<span>API Token</span>
<span>API Tokens</span>
</div>
<a-space size="small" wrap>
<a-button size="small" @click="tokenVisible = !tokenVisible">
<template #icon>
<EyeInvisibleOutlined v-if="tokenVisible" />
<EyeOutlined v-else />
</template>
{{ tokenVisible ? 'Hide' : 'Show' }}
</a-button>
<a-button size="small" :disabled="!apiToken" @click="copyApiToken">
<template #icon>
<CopyOutlined />
</template>
Copy
</a-button>
<a-button size="small" danger :loading="tokenRotating" @click="regenerateApiToken">
<template #icon>
<ReloadOutlined />
</template>
Regenerate
</a-button>
</a-space>
<a-button type="primary" size="small" :href="settingsHref">
Manage tokens
</a-button>
</div>
<a-spin :spinning="tokenLoading" size="small">
<pre
class="token-value">{{ tokenVisible ? (apiToken || '—') : (apiToken ? '••••••••••••••••••••••••••••' : '—') }}</pre>
</a-spin>
<p class="token-hint">
Send it on every request as <code>Authorization: Bearer &lt;token&gt;</code>. Token-authenticated
callers skip CSRF and don't need a session cookie. Regenerating rotates the secret immediately
running bots will need the new value.
Create, enable, or revoke named Bearer tokens in
<a :href="settingsHref">Settings Security</a>. Send each request as
<code>Authorization: Bearer &lt;token&gt;</code>. Token-authenticated callers skip CSRF and don't
need a session cookie. Deleting a token revokes it immediately — running bots will need a new one.
</p>
</a-card>
<a-card class="curl-card" size="small" title="Quick example">
<pre class="code-block">{{ curlExample }}</pre>
<CodeBlock :code="curlExample" lang="text" />
</a-card>
<div class="toolbar">
<a-input-search
v-model:value="searchQuery"
placeholder="Search endpoints by path, method, or description…"
allow-clear
class="search-bar"
>
<template #prefix><SearchOutlined /></template>
</a-input-search>
<span class="match-count" v-if="searchQuery">
{{ visibleEndpoints }} / {{ endpointCount }} endpoints
</span>
<a-space size="small">
<a-button size="small" @click="expandAll">
<template #icon><ExpandOutlined /></template>
Expand all
</a-button>
<a-button size="small" @click="collapseAll">
<template #icon><CompressOutlined /></template>
Collapse all
</a-button>
</a-space>
</div>
<nav class="toc-nav">
<span class="toc-label">On this page:</span>
<a v-for="s in sections" :key="s.id" class="toc-link" :href="`#${s.id}`"
@click.prevent="scrollToSection(s.id)">
{{ s.title }}
</a>
<div class="toc-links">
<a
v-for="s in sections"
:key="s.id"
class="toc-link"
:class="{ active: activeSection === s.id }"
:href="`#${s.id}`"
@click.prevent="scrollToSection(s.id)"
>
<component :is="sectionIcons[s.id]" class="toc-icon" />
<span class="toc-text">{{ s.title }}</span>
<span class="toc-badge">{{ s.endpoints.length }}</span>
</a>
</div>
</nav>
<EndpointSection v-for="s in sections" :key="s.id" :section="s" />
<EndpointSection
v-for="s in sections"
:key="s.id"
:section="s"
:icon="sectionIcons[s.id]"
:collapsed="isCollapsed(s.id)"
@toggle="toggleSection(s.id)"
/>
</div>
</a-layout-content>
</a-layout>
@@ -194,20 +278,25 @@ onMounted(() => {
}
.docs-header {
margin-bottom: 18px;
margin-bottom: 20px;
padding: 24px;
background: var(--bg-card);
border: 1px solid rgba(128, 128, 128, 0.12);
border-radius: 10px;
}
.docs-title {
font-size: 26px;
font-weight: 700;
font-size: 28px;
font-weight: 800;
margin: 0 0 8px;
color: rgba(0, 0, 0, 0.88);
letter-spacing: -0.3px;
}
.docs-lead {
margin: 0;
color: rgba(0, 0, 0, 0.65);
line-height: 1.6;
line-height: 1.65;
font-size: 14px;
}
@@ -231,7 +320,8 @@ onMounted(() => {
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 8px;
margin-bottom: 10px;
min-height: 32px;
}
.token-card-title {
@@ -242,18 +332,6 @@ onMounted(() => {
font-size: 14px;
}
.token-value {
background: rgba(128, 128, 128, 0.08);
border: 1px solid rgba(128, 128, 128, 0.15);
border-radius: 6px;
padding: 10px 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 13px;
margin: 0;
word-break: break-all;
white-space: pre-wrap;
}
.token-hint {
margin: 10px 0 0;
color: rgba(0, 0, 0, 0.55);
@@ -275,35 +353,110 @@ onMounted(() => {
overflow-x: auto;
}
.toolbar {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.search-bar {
flex: 1;
min-width: 200px;
max-width: 480px;
}
.match-count {
font-size: 12px;
color: rgba(0, 0, 0, 0.5);
white-space: nowrap;
}
.toc-nav {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 14px;
align-items: flex-start;
gap: 8px 12px;
padding: 12px 16px;
background: rgba(128, 128, 128, 0.08);
border-radius: 6px;
background: var(--bg-card);
border: 1px solid rgba(128, 128, 128, 0.12);
border-radius: 8px;
margin-bottom: 16px;
}
.toc-label {
font-size: 12px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
letter-spacing: 0.6px;
color: rgba(0, 0, 0, 0.5);
padding-top: 3px;
flex-shrink: 0;
}
.toc-links {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.toc-link {
color: #1677ff;
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 10px;
border-radius: 20px;
font-size: 12.5px;
color: rgba(0, 0, 0, 0.65);
background: rgba(128, 128, 128, 0.06);
border: 1px solid transparent;
text-decoration: none;
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
white-space: nowrap;
}
.toc-link:hover {
color: #4096ff;
text-decoration: underline;
background: rgba(22, 119, 255, 0.08);
color: #1677ff;
border-color: rgba(22, 119, 255, 0.2);
}
.toc-link.active {
background: rgba(22, 119, 255, 0.12);
color: #1677ff;
border-color: rgba(22, 119, 255, 0.3);
font-weight: 600;
}
.toc-icon {
font-size: 13px;
opacity: 0.8;
}
.toc-text {
font-size: 12.5px;
}
.toc-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
font-size: 10.5px;
font-weight: 700;
background: rgba(22, 119, 255, 0.12);
color: #1677ff;
line-height: 1;
}
.toc-link.active .toc-badge {
background: #1677ff;
color: #fff;
}
</style>
@@ -312,28 +465,97 @@ body.dark .docs-title {
color: rgba(255, 255, 255, 0.92);
}
html[data-theme='ultra-dark'] .docs-title {
color: rgba(255, 255, 255, 0.95);
}
body.dark .docs-header {
background: #252526;
border-color: rgba(255, 255, 255, 0.08);
}
html[data-theme='ultra-dark'] .docs-header {
background: #0a0a0a;
border-color: rgba(255, 255, 255, 0.06);
}
body.dark .docs-lead,
body.dark .token-hint {
color: rgba(255, 255, 255, 0.7);
}
html[data-theme='ultra-dark'] .docs-lead,
html[data-theme='ultra-dark'] .token-hint {
color: rgba(255, 255, 255, 0.75);
}
body.dark .docs-lead code,
body.dark .token-hint code {
background: rgba(255, 255, 255, 0.1);
}
body.dark .token-value,
html[data-theme='ultra-dark'] .docs-lead code,
html[data-theme='ultra-dark'] .token-hint code {
background: rgba(255, 255, 255, 0.12);
}
body.dark .code-block {
background: rgba(255, 255, 255, 0.04);
border-color: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.88);
}
html[data-theme='ultra-dark'] .code-block {
background: rgba(255, 255, 255, 0.02);
border-color: rgba(255, 255, 255, 0.08);
}
body.dark .toc-nav {
background: rgba(255, 255, 255, 0.04);
background: #252526;
border-color: rgba(255, 255, 255, 0.08);
}
html[data-theme='ultra-dark'] .toc-nav {
background: #0a0a0a;
border-color: rgba(255, 255, 255, 0.06);
}
body.dark .toc-label {
color: rgba(255, 255, 255, 0.55);
}
html[data-theme='ultra-dark'] .toc-label {
color: rgba(255, 255, 255, 0.6);
}
body.dark .toc-link {
color: rgba(255, 255, 255, 0.65);
background: rgba(255, 255, 255, 0.06);
}
html[data-theme='ultra-dark'] .toc-link {
background: rgba(255, 255, 255, 0.04);
}
body.dark .toc-link:hover {
background: rgba(88, 166, 255, 0.12);
color: #58a6ff;
border-color: rgba(88, 166, 255, 0.25);
}
body.dark .toc-link.active {
background: rgba(88, 166, 255, 0.15);
color: #58a6ff;
border-color: rgba(88, 166, 255, 0.35);
}
body.dark .toc-badge {
background: rgba(88, 166, 255, 0.15);
color: #58a6ff;
}
body.dark .toc-link.active .toc-badge {
background: #58a6ff;
color: #0d1117;
}
</style>

View File

@@ -0,0 +1,174 @@
<script setup>
import { computed, ref } from 'vue';
import { message } from 'ant-design-vue';
import { CopyOutlined, CheckOutlined } from '@ant-design/icons-vue';
const props = defineProps({
code: { type: String, default: '' },
lang: { type: String, default: 'json' },
});
const copied = ref(false);
function escapeHtml(str) {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function highlightJson(str) {
const escaped = escapeHtml(str);
return escaped.replace(
/("(?:[^"\\]|\\.)*")\s*(:)|("(?:[^"\\]|\\.)*")|(-?\d+\.?\d*(?:[eE][+-]?\d+)?)\b|(true|false)|(null)|([{}[\]])/g,
(_m, key, colon, string, number, bool, nil) => {
if (colon) return `<span class="json-key">${key}</span>${colon}`;
if (string) return `<span class="json-string">${string}</span>`;
if (number) return `<span class="json-number">${number}</span>`;
if (bool) return `<span class="json-boolean">${bool}</span>`;
if (nil) return `<span class="json-null">${nil}</span>`;
return _m;
}
);
}
const highlighted = computed(() => {
if (props.lang === 'json') {
return highlightJson(props.code);
}
return escapeHtml(props.code);
});
async function copyCode() {
try {
await navigator.clipboard.writeText(props.code);
copied.value = true;
message.success('Copied');
setTimeout(() => { copied.value = false; }, 2000);
} catch {
message.error('Copy failed');
}
}
</script>
<template>
<div class="code-block-wrapper">
<div class="code-toolbar">
<span class="lang-badge">{{ lang.toUpperCase() }}</span>
<button class="copy-btn" :class="{ copied }" @click="copyCode" :title="copied ? 'Copied' : 'Copy'">
<CheckOutlined v-if="copied" />
<CopyOutlined v-else />
</button>
</div>
<pre class="code-block" :class="`lang-${lang}`"><code v-html="highlighted"></code></pre>
</div>
</template>
<style scoped>
.code-block-wrapper {
position: relative;
border-radius: 6px;
overflow: hidden;
border: 1px solid rgba(128, 128, 128, 0.15);
}
.code-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 8px;
background: rgba(128, 128, 128, 0.06);
border-bottom: 1px solid rgba(128, 128, 128, 0.1);
}
.lang-badge {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.5px;
color: rgba(0, 0, 0, 0.4);
text-transform: uppercase;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.copy-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border: 1px solid rgba(128, 128, 128, 0.15);
border-radius: 4px;
background: rgba(255, 255, 255, 0.7);
color: rgba(0, 0, 0, 0.45);
cursor: pointer;
font-size: 12px;
transition: all 0.15s;
}
.copy-btn:hover {
background: #fff;
color: #1677ff;
border-color: #1677ff;
}
.copy-btn.copied {
background: #52c41a;
color: #fff;
border-color: #52c41a;
}
.code-block {
background: rgba(128, 128, 128, 0.04);
padding: 10px 12px;
margin: 0;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12.5px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
overflow-x: auto;
border: none;
border-radius: 0;
}
</style>
<style>
.json-key { color: #0550ae; }
.json-string { color: #116329; }
.json-number { color: #9a6700; }
.json-boolean { color: #cf222e; }
.json-null { color: #8250df; }
body.dark .code-block-wrapper {
border-color: rgba(255, 255, 255, 0.1);
}
body.dark .code-toolbar {
background: rgba(255, 255, 255, 0.03);
border-color: rgba(255, 255, 255, 0.06);
}
body.dark .lang-badge {
color: rgba(255, 255, 255, 0.4);
}
body.dark .code-block {
background: rgba(255, 255, 255, 0.03);
color: rgba(255, 255, 255, 0.88);
}
body.dark .json-key { color: #79c0ff; }
body.dark .json-string { color: #7ee787; }
body.dark .json-number { color: #d29922; }
body.dark .json-boolean { color: #ff7b72; }
body.dark .json-null { color: #d2a8ff; }
body.dark .copy-btn {
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.45);
border-color: rgba(255, 255, 255, 0.12);
}
body.dark .copy-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #58a6ff;
border-color: #58a6ff;
}
</style>

View File

@@ -1,6 +1,7 @@
<script setup>
import { computed } from 'vue';
import { methodColors } from './endpoints.js';
import { methodColors, safeInlineHtml } from './endpoints.js';
import CodeBlock from './CodeBlock.vue';
const props = defineProps({
endpoint: { type: Object, required: true },
@@ -24,7 +25,7 @@ const paramColumns = [
<code class="endpoint-path">{{ endpoint.path }}</code>
</div>
<p v-if="endpoint.summary" class="endpoint-summary">{{ endpoint.summary }}</p>
<p v-if="endpoint.summary" class="endpoint-summary" v-html="safeInlineHtml(endpoint.summary)"></p>
<div v-if="hasParams" class="endpoint-block">
<div class="block-label">Parameters</div>
@@ -33,27 +34,39 @@ const paramColumns = [
<div v-if="endpoint.body" class="endpoint-block">
<div class="block-label">Request body</div>
<a-typography-paragraph :copyable="{ text: endpoint.body }">
<pre class="code-block">{{ endpoint.body }}</pre>
</a-typography-paragraph>
<CodeBlock :code="endpoint.body" lang="json" />
</div>
<div v-if="endpoint.response" class="endpoint-block">
<div class="block-label">Response</div>
<a-typography-paragraph :copyable="{ text: endpoint.response }">
<pre class="code-block">{{ endpoint.response }}</pre>
</a-typography-paragraph>
<CodeBlock :code="endpoint.response" lang="json" />
</div>
<div v-if="endpoint.errorResponse" class="endpoint-block">
<div class="block-label error-label">Error response</div>
<CodeBlock :code="endpoint.errorResponse" lang="json" />
</div>
</div>
</template>
<style scoped>
.endpoint-row {
padding: 12px 0;
padding: 14px 0;
margin: 0;
transition: background 0.15s;
border-radius: 6px;
padding-left: 8px;
padding-right: 8px;
margin-left: -8px;
margin-right: -8px;
}
.endpoint-row:hover {
background: rgba(128, 128, 128, 0.03);
}
.endpoint-row + .endpoint-row {
border-top: 1px solid rgba(128, 128, 128, 0.15);
border-top: 1px solid rgba(128, 128, 128, 0.1);
}
.endpoint-header {
@@ -64,38 +77,52 @@ const paramColumns = [
}
.method-tag {
font-weight: 600;
font-weight: 700;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px;
letter-spacing: 0.5px;
min-width: 60px;
min-width: 56px;
text-align: center;
text-transform: uppercase;
border-radius: 4px;
padding: 2px 8px;
line-height: 1.6;
}
.endpoint-path {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 13px;
font-size: 13.5px;
word-break: break-all;
color: rgba(0, 0, 0, 0.8);
background: rgba(128, 128, 128, 0.06);
padding: 2px 8px;
border-radius: 4px;
}
.endpoint-summary {
margin: 8px 0 0;
color: rgba(0, 0, 0, 0.65);
line-height: 1.55;
color: rgba(0, 0, 0, 0.6);
line-height: 1.6;
font-size: 13.5px;
}
.endpoint-block {
margin-top: 12px;
margin-top: 14px;
}
.block-label {
font-size: 12px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: rgba(0, 0, 0, 0.5);
letter-spacing: 0.6px;
color: rgba(0, 0, 0, 0.45);
margin-bottom: 6px;
}
.error-label {
color: #cf222e;
}
.code-block {
background: rgba(128, 128, 128, 0.08);
border: 1px solid rgba(128, 128, 128, 0.15);
@@ -112,12 +139,29 @@ const paramColumns = [
</style>
<style>
body.dark .endpoint-row:hover {
background: rgba(255, 255, 255, 0.02);
}
body.dark .endpoint-row + .endpoint-row {
border-top-color: rgba(255, 255, 255, 0.08);
}
body.dark .endpoint-path {
color: rgba(255, 255, 255, 0.82);
background: rgba(255, 255, 255, 0.05);
}
body.dark .endpoint-summary {
color: rgba(255, 255, 255, 0.7);
color: rgba(255, 255, 255, 0.65);
}
body.dark .block-label {
color: rgba(255, 255, 255, 0.55);
color: rgba(255, 255, 255, 0.45);
}
body.dark .error-label {
color: #ff7b72;
}
body.dark .code-block {

View File

@@ -1,16 +1,57 @@
<script setup>
import { computed } from 'vue';
import {
DownOutlined,
RightOutlined,
} from '@ant-design/icons-vue';
import EndpointRow from './EndpointRow.vue';
import { safeInlineHtml } from './endpoints.js';
defineProps({
const props = defineProps({
section: { type: Object, required: true },
icon: { type: Object, default: null },
collapsed: { type: Boolean, default: false },
});
const emit = defineEmits(['toggle']);
const endpointLabel = computed(() =>
props.section.endpoints.length === 1
? '1 endpoint'
: `${props.section.endpoints.length} endpoints`
);
</script>
<template>
<section :id="section.id" class="api-section">
<h2 class="section-title">{{ section.title }}</h2>
<p v-if="section.description" class="section-description">{{ section.description }}</p>
<div class="endpoints">
<div class="section-header" @click="emit('toggle')">
<div class="section-header-left">
<DownOutlined v-if="!collapsed" class="collapse-icon" />
<RightOutlined v-else class="collapse-icon" />
<component v-if="icon" :is="icon" class="section-icon" />
<h2 class="section-title">{{ section.title }}</h2>
</div>
<span class="endpoint-count">{{ endpointLabel }}</span>
</div>
<p v-if="section.description && !collapsed" class="section-description" v-html="safeInlineHtml(section.description)"></p>
<div v-if="section.subHeader && !collapsed" class="sub-header-block">
<div class="block-label">Response headers</div>
<a-table
:columns="[{ title: 'Header', dataIndex: 'name', key: 'name', width: 240 }, { title: 'Description', dataIndex: 'desc', key: 'desc' }]"
:data-source="section.subHeader"
:pagination="false"
size="small"
row-key="name"
>
<template #bodyCell="{ column, text }">
<span v-if="column.dataIndex === 'desc'" v-html="safeInlineHtml(text)"></span>
<template v-else>{{ text }}</template>
</template>
</a-table>
</div>
<div v-show="!collapsed" class="endpoints">
<EndpointRow v-for="(endpoint, idx) in section.endpoints" :key="idx" :endpoint="endpoint" />
</div>
</section>
@@ -19,24 +60,89 @@ defineProps({
<style scoped>
.api-section {
background: #fff;
border: 1px solid rgba(128, 128, 128, 0.15);
border: 1px solid rgba(128, 128, 128, 0.12);
border-radius: 8px;
padding: 20px 24px;
margin-bottom: 20px;
scroll-margin-top: 16px;
margin-bottom: 16px;
transition: box-shadow 0.2s, border-color 0.2s;
}
.api-section:hover {
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
user-select: none;
}
.section-header:hover .collapse-icon,
.section-header:hover .section-icon {
color: #1677ff;
}
.section-header-left {
display: flex;
align-items: center;
gap: 10px;
}
.collapse-icon {
font-size: 12px;
color: rgba(0, 0, 0, 0.4);
transition: color 0.2s;
}
.section-icon {
font-size: 18px;
color: rgba(0, 0, 0, 0.45);
transition: color 0.2s;
}
.section-title {
font-size: 20px;
font-weight: 600;
font-weight: 700;
margin: 0;
color: rgba(0, 0, 0, 0.88);
}
.endpoint-count {
font-size: 11px;
font-weight: 600;
color: rgba(0, 0, 0, 0.45);
white-space: nowrap;
background: rgba(128, 128, 128, 0.08);
padding: 3px 10px;
border-radius: 12px;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.section-description {
margin: 6px 0 14px;
margin: 12px 0 14px;
color: rgba(0, 0, 0, 0.65);
line-height: 1.55;
line-height: 1.6;
}
.sub-header-block {
margin-bottom: 14px;
}
.block-label {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: rgba(0, 0, 0, 0.5);
margin-bottom: 6px;
}
.endpoints {
padding-top: 8px;
border-top: 1px solid rgba(128, 128, 128, 0.1);
}
.endpoints > :first-child {
@@ -47,19 +153,40 @@ defineProps({
<style>
body.dark .api-section {
background: #252526;
border-color: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.08);
}
body.dark .api-section:hover {
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.25);
}
html[data-theme='ultra-dark'] .api-section {
background: #0a0a0a;
border-color: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.06);
}
html[data-theme='ultra-dark'] .api-section:hover {
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.4);
}
body.dark .section-title {
color: rgba(255, 255, 255, 0.92);
}
body.dark .section-icon {
color: rgba(255, 255, 255, 0.5);
}
body.dark .section-description {
color: rgba(255, 255, 255, 0.7);
}
body.dark .block-label {
color: rgba(255, 255, 255, 0.55);
}
body.dark .endpoint-count {
color: rgba(255, 255, 255, 0.55);
background: rgba(255, 255, 255, 0.06);
}
</style>

View File

@@ -1,6 +1,31 @@
export function safeInlineHtml(input) {
if (!input) return '';
const escape = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const open = '<code>';
const close = '</code>';
let out = '';
let i = 0;
while (i < input.length) {
const oIdx = input.indexOf(open, i);
if (oIdx === -1) {
out += escape(input.slice(i));
break;
}
out += escape(input.slice(i, oIdx));
const cIdx = input.indexOf(close, oIdx + open.length);
if (cIdx === -1) {
out += escape(input.slice(oIdx));
break;
}
out += '<code>' + escape(input.slice(oIdx + open.length, cIdx)) + '</code>';
i = cIdx + close.length;
}
return out;
}
export const sections = [
{
id: 'auth',
id: 'authentication',
title: 'Authentication',
description:
'Two authentication modes are supported. UI sessions use a cookie set by the login endpoint. Programmatic clients (bots, scripts, remote panels) authenticate with a Bearer token taken from Settings → Security → API Token. Both work for every endpoint under /panel/api/*.',
@@ -17,11 +42,14 @@ export const sections = [
body: '{\n "username": "admin",\n "password": "admin",\n "twoFactorCode": "123456"\n}',
response:
'{\n "success": true,\n "msg": "Logged in successfully"\n}',
errorResponse:
'{\n "success": false,\n "msg": "Wrong username or password"\n}',
},
{
method: 'GET',
method: 'POST',
path: '/logout',
summary: 'Clear the session cookie. Redirects back to the login page; not useful from non-browser clients.',
summary: 'Clear the session cookie. Requires the CSRF header for browser sessions.',
response: '{\n "success": true\n}',
},
{
method: 'GET',
@@ -41,9 +69,9 @@ export const sections = [
{
id: 'inbounds',
title: 'Inbounds API',
title: 'Inbounds',
description:
'Manage inbound configurations and their clients. All endpoints live under /panel/api/inbounds and require a logged-in session or Bearer token. Link-generating endpoints honour X-Forwarded-Host / X-Forwarded-Proto, so callers behind a reverse proxy get the correct external host in returned URLs.',
'Manage inbound configurations and their clients. All endpoints live under /panel/api/inbounds and require a logged-in session or Bearer token. Link-generating endpoints honour forwarded headers only when the request comes from a configured trusted proxy.',
endpoints: [
{
method: 'GET',
@@ -67,6 +95,7 @@ export const sections = [
params: [
{ name: 'email', in: 'path', type: 'string', desc: 'Client email (unique across the panel).' },
],
response: '{\n "success": true,\n "obj": {\n "email": "user1",\n "up": 1048576,\n "down": 2097152,\n "total": 10737418240,\n "expiryTime": 1735689600000\n }\n}',
},
{
method: 'GET',
@@ -75,6 +104,7 @@ export const sections = [
params: [
{ name: 'id', in: 'path', type: 'string', desc: 'Client subId / UUID.' },
],
response: '{\n "success": true,\n "obj": {\n "email": "user1",\n "up": 1048576,\n "down": 2097152,\n "total": 10737418240,\n "expiryTime": 1735689600000\n }\n}',
},
{
method: 'POST',
@@ -82,6 +112,8 @@ export const sections = [
summary: 'Create a new inbound. Send the full inbound payload (protocol, port, settings JSON, streamSettings JSON, sniffing JSON, remark, expiryTime, total, enable).',
body:
'{\n "enable": true,\n "remark": "VLESS-443",\n "listen": "",\n "port": 443,\n "protocol": "vless",\n "expiryTime": 0,\n "total": 0,\n "settings": "{\\"clients\\":[{\\"id\\":\\"...\\",\\"email\\":\\"user1\\"}],\\"decryption\\":\\"none\\",\\"fallbacks\\":[]}",\n "streamSettings": "{\\"network\\":\\"tcp\\",\\"security\\":\\"reality\\",\\"realitySettings\\":{...}}",\n "sniffing": "{\\"enabled\\":true,\\"destOverride\\":[\\"http\\",\\"tls\\"]}"\n}',
errorResponse:
'{\n "success": false,\n "msg": "Port 443 is already in use"\n}',
},
{
method: 'POST',
@@ -161,6 +193,14 @@ export const sections = [
body:
'{\n "id": 1,\n "settings": "{\\"clients\\":[{\\"id\\":\\"uuid-here\\",\\"email\\":\\"user1\\",\\"limitIp\\":2,\\"totalGB\\":10737418240,\\"expiryTime\\":1735689600000,\\"enable\\":true}]}"\n}',
},
{
method: 'POST',
path: '/panel/api/inbounds/:id/resetTraffic',
summary: 'Zero out upload + download counters for a single inbound. Does not touch per-client counters.',
params: [
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
],
},
{
method: 'POST',
path: '/panel/api/inbounds/:id/resetClientTraffic/:email',
@@ -209,6 +249,7 @@ export const sections = [
method: 'POST',
path: '/panel/api/inbounds/lastOnline',
summary: 'Map of client email → last-seen unix timestamp.',
response: '{\n "success": true,\n "obj": [\n { "email": "user1", "lastOnline": 1700000000 },\n { "email": "user2", "lastOnline": 1699999000 }\n ]\n}',
},
{
method: 'GET',
@@ -256,7 +297,7 @@ export const sections = [
{
id: 'server',
title: 'Server API',
title: 'Server',
description:
'System status, log retrieval, certificate generators, Xray binary management, and backup/restore. All under /panel/api/server.',
endpoints: [
@@ -264,6 +305,7 @@ export const sections = [
method: 'GET',
path: '/panel/api/server/status',
summary: 'Real-time machine snapshot: CPU, memory, swap, disk, network IO, load averages, open connections, Xray state. Cached and refreshed every 2 seconds in the background.',
response: '{\n "success": true,\n "obj": {\n "cpu": 12.5,\n "mem": { "current": 2147483648, "total": 8589934592 },\n "swap": { "current": 0, "total": 4294967296 },\n "disk": { "current": 53687091200, "total": 268435456000 },\n "netIO": { "up": 1073741824, "down": 2147483648 },\n "xray": { "state": "running", "version": "v25.10.31" },\n "tcpCount": 42,\n "load": { "load1": 0.5, "load5": 0.3, "load15": 0.2 }\n }\n}',
},
{
method: 'GET',
@@ -278,7 +320,36 @@ export const sections = [
path: '/panel/api/server/history/:metric/:bucket',
summary: 'Aggregated time-series for one metric. Returns an array of {t, v} samples covering the last ~6 hours.',
params: [
{ name: 'metric', in: 'path', type: 'string', desc: 'cpu | mem | swap | netIn | netOut | tcpCount | udpCount | load1 | online.' },
{ name: 'metric', in: 'path', type: 'string', desc: 'cpu | mem | netUp | netDown | online | load1 | load5 | load15.' },
{ name: 'bucket', in: 'path', type: 'number', desc: 'Bucket size in seconds. Allowed: 2, 30, 60, 120, 180, 300.' },
],
response: '{\n "success": true,\n "obj": [\n { "t": 1700000000, "v": 12.5 },\n { "t": 1700000002, "v": 13.1 }\n ]\n}',
},
{
method: 'GET',
path: '/panel/api/server/xrayMetricsState',
summary: 'Xray runtime metrics state — whether the xray config has a `metrics` block, which expvar keys are flowing, and the current snapshot values for each. Returns an empty state when metrics are not configured.',
},
{
method: 'GET',
path: '/panel/api/server/xrayMetricsHistory/:metric/:bucket',
summary: 'Time-series history for one Xray runtime metric over the last ~6 hours. Same {t, v} shape as /history/:metric/:bucket.',
params: [
{ name: 'metric', in: 'path', type: 'string', desc: 'xrAlloc | xrSys | xrHeapObjects | xrNumGC | xrPauseNs.' },
{ name: 'bucket', in: 'path', type: 'number', desc: 'Bucket size in seconds. Allowed: 2, 30, 60, 120, 180, 300.' },
],
},
{
method: 'GET',
path: '/panel/api/server/xrayObservatory',
summary: 'Latest snapshot from the Xray observatory — per-outbound latency, health status, and last-probe time. Only populated when the Xray config has an observatory configured.',
},
{
method: 'GET',
path: '/panel/api/server/xrayObservatoryHistory/:tag/:bucket',
summary: 'Time-series of observatory probe results for one outbound tag. Same {t, v} shape as the other history endpoints.',
params: [
{ name: 'tag', in: 'path', type: 'string', desc: 'Outbound tag from the observatory config.' },
{ name: 'bucket', in: 'path', type: 'number', desc: 'Bucket size in seconds. Allowed: 2, 30, 60, 120, 180, 300.' },
],
},
@@ -286,6 +357,7 @@ export const sections = [
method: 'GET',
path: '/panel/api/server/getXrayVersion',
summary: 'List Xray binary versions available for install on this host.',
response: '{\n "success": true,\n "obj": ["v25.10.31", "v25.9.15", "v25.8.1"]\n}',
},
{
method: 'GET',
@@ -295,7 +367,8 @@ export const sections = [
{
method: 'GET',
path: '/panel/api/server/getConfigJson',
summary: 'Return the assembled Xray config thats currently running on this host.',
summary: 'Return the assembled Xray config that\u2019s currently running on this host.',
response: '{\n "success": true,\n "obj": {\n "log": { "loglevel": "warning" },\n "inbounds": [...],\n "outbounds": [...],\n "routing": { "rules": [...] }\n }\n}',
},
{
method: 'GET',
@@ -306,36 +379,45 @@ export const sections = [
method: 'GET',
path: '/panel/api/server/getNewUUID',
summary: 'Generate a fresh UUID v4. Convenience helper for client IDs.',
response: '{\n "success": true,\n "obj": "550e8400-e29b-41d4-a716-446655440000"\n}',
},
{
method: 'GET',
path: '/panel/api/server/getNewX25519Cert',
summary: 'Generate a new X25519 keypair for Reality.',
response: '{\n "success": true,\n "obj": {\n "privateKey": "uN9qLfV3zH8w...",\n "publicKey": "5v8xPqR2sM7k..."\n }\n}',
},
{
method: 'GET',
path: '/panel/api/server/getNewmldsa65',
summary: 'Generate a new ML-DSA-65 keypair (post-quantum signature). Returns {privateKey, publicKey, seed}.',
response: '{\n "success": true,\n "obj": {\n "privateKey": "mdsa65priv...",\n "publicKey": "mdsa65pub...",\n "seed": "random-seed..."\n }\n}',
},
{
method: 'GET',
path: '/panel/api/server/getNewmlkem768',
summary: 'Generate a new ML-KEM-768 keypair (post-quantum KEM). Returns {clientKey, serverKey}.',
response: '{\n "success": true,\n "obj": {\n "clientKey": "mlkem768-client...",\n "serverKey": "mlkem768-server..."\n }\n}',
},
{
method: 'GET',
path: '/panel/api/server/getNewVlessEnc',
summary: 'Generate a new VLESS encryption keypair.',
summary: 'Generate VLESS encryption auth options. Returns an auths array each with id, label, encryption, and decryption fields.',
response: '{\n "success": true,\n "obj": {\n "auths": [\n { "id": 0, "label": "Auth #0", "encryption": "aes-256-gcm", "decryption": "" }\n ]\n }\n}',
},
{
method: 'POST',
path: '/panel/api/server/stopXrayService',
summary: 'Stop the Xray binary. All proxies go offline immediately.',
errorResponse:
'{\n "success": false,\n "msg": "Xray is not running"\n}',
},
{
method: 'POST',
path: '/panel/api/server/restartXrayService',
summary: 'Reload Xray with the current config. Typically required after structural inbound or routing changes.',
errorResponse:
'{\n "success": false,\n "msg": "Xray config is invalid: ..."\n}',
},
{
method: 'POST',
@@ -354,6 +436,10 @@ export const sections = [
method: 'POST',
path: '/panel/api/server/updateGeofile',
summary: 'Refresh the default GeoIP / GeoSite data files. Body can include a fileName, or use the /:fileName variant.',
params: [
{ name: 'fileName', in: 'body (form)', type: 'string', desc: 'Filename to update (e.g. geoip.dat, geosite.dat). Omit to update all defaults.' },
],
body: 'fileName=geoip.dat',
},
{
method: 'POST',
@@ -366,11 +452,12 @@ export const sections = [
{
method: 'POST',
path: '/panel/api/server/logs/:count',
summary: 'Return the last N lines of the panels own log.',
summary: 'Return the last N lines of the panel\u2019s own log.',
params: [
{ name: 'count', in: 'path', type: 'number', desc: 'Number of trailing log lines.' },
],
body: '{\n "level": "info",\n "syslog": false\n}',
response: '{\n "success": true,\n "obj": "2025/01/01 12:00:00 [INFO] Server started\\n2025/01/01 12:00:01 [INFO] Xray is running"\n}',
},
{
method: 'POST',
@@ -378,24 +465,38 @@ export const sections = [
summary: 'Return the last N lines of the Xray process log.',
params: [
{ name: 'count', in: 'path', type: 'number', desc: 'Number of trailing log lines.' },
{ name: 'filter', in: 'body (form)', type: 'string', desc: 'Keyword filter — only lines containing this string.' },
{ name: 'showDirect', in: 'body (form)', type: 'string', desc: '"true" to include direct (freedom) traffic lines.' },
{ name: 'showBlocked', in: 'body (form)', type: 'string', desc: '"true" to include blocked (blackhole) traffic lines.' },
{ name: 'showProxy', in: 'body (form)', type: 'string', desc: '"true" to include proxy traffic lines.' },
],
body: 'filter=error&showDirect=false&showBlocked=true&showProxy=true',
response: '{\n "success": true,\n "obj": "2025/01/01 12:00:00 rejected vless proxy example.com reason: no valid user\\n2025/01/01 12:00:01 direct freedom ok"\n}',
},
{
method: 'POST',
path: '/panel/api/server/importDB',
summary: 'Restore the panel DB from an uploaded SQLite file (multipart form, field name "db"). The panel restarts after restore. Destructive.',
params: [
{ name: 'db', in: 'body (multipart)', type: 'file', desc: 'SQLite database file to upload.' },
],
},
{
method: 'POST',
path: '/panel/api/server/getNewEchCert',
summary: 'Generate a new ECH (Encrypted Client Hello) keypair. Body picks the algorithm.',
summary: 'Generate a new ECH (Encrypted Client Hello) keypair and config list for the given SNI.',
params: [
{ name: 'sni', in: 'body (form)', type: 'string', desc: 'Server Name Indication to generate the ECH config for.' },
],
body: 'sni=example.com',
response: '{\n "success": true,\n "obj": {\n "echKeySet": "...",\n "echServerKeys": [...],\n "echConfigList": "..."\n }\n}',
},
],
},
{
id: 'nodes',
title: 'Nodes API',
title: 'Nodes',
description:
'Manage remote 3x-ui panels acting as nodes for a central panel. All endpoints under /panel/api/nodes.',
endpoints: [
@@ -403,6 +504,7 @@ export const sections = [
method: 'GET',
path: '/panel/api/nodes/list',
summary: 'List every configured node with its connection details, health, and last heartbeat patch.',
response: '{\n "success": true,\n "obj": [\n {\n "id": 1,\n "name": "de-fra-1",\n "scheme": "https",\n "host": "node1.example.com",\n "port": 2053,\n "status": "online",\n "cpu": 23.5,\n "mem": 45.1\n }\n ]\n}',
},
{
method: 'GET',
@@ -422,10 +524,11 @@ export const sections = [
{
method: 'POST',
path: '/panel/api/nodes/update/:id',
summary: 'Replace a nodes connection details. Same body shape as /add.',
summary: 'Replace a node\u2019s connection details. Same body shape as /add.',
params: [
{ name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
],
body: '{\n "name": "de-fra-1",\n "scheme": "https",\n "host": "node1.example.com",\n "port": 2053,\n "basePath": "/",\n "apiToken": "abcdef..."\n}',
},
{
method: 'POST',
@@ -448,6 +551,8 @@ export const sections = [
method: 'POST',
path: '/panel/api/nodes/test',
summary: 'Probe a node without saving it. Uses the body as connection details and returns whether the handshake succeeds.',
body: '{\n "scheme": "https",\n "host": "node1.example.com",\n "port": 2053,\n "basePath": "/",\n "apiToken": "abcdef..."\n}',
response: '{\n "success": true,\n "obj": {\n "status": "online",\n "cpu": 12.5,\n "mem": 45.2\n }\n}',
},
{
method: 'POST',
@@ -463,16 +568,16 @@ export const sections = [
summary: 'Aggregated metric history for a node — same shape as /server/history, scoped to one node.',
params: [
{ name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
{ name: 'metric', in: 'path', type: 'string', desc: 'Metric key (cpu, mem, netIn, …).' },
{ name: 'bucket', in: 'path', type: 'number', desc: 'Bucket size in seconds.' },
{ name: 'metric', in: 'path', type: 'string', desc: 'cpu | mem.' },
{ name: 'bucket', in: 'path', type: 'number', desc: 'Bucket size in seconds. Allowed: 2, 30, 60, 120, 180, 300.' },
],
},
],
},
{
id: 'customGeo',
title: 'Custom Geo API',
id: 'custom-geo',
title: 'Custom Geo',
description:
'Manage user-supplied GeoIP / GeoSite source files. All endpoints under /panel/api/custom-geo.',
endpoints: [
@@ -531,12 +636,268 @@ export const sections = [
description: 'Operations that interact with the configured Telegram bot.',
endpoints: [
{
method: 'GET',
method: 'POST',
path: '/panel/api/backuptotgbot',
summary: 'Send a fresh DB backup to every Telegram chat configured as an admin recipient. No body, no params.',
},
],
},
{
id: 'settings',
title: 'Settings',
description:
'Panel configuration and user credentials. All endpoints live under /panel/setting and require a logged-in session or Bearer token.',
endpoints: [
{
method: 'POST',
path: '/panel/setting/all',
summary: 'Return every panel setting: web server, Telegram bot, subscription, security, LDAP. The full JSON blob that the Settings page edits.',
response: '{\n "success": true,\n "obj": {\n "webPort": 2053,\n "webCertFile": "",\n "webKeyFile": "",\n "webBasePath": "/",\n "subPort": 10882,\n "subPath": "/sub/",\n "tgBotEnable": false,\n "tgBotToken": "",\n ...\n }\n}',
},
{
method: 'POST',
path: '/panel/setting/defaultSettings',
summary: 'Return the computed default settings based on the request host. Useful to preview what a fresh install would use.',
},
{
method: 'POST',
path: '/panel/setting/update',
summary: 'Persist every setting at once. The body mirrors the shape returned by /all. Invalid values (bad ports, missing cert pairs, etc.) are rejected before write.',
body: '{\n "webPort": 2053,\n "webBasePath": "/",\n "subPort": 10882,\n "subPath": "/sub/",\n "tgBotEnable": false,\n ...\n}',
},
{
method: 'POST',
path: '/panel/setting/updateUser',
summary: 'Change the panel admin username and password. Requires the current credentials for verification. The session is refreshed with the new values on success.',
params: [
{ name: 'oldUsername', in: 'body', type: 'string', desc: 'Current admin username.' },
{ name: 'oldPassword', in: 'body', type: 'string', desc: 'Current admin password.' },
{ name: 'newUsername', in: 'body', type: 'string', desc: 'Desired new username.' },
{ name: 'newPassword', in: 'body', type: 'string', desc: 'Desired new password.' },
],
body: '{\n "oldUsername": "admin",\n "oldPassword": "admin",\n "newUsername": "newadmin",\n "newPassword": "newpass"\n}',
},
{
method: 'POST',
path: '/panel/setting/restartPanel',
summary: 'Restart the entire 3x-ui process after a 3-second grace period. The connection drops immediately; the panel comes back online ~5-10 seconds later.',
},
{
method: 'GET',
path: '/panel/setting/getDefaultJsonConfig',
summary: 'Return the built-in default Xray JSON config template that ships with this panel version.',
},
],
},
{
id: 'api-tokens',
title: 'API Tokens',
description:
'Manage Bearer tokens used for programmatic auth (bots, central panels acting on this node, CI). Each token has a unique name and an enabled flag — disable to revoke without deleting, delete to revoke permanently. Tokens are stored plaintext so the SPA can show them on demand. Send one as <code>Authorization: Bearer &lt;token&gt;</code> on any /panel/api/* request.',
endpoints: [
{
method: 'GET',
path: '/panel/setting/apiTokens',
summary: 'List every API token, enabled or not.',
response: '{\n "success": true,\n "obj": [\n {\n "id": 1,\n "name": "default",\n "token": "abcdef-12345-...",\n "enabled": true,\n "createdAt": 1736000000\n }\n ]\n}',
},
{
method: 'POST',
path: '/panel/setting/apiTokens/create',
summary: 'Mint a new API token. Name must be unique and 1-64 characters; the token string is server-generated.',
params: [
{ name: 'name', in: 'body', type: 'string', desc: 'Human-readable label, e.g. "central-panel-a".' },
],
body: '{\n "name": "central-panel-a"\n}',
response: '{\n "success": true,\n "obj": {\n "id": 2,\n "name": "central-panel-a",\n "token": "new-token-string",\n "enabled": true,\n "createdAt": 1736000000\n }\n}',
errorResponse: '{\n "success": false,\n "msg": "a token with that name already exists"\n}',
},
{
method: 'POST',
path: '/panel/setting/apiTokens/delete/:id',
summary: 'Permanently delete a token. Any caller using it stops authenticating immediately.',
params: [
{ name: 'id', in: 'path', type: 'number', desc: 'Token row ID.' },
],
response: '{\n "success": true\n}',
},
{
method: 'POST',
path: '/panel/setting/apiTokens/setEnabled/:id',
summary: 'Toggle a token enabled/disabled without deleting it. Disabled tokens are rejected by checkAPIAuth on the next request.',
params: [
{ name: 'id', in: 'path', type: 'number', desc: 'Token row ID.' },
{ name: 'enabled', in: 'body', type: 'boolean', desc: 'New enabled state.' },
],
body: '{\n "enabled": false\n}',
response: '{\n "success": true\n}',
},
],
},
{
id: 'xray-settings',
title: 'Xray Settings',
description:
'Xray configuration template, outbound management, Warp/Nord integration, and config testing. All endpoints under /panel/xray.',
endpoints: [
{
method: 'POST',
path: '/panel/xray/',
summary: 'Return the Xray config template (JSON string), available inbound tags, client reverse tags, and the configured outbound test URL in one response.',
response: '{\n "success": true,\n "obj": {\n "xraySetting": "{...raw xray config...}",\n "inboundTags": "[\\"inbound-443\\"]",\n "clientReverseTags": "[]",\n "outboundTestUrl": "https://www.google.com/generate_204"\n }\n}',
},
{
method: 'GET',
path: '/panel/xray/getDefaultJsonConfig',
summary: 'Return the built-in default Xray config shipped with the panel (identical to /panel/setting/getDefaultJsonConfig).',
},
{
method: 'GET',
path: '/panel/xray/getOutboundsTraffic',
summary: 'Return traffic statistics for every outbound. Each outbound shows up/down/total counters.',
},
{
method: 'GET',
path: '/panel/xray/getXrayResult',
summary: 'Return the most recent Xray process stdout/stderr output. Useful to check for startup errors or runtime warnings.',
},
{
method: 'POST',
path: '/panel/xray/update',
summary: 'Save the Xray JSON config template and optionally the outbound test URL. Both are sent as form fields.',
params: [
{ name: 'xraySetting', in: 'body (form)', type: 'string', desc: 'Full Xray JSON config template.' },
{ name: 'outboundTestUrl', in: 'body (form)', type: 'string', desc: 'URL used for outbound reachability tests. Defaults to https://www.google.com/generate_204.' },
],
},
{
method: 'POST',
path: '/panel/xray/warp/:action',
summary: 'Manage Cloudflare Warp integration. The action parameter selects the operation.',
params: [
{ name: 'action', in: 'path', type: 'string', desc: 'data — return Warp stats (quota, remaining). del — delete Warp data. config — return current Warp config. reg — register a new Warp endpoint (sends privateKey, publicKey). license — set a Warp+ license key (sends license).' },
{ name: 'privateKey', in: 'body (form)', type: 'string', desc: 'Required when action=reg.' },
{ name: 'publicKey', in: 'body (form)', type: 'string', desc: 'Required when action=reg.' },
{ name: 'license', in: 'body (form)', type: 'string', desc: 'Required when action=license.' },
],
},
{
method: 'POST',
path: '/panel/xray/nord/:action',
summary: 'Manage NordVPN integration. The action parameter selects the operation.',
params: [
{ name: 'action', in: 'path', type: 'string', desc: 'countries — list available countries. servers — list servers in a country (sends countryId). reg — get NordVPN credentials (sends token). setKey — store NordVPN API key (sends key). data — return current NordVPN connection data. del — delete NordVPN data.' },
{ name: 'countryId', in: 'body (form)', type: 'string', desc: 'Required when action=servers.' },
{ name: 'token', in: 'body (form)', type: 'string', desc: 'Required when action=reg.' },
{ name: 'key', in: 'body (form)', type: 'string', desc: 'Required when action=setKey.' },
],
},
{
method: 'POST',
path: '/panel/xray/resetOutboundsTraffic',
summary: 'Reset traffic counters for a specific outbound by tag.',
params: [
{ name: 'tag', in: 'body (form)', type: 'string', desc: 'Outbound tag to reset (e.g. "proxy", "direct").' },
],
body: 'tag=proxy',
},
{
method: 'POST',
path: '/panel/xray/testOutbound',
summary: 'Test an outbound configuration. Sends the outbound JSON (required), optionally all outbounds (to resolve sockopt.dialerProxy dependencies), and a mode flag.',
params: [
{ name: 'outbound', in: 'body (form)', type: 'string', desc: 'JSON-encoded single outbound to test (required).' },
{ name: 'allOutbounds', in: 'body (form)', type: 'string', desc: 'JSON array of all outbounds — used to resolve dialerProxy chains.' },
{ name: 'mode', in: 'body (form)', type: 'string', desc: '"tcp" for a fast dial-only probe (parallel-safe). Default/empty uses a full HTTP probe through a temp xray instance.' },
],
body: 'outbound={"protocol":"freedom","settings":{}}&mode=tcp',
},
],
},
{
id: 'subscription',
title: 'Subscription Server',
description:
'A separate HTTP/HTTPS server that serves proxy subscription links (standard, JSON, and Clash) to clients. The server listens on its own port (default 10882) and is configured in Settings → Subscription. Paths are configurable; defaults are shown below. All subscription endpoints set response headers for client apps to read traffic/expiry info.',
subHeader: [
{ name: 'Subscription-Userinfo', desc: 'Traffic and expiry: <code>upload=N; download=N; total=N; expire=TS</code>' },
{ name: 'Profile-Title', desc: 'Base64-encoded subscription display name' },
{ name: 'Profile-Web-Page-Url', desc: 'Link to the subscription info page' },
{ name: 'Support-Url', desc: 'Support contact URL configured in settings' },
{ name: 'Profile-Update-Interval', desc: 'Suggested polling interval in minutes (e.g. <code>10</code>)' },
{ name: 'Announce', desc: 'Base64-encoded announcement string' },
{ name: 'Routing-Enable', desc: '<code>true</code> or <code>false</code> — whether routing rules are included' },
{ name: 'Routing', desc: 'Global routing rules for client apps that support them (e.g. Happ)' },
],
endpoints: [
{
method: 'GET',
path: '/{subPath}:subid',
summary: 'Return base64-encoded subscription links for all enabled clients matching the subscription ID. When the request has an Accept: text/html header or ?html=1, renders a styled info page instead. Default path: /sub/:subid.',
params: [
{ name: 'subid', in: 'path', type: 'string', desc: 'Client subscription ID.' },
],
},
{
method: 'GET',
path: '/{jsonPath}:subid',
summary: 'Return subscription as a JSON array of proxy configs (one per enabled client). Only when JSON subscription is enabled in settings. Default path: /json/:subid.',
params: [
{ name: 'subid', in: 'path', type: 'string', desc: 'Client subscription ID.' },
],
},
{
method: 'GET',
path: '/{clashPath}:subid',
summary: 'Return subscription as a Clash/Mihomo-compatible YAML config. Only when Clash subscription is enabled in settings. Default path: /clash/:subid.',
params: [
{ name: 'subid', in: 'path', type: 'string', desc: 'Client subscription ID.' },
],
},
],
},
{
id: 'websocket',
title: 'WebSocket',
description:
'Real-time status updates via WebSocket. Connect once at <code>ws://<panel>/ws</code> to receive a stream of JSON messages without polling. Requires an authenticated session cookie (Bearer token auth is not supported). Each message has a <code>type</code> field that identifies the payload shape.',
endpoints: [
{
method: 'GET',
path: '/ws',
summary: 'Upgrade an HTTP connection to a WebSocket. Requires an authenticated session cookie (Bearer token auth is not supported here). Returns 101 Switching Protocols on success. The server then pushes JSON messages described below.',
},
{
method: 'WS',
path: '→ type: status',
summary: 'Server health snapshot pushed every 2 seconds. Contains CPU, memory, swap, disk, network IO, load, and Xray state — same shape as <code>GET /panel/api/server/status</code>.',
response: '{\n "type": "status",\n "data": { "cpu": 12.5, "mem": { "current": 2147483648, "total": 8589934592 }, "xray": { "state": "running" } }\n}',
},
{
method: 'WS',
path: '→ type: xrayState',
summary: 'Xray process state change. Fired when Xray starts, stops, or encounters an error.',
response: '{\n "type": "xrayState",\n "data": "running"\n}',
},
{
method: 'WS',
path: '→ type: notification',
summary: 'In-panel toast notification. Fired on Xray stop/restart, DB import, panel restart, etc.',
response: '{\n "type": "notification",\n "title": "Xray service restarted",\n "body": "Xray has been restarted successfully",\n "severity": "success"\n}',
},
{
method: 'WS',
path: '→ type: invalidate',
summary: 'Instructs the UI to re-fetch a resource. Fired when another admin session modifies data (e.g. toggling inbound enable).',
response: '{\n "type": "invalidate",\n "resource": "inbounds"\n}',
},
],
},
];
export const methodColors = {
@@ -545,4 +906,5 @@ export const methodColors = {
PUT: 'orange',
PATCH: 'orange',
DELETE: 'red',
WS: 'purple',
};

View File

@@ -32,6 +32,8 @@ const props = defineProps({
lastOnlineMap: { type: Object, default: () => ({}) },
isDarkTheme: { type: Boolean, default: false },
pageSize: { type: Number, default: 0 },
totalClientCount: { type: Number, default: 0 },
statsVersion: { type: Number, default: 0 },
});
const emit = defineEmits([
@@ -62,7 +64,11 @@ watch([clients, () => props.pageSize], () => {
});
// === Per-client stats lookup =======================================
// statsVersion bumps on every ws merge so this computed re-evaluates
// (DBInbound isn't reactive — the in-place stat mutations alone don't
// trigger Vue's tracking).
const statsMap = computed(() => {
void props.statsVersion;
const m = new Map();
for (const cs of (props.dbInbound.clientStats || [])) m.set(cs.email, cs);
return m;
@@ -138,7 +144,7 @@ function statsExpColor(email) {
return PURPLE;
}
const isRemovable = computed(() => clients.value.length > 1);
const isRemovable = computed(() => (props.totalClientCount || clients.value.length) > 1);
function totalGbDisplay(client) {
if (!client.totalGB || client.totalGB <= 0) return '';
@@ -216,6 +222,14 @@ watch(clients, (list) => {
if (next.size !== selected.value.size) selected.value = next;
});
const statsClient = ref(null);
function openStats(client) {
statsClient.value = client;
}
function closeStats() {
statsClient.value = null;
}
function confirmBulkDelete() {
const picked = clients.value.filter((c) => selected.value.has(rowKey(c)));
if (picked.length === 0) return;
@@ -432,6 +446,9 @@ function confirmBulkDelete() {
<span class="client-email">{{ client.email }}</span>
</a-tooltip>
<div class="client-card-actions">
<a-tooltip :title="t('info')">
<InfoCircleOutlined class="row-icon" @click="openStats(client)" />
</a-tooltip>
<a-switch :checked="client.enable" size="small"
@change="(next) => emit('toggle-enable-client', { dbInbound, client, next })" />
<a-dropdown :trigger="['click']" placement="bottomRight">
@@ -458,52 +475,55 @@ function confirmBulkDelete() {
</a-dropdown>
</div>
</div>
</div>
<div v-if="client.comment && client.comment.trim()" class="client-comment-line">
{{ client.comment.length > 80 ? client.comment.substring(0, 77) + '…' : client.comment }}
</div>
<div class="client-card-foot">
<a-modal :open="!!statsClient" :footer="null" :width="360" centered
:title="statsClient ? statsClient.email || t('info') : ''" @cancel="closeStats">
<div v-if="statsClient" class="client-card-foot">
<div v-if="statsClient.comment && statsClient.comment.trim()" class="client-comment-line">
{{ statsClient.comment }}
</div>
<div class="stat-row">
<span class="stat-label">{{ t('pages.inbounds.traffic') }}</span>
<a-tag :color="clientStatsColor(client.email)">
{{ SizeFormatter.sizeFormat(getSum(client.email)) }} /
<InfinityIcon v-if="isUnlimitedTotal(client)" />
<template v-else>{{ totalGbDisplay(client) }}</template>
<a-tag :color="clientStatsColor(statsClient.email)">
{{ SizeFormatter.sizeFormat(getSum(statsClient.email)) }} /
<InfinityIcon v-if="isUnlimitedTotal(statsClient)" />
<template v-else>{{ totalGbDisplay(statsClient) }}</template>
</a-tag>
</div>
<div class="stat-row">
<span class="stat-label">{{ t('remained') }}</span>
<a-tag v-if="isUnlimitedTotal(client)" color="purple" :style="{ border: 'none' }" class="infinite-tag">
<a-tag v-if="isUnlimitedTotal(statsClient)" color="purple" :style="{ border: 'none' }" class="infinite-tag">
<InfinityIcon />
</a-tag>
<a-tag v-else :color="isClientDepleted(client.email) ? 'red' : ''">
{{ SizeFormatter.sizeFormat(getRem(client.email)) }}
<a-tag v-else :color="isClientDepleted(statsClient.email) ? 'red' : ''">
{{ SizeFormatter.sizeFormat(getRem(statsClient.email)) }}
</a-tag>
</div>
<div class="stat-row">
<span class="stat-label">{{ t('pages.inbounds.allTimeTraffic') }}</span>
<a-tag>{{ SizeFormatter.sizeFormat(getAllTime(client.email)) }}</a-tag>
<a-tag>{{ SizeFormatter.sizeFormat(getAllTime(statsClient.email)) }}</a-tag>
</div>
<div class="stat-row">
<span class="stat-label">{{ t('online') }}</span>
<a-tag v-if="client.enable && isClientOnline(client.email)" color="green">{{ t('online') }}</a-tag>
<a-tag v-if="statsClient.enable && isClientOnline(statsClient.email)" color="green">{{ t('online') }}</a-tag>
<a-tag v-else>{{ t('offline') }}</a-tag>
</div>
<div class="stat-row">
<span class="stat-label">{{ t('pages.inbounds.expireDate') }}</span>
<a-tag v-if="client.expiryTime > 0" :color="ColorUtils.userExpiryColor(expireDiff, client, isDarkTheme)">
{{ IntlUtil.formatRelativeTime(client.expiryTime) }}
<a-tag v-if="statsClient.expiryTime > 0"
:color="ColorUtils.userExpiryColor(expireDiff, statsClient, isDarkTheme)">
{{ IntlUtil.formatRelativeTime(statsClient.expiryTime) }}
</a-tag>
<a-tag v-else-if="client.expiryTime < 0" color="green">
{{ -client.expiryTime / 86400000 }}d ({{ t('pages.client.delayedStart') }})
<a-tag v-else-if="statsClient.expiryTime < 0" color="green">
{{ -statsClient.expiryTime / 86400000 }}d ({{ t('pages.client.delayedStart') }})
</a-tag>
<a-tag v-else color="purple">
<InfinityIcon />
</a-tag>
</div>
</div>
</div>
</a-modal>
</template>
<a-pagination v-if="pageSize > 0 && clients.length > pageSize" v-model:current="currentPage"

View File

@@ -0,0 +1,185 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { message } from 'ant-design-vue';
import { HttpUtil, SizeFormatter, IntlUtil } from '@/utils';
import { TLS_FLOW_CONTROL } from '@/models/inbound.js';
const { t } = useI18n();
const props = defineProps({
open: { type: Boolean, default: false },
dbInbound: { type: Object, default: null },
dbInbounds: { type: Array, default: () => [] },
});
const emit = defineEmits(['update:open', 'saved']);
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
const sourceInboundId = ref(null);
const selectedEmails = ref([]);
const flow = ref('');
const saving = ref(false);
const sources = computed(() => {
if (!props.dbInbound) return [];
return props.dbInbounds
.filter(
(row) =>
row.id !== props.dbInbound.id &&
typeof row.isMultiUser === 'function' &&
row.isMultiUser(),
)
.map((row) => {
let count = 0;
try { count = (row.toInbound().clients || []).length; } catch (_e) { /* ignore */ }
return { id: row.id, label: `${row.remark || `#${row.id}`} (${row.protocol}, ${count})` };
});
});
const sourceInbound = computed(() => {
if (!sourceInboundId.value) return null;
return props.dbInbounds.find((r) => r.id === sourceInboundId.value) || null;
});
const sourceClients = computed(() => {
const sb = sourceInbound.value;
if (!sb) return [];
let list = [];
try { list = sb.toInbound().clients || []; } catch (_e) { /* ignore */ }
const stats = new Map((sb.clientStats || []).map((s) => [s.email, s]));
return list
.filter((c) => c.email)
.map((c) => {
const s = stats.get(c.email);
const used = s ? (s.up || 0) + (s.down || 0) : 0;
let expiryLabel = t('unlimited');
if (c.expiryTime > 0) expiryLabel = IntlUtil.formatDate(c.expiryTime);
else if (c.expiryTime < 0) expiryLabel = `${-c.expiryTime / 86400000}d`;
return { email: c.email, trafficLabel: SizeFormatter.sizeFormat(used), expiryLabel };
});
});
const showFlow = computed(() => {
if (!props.dbInbound) return false;
try {
const inb = props.dbInbound.toInbound();
return !!(inb && typeof inb.canEnableTlsFlow === 'function' && inb.canEnableTlsFlow());
} catch (_e) { return false; }
});
const columns = computed(() => [
{ title: t('pages.inbounds.email'), dataIndex: 'email', width: 280 },
{ title: t('pages.inbounds.traffic'), dataIndex: 'trafficLabel', width: 140 },
{ title: t('pages.inbounds.expireDate'), dataIndex: 'expiryLabel', width: 160 },
]);
const rowSelection = computed(() => ({
selectedRowKeys: selectedEmails.value,
onChange: (keys) => { selectedEmails.value = keys; },
}));
const title = computed(() => {
if (!props.dbInbound) return t('pages.client.copyFromInbound');
const target = props.dbInbound.remark || `#${props.dbInbound.id}`;
return `${t('pages.client.copyToInbound')} ${target}`;
});
watch(() => props.open, (next) => {
if (!next) return;
sourceInboundId.value = null;
selectedEmails.value = [];
flow.value = '';
saving.value = false;
});
watch(sourceInboundId, () => {
selectedEmails.value = [];
});
function selectAll() {
selectedEmails.value = sourceClients.value.map((c) => c.email);
}
function clearAll() {
selectedEmails.value = [];
}
async function ok() {
if (!sourceInboundId.value) {
message.error(t('pages.client.copySelectSourceFirst'));
return;
}
if (!props.dbInbound) return;
saving.value = true;
try {
const payload = {
sourceInboundId: sourceInboundId.value,
clientEmails: selectedEmails.value,
};
if (showFlow.value && flow.value) payload.flow = flow.value;
const msg = await HttpUtil.post(
`/panel/api/inbounds/${props.dbInbound.id}/copyClients`,
payload,
);
if (!msg?.success) return;
const obj = msg.obj || {};
const addedCount = (obj.added || []).length;
const errorList = obj.errors || [];
if (addedCount > 0) {
message.success(`${t('pages.client.copyResultSuccess')}: ${addedCount}`);
} else {
message.warning(t('pages.client.copyResultNone'));
}
if (errorList.length > 0) {
message.error(`${t('pages.client.copyResultErrors')}: ${errorList.join('; ')}`);
}
emit('saved');
emit('update:open', false);
} finally {
saving.value = false;
}
}
function close() {
if (saving.value) return;
emit('update:open', false);
}
</script>
<template>
<a-modal :open="open" :title="title" :ok-text="t('pages.client.copySelected')" :cancel-text="t('close')"
:confirm-loading="saving" :mask-closable="false" width="720px" @ok="ok" @cancel="close">
<a-space direction="vertical" :style="{ width: '100%' }">
<div>
<div :style="{ marginBottom: '6px' }">{{ t('pages.client.copySource') }}</div>
<a-select v-model:value="sourceInboundId" :style="{ width: '100%' }" allow-clear>
<a-select-option v-for="item in sources" :key="item.id" :value="item.id">
{{ item.label }}
</a-select-option>
</a-select>
</div>
<div v-if="sourceInboundId">
<a-space :style="{ marginBottom: '8px' }">
<a-button size="small" @click="selectAll">{{ t('pages.client.selectAll') }}</a-button>
<a-button size="small" @click="clearAll">{{ t('pages.client.clearAll') }}</a-button>
</a-space>
<a-table :columns="columns" :data-source="sourceClients" :pagination="false" size="small"
:row-key="(r) => r.email" :row-selection="rowSelection" :scroll="{ y: 280 }" />
</div>
<div v-if="showFlow">
<div :style="{ marginBottom: '6px' }">{{ t('pages.client.copyFlowLabel') }}</div>
<a-select v-model:value="flow" :style="{ width: '100%' }" allow-clear>
<a-select-option value="">{{ t('none') }}</a-select-option>
<a-select-option v-for="key in FLOW_OPTIONS" :key="key" :value="key">{{ key }}</a-select-option>
</a-select>
<div :style="{ marginTop: '4px', fontSize: '12px', opacity: 0.7 }">
{{ t('pages.client.copyFlowHint') }}
</div>
</div>
</a-space>
</a-modal>
</template>

View File

@@ -12,6 +12,7 @@ import {
SizeFormatter,
Wireguard,
} from '@/utils';
import { getRandomRealityTarget } from '@/models/reality-targets';
import {
Inbound,
Protocols,
@@ -31,6 +32,7 @@ import {
import { DBInbound } from '@/models/dbinbound.js';
import FinalMaskForm from '@/components/FinalMaskForm.vue';
import DateTimePicker from '@/components/DateTimePicker.vue';
import JsonEditor from '@/components/JsonEditor.vue';
import { useNodeList } from '@/composables/useNodeList.js';
const { t } = useI18n();
@@ -69,6 +71,7 @@ const inbound = ref(null);
const dbForm = ref(null);
const saving = ref(false);
const advancedJson = ref({ stream: '', sniffing: '', settings: '' });
const activeTabKey = ref('basic');
// Cached default cert/key paths from /panel/setting/defaultSettings —
// powers the "Set default cert" button on the TLS form.
const defaultCert = ref('');
@@ -240,9 +243,60 @@ watch(() => props.open, (next) => {
dbForm.value = freshDbForm();
primeAdvancedJson();
}
activeTabKey.value = 'basic';
fetchDefaultCertSettings();
});
function applyAdvancedJsonToBasic() {
if (!inbound.value) return true;
let parsedSettings;
let parsedStream;
let parsedSniffing;
try {
parsedSettings = advancedJson.value.settings.trim()
? JSON.parse(advancedJson.value.settings)
: inbound.value.settings?.toJson?.();
} catch (e) { message.error(`Settings JSON invalid: ${e.message}`); return false; }
try {
parsedStream = advancedJson.value.stream.trim()
? JSON.parse(advancedJson.value.stream)
: inbound.value.stream?.toJson?.();
} catch (e) { message.error(`Stream JSON invalid: ${e.message}`); return false; }
try {
parsedSniffing = advancedJson.value.sniffing.trim()
? JSON.parse(advancedJson.value.sniffing)
: inbound.value.sniffing?.toJson?.();
} catch (e) { message.error(`Sniffing JSON invalid: ${e.message}`); return false; }
try {
inbound.value = Inbound.fromJson({
port: inbound.value.port,
listen: inbound.value.listen,
protocol: inbound.value.protocol,
settings: parsedSettings,
streamSettings: parsedStream,
tag: inbound.value.tag,
sniffing: parsedSniffing,
clientStats: inbound.value.clientStats,
});
} catch (e) {
message.error(`Advanced JSON: ${e.message}`);
return false;
}
return true;
}
let isRevertingTab = false;
watch(activeTabKey, (next, prev) => {
if (isRevertingTab) { isRevertingTab = false; return; }
if (prev === 'advanced' && next !== 'advanced') {
if (!applyAdvancedJsonToBasic()) {
isRevertingTab = true;
activeTabKey.value = 'advanced';
}
}
});
// In add mode, switching protocol restamps settings + re-syncs port.
function onProtocolChange(next) {
if (props.mode === 'edit' || !inbound.value) return;
@@ -339,11 +393,9 @@ function clearMldsa65() {
inbound.value.stream.reality.settings.mldsa65Verify = '';
}
// Reality target/SNI randomizer — only available if the helper is loaded
function randomizeRealityTarget() {
if (!inbound.value?.stream?.reality) return;
if (typeof window.getRandomRealityTarget !== 'function') return;
const t = window.getRandomRealityTarget();
const t = getRandomRealityTarget();
inbound.value.stream.reality.target = t.target;
inbound.value.stream.reality.serverNames = t.sni;
}
@@ -393,16 +445,29 @@ async function fetchDefaultCertSettings() {
}
// === VLESS encryption helpers =======================================
// `xray vlessenc` returns both X25519 and ML-KEM-768 variants every
// call; the user clicks one of two buttons to pick which block goes
// into decryption/encryption.
async function getNewVlessEnc(authLabel) {
if (!authLabel || !inbound.value?.settings) return;
// `xray vlessenc` returns both X25519 and ML-KEM-768 auth variants every
// call; the user clicks one button to pick which block goes into
// decryption/encryption. Both generated strings share the same hybrid
// mlkem768x25519plus prefix; the auth choice is the final key block.
function normalizeVlessAuthLabel(label = '') {
return label.toLowerCase().replace(/[-_\s]/g, '');
}
function matchesVlessAuth(block, authId) {
if (block?.id === authId) return true;
const label = normalizeVlessAuthLabel(block?.label);
if (authId === 'mlkem768') return label.includes('mlkem768');
if (authId === 'x25519') return label.includes('x25519');
return false;
}
async function getNewVlessEnc(authId) {
if (!authId || !inbound.value?.settings) return;
saving.value = true;
try {
const msg = await HttpUtil.get('/panel/api/server/getNewVlessEnc');
if (!msg?.success) return;
const block = (msg.obj?.auths || []).find((a) => a.label === authLabel);
const block = (msg.obj?.auths || []).find((a) => matchesVlessAuth(a, authId));
if (!block) return;
inbound.value.settings.decryption = block.decryption;
inbound.value.settings.encryption = block.encryption;
@@ -417,6 +482,17 @@ function clearVlessEnc() {
inbound.value.settings.encryption = 'none';
}
const selectedVlessAuth = computed(() => {
const encryption = inbound.value?.settings?.encryption;
if (!encryption || encryption === 'none') return 'None';
const parts = encryption.split('.').filter(Boolean);
const authKey = parts[parts.length - 1] || '';
if (!authKey) return 'Custom';
return authKey.length > 300 ? 'ML-KEM-768 auth' : 'X25519 auth';
});
// === SS method change tracks legacy semantics =========================
function onSSMethodChange() {
inbound.value.settings.password = RandomUtil.randomShadowsocksPassword(inbound.value.settings.method);
@@ -549,7 +625,7 @@ watch(
<template>
<a-modal :open="open" :title="title" :ok-text="okText" :cancel-text="t('close')" :confirm-loading="saving"
:mask-closable="false" width="780px" @ok="submit" @cancel="close">
<a-tabs v-if="inbound && dbForm" default-active-key="basic">
<a-tabs v-if="inbound && dbForm" v-model:active-key="activeTabKey">
<!-- ============================== BASICS ============================== -->
<a-tab-pane key="basic" :tab="t('pages.xray.basicTemplate')">
<a-form :colon="false" :label-col="{ sm: { span: 8 } }" :wrapper-col="{ sm: { span: 14 } }">
@@ -559,7 +635,7 @@ watch(
<a-form-item :label="t('pages.inbounds.remark')">
<a-input v-model:value="dbForm.remark" />
</a-form-item>
<a-form-item :label="t('pages.inbounds.deployTo')">
<a-form-item v-if="selectableNodes.length > 0" :label="t('pages.inbounds.deployTo')">
<a-select v-model:value="dbForm.nodeId" :disabled="mode === 'edit'"
:placeholder="t('pages.inbounds.localPanel')" allow-clear>
<a-select-option :value="null">{{ t('pages.inbounds.localPanel') }}</a-select-option>
@@ -604,10 +680,7 @@ watch(
</a-tab-pane>
<!-- ============================== PROTOCOL ============================== -->
<!-- TUN has no per-protocol form yet (interface/mtu/gateway live in
settings JSON), so the tab would render empty hide it until
a TUN form is added. -->
<a-tab-pane v-if="protocol !== Protocols.TUN" key="protocol" :tab="t('pages.inbounds.protocol')">
<a-tab-pane key="protocol" :tab="t('pages.inbounds.protocol')">
<!-- Multi-user inbounds: in add mode embed the first client form,
in edit mode show a count summary. -->
<template v-if="isMultiUser">
@@ -731,14 +804,17 @@ watch(
</a-form-item>
<a-form-item label=" ">
<a-space :size="8" wrap>
<a-button type="primary" :loading="saving" @click="getNewVlessEnc('X25519, not Post-Quantum')">
X25519
<a-button type="primary" :loading="saving" @click="getNewVlessEnc('x25519')">
X25519 auth
</a-button>
<a-button type="primary" :loading="saving" @click="getNewVlessEnc('ML-KEM-768, Post-Quantum')">
ML-KEM-768
<a-button type="primary" :loading="saving" @click="getNewVlessEnc('mlkem768')">
ML-KEM-768 auth
</a-button>
<a-button danger @click="clearVlessEnc">Clear</a-button>
</a-space>
<a-typography-text type="secondary" class="vless-auth-state">
Selected: {{ selectedVlessAuth }}
</a-typography-text>
</a-form-item>
</a-form>
@@ -817,24 +893,126 @@ watch(
<!-- Tunnel -->
<a-form v-if="protocol === Protocols.TUNNEL" :colon="false" :label-col="{ sm: { span: 8 } }"
:wrapper-col="{ sm: { span: 14 } }" class="mt-12">
<a-form-item label="Address">
<a-input v-model:value="inbound.settings.address" />
<a-form-item label="Rewrite address">
<a-input v-model:value="inbound.settings.rewriteAddress" />
</a-form-item>
<a-form-item label="Destination port">
<a-input-number v-model:value="inbound.settings.port" :min="1" :max="65535" />
<a-form-item label="Rewrite port">
<a-input-number v-model:value="inbound.settings.rewritePort" :min="0" :max="65535" />
</a-form-item>
<a-form-item label="Network">
<a-select v-model:value="inbound.settings.network">
<a-form-item label="Allowed network">
<a-select v-model:value="inbound.settings.allowedNetwork">
<a-select-option value="tcp,udp">TCP, UDP</a-select-option>
<a-select-option value="tcp">TCP</a-select-option>
<a-select-option value="udp">UDP</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Port map">
<a-button size="small" @click="inbound.settings.addPortMap('', '')">
<template #icon>
<PlusOutlined />
</template>
</a-button>
</a-form-item>
<a-form-item v-if="inbound.settings.portMap.length > 0" :wrapper-col="{ span: 24 }">
<a-input-group v-for="(pm, idx) in inbound.settings.portMap" :key="`pm-${idx}`" compact class="mb-8">
<a-input :style="{ width: '30%' }" v-model:value="pm.name" placeholder="5555">
<template #addonBefore>{{ idx + 1 }}</template>
</a-input>
<a-input :style="{ width: '60%' }" v-model:value="pm.value" placeholder="1.1.1.1:7777" />
<a-button @click="inbound.settings.removePortMap(idx)">
<template #icon>
<MinusOutlined />
</template>
</a-button>
</a-input-group>
</a-form-item>
<a-form-item label="Follow redirect">
<a-switch v-model:checked="inbound.settings.followRedirect" />
</a-form-item>
</a-form>
<!-- TUN -->
<a-form v-if="protocol === Protocols.TUN" :colon="false" :label-col="{ sm: { span: 8 } }"
:wrapper-col="{ sm: { span: 14 } }" class="mt-12">
<a-form-item label="Interface name">
<a-input v-model:value="inbound.settings.name" placeholder="xray0" />
</a-form-item>
<a-form-item label="MTU">
<a-input-number v-model:value="inbound.settings.mtu" :min="0" />
</a-form-item>
<a-form-item label="Gateway">
<a-button size="small" @click="inbound.settings.gateway.push('')">
<template #icon>
<PlusOutlined />
</template>
</a-button>
<a-input v-for="(_ip, j) in inbound.settings.gateway" :key="`tun-gw-${j}`"
v-model:value="inbound.settings.gateway[j]" class="mt-4"
:placeholder="j === 0 ? '10.0.0.1/16' : 'fc00::1/64'">
<template #addonAfter>
<a-button size="small" @click="inbound.settings.gateway.splice(j, 1)">
<template #icon>
<MinusOutlined />
</template>
</a-button>
</template>
</a-input>
</a-form-item>
<a-form-item label="DNS">
<a-button size="small" @click="inbound.settings.dns.push('')">
<template #icon>
<PlusOutlined />
</template>
</a-button>
<a-input v-for="(_ip, j) in inbound.settings.dns" :key="`tun-dns-${j}`"
v-model:value="inbound.settings.dns[j]" class="mt-4" :placeholder="j === 0 ? '1.1.1.1' : '8.8.8.8'">
<template #addonAfter>
<a-button size="small" @click="inbound.settings.dns.splice(j, 1)">
<template #icon>
<MinusOutlined />
</template>
</a-button>
</template>
</a-input>
</a-form-item>
<a-form-item label="User level">
<a-input-number v-model:value="inbound.settings.userLevel" :min="0" />
</a-form-item>
<a-form-item>
<template #label>
<a-tooltip
title="Windows-only. CIDRs added to the system routing table automatically so matching traffic goes through TUN.">
Auto system routes
</a-tooltip>
</template>
<a-button size="small" @click="inbound.settings.autoSystemRoutingTable.push('')">
<template #icon>
<PlusOutlined />
</template>
</a-button>
<a-input v-for="(_ip, j) in inbound.settings.autoSystemRoutingTable" :key="`tun-rt-${j}`"
v-model:value="inbound.settings.autoSystemRoutingTable[j]" class="mt-4"
:placeholder="j === 0 ? '0.0.0.0/0' : '::/0'">
<template #addonAfter>
<a-button size="small" @click="inbound.settings.autoSystemRoutingTable.splice(j, 1)">
<template #icon>
<MinusOutlined />
</template>
</a-button>
</template>
</a-input>
</a-form-item>
<a-form-item>
<template #label>
<a-tooltip
title='Physical interface for outbound traffic. Use "auto" to detect; auto-enabled when Auto system routes is set.'>
Auto outbounds interface
</a-tooltip>
</template>
<a-input v-model:value="inbound.settings.autoOutboundsInterface" placeholder="auto" />
</a-form-item>
</a-form>
<!-- WireGuard -->
<a-form v-if="protocol === Protocols.WIREGUARD" :colon="false" :label-col="{ sm: { span: 8 } }"
:wrapper-col="{ sm: { span: 14 } }" class="mt-12">
@@ -1644,6 +1822,98 @@ watch(
</a-select>
</a-form-item>
</template>
<!-- ====== Hysteria stream settings ====== -->
<!-- Per https://xtls.github.io/config/transports/hysteria.html -->
<template v-if="protocol === Protocols.HYSTERIA">
<a-form-item>
<template #label>
<a-tooltip title="Hysteria protocol version. Currently must be 2.">
Version
</a-tooltip>
</template>
<a-input-number v-model:value="inbound.stream.hysteria.version" :min="2" :max="2" />
</a-form-item>
<a-form-item>
<template #label>
<a-tooltip title="Obfuscation password. Must match between server and client.">
Obfs password
</a-tooltip>
</template>
<a-input v-model:value="inbound.stream.hysteria.auth" />
</a-form-item>
<a-form-item>
<template #label>
<a-tooltip title="Idle timeout (seconds) for a single QUIC native UDP connection.">
UDP idle timeout
</a-tooltip>
</template>
<a-input-number v-model:value="inbound.stream.hysteria.udpIdleTimeout" :min="0" />
</a-form-item>
<a-form-item label="Masquerade">
<a-switch v-model:checked="inbound.stream.hysteria.masqueradeSwitch" />
</a-form-item>
<template v-if="inbound.stream.hysteria.masqueradeSwitch">
<a-form-item label="Type">
<a-select v-model:value="inbound.stream.hysteria.masquerade.type" :style="{ width: '50%' }">
<a-select-option value="proxy">Proxy</a-select-option>
<a-select-option value="file">File</a-select-option>
<a-select-option value="string">String</a-select-option>
</a-select>
</a-form-item>
<!-- Proxy type: url / rewriteHost / insecure -->
<template v-if="inbound.stream.hysteria.masquerade.type === 'proxy'">
<a-form-item label="URL">
<a-input v-model:value="inbound.stream.hysteria.masquerade.url" placeholder="https://example.com" />
</a-form-item>
<a-form-item label="Rewrite Host">
<a-switch v-model:checked="inbound.stream.hysteria.masquerade.rewriteHost" />
</a-form-item>
<a-form-item label="Insecure">
<a-switch v-model:checked="inbound.stream.hysteria.masquerade.insecure" />
</a-form-item>
</template>
<!-- File type: dir -->
<a-form-item v-if="inbound.stream.hysteria.masquerade.type === 'file'" label="Directory">
<a-input v-model:value="inbound.stream.hysteria.masquerade.dir" placeholder="/path/to/www" />
</a-form-item>
<!-- String type: content / statusCode / headers -->
<template v-if="inbound.stream.hysteria.masquerade.type === 'string'">
<a-form-item label="Content">
<a-textarea v-model:value="inbound.stream.hysteria.masquerade.content"
:auto-size="{ minRows: 2, maxRows: 6 }" />
</a-form-item>
<a-form-item label="Status Code">
<a-input-number v-model:value="inbound.stream.hysteria.masquerade.statusCode" :min="100" :max="599"
placeholder="200" />
</a-form-item>
<a-form-item label="Headers">
<a-button size="small" @click="inbound.stream.hysteria.masquerade.addHeader('', '')">
<template #icon>
<PlusOutlined />
</template>
</a-button>
</a-form-item>
<a-form-item v-if="inbound.stream.hysteria.masquerade.headers.length > 0" :wrapper-col="{ span: 24 }">
<a-input-group v-for="(h, idx) in inbound.stream.hysteria.masquerade.headers" :key="`mh-${idx}`"
compact class="mb-8">
<a-input :style="{ width: '45%' }" v-model:value="h.name" placeholder="Name">
<template #addonBefore>{{ idx + 1 }}</template>
</a-input>
<a-input :style="{ width: '45%' }" v-model:value="h.value" placeholder="Value" />
<a-button @click="inbound.stream.hysteria.masquerade.removeHeader(idx)">
<template #icon>
<MinusOutlined />
</template>
</a-button>
</a-input-group>
</a-form-item>
</template>
</template>
</template>
</a-form>
<!-- ====== FinalMask (TCP/UDP masks + QUIC params) ====== -->
@@ -1687,16 +1957,13 @@ watch(
class="mb-12" />
<a-form layout="vertical">
<a-form-item label="settings (clients, encryption, fallbacks, )">
<a-textarea v-model:value="advancedJson.settings" :auto-size="{ minRows: 10, maxRows: 24 }"
spellcheck="false" class="json-editor" />
<JsonEditor v-model:value="advancedJson.settings" min-height="280px" max-height="520px" />
</a-form-item>
<a-form-item label="streamSettings">
<a-textarea v-model:value="advancedJson.stream" :auto-size="{ minRows: 10, maxRows: 24 }" spellcheck="false"
class="json-editor" />
<JsonEditor v-model:value="advancedJson.stream" min-height="280px" max-height="520px" />
</a-form-item>
<a-form-item label="sniffing (overrides the Sniffing tab when set)">
<a-textarea v-model:value="advancedJson.sniffing" :auto-size="{ minRows: 6, maxRows: 16 }"
spellcheck="false" class="json-editor" />
<JsonEditor v-model:value="advancedJson.sniffing" min-height="180px" max-height="360px" />
</a-form-item>
</a-form>
</a-tab-pane>
@@ -1741,9 +2008,9 @@ watch(
color: #ff4d4f;
}
.json-editor {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
.vless-auth-state {
display: block;
margin-top: 6px;
}
.client-summary {

View File

@@ -585,19 +585,50 @@ const showSubscriptionTab = computed(
</tbody>
</table>
<!-- TUN -->
<dl v-if="inbound.protocol === Protocols.TUN" class="info-list info-list-block">
<div class="info-row">
<dt>Interface name</dt>
<dd><a-tag color="green" class="value-tag">{{ inbound.settings.name }}</a-tag></dd>
</div>
<div class="info-row">
<dt>MTU</dt>
<dd><a-tag color="green">{{ inbound.settings.mtu }}</a-tag></dd>
</div>
<div v-if="inbound.settings.gateway?.length" class="info-row">
<dt>Gateway</dt>
<dd><a-tag v-for="(ip, j) in inbound.settings.gateway" :key="`tun-i-gw-${j}`" color="green"
class="value-tag">{{ ip }}</a-tag></dd>
</div>
<div v-if="inbound.settings.dns?.length" class="info-row">
<dt>DNS</dt>
<dd><a-tag v-for="(ip, j) in inbound.settings.dns" :key="`tun-i-dns-${j}`" color="green">{{ ip }}</a-tag>
</dd>
</div>
<div class="info-row">
<dt>Outbounds interface</dt>
<dd><a-tag color="green">{{ inbound.settings.autoOutboundsInterface || 'auto' }}</a-tag></dd>
</div>
<div v-if="inbound.settings.autoSystemRoutingTable?.length" class="info-row">
<dt>Auto system routes</dt>
<dd><a-tag v-for="(cidr, j) in inbound.settings.autoSystemRoutingTable" :key="`tun-i-rt-${j}`"
color="green">{{ cidr }}</a-tag></dd>
</div>
</dl>
<!-- Tunnel -->
<dl v-if="inbound.protocol === Protocols.TUNNEL" class="info-list info-list-block">
<div class="info-row">
<dt>{{ t('pages.inbounds.targetAddress') }}</dt>
<dd><a-tag color="green" class="value-tag">{{ inbound.settings.address }}</a-tag></dd>
<dd><a-tag color="green" class="value-tag">{{ inbound.settings.rewriteAddress }}</a-tag></dd>
</div>
<div class="info-row">
<dt>{{ t('pages.inbounds.destinationPort') }}</dt>
<dd><a-tag color="green">{{ inbound.settings.port }}</a-tag></dd>
<dd><a-tag color="green">{{ inbound.settings.rewritePort }}</a-tag></dd>
</div>
<div class="info-row">
<dt>{{ t('pages.inbounds.network') }}</dt>
<dd><a-tag color="green">{{ inbound.settings.network }}</a-tag></dd>
<dd><a-tag color="green">{{ inbound.settings.allowedNetwork }}</a-tag></dd>
</div>
<div class="info-row">
<dt>FollowRedirect</dt>

View File

@@ -1,5 +1,5 @@
<script setup>
import { computed, ref } from 'vue';
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import {
PlusOutlined,
@@ -49,6 +49,8 @@ const props = defineProps({
// Map node id -> node row, supplied by the parent page so each
// inbound row can render its node name without an extra fetch.
nodesById: { type: Map, default: () => new Map() },
hasActiveNode: { type: Boolean, default: false },
statsVersion: { type: Number, default: 0 },
});
const emit = defineEmits([
@@ -67,9 +69,29 @@ const emit = defineEmits([
]);
// ============ Toolbar / search & filter =============================
const enableFilter = ref(false);
const searchKey = ref('');
const filterBy = ref('');
const FILTER_STATE_KEY = 'inboundsFilterState';
const savedFilterState = (() => {
try {
return JSON.parse(localStorage.getItem(FILTER_STATE_KEY) || '{}');
} catch (_e) {
return {};
}
})();
const enableFilter = ref(!!savedFilterState.enableFilter);
const searchKey = ref(savedFilterState.searchKey || '');
const filterBy = ref(savedFilterState.filterBy || '');
const protocolFilter = ref(savedFilterState.protocolFilter || '');
const nodeFilter = ref(savedFilterState.nodeFilter || '');
watch([enableFilter, searchKey, filterBy, protocolFilter, nodeFilter], () => {
localStorage.setItem(FILTER_STATE_KEY, JSON.stringify({
enableFilter: enableFilter.value,
searchKey: searchKey.value,
filterBy: filterBy.value,
protocolFilter: protocolFilter.value,
nodeFilter: nodeFilter.value,
}));
});
// Toggle the filter mode — flip cleans the other input.
function onToggleFilter() {
@@ -77,6 +99,35 @@ function onToggleFilter() {
else filterBy.value = '';
}
const protocolOptions = computed(() => {
const values = new Set(props.dbInbounds.map((i) => i.protocol).filter(Boolean));
return [...values].sort();
});
const nodeOptions = computed(() => {
const values = new Map();
if (props.dbInbounds.some((i) => i.nodeId == null)) {
values.set('local', t('pages.inbounds.localPanel'));
}
for (const dbInbound of props.dbInbounds) {
if (dbInbound.nodeId == null) continue;
const node = props.nodesById.get(dbInbound.nodeId);
values.set(String(dbInbound.nodeId), node?.name || `#${dbInbound.nodeId}`);
}
return [...values.entries()].map(([value, label]) => ({ value, label }));
});
function applySecondaryFilters(rows) {
return rows.filter((dbInbound) => {
if (protocolFilter.value && dbInbound.protocol !== protocolFilter.value) return false;
if (nodeFilter.value) {
const nodeValue = dbInbound.nodeId == null ? 'local' : String(dbInbound.nodeId);
if (nodeValue !== nodeFilter.value) return false;
}
return true;
});
}
// ============ Search / filter projection =============================
// Mirrors the legacy logic: when searching, keep inbounds that match
// anywhere (deep search); when filtering, keep inbounds that have at
@@ -99,7 +150,7 @@ function projectInbound(dbInbound, predicate) {
const visibleInbounds = computed(() => {
if (enableFilter.value) {
if (ObjectUtil.isEmpty(filterBy.value)) return [...props.dbInbounds];
if (ObjectUtil.isEmpty(filterBy.value)) return applySecondaryFilters([...props.dbInbounds]);
const out = [];
for (const dbInbound of props.dbInbounds) {
const c = props.clientCount[dbInbound.id];
@@ -107,15 +158,65 @@ const visibleInbounds = computed(() => {
const list = c[filterBy.value];
out.push(projectInbound(dbInbound, (client) => list.includes(client.email)));
}
return out;
return applySecondaryFilters(out);
}
if (ObjectUtil.isEmpty(searchKey.value)) return [...props.dbInbounds];
if (ObjectUtil.isEmpty(searchKey.value)) return applySecondaryFilters([...props.dbInbounds]);
const out = [];
for (const dbInbound of props.dbInbounds) {
if (!ObjectUtil.deepSearch(dbInbound, searchKey.value)) continue;
out.push(projectInbound(dbInbound, (client) => ObjectUtil.deepSearch(client, searchKey.value)));
}
return out;
return applySecondaryFilters(out);
});
// ============ Sorting =================================================
const sortState = ref({ column: null, order: null });
function sortableCol(col, key) {
return {
...col,
sorter: true,
showSorterTooltip: false,
sortOrder: sortState.value.column === key ? sortState.value.order : null,
sortDirections: ['ascend', 'descend'],
};
}
const sortFns = {
id: (a, b) => a.id - b.id,
enable: (a, b) => Number(a.enable) - Number(b.enable),
remark: (a, b) => (a.remark || '').localeCompare(b.remark || ''),
port: (a, b) => a.port - b.port,
protocol: (a, b) => a.protocol.localeCompare(b.protocol),
traffic: (a, b) => (a.up + a.down) - (b.up + b.down),
allTimeInbound: (a, b) => (a.allTime || 0) - (b.allTime || 0),
expiryTime: (a, b) => (a.expiryTime || Infinity) - (b.expiryTime || Infinity),
node: (a, b) => {
const nameA = props.nodesById.get(a.nodeId)?.name ?? (a.nodeId == null ? '\uffff' : `node #${a.nodeId}`);
const nameB = props.nodesById.get(b.nodeId)?.name ?? (b.nodeId == null ? '\uffff' : `node #${b.nodeId}`);
return nameA.localeCompare(nameB);
},
clients: (a, b) => (props.clientCount[a.id]?.clients || 0) - (props.clientCount[b.id]?.clients || 0),
};
const sortedInbounds = computed(() => {
const { column, order } = sortState.value;
if (!column || !order) return visibleInbounds.value;
const fn = sortFns[column];
if (!fn) return visibleInbounds.value;
const sorted = [...visibleInbounds.value].sort(fn);
return order === 'descend' ? sorted.reverse() : sorted;
});
function onTableChange(_pag, _filters, sorter) {
sortState.value = {
column: sorter?.columnKey || sorter?.field || null,
order: sorter?.order || null,
};
}
watch([searchKey, filterBy], () => {
sortState.value = { column: null, order: null };
});
// ============ Columns =================================================
@@ -128,23 +229,23 @@ const hasAnyRemark = computed(() =>
const desktopColumns = computed(() => {
const cols = [
{ title: 'ID', dataIndex: 'id', key: 'id', align: 'right', width: 30 },
{ title: t('pages.inbounds.operate'), key: 'action', align: 'center', width: 30 },
{ title: t('pages.inbounds.enable'), key: 'enable', align: 'center', width: 35 },
sortableCol({ title: 'ID', dataIndex: 'id', key: 'id', align: 'right', width: 30 }, 'id'),
{ title: t('pages.inbounds.operate'), key: 'action', align: 'center', width: 60 },
sortableCol({ title: t('pages.inbounds.enable'), key: 'enable', align: 'center', width: 35 }, 'enable'),
];
if (hasAnyRemark.value) {
cols.push({ title: t('pages.inbounds.remark'), dataIndex: 'remark', key: 'remark', align: 'center', width: 60 });
cols.push(sortableCol({ title: t('pages.inbounds.remark'), dataIndex: 'remark', key: 'remark', align: 'center', width: 60 }, 'remark'));
}
if (props.nodesById.size > 0) {
cols.push({ title: t('pages.inbounds.node'), key: 'node', align: 'center', width: 60 });
if (props.hasActiveNode) {
cols.push(sortableCol({ title: t('pages.inbounds.node'), key: 'node', align: 'center', width: 60 }, 'node'));
}
cols.push(
{ title: t('pages.inbounds.port'), dataIndex: 'port', key: 'port', align: 'center', width: 40 },
{ title: t('pages.inbounds.protocol'), key: 'protocol', align: 'left', width: 130 },
{ title: t('clients'), key: 'clients', align: 'left', width: 50 },
{ title: t('pages.inbounds.traffic'), key: 'traffic', align: 'center', width: 90 },
{ title: t('pages.inbounds.allTimeTraffic'), key: 'allTimeInbound', align: 'center', width: 95 },
{ title: t('pages.inbounds.expireDate'), key: 'expiryTime', align: 'center', width: 40 },
sortableCol({ title: t('pages.inbounds.port'), dataIndex: 'port', key: 'port', align: 'center', width: 40 }, 'port'),
sortableCol({ title: t('pages.inbounds.protocol'), key: 'protocol', align: 'left', width: 130 }, 'protocol'),
sortableCol({ title: t('clients'), key: 'clients', align: 'left', width: 50 }, 'clients'),
sortableCol({ title: t('pages.inbounds.traffic'), key: 'traffic', align: 'center', width: 90 }, 'traffic'),
sortableCol({ title: t('pages.inbounds.allTimeTraffic'), key: 'allTimeInbound', align: 'center', width: 95 }, 'allTimeInbound'),
sortableCol({ title: t('pages.inbounds.expireDate'), key: 'expiryTime', align: 'center', width: 40 }, 'expiryTime'),
);
return cols;
});
@@ -163,6 +264,14 @@ function isExpanded(id) {
return expandedIds.value.has(id);
}
const statsRecord = ref(null);
function openStats(record) {
statsRecord.value = record;
}
function closeStats() {
statsRecord.value = null;
}
// ============ Pagination ============================================
function paginationFor(rows) {
const size = props.pageSize > 0 ? props.pageSize : rows.length || 1;
@@ -269,20 +378,35 @@ function showQrCodeMenu(dbInbound) {
<a-radio-button value="expiring">{{ t('depletingSoon') }}</a-radio-button>
<a-radio-button value="online">{{ t('online') }}</a-radio-button>
</a-radio-group>
<a-select v-model:value="protocolFilter" allow-clear :placeholder="t('pages.inbounds.protocol')"
:size="isMobile ? 'small' : 'middle'" :style="{ width: '150px' }">
<a-select-option v-for="protocol in protocolOptions" :key="protocol" :value="protocol">
{{ protocol }}
</a-select-option>
</a-select>
<a-select v-if="hasActiveNode && nodeOptions.length > 0" v-model:value="nodeFilter" allow-clear
:placeholder="t('pages.inbounds.node')" :size="isMobile ? 'small' : 'middle'" :style="{ width: '170px' }">
<a-select-option v-for="node in nodeOptions" :key="node.value" :value="node.value">
{{ node.label }}
</a-select-option>
</a-select>
</div>
<!-- ====================== Mobile: card list ======================= -->
<div v-if="isMobile" class="inbound-cards">
<div v-if="visibleInbounds.length === 0" class="card-empty"></div>
<div v-for="record in visibleInbounds" :key="record.id" class="inbound-card">
<!-- Header: chevron (multi-user only) + remark + enable + actions -->
<div v-for="record in sortedInbounds" :key="record.id" class="inbound-card">
<!-- Header: chevron (multi-user only) + id + remark + info + enable + actions -->
<div class="card-head" @click="record.isMultiUser() && toggleExpanded(record.id)">
<RightOutlined v-if="record.isMultiUser()" class="card-expand"
:class="{ 'is-expanded': isExpanded(record.id) }" />
<span class="card-id">#{{ record.id }}</span>
<span class="tag-name">{{ record.remark }}</span>
<div class="card-actions" @click.stop>
<a-tooltip :title="t('info')">
<InfoCircleOutlined class="row-action-trigger" @click="openStats(record)" />
</a-tooltip>
<a-switch :checked="record.enable" size="small" @change="(next) => onSwitchEnable(record, next)" />
<a-dropdown :trigger="['click']" placement="bottomRight">
<MoreOutlined class="row-action-trigger" @click.prevent />
@@ -340,74 +464,12 @@ function showQrCodeMenu(dbInbound) {
</div>
</div>
<!-- 2-column labelled stat grid: protocol/port/node + traffic/clients/expiry -->
<div class="card-stats">
<div class="stat-row">
<span class="stat-label">{{ t('pages.inbounds.protocol') }}</span>
<a-tag color="purple">{{ record.protocol }}</a-tag>
<template v-if="record.isVMess || record.isVLess || record.isTrojan || record.isSS">
<a-tag color="green">{{ record.toInbound().stream.network }}</a-tag>
<a-tag v-if="record.toInbound().stream.isTls" color="blue">TLS</a-tag>
<a-tag v-if="record.toInbound().stream.isReality" color="blue">Reality</a-tag>
</template>
</div>
<div class="stat-row">
<span class="stat-label">{{ t('pages.inbounds.port') }}</span>
<a-tag>{{ record.port }}</a-tag>
</div>
<div v-if="nodesById.size > 0" class="stat-row">
<span class="stat-label">{{ t('pages.inbounds.node') }}</span>
<a-tag v-if="record.nodeId == null" color="default">
{{ t('pages.inbounds.localPanel') }}
</a-tag>
<a-tag v-else-if="nodesById.get(record.nodeId)"
:color="nodesById.get(record.nodeId).status === 'online' ? 'blue' : 'red'">
{{ nodesById.get(record.nodeId).name }}
</a-tag>
<a-tag v-else color="orange">#{{ record.nodeId }}</a-tag>
</div>
<div class="stat-row">
<span class="stat-label">{{ t('pages.inbounds.traffic') }}</span>
<a-tag :color="ColorUtils.usageColor(record.up + record.down, trafficDiff, record.total)">
{{ SizeFormatter.sizeFormat(record.up + record.down) }} /
<template v-if="record.total > 0">{{ SizeFormatter.sizeFormat(record.total) }}</template>
<InfinityIcon v-else />
</a-tag>
</div>
<div class="stat-row">
<span class="stat-label">{{ t('pages.inbounds.allTimeTraffic') }}</span>
<a-tag>{{ SizeFormatter.sizeFormat(record.allTime || 0) }}</a-tag>
</div>
<div v-if="clientCount[record.id]" class="stat-row">
<span class="stat-label">{{ t('clients') }}</span>
<a-tag color="green">{{ clientCount[record.id].clients }}</a-tag>
<a-tag v-if="clientCount[record.id].online.length" color="blue">
{{ clientCount[record.id].online.length }} {{ t('online') }}
</a-tag>
<a-tag v-if="clientCount[record.id].depleted.length" color="red">
{{ clientCount[record.id].depleted.length }} {{ t('depleted') }}
</a-tag>
<a-tag v-if="clientCount[record.id].expiring.length" color="orange">
{{ clientCount[record.id].expiring.length }} {{ t('depletingSoon') }}
</a-tag>
</div>
<div class="stat-row">
<span class="stat-label">{{ t('pages.inbounds.expireDate') }}</span>
<a-tag v-if="record.expiryTime > 0"
:color="ColorUtils.usageColor(Date.now(), expireDiff, record._expiryTime)">
{{ IntlUtil.formatRelativeTime(record.expiryTime) }}
</a-tag>
<a-tag v-else color="purple">
<InfinityIcon />
</a-tag>
</div>
</div>
<!-- Expanded client list (multi-user only) -->
<div v-if="record.isMultiUser() && isExpanded(record.id)" class="card-clients">
<ClientRowTable :db-inbound="record" :is-mobile="true" :traffic-diff="trafficDiff" :expire-diff="expireDiff"
:online-clients="onlineClients" :last-online-map="lastOnlineMap" :is-dark-theme="isDarkTheme"
:page-size="pageSize"
:page-size="pageSize" :total-client-count="clientCount[record.id]?.clients || 0"
:stats-version="statsVersion"
@edit-client="(p) => emit('edit-client', p)" @qrcode-client="(p) => emit('qrcode-client', p)"
@info-client="(p) => emit('info-client', p)"
@reset-traffic-client="(p) => emit('reset-traffic-client', p)"
@@ -418,10 +480,77 @@ function showQrCodeMenu(dbInbound) {
</div>
</div>
<!-- ====================== Mobile: info modal ====================== -->
<a-modal v-if="isMobile" :open="!!statsRecord" :footer="null" :width="360" centered
:title="statsRecord ? `#${statsRecord.id} ${statsRecord.remark || ''}`.trim() : ''" @cancel="closeStats">
<div v-if="statsRecord" class="card-stats">
<div class="stat-row">
<span class="stat-label">{{ t('pages.inbounds.protocol') }}</span>
<a-tag color="purple">{{ statsRecord.protocol }}</a-tag>
<template
v-if="statsRecord.isVMess || statsRecord.isVLess || statsRecord.isTrojan || statsRecord.isSS || statsRecord.isHysteria">
<a-tag color="green">{{ statsRecord.isHysteria ? 'UDP' : statsRecord.toInbound().stream.network }}</a-tag>
<a-tag v-if="statsRecord.toInbound().stream.isTls" color="blue">TLS</a-tag>
<a-tag v-if="statsRecord.toInbound().stream.isReality" color="blue">Reality</a-tag>
</template>
</div>
<div class="stat-row">
<span class="stat-label">{{ t('pages.inbounds.port') }}</span>
<a-tag>{{ statsRecord.port }}</a-tag>
</div>
<div v-if="hasActiveNode" class="stat-row">
<span class="stat-label">{{ t('pages.inbounds.node') }}</span>
<a-tag v-if="statsRecord.nodeId == null" color="default">
{{ t('pages.inbounds.localPanel') }}
</a-tag>
<a-tag v-else-if="nodesById.get(statsRecord.nodeId)"
:color="nodesById.get(statsRecord.nodeId).status === 'online' ? 'blue' : 'red'">
{{ nodesById.get(statsRecord.nodeId).name }}
</a-tag>
<a-tag v-else color="orange">#{{ statsRecord.nodeId }}</a-tag>
</div>
<div class="stat-row">
<span class="stat-label">{{ t('pages.inbounds.traffic') }}</span>
<a-tag :color="ColorUtils.usageColor(statsRecord.up + statsRecord.down, trafficDiff, statsRecord.total)">
{{ SizeFormatter.sizeFormat(statsRecord.up + statsRecord.down) }} /
<template v-if="statsRecord.total > 0">{{ SizeFormatter.sizeFormat(statsRecord.total) }}</template>
<InfinityIcon v-else />
</a-tag>
</div>
<div class="stat-row">
<span class="stat-label">{{ t('pages.inbounds.allTimeTraffic') }}</span>
<a-tag>{{ SizeFormatter.sizeFormat(statsRecord.allTime || 0) }}</a-tag>
</div>
<div v-if="clientCount[statsRecord.id]" class="stat-row">
<span class="stat-label">{{ t('clients') }}</span>
<a-tag color="green" class="client-count-tag">{{ clientCount[statsRecord.id].clients }}</a-tag>
<a-tag v-if="clientCount[statsRecord.id].online.length" color="blue">
{{ clientCount[statsRecord.id].online.length }} {{ t('online') }}
</a-tag>
<a-tag v-if="clientCount[statsRecord.id].depleted.length" color="red">
{{ clientCount[statsRecord.id].depleted.length }} {{ t('depleted') }}
</a-tag>
<a-tag v-if="clientCount[statsRecord.id].expiring.length" color="orange">
{{ clientCount[statsRecord.id].expiring.length }} {{ t('depletingSoon') }}
</a-tag>
</div>
<div class="stat-row">
<span class="stat-label">{{ t('pages.inbounds.expireDate') }}</span>
<a-tag v-if="statsRecord.expiryTime > 0"
:color="ColorUtils.usageColor(Date.now(), expireDiff, statsRecord._expiryTime)">
{{ IntlUtil.formatRelativeTime(statsRecord.expiryTime) }}
</a-tag>
<a-tag v-else color="purple">
<InfinityIcon />
</a-tag>
</div>
</div>
</a-modal>
<!-- ====================== Desktop: a-table ======================== -->
<a-table v-else :columns="columns" :data-source="visibleInbounds" :row-key="(r) => r.id"
:pagination="paginationFor(visibleInbounds)" :scroll="{ x: 1000 }" :style="{ marginTop: '10px' }" size="small"
:row-class-name="(r) => (r.isMultiUser() ? '' : 'hide-expand-icon')">
<a-table v-else :columns="columns" :data-source="sortedInbounds" :row-key="(r) => r.id"
:pagination="paginationFor(sortedInbounds)" :scroll="{ x: 1000 }" :style="{ marginTop: '10px' }" size="small"
:row-class-name="(r) => (r.isMultiUser() ? '' : 'hide-expand-icon')" @change="onTableChange">
<!-- Per-inbound client list, expanded by clicking the row's
default expand chevron. Hidden via row-class-name for
non-multi-user inbounds (matches legacy behavior). -->
@@ -429,6 +558,8 @@ function showQrCodeMenu(dbInbound) {
<ClientRowTable v-if="record.isMultiUser()" :db-inbound="record" :is-mobile="isMobile"
:traffic-diff="trafficDiff" :expire-diff="expireDiff" :online-clients="onlineClients"
:last-online-map="lastOnlineMap" :is-dark-theme="isDarkTheme" :page-size="pageSize"
:total-client-count="clientCount[record.id]?.clients || 0"
:stats-version="statsVersion"
@edit-client="(p) => emit('edit-client', p)"
@qrcode-client="(p) => emit('qrcode-client', p)" @info-client="(p) => emit('info-client', p)"
@reset-traffic-client="(p) => emit('reset-traffic-client', p)"
@@ -440,59 +571,68 @@ function showQrCodeMenu(dbInbound) {
<template #bodyCell="{ column, record }">
<!-- ============== Action dropdown ============== -->
<template v-if="column.key === 'action'">
<a-dropdown :trigger="['click']">
<MoreOutlined class="row-action-trigger" @click.prevent />
<template #overlay>
<a-menu @click="(a) => emit('row-action', { key: a.key, dbInbound: record })">
<a-menu-item key="edit">
<EditOutlined /> {{ t('edit') }}
</a-menu-item>
<a-menu-item v-if="showQrCodeMenu(record)" key="qrcode">
<QrcodeOutlined /> {{ t('qrCode') }}
</a-menu-item>
<template v-if="record.isMultiUser()">
<a-menu-item key="addClient">
<UserAddOutlined /> {{ t('pages.client.add') }}
</a-menu-item>
<a-menu-item key="addBulkClient">
<UsergroupAddOutlined /> {{ t('pages.client.bulk') }}
</a-menu-item>
<a-menu-item key="copyClients">
<CopyOutlined /> {{ t('pages.client.copyFromInbound') }}
</a-menu-item>
<a-menu-item key="resetClients">
<FileDoneOutlined /> {{ t('pages.inbounds.resetInboundClientTraffics') }}
</a-menu-item>
<a-menu-item key="export">
<ExportOutlined /> {{ t('pages.inbounds.export') }}
</a-menu-item>
<a-menu-item v-if="subEnable" key="subs">
<ExportOutlined /> {{ t('pages.inbounds.export') }} — {{ t('pages.settings.subSettings') }}
</a-menu-item>
<a-menu-item key="delDepletedClients" class="danger-item">
<RestOutlined /> {{ t('pages.inbounds.delDepletedClients') }}
</a-menu-item>
<div class="action-buttons">
<a-button type="text" size="small" @click.prevent="emit('row-action', {key: 'edit', dbInbound: record})">
<template #icon>
<EditOutlined />
</template>
</a-button>
<a-dropdown :trigger="['click']">
<a-button type="text" size="small" @click.prevent>
<template #icon>
<MoreOutlined />
</template>
<template v-else>
<a-menu-item key="showInfo">
<InfoCircleOutlined /> {{ t('info') }}
</a-button>
<template #overlay>
<a-menu @click="(a) => emit('row-action', { key: a.key, dbInbound: record })">
<a-menu-item v-if="showQrCodeMenu(record)" key="qrcode">
<QrcodeOutlined /> {{ t('qrCode') }}
</a-menu-item>
</template>
<a-menu-item key="clipboard">
<CopyOutlined /> {{ t('pages.inbounds.exportInbound') }}
</a-menu-item>
<a-menu-item key="resetTraffic">
<RetweetOutlined /> {{ t('pages.inbounds.resetTraffic') }}
</a-menu-item>
<a-menu-item key="clone">
<BlockOutlined /> {{ t('pages.inbounds.clone') }}
</a-menu-item>
<a-menu-item key="delete" class="danger-item">
<DeleteOutlined /> {{ t('delete') }}
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
<template v-if="record.isMultiUser()">
<a-menu-item key="addClient">
<UserAddOutlined /> {{ t('pages.client.add') }}
</a-menu-item>
<a-menu-item key="addBulkClient">
<UsergroupAddOutlined /> {{ t('pages.client.bulk') }}
</a-menu-item>
<a-menu-item key="copyClients">
<CopyOutlined /> {{ t('pages.client.copyFromInbound') }}
</a-menu-item>
<a-menu-item key="resetClients">
<FileDoneOutlined /> {{ t('pages.inbounds.resetInboundClientTraffics') }}
</a-menu-item>
<a-menu-item key="export">
<ExportOutlined /> {{ t('pages.inbounds.export') }}
</a-menu-item>
<a-menu-item v-if="subEnable" key="subs">
<ExportOutlined /> {{ t('pages.inbounds.export') }} — {{ t('pages.settings.subSettings') }}
</a-menu-item>
<a-menu-item key="delDepletedClients" class="danger-item">
<RestOutlined /> {{ t('pages.inbounds.delDepletedClients') }}
</a-menu-item>
</template>
<template v-else>
<a-menu-item key="showInfo">
<InfoCircleOutlined /> {{ t('info') }}
</a-menu-item>
</template>
<a-menu-item key="clipboard">
<CopyOutlined /> {{ t('pages.inbounds.exportInbound') }}
</a-menu-item>
<a-menu-item key="resetTraffic">
<RetweetOutlined /> {{ t('pages.inbounds.resetTraffic') }}
</a-menu-item>
<a-menu-item key="clone">
<BlockOutlined /> {{ t('pages.inbounds.clone') }}
</a-menu-item>
<a-menu-item key="delete" class="danger-item">
<DeleteOutlined /> {{ t('delete') }}
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</template>
<!-- ============== Enable switch (desktop) ============== -->
@@ -520,8 +660,8 @@ function showQrCodeMenu(dbInbound) {
<template v-else-if="column.key === 'protocol'">
<div class="protocol-tags">
<a-tag color="purple">{{ record.protocol }}</a-tag>
<template v-if="record.isVMess || record.isVLess || record.isTrojan || record.isSS">
<a-tag color="green">{{ record.toInbound().stream.network }}</a-tag>
<template v-if="record.isVMess || record.isVLess || record.isTrojan || record.isSS || record.isHysteria">
<a-tag color="green">{{ record.isHysteria ? 'UDP' : record.toInbound().stream.network }}</a-tag>
<a-tag v-if="record.toInbound().stream.isTls" color="blue">TLS</a-tag>
<a-tag v-if="record.toInbound().stream.isReality" color="blue">Reality</a-tag>
</template>
@@ -531,14 +671,14 @@ function showQrCodeMenu(dbInbound) {
<!-- ============== Clients tag + popovers ============== -->
<template v-else-if="column.key === 'clients'">
<template v-if="clientCount[record.id]">
<a-tag color="green" style="margin: 0">{{ clientCount[record.id].clients }}</a-tag>
<a-tag color="green" class="client-count-tag" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].clients }}</a-tag>
<a-popover v-if="clientCount[record.id].deactive.length" :title="t('disabled')">
<template #content>
<div class="client-email-list">
<div v-for="email in clientCount[record.id].deactive" :key="email">{{ email }}</div>
</div>
</template>
<a-tag style="margin: 0; padding: 0 2px">{{ clientCount[record.id].deactive.length }}</a-tag>
<a-tag class="client-count-tag" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].deactive.length }}</a-tag>
</a-popover>
<a-popover v-if="clientCount[record.id].depleted.length" :title="t('depleted')">
<template #content>
@@ -546,7 +686,7 @@ function showQrCodeMenu(dbInbound) {
<div v-for="email in clientCount[record.id].depleted" :key="email">{{ email }}</div>
</div>
</template>
<a-tag color="red" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].depleted.length
<a-tag color="red" class="client-count-tag" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].depleted.length
}}</a-tag>
</a-popover>
<a-popover v-if="clientCount[record.id].expiring.length" :title="t('depletingSoon')">
@@ -555,7 +695,7 @@ function showQrCodeMenu(dbInbound) {
<div v-for="email in clientCount[record.id].expiring" :key="email">{{ email }}</div>
</div>
</template>
<a-tag color="orange" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].expiring.length
<a-tag color="orange" class="client-count-tag" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].expiring.length
}}</a-tag>
</a-popover>
<a-popover v-if="clientCount[record.id].online.length" :title="t('online')">
@@ -564,7 +704,7 @@ function showQrCodeMenu(dbInbound) {
<div v-for="email in clientCount[record.id].online" :key="email">{{ email }}</div>
</div>
</template>
<a-tag color="blue" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].online.length }}</a-tag>
<a-tag color="blue" class="client-count-tag" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].online.length }}</a-tag>
</a-popover>
</template>
</template>
@@ -630,7 +770,14 @@ function showQrCodeMenu(dbInbound) {
}
.filter-bar.mobile>* {
margin-bottom: 4px;
margin-bottom: 4px;
}
.action-buttons {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
}
.protocol-tags {
@@ -639,6 +786,10 @@ function showQrCodeMenu(dbInbound) {
gap: 4px;
}
.client-count-tag {
font-variant-numeric: tabular-nums;
}
.row-action-trigger {
font-size: 20px;
cursor: pointer;

View File

@@ -21,6 +21,7 @@ import InboundList from './InboundList.vue';
import InboundFormModal from './InboundFormModal.vue';
import ClientFormModal from './ClientFormModal.vue';
import ClientBulkModal from './ClientBulkModal.vue';
import CopyClientsModal from './CopyClientsModal.vue';
import InboundInfoModal from './InboundInfoModal.vue';
import QrCodeModal from './QrCodeModal.vue';
import TextModal from '@/components/TextModal.vue';
@@ -44,6 +45,7 @@ const {
ipLimitEnable,
remarkModel,
lastOnlineMap,
statsVersion,
refresh,
fetchDefaultSettings,
applyTrafficEvent,
@@ -65,7 +67,7 @@ useWebSocket({
const { isMobile } = useMediaQuery();
// Node list lives on the central panel; the Inbounds page consumes
// the id→node map for the new "Node" column. Fetched once on mount.
const { byId: nodesById } = useNodeList();
const { byId: nodesById, hasActive: hasActiveNode } = useNodeList();
const basePath = window.X_UI_BASE_PATH || '';
const requestUri = window.location.pathname;
@@ -88,6 +90,8 @@ const clientIndex = ref(null);
const bulkOpen = ref(false);
const bulkDbInbound = ref(null);
const copyOpen = ref(false);
const copyDbInbound = ref(null);
// === Info / QR-code modals ===========================================
const infoOpen = ref(false);
@@ -393,7 +397,7 @@ function confirmResetTraffic(dbInbound) {
okText: 'Reset',
cancelText: 'Cancel',
onOk: async () => {
const msg = await HttpUtil.post(`/panel/api/inbounds/resetAllTraffics`);
const msg = await HttpUtil.post(`/panel/api/inbounds/${dbInbound.id}/resetTraffic`);
if (msg?.success) await refresh();
},
});
@@ -515,10 +519,8 @@ function onRowAction({ key, dbInbound }) {
exportInboundClipboard(dbInbound);
break;
case 'copyClients':
// Copy-clients-from-inbound is a tiny dedicated modal in legacy
// (lets you tick clients to copy across inbounds). Defer to a
// future commit — surface a friendly message for now.
message.info('Copy clients across inbounds — coming soon');
copyDbInbound.value = dbInbound;
copyOpen.value = true;
break;
case 'delete':
confirmDelete(dbInbound);
@@ -646,7 +648,9 @@ function onRowAction({ key, dbInbound }) {
<InboundList :db-inbounds="dbInbounds" :client-count="clientCount" :online-clients="onlineClients"
:last-online-map="lastOnlineMap" :is-dark-theme="themeState.isDark" :expire-diff="expireDiff"
:traffic-diff="trafficDiff" :page-size="pageSize" :is-mobile="isMobile"
:sub-enable="subSettings.enable" :nodes-by-id="nodesById" @refresh="refresh"
:sub-enable="subSettings.enable" :nodes-by-id="nodesById" :has-active-node="hasActiveNode"
:stats-version="statsVersion"
@refresh="refresh"
@add-inbound="onAddInbound" @general-action="onGeneralAction" @row-action="onRowAction"
@edit-client="onEditClient" @qrcode-client="onQrcodeClient" @info-client="onInfoClient"
@reset-traffic-client="onResetTrafficClient" @delete-client="onDeleteClient"
@@ -663,6 +667,8 @@ function onRowAction({ key, dbInbound }) {
:ip-limit-enable="ipLimitEnable" :traffic-diff="trafficDiff" @saved="refresh" />
<ClientBulkModal v-model:open="bulkOpen" :db-inbound="bulkDbInbound" :sub-enable="subSettings.enable"
:tg-bot-enable="tgBotEnable" :ip-limit-enable="ipLimitEnable" @saved="refresh" />
<CopyClientsModal v-model:open="copyOpen" :db-inbound="copyDbInbound" :db-inbounds="dbInbounds"
@saved="refresh" />
<InboundInfoModal v-model:open="infoOpen" :db-inbound="infoDbInbound" :client-index="infoClientIndex"
:remark-model="remarkModel" :expire-diff="expireDiff" :traffic-diff="trafficDiff"
:ip-limit-enable="ipLimitEnable" :tg-bot-enable="tgBotEnable" :sub-settings="subSettings"

View File

@@ -98,7 +98,6 @@ watch(() => props.open, (next) => {
}
const open = [];
if (subLink.value) open.push('sub');
if (subJsonLink.value) open.push('sub-json');
activeKeys.value = open;
});

View File

@@ -11,7 +11,7 @@ const props = defineProps({
value: { type: String, required: true },
remark: { type: String, default: '' },
downloadName: { type: String, default: '' },
size: { type: Number, default: 240 },
size: { type: Number, default: 360 },
showQr: { type: Boolean, default: true },
});
@@ -47,7 +47,7 @@ function download() {
</div>
<div v-if="showQr" class="qr-panel-canvas">
<a-qrcode class="qr-code" :value="value" :size="size" type="svg" :bordered="false"
:title="t('copy')" @click="copy" />
color="#000000" bg-color="#ffffff" :title="t('copy')" @click="copy" />
</div>
</div>
</template>
@@ -82,8 +82,15 @@ function download() {
.qr-panel-canvas .qr-code {
cursor: pointer;
padding: 0 !important;
background: #fff;
border-radius: 4px;
line-height: 0;
}
.qr-panel-canvas .qr-code :deep(svg) {
display: block;
width: 100%;
height: auto;
max-width: 360px;
}
</style>

View File

@@ -23,6 +23,11 @@ export function useInbounds() {
const clientCount = ref({});
const onlineClients = ref([]);
const lastOnlineMap = ref({});
// Bumps on every client_stats merge so the per-inbound ClientRowTable
// child can re-render. DBInbound is a plain class instance, not reactive,
// so the in-place mutations on its clientStats array are invisible to
// Vue's tracking unless something else (this tick) signals the change.
const statsVersion = ref(0);
// Default-settings sidecar fields the table needs for color/expiry math.
const expireDiff = ref(0);
@@ -173,9 +178,9 @@ export function useInbounds() {
rebuildClientCount();
}
// The client_stats payload carries absolute traffic counters for the
// clients that had activity in the latest window plus per-inbound
// totals. Both are absolute (not deltas), so we overwrite in place.
// The client_stats payload carries absolute traffic counters for every
// client + per-inbound totals (full snapshot, not deltas). Both are
// overwritten in place.
function applyClientStatsEvent(payload) {
if (!payload || typeof payload !== 'object') return;
let touched = false;
@@ -220,6 +225,7 @@ export function useInbounds() {
}
if (touched) {
statsVersion.value++;
dbInbounds.value = [...dbInbounds.value];
rebuildClientCount();
}
@@ -315,6 +321,7 @@ export function useInbounds() {
clientCount,
onlineClients,
lastOnlineMap,
statsVersion,
totals,
expireDiff,
trafficDiff,

View File

@@ -14,6 +14,10 @@ import {
SwapOutlined,
EyeOutlined,
EyeInvisibleOutlined,
ThunderboltOutlined,
DesktopOutlined,
DatabaseOutlined,
ForkOutlined,
} from '@ant-design/icons-vue';
const { t } = useI18n();
@@ -31,6 +35,7 @@ import PanelUpdateModal from './PanelUpdateModal.vue';
import LogModal from './LogModal.vue';
import BackupModal from './BackupModal.vue';
import SystemHistoryModal from './SystemHistoryModal.vue';
import XrayMetricsModal from './XrayMetricsModal.vue';
import XrayLogModal from './XrayLogModal.vue';
import VersionModal from './VersionModal.vue';
@@ -71,6 +76,7 @@ const logsOpen = ref(false);
const backupOpen = ref(false);
const panelUpdateOpen = ref(false);
const sysHistoryOpen = ref(false);
const xrayMetricsOpen = ref(false);
const xrayLogsOpen = ref(false);
const versionOpen = ref(false);
const configTextOpen = ref(false);
@@ -98,6 +104,18 @@ function openSystemHistory() { sysHistoryOpen.value = true; }
function openXrayLogs() { xrayLogsOpen.value = true; }
function openVersionSwitch() { versionOpen.value = true; }
function openPanelVersion() {
if (panelUpdateInfo.value.updateAvailable) {
panelUpdateOpen.value = true;
} else {
window.open('https://github.com/MHSanaei/3x-ui/releases', '_blank', 'noopener,noreferrer');
}
}
function openTelegram() {
window.open('https://t.me/XrayUI', '_blank', 'noopener,noreferrer');
}
// Legacy "Config" action — fetch the rendered xray config and show
// it as JSON in the shared TextModal (same UX as main).
async function openConfig() {
@@ -155,62 +173,83 @@ async function openConfig() {
<a-col :xs="24" :lg="12">
<a-card title="3X-UI" hoverable>
<template v-if="panelUpdateInfo.updateAvailable" #extra>
<a-tooltip :title="`${t('pages.index.updatePanel')}: ${panelUpdateInfo.latestVersion}`">
<a-tag color="orange" class="update-tag" @click="panelUpdateOpen = true">
<CloudDownloadOutlined />
{{ panelUpdateInfo.latestVersion }}
<span v-if="!isMobile">{{ t('pages.index.updatePanel') }}</span>
</a-tag>
</a-tooltip>
<template #actions>
<a-space class="action" @click="openTelegram">
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor" class="tg-icon"
aria-hidden="true">
<path
d="M21.93 4.34a1.5 1.5 0 0 0-2.05-1.6L2.97 9.6c-.92.36-.91 1.66.02 1.99l4.32 1.53 1.7 5.23a1 1 0 0 0 1.68.36l2.43-2.43 4.36 3.21a1.5 1.5 0 0 0 2.36-.91l3.09-13.86a1.5 1.5 0 0 0 0-.38ZM9.97 14.66l-.55 3.36-1.36-4.2 9.8-7.05-7.89 7.89Z" />
</svg>
<span v-if="!isMobile">@XrayUI</span>
</a-space>
<a-space class="action" :class="{ 'action-update': panelUpdateInfo.updateAvailable }"
@click="openPanelVersion">
<CloudDownloadOutlined />
<span v-if="!isMobile">
{{ panelUpdateInfo.updateAvailable
? `${t('update')} ${panelUpdateInfo.latestVersion}`
: `v${displayVersion}` }}
</span>
</a-space>
</template>
</a-card>
</a-col>
<a-col :xs="24" :lg="12">
<a-card :title="t('pages.index.charts')" hoverable>
<template #actions>
<a-space class="action" @click="openSystemHistory">
<AreaChartOutlined />
<span v-if="!isMobile">{{ t('pages.index.systemHistoryTitle') }}</span>
</a-space>
<a-space class="action" @click="xrayMetricsOpen = true">
<AreaChartOutlined />
<span v-if="!isMobile">{{ t('pages.index.xrayMetricsTitle') }}</span>
</a-space>
</template>
<div class="link-tags">
<a href="https://github.com/MHSanaei/3x-ui/releases" target="_blank" rel="noopener noreferrer">
<a-tag color="green">v{{ displayVersion }}</a-tag>
</a>
<a href="https://t.me/XrayUI" target="_blank" rel="noopener noreferrer">
<a-tag color="green">@XrayUI</a-tag>
</a>
<a href="https://github.com/MHSanaei/3x-ui/wiki" target="_blank" rel="noopener noreferrer">
<a-tag color="purple">{{ t('pages.index.documentation') }}</a-tag>
</a>
</div>
</a-card>
</a-col>
<a-col :xs="24" :lg="12">
<a-card :title="t('pages.index.operationHours')" hoverable>
<a-tag :color="status.xray.color">
Xray: {{ TimeFormatter.formatSecond(status.appStats.uptime) }}
</a-tag>
<a-tag color="green">OS: {{ TimeFormatter.formatSecond(status.uptime) }}</a-tag>
</a-card>
</a-col>
<a-col :xs="24" :lg="12">
<a-card :title="t('pages.index.systemLoad')" hoverable>
<template #extra>
<a-tag color="blue" class="history-tag" @click="openSystemHistory">
<AreaChartOutlined />
{{ t('pages.index.systemHistoryTitle') }}
</a-tag>
</template>
<a-tooltip :title="t('pages.index.systemLoadDesc')">
<a-tag color="green">
{{ status.loads[0] }} | {{ status.loads[1] }} | {{ status.loads[2] }}
</a-tag>
</a-tooltip>
<a-row :gutter="isMobile ? [8, 8] : 0">
<a-col :span="12">
<CustomStatistic title="Xray" :value="TimeFormatter.formatSecond(status.appStats.uptime)">
<template #prefix>
<ThunderboltOutlined />
</template>
</CustomStatistic>
</a-col>
<a-col :span="12">
<CustomStatistic title="OS" :value="TimeFormatter.formatSecond(status.uptime)">
<template #prefix>
<DesktopOutlined />
</template>
</CustomStatistic>
</a-col>
</a-row>
</a-card>
</a-col>
<a-col :xs="24" :lg="12">
<a-card :title="t('usage')" hoverable>
<a-tag color="green">
{{ t('pages.index.memory') }}: {{ SizeFormatter.sizeFormat(status.appStats.mem) }}
</a-tag>
<a-tag color="green">
{{ t('pages.index.threads') }}: {{ status.appStats.threads }}
</a-tag>
<a-row :gutter="isMobile ? [8, 8] : 0">
<a-col :span="12">
<CustomStatistic :title="t('pages.index.memory')"
:value="SizeFormatter.sizeFormat(status.appStats.mem)">
<template #prefix>
<DatabaseOutlined />
</template>
</CustomStatistic>
</a-col>
<a-col :span="12">
<CustomStatistic :title="t('pages.index.threads')" :value="status.appStats.threads">
<template #prefix>
<ForkOutlined />
</template>
</CustomStatistic>
</a-col>
</a-row>
</a-card>
</a-col>
@@ -318,6 +357,7 @@ async function openConfig() {
<LogModal v-model:open="logsOpen" />
<BackupModal v-model:open="backupOpen" :base-path="basePath" @busy="setBusy" />
<SystemHistoryModal v-model:open="sysHistoryOpen" :status="status" />
<XrayMetricsModal v-model:open="xrayMetricsOpen" />
<XrayLogModal v-model:open="xrayLogsOpen" />
<VersionModal v-model:open="versionOpen" :status="status" @busy="setBusy" />
<TextModal v-model:open="configTextOpen" :title="t('pages.index.config')" :content="configText"
@@ -374,12 +414,13 @@ async function openConfig() {
justify-content: center;
}
.update-tag {
cursor: pointer;
margin: 0;
display: inline-flex;
align-items: center;
gap: 4px;
.action-update {
color: #fa8c16;
font-weight: 600;
}
.action-update :deep(.anticon) {
color: #fa8c16;
}
.history-tag {
@@ -390,18 +431,9 @@ async function openConfig() {
margin-inline-end: 0;
}
.link-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.link-tags a {
display: inline-flex;
}
.link-tags :deep(.ant-tag) {
margin-inline-end: 0;
.tg-icon {
display: inline-block;
vertical-align: -2px;
}
.ip-toggle-icon {

View File

@@ -53,7 +53,9 @@ function parseLogLine(line) {
service = 'X-UI:';
}
return { date, time, levelText, levelClass, service, body };
const stamp = [date, time].filter(Boolean).join(' ');
return { date, time, stamp, levelText, levelClass, service, body };
}
const parsedLogs = computed(() => logs.value.map(parseLogLine));
@@ -133,33 +135,25 @@ const modalWidth = computed(() => (isMobile.value ? '100vw' : '800px'));
<template v-else-if="isMobile">
<div v-for="(log, idx) in parsedLogs" :key="idx" class="log-card">
<div class="log-card-head">
<span v-if="log.date || log.time" class="log-time">
<span v-if="log.time">{{ log.time }}</span>
<span v-if="log.date" class="log-date">{{ log.date }}</span>
<span v-if="log.stamp" class="log-time">
<span v-if="log.time">{{ log.time }}</span>{{ log.time && log.date ? ' ' : '' }}<span v-if="log.date" class="log-date">{{ log.date }}</span>
</span>
<span v-if="log.levelText" class="log-level-badge" :class="log.levelClass">
{{ log.levelText }}
</span>
</div>
<div v-if="log.body || log.service" class="log-body">
<b v-if="log.service">{{ log.service }}</b>
<span v-if="log.body" class="log-body-text">{{ log.body }}</span>
<b v-if="log.service">{{ log.service }}</b>{{ log.service && log.body ? ' ' : '' }}<span v-if="log.body" class="log-body-text">{{ log.body }}</span>
</div>
</div>
</template>
<template v-else>
<div v-for="(log, idx) in parsedLogs" :key="idx" class="log-line">
<span v-if="log.date || log.time" class="log-stamp">
{{ log.date }}<template v-if="log.date && log.time"> </template>{{ log.time }}
</span>
<span v-if="log.levelText" class="log-level" :class="log.levelClass">
{{ log.levelText }}
</span>
<span v-if="log.stamp" class="log-stamp">{{ log.stamp }}</span>{{ log.stamp && log.levelText ? ' ' : '' }}<span v-if="log.levelText" class="log-level" :class="log.levelClass">{{ log.levelText }}</span>
<template v-if="log.body || log.service">
<span> - </span>
<b v-if="log.service">{{ log.service }} </b>
<span>{{ log.body }}</span>
<b v-if="log.service">{{ log.service }}</b>{{ log.service && log.body ? ' ' : '' }}<span>{{ log.body }}</span>
</template>
</div>
</template>

View File

@@ -0,0 +1,347 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { HttpUtil, SizeFormatter } from '@/utils';
import Sparkline from '@/components/Sparkline.vue';
import { useMediaQuery } from '@/composables/useMediaQuery.js';
const { t } = useI18n();
const { isMobile } = useMediaQuery();
const modalWidth = computed(() => (isMobile.value ? '95vw' : '900px'));
const props = defineProps({
open: { type: Boolean, default: false },
});
const emit = defineEmits(['update:open']);
const OBS_KEY = 'xrObs';
const metrics = [
{ key: 'xrAlloc', tab: 'Heap', unit: 'B', stroke: '#7c4dff' },
{ key: 'xrSys', tab: 'Sys', unit: 'B', stroke: '#1890ff' },
{ key: 'xrHeapObjects', tab: 'Objects', unit: '', stroke: '#13c2c2' },
{ key: 'xrNumGC', tab: 'GC Count', unit: '', stroke: '#fa8c16' },
{ key: 'xrPauseNs', tab: 'GC Pause', unit: 'ns', stroke: '#f5222d' },
{ key: OBS_KEY, tab: 'Observatory', unit: 'ms', stroke: '#52c41a' },
];
const activeKey = ref('xrAlloc');
const bucket = ref(2);
const points = ref([]);
const labels = ref([]);
const state = ref({ enabled: false, listen: '', reason: '' });
const obsTags = ref([]);
const obsActiveTag = ref('');
let obsTimer = null;
const activeMetric = computed(() => metrics.find((m) => m.key === activeKey.value));
const isObservatory = computed(() => activeKey.value === OBS_KEY);
const strokeColor = computed(() => activeMetric.value?.stroke || '#008771');
const activeObsTag = computed(() => obsTags.value.find((tg) => tg.tag === obsActiveTag.value) || null);
function unitFormatter(unit) {
if (unit === 'B') {
return (v) => SizeFormatter.sizeFormat(Math.max(0, Number(v) || 0));
}
if (unit === 'ns') {
return (v) => {
const n = Math.max(0, Number(v) || 0);
if (n >= 1e6) return `${(n / 1e6).toFixed(2)} ms`;
if (n >= 1e3) return `${(n / 1e3).toFixed(1)} µs`;
return `${n.toFixed(0)} ns`;
};
}
if (unit === 'ms') {
return (v) => `${Math.round(Number(v) || 0)} ms`;
}
return (v) => {
const n = Number(v) || 0;
return Math.round(n).toLocaleString();
};
}
const yFormatter = computed(() => unitFormatter(activeMetric.value?.unit ?? ''));
function fmtTimestamp(unixSec) {
if (!unixSec) return '—';
const d = new Date(unixSec * 1000);
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
const ss = String(d.getSeconds()).padStart(2, '0');
return `${d.toLocaleDateString()} ${hh}:${mm}:${ss}`;
}
async function fetchState() {
try {
const msg = await HttpUtil.get('/panel/api/server/xrayMetricsState');
if (msg?.success && msg.obj) state.value = msg.obj;
} catch (e) {
console.error('Failed to fetch xray metrics state', e);
}
}
async function fetchObservatory() {
try {
const msg = await HttpUtil.get('/panel/api/server/xrayObservatory');
if (msg?.success && Array.isArray(msg.obj)) {
obsTags.value = msg.obj;
if (!obsTags.value.find((tg) => tg.tag === obsActiveTag.value)) {
obsActiveTag.value = obsTags.value[0]?.tag || '';
}
} else {
obsTags.value = [];
}
} catch (e) {
console.error('Failed to fetch observatory snapshot', e);
obsTags.value = [];
}
}
async function fetchMetricBucket() {
const m = activeMetric.value;
if (!m) return;
try {
const url = `/panel/api/server/xrayMetricsHistory/${m.key}/${bucket.value}`;
const msg = await HttpUtil.get(url);
applyHistory(msg);
} catch (e) {
console.error('Failed to fetch xray metrics bucket', e);
labels.value = [];
points.value = [];
}
}
async function fetchObsBucket() {
const tag = obsActiveTag.value;
if (!tag) {
labels.value = [];
points.value = [];
return;
}
try {
const url = `/panel/api/server/xrayObservatoryHistory/${encodeURIComponent(tag)}/${bucket.value}`;
const msg = await HttpUtil.get(url);
applyHistory(msg);
} catch (e) {
console.error('Failed to fetch observatory bucket', e);
labels.value = [];
points.value = [];
}
}
function applyHistory(msg) {
if (msg?.success && Array.isArray(msg.obj)) {
const vals = [];
const labs = [];
for (const p of msg.obj) {
const d = new Date(p.t * 1000);
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
const ss = String(d.getSeconds()).padStart(2, '0');
labs.push(bucket.value >= 60 ? `${hh}:${mm}` : `${hh}:${mm}:${ss}`);
vals.push(Number(p.v) || 0);
}
labels.value = labs;
points.value = vals;
} else {
labels.value = [];
points.value = [];
}
}
function refreshActive() {
if (isObservatory.value) {
fetchObsBucket();
} else {
fetchMetricBucket();
}
}
function startObsPolling() {
stopObsPolling();
obsTimer = window.setInterval(async () => {
if (!props.open || !isObservatory.value) return;
await fetchObservatory();
fetchObsBucket();
}, 2000);
}
function stopObsPolling() {
if (obsTimer != null) {
window.clearInterval(obsTimer);
obsTimer = null;
}
}
function close() {
emit('update:open', false);
}
watch(() => props.open, (next) => {
if (next) {
activeKey.value = 'xrAlloc';
fetchState();
fetchMetricBucket();
} else {
stopObsPolling();
}
});
watch(activeKey, async (key) => {
if (!props.open) return;
if (key === OBS_KEY) {
await fetchObservatory();
fetchObsBucket();
startObsPolling();
} else {
stopObsPolling();
fetchMetricBucket();
}
});
watch(bucket, () => {
if (props.open) refreshActive();
});
watch(obsActiveTag, () => {
if (props.open && isObservatory.value) fetchObsBucket();
});
</script>
<template>
<a-modal :open="open" :closable="true" :footer="null" :width="modalWidth" @cancel="close">
<template #title>
{{ t('pages.index.xrayMetricsTitle') }}
<a-select v-model:value="bucket" size="small" class="bucket-select">
<a-select-option :value="2">2m</a-select-option>
<a-select-option :value="30">30m</a-select-option>
<a-select-option :value="60">1h</a-select-option>
<a-select-option :value="120">2h</a-select-option>
<a-select-option :value="180">3h</a-select-option>
<a-select-option :value="300">5h</a-select-option>
</a-select>
</template>
<a-alert v-if="!state.enabled" type="warning" show-icon class="metrics-alert"
:message="t('pages.index.xrayMetricsDisabled')"
:description="state.reason || t('pages.index.xrayMetricsHint')" />
<a-tabs v-model:active-key="activeKey" size="small" class="history-tabs">
<a-tab-pane v-for="m in metrics" :key="m.key" :tab="m.tab" />
</a-tabs>
<div v-if="isObservatory" class="obs-pane">
<a-alert v-if="state.enabled && obsTags.length === 0" type="info" show-icon class="metrics-alert"
:message="t('pages.index.xrayObservatoryEmpty')"
:description="t('pages.index.xrayObservatoryHint')" />
<div v-else class="obs-controls">
<a-select v-model:value="obsActiveTag" size="small" class="obs-select"
:placeholder="t('pages.index.xrayObservatoryTagPlaceholder')">
<a-select-option v-for="tg in obsTags" :key="tg.tag" :value="tg.tag">
<span class="obs-dot" :class="tg.alive ? 'is-alive' : 'is-dead'" />
{{ tg.tag }}
</a-select-option>
</a-select>
<div v-if="activeObsTag" class="obs-stats">
<a-tag :color="activeObsTag.alive ? 'green' : 'red'">
{{ activeObsTag.alive ? t('pages.index.xrayObservatoryAlive') : t('pages.index.xrayObservatoryDead') }}
</a-tag>
<a-tag color="blue">{{ activeObsTag.delay }} ms</a-tag>
<span class="obs-stamp">
{{ t('pages.index.xrayObservatoryLastSeen') }}: {{ fmtTimestamp(activeObsTag.lastSeenTime) }}
</span>
<span class="obs-stamp">
{{ t('pages.index.xrayObservatoryLastTry') }}: {{ fmtTimestamp(activeObsTag.lastTryTime) }}
</span>
</div>
</div>
</div>
<div class="cpu-chart-wrap">
<div class="cpu-chart-meta">
Timeframe: {{ bucket }} sec per point (total {{ points.length }} points)
<span v-if="state.enabled && state.listen" class="listen-tag"> · {{ state.listen }}</span>
</div>
<Sparkline :data="points" :labels="labels" :vb-width="840" :height="220" :stroke="strokeColor" :stroke-width="2.2"
:show-grid="true" :show-axes="true" :tick-count-x="5" :max-points="points.length || 1" :fill-opacity="0.18"
:marker-radius="3.2" :show-tooltip="true" :value-min="0" :value-max="null" :y-formatter="yFormatter" />
</div>
</a-modal>
</template>
<style scoped>
.bucket-select {
width: 80px;
margin-left: 10px;
}
.metrics-alert {
margin-bottom: 10px;
}
.history-tabs {
margin-bottom: 4px;
}
.obs-pane {
padding: 4px 16px 0;
}
.obs-controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.obs-select {
min-width: 240px;
}
.obs-stats {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
font-size: 12px;
opacity: 0.85;
}
.obs-stamp {
opacity: 0.7;
}
.obs-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 6px;
vertical-align: middle;
}
.obs-dot.is-alive {
background: #52c41a;
}
.obs-dot.is-dead {
background: #f5222d;
}
.cpu-chart-wrap {
padding: 8px 16px 16px;
}
.cpu-chart-meta {
margin-bottom: 10px;
font-size: 11px;
opacity: 0.65;
}
.listen-tag {
opacity: 0.7;
}
</style>

View File

@@ -28,6 +28,7 @@ function defaultForm() {
basePath: '/',
apiToken: '',
enable: true,
allowPrivateAddress: false,
};
}
@@ -69,6 +70,7 @@ function buildPayload() {
basePath: form.basePath?.trim() || '/',
apiToken: form.apiToken?.trim() || '',
enable: !!form.enable,
allowPrivateAddress: !!form.allowPrivateAddress,
};
}
@@ -161,6 +163,11 @@ async function onSave() {
</a-col>
</a-row>
<a-form-item label="Allow private address">
<a-switch v-model:checked="form.allowPrivateAddress" />
<div class="hint">Enable only for nodes on a private network or VPN.</div>
</a-form-item>
<a-form-item :label="t('pages.nodes.apiToken')" required>
<a-input-password v-model:value="form.apiToken" :placeholder="t('pages.nodes.apiTokenPlaceholder')" />
<div class="hint">{{ t('pages.nodes.apiTokenHint') }}</div>

View File

@@ -1,5 +1,5 @@
<script setup>
import { computed } from 'vue';
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import {
EditOutlined,
@@ -7,6 +7,11 @@ import {
PlusOutlined,
ThunderboltOutlined,
ExclamationCircleOutlined,
EyeOutlined,
EyeInvisibleOutlined,
InfoCircleOutlined,
MoreOutlined,
RightOutlined,
} from '@ant-design/icons-vue';
import NodeHistoryPanel from './NodeHistoryPanel.vue';
@@ -26,8 +31,6 @@ const emit = defineEmits([
const { t } = useI18n();
// Render the address column as a clickable URL so admins can jump to
// the remote panel directly from the list.
const dataSource = computed(() =>
props.nodes.map((n) => ({
...n,
@@ -36,6 +39,8 @@ const dataSource = computed(() =>
})),
);
const showAddress = ref(false);
function statusColor(status) {
switch (status) {
case 'online': return 'green';
@@ -70,6 +75,25 @@ function formatPct(p) {
if (typeof p !== 'number' || isNaN(p)) return '-';
return `${p.toFixed(1)}%`;
}
const statsNode = ref(null);
function openStats(node) {
statsNode.value = node;
}
function closeStats() {
statsNode.value = null;
}
const expandedIds = ref(new Set());
function toggleExpanded(id) {
const next = new Set(expandedIds.value);
if (next.has(id)) next.delete(id);
else next.add(id);
expandedIds.value = next;
}
function isExpanded(id) {
return expandedIds.value.has(id);
}
</script>
<template>
@@ -83,7 +107,103 @@ function formatPct(p) {
</a-button>
</div>
<a-table :data-source="dataSource" :pagination="false" :loading="loading" :scroll="{ x: 'max-content' }"
<!-- ====================== Mobile: card list ======================= -->
<div v-if="isMobile" class="node-cards">
<div v-if="dataSource.length === 0" class="card-empty"></div>
<div v-for="record in dataSource" :key="record.id" class="node-card">
<div class="card-head" @click="toggleExpanded(record.id)">
<RightOutlined class="card-expand" :class="{ 'is-expanded': isExpanded(record.id) }" />
<a-badge
:status="statusColor(record.status) === 'green' ? 'success' : (statusColor(record.status) === 'red' ? 'error' : 'default')" />
<span class="node-name">{{ record.name }}</span>
<div class="card-actions" @click.stop>
<a-tooltip :title="t('info')">
<InfoCircleOutlined class="row-action-trigger" @click="openStats(record)" />
</a-tooltip>
<a-switch :checked="record.enable" size="small" @change="(v) => emit('toggle-enable', record, v)" />
<a-dropdown :trigger="['click']" placement="bottomRight">
<MoreOutlined class="row-action-trigger" @click.prevent />
<template #overlay>
<a-menu>
<a-menu-item key="probe" @click="emit('probe', record)">
<ThunderboltOutlined /> {{ t('pages.nodes.probe') }}
</a-menu-item>
<a-menu-item key="edit" @click="emit('edit', record)">
<EditOutlined /> {{ t('edit') }}
</a-menu-item>
<a-menu-item key="delete" class="danger-item" @click="emit('delete', record)">
<DeleteOutlined /> {{ t('delete') }}
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</div>
<div v-if="isExpanded(record.id)" class="card-history">
<NodeHistoryPanel :node="record" />
</div>
</div>
</div>
<a-modal v-if="isMobile" :open="!!statsNode" :footer="null" :width="360" centered
:title="statsNode ? statsNode.name : ''" @cancel="closeStats">
<div v-if="statsNode" class="card-stats">
<div v-if="statsNode.remark" class="stat-row">
<span class="stat-label">{{ t('pages.nodes.name') }}</span>
<span>{{ statsNode.remark }}</span>
</div>
<div class="stat-row">
<span class="stat-label">{{ t('pages.nodes.address') }}</span>
<a :href="statsNode.url" target="_blank" rel="noopener noreferrer"
:class="showAddress ? 'address-visible' : 'address-hidden'">{{ statsNode.url }}</a>
<a-tooltip :title="t('pages.index.toggleIpVisibility')">
<component :is="showAddress ? EyeOutlined : EyeInvisibleOutlined" class="ip-toggle-icon"
@click="showAddress = !showAddress" />
</a-tooltip>
</div>
<div class="stat-row">
<span class="stat-label">{{ t('pages.nodes.status') }}</span>
<a-badge
:status="statusColor(statsNode.status) === 'green' ? 'success' : (statusColor(statsNode.status) === 'red' ? 'error' : 'default')" />
<span>{{ t(`pages.nodes.statusValues.${statsNode.status || 'unknown'}`) }}</span>
<a-tooltip v-if="statsNode.lastError" :title="statsNode.lastError">
<ExclamationCircleOutlined style="color: #faad14" />
</a-tooltip>
</div>
<div class="stat-row">
<span class="stat-label">{{ t('pages.nodes.cpu') }}</span>
<a-tag>{{ formatPct(statsNode.cpuPct) }}</a-tag>
</div>
<div class="stat-row">
<span class="stat-label">{{ t('pages.nodes.mem') }}</span>
<a-tag>{{ formatPct(statsNode.memPct) }}</a-tag>
</div>
<div class="stat-row">
<span class="stat-label">{{ t('pages.nodes.xrayVersion') }}</span>
<a-tag>{{ statsNode.xrayVersion || '-' }}</a-tag>
</div>
<div class="stat-row">
<span class="stat-label">{{ t('pages.nodes.uptime') }}</span>
<a-tag>{{ formatUptime(statsNode.uptimeSecs) }}</a-tag>
</div>
<div class="stat-row">
<span class="stat-label">{{ t('pages.nodes.latency') }}</span>
<a-tag>
<template v-if="statsNode.latencyMs > 0">{{ statsNode.latencyMs }} ms</template>
<template v-else>-</template>
</a-tag>
</div>
<div class="stat-row">
<span class="stat-label">{{ t('pages.nodes.lastHeartbeat') }}</span>
<a-tag>{{ relativeTime(statsNode.lastHeartbeat) }}</a-tag>
</div>
</div>
</a-modal>
<!-- ====================== Desktop: a-table ======================== -->
<a-table v-else :data-source="dataSource" :pagination="false" :loading="loading" :scroll="{ x: 'max-content' }"
size="middle" row-key="id">
<template #expandedRowRender="{ record }">
<NodeHistoryPanel :node="record" />
@@ -97,9 +217,19 @@ function formatPct(p) {
</template>
</a-table-column>
<a-table-column :title="t('pages.nodes.address')" data-index="url" :ellipsis="true">
<a-table-column data-index="url" :ellipsis="true">
<template #title>
<span class="address-header">
{{ t('pages.nodes.address') }}
<a-tooltip :title="t('pages.index.toggleIpVisibility')">
<component :is="showAddress ? EyeOutlined : EyeInvisibleOutlined" class="ip-toggle-icon"
@click="showAddress = !showAddress" />
</a-tooltip>
</span>
</template>
<template #default="{ record }">
<a :href="record.url" target="_blank" rel="noopener noreferrer">{{ record.url }}</a>
<a :href="record.url" target="_blank" rel="noopener noreferrer"
:class="showAddress ? 'address-visible' : 'address-hidden'">{{ record.url }}</a>
</template>
</a-table-column>
@@ -203,4 +333,133 @@ function formatPct(p) {
font-size: 12px;
opacity: 0.65;
}
.address-header {
display: inline-flex;
align-items: center;
gap: 6px;
}
.ip-toggle-icon {
cursor: pointer;
font-size: 14px;
opacity: 0.7;
}
.ip-toggle-icon:hover {
opacity: 1;
}
.address-hidden {
filter: blur(5px);
transition: filter 0.2s ease;
}
.address-visible {
filter: none;
}
.node-cards {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 4px;
}
.node-card {
border: 1px solid rgba(128, 128, 128, 0.2);
border-radius: 10px;
padding: 12px;
background: rgba(255, 255, 255, 0.02);
display: flex;
flex-direction: column;
gap: 8px;
}
:global(body.dark) .node-card {
background: rgba(255, 255, 255, 0.03);
border-color: rgba(255, 255, 255, 0.1);
}
.card-head {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
}
.card-expand {
font-size: 12px;
opacity: 0.6;
transition: transform 150ms ease;
flex-shrink: 0;
}
.card-expand.is-expanded {
transform: rotate(90deg);
}
.node-name {
font-weight: 600;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.row-action-trigger {
font-size: 20px;
cursor: pointer;
}
.card-stats {
display: flex;
flex-direction: column;
gap: 6px;
}
.stat-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
.stat-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.04em;
opacity: 0.6;
min-width: 96px;
flex-shrink: 0;
}
.card-stats :deep(.ant-tag) {
margin: 0;
}
.card-history {
margin-top: 4px;
padding-top: 8px;
border-top: 1px solid rgba(128, 128, 128, 0.15);
}
.card-empty {
text-align: center;
opacity: 0.4;
padding: 20px 0;
}
.danger-item {
color: #ff4d4f;
}
</style>

View File

@@ -108,33 +108,33 @@ async function onToggleEnable(node, next) {
<a-spin :spinning="!fetched" :delay="200" tip="Loading…" size="large">
<div v-if="!fetched" class="loading-spacer" />
<a-row v-else :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]">
<a-row v-else :gutter="[isMobile ? 8 : 16, isMobile ? 8 : 12]">
<!-- Summary statistics card -->
<a-col :span="24">
<a-card size="small" hoverable class="summary-card">
<a-row :gutter="[16, 12]">
<a-col :sm="12" :md="6">
<a-row :gutter="[16, isMobile ? 16 : 12]">
<a-col :xs="12" :sm="12" :md="6">
<CustomStatistic :title="t('pages.nodes.totalNodes')" :value="String(totals.total)">
<template #prefix>
<CloudServerOutlined />
</template>
</CustomStatistic>
</a-col>
<a-col :sm="12" :md="6">
<a-col :xs="12" :sm="12" :md="6">
<CustomStatistic :title="t('pages.nodes.onlineNodes')" :value="String(totals.online)">
<template #prefix>
<CheckCircleOutlined style="color: #52c41a" />
</template>
</CustomStatistic>
</a-col>
<a-col :sm="12" :md="6">
<a-col :xs="12" :sm="12" :md="6">
<CustomStatistic :title="t('pages.nodes.offlineNodes')" :value="String(totals.offline)">
<template #prefix>
<CloseCircleOutlined style="color: #ff4d4f" />
</template>
</CustomStatistic>
</a-col>
<a-col :sm="12" :md="6">
<a-col :xs="12" :sm="12" :md="6">
<CustomStatistic :title="t('pages.nodes.avgLatency')"
:value="totals.avgLatency > 0 ? `${totals.avgLatency} ms` : '-'">
<template #prefix>

View File

@@ -153,6 +153,14 @@ onMounted(loadInboundTags);
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Trusted proxy CIDRs</template>
<template #description>Comma-separated IPs/CIDRs allowed to set forwarded host, proto, and client IP headers.</template>
<template #control>
<a-input v-model:value="allSetting.trustedProxyCIDRs" placeholder="127.0.0.1/32,::1/128" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.settings.pageSize') }}</template>
<template #description>{{ t('pages.settings.pageSizeDesc') }}</template>
@@ -298,8 +306,12 @@ onMounted(loadInboundTags);
<SettingListItem paddings="small">
<template #title>{{ t('password') }}</template>
<template #description>
{{ allSetting.hasLdapPassword ? 'Configured; leave blank to keep current password.' : 'Not configured.' }}
</template>
<template #control>
<a-input-password v-model:value="allSetting.ldapPassword" />
<a-input-password v-model:value="allSetting.ldapPassword"
:placeholder="allSetting.hasLdapPassword ? 'Configured - enter a new value to replace' : ''" />
</template>
</SettingListItem>

View File

@@ -52,10 +52,9 @@ async function sendUpdateUser() {
try {
const msg = await HttpUtil.post('/panel/setting/updateUser', user);
if (msg?.success) {
// Force re-login at the standard logout path; basePath is handled
// by the Go router so a relative redirect is correct here.
const basePath = window.X_UI_BASE_PATH || '';
window.location.replace(`${basePath}logout`);
await HttpUtil.post('/logout');
const basePath = window.X_UI_BASE_PATH || '/';
window.location.replace(basePath);
}
} finally {
updating.value = false;
@@ -76,34 +75,41 @@ function updateUser() {
}
}
// === API Token =========================================================
// Surfaces the panel's API token so a remote central panel can register
// this instance as a node. Lazy-loaded on tab mount; rotation requires
// confirmation since it invalidates any cached value upstream.
const apiToken = ref('');
const apiTokenLoading = ref(false);
const apiTokenRotating = ref(false);
const apiTokens = ref([]);
const apiTokensLoading = ref(false);
const visibleTokenIds = ref(new Set());
const createOpen = ref(false);
const createName = ref('');
const creating = ref(false);
async function loadApiToken() {
apiTokenLoading.value = true;
async function loadApiTokens() {
apiTokensLoading.value = true;
try {
const msg = await HttpUtil.get('/panel/setting/getApiToken');
if (msg?.success) apiToken.value = msg.obj || '';
const msg = await HttpUtil.get('/panel/setting/apiTokens');
if (msg?.success) apiTokens.value = Array.isArray(msg.obj) ? msg.obj : [];
} finally {
apiTokenLoading.value = false;
apiTokensLoading.value = false;
}
}
async function copyApiToken() {
if (!apiToken.value) return;
function isTokenVisible(id) {
return visibleTokenIds.value.has(id);
}
function toggleTokenVisibility(id) {
const next = new Set(visibleTokenIds.value);
if (next.has(id)) next.delete(id); else next.add(id);
visibleTokenIds.value = next;
}
async function copyToken(token) {
if (!token) return;
try {
await navigator.clipboard.writeText(apiToken.value);
await navigator.clipboard.writeText(token);
message.success(t('copySuccess'));
} catch (_e) {
// navigator.clipboard can be undefined on http:// — fall back to
// a transient input + execCommand path.
const ta = document.createElement('textarea');
ta.value = apiToken.value;
ta.value = token;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
@@ -112,28 +118,66 @@ async function copyApiToken() {
}
}
function regenerateApiToken() {
function openCreateModal() {
createName.value = '';
createOpen.value = true;
}
async function confirmCreateToken() {
const name = createName.value.trim();
if (!name) {
message.error(t('pages.settings.security.apiTokenNameRequired') || 'Name is required');
return;
}
creating.value = true;
try {
const msg = await HttpUtil.post('/panel/setting/apiTokens/create', { name });
if (msg?.success) {
createOpen.value = false;
await loadApiTokens();
if (msg.obj?.id != null) {
const next = new Set(visibleTokenIds.value);
next.add(msg.obj.id);
visibleTokenIds.value = next;
}
}
} finally {
creating.value = false;
}
}
function confirmDeleteToken(row) {
Modal.confirm({
title: t('pages.nodes.regenerateConfirm'),
okText: t('confirm'),
title: `${t('delete')} "${row.name}"?`,
content: t('pages.settings.security.apiTokenDeleteWarning')
|| 'Any caller using this token will stop authenticating immediately.',
okText: t('delete'),
cancelText: t('cancel'),
okType: 'danger',
onOk: async () => {
apiTokenRotating.value = true;
try {
const msg = await HttpUtil.post('/panel/setting/regenerateApiToken');
if (msg?.success) {
apiToken.value = msg.obj || '';
message.success(t('success'));
}
} finally {
apiTokenRotating.value = false;
}
const msg = await HttpUtil.post(`/panel/setting/apiTokens/delete/${row.id}`);
if (msg?.success) await loadApiTokens();
},
});
}
onMounted(loadApiToken);
async function toggleTokenEnabled(row) {
const target = !row.enabled;
const msg = await HttpUtil.post(`/panel/setting/apiTokens/setEnabled/${row.id}`, { enabled: target });
if (msg?.success) row.enabled = target;
}
function maskToken(token) {
if (!token) return '';
return '•'.repeat(Math.min(token.length, 24));
}
function formatTokenDate(ts) {
if (!ts) return '';
return new Date(ts * 1000).toLocaleString();
}
onMounted(loadApiTokens);
function toggleTwoFactor() {
// Switch read-only — the actual flip happens after the modal succeeds.
@@ -217,24 +261,144 @@ function toggleTwoFactor() {
</a-collapse-panel>
<a-collapse-panel key="3" :header="t('pages.nodes.apiToken')">
<SettingListItem paddings="small">
<template #title>{{ t('pages.nodes.apiToken') }}</template>
<template #description>{{ t('pages.nodes.apiTokenHint') }}</template>
<template #control>
<a-input-password :value="apiToken" readonly :loading="apiTokenLoading" style="min-width: 240px" />
</template>
</SettingListItem>
<a-list-item>
<a-space direction="horizontal" :style="{ padding: '0 20px' }">
<a-button :disabled="!apiToken" @click="copyApiToken">{{ t('copy') }}</a-button>
<a-button danger :loading="apiTokenRotating" @click="regenerateApiToken">
{{ t('pages.nodes.regenerate') }}
<div class="api-token-section">
<div class="api-token-header">
<p class="api-token-hint">{{ t('pages.nodes.apiTokenHint') }}</p>
<a-button type="primary" size="small" @click="openCreateModal">
+ {{ t('pages.settings.security.apiTokenNew') || 'New token' }}
</a-button>
</a-space>
</a-list-item>
</div>
<a-spin :spinning="apiTokensLoading">
<a-empty v-if="!apiTokens.length && !apiTokensLoading"
:description="t('pages.settings.security.apiTokenEmpty') || 'No tokens yet'" />
<div v-for="row in apiTokens" :key="row.id" class="api-token-row" :class="{ disabled: !row.enabled }">
<div class="api-token-row-head">
<div class="api-token-name-wrap">
<span class="api-token-name">{{ row.name }}</span>
<span class="api-token-created">{{ formatTokenDate(row.createdAt) }}</span>
</div>
<div class="api-token-actions">
<a-switch size="small" :checked="row.enabled" @change="toggleTokenEnabled(row)" />
<a-button size="small" danger type="text" @click="confirmDeleteToken(row)">
{{ t('delete') }}
</a-button>
</div>
</div>
<div class="api-token-value-wrap">
<code class="api-token-value">{{ isTokenVisible(row.id) ? row.token : maskToken(row.token) }}</code>
<a-button size="small" @click="toggleTokenVisibility(row.id)">
{{ isTokenVisible(row.id)
? (t('pages.settings.security.hide') || 'Hide')
: (t('pages.settings.security.show') || 'Show') }}
</a-button>
<a-button size="small" @click="copyToken(row.token)">{{ t('copy') }}</a-button>
</div>
</div>
</a-spin>
</div>
</a-collapse-panel>
</a-collapse>
<a-modal v-model:open="createOpen" :title="t('pages.settings.security.apiTokenNew') || 'New API token'"
:confirm-loading="creating" :ok-text="t('confirm')" :cancel-text="t('cancel')" @ok="confirmCreateToken">
<a-form layout="vertical">
<a-form-item :label="t('pages.settings.security.apiTokenName') || 'Name'" required>
<a-input v-model:value="createName" maxlength="64"
:placeholder="t('pages.settings.security.apiTokenNamePlaceholder') || 'e.g. central-panel-a'"
@keyup.enter="confirmCreateToken" />
</a-form-item>
</a-form>
</a-modal>
<TwoFactorModal v-model:open="tfa.open" :title="tfa.title" :description="tfa.description" :token="tfa.token"
:type="tfa.type" @confirm="onTfaConfirm" />
</template>
<style scoped>
.api-token-section {
padding: 8px 20px 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.api-token-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.api-token-hint {
margin: 0;
font-size: 12.5px;
opacity: 0.7;
flex: 1;
min-width: 200px;
}
.api-token-row {
border: 1px solid rgba(128, 128, 128, 0.18);
border-radius: 8px;
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 8px;
transition: opacity 0.15s;
}
.api-token-row.disabled {
opacity: 0.55;
}
.api-token-row-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
}
.api-token-name-wrap {
display: flex;
flex-direction: column;
gap: 2px;
}
.api-token-name {
font-weight: 600;
font-size: 13.5px;
}
.api-token-created {
font-size: 11px;
opacity: 0.55;
}
.api-token-actions {
display: flex;
align-items: center;
gap: 8px;
}
.api-token-value-wrap {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.api-token-value {
flex: 1;
min-width: 0;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12.5px;
padding: 4px 8px;
background: rgba(128, 128, 128, 0.08);
border-radius: 4px;
word-break: break-all;
}
</style>

View File

@@ -1,5 +1,5 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { Modal } from 'ant-design-vue';
import {
@@ -152,6 +152,35 @@ const confAlerts = computed(() => {
});
const alertVisible = ref(true);
const tabSlugs = ['general', 'security', 'telegram', 'subscription', 'subscription-formats'];
const slugToKey = (slug) => {
const i = tabSlugs.indexOf(slug);
return i >= 0 ? String(i + 1) : '1';
};
const keyToSlug = (key) => tabSlugs[Number(key) - 1] || tabSlugs[0];
const activeTabKey = ref(slugToKey(window.location.hash.slice(1)));
function onTabChange(key) {
activeTabKey.value = key;
const slug = keyToSlug(key);
if (window.location.hash !== `#${slug}`) {
history.replaceState(null, '', `#${slug}`);
}
}
function syncTabFromHash() {
activeTabKey.value = slugToKey(window.location.hash.slice(1));
}
onMounted(() => {
window.addEventListener('hashchange', syncTabFromHash);
});
onBeforeUnmount(() => {
window.removeEventListener('hashchange', syncTabFromHash);
});
</script>
<template>
@@ -199,39 +228,49 @@ const alertVisible = ref(true);
</a-col>
<a-col :span="24">
<a-tabs default-active-key="1">
<a-tabs :active-key="activeTabKey" :class="{ 'icons-only': isMobile }" @change="onTabChange">
<a-tab-pane key="1" class="tab-pane">
<template #tab>
<SettingOutlined />
<span>{{ t('pages.settings.panelSettings') }}</span>
<a-tooltip :title="isMobile ? t('pages.settings.panelSettings') : null">
<SettingOutlined />
</a-tooltip>
<span v-if="!isMobile">{{ t('pages.settings.panelSettings') }}</span>
</template>
<GeneralTab :all-setting="allSetting" />
</a-tab-pane>
<a-tab-pane key="2" class="tab-pane">
<template #tab>
<SafetyOutlined />
<span>{{ t('pages.settings.securitySettings') }}</span>
<a-tooltip :title="isMobile ? t('pages.settings.securitySettings') : null">
<SafetyOutlined />
</a-tooltip>
<span v-if="!isMobile">{{ t('pages.settings.securitySettings') }}</span>
</template>
<SecurityTab :all-setting="allSetting" />
</a-tab-pane>
<a-tab-pane key="3" class="tab-pane">
<template #tab>
<MessageOutlined />
<span>{{ t('pages.settings.TGBotSettings') }}</span>
<a-tooltip :title="isMobile ? t('pages.settings.TGBotSettings') : null">
<MessageOutlined />
</a-tooltip>
<span v-if="!isMobile">{{ t('pages.settings.TGBotSettings') }}</span>
</template>
<TelegramTab :all-setting="allSetting" />
</a-tab-pane>
<a-tab-pane key="4" class="tab-pane">
<template #tab>
<CloudServerOutlined />
<span>{{ t('pages.settings.subSettings') }}</span>
<a-tooltip :title="isMobile ? t('pages.settings.subSettings') : null">
<CloudServerOutlined />
</a-tooltip>
<span v-if="!isMobile">{{ t('pages.settings.subSettings') }}</span>
</template>
<SubscriptionGeneralTab :all-setting="allSetting" />
</a-tab-pane>
<a-tab-pane v-if="allSetting.subJsonEnable || allSetting.subClashEnable" key="5" class="tab-pane">
<template #tab>
<CodeOutlined />
<span>{{ t('pages.settings.subSettings') }} (Formats)</span>
<a-tooltip :title="isMobile ? `${t('pages.settings.subSettings')} (Formats)` : null">
<CodeOutlined />
</a-tooltip>
<span v-if="!isMobile">{{ t('pages.settings.subSettings') }} (Formats)</span>
</template>
<SubscriptionFormatsTab :all-setting="allSetting" />
</a-tab-pane>
@@ -304,4 +343,33 @@ const alertVisible = ref(true);
.tab-pane {
padding-top: 20px;
}
.icons-only :deep(.ant-tabs-nav) {
margin-bottom: 8px;
}
.icons-only :deep(.ant-tabs-nav-wrap) {
width: 100%;
}
.icons-only :deep(.ant-tabs-nav-list) {
display: flex;
width: 100%;
}
.icons-only :deep(.ant-tabs-tab) {
flex: 1 1 0;
justify-content: center;
margin: 0;
padding: 10px 0;
}
.icons-only :deep(.ant-tabs-tab .anticon) {
margin: 0;
font-size: 18px;
}
.icons-only :deep(.ant-tabs-nav-operations) {
display: none;
}
</style>

View File

@@ -112,6 +112,14 @@ function normalizeSubPath() {
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.settings.subEmailInRemark') }}</template>
<template #description>{{ t('pages.settings.subEmailInRemarkDesc') }}</template>
<template #control>
<a-switch v-model:checked="allSetting.subEmailInRemark" />
</template>
</SettingListItem>
<a-divider>{{ t('pages.settings.subTitle') }}</a-divider>
<SettingListItem paddings="small">

View File

@@ -23,9 +23,12 @@ defineProps({
<SettingListItem paddings="small">
<template #title>{{ t('pages.settings.telegramToken') }}</template>
<template #description>{{ t('pages.settings.telegramTokenDesc') }}</template>
<template #description>
{{ allSetting.hasTgBotToken ? 'Configured; leave blank to keep current token.' : t('pages.settings.telegramTokenDesc') }}
</template>
<template #control>
<a-input v-model:value="allSetting.tgBotToken" type="text" />
<a-input-password v-model:value="allSetting.tgBotToken"
:placeholder="allSetting.hasTgBotToken ? 'Configured - enter a new token to replace' : ''" />
</template>
</SettingListItem>

View File

@@ -38,18 +38,24 @@ function buildTotp() {
watch(() => props.open, (next) => {
if (!next) return;
enteredCode.value = '';
totp = null;
qrValue.value = '';
if (props.token) {
buildTotp();
}
});
function close(success) {
emit('confirm', success);
function close(success, code = '') {
emit('confirm', success, code);
emit('update:open', false);
enteredCode.value = '';
}
function onOk() {
if (props.type === 'confirm' && !props.token) {
close(true, enteredCode.value);
return;
}
if (!totp) return;
if (totp.generate() === enteredCode.value) {
close(true);
@@ -76,7 +82,7 @@ async function copyToken() {
<p>{{ t('pages.settings.security.twoFactorModalFirstStep') }}</p>
<div class="qr-wrap">
<a-qrcode class="qr-code" :value="qrValue" :size="180" type="svg" :bordered="false"
error-level="L" :title="t('copy')" @click="copyToken" />
color="#000000" bg-color="#ffffff" error-level="L" :title="t('copy')" @click="copyToken" />
<span class="qr-token">{{ token }}</span>
</div>
<a-divider />

View File

@@ -40,7 +40,6 @@ const subUrl = subData.subUrl || '';
const subJsonUrl = subData.subJsonUrl || '';
const subClashUrl = subData.subClashUrl || '';
const subTitle = subData.subTitle || '';
const subSupportUrl = subData.subSupportUrl || '';
const links = Array.isArray(subData.links) ? subData.links : [];
// Panel's "Calendar Type" setting; controls whether expiry / lastOnline
// render in Gregorian or Jalali on this standalone subscription page.
@@ -205,7 +204,7 @@ const themeClass = computed(() => ({
<div class="qr-box">
<a-tag color="purple" class="qr-tag">{{ t('pages.settings.subSettings') }}</a-tag>
<a-qrcode class="qr-code" :value="subUrl" :size="QR_SIZE" type="svg" :bordered="false"
:title="t('copy')" @click="copy(subUrl)" />
color="#000000" bg-color="#ffffff" :title="t('copy')" @click="copy(subUrl)" />
</div>
</a-col>
<a-col v-if="subJsonUrl" :xs="24" :sm="12" class="qr-col">
@@ -214,14 +213,14 @@ const themeClass = computed(() => ({
{{ t('pages.settings.subSettings') }} JSON
</a-tag>
<a-qrcode class="qr-code" :value="subJsonUrl" :size="QR_SIZE" type="svg" :bordered="false"
:title="t('copy')" @click="copy(subJsonUrl)" />
color="#000000" bg-color="#ffffff" :title="t('copy')" @click="copy(subJsonUrl)" />
</div>
</a-col>
<a-col v-if="subClashUrl" :xs="24" :sm="12" class="qr-col">
<div class="qr-box">
<a-tag color="purple" class="qr-tag">Clash / Mihomo</a-tag>
<a-qrcode class="qr-code" :value="subClashUrl" :size="QR_SIZE" type="svg" :bordered="false"
:title="t('copy')" @click="copy(subClashUrl)" />
color="#000000" bg-color="#ffffff" :title="t('copy')" @click="copy(subClashUrl)" />
</div>
</a-col>
</a-row>

View File

@@ -10,6 +10,7 @@ import {
import { Modal } from 'ant-design-vue';
import BalancerFormModal from './BalancerFormModal.vue';
import JsonEditor from '@/components/JsonEditor.vue';
const { t } = useI18n();
@@ -21,6 +22,7 @@ const { t } = useI18n();
const props = defineProps({
templateSettings: { type: Object, default: null },
clientReverseTags: { type: Array, default: () => [] },
isMobile: { type: Boolean, default: false },
});
const STRATEGY_LABELS = {
@@ -196,7 +198,7 @@ function confirmDelete(idx) {
}
const columns = computed(() => [
{ title: '#', key: 'action', align: 'center', width: 80 },
{ title: '#', key: 'action', align: 'center', width: 100 },
{ title: 'Tag', dataIndex: 'tag', key: 'tag', align: 'center', width: 160 },
{ title: 'Strategy', key: 'strategy', align: 'center', width: 140 },
{ title: 'Selector', key: 'selector', align: 'center' },
@@ -266,25 +268,39 @@ const obsText = computed({
{{ t('pages.xray.Balancers') }}
</a-button>
<a-table :columns="columns" :data-source="rows" :row-key="(r) => r.key" :pagination="false" size="small" bordered>
<a-table :columns="columns" :data-source="rows" :row-key="(r) => r.key" :pagination="false"
size="small" :scroll="{ x: 400 }">
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'action'">
<span class="row-index">{{ index + 1 }}</span>
<a-dropdown :trigger="['click']">
<a-button shape="circle" size="small" class="action-btn">
<MoreOutlined />
</a-button>
<template #overlay>
<a-menu>
<a-menu-item @click="openEdit(index)">
<EditOutlined /> {{ t('edit') }}
</a-menu-item>
<a-menu-item class="danger" @click="confirmDelete(index)">
<DeleteOutlined /> {{ t('delete') }}
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
<div class="action-cell">
<span class="row-index">{{ index + 1 }}</span>
<div :class="!isMobile ? 'action-buttons' : ''">
<a-button v-if="!isMobile" shape="circle" size="small" @click="openEdit(index)">
<template #icon>
<EditOutlined />
</template>
</a-button>
<a-dropdown :trigger="['click']">
<a-button shape="circle" size="small">
<template #icon>
<MoreOutlined />
</template>
</a-button>
<template #overlay>
<a-menu>
<a-menu-item v-if="isMobile" @click="openEdit(index)">
<EditOutlined /> {{ t('edit') }}
</a-menu-item>
<a-menu-item class="danger" @click="confirmDelete(index)">
<DeleteOutlined /> {{ t('delete') }}
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</div>
</template>
<template v-else-if="column.key === 'strategy'">
@@ -305,8 +321,7 @@ const obsText = computed({
<a-radio-button v-if="hasObservatory" value="observatory">Observatory</a-radio-button>
<a-radio-button v-if="hasBurstObservatory" value="burstObservatory">Burst Observatory</a-radio-button>
</a-radio-group>
<a-textarea v-model:value="obsText" :auto-size="{ minRows: 8, maxRows: 24 }" spellcheck="false"
class="json-editor" />
<JsonEditor v-model:value="obsText" min-height="220px" max-height="480px" />
</template>
</template>
@@ -316,23 +331,29 @@ const obsText = computed({
</template>
<style scoped>
.action-cell {
display: flex;
align-items: center;
gap: 6px;
}
.row-index {
font-weight: 500;
opacity: 0.7;
margin-right: 6px;
min-width: 18px;
text-align: right;
}
.action-btn {
vertical-align: middle;
.action-buttons {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
margin-left: auto;
}
.danger {
color: #ff4d4f;
}
.json-editor {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
margin-top: 8px;
}
</style>

View File

@@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const props = defineProps({
defineProps({
open: { type: Boolean, default: false },
});

View File

@@ -21,6 +21,7 @@ import {
DNSRuleActions,
} from '@/models/outbound.js';
import FinalMaskForm from '@/components/FinalMaskForm.vue';
import JsonEditor from '@/components/JsonEditor.vue';
const { t } = useI18n();
@@ -80,8 +81,17 @@ watch(() => props.open, (next) => {
primeAdvancedJson();
});
watch(activeKey, (key) => {
if (key === '2') primeAdvancedJson();
let isRevertingTab = false;
watch(activeKey, (key, prev) => {
if (isRevertingTab) { isRevertingTab = false; return; }
if (key === '2') {
primeAdvancedJson();
} else if (key === '1' && prev === '2') {
if (!applyAdvancedJsonToForm()) {
isRevertingTab = true;
activeKey.value = '2';
}
}
});
function primeAdvancedJson() {
@@ -93,6 +103,33 @@ function primeAdvancedJson() {
}
}
function applyAdvancedJsonToForm() {
const raw = advancedJson.value.trim();
if (!raw) return true;
let currentJson = '';
try {
currentJson = JSON.stringify(outbound.value?.toJson() ?? {}, null, 2);
} catch (_e) { /* fall through */ }
if (raw === currentJson.trim()) return true;
let parsed;
try {
parsed = JSON.parse(raw);
} catch (e) {
message.error(`JSON: ${e.message}`);
return false;
}
try {
const fallbackTag = outbound.value?.tag;
const next = Outbound.fromJson(parsed);
if (!next.tag && fallbackTag) next.tag = fallbackTag;
outbound.value = next;
return true;
} catch (e) {
message.error(`JSON: ${e.message}`);
return false;
}
}
function close() { emit('update:open', false); }
function onProtocolChange(next) {
@@ -131,27 +168,15 @@ const tagHelp = computed(() => {
// ============== Submit ==============
function onOk() {
if (!outbound.value) return;
if (activeKey.value === '2' && !applyAdvancedJsonToForm()) return;
if (!outbound.value.tag?.trim()) {
message.error(t('somethingWentWrong'));
message.error('Tag is required');
return;
}
if (duplicateTag.value) {
message.error(t('somethingWentWrong'));
message.error('Tag already used by another outbound');
return;
}
// If user spent time in the JSON tab, prefer that body — round-trip
// it through Outbound.fromJson so the wire shape stays consistent.
if (activeKey.value === '2' && advancedJson.value.trim()) {
try {
const parsed = JSON.parse(advancedJson.value);
const built = Outbound.fromJson(parsed);
emit('confirm', built.toJson());
return;
} catch (e) {
message.error(`JSON: ${e.message}`);
return;
}
}
emit('confirm', outbound.value.toJson());
}
@@ -170,6 +195,7 @@ function convertLink() {
return;
}
outbound.value = next;
primeAdvancedJson();
linkInput.value = '';
message.success('Link imported successfully...');
activeKey.value = '1';
@@ -964,8 +990,7 @@ function regenerateWgKeys() {
<a-button>Convert</a-button>
</template>
</a-input-search>
<a-textarea v-model:value="advancedJson" :auto-size="{ minRows: 14, maxRows: 30 }" spellcheck="false"
class="json-editor" />
<JsonEditor v-model:value="advancedJson" min-height="360px" max-height="600px" />
</a-space>
</a-tab-pane>
</a-tabs>
@@ -1008,11 +1033,6 @@ function regenerateWgKeys() {
opacity: 0.85;
}
.json-editor {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
}
/* AD-Vue 4 renders a-checkbox children inside a-checkbox-group as
* inline-block, but inside a narrow form wrapper they can wrap
* inconsistently. Force a clean horizontal row with even gaps. */

View File

@@ -157,9 +157,9 @@ function hasBreakdown(r) {
// === Columns ========================================================
// Computed so titles re-render after a locale swap.
const columns = computed(() => [
{ title: '#', key: 'action', align: 'center', width: 70 },
{ title: 'Tag', key: 'identity', align: 'left', width: 220 },
{ title: t('pages.inbounds.address'), key: 'address', align: 'left', width: 230 },
{ title: '#', key: 'action', align: 'center', width: 100 },
{ title: 'Tag', key: 'identity', align: 'left' },
{ title: t('pages.inbounds.address'), key: 'address', align: 'left' },
{ title: t('pages.inbounds.traffic'), key: 'traffic', align: 'left', width: 200 },
{ title: t('pages.xray.latency') !== 'pages.xray.latency' ? t('pages.xray.latency') : 'Latency', key: 'testResult', align: 'left', width: 140 },
{ title: t('check'), key: 'test', align: 'center', width: 80 },
@@ -322,33 +322,41 @@ const rows = computed(() => {
<template v-if="column.key === 'action'">
<div class="action-cell">
<span class="row-index">{{ index + 1 }}</span>
<a-dropdown :trigger="['click']">
<a-button shape="circle" size="small">
<MoreOutlined />
</a-button>
<template #overlay>
<a-menu>
<a-menu-item v-if="index > 0" @click="setFirst(index)">
<VerticalAlignTopOutlined /> Move to top
</a-menu-item>
<a-menu-item @click="openEdit(index)">
<EditOutlined /> Edit
</a-menu-item>
<a-menu-item :disabled="index === 0" @click="moveUp(index)">
<ArrowUpOutlined />
</a-menu-item>
<a-menu-item :disabled="index === rows.length - 1" @click="moveDown(index)">
<ArrowDownOutlined />
</a-menu-item>
<a-menu-item @click="emit('reset-traffic', record.tag || '')">
<RetweetOutlined /> Reset traffic
</a-menu-item>
<a-menu-item class="danger" @click="confirmDelete(index)">
<DeleteOutlined /> Delete
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
<div class="action-buttons">
<a-button shape="circle" size="small" @click="openEdit(index)">
<template #icon>
<EditOutlined />
</template>
</a-button>
<a-dropdown :trigger="['click']">
<a-button shape="circle" size="small">
<template #icon>
<MoreOutlined />
</template>
</a-button>
<template #overlay>
<a-menu>
<a-menu-item v-if="index > 0" @click="setFirst(index)">
<VerticalAlignTopOutlined /> Move to top
</a-menu-item>
<a-menu-item :disabled="index === 0" @click="moveUp(index)">
<ArrowUpOutlined />
</a-menu-item>
<a-menu-item :disabled="index === rows.length - 1" @click="moveDown(index)">
<ArrowDownOutlined />
</a-menu-item>
<a-menu-item @click="emit('reset-traffic', record.tag || '')">
<RetweetOutlined /> Reset traffic
</a-menu-item>
<a-menu-item class="danger" @click="confirmDelete(index)">
<DeleteOutlined /> Delete
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</div>
</template>
@@ -444,6 +452,11 @@ const rows = computed(() => {
justify-content: flex-end;
}
.toolbar-right :global(.ant-space),
.header-actions :global(.ant-space) {
margin-bottom: 0 !important;
}
.card-empty {
text-align: center;
opacity: 0.4;
@@ -526,6 +539,14 @@ const rows = computed(() => {
text-align: right;
}
.action-buttons {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
margin-left: auto;
}
.identity-cell {
display: flex;
flex-direction: column;

View File

@@ -10,6 +10,7 @@ import {
ClusterOutlined,
ArrowUpOutlined,
ArrowDownOutlined,
HolderOutlined,
} from '@ant-design/icons-vue';
import { Modal } from 'ant-design-vue';
@@ -22,9 +23,11 @@ const { t } = useI18n();
// "lead value + N more" pill per criterion (matches the legacy pill
// layout); full lists surface via tooltip on hover.
//
// Reorder uses up/down buttons in the action menu rather than the
// jQuery-Sortable drag handle the legacy panel used — same effect,
// no extra dep. The mobile column layout drops source/network/
// Reorder via Pointer Events on the grip icon — unified mouse +
// touch + pen path so the same code works on desktop and mobile
// (HTML5 drag doesn't fire from touch on iOS Safari, hence the
// switch). Up/down buttons in the action menu stay as a keyboard
// fallback. The mobile column layout drops source/network/
// destination criteria for readability.
const props = defineProps({
@@ -162,22 +165,91 @@ function moveDown(idx) {
[rules[idx + 1], rules[idx]] = [rules[idx], rules[idx + 1]];
}
const draggedIndex = ref(null);
const dropTargetIndex = ref(null);
let dragStartY = 0;
let dragMoved = false;
function onHandlePointerDown(idx, ev) {
if (ev.button != null && ev.button !== 0) return;
ev.preventDefault();
draggedIndex.value = idx;
dropTargetIndex.value = idx;
dragStartY = ev.clientY;
dragMoved = false;
document.addEventListener('pointermove', onDragPointerMove);
document.addEventListener('pointerup', onDragPointerUp);
document.addEventListener('pointercancel', onDragPointerUp);
}
function onDragPointerMove(ev) {
if (draggedIndex.value == null) return;
if (!dragMoved && Math.abs(ev.clientY - dragStartY) < 5) return;
dragMoved = true;
const el = document.elementFromPoint(ev.clientX, ev.clientY);
if (!el) return;
const target = el.closest('[data-row-key]');
if (!target) return;
const idx = Number(target.getAttribute('data-row-key'));
if (Number.isFinite(idx)) dropTargetIndex.value = idx;
}
function onDragPointerUp() {
document.removeEventListener('pointermove', onDragPointerMove);
document.removeEventListener('pointerup', onDragPointerUp);
document.removeEventListener('pointercancel', onDragPointerUp);
const from = draggedIndex.value;
const to = dropTargetIndex.value;
draggedIndex.value = null;
dropTargetIndex.value = null;
if (!dragMoved || from == null || to == null || from === to) return;
const rules = props.templateSettings.routing.rules;
const [moved] = rules.splice(from, 1);
rules.splice(to, 0, moved);
}
function rowProps(_record, index) {
const classes = [];
if (draggedIndex.value === index) classes.push('row-dragging');
if (dropTargetIndex.value === index && draggedIndex.value !== index) {
classes.push(index > draggedIndex.value ? 'drop-after' : 'drop-before');
}
return { class: classes.join(' ') };
}
// === Columns =========================================================
// Computed so titles re-render after a locale swap.
const desktopColumns = computed(() => [
{ title: '#', align: 'center', width: 70, key: 'action' },
{ title: '#', align: 'center', width: 100, key: 'action' },
{ title: 'Source', align: 'left', width: 180, key: 'source' },
{ title: t('pages.inbounds.network'), align: 'left', width: 180, key: 'network' },
{ title: 'Destination', align: 'left', key: 'destination' },
{ title: t('pages.xray.Inbounds'), align: 'left', width: 180, key: 'inbound' },
{ title: t('pages.xray.Outbounds'), align: 'left', width: 170, key: 'target' },
{ title: t('pages.xray.Outbounds'), align: 'left', width: 170, key: 'outbound' },
{ title: t('pages.xray.Balancers'), align: 'left', width: 150, key: 'balancer' },
]);
const mobileColumns = computed(() => [
{ title: '#', align: 'center', width: 70, key: 'action' },
{ title: t('pages.xray.Inbounds'), align: 'left', key: 'inbound' },
{ title: t('pages.xray.Outbounds'), align: 'left', width: 140, key: 'target' },
]);
const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopColumns.value));
const columns = computed(() => desktopColumns.value);
function ruleCriteriaChips(rule) {
const chips = [];
if (rule.domain) chips.push({ label: 'Domain', value: rule.domain });
if (rule.ip) chips.push({ label: 'IP', value: rule.ip });
if (rule.port) chips.push({ label: 'Port', value: rule.port });
if (rule.sourceIP) chips.push({ label: 'Src IP', value: rule.sourceIP });
if (rule.sourcePort) chips.push({ label: 'Src Port', value: rule.sourcePort });
if (rule.network) chips.push({ label: 'L4', value: rule.network });
if (rule.protocol) chips.push({ label: 'Protocol', value: rule.protocol });
if (rule.user) chips.push({ label: 'User', value: rule.user });
if (rule.vlessRoute) chips.push({ label: 'VLESS', value: rule.vlessRoute });
return chips;
}
function chipPreview(value) {
const parts = csv(value);
if (parts.length === 0) return '';
if (parts.length === 1) return parts[0];
return `${parts[0]} +${parts.length - 1}`;
}
</script>
<template>
@@ -189,34 +261,117 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
{{ t('pages.xray.Routings') }}
</a-button>
<a-table :columns="columns" :data-source="rows" :row-key="(r) => r.key" :pagination="false"
:scroll="isMobile ? {} : { x: 1000 }" size="small" class="routing-table">
<!-- Mobile: stacked cards. The desktop a-table doesn't fit on a
phone (~520px of columns alone), so render each rule as a
compact card with the routing summary + criteria chips. -->
<div v-if="isMobile" class="rule-list">
<div v-for="(rule, index) in rows" :key="rule.key" class="rule-card" :class="{
'row-dragging': draggedIndex === index,
'drop-before': dropTargetIndex === index && draggedIndex != null && index < draggedIndex,
'drop-after': dropTargetIndex === index && draggedIndex != null && index > draggedIndex,
}" :data-row-key="index">
<div class="rule-card-head">
<HolderOutlined class="drag-handle" @pointerdown="onHandlePointerDown(index, $event)" />
<span class="rule-number">#{{ index + 1 }}</span>
<a-dropdown :trigger="['click']">
<a-button shape="circle" size="small">
<MoreOutlined />
</a-button>
<template #overlay>
<a-menu>
<a-menu-item @click="openEdit(index)">
<EditOutlined /> {{ t('edit') }}
</a-menu-item>
<a-menu-item :disabled="index === 0" @click="moveUp(index)">
<ArrowUpOutlined />
</a-menu-item>
<a-menu-item :disabled="index === rows.length - 1" @click="moveDown(index)">
<ArrowDownOutlined />
</a-menu-item>
<a-menu-item class="danger" @click="confirmDelete(index)">
<DeleteOutlined /> {{ t('delete') }}
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
<div class="rule-flow">
<div class="flow-side">
<span class="flow-label">{{ t('pages.xray.Inbounds') }}</span>
<a-tag v-if="rule.inboundTag" color="blue" class="flow-tag">
{{ chipPreview(rule.inboundTag) }}
</a-tag>
<span v-else class="criterion-empty">any</span>
</div>
<span class="flow-arrow">→</span>
<div class="flow-side flow-side-target">
<span class="flow-label">{{
rule.balancerTag ? (t('pages.xray.balancer') || 'Balancer') : t('pages.xray.Outbounds')
}}</span>
<a-tag v-if="rule.outboundTag" color="green" class="flow-tag">
<ExportOutlined /> {{ rule.outboundTag }}
</a-tag>
<a-tag v-else-if="rule.balancerTag" color="purple" class="flow-tag">
<ClusterOutlined /> {{ rule.balancerTag }}
</a-tag>
<span v-else class="criterion-empty">—</span>
</div>
</div>
<div v-if="ruleCriteriaChips(rule).length" class="rule-criteria">
<a-tooltip v-for="chip in ruleCriteriaChips(rule)" :key="chip.label" :title="`${chip.label}: ${chip.value}`">
<span class="criterion-chip">
<span class="criterion-chip-label">{{ chip.label }}</span>
<span class="criterion-chip-value">{{ chipPreview(chip.value) }}</span>
</span>
</a-tooltip>
</div>
</div>
<div v-if="!rows.length" class="rule-empty">—</div>
</div>
<a-table v-else :columns="columns" :data-source="rows" :row-key="(r) => r.key" :pagination="false"
:scroll="{ x: 1150 }" size="small" class="routing-table" :custom-row="rowProps">
<template #bodyCell="{ column, record, index }">
<!-- ============== # / actions ============== -->
<template v-if="column.key === 'action'">
<div class="action-cell">
<HolderOutlined class="drag-handle" :title="t('drag') || 'Drag to reorder'"
@pointerdown="onHandlePointerDown(index, $event)" />
<span class="row-index">{{ index + 1 }}</span>
<a-dropdown :trigger="['click']">
<a-button shape="circle" size="small">
<MoreOutlined />
<div :class="!isMobile ? 'action-buttons' : ''">
<a-button v-if="!isMobile" shape="circle" size="small" @click="openEdit(index)">
<template #icon>
<EditOutlined />
</template>
</a-button>
<template #overlay>
<a-menu>
<a-menu-item @click="openEdit(index)">
<EditOutlined /> {{ t('edit') }}
</a-menu-item>
<a-menu-item :disabled="index === 0" @click="moveUp(index)">
<ArrowUpOutlined />
</a-menu-item>
<a-menu-item :disabled="index === rows.length - 1" @click="moveDown(index)">
<ArrowDownOutlined />
</a-menu-item>
<a-menu-item class="danger" @click="confirmDelete(index)">
<DeleteOutlined /> {{ t('delete') }}
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
<a-dropdown :trigger="['click']">
<a-button shape="circle" size="small">
<template #icon>
<MoreOutlined />
</template>
</a-button>
<template #overlay>
<a-menu>
<a-menu-item v-if="isMobile" @click="openEdit(index)">
<EditOutlined /> {{ t('edit') }}
</a-menu-item>
<a-menu-item :disabled="index === 0" @click="moveUp(index)">
<ArrowUpOutlined />
</a-menu-item>
<a-menu-item :disabled="index === rows.length - 1" @click="moveDown(index)">
<ArrowDownOutlined />
</a-menu-item>
<a-menu-item class="danger" @click="confirmDelete(index)">
<DeleteOutlined /> {{ t('delete') }}
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</div>
</template>
@@ -228,7 +383,7 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
<span class="criterion-label">IP</span>
<span class="criterion-value">{{ csv(record.sourceIP)[0] }}</span>
<span v-if="csv(record.sourceIP).length > 1" class="criterion-more">+{{ csv(record.sourceIP).length - 1
}}</span>
}}</span>
</span>
</a-tooltip>
<a-tooltip v-if="record.sourcePort" :title="`Source port: ${record.sourcePort}`">
@@ -259,7 +414,7 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
<span class="criterion-label">L4</span>
<span class="criterion-value">{{ csv(record.network)[0] }}</span>
<span v-if="csv(record.network).length > 1" class="criterion-more">+{{ csv(record.network).length - 1
}}</span>
}}</span>
</span>
</a-tooltip>
<a-tooltip v-if="record.protocol" :title="`Protocol: ${record.protocol}`">
@@ -267,7 +422,7 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
<span class="criterion-label">Protocol</span>
<span class="criterion-value">{{ csv(record.protocol)[0] }}</span>
<span v-if="csv(record.protocol).length > 1" class="criterion-more">+{{ csv(record.protocol).length - 1
}}</span>
}}</span>
</span>
</a-tooltip>
<a-tooltip v-if="record.attrs" :title="`Attrs: ${record.attrs}`">
@@ -295,7 +450,7 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
<span class="criterion-label">Domain</span>
<span class="criterion-value">{{ csv(record.domain)[0] }}</span>
<span v-if="csv(record.domain).length > 1" class="criterion-more">+{{ csv(record.domain).length - 1
}}</span>
}}</span>
</span>
</a-tooltip>
<a-tooltip v-if="record.port" :title="`Destination port: ${record.port}`">
@@ -303,7 +458,7 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
<span class="criterion-label">Port</span>
<span class="criterion-value">{{ csv(record.port)[0] }}</span>
<span v-if="csv(record.port).length > 1" class="criterion-more">+{{ csv(record.port).length - 1
}}</span>
}}</span>
</span>
</a-tooltip>
<span v-if="!record.ip && !record.domain && !record.port" class="criterion-empty">—</span>
@@ -326,25 +481,32 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
<span class="criterion-label">User</span>
<span class="criterion-value">{{ csv(record.user)[0] }}</span>
<span v-if="csv(record.user).length > 1" class="criterion-more">+{{ csv(record.user).length - 1
}}</span>
}}</span>
</span>
</a-tooltip>
<span v-if="!record.inboundTag && !record.user" class="criterion-empty">—</span>
</div>
</template>
<!-- ============== Outbound / balancer target ============== -->
<template v-else-if="column.key === 'target'">
<!-- ============== Outbound ============== -->
<template v-else-if="column.key === 'outbound'">
<div class="target-cell">
<div v-if="record.outboundTag" class="target-row">
<ExportOutlined class="target-icon" />
<a-tag color="green">{{ record.outboundTag }}</a-tag>
</div>
<span v-else class="criterion-empty">—</span>
</div>
</template>
<!-- ============== Balancer ============== -->
<template v-else-if="column.key === 'balancer'">
<div class="target-cell">
<div v-if="record.balancerTag" class="target-row">
<ClusterOutlined class="target-icon" />
<a-tag color="purple">{{ record.balancerTag }}</a-tag>
</div>
<span v-if="!record.outboundTag && !record.balancerTag" class="criterion-empty">—</span>
<span v-else class="criterion-empty">—</span>
</div>
</template>
</template>
@@ -362,6 +524,36 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
gap: 6px;
}
.drag-handle {
cursor: grab;
opacity: 0.35;
font-size: 14px;
padding: 4px;
margin: -4px;
touch-action: none;
transition: opacity 0.15s;
}
.drag-handle:hover {
opacity: 0.8;
}
.drag-handle:active {
cursor: grabbing;
}
:deep(.row-dragging) {
opacity: 0.4;
}
:deep(.drop-before > td) {
box-shadow: inset 0 2px 0 0 #1677ff;
}
:deep(.drop-after > td) {
box-shadow: inset 0 -2px 0 0 #1677ff;
}
.row-index {
font-weight: 500;
opacity: 0.7;
@@ -369,6 +561,14 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
text-align: right;
}
.action-buttons {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
margin-left: auto;
}
.criterion-flow {
display: flex;
flex-direction: column;
@@ -429,4 +629,136 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
.danger {
color: #ff4d4f;
}
/* === Mobile card list ====================================== */
.rule-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.rule-card {
position: relative;
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 12px;
background: var(--bg-card, #fff);
border: 1px solid rgba(128, 128, 128, 0.15);
border-radius: 8px;
transition: opacity 0.15s, box-shadow 0.15s;
}
.rule-card.row-dragging {
opacity: 0.4;
}
.rule-card.drop-before {
box-shadow: inset 0 2px 0 0 #1677ff;
}
.rule-card.drop-after {
box-shadow: inset 0 -2px 0 0 #1677ff;
}
.rule-card-head {
display: flex;
align-items: center;
gap: 8px;
}
.rule-number {
font-weight: 600;
font-size: 13px;
opacity: 0.75;
flex: 1;
}
.rule-flow {
display: flex;
align-items: center;
gap: 8px;
}
.flow-side {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.flow-side-target {
align-items: flex-end;
text-align: right;
}
.flow-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.55;
}
.flow-tag {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin: 0;
}
.flow-arrow {
font-size: 16px;
opacity: 0.45;
flex-shrink: 0;
}
.rule-criteria {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding-top: 6px;
border-top: 1px dashed rgba(128, 128, 128, 0.2);
}
.criterion-chip {
display: inline-flex;
align-items: baseline;
gap: 4px;
padding: 1px 6px;
font-size: 11px;
background: rgba(128, 128, 128, 0.08);
border-radius: 4px;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.criterion-chip-label {
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.04em;
opacity: 0.6;
}
.criterion-chip-value {
font-weight: 500;
}
.rule-empty {
padding: 24px;
text-align: center;
opacity: 0.4;
}
:global(body.dark) .rule-card {
background: rgba(255, 255, 255, 0.04);
border-color: rgba(255, 255, 255, 0.08);
}
:global(body.dark) .criterion-chip {
background: rgba(255, 255, 255, 0.06);
}
</style>

View File

@@ -27,6 +27,7 @@ const loading = ref(false);
const warpData = ref(null);
const warpConfig = ref(null);
const warpPlus = ref('');
const licenseError = ref('');
// Held in memory so the parent's add/reset handlers receive the same
// object the modal computed from getConfig().
const stagedOutbound = ref(null);
@@ -41,6 +42,7 @@ watch(() => props.open, (next) => {
if (!next) return;
warpConfig.value = null;
stagedOutbound.value = null;
licenseError.value = '';
fetchData();
});
@@ -89,12 +91,15 @@ async function getConfig() {
async function updateLicense() {
if (warpPlus.value.length < 26) return;
loading.value = true;
licenseError.value = '';
try {
const msg = await HttpUtil.post('/panel/xray/warp/license', { license: warpPlus.value });
if (msg?.success) {
warpData.value = JSON.parse(msg.obj);
warpConfig.value = null;
warpPlus.value = '';
} else {
licenseError.value = msg?.msg || 'Failed to set WARP license.';
}
} finally {
loading.value = false;
@@ -233,9 +238,12 @@ const hasConfig = computed(() => !ObjectUtil.isEmpty(warpConfig.value));
<a-collapse-panel header="WARP / WARP+ license key">
<a-form :colon="false" :label-col="{ md: { span: 6 } }" :wrapper-col="{ md: { span: 14 } }">
<a-form-item label="Key">
<a-input v-model:value="warpPlus" placeholder="26-char WARP+ key" />
<a-button type="primary" class="mt-8" :disabled="warpPlus.length < 26" :loading="loading"
@click="updateLicense">Update</a-button>
<a-input v-model:value="warpPlus" placeholder="26-char WARP+ key" @update:value="licenseError = ''" />
<div class="license-actions mt-8">
<a-button type="primary" :disabled="warpPlus.length < 26" :loading="loading"
@click="updateLicense">Update</a-button>
<a-alert v-if="licenseError" :message="licenseError" type="error" show-icon class="license-error" />
</div>
</a-form-item>
</a-form>
</a-collapse-panel>
@@ -358,4 +366,16 @@ const hasConfig = computed(() => !ObjectUtil.isEmpty(warpConfig.value));
.ml-8 {
margin-left: 8px;
}
.license-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.license-error {
flex: 1;
min-width: 0;
}
</style>

View File

@@ -1,5 +1,5 @@
<script setup>
import { computed, ref } from 'vue';
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { Modal, message } from 'ant-design-vue';
import {
@@ -22,6 +22,7 @@ import BalancersTab from './BalancersTab.vue';
import DnsTab from './DnsTab.vue';
import WarpModal from './WarpModal.vue';
import NordModal from './NordModal.vue';
import JsonEditor from '@/components/JsonEditor.vue';
import { useXraySetting } from './useXraySetting.js';
import { useWebSocket } from '@/composables/useWebSocket.js';
@@ -186,9 +187,6 @@ function onRemoveRoutingRules({ prefix }) {
);
}
// `message` is used by some of the in-progress UX flows (kept around
// because future provisioning errors will surface through it).
void message;
const { isMobile } = useMediaQuery();
const basePath = window.X_UI_BASE_PATH || '';
@@ -208,6 +206,51 @@ function confirmRestart() {
onOk: () => restartXray(),
});
}
const tabKeys = ['tpl-basic', 'tpl-routing', 'tpl-outbound', 'tpl-balancer', 'tpl-dns', 'tpl-advanced'];
const slugByKey = {
'tpl-basic': 'basic',
'tpl-routing': 'routing',
'tpl-outbound': 'outbound',
'tpl-balancer': 'balancer',
'tpl-dns': 'dns',
'tpl-advanced': 'advanced',
};
const keyBySlug = Object.fromEntries(Object.entries(slugByKey).map(([k, v]) => [v, k]));
const activeTabKey = ref(keyBySlug[window.location.hash.slice(1)] || tabKeys[0]);
function onTabChange(key) {
activeTabKey.value = key;
const slug = slugByKey[key];
if (slug && window.location.hash !== `#${slug}`) {
history.replaceState(null, '', `#${slug}`);
}
}
function onSaveAll() {
try {
JSON.parse(xraySetting.value);
} catch (e) {
message.error(`Advanced JSON: ${e.message}`);
activeTabKey.value = 'tpl-advanced';
return;
}
saveAll();
}
function syncTabFromHash() {
const key = keyBySlug[window.location.hash.slice(1)];
if (key) activeTabKey.value = key;
}
onMounted(() => {
window.addEventListener('hashchange', syncTabFromHash);
});
onBeforeUnmount(() => {
window.removeEventListener('hashchange', syncTabFromHash);
});
</script>
<template>
@@ -234,7 +277,7 @@ function confirmRestart() {
<a-row class="header-row">
<a-col :xs="24" :sm="14" class="header-actions">
<a-space direction="horizontal">
<a-button type="primary" :disabled="saveDisabled" @click="saveAll">
<a-button type="primary" :disabled="saveDisabled" @click="onSaveAll">
{{ t('pages.xray.save') }}
</a-button>
<a-button type="primary" danger :disabled="!saveDisabled" @click="confirmRestart">
@@ -259,10 +302,13 @@ function confirmRestart() {
<!-- Tabs -->
<a-col :span="24">
<a-tabs default-active-key="tpl-basic">
<a-tabs :active-key="activeTabKey" :class="{ 'icons-only': isMobile }" @change="onTabChange">
<a-tab-pane key="tpl-basic" class="tab-pane">
<template #tab>
<SettingOutlined /> <span>{{ t('pages.xray.basicTemplate') }}</span>
<a-tooltip :title="isMobile ? t('pages.xray.basicTemplate') : null">
<SettingOutlined />
</a-tooltip>
<span v-if="!isMobile">{{ t('pages.xray.basicTemplate') }}</span>
</template>
<BasicsTab :template-settings="templateSettings" :outbound-test-url="outboundTestUrl"
:warp-exist="warpExist" :nord-exist="nordExist"
@@ -272,7 +318,10 @@ function confirmRestart() {
<a-tab-pane key="tpl-routing" class="tab-pane">
<template #tab>
<SwapOutlined /> <span>{{ t('pages.xray.Routings') }}</span>
<a-tooltip :title="isMobile ? t('pages.xray.Routings') : null">
<SwapOutlined />
</a-tooltip>
<span v-if="!isMobile">{{ t('pages.xray.Routings') }}</span>
</template>
<RoutingTab :template-settings="templateSettings" :inbound-tags="inboundTags"
:client-reverse-tags="clientReverseTags" :is-mobile="isMobile" />
@@ -280,7 +329,10 @@ function confirmRestart() {
<a-tab-pane key="tpl-outbound" class="tab-pane">
<template #tab>
<UploadOutlined /> <span>{{ t('pages.xray.Outbounds') }}</span>
<a-tooltip :title="isMobile ? t('pages.xray.Outbounds') : null">
<UploadOutlined />
</a-tooltip>
<span v-if="!isMobile">{{ t('pages.xray.Outbounds') }}</span>
</template>
<OutboundsTab :template-settings="templateSettings" :outbounds-traffic="outboundsTraffic"
:outbound-test-states="outboundTestStates" :testing-all="testingAll"
@@ -292,21 +344,31 @@ function confirmRestart() {
<a-tab-pane key="tpl-balancer" class="tab-pane">
<template #tab>
<ClusterOutlined /> <span>{{ t('pages.xray.Balancers') }}</span>
<a-tooltip :title="isMobile ? t('pages.xray.Balancers') : null">
<ClusterOutlined />
</a-tooltip>
<span v-if="!isMobile">{{ t('pages.xray.Balancers') }}</span>
</template>
<BalancersTab :template-settings="templateSettings" :client-reverse-tags="clientReverseTags" />
<BalancersTab :template-settings="templateSettings"
:client-reverse-tags="clientReverseTags" :is-mobile="isMobile" />
</a-tab-pane>
<a-tab-pane key="tpl-dns" class="tab-pane">
<template #tab>
<DatabaseOutlined /> <span>DNS</span>
<a-tooltip :title="isMobile ? 'DNS' : null">
<DatabaseOutlined />
</a-tooltip>
<span v-if="!isMobile">DNS</span>
</template>
<DnsTab :template-settings="templateSettings" />
</a-tab-pane>
<a-tab-pane key="tpl-advanced" class="tab-pane">
<template #tab>
<CodeOutlined /> <span>{{ t('pages.xray.advancedTemplate') }}</span>
<a-tooltip :title="isMobile ? t('pages.xray.advancedTemplate') : null">
<CodeOutlined />
</a-tooltip>
<span v-if="!isMobile">{{ t('pages.xray.advancedTemplate') }}</span>
</template>
<a-list-item-meta :title="t('pages.xray.Template')" :description="t('pages.xray.TemplateDesc')" />
<a-radio-group v-model:value="advSettings" button-style="solid"
@@ -316,8 +378,7 @@ function confirmRestart() {
<a-radio-button value="outboundSettings">{{ t('pages.xray.Outbounds') }}</a-radio-button>
<a-radio-button value="routingRuleSettings">{{ t('pages.xray.Routings') }}</a-radio-button>
</a-radio-group>
<a-textarea v-model:value="advancedText" :auto-size="{ minRows: 18, maxRows: 40 }"
spellcheck="false" class="json-editor" />
<JsonEditor v-model:value="advancedText" min-height="420px" max-height="720px" />
</a-tab-pane>
</a-tabs>
</a-col>
@@ -404,8 +465,32 @@ function confirmRestart() {
margin: 0;
}
.json-editor {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
.icons-only :deep(.ant-tabs-nav) {
margin-bottom: 8px;
}
.icons-only :deep(.ant-tabs-nav-wrap) {
width: 100%;
}
.icons-only :deep(.ant-tabs-nav-list) {
display: flex;
width: 100%;
}
.icons-only :deep(.ant-tabs-tab) {
flex: 1 1 0;
justify-content: center;
margin: 0;
padding: 10px 0;
}
.icons-only :deep(.ant-tabs-tab .anticon) {
margin: 0;
font-size: 18px;
}
.icons-only :deep(.ant-tabs-nav-operations) {
display: none;
}
</style>

View File

@@ -75,6 +75,13 @@ export class HttpUtil {
}
}
export function applyDocumentTitle() {
const host = window.location.hostname;
if (!host) return;
const current = document.title.trim();
document.title = current ? `${host} - ${current}` : host;
}
export class PromiseUtil {
static async sleep(timeout) {
await new Promise(resolve => {

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>3x-ui · Xray</title>
<title>Xray Config</title>
</head>
<body>
<div id="message"></div>

27
go.mod
View File

@@ -22,10 +22,11 @@ require (
github.com/xlzd/gotp v0.1.0
github.com/xtls/xray-core v1.260327.0
go.uber.org/atomic v1.11.0
golang.org/x/crypto v0.50.0
golang.org/x/sys v0.43.0
golang.org/x/text v0.36.0
golang.org/x/crypto v0.51.0
golang.org/x/sys v0.44.0
golang.org/x/text v0.37.0
google.golang.org/grpc v1.81.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1
)
@@ -69,13 +70,13 @@ require (
github.com/pires/go-proxyproto v0.12.0 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/quic-go/quic-go v0.59.1 // indirect
github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/sagernet/sing v0.8.9 // indirect
github.com/sagernet/sing v0.8.10 // indirect
github.com/sagernet/sing-shadowsocks v0.2.9 // indirect
github.com/tklauser/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect
github.com/tklauser/go-sysconf v0.4.0 // indirect
github.com/tklauser/numcpus v0.12.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
@@ -86,16 +87,16 @@ require (
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
golang.org/x/arch v0.26.0 // indirect
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
golang.org/x/mod v0.35.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/arch v0.27.0 // indirect
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a // indirect
golang.org/x/mod v0.36.0 // indirect
golang.org/x/net v0.54.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/time v0.15.0 // indirect
golang.org/x/tools v0.44.0 // indirect
golang.org/x/tools v0.45.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260504160031-60b97b32f348 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect
lukechampine.com/blake3 v1.4.1 // indirect

54
go.sum
View File

@@ -148,16 +148,16 @@ github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af h1:er2acxbi3N1nvEq6HXHUAR1nTWEJmQfqiGR8EVT9rfs=
github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sagernet/sing v0.8.9 h1:iX8FyMrWNl/divVgTe7cLT9n36v6bfzfnCYlcM1cLaU=
github.com/sagernet/sing v0.8.9/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak=
github.com/sagernet/sing v0.8.10 h1:V5VZffy8rm4dtBVKIpKa8vibRR2SiJprtu/10DFUalU=
github.com/sagernet/sing v0.8.10/go.mod h1:olXxWQNqRW/l2Q6JI3b2Qmz8iQnIFlOeeH8bx6JhgUA=
github.com/sagernet/sing-shadowsocks v0.2.9 h1:Paep5zCszRKsEn8587O0MnhFWKJwDW1Y4zOYYlIxMkM=
github.com/sagernet/sing-shadowsocks v0.2.9/go.mod h1:TE/Z6401Pi8tgr0nBZcM/xawAI6u3F6TTbz4nH/qw+8=
github.com/shirou/gopsutil/v4 v4.26.4 h1:B4SXVbcwTyrocPHEmWBC4uCYr4Xcu3MK1TXqbprAOWY=
@@ -175,10 +175,10 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU=
github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI=
github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4=
github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
@@ -225,16 +225,16 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
golang.org/x/arch v0.26.0 h1:jZ6dpec5haP/fUv1kLCbuJy6dnRrfX6iVK08lZBFpk4=
golang.org/x/arch v0.26.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/arch v0.27.0 h1:0WNVcR8u9yFz8j5FvdHpgwNp3FS5U4guYdzHwEiGjoU=
golang.org/x/arch v0.27.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a h1:+3jdDGGB8NGb1Zktc737jlt3/A5f6UlwSzmvqUuufxw=
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -242,22 +242,22 @@ golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A=
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260504160031-60b97b32f348 h1:pfIbyB44sWzHiCpRqIen67ZQnVXSfIxWrqUMk1qwODE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260504160031-60b97b32f348/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 h1:seT2EwLWM78plQ7wcDfuWBc/4FAEAXDDiaSol4ku4qo=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw=
google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
@@ -265,6 +265,8 @@ google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -763,6 +763,9 @@ config_after_install() {
prompt_and_setup_ssl "${config_port}" "${config_webBasePath}" "${server_ip}"
# Retrieve the API token for display
local config_apiToken=$(${xui_folder}/x-ui setting -getApiToken true | grep -Eo 'apiToken: .+' | awk '{print $2}')
# Display final credentials and access information
echo ""
echo -e "${green}═══════════════════════════════════════════${plain}"
@@ -773,6 +776,7 @@ config_after_install() {
echo -e "${green}Port: ${config_port}${plain}"
echo -e "${green}WebBasePath: ${config_webBasePath}${plain}"
echo -e "${green}Access URL: ${SSL_SCHEME}://${SSL_HOST}:${config_port}/${config_webBasePath}${plain}"
echo -e "${green}API Token: ${config_apiToken}${plain}"
echo -e "${green}═══════════════════════════════════════════${plain}"
echo -e "${yellow}⚠ IMPORTANT: Save these credentials securely!${plain}"
if [[ "$SSL_SCHEME" == "https" ]]; then

View File

@@ -11,17 +11,25 @@ import (
"github.com/mhsanaei/3x-ui/v3/config"
"github.com/op/go-logging"
"gopkg.in/natefinch/lumberjack.v2"
)
const (
maxLogBufferSize = 10240 // Maximum log entries kept in memory
logFileName = "3xui.log" // Log file name
timeFormat = "2006/01/02 15:04:05" // Log timestamp format
// On-disk rotation limits — single file capped, old segments pruned automatically.
maxLogFileMB = 10 // rotate active log when larger than this
maxLogBackups = 5 // rotated files retained (beyond current segment)
maxLogAgeDays = 7 // remove rotated backups older than this (0 disables time-based pruning)
compressRotated = true
)
var (
logger *logging.Logger
logFile *os.File
logger *logging.Logger
fileRotate *lumberjack.Logger // nil when file backend disabled
// logBuffer maintains recent log entries in memory for web UI retrieval
logBuffer []struct {
@@ -81,8 +89,8 @@ func initDefaultBackend() logging.Backend {
return logging.NewBackendFormatter(backend, newFormatter(includeTime))
}
// initFileBackend creates the file logging backend.
// Creates log directory and truncates log file on startup for fresh logs.
// initFileBackend creates the file logging backend with size/agebounded rotation
// so log volume cannot grow without limit on disk.
func initFileBackend() logging.Backend {
logDir := config.GetLogFolder()
if err := os.MkdirAll(logDir, 0o750); err != nil {
@@ -91,19 +99,16 @@ func initFileBackend() logging.Backend {
}
logPath := filepath.Join(logDir, logFileName)
file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o660)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to open log file %s: %v\n", logPath, err)
return nil
fileRotate = &lumberjack.Logger{
Filename: logPath,
MaxSize: maxLogFileMB,
MaxBackups: maxLogBackups,
MaxAge: maxLogAgeDays,
LocalTime: true,
Compress: compressRotated,
}
// Close previous log file if exists
if logFile != nil {
_ = logFile.Close()
}
logFile = file
backend := logging.NewLogBackend(file, "", 0)
backend := logging.NewLogBackend(fileRotate, "", 0)
return logging.NewBackendFormatter(backend, newFormatter(true))
}
@@ -116,12 +121,12 @@ func newFormatter(withTime bool) logging.Formatter {
return logging.MustStringFormatter(format)
}
// CloseLogger closes the log file and cleans up resources.
// CloseLogger closes the rotating log writer and cleans up resources.
// Should be called during application shutdown.
func CloseLogger() {
if logFile != nil {
_ = logFile.Close()
logFile = nil
if fileRotate != nil {
_ = fileRotate.Close()
fileRotate = nil
}
}

35
main.go
View File

@@ -81,11 +81,7 @@ func runWebServer() {
case syscall.SIGHUP:
logger.Info("Received SIGHUP signal. Restarting servers...")
// --- FIX FOR TELEGRAM BOT CONFLICT (409): Stop bot before restart ---
service.StopBot()
// --
err := server.Stop()
err := server.StopPanelOnly()
if err != nil {
logger.Debug("Error stopping web server:", err)
}
@@ -96,7 +92,7 @@ func runWebServer() {
server = web.NewServer()
global.SetWebServer(server)
err = server.Start()
err = server.StartPanelOnly()
if err != nil {
log.Fatalf("Error restarting web server: %v", err)
return
@@ -395,6 +391,28 @@ func GetListenIP(getListen bool) {
}
}
func GetApiToken(getApiToken bool) {
if !getApiToken {
return
}
apiTokenService := service.ApiTokenService{}
tokens, err := apiTokenService.List()
if err != nil {
fmt.Println("get apiToken failed, error info:", err)
return
}
if len(tokens) > 0 {
fmt.Println("apiToken:", tokens[0].Token)
return
}
created, err := apiTokenService.Create("install")
if err != nil {
fmt.Println("create apiToken failed, error info:", err)
return
}
fmt.Println("apiToken:", created.Token)
}
// migrateDb performs database migration operations for the 3x-ui panel.
func migrateDb() {
inboundService := service.InboundService{}
@@ -437,6 +455,7 @@ func main() {
var reset bool
var show bool
var getCert bool
var getApiToken bool
var resetTwoFactor bool
settingCmd.BoolVar(&reset, "reset", false, "Reset all settings")
settingCmd.BoolVar(&show, "show", false, "Display current settings")
@@ -448,6 +467,7 @@ func main() {
settingCmd.BoolVar(&resetTwoFactor, "resetTwoFactor", false, "Reset two-factor authentication settings")
settingCmd.BoolVar(&getListen, "getListen", false, "Display current panel listenIP IP")
settingCmd.BoolVar(&getCert, "getCert", false, "Display current certificate settings")
settingCmd.BoolVar(&getApiToken, "getApiToken", false, "Display current API token")
settingCmd.StringVar(&webCertFile, "webCert", "", "Set path to public key file for panel")
settingCmd.StringVar(&webKeyFile, "webCertKey", "", "Set path to private key file for panel")
settingCmd.StringVar(&tgbottoken, "tgbottoken", "", "Set token for Telegram bot")
@@ -505,6 +525,9 @@ func main() {
if getCert {
GetCertificate(getCert)
}
if getApiToken {
GetApiToken(getApiToken)
}
if (tgbottoken != "") || (tgbotchatid != "") || (tgbotRuntime != "") {
updateTgbotSetting(tgbottoken, tgbotchatid, tgbotRuntime)
}

View File

@@ -265,6 +265,14 @@ func (s *SubJsonService) streamData(stream string) map[string]any {
streamSettings["wsSettings"] = s.removeAcceptProxy(streamSettings["wsSettings"])
case "httpupgrade":
streamSettings["httpupgradeSettings"] = s.removeAcceptProxy(streamSettings["httpupgradeSettings"])
case "xhttp":
streamSettings["xhttpSettings"] = s.removeAcceptProxy(streamSettings["xhttpSettings"])
if xhttp, ok := streamSettings["xhttpSettings"].(map[string]any); ok {
delete(xhttp, "noSSEHeader")
delete(xhttp, "scMaxBufferedPosts")
delete(xhttp, "scStreamUpServerSecs")
delete(xhttp, "serverMaxHeaderBytes")
}
}
return streamSettings
}
@@ -447,6 +455,9 @@ func (s *SubJsonService) genHy(inbound *model.Inbound, newStream map[string]any,
if udpIdleTimeout, ok := hyStream["udpIdleTimeout"].(float64); ok {
outHyStream["udpIdleTimeout"] = int(udpIdleTimeout)
}
if masquerade, ok := hyStream["masquerade"].(map[string]any); ok {
outHyStream["masquerade"] = masquerade
}
newStream["hysteriaSettings"] = outHyStream
if finalmask, ok := hyStream["finalmask"].(map[string]any); ok {

View File

@@ -28,6 +28,7 @@ type SubService struct {
showInfo bool
remarkModel string
datepicker string
emailInRemark bool
inboundService service.InboundService
settingService service.SettingService
// nodesByID is populated per request from the Node table so
@@ -76,6 +77,12 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C
if err != nil {
s.datepicker = "gregorian"
}
s.emailInRemark, err = s.settingService.GetSubEmailInRemark()
if err != nil {
s.emailInRemark = true
}
seenEmails := make(map[string]struct{})
for _, inbound := range inbounds {
clients, err := s.inboundService.GetClients(inbound)
@@ -886,7 +893,7 @@ func (s *SubService) genRemark(inbound *model.Inbound, email string, extra strin
'e': "",
'o': "",
}
if len(email) > 0 {
if len(email) > 0 && s.emailInRemark {
orders['e'] = email
}
if len(inbound.Remark) > 0 {
@@ -1018,6 +1025,10 @@ func buildXhttpExtra(xhttp map[string]any) map[string]any {
}
}
if mode, ok := xhttp["mode"].(string); ok && len(mode) > 0 {
extra["mode"] = mode
}
stringFields := []string{
"sessionPlacement", "sessionKey",
"seqPlacement", "seqKey",

80
util/netsafe/netsafe.go Normal file
View File

@@ -0,0 +1,80 @@
package netsafe
import (
"context"
"fmt"
"net"
"regexp"
"strings"
"time"
)
func IsBlockedIP(ip net.IP) bool {
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() || ip.IsUnspecified()
}
type allowPrivateCtxKey struct{}
func ContextWithAllowPrivate(ctx context.Context, allow bool) context.Context {
return context.WithValue(ctx, allowPrivateCtxKey{}, allow)
}
func AllowPrivateFromContext(ctx context.Context) bool {
v, _ := ctx.Value(allowPrivateCtxKey{}).(bool)
return v
}
var defaultDialer = &net.Dialer{Timeout: 10 * time.Second}
func SSRFGuardedDialContext(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
allowPrivate := AllowPrivateFromContext(ctx)
var ips []net.IPAddr
if ip := net.ParseIP(host); ip != nil {
ips = []net.IPAddr{{IP: ip}}
} else {
ips, err = net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, err
}
}
var lastErr error
for _, ipAddr := range ips {
if !allowPrivate && IsBlockedIP(ipAddr.IP) {
lastErr = fmt.Errorf("blocked private/internal address %s", ipAddr.IP)
continue
}
conn, derr := defaultDialer.DialContext(ctx, network, net.JoinHostPort(ipAddr.IP.String(), port))
if derr == nil {
return conn, nil
}
lastErr = derr
}
if lastErr == nil {
lastErr = fmt.Errorf("no usable address for %s", host)
}
return nil, lastErr
}
var hostnamePattern = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*$`)
func NormalizeHost(addr string) (string, error) {
addr = strings.TrimSpace(addr)
if addr == "" {
return "", fmt.Errorf("address is required")
}
if strings.HasPrefix(addr, "[") && strings.HasSuffix(addr, "]") {
addr = addr[1 : len(addr)-1]
}
if ip := net.ParseIP(addr); ip != nil {
return ip.String(), nil
}
if len(addr) > 253 || !hostnamePattern.MatchString(addr) {
return "", fmt.Errorf("invalid host %q", addr)
}
return addr, nil
}

View File

@@ -19,6 +19,7 @@ type APIController struct {
nodeController *NodeController
settingService service.SettingService
userService service.UserService
apiTokenService service.ApiTokenService
Tgbot service.Tgbot
}
@@ -29,25 +30,11 @@ func NewAPIController(g *gin.RouterGroup, customGeo *service.CustomGeoService) *
return a
}
// checkAPIAuth is a middleware that returns 404 for unauthenticated API requests
// to hide the existence of API endpoints from unauthorized users.
//
// Two auth paths are accepted:
// 1. Authorization: Bearer <apiToken> — used by remote central panels
// polling this instance as a node. Matches via constant-time compare.
// Sets c.Set("api_authed", true) so CSRFMiddleware can short-circuit.
// 2. Existing session cookie — used by browsers logged into the panel UI.
//
// Anything else falls through to a 404 so the API endpoints remain hidden.
func (a *APIController) checkAPIAuth(c *gin.Context) {
auth := c.GetHeader("Authorization")
if strings.HasPrefix(auth, "Bearer ") {
tok := strings.TrimPrefix(auth, "Bearer ")
if a.settingService.MatchApiToken(tok) {
// Handlers like InboundController.addInbound assume a logged-in
// user (inbound.UserId = user.Id). Bearer callers have no
// session, so attach the first user as a fallback. Single-user
// panels are the norm here.
if a.apiTokenService.Match(tok) {
if u, err := a.userService.GetFirstUser(); err == nil {
session.SetAPIAuthUser(c, u)
}
@@ -57,7 +44,11 @@ func (a *APIController) checkAPIAuth(c *gin.Context) {
}
}
if !session.IsLogin(c) {
c.AbortWithStatus(http.StatusNotFound)
if c.GetHeader("X-Requested-With") == "XMLHttpRequest" {
c.AbortWithStatus(http.StatusUnauthorized)
} else {
c.AbortWithStatus(http.StatusNotFound)
}
return
}
c.Next()
@@ -85,7 +76,7 @@ func (a *APIController) initRouter(g *gin.RouterGroup, customGeo *service.Custom
NewCustomGeoController(api.Group("/custom-geo"), customGeo)
// Extra routes
api.GET("/backuptotgbot", a.BackuptoTgbot)
api.POST("/backuptotgbot", a.BackuptoTgbot)
}
// BackuptoTgbot sends a backup of the panel data to Telegram bot admins.

View File

@@ -0,0 +1,160 @@
package controller
import (
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
type routeDef struct {
Method string
Path string
}
// routePattern matches route registrations like g.GET("/path", handler) or api.GET("/path", handler)
var routePattern = regexp.MustCompile(`\b(g|api)\.(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\("([^"]+)"`)
// docRoutePattern matches { method: 'X', path: 'Y' ... } entries in endpoints.js.
var docRoutePattern = regexp.MustCompile(`method:\s*'([A-Z]+)'\s*,\s*path:\s*'([^']+)'`)
// buildDocSet parses frontend/src/pages/api-docs/endpoints.js and returns the
// set of documented "METHOD PATH" keys. WS pseudo-routes and subscription
// placeholders (paths starting with /{...}) are skipped because they aren't
// registered on the main Gin engine.
func buildDocSet(t *testing.T) map[string]bool {
t.Helper()
controllerDir, err := filepath.Abs(".")
if err != nil {
t.Fatalf("failed to get current dir: %v", err)
}
endpointsPath := filepath.Join(controllerDir, "..", "..", "frontend", "src", "pages", "api-docs", "endpoints.js")
data, err := os.ReadFile(endpointsPath)
if err != nil {
t.Fatalf("failed to read endpoints.js at %s: %v", endpointsPath, err)
}
docSet := make(map[string]bool)
for _, m := range docRoutePattern.FindAllStringSubmatch(string(data), -1) {
method, path := m[1], m[2]
if method == "WS" {
continue
}
if !strings.HasPrefix(path, "/") || strings.HasPrefix(path, "/{") {
continue
}
docSet[method+" "+path] = true
}
if len(docSet) == 0 {
t.Fatalf("no documented routes parsed from %s — regex or file format may have changed", endpointsPath)
}
return docSet
}
func TestAPIRoutesDocumented(t *testing.T) {
docSet := buildDocSet(t)
controllerDir, err := filepath.Abs(".")
if err != nil {
t.Fatalf("failed to get current dir: %v", err)
}
var allRoutes []routeDef
entries, err := os.ReadDir(controllerDir)
if err != nil {
t.Fatalf("failed to read controller dir: %v", err)
}
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") {
continue
}
data, err := os.ReadFile(filepath.Join(controllerDir, entry.Name()))
if err != nil {
t.Fatalf("failed to read %s: %v", entry.Name(), err)
}
src := string(data)
// Determine the base path for this file based on its initRouter patterns
basePath := ""
switch entry.Name() {
case "index.go":
basePath = ""
case "xui.go":
basePath = "/panel"
case "api.go":
basePath = "/panel/api"
case "inbound.go":
basePath = "/panel/api/inbounds"
case "server.go":
basePath = "/panel/api/server"
case "node.go":
basePath = "/panel/api/nodes"
case "setting.go":
basePath = "/panel/setting"
case "xray_setting.go":
basePath = "/panel/xray"
case "custom_geo.go":
basePath = "/panel/api/custom-geo"
case "websocket.go":
basePath = ""
}
// Find all route registrations
matches := routePattern.FindAllStringSubmatch(src, -1)
for _, m := range matches {
method := m[2]
path := strings.TrimSpace(m[3])
if basePath == "" {
allRoutes = append(allRoutes, routeDef{Method: method, Path: path})
} else {
fullPath := basePath + path
allRoutes = append(allRoutes, routeDef{Method: method, Path: fullPath})
}
}
}
// The WebSocket route /ws is registered in web/web.go (not a controller file)
allRoutes = append(allRoutes, routeDef{Method: "GET", Path: "/ws"})
missingFromDocs := 0
foundInDoc := 0
sourceSet := make(map[string]bool)
for _, r := range allRoutes {
key := r.Method + " " + r.Path
// Skip SPA page routes (these are UI pages, not API endpoints)
spaPages := map[string]bool{
"/": true, "/panel/": true, "/panel/inbounds": true,
"/panel/nodes": true, "/panel/settings": true,
"/panel/xray": true, "/panel/api-docs": true,
}
if spaPages[r.Path] {
continue
}
// Skip /panel/csrf-token (documented under auth as /csrf-token)
if r.Path == "/panel/csrf-token" {
continue
}
// Skip Chrome DevTools route
if strings.Contains(r.Path, ".well-known") {
continue
}
sourceSet[key] = true
if docSet[key] {
foundInDoc++
} else {
missingFromDocs++
t.Errorf("Route not documented in endpoints.js: %s %s", r.Method, r.Path)
}
}
t.Logf("Routes found in source: %d, documented: %d, matching: %d, missing: %d",
len(sourceSet), len(docSet), foundInDoc, missingFromDocs)
if missingFromDocs > 0 {
t.Errorf("Found %d undocumented route(s). Update endpoints.js to match.", missingFromDocs)
}
}

View File

@@ -57,7 +57,11 @@ func serveDistPage(c *gin.Context, name string) {
}
csrfMeta := []byte(`<meta name="csrf-token" content="` + htmlpkg.EscapeString(csrfToken) + `">`)
script := `<script>window.X_UI_BASE_PATH="` + escapedBase + `"`
nonceAttr := ""
if nonce := c.GetString("csp_nonce"); nonce != "" {
nonceAttr = ` nonce="` + htmlpkg.EscapeString(nonce) + `"`
}
script := `<script` + nonceAttr + `>window.X_UI_BASE_PATH="` + escapedBase + `"`
if name != "login.html" {
escapedVer := jsEscape.Replace(config.GetVersion())
script += `;window.X_UI_CUR_VER="` + escapedVer + `"`

View File

@@ -77,6 +77,7 @@ func (a *InboundController) initRouter(g *gin.RouterGroup) {
g.POST("/:id/copyClients", a.copyInboundClients)
g.POST("/:id/delClient/:clientId", a.delInboundClient)
g.POST("/updateClient/:clientId", a.updateInboundClient)
g.POST("/:id/resetTraffic", a.resetInboundTraffic)
g.POST("/:id/resetClientTraffic/:email", a.resetClientTraffic)
g.POST("/resetAllTraffics", a.resetAllTraffics)
g.POST("/resetAllClientTraffics/:id", a.resetAllClientTraffics)
@@ -441,6 +442,24 @@ func (a *InboundController) resetClientTraffic(c *gin.Context) {
}
}
// resetInboundTraffic resets traffic counters for a specific inbound.
func (a *InboundController) resetInboundTraffic(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
return
}
err = a.inboundService.ResetInboundTraffic(id)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
} else {
a.xrayService.SetToNeedRestart()
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetInboundTrafficSuccess"), nil)
}
// resetAllTraffics resets all traffic counters across all inbounds.
func (a *InboundController) resetAllTraffics(c *gin.Context) {
err := a.inboundService.ResetAllTraffics()
@@ -582,17 +601,19 @@ func (a *InboundController) delInboundClientByEmail(c *gin.Context) {
// controller layer means the service interface stays HTTP-agnostic — service
// methods receive a plain host string instead of a *gin.Context.
func resolveHost(c *gin.Context) string {
if h := strings.TrimSpace(c.GetHeader("X-Forwarded-Host")); h != "" {
if i := strings.Index(h, ","); i >= 0 {
h = strings.TrimSpace(h[:i])
if isTrustedForwardedRequest(c) {
if h := strings.TrimSpace(c.GetHeader("X-Forwarded-Host")); h != "" {
if i := strings.Index(h, ","); i >= 0 {
h = strings.TrimSpace(h[:i])
}
if hp, _, err := net.SplitHostPort(h); err == nil {
return hp
}
return h
}
if hp, _, err := net.SplitHostPort(h); err == nil {
return hp
if h := c.GetHeader("X-Real-IP"); h != "" {
return h
}
return h
}
if h := c.GetHeader("X-Real-IP"); h != "" {
return h
}
if h, _, err := net.SplitHostPort(c.Request.Host); err == nil {
return h

View File

@@ -39,15 +39,10 @@ func NewIndexController(g *gin.RouterGroup) *IndexController {
// initRouter sets up the routes for index, login, logout, and two-factor authentication.
func (a *IndexController) initRouter(g *gin.RouterGroup) {
g.GET("/", a.index)
g.GET("/logout", a.logout)
// Public CSRF endpoint — the SPA login page (served by Vite in
// dev or by serveDistPage in prod) needs a token to POST /login,
// but the panel-side /panel/csrf-token sits behind checkLogin.
// EnsureCSRFToken creates a session token even for anonymous
// callers, so any pre-login flow can bootstrap from here.
g.GET("/csrf-token", a.csrfToken)
g.POST("/login", middleware.CSRFMiddleware(), a.login)
g.POST("/logout", middleware.CSRFMiddleware(), a.logout)
g.POST("/getTwoFactorEnable", middleware.CSRFMiddleware(), a.getTwoFactorEnable)
}
@@ -140,7 +135,6 @@ func loginFailureReason(err error) string {
return "invalid credentials"
}
// logout handles user logout by clearing the session and redirecting to the login page.
func (a *IndexController) logout(c *gin.Context) {
user := session.GetLoginUser(c)
if user != nil {
@@ -150,7 +144,7 @@ func (a *IndexController) logout(c *gin.Context) {
logger.Warning("Unable to clear session on logout:", err)
}
c.Header("Cache-Control", "no-store")
c.Redirect(http.StatusTemporaryRedirect, c.GetString("base_path"))
c.JSON(http.StatusOK, gin.H{"success": true})
}
// csrfToken returns the session CSRF token. Public — the login page

View File

@@ -23,9 +23,10 @@ var filenameRegex = regexp.MustCompile(`^[a-zA-Z0-9_\-.]+$`)
type ServerController struct {
BaseController
serverService service.ServerService
settingService service.SettingService
panelService service.PanelService
serverService service.ServerService
settingService service.SettingService
panelService service.PanelService
xrayMetricsService service.XrayMetricsService
lastStatus *service.Status
@@ -47,6 +48,10 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
g.GET("/status", a.status)
g.GET("/cpuHistory/:bucket", a.getCpuHistoryBucket)
g.GET("/history/:metric/:bucket", a.getMetricHistoryBucket)
g.GET("/xrayMetricsState", a.getXrayMetricsState)
g.GET("/xrayMetricsHistory/:metric/:bucket", a.getXrayMetricsHistoryBucket)
g.GET("/xrayObservatory", a.getXrayObservatory)
g.GET("/xrayObservatoryHistory/:tag/:bucket", a.getXrayObservatoryHistoryBucket)
g.GET("/getXrayVersion", a.getXrayVersion)
g.GET("/getPanelUpdateInfo", a.getPanelUpdateInfo)
g.GET("/getConfigJson", a.getConfigJson)
@@ -75,7 +80,9 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
func (a *ServerController) refreshStatus() {
a.lastStatus = a.serverService.GetStatus(a.lastStatus)
if a.lastStatus != nil {
a.serverService.AppendStatusSample(time.Now(), a.lastStatus)
now := time.Now()
a.serverService.AppendStatusSample(now, a.lastStatus)
a.xrayMetricsService.Sample(now)
// Broadcast status update via WebSocket
websocket.BroadcastStatus(a.lastStatus)
}
@@ -143,6 +150,42 @@ func (a *ServerController) getMetricHistoryBucket(c *gin.Context) {
jsonObj(c, a.serverService.AggregateSystemMetric(metric, bucket, 60), nil)
}
func (a *ServerController) getXrayMetricsState(c *gin.Context) {
jsonObj(c, a.xrayMetricsService.State(), nil)
}
func (a *ServerController) getXrayMetricsHistoryBucket(c *gin.Context) {
metric := c.Param("metric")
if !slices.Contains(service.XrayMetricKeys, metric) {
jsonMsg(c, "invalid metric", fmt.Errorf("unknown metric"))
return
}
bucket, err := strconv.Atoi(c.Param("bucket"))
if err != nil || bucket <= 0 || !allowedHistoryBuckets[bucket] {
jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket"))
return
}
jsonObj(c, a.xrayMetricsService.AggregateMetric(metric, bucket, 60), nil)
}
func (a *ServerController) getXrayObservatory(c *gin.Context) {
jsonObj(c, a.xrayMetricsService.ObservatorySnapshot(), nil)
}
func (a *ServerController) getXrayObservatoryHistoryBucket(c *gin.Context) {
tag := c.Param("tag")
if !a.xrayMetricsService.HasObservatoryTag(tag) {
jsonMsg(c, "invalid tag", fmt.Errorf("unknown observatory tag"))
return
}
bucket, err := strconv.Atoi(c.Param("bucket"))
if err != nil || bucket <= 0 || !allowedHistoryBuckets[bucket] {
jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket"))
return
}
jsonObj(c, a.xrayMetricsService.AggregateObservatory(tag, bucket, 60), nil)
}
func (a *ServerController) getXrayVersion(c *gin.Context) {
const cacheTTLSeconds = 15 * 60

View File

@@ -2,6 +2,7 @@ package controller
import (
"errors"
"strconv"
"time"
"github.com/mhsanaei/3x-ui/v3/util/crypto"
@@ -22,9 +23,10 @@ type updateUserForm struct {
// SettingController handles settings and user management operations.
type SettingController struct {
settingService service.SettingService
userService service.UserService
panelService service.PanelService
settingService service.SettingService
userService service.UserService
panelService service.PanelService
apiTokenService service.ApiTokenService
}
// NewSettingController creates a new SettingController and initializes its routes.
@@ -44,8 +46,10 @@ func (a *SettingController) initRouter(g *gin.RouterGroup) {
g.POST("/updateUser", a.updateUser)
g.POST("/restartPanel", a.restartPanel)
g.GET("/getDefaultJsonConfig", a.getDefaultXrayConfig)
g.GET("/getApiToken", a.getApiToken)
g.POST("/regenerateApiToken", a.regenerateApiToken)
g.GET("/apiTokens", a.listApiTokens)
g.POST("/apiTokens/create", a.createApiToken)
g.POST("/apiTokens/delete/:id", a.deleteApiToken)
g.POST("/apiTokens/setEnabled/:id", a.setApiTokenEnabled)
}
// getAllSetting retrieves all current settings.
@@ -76,7 +80,13 @@ func (a *SettingController) updateSetting(c *gin.Context) {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
return
}
oldTwoFactor, twoFactorErr := a.settingService.GetTwoFactorEnable()
err = a.settingService.UpdateAllSetting(allSetting)
if err == nil && twoFactorErr == nil && !oldTwoFactor && allSetting.TwoFactorEnable {
if bumpErr := a.userService.BumpLoginEpoch(); bumpErr != nil {
err = bumpErr
}
}
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
}
@@ -124,26 +134,56 @@ func (a *SettingController) getDefaultXrayConfig(c *gin.Context) {
jsonObj(c, defaultJsonConfig, nil)
}
// getApiToken returns the panel's API token used by remote central
// panels to authenticate as Bearer tokens. The token is auto-generated
// on first read so existing installs upgrade transparently.
func (a *SettingController) getApiToken(c *gin.Context) {
tok, err := a.settingService.GetApiToken()
type apiTokenCreateForm struct {
Name string `json:"name" form:"name"`
}
type apiTokenEnabledForm struct {
Enabled bool `json:"enabled" form:"enabled"`
}
func (a *SettingController) listApiTokens(c *gin.Context) {
rows, err := a.apiTokenService.List()
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getSettings"), err)
return
}
jsonObj(c, tok, nil)
jsonObj(c, rows, nil)
}
// regenerateApiToken rotates the API token. Any central panel that had
// the old value cached will start failing heartbeats until it is updated
// with the new token — that's intentional, it's the whole point of rotation.
func (a *SettingController) regenerateApiToken(c *gin.Context) {
tok, err := a.settingService.RegenerateApiToken()
func (a *SettingController) createApiToken(c *gin.Context) {
form := &apiTokenCreateForm{}
if err := c.ShouldBind(form); err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
return
}
row, err := a.apiTokenService.Create(form.Name)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
return
}
jsonObj(c, tok, nil)
jsonObj(c, row, nil)
}
func (a *SettingController) deleteApiToken(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), a.apiTokenService.Delete(id))
}
func (a *SettingController) setApiTokenEnabled(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
return
}
form := &apiTokenEnabledForm{}
if bindErr := c.ShouldBind(form); bindErr != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), bindErr)
return
}
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), a.apiTokenService.SetEnabled(id, form.Enabled))
}

View File

@@ -9,29 +9,75 @@ import (
"github.com/mhsanaei/3x-ui/v3/logger"
"github.com/mhsanaei/3x-ui/v3/web/entity"
"github.com/mhsanaei/3x-ui/v3/web/service"
"github.com/gin-gonic/gin"
)
// getRemoteIp extracts the real IP address from the request headers or remote address.
func getRemoteIp(c *gin.Context) string {
if ip, ok := extractTrustedIP(c.GetHeader("X-Real-IP")); ok {
return ip
remoteIP, ok := extractTrustedIP(c.Request.RemoteAddr)
if !ok {
return "unknown"
}
if xff := c.GetHeader("X-Forwarded-For"); xff != "" {
for _, part := range strings.Split(xff, ",") {
if ip, ok := extractTrustedIP(part); ok {
return ip
if isTrustedProxy(remoteIP) {
if ip, ok := extractTrustedIP(c.GetHeader("X-Real-IP")); ok {
return ip
}
if xff := c.GetHeader("X-Forwarded-For"); xff != "" {
for _, part := range strings.Split(xff, ",") {
if ip, ok := extractTrustedIP(part); ok {
return ip
}
}
}
}
if ip, ok := extractTrustedIP(c.Request.RemoteAddr); ok {
return ip
return remoteIP
}
func isTrustedForwardedRequest(c *gin.Context) bool {
remoteIP, ok := extractTrustedIP(c.Request.RemoteAddr)
return ok && isTrustedProxy(remoteIP)
}
func isTrustedProxy(ip string) bool {
addr, err := netip.ParseAddr(ip)
if err != nil {
return false
}
return "unknown"
trusted := trustedProxyCIDRs()
for _, value := range strings.Split(trusted, ",") {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if prefix, err := netip.ParsePrefix(value); err == nil {
if prefix.Contains(addr) {
return true
}
continue
}
if proxyIP, err := netip.ParseAddr(value); err == nil && proxyIP.Unmap() == addr.Unmap() {
return true
}
}
return false
}
func trustedProxyCIDRs() (trusted string) {
trusted = "127.0.0.1/32,::1/128"
defer func() {
_ = recover()
}()
settingService := service.SettingService{}
if value, err := settingService.GetTrustedProxyCIDRs(); err == nil && strings.TrimSpace(value) != "" {
trusted = value
}
return trusted
}
func extractTrustedIP(value string) (string, bool) {

View File

@@ -0,0 +1,34 @@
package controller
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func TestGetRemoteIpIgnoresForwardedHeadersFromUntrustedRemote(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
c.Request.RemoteAddr = "203.0.113.10:12345"
c.Request.Header.Set("X-Real-IP", "198.51.100.9")
c.Request.Header.Set("X-Forwarded-For", "198.51.100.8")
if got := getRemoteIp(c); got != "203.0.113.10" {
t.Fatalf("remote IP = %q, want request remote address", got)
}
}
func TestGetRemoteIpHonorsForwardedHeadersFromTrustedLoopbackProxy(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
c.Request.RemoteAddr = "127.0.0.1:12345"
c.Request.Header.Set("X-Forwarded-For", "198.51.100.8, 127.0.0.1")
if got := getRemoteIp(c); got != "198.51.100.8" {
t.Fatalf("remote IP = %q, want forwarded client IP", got)
}
}

View File

@@ -213,6 +213,11 @@ func (a *XraySettingController) testOutbound(c *gin.Context) {
// Load the test URL from server settings to prevent SSRF via user-controlled URLs
testURL, _ := a.SettingService.GetXrayOutboundTestUrl()
testURL, err := service.SanitizePublicHTTPURL(testURL, false)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
result, err := a.OutboundService.TestOutbound(outboundJSON, testURL, allOutboundsJSON, mode)
if err != nil {

View File

@@ -21,13 +21,14 @@ type Msg struct {
// AllSetting contains all configuration settings for the 3x-ui panel including web server, Telegram bot, and subscription settings.
type AllSetting struct {
// Web server settings
WebListen string `json:"webListen" form:"webListen"` // Web server listen IP address
WebDomain string `json:"webDomain" form:"webDomain"` // Web server domain for domain validation
WebPort int `json:"webPort" form:"webPort"` // Web server port number
WebCertFile string `json:"webCertFile" form:"webCertFile"` // Path to SSL certificate file for web server
WebKeyFile string `json:"webKeyFile" form:"webKeyFile"` // Path to SSL private key file for web server
WebBasePath string `json:"webBasePath" form:"webBasePath"` // Base path for web panel URLs
SessionMaxAge int `json:"sessionMaxAge" form:"sessionMaxAge"` // Session maximum age in minutes
WebListen string `json:"webListen" form:"webListen"` // Web server listen IP address
WebDomain string `json:"webDomain" form:"webDomain"` // Web server domain for domain validation
WebPort int `json:"webPort" form:"webPort"` // Web server port number
WebCertFile string `json:"webCertFile" form:"webCertFile"` // Path to SSL certificate file for web server
WebKeyFile string `json:"webKeyFile" form:"webKeyFile"` // Path to SSL private key file for web server
WebBasePath string `json:"webBasePath" form:"webBasePath"` // Base path for web panel URLs
SessionMaxAge int `json:"sessionMaxAge" form:"sessionMaxAge"` // Session maximum age in minutes
TrustedProxyCIDRs string `json:"trustedProxyCIDRs" form:"trustedProxyCIDRs"` // Trusted reverse proxy IPs/CIDRs for forwarded headers
// UI settings
PageSize int `json:"pageSize" form:"pageSize"` // Number of items per page in lists
@@ -74,6 +75,7 @@ type AllSetting struct {
RestartXrayOnClientDisable bool `json:"restartXrayOnClientDisable" form:"restartXrayOnClientDisable"` // Restart Xray when clients are auto-disabled by expiry/traffic limit
SubEncrypt bool `json:"subEncrypt" form:"subEncrypt"` // Encrypt subscription responses
SubShowInfo bool `json:"subShowInfo" form:"subShowInfo"` // Show client information in subscriptions
SubEmailInRemark bool `json:"subEmailInRemark" form:"subEmailInRemark"` // Include email in subscription remark/name
SubURI string `json:"subURI" form:"subURI"` // Subscription server URI
SubJsonPath string `json:"subJsonPath" form:"subJsonPath"` // Path for JSON subscription endpoint
SubJsonURI string `json:"subJsonURI" form:"subJsonURI"` // JSON subscription server URI
@@ -110,6 +112,20 @@ type AllSetting struct {
// JSON subscription routing rules
}
// AllSettingView is the browser-safe settings read model. Secret values
// are redacted from the embedded write model and represented by presence
// flags so the UI can show configured/not configured state.
type AllSettingView struct {
AllSetting
HasTgBotToken bool `json:"hasTgBotToken"`
HasTwoFactorToken bool `json:"hasTwoFactorToken"`
HasLdapPassword bool `json:"hasLdapPassword"`
HasApiToken bool `json:"hasApiToken"`
HasWarpSecret bool `json:"hasWarpSecret"`
HasNordSecret bool `json:"hasNordSecret"`
}
// CheckValid validates all settings in the AllSetting struct, checking IP addresses, ports, SSL certificates, and other configuration values.
func (s *AllSetting) CheckValid() error {
if s.WebListen != "" {
@@ -179,6 +195,19 @@ func (s *AllSetting) CheckValid() error {
s.SubClashPath += "/"
}
for _, cidr := range strings.Split(s.TrustedProxyCIDRs, ",") {
cidr = strings.TrimSpace(cidr)
if cidr == "" {
continue
}
if ip := net.ParseIP(cidr); ip != nil {
continue
}
if _, _, err := net.ParseCIDR(cidr); err != nil {
return common.NewError("trusted proxy CIDR is not valid:", cidr)
}
}
_, err := time.LoadLocation(s.TimeLocation)
if err != nil {
return common.NewError("time location not exist:", s.TimeLocation)

View File

@@ -181,7 +181,7 @@ func (j *CheckClientIpJob) processLogFile() bool {
var timestamp int64
timestampMatches := timestampRegex.FindStringSubmatch(line)
if len(timestampMatches) >= 2 {
t, err := time.Parse("2006/01/02 15:04:05", timestampMatches[1])
t, err := time.ParseInLocation("2006/01/02 15:04:05", timestampMatches[1], time.Local)
if err == nil {
timestamp = t.Unix()
} else {

View File

@@ -43,36 +43,6 @@ func (a *atomicBool) takeAndReset() bool {
return v
}
type emailSet struct {
mu sync.Mutex
m map[string]struct{}
}
func newEmailSet() *emailSet { return &emailSet{m: make(map[string]struct{})} }
func (s *emailSet) addAll(emails []string) {
if len(emails) == 0 {
return
}
s.mu.Lock()
for _, e := range emails {
if e != "" {
s.m[e] = struct{}{}
}
}
s.mu.Unlock()
}
func (s *emailSet) slice() []string {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]string, 0, len(s.m))
for e := range s.m {
out = append(out, e)
}
return out
}
func NewNodeTrafficSyncJob() *NodeTrafficSyncJob {
return &NodeTrafficSyncJob{}
}
@@ -97,7 +67,6 @@ func (j *NodeTrafficSyncJob) Run() {
return
}
touched := newEmailSet()
sem := make(chan struct{}, nodeTrafficSyncConcurrency)
var wg sync.WaitGroup
for _, n := range nodes {
@@ -109,7 +78,7 @@ func (j *NodeTrafficSyncJob) Run() {
go func(n *model.Node) {
defer wg.Done()
defer func() { <-sem }()
j.syncOne(mgr, n, touched)
j.syncOne(mgr, n)
}(n)
}
wg.Wait()
@@ -135,12 +104,10 @@ func (j *NodeTrafficSyncJob) Run() {
})
clientStats := map[string]any{}
if emails := touched.slice(); len(emails) > 0 {
if stats, err := j.inboundService.GetActiveClientTraffics(emails); err != nil {
logger.Warning("node traffic sync: get client traffics for websocket failed:", err)
} else if len(stats) > 0 {
clientStats["clients"] = stats
}
if stats, err := j.inboundService.GetAllClientTraffics(); err != nil {
logger.Warning("node traffic sync: get all client traffics for websocket failed:", err)
} else if len(stats) > 0 {
clientStats["clients"] = stats
}
if summary, err := j.inboundService.GetInboundsTrafficSummary(); err != nil {
logger.Warning("node traffic sync: get inbounds summary for websocket failed:", err)
@@ -156,7 +123,7 @@ func (j *NodeTrafficSyncJob) Run() {
}
}
func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, touched *emailSet) {
func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node) {
ctx, cancel := context.WithTimeout(context.Background(), nodeTrafficSyncRequestTimeout)
defer cancel()
@@ -179,16 +146,4 @@ func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, touche
if changed {
j.structural.set()
}
for _, ib := range snap.Inbounds {
if ib == nil {
continue
}
emails := make([]string, 0, len(ib.ClientStats))
for _, cs := range ib.ClientStats {
if cs.Email != "" {
emails = append(emails, cs.Email)
}
}
touched.addAll(emails)
}
}

View File

@@ -95,18 +95,16 @@ func (j *XrayTrafficJob) Run() {
"lastOnlineMap": lastOnlineMap,
})
// Compact delta payload: per-client absolute counters for clients active
// this cycle, plus inbound-level absolute totals. Frontend applies both
// in-place — typical payload ~1050KB even for 10k+ client deployments.
// Replaces the old full-inbound-list broadcast that hit WS size limits
// (510MB) and forced the frontend into a REST refetch.
// Full snapshot every cycle: absolute per-client counters and inbound
// totals. Frontend overwrites both in place. The previous delta path
// (activeEmails -> GetActiveClientTraffics) silently omitted the
// clients array whenever nobody moved bytes in the cycle, leaving the
// client rows in the UI stuck at stale traffic/remained/all-time.
clientStatsPayload := map[string]any{}
if activeEmails := activeEmails(clientTraffics); len(activeEmails) > 0 {
if stats, err := j.inboundService.GetActiveClientTraffics(activeEmails); err != nil {
logger.Warning("get active client traffics for websocket failed:", err)
} else if len(stats) > 0 {
clientStatsPayload["clients"] = stats
}
if stats, err := j.inboundService.GetAllClientTraffics(); err != nil {
logger.Warning("get all client traffics for websocket failed:", err)
} else if len(stats) > 0 {
clientStatsPayload["clients"] = stats
}
if inboundSummary, err := j.inboundService.GetInboundsTrafficSummary(); err != nil {
logger.Warning("get inbounds traffic summary for websocket failed:", err)
@@ -126,32 +124,17 @@ func (j *XrayTrafficJob) Run() {
}
}
// activeEmails returns the set of client emails that had non-zero traffic in
// the current collection window. Idle clients are skipped — no need to push
// their (unchanged) counters to the frontend.
func activeEmails(clientTraffics []*xray.ClientTraffic) []string {
if len(clientTraffics) == 0 {
return nil
}
emails := make([]string, 0, len(clientTraffics))
for _, ct := range clientTraffics {
if ct == nil || ct.Email == "" {
continue
}
if ct.Up == 0 && ct.Down == 0 {
continue
}
emails = append(emails, ct.Email)
}
return emails
}
func (j *XrayTrafficJob) informTrafficToExternalAPI(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) {
informURL, err := j.settingService.GetExternalTrafficInformURI()
if err != nil {
logger.Warning("get ExternalTrafficInformURI failed:", err)
return
}
informURL, err = service.SanitizePublicHTTPURL(informURL, false)
if err != nil {
logger.Warning("ExternalTrafficInformURI blocked:", err)
return
}
requestBody, err := json.Marshal(map[string]any{"clientTraffics": clientTraffics, "inboundTraffics": inboundTraffics})
if err != nil {
logger.Warning("parse client/inbound traffic failed:", err)

View File

@@ -1,6 +1,8 @@
package middleware
import (
"crypto/rand"
"encoding/base64"
"net/http"
"github.com/mhsanaei/3x-ui/v3/web/session"
@@ -11,10 +13,12 @@ import (
// SecurityHeadersMiddleware adds browser hardening headers to panel responses.
func SecurityHeadersMiddleware(directHTTPS bool) gin.HandlerFunc {
return func(c *gin.Context) {
nonce := newCSPNonce()
c.Set("csp_nonce", nonce)
c.Header("X-Content-Type-Options", "nosniff")
c.Header("X-Frame-Options", "DENY")
c.Header("Referrer-Policy", "no-referrer")
c.Header("Content-Security-Policy", "frame-ancestors 'none'; base-uri 'self'; form-action 'self'")
c.Header("Content-Security-Policy", "default-src 'self'; script-src 'self' 'nonce-"+nonce+"'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ws: wss:; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'")
if directHTTPS {
c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
}
@@ -22,6 +26,14 @@ func SecurityHeadersMiddleware(directHTTPS bool) gin.HandlerFunc {
}
}
func newCSPNonce() string {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return ""
}
return base64.RawStdEncoding.EncodeToString(b[:])
}
// CSRFMiddleware rejects unsafe requests that do not include the session CSRF token.
// Bearer-token-authenticated callers (api_authed flag set by APIController.checkAPIAuth)
// short-circuit the CSRF check — they are not browser sessions, so the

View File

@@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strconv"
@@ -16,6 +17,7 @@ import (
"github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/logger"
"github.com/mhsanaei/3x-ui/v3/util/netsafe"
)
const remoteHTTPTimeout = 10 * time.Second
@@ -25,6 +27,7 @@ var remoteHTTPClient = &http.Client{
MaxIdleConns: 64,
MaxIdleConnsPerHost: 4,
IdleConnTimeout: 60 * time.Second,
DialContext: netsafe.SSRFGuardedDialContext,
},
}
@@ -50,7 +53,18 @@ func NewRemote(n *model.Node) *Remote {
func (r *Remote) Name() string { return "node:" + r.node.Name }
func (r *Remote) baseURL() string {
func (r *Remote) baseURL() (string, error) {
addr, err := netsafe.NormalizeHost(r.node.Address)
if err != nil {
return "", err
}
scheme := r.node.Scheme
if scheme != "http" && scheme != "https" {
scheme = "https"
}
if r.node.Port <= 0 || r.node.Port > 65535 {
return "", fmt.Errorf("invalid node port %d", r.node.Port)
}
bp := r.node.BasePath
if bp == "" {
bp = "/"
@@ -58,7 +72,12 @@ func (r *Remote) baseURL() string {
if !strings.HasSuffix(bp, "/") {
bp += "/"
}
return fmt.Sprintf("%s://%s:%d%s", r.node.Scheme, r.node.Address, r.node.Port, bp)
u := &url.URL{
Scheme: scheme,
Host: net.JoinHostPort(addr, strconv.Itoa(r.node.Port)),
Path: bp,
}
return u.String(), nil
}
func (r *Remote) do(ctx context.Context, method, path string, body any) (*envelope, error) {
@@ -66,7 +85,11 @@ func (r *Remote) do(ctx context.Context, method, path string, body any) (*envelo
return nil, errors.New("node has no API token configured")
}
target := r.baseURL() + strings.TrimPrefix(path, "/")
base, err := r.baseURL()
if err != nil {
return nil, err
}
target := base + strings.TrimPrefix(path, "/")
var (
reqBody io.Reader
@@ -78,15 +101,15 @@ func (r *Remote) do(ctx context.Context, method, path string, body any) (*envelo
reqBody = strings.NewReader(b.Encode())
contentType = "application/x-www-form-urlencoded"
default:
buf, err := json.Marshal(b)
if err != nil {
return nil, fmt.Errorf("marshal body: %w", err)
buf, jerr := json.Marshal(b)
if jerr != nil {
return nil, fmt.Errorf("marshal body: %w", jerr)
}
reqBody = bytes.NewReader(buf)
contentType = "application/json"
}
cctx, cancel := context.WithTimeout(ctx, remoteHTTPTimeout)
cctx, cancel := context.WithTimeout(netsafe.ContextWithAllowPrivate(ctx, r.node.AllowPrivateAddress), remoteHTTPTimeout)
defer cancel()
req, err := http.NewRequestWithContext(cctx, method, target, reqBody)
if err != nil {
@@ -311,7 +334,7 @@ func wireInbound(ib *model.Inbound) url.Values {
v.Set("port", strconv.Itoa(ib.Port))
v.Set("protocol", string(ib.Protocol))
v.Set("settings", ib.Settings)
v.Set("streamSettings", ib.StreamSettings)
v.Set("streamSettings", sanitizeStreamSettingsForRemote(ib.StreamSettings))
v.Set("tag", ib.Tag)
v.Set("sniffing", ib.Sniffing)
if ib.TrafficReset != "" {
@@ -319,3 +342,44 @@ func wireInbound(ib *model.Inbound) url.Values {
}
return v
}
// sanitizeStreamSettingsForRemote strips file-based TLS certificate paths
// from the StreamSettings before sending to a remote node. File paths
// (certificateFile / keyFile) are local to the main panel's filesystem
// and will cause Xray on the remote node to crash if they don't exist there.
// Inline certificate content (certificate / key) is kept intact.
func sanitizeStreamSettingsForRemote(streamSettings string) string {
if streamSettings == "" {
return streamSettings
}
var stream map[string]any
if err := json.Unmarshal([]byte(streamSettings), &stream); err != nil {
return streamSettings
}
tlsSettings, ok := stream["tlsSettings"].(map[string]any)
if !ok {
return streamSettings
}
certificates, ok := tlsSettings["certificates"].([]any)
if !ok {
return streamSettings
}
for _, cert := range certificates {
c, ok := cert.(map[string]any)
if !ok {
continue
}
delete(c, "certificateFile")
delete(c, "keyFile")
}
out, err := json.Marshal(stream)
if err != nil {
return streamSettings
}
return string(out)
}

119
web/service/api_token.go Normal file
View File

@@ -0,0 +1,119 @@
package service
import (
"crypto/subtle"
"errors"
"strings"
"github.com/mhsanaei/3x-ui/v3/database"
"github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/util/common"
"github.com/mhsanaei/3x-ui/v3/util/random"
)
type ApiTokenService struct{}
const apiTokenLength = 48
type ApiTokenView struct {
Id int `json:"id"`
Name string `json:"name"`
Token string `json:"token"`
Enabled bool `json:"enabled"`
CreatedAt int64 `json:"createdAt"`
}
func toView(t *model.ApiToken) *ApiTokenView {
return &ApiTokenView{
Id: t.Id,
Name: t.Name,
Token: t.Token,
Enabled: t.Enabled,
CreatedAt: t.CreatedAt,
}
}
func (s *ApiTokenService) List() ([]*ApiTokenView, error) {
db := database.GetDB()
var rows []*model.ApiToken
if err := db.Model(model.ApiToken{}).Order("id asc").Find(&rows).Error; err != nil {
return nil, err
}
out := make([]*ApiTokenView, 0, len(rows))
for _, r := range rows {
out = append(out, toView(r))
}
return out, nil
}
func (s *ApiTokenService) Create(name string) (*ApiTokenView, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, common.NewError("token name is required")
}
if len(name) > 64 {
return nil, common.NewError("token name must be 64 characters or fewer")
}
db := database.GetDB()
var count int64
if err := db.Model(model.ApiToken{}).Where("name = ?", name).Count(&count).Error; err != nil {
return nil, err
}
if count > 0 {
return nil, common.NewError("a token with that name already exists")
}
row := &model.ApiToken{
Name: name,
Token: random.Seq(apiTokenLength),
Enabled: true,
}
if err := db.Create(row).Error; err != nil {
return nil, err
}
return toView(row), nil
}
func (s *ApiTokenService) Delete(id int) error {
if id <= 0 {
return common.NewError("invalid token id")
}
db := database.GetDB()
return db.Where("id = ?", id).Delete(model.ApiToken{}).Error
}
func (s *ApiTokenService) SetEnabled(id int, enabled bool) error {
if id <= 0 {
return common.NewError("invalid token id")
}
db := database.GetDB()
res := db.Model(model.ApiToken{}).Where("id = ?", id).Update("enabled", enabled)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return errors.New("token not found")
}
return nil
}
// Match returns true when the presented bearer token matches any enabled
// row in api_tokens. Uses constant-time compare per row so a remote
// attacker can't time-attack tokens byte-by-byte.
func (s *ApiTokenService) Match(presented string) bool {
if presented == "" {
return false
}
db := database.GetDB()
var rows []*model.ApiToken
if err := db.Model(model.ApiToken{}).Where("enabled = ?", true).Find(&rows).Error; err != nil {
return false
}
presentedBytes := []byte(presented)
matched := false
for _, r := range rows {
if subtle.ConstantTimeCompare([]byte(r.Token), presentedBytes) == 1 {
matched = true
}
}
return matched
}

View File

@@ -1,4 +1,21 @@
{
"api": {
"services": [
"HandlerService",
"LoggerService",
"StatsService"
],
"tag": "api"
},
"inbounds": [{
"listen": "127.0.0.1",
"port": 62789,
"protocol": "tunnel",
"settings": {
"rewriteAddress": "127.0.0.1"
},
"tag": "api"
}],
"log": {
"access": "none",
"dnsLog": false,
@@ -6,39 +23,21 @@
"loglevel": "warning",
"maskAddress": ""
},
"api": {
"tag": "api",
"services": [
"HandlerService",
"LoggerService",
"StatsService"
]
"metrics": {
"listen": "127.0.0.1:11111",
"tag": "metrics_out"
},
"inbounds": [
{
"tag": "api",
"listen": "127.0.0.1",
"port": 62789,
"protocol": "tunnel",
"settings": {
"address": "127.0.0.1"
}
}
],
"outbounds": [
{
"tag": "direct",
"outbounds": [{
"protocol": "freedom",
"settings": {
"domainStrategy": "AsIs",
"redirect": "",
"noises": []
}
"domainStrategy": "AsIs"
},
"tag": "direct"
},
{
"tag": "blocked",
"protocol": "blackhole",
"settings": {}
"settings": {},
"tag": "blocked"
}
],
"policy": {
@@ -57,33 +56,28 @@
},
"routing": {
"domainStrategy": "AsIs",
"rules": [
{
"type": "field",
"rules": [{
"inboundTag": [
"api"
],
"outboundTag": "api"
"outboundTag": "api",
"type": "field"
},
{
"type": "field",
"outboundTag": "blocked",
"ip": [
"geoip:private"
]
],
"outboundTag": "blocked",
"type": "field"
},
{
"type": "field",
"outboundTag": "blocked",
"protocol": [
"bittorrent"
]
],
"type": "field"
}
]
},
"stats": {},
"metrics": {
"tag": "metrics_out",
"listen": "127.0.0.1:11111"
}
}
"stats": {}
}

View File

@@ -18,6 +18,7 @@ import (
"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/util/netsafe"
)
const (
@@ -164,8 +165,7 @@ func CustomGeoLocalFileNeedsRepair(path string) bool {
}
func isBlockedIP(ip net.IP) bool {
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() || ip.IsUnspecified()
return netsafe.IsBlockedIP(ip)
}
// checkSSRFDefault validates that the given host does not resolve to a private/internal IP.

View File

@@ -849,6 +849,9 @@ func (s *InboundService) AddInboundClient(data *model.Inbound) (bool, error) {
// Secure client ID
for _, client := range clients {
if strings.TrimSpace(client.Email) == "" {
return false, common.NewError("client email is required")
}
switch oldInbound.Protocol {
case "trojan":
if client.Password == "" {
@@ -1317,8 +1320,11 @@ func (s *InboundService) UpdateInboundClient(data *model.Inbound, clientId strin
if newClientId == "" || clientIndex == -1 {
return false, common.NewError("empty client ID")
}
if strings.TrimSpace(clients[0].Email) == "" {
return false, common.NewError("client email is required")
}
if len(clients[0].Email) > 0 && clients[0].Email != oldEmail {
if clients[0].Email != oldEmail {
existEmail, err := s.checkEmailsExistForClients(clients)
if err != nil {
return false, err
@@ -2036,6 +2042,7 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB) (bool, int64, error) {
traffics[traffic_index].Up = 0
if !traffic.Enable {
traffics[traffic_index].Enable = true
c["enable"] = true
clientsToAdd = append(clientsToAdd,
struct {
protocol string
@@ -3315,6 +3322,20 @@ func (s *InboundService) GetActiveClientTraffics(emails []string) ([]*xray.Clien
return traffics, nil
}
// GetAllClientTraffics returns the full set of client_traffics rows so the
// websocket broadcasters can ship a complete snapshot every cycle. The old
// delta-only path (GetActiveClientTraffics on activeEmails) silently dropped
// the per-client section whenever no client moved bytes in the cycle or a
// node sync failed, leaving client rows in the UI stuck at stale numbers.
func (s *InboundService) GetAllClientTraffics() ([]*xray.ClientTraffic, error) {
db := database.GetDB()
var traffics []*xray.ClientTraffic
if err := db.Model(xray.ClientTraffic{}).Find(&traffics).Error; err != nil {
return nil, err
}
return traffics, nil
}
type InboundTrafficSummary struct {
Id int `json:"id"`
Up int64 `json:"up"`

View File

@@ -130,6 +130,7 @@ func (h *metricHistory) aggregate(metric string, bucketSeconds int, maxPoints in
var (
systemMetrics = newMetricHistory()
nodeMetrics = newMetricHistory()
xrayMetrics = newMetricHistory()
)
// SystemMetricKeys lists the metric names ServerService writes on every
@@ -141,3 +142,11 @@ var SystemMetricKeys = []string{
// NodeMetricKeys lists the per-node metric names NodeHeartbeatJob writes.
var NodeMetricKeys = []string{"cpu", "mem"}
// XrayMetricKeys lists series sourced from xray's /debug/vars expvar
// endpoint. Populated by XrayMetricsService.Sample on the same 2s cadence
// as the system metrics, but only when the xray config has a `metrics`
// block configured.
var XrayMetricKeys = []string{
"xrAlloc", "xrSys", "xrHeapObjects", "xrNumGC", "xrPauseNs",
}

View File

@@ -5,7 +5,9 @@ import (
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
@@ -13,6 +15,7 @@ import (
"github.com/mhsanaei/3x-ui/v3/database"
"github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/util/common"
"github.com/mhsanaei/3x-ui/v3/util/netsafe"
"github.com/mhsanaei/3x-ui/v3/web/runtime"
)
@@ -34,6 +37,7 @@ var nodeHTTPClient = &http.Client{
MaxIdleConns: 64,
MaxIdleConnsPerHost: 4,
IdleConnTimeout: 60 * time.Second,
DialContext: netsafe.SSRFGuardedDialContext,
},
}
@@ -53,31 +57,38 @@ func (s *NodeService) GetById(id int) (*model.Node, error) {
return n, nil
}
func normalizeBasePath(p string) string {
p = strings.TrimSpace(p)
if p == "" {
return "/"
}
if !strings.HasPrefix(p, "/") {
p = "/" + p
}
if !strings.HasSuffix(p, "/") {
p = p + "/"
}
return p
}
func (s *NodeService) normalize(n *model.Node) error {
n.Name = strings.TrimSpace(n.Name)
n.Address = strings.TrimSpace(n.Address)
n.ApiToken = strings.TrimSpace(n.ApiToken)
if n.Name == "" {
return common.NewError("node name is required")
}
if n.Address == "" {
return common.NewError("node address is required")
addr, err := netsafe.NormalizeHost(n.Address)
if err != nil {
return common.NewError(err.Error())
}
n.Address = addr
if n.Port <= 0 || n.Port > 65535 {
return common.NewError("node port must be 1-65535")
}
if n.Scheme != "http" && n.Scheme != "https" {
n.Scheme = "https"
}
if n.BasePath == "" {
n.BasePath = "/"
}
if !strings.HasPrefix(n.BasePath, "/") {
n.BasePath = "/" + n.BasePath
}
if !strings.HasSuffix(n.BasePath, "/") {
n.BasePath = n.BasePath + "/"
}
n.BasePath = normalizeBasePath(n.BasePath)
return nil
}
@@ -99,14 +110,15 @@ func (s *NodeService) Update(id int, in *model.Node) error {
return err
}
updates := map[string]any{
"name": in.Name,
"remark": in.Remark,
"scheme": in.Scheme,
"address": in.Address,
"port": in.Port,
"base_path": in.BasePath,
"api_token": in.ApiToken,
"enable": in.Enable,
"name": in.Name,
"remark": in.Remark,
"scheme": in.Scheme,
"address": in.Address,
"port": in.Port,
"base_path": in.BasePath,
"api_token": in.ApiToken,
"enable": in.Enable,
"allow_private_address": in.AllowPrivateAddress,
}
if err := db.Model(model.Node{}).Where("id = ?", id).Updates(updates).Error; err != nil {
return err
@@ -168,10 +180,29 @@ func (s *NodeService) AggregateNodeMetric(id int, metric string, bucketSeconds i
func (s *NodeService) Probe(ctx context.Context, n *model.Node) (HeartbeatPatch, error) {
patch := HeartbeatPatch{LastHeartbeat: time.Now().Unix()}
url := fmt.Sprintf("%s://%s:%d%spanel/api/server/status",
n.Scheme, n.Address, n.Port, n.BasePath)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
addr, err := netsafe.NormalizeHost(n.Address)
if err != nil {
patch.LastError = err.Error()
return patch, err
}
scheme := n.Scheme
if scheme != "http" && scheme != "https" {
scheme = "https"
}
if n.Port <= 0 || n.Port > 65535 {
patch.LastError = "node port must be 1-65535"
return patch, errors.New(patch.LastError)
}
probeURL := &url.URL{
Scheme: scheme,
Host: net.JoinHostPort(addr, strconv.Itoa(n.Port)),
Path: normalizeBasePath(n.BasePath) + "panel/api/server/status",
}
req, err := http.NewRequestWithContext(
netsafe.ContextWithAllowPrivate(ctx, n.AllowPrivateAddress),
http.MethodGet, probeURL.String(), nil)
if err != nil {
patch.LastError = err.Error()
return patch, err

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