Two related cleanups in the new /clients surface:
1. Move ResetAllClientTraffics (bulk-reset of xray_client_traffic +
last_traffic_reset_time, with node-runtime propagation) from
InboundService to ClientService. PeriodicTrafficResetJob now holds
a clientService and calls
j.clientService.ResetAllClientTraffics(&j.inboundService, id).
The last client-mutation method on InboundService is gone.
2. Shorten redundantly-named routes/handlers under /panel/api/clients:
- /clientIps/:email -> /ips/:email (handler getIps)
- /clearClientIps/:email -> /clearIps/:email (handler clearIps)
The "client" prefix was redundant inside the clients namespace.
Frontend (InboundInfoModal) and api-docs updated to match.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Moves the client mutation surface out of InboundService and into
ClientService. These methods all operate on a single client (identity
fields, traffic limits, expiry, ip limit, enable state, telegram tg id)
and didn't belong on the inbound aggregate.
Moved (12 methods): AddInboundClient, UpdateInboundClient, DelInboundClient,
DelInboundClientByEmail, checkEmailsExistForClients, SetClientTelegramUserID,
checkIsEnabledByEmail, ToggleClientEnableByEmail, SetClientEnableByEmail,
ResetClientIpLimitByEmail, ResetClientExpiryTimeByEmail,
ResetClientTrafficLimitByEmail.
Each method now takes an explicit *InboundService for the helpers that
legitimately stay on InboundService (GetInbound, GetClients, runtimeFor,
AddClientStat / UpdateClientStat / DelClientStat, DelClientIPs /
UpdateClientIPs, emailUsedByOtherInbounds, getAllEmailSubIDs,
GetClientInboundByEmail / GetClientInboundByTrafficID,
GetClientTrafficByEmail).
Stays on InboundService: ResetClientTrafficByEmail and
ResetClientTraffic(id, email) — these mutate xray_client_traffic rows,
not client identity, so they're inbound-side bookkeeping.
Callers updated: tgbot (6 calls), ldap_sync_job (1 call),
InboundService internal (writeBackClientSubID, CopyInboundClients,
AddInbound's email-uniqueness check), ClientService Create/Update/
Delete/Attach/Detach.
Also removes a dead resetAllClientTraffics controller handler whose
route was already gone after the previous /clients API migration.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds two thin helpers to ClientService (CreateOne, DetachByEmail) and
rewrites tgbot.SubmitAddClient and ldap_sync_job to call ClientService
directly. Removes the JSON-blob payloads (BuildJSONForProtocol output for
add, clientsToJSON/clientToJSON helpers) that callers previously fed to
InboundService.AddInboundClient/DelInboundClient.
ldap_sync_job.batchSetEnable now loops InboundService.SetClientEnableByEmail
per email instead of trying to coerce AddInboundClient into doing the
update — the old path would have failed duplicate-email validation for
existing clients anyway.
The legacy InboundService.AddInboundClient/UpdateInboundClient/
DelInboundClient methods stay in place; they are now only used internally
by ClientService Create/Update/Delete/Attach. Inlining + deleting them
follows in a separate commit.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
After the multi-inbound client migration, client state belongs to the
client API surface, not the inbound one. Twelve routes that were
crammed under /panel/api/inbounds/* now live where they belong, under
/panel/api/clients/*.
Moved (route, handler, doc):
POST /clientIps/:email
POST /clearClientIps/:email
POST /onlines
POST /lastOnline
POST /updateClientTraffic/:email
POST /resetAllClientTraffics/:id
POST /delDepletedClients/:id
POST /:id/resetClientTraffic/:email
GET /getClientTraffics/:email
GET /getClientTrafficsById/:id
GET /getSubLinks/:subId
GET /getClientLinks/:id/:email
Their /clients/* counterparts are:
POST /clients/clientIps/:email
POST /clients/clearClientIps/:email
POST /clients/onlines
POST /clients/lastOnline
POST /clients/updateTraffic/:email
POST /clients/resetTraffic/:email (email-only, fans out)
GET /clients/traffic/:email
GET /clients/traffic/byId/:id
GET /clients/subLinks/:subId
GET /clients/links/:id/:email
per-inbound resetAllClientTraffics and delDepletedClients are dropped
entirely — the Clients page already exposes global Reset All Traffic
and Delete depleted actions, and per-inbound resets are meaningless
once a client can be attached to many inbounds.
ClientService.ResetTrafficByEmail is the new email-only reset path:
it looks up every inbound the client is attached to and pushes the
counter reset + Xray re-add through inboundService.ResetClientTraffic
for each one, so depleted users come back online instantly.
Frontend callers (ClientsPage, useClients, ClientQrModal,
ClientInfoModal, InboundInfoModal, InboundsPage, useInbounds) all
switched to the new paths. The Inbounds page drops its per-inbound
"Reset client traffic" and "Delete depleted clients" dropdown items —
users do those at the client level now. api-docs is rebuilt to match.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Mirrors the legacy delDepletedClients action that lived under the
inbounds page, but as a first-class /panel/api/clients/delDepleted
endpoint backed by ClientService. The new path goes through
ClientService.Delete for each depleted email, so the new clients +
client_inbounds + xray_client_traffic tables stay consistent.
Adds a danger-styled toolbar button on the Clients page (next to
Reset all client traffic) with a confirm dialog and a toast
reporting the deleted count.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Now that clients live as first-class rows attached to one or many
inbounds, the per-inbound client UI on the inbounds page is dead
weight — every client action either has a global equivalent on the
Clients page or makes no sense in a many-to-many world.
Deletes ClientFormModal, ClientBulkModal, CopyClientsModal, and
ClientRowTable from inbounds/. Strips the matching emits, refs,
handlers, and dropdown menu items from InboundList and InboundsPage,
and removes the dead mobile expand-chevron state and the desktop
expanded-row plumbing that drove the inline client table.
The InboundFormModal Clients tab still works in add-mode (one inline
client at inbound creation) — that flow goes through ClientService.
SyncInbound on save and remains useful.
Fixes a stray "</a-dropdown>" left over by an earlier toolbar edit
in ClientsPage that broke the template parser.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds the same row-card layout the inbounds page uses on mobile: the
table is suppressed under the mobile breakpoint and each client renders
as a compact card with a status dot, email, Info button, Enable switch,
and overflow menu. All the per-client detail (traffic, remaining,
expiry, attached inbounds, flow, created/updated, URL, subscription)
opens through the existing info modal.
Multi-select with bulk delete wires AntD row-selection on desktop and
a per-card checkbox on mobile; a Delete (N) button appears in the
toolbar when anything is selected.
Bulk add reuses the five email-generation modes from the inbound bulk
modal but takes a multi-inbound picker so one bulk run can attach to
several inbounds at once. Submits client-by-client through the
existing /panel/api/clients/add endpoint.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Removes the AllTime field from Inbound and ClientTraffic and migrates
existing DBs by dropping the all_time columns on startup. The counter
duplicated up+down without adding signal, and the per-event accumulator
ran on every traffic write.
Frontend: drop the All-time column from the inbound list and the
client-row table, the All-time row from the client info modal, and the
All-Time Total Usage tile from the inbounds summary card. The
allTimeTraffic/allTimeTrafficUsage i18n keys are removed across every
locale.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- InboundFormModal: split the multi-line help string in the
PortFallback section onto one line — Vue's template parser was
bailing on Unterminated string constant because a single-quoted
literal spanned two lines inside a {{ }} interpolation.
- ClientInfoModal: t('disable') was missing at the root level, so
vue-i18n returned the key path literally. Use t('disabled') which
exists.
- Linter cleanup elsewhere: pages.client.* references renamed to
pages.clients.* to match the merged i18n block; whitespace
normalisation in a few unrelated Vue templates.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The multi-select was gated on add-only, so editing a client had no way
to change which inbounds it belonged to. The picker now shows in both
modes, and on submit the modal diffs the picked set against the
original attachedIds — additions go through the /attach endpoint,
removals through /detach, both after the field update lands so the
new attachments get the latest values.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The Clients page table gains:
- Online column — green/grey tag driven by /panel/api/inbounds/onlines,
polled every 10s.
- Remaining column — bytes-remaining tag, coloured green/orange/red
against quota, purple infinity when unlimited.
- Action icons per row: QR, Info, Reset traffic, Edit, Delete.
ClientInfoModal shows the full client detail (uuid/password/auth,
traffic ↑/↓ + remaining + all-time, expiry absolute + relative,
attached inbounds chip list, online + last-online).
ClientQrModal fetches links for the client's subId via
/panel/api/inbounds/getSubLinks/:subId and renders each one through
the existing QrPanel component.
Reset Traffic confirms then calls the existing per-inbound endpoint
on the client's first attached inbound (the traffic row is keyed on
email globally, so any attached inbound resets the shared counter).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a new "portfallback" protocol that emits as a VLESS-TLS inbound
under the hood but is paired with a sidecar table of child inbounds.
Panel auto-builds settings.fallbacks at Xray-config-gen time from the
sidecar — each child's listen+port becomes the fallback dest, with
SNI/ALPN/path/xver match criteria pulled from the row. No more typing
loopback ports by hand or keeping settings.fallbacks in sync.
Backend: new FallbackService (Get/SetChildren, BuildFallbacksJSON);
two new routes (GET/POST /panel/api/inbounds/:id/fallbackChildren);
xray.GetXrayConfig injects fallbacks for PortFallback inbounds; the
inbound model emits protocol="vless" so Xray accepts the config.
Frontend: PORTFALLBACK joins the protocol dropdown; selecting it
shows the standard VLESS controls plus a Fallback Children table
(inbound picker + per-row SNI/ALPN/path/xver). Children are loaded
on edit and replaced atomically on save.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds /panel/api/clients endpoints (list, get, add, update, del,
attach, detach) backed by ClientService methods that orchestrate
the per-inbound Add/Update/Del flows so a single client row is
created once and attached to many inbounds in one operation.
The frontend gains a dedicated Clients page (frontend/clients.html
+ src/pages/clients/) with an AntD table, multi-inbound attach
modal, and full CRUD. Axios interceptor learns to honour
Content-Type: application/json so the JSON endpoints work
alongside the legacy form-encoded ones.
The legacy per-inbound client modal stays untouched in this PR —
both flows now write to the same source of truth.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds ClientService.SyncInbound that reconciles the new tables from
each inbound's clients list whenever existing service paths mutate
settings.clients. Wires it into AddInbound, UpdateInbound,
AddInboundClient, UpdateInboundClient, DelInboundClient,
DelInboundClientByEmail, DelDepletedClients, autoRenewClients, and
the timestamp-backfill path in adjustTraffics, plus DetachInbound
on DelInbound.
GetXrayConfig now builds settings.clients from the new tables before
writing config.json, and getInboundsBySubId joins through them
instead of JSON_EACH on settings JSON. Live Xray config and
subscription endpoints are now driven by the relational view;
settings.clients JSON stays in step as a side effect of every write.
Introduces three new GORM-backed tables (clients, client_inbounds,
inbound_fallback_children) and a populate-only seeder that backfills
them from each inbound's existing settings.clients JSON. Duplicate
emails across inbounds auto-merge under one client row, with each
field conflict logged. Existing services are unchanged and continue
reading from settings.clients — this commit is groundwork only.
The bot's add-client flow already serialised client_Flow into the VLESS
JSON template but never exposed a way to set it from Telegram, so every
client ended up with an empty flow regardless of the inbound's transport.
Added an inline "Flow" row to the VLESS protocol keyboard with three
choices — None, xtls-rprx-vision, and xtls-rprx-vision-udp443 — and a
matching i18n key in all 13 locale files. The row is only shown when
the inbound can actually use Vision flow (mirrors the frontend's
canEnableTlsFlow check: VLESS over TCP with TLS or Reality); on other
transports it's hidden and any stale client_Flow value is reset, so the
generated JSON stays consistent with the inbound's stream settings.
SetInboundEnable called rt.DelInbound for every runtime, but Remote.DelInbound
hits panel/api/inbounds/del/:id on the node — a real row delete, not just a
"stop serving" hint like Local.DelInbound. Flipping the enable switch on a
remote inbound therefore wiped the row on the node entirely.
Route remote inbounds through UpdateInbound instead so the row stays and only
the enable flag is patched. Local path keeps the Del+Add flow since that's
how Xray's gRPC API expects to be driven.
Fixes#4402
The fast-probe mode hard-coded net.DialTimeout("tcp", ...), so testing a
WARP/WireGuard or Hysteria outbound always failed with an i/o timeout —
those transports only listen on UDP, never on TCP.
Probe is now transport-aware: extractOutboundEndpoints tags each endpoint
with the network the proxy actually listens on (UDP for wireguard,
hysteria, and any outbound whose streamSettings.network is hysteria, kcp,
or quic; TCP otherwise). probeUDPEndpoint dials UDP, writes a single
sentinel byte so the kernel can surface ICMP errors, and treats a read
timeout as success (WireGuard ignores invalid packets, so silence is the
expected reply from a reachable server). The result's mode field now
reflects what was probed, so the UI badge shows UDP for these outbounds
instead of mislabelling them as TCP.
Collapsed repeated stream/sniffing/settings handling in InboundFormModal
into shared helpers (stampAdvancedTextFor, parseAdvancedSliceWithLabel,
compactAdvancedJson, withSaving) plus a wrapped-config factory for the
single-key editors. Cuts ~120 lines from the script section with no
behavior change.
The advanced-panel subtitle and editor-meta text used a fixed dark color
that was unreadable on the dark and ultra-dark modal backgrounds.
Switched both to opacity-on-inherit so they pick up AntD's theme-aware
foreground color, the same pattern .section-heading already uses.
* fix: prevent online clients from randomly disappearing from panel UI
Online status was determined solely by whether a client transferred
bytes in the current 5-second polling window. The online list was
completely replaced each cycle, so idle-but-connected clients with no
traffic delta in that window were dropped from the UI.
Now online status is computed from lastOnline DB timestamps with a
5-second grace period via RefreshOnlineClientsFromMap(), so clients
remain visible across idle polling windows.
Closes#4384
* fix: extend online client grace period to survive idle poll cycles
The 5s grace period equalled the traffic-poll interval, so a client
whose Xray stats reported a zero delta for one cycle was still dropped
on the very next tick. Bump to 20s (~4 polls) so idle-but-connected
sessions stay visible across momentary counter gaps without lingering
long after a real disconnect.
Refs #4384
---------
Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
Xray-core's RandomStrategy and RoundRobinStrategy register a pending
dependency on the Observatory feature whenever fallbackTag is non-empty.
Since the panel only provisions observatory for leastPing / leastLoad
balancers, picking roundRobin with a fallbackTag caused xray to fail
boot with "not all dependencies are resolved". Disable the fallback
field for the two strategies that cannot resolve it, and strip
fallbackTag from the wire balancer as a defensive backstop for users
who edit the JSON template directly.
The eager `import.meta.glob` was statically pulling all 13 locale JSON
files into the main bundle, defeating the sibling lazy glob and emitting
INEFFECTIVE_DYNAMIC_IMPORT warnings. Statically import only the en-US
fallback, lazy-load the rest, and await `readyI18n()` in each entry
before mount so the first paint still uses the active locale.
When Cloudflare Rocket Loader is enabled, it interferes with inline scripts that set window.X_UI_BASE_PATH, causing the frontend to fail to configure the correct base URL for API calls. This results in 404 errors on the login page when calling /getTwoFactorEnable.
Solution: Add meta name='base-path' tag to HTML (similar to csrf-token), update axios initialization to read from meta tag as fallback. Meta tags are not affected by CSP or Rocket Loader delays.
Fixes#4393
- Frontend: Only include streamSettings in toJson() for vmess, vless, trojan, shadowsocks, and hysteria protocols
- Frontend: Hide Stream tab in Advanced section for unsupported protocols
- Frontend: Clear streamSettings in Advanced tab when switching to unsupported protocols
- Frontend: Add CodeMirror JSON editor to config view in index page with mobile responsive design
- Backend: Add normalizeStreamSettings() to clear streamSettings for tunnel, mixed, http, tun, and wireguard protocols
- Backend: Apply normalization in AddInbound() and UpdateInbound()
- Backend: Add omitempty JSON tag to StreamSettings field to exclude null values from Xray config
The Obfs password field in the Hysteria2 stream settings tab was incorrectly
labeled. It binds to hysteriaSettings.auth (the server-wide authentication
password), not to the salamander obfuscation password. Per Xray-core docs,
Hysteria2 salamander obfuscation belongs in finalmask.udp[].salamander.password,
which is correctly handled by the FinalMaskForm (UDP Masks section).
Fixed the label to Auth password with an accurate tooltip explaining that
salamander obfuscation is configured via the UDP Masks section below.
3x-ui has a growing ecosystem of community tools (Terraform, scripts,
exporters, etc.). This adds a Community Tools section between
Acknowledgment and Support project in all 6 localized READMEs so users
can discover them from the main project page.
The format mirrors the existing Acknowledgment section so future
maintainers of 3x-ui-related tools can extend it with one-line PRs.
- fromHysteriaLink: parse security= URL param and populate stream.tls
(SNI, fingerprint, ALPN, ECH) when security=tls; previously always
forced security to 'none'
- fromHysteriaLink: parse fm JSON param and populate both
stream.finalmask.quicParams (drives the QUIC Params toggle in
FinalMaskForm) and the mirrored stream.hysteria fields
- fromParamLink (VLESS/Trojan/SS): parse fm JSON param and restore
stream.finalmask (TCP masks, UDP masks, QUIC params)
- fromVmessLink (VMess): same fm handling for the base64-JSON path
Closes#4376
When creating a Hysteria (or any TLS-required) inbound from the central
panel and deploying it to a remote node, sanitizeStreamSettingsForRemote
was unconditionally stripping certificateFile / keyFile from the TLS
settings. This left Xray on the remote node with a TLS block containing
no certificate, causing Xray to crash and the inbounds page to hang.
The fix: only strip cert file paths when inline certificate content
(certificate / key arrays) is also present in the same entry — those
file paths are then truly redundant. When only file paths are present
the user explicitly entered paths that live on the remote node's
filesystem; they are now passed through untouched.
Fixes#4370
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.
* 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>
- 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
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.
- 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.
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.
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.