187 Commits

Author SHA1 Message Date
MHSanaei f3a57d4c57 3.4.2 2026-06-29 20:28:08 +02:00
MHSanaei 86813758cc fix(node): stop the offline-sync toast firing on saves to online nodes
IsNodePending fed the user-facing "saved locally, node offline, will
sync on reconnect" toast off three conditions, one of which was the
node's config_dirty flag. But every node-backed client/inbound edit
marks the node dirty unconditionally inside its write transaction — it
is the reconcile self-heal marker, set even for edits pushed live to a
healthy online node. The controller reads that freshly-set flag right
after the save, so the warning fired on every save to a node-backed
inbound regardless of the node actually being online.

Drop the dirty term so the predicate reflects only what the message
claims: the node being unreachable (offline or disabled). Offline and
disabled nodes still mark dirty and still surface the toast.

Add regression tests: online+dirty must not be pending; offline and
disabled must be.
2026-06-29 18:35:38 +02:00
MHSanaei d8221a8153 fix(sub): bake Host VLESS Route into subscription UUIDs
The Host VLESS Route field was stored and shown in the panel but never applied to any generated subscription (raw, JSON, Clash), so the UUID was emitted unmodified (#5655).

Xray reads the route from the UUID's 3rd group (bytes 6-7, net.PortFromBytes) and masks those bytes to zero before authenticating, so a value can be baked into the share/JSON/Clash UUIDs without breaking the user match. A shared applyVlessRoute helper encodes a single 0-65535 value as the 3rd group; empty/invalid/non-UUID input is left unchanged, so legacy data never yields a broken link and no DB migration is needed.

The field was wrongly validated as a multi-segment port spec (that form belongs to the separate server-side routing rule). It is now a single value 0-65535, with frontend validation, link-preview parity (genVlessLink/hostToExternalProxyEntry), hint + error translations across all 13 locales, and tests on every path.

Closes #5655
2026-06-29 14:32:23 +02:00
MHSanaei 789e92cddc fix(clients): re-enable depleted clients on API renewal (#5619)
Renewing a subscription via POST /panel/api/clients/bulkAdjust extended a client's expiry/quota but left it disabled. The enforcement loop disables a depleted client across client_traffics, client_records and the inbound settings JSON (and pushes that to the node), while BulkAdjust only updated expiry/total and never cleared enable. On a node its UpdateUser push was built from the stale ClientRecord (Enable=false), which the next traffic poll merged back onto the master, so the client never recovered.

BulkAdjust now re-enables a client only when it was disabled because it was depleted and the adjustment lifts it back within limits, computed as a set-difference of the production depletedCond predicate and applied through the canonical BulkSetEnable (run after the per-inbound loop, since lockInbound is non-reentrant). Manually-disabled or still-depleted clients stay disabled.

Update now writes the clients.enable column explicitly so re-enabling sticks for inbound-less clients and stops feeding a stale record into node pushes.
2026-06-29 13:39:03 +02:00
nima1024m 7a5d6da28c fix(xray): clean stale routing references when a balancer or outbound is deleted (#5648)
* feat(xray): reference-cleanup helpers for entity deletion

When an outbound or balancer is deleted on the Xray page, routing rules and
balancers that reference it must be repaired in the same edit, or the saved
config breaks the core: a dangling balancerTag stops Router.Init (whole core
down), a dangling outboundTag black-holes matched traffic at the dispatcher.

Add pure plan*/apply* helpers that compute and apply the cleanup. A rule is
kept when a destination (outboundTag or balancerTag) remains and dropped when
none does. Deleting an outbound cascades: emptying a balancer selector removes
that balancer too, then repairs its rules in one pass against the full removed
set; fallbackTag and dialerProxy references are cleared and observatories
re-synced.

* fix(balancers): clean routing rules referencing a deleted balancer

Deleting a balancer left routing rules pointing at its balancerTag. xray-core's
Router.Init then fails ("balancer <tag> not found"), the core won't restart and
every inbound drops — the saved config passes CheckXrayConfig (JSON shape only),
so it breaks only on the next restart.

The delete confirm now lists the affected rules (modified vs removed) next to
the existing observatory warning and applies planBalancerDeletion's cleanup: a
rule keeps its outboundTag when present, otherwise the whole rule is dropped.
Adds the shared DeletionImpactList and refCleanup strings across all 13 locales.

* fix(outbounds): clean rules, balancer selectors and dialerProxy on outbound delete

Deleting an outbound left routing rules pointing at its outboundTag (matched
traffic black-holed at the dispatcher), plus stale references in balancer
selectors / fallbackTag and other outbounds' dialerProxy.

The delete confirm now shows planOutboundDeletion's impact and applies the
cascade: rules keep a remaining balancerTag (else are dropped), the tag is
pulled from balancer selectors and fallbacks, dialerProxy references are
cleared, and a balancer whose selector is emptied is removed along with its
own now-targetless rules.

* refactor(xray): share one rule classifier across preview and apply

Code review flagged that the keep/drop predicate was transcribed twice — in
ruleImpacts (the delete-modal preview) and in applyCleanup (the mutation) — kept
in sync only by a parity test. Extract a single classifyRule() that both call,
so the preview can never disagree with what apply actually does.

Also harden balancersEmptiedBy to skip tagless balancers: an empty/missing tag
would otherwise enter the removed set as "" and silently drop every other
tagless balancer (only reachable via a hand-edited config, but a silent data
loss). And remove observersRemovedByDeletingBalancer, orphaned once BalancersTab
switched to planBalancerDeletion.

* fix(xray): null-guard reference cleanup against unvalidated configs

The PR review noted that classifyRule and applyCleanup dereferenced rule /
balancer entries directly, while the sibling propagateOutboundTagRename uses
optional chaining — because fetchXrayConfig falls back to the unvalidated parsed
object when Zod validation fails, a stray null in rules / balancers can survive
into the editor and would throw during the delete preview/apply.

Match that defensive style: classifyRule and balancersEmptiedBy read through
optional chaining, the balancer loop skips nullish entries, and the dialerProxy
walk guards the outbound. A delete on a hand-edited config with null entries now
degrades gracefully instead of throwing.
2026-06-29 12:52:18 +02:00
nima1024m 71aca2018a feat(a11y): screen-reader & keyboard accessibility across the panel (#5486) (#5652)
* feat(a11y): label list, toolbar & dashboard actions for screen readers

Phase 1 of #5486 (Android TalkBack support). Icon-only controls across
the management surfaces previously announced only their untranslated
icon name (e.g. "edit", "ellipsis") or nothing at all.

- Add aria-label to icon-only row-action and toolbar buttons across
  inbounds, clients, groups, hosts, nodes and xray
  (outbounds/routing/dns/balancers) lists, plus the dashboard cards.
- Make clickable bare icons and AntD Card actions keyboard-operable via
  role/tabIndex + Enter/Space (new activateOnKey helper); convert mobile
  dropdown triggers to buttons so they open from the keyboard.
- Fix the sidebar hamburger's mislabeled aria-label (was the dashboard
  label) and translate previously-hardcoded outbound menu labels.

New i18n keys in all 13 locales: sort, menu.openMenu,
pages.xray.outbound.moveToTop.

* feat(a11y): label modal, QR and copy/download controls for screen readers

Phase 2 of #5486. Modal and overlay controls relied on tooltips (not a
reliable accessible name) or were bare clickable icons with no keyboard
or screen-reader support.

- Add aria-label to copy/QR/download/info icon buttons in the inbound and
  client info modals, sub-links modal, QR panel, backup/log modals, and
  to the bare search/select inputs of the attach/detach client modals.
- Make click-to-copy QR codes and the IP-log refresh/clear, geofile
  reload and log refresh icons keyboard-operable (role/tabIndex +
  Enter/Space) with translated labels.
- Label the 2FA code input; drop the QrPanel download-image string
  fallback now that the key exists.

New i18n key in all 13 locales: downloadImage.

* feat(a11y): label form fields and shared form components for screen readers

Phase 3 of #5486. Form controls and shared form widgets were largely
unlabelled, and several remove controls were not keyboard-operable.

- SettingListItem now ties its title to the control via aria-labelledby,
  giving accessible names to the ~90 settings-tab inputs at once.
- InputAddon gains button semantics (role/tabIndex/Enter+Space) and an
  ariaLabel prop when used as an interactive remove control.
- Sparkline charts expose a role="img" summary of their latest values.
- Add aria-label to add/remove/regenerate icon buttons and bare
  inputs/selects across inbound, client and xray (dns/routing/balancer/
  outbound) forms; make clickable remove icons keyboard-operable; mark
  decorative help/target icons aria-hidden; label the JSON editor,
  date-time clear button, header-map remove, notification select-all and
  remark token chips.

New i18n keys in all 13 locales: regenerate, jsonEditor,
pages.xray.balancer.{costMatch,costValue,costRegexp}.

* chore(a11y): add eslint-plugin-jsx-a11y harness and fix flagged interactions

Phase 4 of #5486. Adds eslint-plugin-jsx-a11y (recommended ruleset,
scoped to .tsx) so screen-reader/keyboard regressions fail lint.

- Make the mobile node-card header a proper keyboard disclosure
  (role=button, aria-expanded, Enter/Space activation that ignores
  clicks on the nested action buttons) and drop the now-redundant
  stop-propagation click handlers the linter flagged on card-action
  wrappers in the node, client and inbound mobile cards.
- Disable jsx-a11y/no-autofocus: the autofocus on the login field and
  modal primary inputs is intentional focus management that helps
  screen-reader and keyboard users land on the right control.

make lint passes with the a11y ruleset enforced.

* feat(a11y): cover remaining deferred spots (settings tabs, sockopt, API docs)

Completes the panel sweep for #5486 by labelling the spots previously
left out of phases 1-4:

- NotifyTimeField (Telegram notifications): the mode, interval, unit and
  custom-cron inputs now carry aria-labels.
- The Sockopt toggle in transport options.
- Settings category tabs in icons-only (mobile) mode now expose the tab
  name as the icon's aria-label instead of the raw icon name.
- The Swagger API-docs view is wrapped in a labelled region landmark.

New i18n keys in all 13 locales: pages.settings.notifyTime.{interval,unit}.

* feat(a11y): label shared xray form components and remark field

Code review surfaced frontend/src/lib/xray/forms/ — shared form components
used by the host and inbound JSON forms — which the initial audit missed.

- FinalMaskForm (TCP/UDP final-mask editor): label the icon-only add and
  regenerate buttons and make all six remove icons keyboard-operable
  (role/tabIndex/Enter+Space); adds useTranslation to its sub-components.
- CustomSockoptList: the remove icon is now keyboard-operable.
- SniffingFields: aria-label on the otherwise label-less destOverride select.
- RemarkTemplateField: aria-label on the remark-variable picker button.

New i18n key in all 13 locales: pages.inbounds.sniffingDestOverride.

* feat(a11y): label client info modal and WireGuard config block

After rebasing onto the WireGuard client-config feature, re-apply the
ClientInfoModal copy/QR/IP-log aria-labels (the modal was restructured
upstream, so the original labels did not carry over) and label the new
ConfigBlock component's copy/download/QR actions. ConfigBlock's action
wrapper keeps its stop-propagation handler (a non-interactive guard for
the Collapse header) under a scoped jsx-a11y exception.

* fix(frontend): let npm install jsx-a11y under ESLint 10

eslint-plugin-jsx-a11y@6.10.2 declares a peer range that stops at ESLint 9,
but the panel is on ESLint 10, so `npm ci` aborts with ERESOLVE even though
the plugin runs fine on ESLint 10 with flat config. Add an npm override so
jsx-a11y accepts the project's ESLint version. This keeps normal peer
resolution (recharts' react-is peer still auto-installs) — no global
legacy-peer-deps and no manual react-is pin needed.

* fix(a11y): size mobile row triggers and move node expand role to chevron

Address automated review on #5652:
- add size="small" to the inbound/client/node mobile-card "more" dropdown
  triggers so they match the adjacent small Switch and the established
  desktop RowActions pattern.
- move the node card-head disclosure semantics (role/tabIndex/aria-expanded/
  keyboard) onto the chevron affordance so the expand control is no longer a
  role="button" wrapping the Switch, info button and dropdown. Mouse
  click-anywhere-to-expand is preserved on the header div.
2026-06-29 12:51:29 +02:00
MHSanaei 6c71b725da fix(clients): hide WireGuard config after detaching the WG inbound
The client info and QR modals rendered a WireGuard config whenever the
client still carried leftover WG key material (privateKey / publicKey /
allowedIPs / preSharedKey / keepAlive), regardless of whether a WireGuard
inbound was actually attached. After detaching the WG inbound the config
kept showing, built with an empty endpoint port and public key.

Gate wgConfigText on an attached WireGuard inbound (wgInbound) being
present, not just isWireguardClient(client), in both ClientInfoModal and
ClientQrModal.

Also rename the i18n key pages.clients.conf -> config and add the missing
pages.clients keys (wireguardConfig, config, bulkFlow, bulkFlowNoChange,
bulkFlowDisable) to all 12 non-English locales so each one matches en-US.
2026-06-29 01:15:37 +02:00
MHSanaei a329882e0e feat(wireguard): client config UX, collapsible config card, configurable DNS
Land the WireGuard client-config UX work on main (the upstream PR #5642
branch could not be pushed to).

- Reusable collapsible ConfigBlock (copy/download/QR, actions aligned right)
  for the client .conf, used by client info and the public sub page.
- Correct .conf: canonical PresharedKey casing and DNS sourced from the inbound
  (configurable per-inbound, default 1.1.1.1, 1.0.0.1).
- Configurable per-inbound DNS for WireGuard (schema + form + backend hint via
  InboundOption.WgDns); inert at the Xray layer.
- Public sub page now shows the WireGuard config, rebuilt from the share link;
  the Go wireguard:// link carries dns/presharedkey/keepalive for completeness.
- QR enabled for the wireguard:// link; link rows are compact like other protocols.
- Client information order is subscription, copy URL, WireGuard config; the
  redundant config tab is removed from the add/edit client modal.
- Drop the Inbound Information and QR Code row actions for WireGuard inbounds.
2026-06-29 00:50:34 +02:00
Nikan Zeyaei 60c54827aa feat: ldap skip tls verify (#5637)
* feat(ldap): add InsecureSkipVerify field and tlsConfig helper

Extract the inline TLS config at both LDAPS dial sites (FetchVlessFlags,
AuthenticateUser) into a tlsConfig(cfg) helper, and add a new
Config.InsecureSkipVerify bool that flows through to
tls.Config.InsecureSkipVerify. This unblocks enterprise environments
(e.g. Microsoft AD CS with internal CAs) where the server certificate
chain cannot be imported into the system trust store.

Behavior is identical when InsecureSkipVerify is false (the default) -
pure refactor + plumbing. The helper is unit-testable without a live
server, which is why it is extracted.

Closes https://github.com/MHSanaei/3x-ui/issues/5538

* feat(settings): add LdapInsecureSkipVerify setting

Plumb the new LDAP skip-TLS-verify toggle through the settings stack:
- AllSetting struct field (json/form tag: ldapInsecureSkipVerify)
- defaultValueMap default ("false")
- GetLdapInsecureSkipVerify() getter
- ldap_sync_job wiring into ldaputil.Config (FetchVlessFlags path)
- panel/user.go wiring into ldaputil.Config (AuthenticateUser path;
  the original issue's file list missed this)

Persistence is handled by UpdateAllSetting's reflect loop, matching
the existing pattern used by ldapUseTLS (no explicit setter).

Closes https://github.com/MHSanaei/3x-ui/issues/5538

* feat(ui): add Skip TLS verification switch in LDAP settings

Wire the new ldapInsecureSkipVerify setting into the hand-written
frontend model and Zod schema, and render it as a new Switch in
GeneralTab right under "Use TLS (LDAPS)". The switch is disabled
when TLS is off (the setting is meaningless without LDAPS) and shows
an insecure-warning description to make the security implication
visible to operators.

Also adds a Vitest round-trip test pinning schema acceptance and
model default-to-false behavior.

Closes https://github.com/MHSanaei/3x-ui/issues/5538

* chore(i18n): add Skip TLS verification strings to all locales

Add pages.settings.ldap.skipTlsVerify and skipTlsVerifyDesc to all 13
backend-served translation files, matching the existing repo
convention of keeping LDAP keys present in every locale (en-US, fa-IR,
ru-RU, zh-CN, zh-TW, pt-BR, ar-EG, uk-UA, id-ID, tr-TR, vi-VN, ja-JP,
es-ES). No translation-parity test exists in CI, but every other
LDAP key is replicated across all files, so this keeps the
invariant intact.

Closes https://github.com/MHSanaei/3x-ui/issues/5538

* chore(codegen): regenerate frontend artifacts

Regenerate frontend/src/generated/{zod,types,schemas,examples}.ts
and frontend/public/openapi.json via `npm run gen` to reflect the
new ldapInsecureSkipVerify field. The codegen CI job runs
`git diff --exit-code` on these files; failing to commit them would
break the build.

Closes https://github.com/MHSanaei/3x-ui/issues/5538
2026-06-28 18:10:38 +02:00
n0ctal aef35ee0de fix(sync): mark node dirty inside the mutation transaction (atomic ConfigDirty) (#5611)
* fix(sync): mark node dirty inside the mutation transaction

ConfigDirty is currently set by MarkNodeDirty AFTER the mutation, on a
separate DB handle outside the mutation's transaction. A crash or error
between the committed change and the mark leaves a committed config
change that never reconciles to the node (silent drift). Add
MarkNodeDirtyTx(tx, id) and call it inside each mutation's transaction so
the dirty mark commits atomically with the change.

* fix(test): initialize DB in TestResolveInboundAddress and group gorm import

Two CI failures on this branch:

- race (-shuffle=on): TestResolveInboundAddress reaches resolveInboundAddress -> configuredPublicHost -> GetSubDomain, which reads the global DB. The test never initialized one, relying on another sub-package test to do so first; under shuffle it ran first and nil-dereferenced gorm. Call initSubDB(t) so it is self-sufficient (empty DB yields an empty subDomain, so the subscriber-host fallback still holds).

- golangci goimports: gorm.io/gorm was grouped with the github.com/mhsanaei/3x-ui local imports in node_dirty_test.go. Move it into the third-party group.
2026-06-28 15:18:28 +02:00
n0ctal 2b10808fbd fix(settings): require re-2FA confirmation for sensitive setting changes (#5610)
* fix(settings): require server-side 2fa for sensitive changes

* fix(lint): group third-party imports separately from local (goimports)

golangci-lint goimports flagged setting.go and setting_security_test.go because xlzd/gotp and gorm.io/gorm were mixed into the github.com/mhsanaei/3x-ui local-prefix group. Move them into the third-party group so the local imports stand alone.
2026-06-28 15:17:15 +02:00
nima1024m 25a86b9ee2 feat(balancers): tabbed Observatory/Burst Observatory form (#5627)
* feat(balancers): tabbed Observatory/Burst form replacing raw JSON

Replace the raw JSON editor for the Observatory / Burst Observatory sections
with a proper Ant Design form, and split the Balancers page into two sub-tabs:
"Balancer Settings" (the existing table) and "Observatory".

Observers stay fully auto-managed by balancer strategy through the existing
syncObservatories logic: users edit only the tunable probe fields, the
subjectSelector is shown read-only since it is derived from the balancers, and
deleting the last balancer that needs an observer now warns in the confirm
dialog that the observer will be removed too. Overlapping selectors keep an
observer alive while any balancer still references it.

Also add the previously missing pingConfig.httpMethod field (HEAD/GET) and
translations for the new strings across all 13 locales.

* refactor(balancers): tighten httpMethod typing and align connectivity default

Address automated review feedback on the Observatory form:
- Use the ObservatoryHttpMethodSchema enum for pingConfig.httpMethod instead of
  a free-form z.string(), and drive the HTTP method Select from its options.
  Removes the previously dead enum export and the duplicate local list, and
  types the field as 'HEAD' | 'GET'.
- Align the schema's connectivity default with DEFAULT_BURST_OBSERVATORY (the
  hicloud URL) so it matches what burst observers are actually created with.

No behavior change.
2026-06-28 15:02:18 +02:00
n0ctal 5713c09980 fix(runtime): refresh cached node remotes on identity change (#5614) 2026-06-28 15:01:18 +02:00
n0ctal 7f8cbf4c4b fix(web): tighten database restore body-cap exemption (#5609) 2026-06-28 15:00:55 +02:00
MHSanaei bbfbd7eba6 Bump minimum eligible Xray version
Update Xray release filtering to only include versions at or above v26.6.27 (previously v26.4.25). Also mark `google.golang.org/protobuf` as a direct dependency in `go.mod` by removing the `// indirect` annotation.
2026-06-28 14:57:43 +02:00
MHSanaei 79069d2b64 fix(wireguard): allocate client IPs in the existing peer subnet
defaultWireguardClients always allocated new tunnel addresses from the
hardcoded 10.0.0.0/24 base, so a legacy or migrated inbound whose peers
live in a different subnet (e.g. 172.16.0.0/24) got new clients in an
unrelated, unroutable range. Derive the allocation base from the existing
peers' /24 and fall back to 10.0.0.0/24 only when there are none.
2026-06-28 14:41:24 +02:00
MHSanaei 9c8cd08f90 feat(wireguard): multi-client support
WireGuard inbounds now manage per-client peers using xray-core's native WireGuard users (AddUser/RemoveUser). Each client lives in settings.clients (canonical, like every other protocol) and is projected to peers[] only when emitting the xray config, at level 0 so the dispatcher's per-user traffic/online counters work with no extra plumbing.

Backend: internal/util/wireguard gains KeyToHex (base64 to hex for the gRPC path), PublicKeyFromPrivate and GenerateWireguardPSK; xray/api.go builds a wireguard account in AddUser with hex keys (RemoveUser already worked); client CRUD generates a keypair and allocates a unique tunnel address per client and never rotates keys on edit; an idempotent migration converts legacy settings.peers into managed clients; WireGuard is included in the raw subscription.

Frontend: WireGuard in the add-client modal with keys on the credential tab, client schema, per-client QR/link/.conf, inbound form reduced to server settings; i18n added across 13 locales.

Fix: guard the settings[clients] assertion in add/update so a legacy WireGuard inbound stored without a clients key no longer panics.
2026-06-28 00:44:38 +02:00
MHSanaei 56b0be0b6a fix(lint): use errors.Is for io.EOF comparison in sys_linux
The errorlint linter rejects direct error comparison with != because it
fails on wrapped errors. Compare via errors.Is(err, io.EOF) instead.
2026-06-27 16:38:07 +02:00
MHSanaei 9b8a0c9b17 feat(groups): reset group traffic without touching client counters
The group page shows traffic counting per group, but the only reset
available zeroed every member client's up/down counters (and their
quotas) via bulkResetTraffic. Group traffic is a derived sum of client
traffic, so zeroing the group display previously required mutating the
clients themselves.

Add a display-only baseline: ClientGroup gains reset_up/reset_down
columns (additive, handled by AutoMigrate). ResetGroupTraffic snapshots
the group's current up/down sum into the baseline, and ListGroups now
reports max(0, sum - baseline). Client counters are left untouched and
no Xray restart is triggered. A new POST /panel/api/clients/groups/
resetTraffic endpoint drives it, creating the client_groups row when the
group exists only as a derived label.

The groups page action now calls the new endpoint; confirm/success
strings updated across all 13 locales to reflect group-only semantics.
2026-06-27 16:33:36 +02:00
MHSanaei fa1a19c03c style: adopt golangci-lint v2 and resolve all findings
Add .golangci.yml (v2): the standard linters plus bodyclose, errorlint, noctx, misspell, rowserrcheck, sqlclosecheck, unconvert, usestdlibvars, with gofumpt + goimports formatters. Enable the std-error-handling exclusion preset for idiomatic Close/Remove/Setenv ignores; scope-exclude SA1019 (parser.ParseDir in tools/openapigen) and ST1005 (intentional capitalized user-facing error copy that tests assert verbatim). No inline nolint directives were introduced.

Resolve all 217 findings behavior-preserving: gofumpt/goimports formatting, explicit blank assignment on intentionally ignored errors, errors.Is/errors.As and %w wrapping, context-aware stdlib calls (CommandContext/QueryContext/NewRequestWithContext/Dialer), staticcheck simplifications, removed redundant conversions, http.StatusOK and http.MethodGet, inlined the go:fix intPtr helper, and deferred sql rows Close. Add a golangci CI job mirroring the existing Go jobs.
2026-06-27 15:42:22 +02:00
MHSanaei d12b186a69 test(sub): align identity-token test with first-link-only EMAIL
876d55f2 made {{EMAIL}}/{{USERNAME}} appear on the first sub-body link
only, but TestIdentityTokensEverywhere still asserted the email survived
on every repeat body link, breaking the go-test and race CI jobs. Update
it to assert the repeat body link drops the identity token while the
display/QR remark keeps it; the first-link case is covered by
TestEmailOnFirstLinkOnly.
2026-06-27 13:56:45 +02:00
MHSanaei 39eb5baf42 fix(inbound): convert legacy externalProxy to hosts on import
An inbound exported from a build that predated the hosts table carries
its external proxies inline in streamSettings.externalProxy. The startup
migration that converts those to host rows runs once and is gated off
afterwards, so it never sees a freshly imported inbound, leaving its
external proxies stranded in streamSettings (never surfaced as Hosts).

Extract the migration's per-inbound conversion into a shared
database.CreateHostsFromExternalProxy and run it inside the AddInbound
transaction. No-op for inbounds without externalProxy (everything the
current UI builds), so it only fires on such imports.
2026-06-27 13:50:06 +02:00
MHSanaei 876d55f274 fix(sub): show {{EMAIL}} on first sub-body link only
The remark template's {{EMAIL}}/{{USERNAME}} were repeated on every link
of a subscription. Strip them from subsequent body links like the usage
tokens, so the email appears once on the first link. Display/QR remarks
and the other client tokens are unaffected.
2026-06-27 12:42:12 +02:00
Nikan Zeyaei 1bad2fcba1 feat(backup): prefix backup filenames with date and time (#5606)
* feat(backup): add YYYY-MM-DD_ date prefix to backup filenames

Refs #5584

* feat(backup): prefix backup filenames with date and time

* fix(backup): put host before date in backup filename

Backup filenames now read {host}_{date}{ext} (e.g. panel.example.com_2026-06-27_000000.db) instead of {date}_{host}{ext}, so files group by server first then sort chronologically within each server.
2026-06-27 12:08:20 +02:00
MHSanaei 4c177f0cf1 fix(shadowsocks): send per-user Account for SS-2022 runtime AddUser
SS-2022 user updates passed shadowsocks_2022.ServerConfig (the inbound-level
config) as the gRPC user account. The core rejects it with "Unknown account
type" because only shadowsocks_2022.Account implements AsAccount(), so live
AddUser failed and renewed/reset/added users stayed inactive until the 30s
auto-restart rebuilt the inbound from the DB.

Use shadowsocks_2022.Account{Key: password} (the per-user type, matching
xray-core's own multi-user builder) so changes apply immediately without a
restart.

Fixes #5597
2026-06-27 12:00:38 +02:00
MHSanaei 439245d42b feat(inbounds): apply remark template to Export all inbound links
Export-all now renders links through the subscription engine via a new GET /panel/api/inbounds/allLinks endpoint, so the configured remark template (name-only display part) is applied per client -- matching the client info/QR pages. Previously it generated links client-side with a hardcoded inbound-email remark.

Host-aware: managed Host endpoints win over the plain link, so HOST and per-host variants render; duplicate client JSON entries are deduped by email and the list is scoped to the logged-in user.
2026-06-27 11:22:45 +02:00
Tomi lla 7a2179535a fix(settings): normalize API token timestamps (#5599)
* fix(settings): normalize API token timestamps

* refactor(api-token): share timestamp threshold

---------

Co-authored-by: Tomilla <5007859+Tomilla@users.noreply.github.com>
2026-06-27 10:30:58 +02:00
MHSanaei 6964d84742 feat(reality): add live REALITY target scanner with IP/CIDR discovery
Replace the static reality-targets list with a server-side TLS 1.3 probe that checks TLS 1.3 + HTTP/2 + X25519 + a trusted certificate.

- Single-domain validate auto-fills target and serverNames from the cert SAN
- Discovery scans an IP/CIDR without SNI to find new targets from their certificates, deduped and ranked by feasibility then latency, private-IP guarded via netsafe
- New endpoints scanRealityTarget and scanRealityTargets with RealityScanResult, plus openapigen and api-docs entries
- Add scanner strings to all 13 locales
- Replace deprecated AntD Alert message prop with title across the panel
2026-06-26 22:18:47 +02:00
MHSanaei 451263f1db feat(sidebar): add documentation link button
Add a Docs button next to the donate button in the sidebar and mobile drawer linking to https://docs.sanaei.dev/, with menu.docs translations across all 13 languages.
2026-06-26 18:55:32 +02:00
MHSanaei 8e4c368200 feat(update): allow opting into the dev channel from a stable build
The panel version button opened the GitHub releases page on a stable, up-to-date build, and the dev-channel toggle only rendered on dev builds, so there was no in-panel path from stable to dev. Drop the IsDevBuild() guard in devChannelActive (the toggle alone drives the channel now), always open the update modal instead of releases, and always render the Dev channel switch.
2026-06-26 18:01:51 +02:00
MHSanaei 522b1b64b0 fix(logger): prevent nil-deref panic in migrate/setting CLI paths
The package-level logger is nil until InitLogger runs, which only happens in runWebServer. The migrate and setting subcommands log without initializing it; PR #5520 added a logger.Info on a success path in MigrationRestoreVisionFlow, so 'x-ui migrate' segfaults on installs with a VLESS inbound needing Vision-flow restoration.

Initialize logger to a usable default at package load so no code path can nil-deref it, and set up the dual backend in migrateDb so migration steps are logged like runWebServer.

Fixes #5581
2026-06-26 11:40:13 +02:00
MHSanaei b1fb39c486 v3.4.1 2026-06-26 00:52:00 +02:00
MHSanaei 9381fa284b feat(logs): add auto-update toggle to Access Logs and Logs viewers
A checkbox in both the Xray Access Logs and panel Logs modals polls the
existing refresh every 5s while enabled, respecting the current row count,
level/filter, and Direct/Blocked/Proxy selections. The poller tears down on
close or untoggle. Adds a localized pages.index.autoUpdate key to all 13 locales.
2026-06-26 00:43:32 +02:00
MHSanaei dc6d13b58f chore: bump deps and modernize test loops
- release.yml: download-artifact v7 -> v8
- frontend: i18next 26.3.1 -> 26.3.2, qs 6.15.2 -> 6.15.3
- go.mod: consolidate indirect requires (go mod tidy)
- tests: adopt Go 1.22 range-over-int loops
2026-06-26 00:10:30 +02:00
MHSanaei e27f2490b2 feat(logs): label the Xray access-log viewer 'Access Logs' across all languages
Distinguishes the access-log modal from the panel 'Logs' viewer it shares a
title with. Adds the accessLogs key to all 13 translation files.
2026-06-25 23:59:59 +02:00
MHSanaei 293c1e44dc perf(metrics): tiered rollup history (7d at ~1.5MB) and cleaner ranges
Replace the flat 48h@2s ring buffer with a 3-tier rollup ladder (2s/1h, 1m/48h, 10m/7d). A sample feeds every tier and rolls up into progressively coarser averages, so per-metric footprint drops from ~21MB to ~1.5MB (measured, 16 system metrics) while extending the range from 48h to 7 days. aggregate() picks the finest tier covering the requested span; a pre-tier flat gob is migrated by replaying its samples through the rollup.

Tidy the dashboard ranges to a professional ladder: 2m, 1h, 3h, 6h, 12h, 24h, 2d, 7d (drop the irregular 2h/5h, the redundant 30m, and the excessive 30d). The allow-list keeps bucket 30 because the node history panel uses it.

Add an initial FreeOSMemory about 60s after boot to reclaim the startup and metric-restore peak instead of waiting for the periodic release. Cover the rollup, tier selection, round-trip, and footprint with tests.
2026-06-25 23:30:13 +02:00
MHSanaei 69ad8b76e1 perf(memory): report real RSS and cut footprint via GOGC + periodic release
The Usage card showed runtime.MemStats.Sys, a never-shrinking high-water mark of reserved address space that also counts memory already returned to the OS, so it overstated real usage (e.g. ~300 MB on an idle 1-client server). Report process RSS instead so the number matches the OS and drops as memory is freed.

Replace the auto GOMEMLIMIT that targeted ~90 percent of total system RAM (a near no-op while the heap sits far below the limit, and a GC-thrash risk on small/shared VPS per go.dev/doc/gc-guide) with: a lower default GOGC (XUI_GOGC, default 75), a periodic debug.FreeOSMemory job (XUI_MEMORY_RELEASE_INTERVAL, default 10m, 0 disables), and a soft limit applied only from an explicit budget (GOMEMLIMIT, XUI_MEMORY_LIMIT, or a real cgroup cap at 90 percent).
2026-06-25 22:16:38 +02:00
MHSanaei b32837e523 fix(node): import per-client traffic history on first sync of a node-hosted inbound
On the first sync of a node-hosted inbound, the central inbound adopted the
node's full lifetime counter but every client_traffics row was seeded at 0 (with
the delta baseline set to the node's current counter). So adding or migrating a
node that already had traffic kept the inbound total correct while every
per-client counter restarted from zero, and the master under-reported per-client
usage by the entire pre-attach history.

Seed a new client_traffics row from the node counter only when the inbound was
created during the same sync (a genuine node-add / inbound re-import); a client
reappearing under a pre-existing inbound still seeds 0, preserving the ghost
protection in TestGhostData_NoPhantomTraffic. The seed is additionally gated on
the delete tombstone so a just-deleted client cannot be resurrected if its
inbound is recreated. Baseline still equals the seeded value, so the next sync
delta is 0 and no traffic is double counted.

Adds TestNodeAdd_ImportsClientHistoryWithNewInbound and
TestNodeAdd_TombstonedClientNotResurrected.
2026-06-25 21:19:27 +02:00
MHSanaei e64e998194 feat(clients): add bulk enable/disable and move selection actions into More menu
Add bulkEnable/bulkDisable named endpoints backed by a shared internal impl, and consolidate the per-selection actions (attach, detach, add to group, ungroup, enable, disable, adjust, sub links) into the clients table's More dropdown so the toolbar only shows the selection count and delete. Translate the new enable/disable confirm dialogs and toasts across all 13 locales.
2026-06-25 19:21:42 +02:00
MHSanaei a4be5a0deb fix(sub): recover {{TRAFFIC_USED}} for clients with orphaned traffic rows
statsForClient resolved usage only through paths keyed by client_traffics.inbound_id (preloaded ClientStats + the statsByEmail index). That id is written once by AddClientStat and never updated, so an inbound delete+recreate orphans the row from every loaded inbound, both paths miss, and the zero-traffic placeholder makes {{TRAFFIC_USED}} read 0.00B for pre-existing clients while the sub-info header (AggregateTrafficByEmails, email-keyed) stays correct.

Add a last-resort lookup by the globally-unique email, cached into statsByEmail for the request. Closes #5567.
2026-06-25 18:18:47 +02:00
MHSanaei e4b881e58a feat(panel): surface dev-build version in UI, bot, and CLI
A dev build now shows its `dev+<commit>` identity instead of a misleading stable-looking version in the sidebar badge, dashboard card, update modal, Telegram status report, startup log, and `x-ui -v`. Adds a shared formatPanelVersion helper (single v prefix; dev labels shown verbatim) and fixes the mobile-tag double-v.

Renames the version getters for clarity: config.GetVersion to GetBaseVersion (raw embedded version), config.GetReportedVersion to GetPanelVersion (advertised/displayed), and the xray process GetVersion to GetXrayVersion.
2026-06-25 02:36:41 +02:00
MHSanaei bcd1358032 fix(nodes): report dev builds as dev+<commit> so updated nodes aren't flagged stale
A node's status reported config.GetVersion() (3.4.0) even on a dev build, so the master compared it against its own dev latestVersion (dev+<sha>) and every node showed 'update available'. Nodes on a dev build now report dev+<short commit>, matching the master's format, so a node on the current dev commit compares as up to date.
2026-06-25 00:46:43 +02:00
MHSanaei e8878b71a4 feat(nodes): add Dev channel option to node panel updates
The node update confirm dialog now offers a 'Dev channel (latest commit)' choice. The dev flag threads master -> nodes/updatePanel -> UpdatePanels -> remote.UpdatePanel -> the node's updatePanel endpoint, which calls StartUpdateChannel(dev) to install the rolling dev-latest build. With no dev flag the node keeps following its own channel setting.
2026-06-25 00:29:03 +02:00
MHSanaei 11c5b53fac feat(sub): add PROTOCOL, TRANSPORT, SECURITY remark template variables 2026-06-25 00:12:25 +02:00
MHSanaei 896016f7f6 fix(web): remove deleted multi-inbound client from runtime regardless of shared email (#5543)
DelInboundClientByEmail gated the runtime RemoveUser/DeleteUser (and its
push-plan resolution) on !emailShared. But Xray users are keyed by inbound
tag + email, so a client attached to two inbounds left its user live in the
running Xray of every inbound where the email was still shared by a sibling
inbound, until an Xray restart.

Decouple the per-inbound runtime removal from emailShared; keep emailShared
only for preserving the shared email-keyed client_traffics/IP rows.
2026-06-24 22:43:18 +02:00
Rick Sanchez fe025e8af3 feat(xray): add tunnel health monitor (#5480)
* feat(xray): add tunnel health monitor

* fix(tunnelmonitor): reuse netproxy client and init logger in tests

Replace the duplicated newHTTPClient/dialContextWithProxy with netproxy.NewHTTPClient, which centralises the http/https/socks5 handling and avoids the dial-goroutine connection leak on context cancellation. Cap failures at the threshold during cooldown so the counter stays a true consecutive-failure count. Add TestMain to initialise the logger and fix the nil-pointer panic in the success-after-failure path.

* fix(tunnelmonitor): observable recovery, signal headroom, and hardening

Address the remaining review findings on the tunnel health monitor:

- Recovery is now synchronous and observable: the callback calls
  server.RestartXray() directly and returns its error instead of just
  enqueuing SIGUSR1, so a failed restart no longer masks as success and
  arms the cooldown while the tunnel is still down.
- Give the OS signal channel headroom (buffer 8) so producers cannot
  starve a SIGTERM/SIGINT out of the single slot.
- Warn at startup when the monitor is enabled without a proxy, since the
  probe then measures host connectivity rather than the xray tunnel.
- Cap failures at the threshold in the nil-recover branch too, matching
  the cooldown cap.
- Document the XUI_TUNNEL_HEALTH_* vars in .env.example and the README.
- Add tests for status-code classification, Normalize bounds, New proxy
  scheme errors, the recovery-error and nil-recover paths, the cooldown
  cap, and Run context cancellation (coverage 90%).

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-06-24 22:01:37 +02:00
FunLay123 3ba43bd86d feat(web): vless encryption new modes (#5517)
* feat(web): add vless encryption new modes

* feat(web): add translations for vless encryption modes

* feat(translation): bring "vlessAuthX25519" and "vlessAuthMlkem768" to general form
2026-06-24 21:22:42 +02:00
w3struk ae9bbdf267 fix(web): serve panel SPA routes from NoRoute (#5536)
* fix(web): serve panel SPA routes from NoRoute

Return the React shell for authenticated panel document routes that are not explicitly registered in Gin, such as /panel/hosts. Keep API, CSRF, static-file, method, and Accept exclusions so API misses remain 404 and auth semantics stay unchanged.

* fix(web): remove unreachable panel path guard

The panel path is always built by appending /panel, so it can never be empty.
Remove the redundant fallback branch without changing SPA routing behavior.

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* fix(web): allowlist static-asset extensions in SPA fallback

The blanket path.Ext check rejected any panel route whose last segment contained a dot, which would reintroduce the refresh 404 for a future client route carrying a dotted parameter (version, domain, or email-like value). Restrict the static-asset exclusion to a known, case-insensitive extension allowlist and add predicate regression cases.
2026-06-24 21:19:12 +02:00
MHSanaei 1d1128cf94 fix(update): read setUpdateChannel body as form field, not JSON
The panel's axios layer posts application/x-www-form-urlencoded, so the dev-channel toggle sent dev=true and ShouldBindJSON failed with 'invalid character d'. Parse c.PostForm("dev") to match the codebase's form-encoded POST convention.
2026-06-24 18:24:54 +02:00
MHSanaei aad2b3eb1e feat(update): add rolling dev update channel for per-commit builds
Adds an opt-in Dev channel so panels running CI per-commit builds can self-update to the latest commit, mirroring the stable online-update flow.

CI publishes/overwrites a single fixed-tag pre-release (dev-latest), force-moved to the newest main commit and marked --latest=false so releases/latest stays the stable tag. Builds stamp the short commit via -ldflags; the panel compares the running commit to the dev release commit to detect an update, and update.sh honors XUI_UPDATE_TAG to install from that tag. Linux/systemd only.
2026-06-24 18:11:22 +02:00