Commit Graph

127 Commits

Author SHA1 Message Date
PathGao 863473783d fix(frontend): preserve cancellation and reject invalid query data (#6143)
* fix(frontend): preserve request cancellation and schema failures

* fix(frontend): limit schema failures to query boundaries

* fix(frontend): keep invalid settings recoverable

Keep settings payload validation tolerant so values accepted by the backend remain editable, while paged clients still fail closed. Add an AbortSignal.any fallback and make timeout tests event-driven.

---------

Co-authored-by: PathGao <gaoyanbo@gaoyanbodeMacBook-Air.local>
2026-07-29 20:51:40 +02:00
PathGao 87ebcc7a6f feat(ui): tag settings that sit at their shipped default value (#6128)
* feat(ui): tag settings that sit at their shipped default value

A field showing 2096 reads identically whether the install never set
it or the operator saved 2096 — newcomers cannot tell which knobs
they have touched, and after the cleared-port fix (#6121) a port can
never visually return to an unset state. Add a small grey tag next to
numeric settings whose current value equals the shipped default.

The tag deliberately compares values, not provenance: a stored 2096
and a fallback 2096 behave identically, so they read identically, and
the tag reacts live as the user types.

The backing endpoint filters defaultValueMap through the AllSetting
field set, so per-install material (secret, panelGuid, node mTLS
keys) and redacted credential fields never leave the server; a test
pins that.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ui): keep the default tag out of the accessible name, pin the defaults contract

From review, in order of severity:

The badge was rendered inside the element whose id feeds the
control's aria-labelledby, so a visible tag changed every field's
accessible name ('Panel Port Default'). The title text now carries
the id on its own span and the badge sits beside it.

The same default values live in three places: the Go defaultValueMap,
the frontend AllSetting class, and the tag's verdict. A new contract
test parses the Go map's string literals and asserts every shared key
matches the AllSetting class default through the tag's own
comparison — and on first run it caught two real drifts
(tgEnabledEvents / smtpEnabledEvents defaulted to '' in the class but
'login.attempt,cpu.high' on the server), now aligned.

matchesFactoryDefault no longer coerces blank or unparsable defaults
(Number('') is 0; a junk string is not false). The Go tests are
table-driven t.Run subtests and gained the structural invariant:
every returned key is an AllSetting json tag outside the credential
deny-list. The service doc comment now describes the projection
mechanism instead of overclaiming; the i18n key is re-indented and
placed at the head of pages.settings in all 13 locales; the fetch
falls back to {} when validation fails; and smtpPort gets the tag so
plain numeric settings-list fields are covered uniformly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 08:03:21 +02:00
PathGao ea35884390 chore(i18n): delete 230 dead translation keys and guard against new ones (#6132)
* chore(i18n): delete 230 dead translation keys and guard against new ones

The 13 locale files carried 230 keys (11% of the set) that nothing in
the frontend or Go sources references — leftovers of renamed features
(the email notifier reuses tgbot.messages.* for subjects, the old
email.subject*/title* set was orphaned; likewise menu.*, the clients
bulk-copy strings, and the secAlert* family). Nothing detected this:
a missing key falls back to en-US and an unused key fails nothing.

A new test now fails the build when an en-US key has no reference in
frontend/src or internal Go sources (dynamic keys are covered by
harvesting concatenation and template-literal prefixes), and pins
that all 13 locales carry exactly the en-US key set, so parity drift
surfaces at test time instead of as a silent fallback.

Each locale shrinks by the same 230 keys; net -2,900 lines across
the translation set.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(i18n): restore the 29 live remarkVars keys, match whole tokens, unmask 9 more

From review: the template-literal harvester required the prefix to end
on a dot, so pages.hosts.remarkVars.desc${token} harvested nothing
and all 29 desc* tooltip keys were wrongly deleted — and the guard
shared the flawed logic, so CI stayed green while the Hosts page
would have shown raw key names in 13 languages. Restored from the
parent commit; the harvester now requires at least one dot but not a
trailing one.

Also from review: references are matched as whole dotted tokens
instead of substrings (a dead key can no longer hide behind a longer
sibling — that unmasked 9 more genuinely dead keys, each verified by
hand before deletion), and the test excludes itself from the scan so
its own prose cannot whitelist a subtree.

Net: -210 keys per locale instead of the previous -230.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 07:59:25 +02:00
Shichao Song 17e6b5a460 inbounds: allow custom monthly traffic reset days (#6071) 2026-07-28 23:27:09 +02:00
PathGao ca6955d88b feat(ui): validate the REALITY client version range at save time (#6126)
* feat(ui): validate the REALITY client version range at save time

The impossible range from PR #6125 — a max below the effective minimum
— could still be saved; the tooltip only helps a user who hovers it.
Add save-time validation mirroring xray-core's parser (up to three
dot-separated parts, each 0-255) on both fields, plus a cross-field
check that a non-empty max is not below a non-empty min. Errors are
field-level i18n keys following the REALITY target precedent, so the
modal stays open and points at the offending field instead of storing
a config that rejects every client.

A malformed min is reported by its own field and skipped by the max
comparison, so the user sees one precise error per field.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ui): reject untrimmed client versions and revalidate max on min edits

From review: the validators trimmed but the save path ships the value
verbatim, and xray-core's part parser accepts no surrounding
whitespace — so a green form could still save a config the core
refuses to load. Reject any value that differs from its trimmed form.

Also revalidate the max field after a min edit when max already
shows an error, so correcting the min clears the stale cross-field
message without waiting for the next submit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 23:04:03 +02:00
PathGao 411271b454 refactor(ui): share one onNumber handler for numeric setting inputs (#6127)
* refactor(ui): share one onNumber handler for numeric setting inputs

The Number(v) || 0 idiom in InputNumber onChange handlers is the root
pattern behind the cleared-port bug (#6121): AntD reports a cleared
field as null, and || 0 turns that into a stored zero or a min-clamp.
The port fields got an inline null-guard; the other sixteen numeric
settings kept the idiom, so every new field is a chance to
reintroduce the bug.

Extract the guard into onNumber(apply): null, empty and NaN change
events are ignored so a cleared field snaps back to its stored value
on blur, and numeric events pass through unchanged. Convert all
sixteen sites in the settings and xray pages. Two sites keep their
deliberate different semantics: smtpPort falls back to 587 on clear,
and the Telegram notify interval clamps through Math.max.

For the non-port fields this changes clearing from storing 0 to
keeping the stored value; zero remains reachable by typing it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(ui): fold the remaining hand-rolled numeric guards into onNumber

From review: ObservatorySettingsTab's sampling field hand-rolled the
same ignore-null semantic and smtpPort kept a fallback-to-587 on
clear that nothing documents as intentional and that silently
overwrites a configured non-standard port — both now go through the
shared helper, leaving the Telegram interval clamp as the one
deliberate exception.

Also from review: narrow the helper to numbers only (no stringMode
input exists in the repo, and the string branch codified a guarantee
the number-typed callback cannot honour), soften the docblock to
describe behavior rather than promise prevention, add a GeneralTab
component test covering the clear-vs-typed-zero semantics, and assert
the blur snap-back in both settings tests so a display/state desync
cannot ship unnoticed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 23:03:13 +02:00
Maksim Alekseev 041476a317 feat(sub): Add XHTTP session field compatibility in share links and subscriptions (#5929)
*  Add sessionKey and sessionPlacement compatability for previous clients

*  Add sessionKey and sessionPlacement compatability for previous clients on backend
2026-07-28 22:15:28 +02:00
Mr. Nickson ff954ec48c fix: stop deleting client_traffics for detached-but-alive clients (#6110)
* fix: stop deleting client_traffics for detached-but-alive clients

MigrationRemoveOrphanedTraffics keyed "orphaned" off presence in some
inbound's settings.clients[] JSON, a definition that predates #4469's
standalone clients table. ClientService.Detach intentionally keeps a
client's traffic row when it drops its last inbound attachment (so it
can be re-attached later without losing stats/expiry), but that client
has no entry in any inbound's JSON anymore - so every x-ui migrate run
or backup restore deleted its traffic row anyway, even though the
client itself was untouched and still listed. Scope the query to the
clients table instead, which is the function's actual intent.

Separately, frontend/src/hooks/useClients.ts recomputed the clients
summary from the client_stats WS snapshot as soon as it arrived, even
when that snapshot held fewer rows than the server's own total (e.g.
exactly the gap above, or any other client with no client_traffics
row). The recompute can only bucket the clients it was given, so the
missing ones silently fell out of every bucket while the headline
total still counted them - the Ended/Disabled cards read 0 and their
hover lists were empty even though the table below listed those rows,
leaving the Filter drawer as the only way to reach them. Extracted the
decision into pickClientsSummary and added the guard: fall back to the
server summary (built from the clients table, always sums to total)
whenever the snapshot doesn't cover every client.

Fixes #6102.

* fix: union both keep-sets instead of replacing (review feedback)

Address the automated review on this PR: switching
MigrationRemoveOrphanedTraffics to key solely off the clients table
traded the original bug for a worse one. The one-shot ClientsTable
seeder (internal/database/db.go) skips a client it fails to unmarshal
and never retries, so a client still live in an inbound's
settings.clients[] JSON can have no clients row at all - the new
predicate deleted its traffic row too, and an empty clients table
would have emptied client_traffics outright. Union both keep-sets: a
row survives if it's referenced by either the clients table or any
inbound's JSON, and is removed only when it's in neither.

Log the delete's outcome instead of discarding it silently, since a
whole-table wipe would otherwise leave no trace.

Rewrote the migration test as a table of all four combinations, driven
through real ClientService calls (SyncInbound, Detach) rather than
hand-built rows wherever a real path produces the state, so it tracks
actual behavior instead of an assumption about it. Added the missing
case the review flagged: a client live in JSON only, with no clients
row, must survive.

Also stripped the // comments this PR had added - CLAUDE.md states
committed Go/TS carries none, which the review separately flagged.
2026-07-28 22:14:01 +02:00
H-TTTTT 8f49327efb feat(sub): allow identity tokens on every subscription link (#5935)
Keep usage tokens first-link-only while adding an opt-in setting for repeating EMAIL and USERNAME in subscription-body remarks.

Co-authored-by: x06579 <x06579@ai-dashboard>
2026-07-28 22:12:52 +02:00
PathGao 604986598f fix(ui): commit date-picker selections immediately instead of on confirm (#6122)
* fix(ui): commit date-picker selections immediately instead of on confirm

With showTime, Ant Design's DatePicker stages a clicked date until the
OK button confirms it. Closing the dropdown any other way - clicking
elsewhere in the form or hitting Create/Save directly - discarded the
staged date without a hint, so an inbound saved this way ended up with
expiryTime=0 (never expires). The Now shortcut commits in one click,
which made it look like only the current time could ever be set.

Drop the confirm step (needConfirm=false) and propagate every calendar
selection through onCalendarChange, so the picked date reaches the form
state the moment it is clicked and can no longer be lost to a race with
the submit button.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(ui): pin calendar clicks committing without a confirm press

A clicked day cell must reach onChange with the exact selected
timestamp while the dropdown is still open, and the footer must not
render a confirm button. Pins the needConfirm-free behavior so a picker
dependency bump cannot silently bring the staged-value discard back.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 20:57:46 +02:00
PathGao 579acbc669 fix(settings): keep the stored port when a port field is cleared (#6121)
* fix(settings): keep the stored port when a port field is cleared

Clearing the panel-port, subscription-port or LDAP-port InputNumber
fired onChange(null), which the handlers coerced to 0; on blur Ant
Design clamped the empty field to min=1 and the next save silently
persisted port 1. For subPort that breaks the generated subscription
links; for webPort it moves the panel itself to port 1 and locks the
admin out until the port is fixed via the x-ui CLI.

Ignore null changes so clearing a port field snaps back to the last
valid value instead of committing a bogus port.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(settings): pin cleared port fields to the stored value

Clearing the subscription-port field must not reach updateSetting at
all, while typed ports still pass through unchanged. Pins the fix so a
handler refactor cannot silently reintroduce the clamped port 1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 20:10:21 +02:00
Sanaei dc6a16019e fix(xray): reject configs xray-core refuses, and check the fixtures against it
The frontend's golden fixtures are the panel's model of an xray config, but
nothing ever asked xray-core whether it would accept them: the snapshots only
prove the Zod schemas agree with themselves. Building every fixture through the
same config builders the panel hands its config to — conf.InboundDetourConfig
for the full-config and AddInbound paths, conf.RouterConfig for
ApplyRoutingConfig, conf.DNSConfig for the dns section — found seven the core
refuses, three of them reachable from the panel's own UI. A refusal is not
scoped to one inbound: the config fails to load and every inbound stays down.

Hysteria: xray-core builds version 2 only, in both the protocol settings and
the transport settings, but the inbound settings schema accepted any version
from 1 up and its comment claimed upstream still supported v1. Both fixtures
carried version 1. The schema now pins 2, GenXrayInboundConfig heals stored
rows on the way out the way it already heals shadowsocks ciphers and wireguard
peers, and the share link drops the dead hysteria:// scheme — the subscription
server already emitted hysteria2:// for the same inbound.

XHTTP uplinkDataPlacement: both transport forms offered "query", which the core
has never accepted for that field (auto and body always, cookie and header in
packet-up mode). Replaced with auto, which was missing, and the default label
now names auto rather than body.

FinalMask items: switching an item to the rand-driven array kind wrote
packet:[] next to the rand. xray-core counts an empty array as a packet and
every item kind is exclusive, so noise answers "len(item.Packet) > 0 &&
item.Rand.To > 0" and header-custom "exactly one item kind must be set". The
editor now clears the packet, and GetXrayConfig strips the residue from rows
already saved with it.

The remaining four were stale fixtures: an xmc mask still on the usernames
shape v26.7.28 replaced with profiles, a fragment mask with no length, and
header-custom and noise items passing an array to the string packet kind — all
shapes the panel's own editors cannot produce.

golden_fixtures_xray_test.go keeps this from drifting again: every fixture in
every category is built through xray-core on each run, with a self-signed pair
standing in for the deployment certificate paths, so the next core bump reports
which fixture it broke.
2026-07-28 14:43:55 +02:00
Sanaei 7f7b7e16a4 feat(xray): update xray-core to v26.7.28 and adapt panel
Bump xtls/xray-core to 5ca6f4b7d4dc (v26.7.28) and move the three binary
pins (DockerInit.sh, the Linux and Windows URLs in release.yml) in lockstep
so the in-process conf.Build() validation and the child binary agree.

XMC finalmask (#6487) is the breaking change. The mask's `usernames` string
list is gone, replaced by a required `profiles` array whose entries each need
a 3-16 character [A-Za-z0-9_] username, a parseable UUID and both Mojang
texture fields; the "default to Dream when empty" fallback was removed, so an
xmc mask saved by an older panel now fails to build and takes the whole
config down with it rather than degrading one inbound.

The textures are a signed blob only Mojang's session server can issue, so a
legacy username cannot be upgraded automatically. The panel now:

- rejects an incomplete xmc mask at save time (AddInbound/UpdateInbound),
  pointing at the specific field that is missing;
- drops only the offending mask when generating the core config, for rows
  that never went through the form (upgrade, node sync, restored backup,
  direct DB edit), warning which inbound lost its obfuscation instead of
  leaving every inbound offline;
- carries legacy usernames into profile stubs in the finalmask form so the
  operator keeps their player names and sees exactly what still needs
  filling in, and edits profiles through a list editor.

No destructive DB migration: unlike the removed shadowsocks ciphers there is
no valid replacement to rewrite to, and dropping the mask from stored rows
would discard the operator's hostname and password for config they can still
repair. The generation-time strip already prevents the startup failure.

Also track the core's xmux maxConnections fallback, lowered from 6 to 3 for
anti-TSPU, in the fresh-XMUX seed so a new panel config matches what the core
would pick on its own.

TUN gained a `desc` key and random utunN naming, but the Go validator no
longer accepts TUN inbounds and the panel only renders legacy saved rows, so
nothing there needs adapting. The remaining commits are REALITY log-warning
wording, gRPC/XHTTP localAddr accuracy and a routing tweak, none of which
change the JSON config surface.

Tests cross-check the panel's profile predicate against conf.XMCProfile.Build()
so a future core release that tightens or relaxes the rules fails loudly
rather than silently emitting configs the core refuses to start on.
2026-07-28 13:14:06 +02:00
Sanaei edb487a005 chore(deps): migrate to react-router 8 and refresh frontend dependencies
react-router-dom 7 is superseded by react-router 8, which folds the DOM
bindings back into the core package. RouterProvider now comes from
`react-router/dom`, while the hooks and `createBrowserRouter` move to
`react-router`. Updates the nine importing modules and the router line in
docs/architecture.md to match.

Also refreshes antd, react-i18next, storybook, eslint, lint-staged and
playwright to current patch/minor releases, and restores alphabetical order
in devDependencies for the @vitest/browser-playwright and playwright entries.

Bumps brace-expansion to 5.0.8, the only release outside the affected range
of GHSA-mh99-v99m-4gvg (unbounded expansion length causing an OOM crash).
`npm audit fix` could not apply this on its own: the lockfile pinned 5.0.7
and npm will not re-resolve a transitive-only dependency in place, so the
entry was updated directly and reinstalled.
2026-07-25 02:07:15 +02:00
Sanaei 892c06c8bc Bug-label issue sweep: 16 fixes (#6083)
* fix(xray): block private-range egress in default freedom finalRules (#6037)

With domainStrategy AsIs the router never resolves domains, so a domain
with a private A record (e.g. 127-0-0-1.nip.io) sails past the
geoip:private routing block and freedom's allow-all finalRules let it
reach loopback services such as the xray gRPC API and metrics listener.

Prepend a block rule for geoip:private to the default template and add
the FreedomFinalRulesPrivateEgressBlock seeder so existing installs
still carrying the stock allow-only (or legacy private-only-allow)
finalRules are upgraded in place; customized rules are left untouched.

* fix(sub): version-gate unencrypted-outbound drops in outbound subscriptions (#6033)

Commit d38c912d taught CheckXrayConfig to keep unencrypted vless/trojan
outbounds when the running core predates the v26.7.11 rejection, but
filterOutboundsRejectedByCore still consulted the embedded validator
unconditionally, so outbound subscriptions kept silently dropping those
outbounds even on downgraded cores.

Apply the same shouldSkipLegacyUnencryptedOutboundRejection gate when
filtering fetched subscription outbounds.

* fix(xray): resolve geodata assets before building outbound configs (#5928)

Saving routing or template settings validates each outbound through the
embedded config loader, and a freedom outbound whose finalRules
reference geoip:private opens geoip.dat during that build. Unlike
ApplyRoutingConfig, ValidateOutboundConfig and AddOutbound never pointed
the in-process loader at the bin folder, so xray-core resolved the file
relative to the panel executable and saving failed with
'stat /usr/local/x-ui/geoip.dat: no such file or directory'.

Call ensureXrayAssetLocation before both build paths.

* fix(api): use a real i18n key in the client get handler (#5911)

The client fetch endpoint localized its error prefix with the bare key
'get', which exists in no translation file, so every lookup of a deleted
client's email logged 'message "get" not found in language ...' noise
alongside the expected record-not-found warning. Reuse the same
pages.inbounds.toasts.obtain key the sibling list handler uses.

* fix(sub): carry host record Host header and path into Clash/JSON output (#5944)

The raw-link path overrides the host/path share params from a Host
record via applyEndpointHostPath, but the Clash and JSON renderers read
the transport settings object, which applyHostStreamOverrides never
touched — so a Host record's WebSocket Host header (and path) silently
vanished from Clash/Mihomo and JSON subscriptions whenever the inbound's
own ws settings left them empty.

Inject hostHeader/path into the ws/httpupgrade/xhttp settings of the
per-host stream, mirroring the raw-link override.

* fix(metrics): accept Unicode outbound tags in the observatory (#5972)

The observatory validator whitelisted ASCII word characters, so any
outbound whose tag carries a flag emoji or other non-ASCII text was
silently dropped from the metrics snapshot, delay history, and health
notifications. The history store is an in-process map, so the strict
charset bought nothing.

Validate tags as non-empty, bounded, control-character-free UTF-8
instead, keeping spaces and emoji while still rejecting garbage input on
the query path.

* fix(database): default sqlite to WAL to stop background-job lock storms (#6057, #6068)

With journal_mode=DELETE every write serializes the whole database and
blocks readers, so under normal multi-job load (traffic sampling, node
sync, mtproto reconcile) transactions regularly outwaited the 10s busy
timeout and jobs failed with 'database is locked'.

Move to WAL by default: readers no longer block writers and vice versa,
which removes the observed contention while writer-writer access still
serializes safely. The single-file-at-rest property is preserved where
it matters — Checkpoint() now issues wal_checkpoint(TRUNCATE), so panel
and Telegram backups read a complete main file, and sqlite folds the WAL
back into the db on clean shutdown. XUI_DB_JOURNAL_MODE=DELETE restores
the previous behavior for setups that copy the live file directly.

* fix(database): strip finalmask.tcp from REALITY inbounds on upgrade (#6038)

validateFinalMaskRealityCombo blocks saving finalmask.tcp together with
REALITY because that combination crashes Xray-core 26.7.11 on the first
connection (XTLS/Xray-core#6453), but it only runs on add/update. An
inbound saved before the validator existed sailed through the upgrade
untouched and took the core down at boot.

Add the InboundRealityFinalmaskTcpStrip seeder: one-time scan that
removes finalmask.tcp from REALITY inbounds (other finalmask transports
survive), so upgraded panels start cleanly.

* fix(xray): stop deleting hand-written direct routing rules on save (#6056)

The DNS allow-rule sync recognized 'its' rules purely by shape
(type=field, ip, port, outboundTag=direct, nothing else), so any manual
rule of that shape — e.g. routing a LAN CIDR to a NAS port over direct —
was silently stripped on every settings save.

Mark managed rules with ruleTag=xui-dns-allow (round-tripped untouched
by both xray-core and the Routing tab editor) and only strip rules that
carry the tag. Untagged legacy managed rules are adopted when their
exact ip-set/port matches a currently configured private DNS endpoint;
anything else is left alone. A stale pre-tag managed rule whose DNS
server was removed now lingers until deleted manually — the safe side of
the trade against eating user rules.

* fix(clients): resolve email lookups through client_inbounds after a move (#6059)

GetClientInboundByEmail trusted the client_traffics.inbound_id pointer
whenever that inbound still existed, but a client moved between inbounds
leaves the row pointing at its old (still existing) inbound. The lookup
then searched the wrong inbound's clients and failed with 'Client Not
Found In Inbound For Email', which broke the Telegram bot's link and QR
generation for moved clients.

When the pointed-at inbound no longer carries the email, re-resolve
through the authoritative client_inbounds link to the inbound that
actually hosts the client.

* fix(nodes): replicate inbound fallbacks to nodes (#5963)

Fallbacks live in the inbound_fallbacks table and were only merged into
settings by the master's local config builder; the runtime inbound
pushed to nodes rebuilt settings without them, and the reconcile job
additionally fingerprinted the raw DB row, so fallback edits neither
reached nodes nor triggered a re-push.

Inject settings.fallbacks in buildRuntimeInboundForAPI (mirroring the
local builder, gated on inboundCanHostFallbacks) and make ReconcileNode
push and fingerprint that same runtime-built payload, aligning the
interactive and reconcile paths.

* fix(database): survive PostgreSQL outages without a runaway restart loop (#6023)

A PostgreSQL that was down or still starting made InitDB fail instantly;
the process exited with a generic startup error and systemd restarted it
every 5s forever, flooding the journal.

Retry the initial postgres connection with backoff (~70s total) and log
the real driver error on every attempt, and cap the systemd units with
StartLimitIntervalSec/StartLimitBurst so a persistently unreachable
database stops the unit instead of looping indefinitely.

* fix(xray): force a full restart when REALITY stream settings change (#6010)

A changed inbound is normally hot-swapped over gRPC as RemoveInbound +
AddInbound, but xray-core does not reliably rebuild a REALITY listener's
authenticator on a runtime re-add — key or shortId edits appeared
applied yet clients kept authenticating against the old parameters until
someone restarted the core manually, on nodes in particular.

Treat any non-client change to an inbound that uses (or starts using)
REALITY as not hot-appliable so the panel restarts the core instead.
Client-only edits on REALITY inbounds keep flowing through the per-user
AlterInbound path and still avoid restarts.

* feat(sub): allow insecure TLS for outbound subscription fetches (#6067)

An outbound subscription served over HTTPS with a self-signed or
private-CA certificate could never be fetched: the fetch client had no
TLS options, so refreshes died with 'x509: certificate signed by unknown
authority' and there was nothing the admin could toggle.

Add a per-subscription 'Allow insecure' switch (persisted as
allow_insecure, default off) that sets InsecureSkipVerify on the fetch
transport — including when the fetch is routed through the panel egress
proxy. The SSRF-guarded dialer and redirect re-validation stay in force
either way.

* fix(reality): send PROXY protocol header in the target scanner when xver is set (#6082)

The REALITY target scanner always probed with a plain TLS handshake, so
a target fronted by an Nginx listener that requires the PROXY protocol
(matching the inbound's xver>=1) reset the connection and the panel
reported a false 'TLS handshake failed'.

Thread the inbound's xver into the scan request and, when it is >=1,
lead with the matching PROXY protocol header (v1 for xver 1, binary v2
for xver 2) built from the dialed connection's own address pair. Batch
candidate scans against public sites are unaffected (xver 0).

* fix(frontend): default sockopt fields when editing a stored inbound (#5956)

Opening an existing inbound ran rawInboundToFormValues over the raw DB
row, and only xhttpSettings was re-parsed through its Zod schema to fill
defaults. A sockopt object saved before the TProxy control existed has
no tproxy key, so the Select rendered blank; picking Off didn't help
because the wire normalizer drops tproxy=off, recreating the missing
key on the next edit.

Re-parse streamSettings.sockopt through SockoptStreamSettingsSchema on
load, mirroring the xhttpSettings handling, so absent keys (tproxy,
tcpcongestion, …) get their schema defaults every time the form opens.
2026-07-23 15:34:42 +02:00
Blazethan 9e117bbdd3 fix(clients): keep VLESS xtls-rprx-vision flow when inbound options reload (#5971)
The client form cleared the `flow` field whenever `showFlow` was false, but
`showFlow` is derived from the inbound options list, which is transiently empty
while the options query (re)loads (`inboundOptionsQuery.data ?? []`). During
that window `showFlow` is a false negative, so the effect silently dropped a
valid `xtls-rprx-vision` flow the user had picked for a Reality/TLS inbound and
never restored it — the client was then saved with an empty flow and could not
connect with XTLS Vision.

Guard the clear so it only runs once the inbound options are actually available.

Adds a regression test that reproduces the drop across an options reload.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 15:58:25 +02:00
H-TTTTT c80e5e276b fix(wireguard): preserve all Allowed IPs in share link, .conf, and subscription (#6051)
The WireGuard client address is stored end-to-end as a string slice
(model.AllowedIPs []string, comma-split/joined in the DB), and the UI
hint documents comma-separated multi-value input. Three export paths
only read index [0], silently dropping every address after the first
(e.g. a dual-stack IPv4+IPv6 client never receives its second address):

- genWireguardLink share link address param (inbound-link.ts)
- genWireguardConfig .conf Address line (inbound-link.ts)
- SubService.genWireguardLink raw subscription address (service.go)

The sibling JSON and Clash subscriptions already emit the full slice,
and WireguardPeerFromClient passes the whole slice into the real Xray
peer config, so only the exported text was wrong. Join all entries,
matching the model's strings.Join(..., ",") convention for the link
params and the ', '-joined AllowedIPs line already used a few lines
below in genWireguardConfig.

Fixes #6031
2026-07-21 15:50:16 +02:00
H-TTTTT 22ff07b24b fix(warp): preserve outbound customization when rotating IP (#6052)
changeIp() rebuilt the warp outbound from a fixed default template via
collectConfig and replaced the whole in-memory object through
onResetOutbound, so only secretKey/address/reserved and the first peer's
publicKey/endpoint survived — a user's custom mtu, domainStrategy,
noKernelTun, the first peer's keepAlive / allowedIPs / preSharedKey, and
any extra peers were silently dropped from the editor. Because the page
keeps rendering that in-memory copy and a later page Save persists it to
/panel/api/xray/update, the loss could become permanent, even though the
backend ChangeWarpIP already patches only the rotated fields and leaves
everything else intact in the stored template.

Mirror the server-side UpdateWarpXraySetting: merge only the rotated
fields (secretKey, address, reserved, first peer publicKey/endpoint)
into the existing outbound, preserving the rest. register() (first-time
creation, nothing to preserve) still uses collectConfig's full build.

Extracted as a pure module-level mergeWarpRotation so it is unit-testable
without rendering the modal.

Fixes #6019
2026-07-21 15:50:00 +02:00
H-TTTTT a9d5d9afdb fix(balancer): pin loopback routing rules ahead of general rules (#6054)
* fix(balancer): pin loopback routing rules ahead of general rules

ensureBalancerLoopback appended a balancer's fallback loopback rule
({ inboundTag: ['_bl_<target>'], balancerTag: <target> }) to the END of
routing.rules via Array.push, and only updated balancerTag in place when
the rule already existed. Xray evaluates routing rules top-to-bottom,
first match wins, and a general domain rule (no inboundTag restriction)
matches every inbound including the loopback one. So once such a general
rule targeting the parent balancer ended up earlier in the array — added
before the fallback was configured, or recreated later by an edit or by
ensureMissingBalancerLoopbacks — the fallback re-entered routing through
the _bl_<target> loopback outbound, the general rule matched again, and
traffic routed straight back into the parent balancer: an unbounded loop
and the CPU spike in the report. detectBalancerCycles only catches
mutual fallback cycles, so the plain acyclic Balancer1 -> Balancer2
chain passed silently.

Insert the loopback rule before the first general (no-inboundTag) rule
instead of pushing to the end, and reposition any existing loopback rule
that already landed after a general rule (preserving its other fields,
mirroring the EnsureStatsRouting hoist used for the same class of bug).
Rules with an inboundTag restriction (api, real inbounds) are left in
place — they can never match loopback traffic, so only the unrestricted
rules are dangerous. ensureMissingBalancerLoopbacks runs on every Save
All and calls ensureBalancerLoopback per fallback, so this also repairs
configs loaded from the DB with the wrong order.

Fixes #5960

* ci: re-trigger checks after flaky FuzzDecodeCertPin timeout

---------

Co-authored-by: x06579 <x06579@ai-dashboard>
2026-07-21 15:48:34 +02:00
Sanaei 5e1cb7693b Repo-wide self-correcting audit: 54 verified bug fixes (#5970)
* fix(email): resolve a name-addr smtpFrom into bare envelope address and display name

The save-time validator accepts any RFC 5322 address form, so a value
like '3x-ui Panel <panel(at)example.com>' passes validation, but Send and
TestConnection fed that raw string to MAIL FROM, which strict servers
reject with 501, and buildMessage mangled it into a quoted local part.
Parse the configured sender at the point of use: the envelope gets the
bare address and, when no explicit sender name is set, the display name
embedded in the setting is used for the From header.

* fix(email): report a missing sender address from the SMTP connection test

TestConnection skipped the empty-from guard that Send enforces, so with
no sender and no username configured the test issued the null reverse-path
and could report success against a lenient relay while every real
notification send kept failing with the missing-sender error. Guard the
test path the same way and surface a dedicated translated message.

* fix(sub): fall back to the raw subscription when an auto-detected format has no content

With format auto-detection enabled, a client whose User-Agent matched the
Clash or JSON regex was routed straight to that format handler. For a
subscription whose entries convert to neither format (an MTProto-only
subscription, for example) the handler returns an empty document and the
request ended as 404, breaking a URL that served the raw list before the
toggle. The auto-detect branches now serve the detected format only when
it produces content and otherwise continue to the raw response; the
explicit format endpoints keep answering 404 for empty documents.

* fix(node): match prefixed central tags when filtering a selected-mode node snapshot

FilterNodeSnapshot compared a node snapshot's inbound tags against the
raw selected-tag list with an exact match, while its two siblings
(SnapshotHasUnadoptedInbounds and the reconcile tagToCentral map) expand
each selected tag to both its bare node-side form and its n<id>- prefixed
central form. A panel-created node inbound is recorded in the selected
list under the central prefixed tag but reported by the node under the
bare tag, so the exact match dropped it from every snapshot and the
orphan sweep then deleted its central row one tick after creation. Expand
the allowed set with the same prefix flip the siblings use.

* fix(client): refuse a bulk quota reduction that would fall to or below zero

BulkAdjust clamped a client's new traffic limit with max(total+addBytes, 0).
Because 0 is the unlimited sentinel, reducing a client's quota by more than
it had left silently granted that client unlimited traffic. The sibling
expiry branch already refuses an over-reduction; mirror it for quota so the
adjustment is skipped with a clear reason instead of crossing the sentinel.

* fix(client): persist a bulk adjustment's applied field even when the sibling field is skipped

In a mixed BulkAdjust (both a days delta and a bytes delta), a per-field
planning skip such as "unlimited expiry" or "unlimited traffic" was recorded
in the same map that gated the client_traffics write. The applied field was
already written to the inbound JSON and the clients table, but the enforcement
row was left untouched, so the depletion job cut the client on the old limit
while the panel showed the new one. Gate the traffic-row write on an actual
inbound-processing failure rather than on any planning-phase skip note.

* fix(inbound): always create in AddInbound instead of overwriting a row whose id was posted

The add controller binds the inbound model's id form field and never clears
it, and AddInbound persisted with GORM Save, which updates in place when the
primary key is non-zero. A client that reused an existing id (for instance by
duplicating an inbound fetched from /get and changing the port) silently
overwrote that stored row instead of creating a new inbound. Zero the id at
the top of AddInbound, matching how it already zeroes the client-stat ids.

* fix(inbound): accept WireGuard clients when creating an inbound

AddInbound's per-client validation switch had cases for every protocol
except WireGuard, so a WireGuard client fell through to the default branch
that requires a non-empty id. WireGuard clients are keyed by their public
key and carry no id, so importing a WireGuard inbound or re-adding one to a
reconciling node was rejected with "empty client ID". Add a wireguard case
that validates the client key, mirroring addInboundClient.

* fix(client): stop holding the inbound-lock registry mutex while waiting on one inbound

lockInbound acquired the global registry mutex and then blocked on the
per-inbound mutex without releasing the registry first. A slow client
operation holding one inbound's mutex (for example a bulk delete pushing to
an unreachable node) made the next waiter park on that inbound while still
holding the registry mutex, which in turn blocked lockInbound for every
other inbound — freezing client mutations panel-wide. Release the registry
mutex before taking the per-inbound lock.

* fix(client): honor keepTraffic when deleting a client that is attached to inbounds

Delete, DeleteByEmail and BulkDelete all pass keepTraffic to their final
cleanup transaction, but each called the per-inbound delete helper with a
hardcoded false. That helper purges the client's traffic, IP and stat rows
before the gated cleanup runs, so keepTraffic=true still destroyed all
traffic history for any client actually attached to an inbound (the pinned
test only covered a record with no inbound mappings). Thread the caller's
keepTraffic through to the per-inbound helper at all three call sites.

* fix(inbound): defer a local MTProto inbound edit's sidecar push until after commit

UpdateInbound applied a local MTProto inbound change by calling the runtime
UpdateInbound (which stops/starts the mtg sidecar or talks to it) from inside
runSerializedTx. That runs process and network I/O on the single traffic-writer
goroutine while a DB transaction is open, so a slow sidecar stalls traffic
accounting and every concurrent client mutation, and a later step failing the
transaction leaves the sidecar ahead of the rolled-back row. Move the push into
the post-commit hook, matching the xray branch. Adds a SetLocalRuntimeOverride
test seam mirroring the existing node override so the deferral is regression
tested.

* fix(client): delete external-link rows when bulk-deleting clients

The single-client Delete path removes a client's client_external_links rows,
but BulkDelete (and the DelDepleted reaper that routes through it) deleted the
record, mappings and traffic while leaving the external-link rows keyed by the
now-dead client id, so they accumulated as orphans. Delete them in the same
cleanup transaction, keyed by client id like the single path.

* fix(inbound): request an xray restart when toggling a routed MTProto inbound

AddInbound, DelInbound and UpdateInbound all flag needRestart when an inbound
routes MTProto through xray, so the egress SOCKS bridge is regenerated. Only
SetInboundEnable's local path omitted it, so toggling a routed MTProto inbound
off then on left the bridge out of the running config while the sidecar dialed
its loopback port, blackholing that inbound until an unrelated restart. Flag the
restart on the local enable path too.

* fix(client): apply enable-by-email to every inbound a client is attached to

ToggleClientEnableByEmail (Telegram bot) and SetClientEnableByEmail (LDAP sync)
resolved a single inbound via the legacy client_traffics pointer and flipped
enable only there. A client attached to several inbounds kept connecting through
the siblings' running Xray after being disabled, and the next edit could
re-enable it everywhere from a stale sibling. Route both through the
applyClientFieldByEmail fan-out (the #5039 fix path) so the whole multi-inbound
identity is toggled at once, dropping the circular Set/Toggle dependency.

* fix(traffic): commit a traffic tick even when a best-effort maintenance helper fails

addTrafficLocked stages the inbound and client deltas, then runs three helpers
(auto-renew, disable depleted clients, disable depleted inbounds) that are meant
to log and continue. All three reused the function-scope err that the deferred
commit/rollback inspects, so the last helper's error decided the whole tick: a
failure in disableInvalidInbounds rolled back the already-staged traffic while
AddTraffic reported success, and because xray had already advanced its counter
baseline that traffic was lost for good. Give each best-effort helper its own
error variable so only a genuine staging failure rolls the tick back.

* fix(traffic): re-enable clients and serialize the write in Reset All Client Traffic

ClientService.ResetAllTraffics zeroed up/down but, unlike every sibling reset
path, never restored enable=true, so clients that had been auto-disabled for
exceeding their quota stayed cut with zero usage after a reset. It also wrote
client_traffics directly on the shared DB handle instead of through the serial
traffic writer, reintroducing the cross-transaction lock-order deadlock the
writer exists to prevent. Restore enable and run the reset inside
submitTrafficWrite within one transaction.

* fix(traffic): keep node reset propagation out of the serial traffic writer

ResetAllTraffics and ResetInboundTraffic performed their remote-node reset HTTP
calls inside submitTrafficWrite. Each call can block up to the remote timeout,
and Reset All Traffics loops every node serially, so the single traffic-writer
goroutine was held for seconds — long enough that the concurrent 5s traffic poll
timed out submitting its own write and dropped the deltas it had already drained
from xray. Do the DB reset inside the writer, then propagate to the nodes after
it returns, matching how the mtproto quota reset is already sequenced.

* fix(sub): stop the subscription from 500ing on valid-but-unusual stream settings

The raw share-link generators used unchecked type assertions and unguarded
array indexing: an empty Reality shortIds/serverNames array (random.Num(0)
panics), a tcp-http header with no request block or an empty request.path, a
grpc block missing its keys, empty stream settings, and a non-string Host
header all panicked mid-generation. Because getSubs loops every client's link
with no recover, one such client 500s the entire subscription for everyone. The
sibling JSON, Clash and frontend generators already guard these; make the raw
generators match with comma-ok assertions and length checks.

* fix(sub): tolerate a hysteria inbound without hysteriaSettings in the JSON subscription

genHy asserted stream["hysteriaSettings"].(map[string]any) without the comma-ok
form, so a hysteria inbound whose StreamSettings omit the hysteriaSettings key
(a valid, representable shape the raw generator renders fine) panicked and 500ed
the entire JSON subscription. Use comma-ok; the downstream reads already guard
each key, so a nil map degrades gracefully.

* fix(sub): emit the pinned peer cert sha256 in Clash subscriptions

The Clash stream builder computed tlsSettings["pin-sha256"] from the inbound's
pinnedPeerCertSha256, but applySecurity's tls case never copied it onto the
proxy, so it was written with no reader and silently dropped. Clash subscribers
lost certificate pinning while JSON subscribers kept it. Surface pin-sha256 on
the proxy in the tls case, matching the JSON emitter.

* fix(link): parse the snake_case and extra-blob xhttp fields when importing a share link

The panel's share-link emitters (Go and TS) carry advanced xhttp knobs as a
snake_case x_padding_bytes plus an extra=<json> payload, but the Go parser's
xhttp branch read only top-level camelCase params, so importing an xhttp link
via the outbound-subscription feature dropped xPaddingBytes, scMaxEachPostBytes
and the rest, silently reverting them to the stream defaults and producing a
non-working outbound. Mirror the TS parser: read the snake_case alias, merge the
extra JSON blob, then let explicit camelCase params win.

* fix(frontend): decode URL-safe base64 when parsing an imported share link

Base64.decode called window.atob directly, which rejects the base64url
alphabet (- and _) and unpadded input. But the panel's own share-link emitter
uses Base64.encode(x, true) (URL-safe, unpadded), and real SIP002 links do too,
so importing a Shadowsocks link whose method:password encodes with a - or _ threw,
fell back to the raw undecoded string, and produced a wrong method and garbage
password (the vmess parser shared the same limitation). Normalize base64url and
re-pad before atob so decode round-trips every emitted link.

* fix(link): honor the vmess ws path and hysteria2 vcn params on import

Two Go/TS parser parity gaps in the outbound share-link import path: parseVmess
only applied a ws link's path when the inner JSON also carried a host key, so a
generator that omits host dropped the path back to the default; and parseHysteria2
hardcoded verifyPeerCertByName to empty, ignoring the vcn param the panel emits,
so a hysteria2 outbound with a decoy SNI and a distinct cert name failed TLS
verification after import. The TS parser handles both; make the Go parser match.

* fix(ui): stop the sniffing form island from clobbering unrendered fields

antd's Form.useWatch only reports registered fields, so while the
sniffing toggle was off the island emitted { enabled: false } upward and
replaced the full Sniffing object in form state. Saving a VLESS reverse
outbound then crashed in sniffingToWire on the missing ipsExcluded
array; the loopback outbound and the inbound sniffing tab shared the
same hole. Watch the store with preserve: true so unrendered fields
keep their values, and seed a missing value from the schema defaults
instead of an empty cast.

* fix(sub): drop empty remark segments instead of leaving a stray separator

expandSegment dropped a "|" segment only when its tokens rendered the unlimited
mark, so a segment whose only token resolved to the empty string (a client with
no comment, an unlimited client's expiry date) was kept as bare decoration,
leaving a trailing "|" or a dangling emoji on every share link's remark. Drop a
token-bearing segment whenever none of its tokens produce a real value, while
still keeping pure-literal segments.

* fix(xray): keep source- and domains-scoped routing rules when an inbound is deleted

removeInboundTagFromRules drops a routing rule whose inboundTag list becomes
empty only if the rule has no other matcher, but routingMatcherKeys omitted
xray-core's canonical source and domains keys. A rule scoped by source or domains
(common in hand-authored or imported configs) therefore lost its whole body —
including a security-relevant block — when its single listed inbound was deleted,
instead of just having the tag trimmed. Recognize source and domains as live
matchers.

* fix(xray): guard RemoveUser against an uninitialized handler client

Every XrayAPI handler method returns an error when HandlerServiceClient is nil,
except RemoveUser, which dereferenced it directly. A depletion sweep runs Init
with the port ignored and, during a restart window where the fresh process's
api port is still 0, Init fails and leaves the client nil — so RemoveUser
panicked (recovered by the traffic writer, but re-thrown every poll) instead of
returning an error. Add the same nil guard the siblings have.

* fix(xray): do not revive a manually stopped Xray on a background restart

RestartXray cleared isManuallyStopped unconditionally at its top, so the @30s
pending-config cron (and warp/ldap/outbound reconcile jobs) that call
RestartXray(false) resurrected an Xray the admin had deliberately stopped —
unlike the crash-detector, which honors the manual-stop flag. Skip a non-forced
restart while the stop flag is set; only an explicit forced restart clears it.

* fix(xray): retry a failed pending-restart instead of dropping the config change

The 30s cron consumed the need-restart flag with IsNeedRestartAndSetFalse before
calling RestartXray and only logged a failure. If RestartXray failed early (a
transient GetXrayConfig DB error) the old process kept running the old config,
the crash detector saw a running process and never retried, and the flag stayed
cleared — so an admin's saved change silently never reached the core. Move the
consume/restart/retry into ApplyPendingRestart, which re-arms the flag on
failure so the next tick retries.

* fix(xray): synchronize the process version and apiPort fields

Start writes p.version and p.apiPort (via refreshVersion/refreshAPIPort) after
flipping the process to running, while GetXrayVersion and GetAPIPort read them
lock-free from the status and traffic poll goroutines. The struct mutex
deliberately excluded these fields, so a restart racing a poll was a real data
race — a torn read of the version string header can crash. Extend the mutex to
cover version and apiPort, doing the blocking version probe before taking the
lock.

* fix(settings): detect a wildcard listen collision between the web and sub ports

The web/sub same-port check compared the two listen addresses as raw strings, so
binding both on all interfaces with different spellings (webListen 0.0.0.0 vs an
empty subListen) slipped past validation and only failed at startup with an
opaque bind error. Treat any wildcard listen ('', 0.0.0.0, ::) as overlapping so
the clash is reported up front, while still allowing two distinct specific
addresses to share a port.

* fix(db): mark the IP-limit cleanup seeder done on a fresh install

ResetIpLimitNoFail2ban is a one-time migration that, on a host without fail2ban,
zeroes every existing client's limitIp because the limit can't be enforced. It
was missing from the fresh-install fast-path seeder list, so on a brand-new DB it
did not run on the first boot but fired on the second — wiping any IP limits the
admin had set in between. Add it to the fast-path so a truly fresh install marks
it done up front (there is nothing to clean), leaving later admin-set limits
intact.

* fix(security): dial outbound subscriptions through the SSRF guard

The outbound-subscription fetch validated the URL host once (resolving DNS and
rejecting private targets) but then fetched with a plain HTTP client that
re-resolves the host at dial time, so a subscription domain the attacker controls
could pass validation as a public IP and rebind to 127.0.0.1 / a cloud metadata
endpoint / an internal host for the actual dial — a blind SSRF into the panel's
network. Route the direct fetch (and its redirects) through
netsafe.SSRFGuardedDialContext, which resolves, checks and dials the same IP
atomically, carrying the subscription's AllowPrivate flag on the request context;
a configured egress proxy still dials its loopback bridge unguarded.

* fix(security): bound the login-limiter attempts map

The login rate limiter keys its records on the caller-supplied username and only
evicted a record when that exact key was revisited or the login succeeded. An
unauthenticated attacker replaying one CSRF token while rotating a fresh username
per request seeded a record that was never revisited, growing the map without
bound until the panel OOMs. Cap the map: before inserting a new record, reclaim
records whose block has lapsed and whose failures aged out, and if the map is
still at the ceiling under a broad flood, drop one so memory can never grow past
the cap.

* fix(tgbot): require admin for privileged callbacks, not just the first switch

answerCallback wraps only its first callback switch in an isAdmin guard; the
second switch (server usage, inbound/online enumeration, database backup export,
ban logs, mass traffic reset, client creation) ran for every caller. Telegram
delivers a callback with the tapping user's id, so a non-admin who can see an
admin's inline keyboard — as when the bot runs in a group — could tap Backup and
receive the full database and config, or reset all traffic. Default-deny before
the second switch: a non-admin may only run the per-user client_* callbacks that
resolve their own data from their Telegram id.

* fix(eventbus): dispatch each subscriber in its own goroutine

The fan-out loop called every subscriber's handler sequentially on the
single dispatch goroutine. The email and Telegram notifiers block on
network I/O for tens of seconds (or minutes when the remote is slow), so
one slow subscriber stalled the whole loop: the 256-slot channel then
filled and Publish silently dropped later events — including high-value
xray.crash and node.down notifications unrelated to the slow handler.

Hand each delivered event to every handler in its own goroutine so a
blocking subscriber can no longer stall delivery to the others. safeCall
already recovers panics, so a detached handler cannot take down the bus.

* fix(integration): cap WARP API response body size

doWarpRequest read the response with an unbounded io.ReadAll, unlike the
sibling NordVPN client which already caps every read at maxResponseSize.
A hostile panel egress proxy or a MITM on the Cloudflare WARP endpoint
could stream an arbitrarily large body and force the panel into an
unbounded allocation. Wrap the body in an io.LimitReader(maxResponseSize)
to match the NordVPN client.

* fix(email): bound every SMTP step with a connection deadline

The "starttls"/"none" transport delivered through net/smtp.SendMail, which
dials with an untimed net.Dial and never sets a socket deadline. When an
SMTP server accepted the TCP connection but then stalled (or was a
blackhole), the caller was released by Send's 30s select, but the sender
goroutine and its socket stayed blocked until the OS TCP timeout — minutes
per notification, leaking a goroutine and a connection each time.
sendWithTLS dialed with a timeout but likewise armed no deadline on the
protocol phase, and TestConnection (called synchronously from the settings
handler, with no select guard) could hang the request indefinitely.

Replace SendMail with sendPlain, which dials with smtpConnectTimeout and
arms conn.SetDeadline(smtpDeadline) before the greeting read, preserving
SendMail's opportunistic STARTTLS upgrade. Arm the same deadline in
sendWithTLS and TestConnection so every SMTP step is bounded.

* fix(server): guard access-log parser against malformed lines

GetXrayLogs split each Xray access-log line on whitespace and then read
fixed offsets — parts[1] for the timestamp and parts[i+1] after the "from",
"accepted" and "email:" markers — without checking the line had that many
fields. A truncated or malformed line (the logged destination is
attacker-influenced) indexed past the slice and panicked; the panel handler
returned a 500 via Gin's recovery.

Extract the per-line field parsing into parseAccessLogFields and length
guard every positional lookup so a short line yields a partial entry
instead of panicking.

* fix(server): guard xray key-generator output parsing

GetNewX25519Cert, GetNewmldsa65 and GetNewmlkem768 parsed xray's stdout by
reading lines[0], lines[1] and each line's second colon-separated field
without any length check — unlike GetNewEchCert, which already guards its
line count. If the xray binary printed fewer than two lines or reformatted
its labels (a version change, or a silent failure that emitted nothing),
the fixed slice index panicked and the handler 500'd.

Extract the shared parsing into parseXrayKeyPairOutput, which length guards
the line count and each label split and returns an error instead of
panicking, then route all three generators through it.

* fix(tgbot): stop auto-deleted messages from resetting wizard state

SendMsgToTgbotDeleteAfter spawns a goroutine that, after the display delay,
deleted the transient message and then unconditionally cleared the chat's
conversation state. Every caller that ends a wizard step already clears the
state synchronously, so that call was redundant — and harmful: if within
the delay the user advanced to the next step (a callback sets a fresh
awaiting_* state), the late goroutine wiped it, and the user's next message
fell through unrecognized, silently dropping their input.

Move the delayed deletion into deleteMessageAfterDelay, which only removes
the message and no longer touches the conversation state. Guard
deleteMessageTgBot against a nil bot so the deletion path is unit-testable.

* fix(frontend): refetch a fresh CSRF token on 403 instead of reusing the stale meta tag

On a 403 to an unsafe method the client cleared its cached CSRF token and
called ensureCsrfToken to retry. But ensureCsrfToken prefers the
<meta name="csrf-token"> tag baked into the page, which the production
panel always injects, so the "refresh" re-read the same stale token and the
/csrf-token refetch was never reached — the retry re-sent the token that had
just been rejected and the save failed with an error toast.

The token lives in the session and rotates when the session is regenerated
(for example re-login in another tab), leaving the tab's baked-in meta token
stale. Fetch the current token straight from /csrf-token in the 403 branch so
the retry uses the authoritative server value. The existing tests only passed
because they strip the meta tag; the new test keeps a stale tag present.

* fix(frontend): surface backend error text from failed requests

HttpUtil.get/post read the thrown HttpError body as response.data.message,
but the backend error envelope (entity.Msg) serializes its text as msg. On
any non-2xx JSON response the real reason was therefore dropped and the
operator saw only the generic "Request failed with status N" toast.

Read response.data.msg first (keeping message and the native error text as
fallbacks). The sibling test had pinned the wrong body shape ({ message });
correct it to the real backend shape ({ success:false, msg }) so it exercises
the actual envelope.

* fix(frontend): share one WebSocket connection across bridge and hooks

websocketBridge.ts and useWebSocket.ts each declared their own
module-scoped sharedClient plus an identical getSharedClient, so the
"shared" client was not shared between them: whenever a page using
useWebSocket (Clients/Inbounds) mounted alongside the always-mounted
bridge, the panel opened two sockets to /ws. The server then pushed every
traffic/stats/nodes/inbounds snapshot to both, doubling WebSocket bandwidth
and running two independent reconnect loops, and the hook's socket was never
disconnected on unmount.

Hoist a single getSharedWebSocketClient into api/websocket.ts and route both
the bridge and the hook through it, so exactly one connection is opened.

* fix(frontend): guard the outbounds WebSocket handler against non-array payloads

onOutbounds wrote the raw WebSocket payload straight into the
outboundsTraffic cache, unlike the sibling onNodes/onInbounds handlers which
first check Array.isArray. A malformed non-array push (for example an object)
would land in the cache with staleTime Infinity; consumers that call
.find()/.map() on the outbounds list would then throw and crash the Outbounds
tab. Add the same Array.isArray guard so a bad push is ignored.

* fix(frontend): key the node table by the computed row key, not id

The desktop node table used rowKey="id", but transitive sub-nodes (the
read-only rows surfaced from downstream nodes) all carry id 0, so a topology
with two or more transitive rows gave React duplicate keys. antd's rowKey
prop overrides the row object's own computed `key` (`t-${guid}` for
transitive rows, the numeric id otherwise), so the unique key the code
already builds was ignored — causing row-state/DOM mis-association on any
re-render (heartbeat refetch, address-eye toggle). The mobile card path
already keyed by record.key.

Key the table by "key" so transitive rows get their distinct t-${guid}
identity; direct nodes keep key === id, so row selection (filtered to numeric
keys) is unchanged.

* fix(frontend): map routing row actions through the rule's real index

The routing table hides balancer-loopback rules (`_bl_*`) but keeps each
visible row's original index in `key`, then handed antd's positional row
index straight to edit/delete/toggle/move/drag — all of which mutate the
full, unfiltered routing.rules array. Once a hidden loopback rule precedes a
visible one (e.g. a balancer whose fallback is another balancer, plus any
rule added afterwards), the positional index no longer matches the array
index, so deleting or editing a rule silently hit the wrong one — including
destroying the loopback rule that keeps the balancer alive.

Add originalRuleIndex to translate a positional row index back through the
row's `key`, and route every mutating handler (openEdit, confirmDelete,
toggleRule, moveUp/moveDown, drag) through it. When no loopback rows are
hidden the mapping is the identity, so ordinary configs are unaffected.

* fix(frontend): map outbound row actions through the outbound's real index

The outbounds table hides balancer-loopback outbounds (`_bl_*`) but keeps
each visible row's original index in `key`, then passed antd's positional
row index to edit/delete/move and to the per-row probe (onTest) and its
result lookup — all of which address the full, unfiltered outbounds array.
Once a hidden loopback outbound precedes a visible one, the positional index
diverges from the array index, so deleting or editing an outbound hit the
wrong one (its deletion-impact plan and removal targeting the wrong entry),
and the test button probed / showed results against the wrong outbound.

Add originalOutboundIndex and route the mutating handlers through it; key
the probe trigger and test-result columns by record.key. With no loopback
rows hidden the mapping is the identity, so ordinary configs are unaffected.

* fix(frontend): tolerate a malformed happyEyeballs value in the Xray Basics tab

BasicsTab derived directHappyEyeballs by calling HappyEyeballsSchema.parse
during render, guarding only against null/non-object. A wrong-typed field
(e.g. happyEyeballs.tryDelayMs as a string) or any other shape mismatch —
reachable via the Complete Template JSON editor or an imported config — threw
straight out of render, white-screening the default Xray landing tab.

Use safeParse and fall back to null so a bad value degrades to "no override"
instead of crashing the page.

* fix(frontend): preserve routing-rule fields the form does not surface

The rule form rebuilt the rule from a fixed literal of only the fields it
edits, and RoutingTab replaces the rule wholesale on confirm. Fields the
form never exposes — localPort, localIP, process, ruleTag, webhook — are in
the rule schema and can arrive via the advanced JSON editor or Import Rules;
opening such a rule in the form and saving silently dropped them.

Carry over every key of the original rule the form does not manage before
applying the form-derived fields, so an edit only touches what it surfaces.

* fix(frontend): re-sync the sniffing island when its value changes externally

The sniffing config editor froze its seed value at mount and only watched
its own inner AntD form, never reflecting a later change to the shared RHF
`sniffing` path. Because the inbound form mounts every tab with
forceRender, the friendly Sniffing tab and the Advanced JSON editor are live
at once: editing sniffing in the JSON editor updated the RHF value but not
the frozen island, so the next interaction with the friendly tab emitted the
stale value and silently discarded the JSON edit.

Add an effect that pushes an external value change into the inner form,
guarded by the same lastEmitted marker the emit path uses so the island
never re-seeds from its own echo and no update loop forms.

* fix(frontend): don't drift a client's byte quota on a no-op save

The quota field shows the total in GB rounded to two decimals; editing a
client and saving converted that display value straight back to bytes. A
byte total not aligned to 0.01 GB — one set via the API or an import — was
therefore rewritten to the rounded value on any save that never touched the
field, losing a few MB each time.

Add resolveTotalBytes: keep the original byte total when the displayed GB
still matches it, and only re-derive from GB when the user actually changed
the field.

* fix(eventbus): deliver events on a bounded per-subscriber worker

The previous fix dispatched each event to every subscriber with a bare
`go safeCall`. That unblocked the dispatch loop, but removed the bus's
backpressure: under a login-attempt flood (which both notifier subscribers
process without rate-limiting) with email/Telegram enabled, every attempt
spawned handler goroutines that each block on network I/O for up to ~30s,
with no bound — a goroutine and outbound-connection storm. It also let a
subscriber's handler run concurrently with itself, racing the Telegram
notifier's lazily-cached hostname.

Give each subscriber its own bounded queue drained by a single worker
goroutine. Dispatch does a non-blocking send per subscriber (dropping only
that subscriber's event when its queue is full), so a slow subscriber still
can't stall the others, concurrency is bounded to one in-flight handler per
subscriber, per-subscriber event order is preserved, and Stop again waits
for in-flight handlers to finish.

* fix(frontend): map outbound mobile-card actions through the real index too

The desktop outbounds table was keyed by the outbound's real index, but the
mobile card list was left keying the probe trigger and every test-state
lookup by the positional row index. With a hidden balancer-loopback outbound
present, tapping Check on a mobile card probed the wrong outbound and the
Test-All results landed on the wrong card. Key onTest and the
testResult/isTesting reads by record.key, matching the desktop columns.

* fix(frontend): meet WCAG AA contrast on the config-block link text

The Storybook accessibility test flagged the share-link <code> block: with no
explicit color it inherited a muted grey that renders as #888888 on the
#f8f8f8 tertiary-fill background in CI's Chromium — a 3.33:1 contrast, below
the 4.5:1 AA threshold. Set the text to the theme's primary text token so the
colour is explicit and high-contrast in both light and dark themes instead of
depending on an inherited value that varies by browser.

* style(sub): simplify a negated conjunction to satisfy staticcheck QF1001

golangci-lint (staticcheck QF1001) flagged the `!(a && b)` guard in
expandSegment. Rewrite it via De Morgan's law to the equivalent
`!a || !b` form so the linter passes; behavior is unchanged.

* fix: close panics and races the audit's own fixes left nearby

Second-pass review of the 54-commit self-correcting audit. Each item below
was confirmed by reading the surrounding source (and, where practical, the
pre-fix code) before being changed; regression tests are included for every
behavioral fix.

Concurrency:
- eventbus: Bus.Subscribe called wg.Add with no synchronization against a
  concurrent Bus.Stop's wg.Wait, a real "WaitGroup misuse" panic risk (e.g. a
  Telegram-bot settings save racing panel shutdown/restart). Stop now flips a
  mu-guarded `stopped` flag before waiting, and Subscribe checks it under the
  same lock, so Add and Wait can no longer race.

Security:
- login_limiter: evictForRoom's fallback eviction picked an arbitrary map
  key, including ones still under an active cooldown - an attacker flooding
  /login with fresh usernames could evict their own (or anyone's) blocked
  record and reset the lockout. The fallback now skips actively-blocked
  records, only falling back to an unconditional evict if the map is
  somehow entirely full of active blocks (preserves the hard memory cap).

Subscription-endpoint panics (reachable by any client hitting /sub):
- internal/sub/service.go: applyPathAndHostParams/Obj (ws/httpupgrade/xhttp
  with no path settings object) and the TLS alpn readers in three places
  used unchecked type assertions - exactly the bug class abab7cd0 patched
  elsewhere in the same switch statements, just not these call sites.
- internal/sub/json_service.go, clash_service.go: the externalProxy loops
  in the JSON and Clash generators used unchecked assertions on a
  legacy/admin-supplied field (missing "port", non-object entry, etc.).
- internal/sub/json_service.go: realityData's shortId/serverName selection
  could assert a non-string array element.

Other correctness:
- client_traffic.go: ResetAllTraffics (touched by 3eb214d0) still skipped
  clearing NodeClientTraffic node-sync baselines, unlike its sibling reset
  paths in the same file - a node's next sync would re-add pre-reset delta
  on top of the freshly-zeroed counter.
- inbound_traffic.go: the traffic-tick tx's Commit/Rollback errors were
  silently discarded; now logged so a backend-level commit failure (e.g. an
  aborted Postgres tx from a best-effort helper) doesn't masquerade as a
  successful tick.
- outbound_subscription.go: the new subscriptionFetchClient doc comment was
  wedged between fetchAndStore's existing comment and fetchAndStore itself,
  leaving fetchAndStore undocumented and the comment describing the wrong
  function.

Convention cleanup:
- Removed narrative // comments added by the audit that violate this repo's
  no-inline-comment rule (mostly narrating the specific bug/fix rather than
  a lasting contract, and mostly on new Test functions, which this repo's
  existing tests never comment) - calibrated against this exact codebase's
  own pre-existing comment style so legitimate godoc-style doc comments
  were left alone.

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
2026-07-17 00:33:06 +02:00
Tomi lla 129f50d92a feat(sub): auto-detect subscription format by User-Agent (Updated) (#5826)
* feat(settings): add subscription format controls

* feat(sub): auto-detect subscription formats

* fix(xray): validate balancer regexes before save

* Revert "fix(xray): validate balancer regexes before save"

This reverts commit 8a208ce71b.

* doc(endpoints): align indent spaces

* doc(settings): improve error message formatting in validateSubUserAgentRegex

- Use NewErrorf with proper formatting instead of NewError with string concatenation
- Add comment explaining the rationale for returning original pattern value
- This preserves the intentional design where empty input is stored as empty
  in the DB and inherited as the runtime default at read time

---------

Co-authored-by: Tomilla <5007859+Tomilla@users.noreply.github.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-07-14 13:01:40 +02:00
H-TTTTT f2b17397f4 fix(frontend): stabilize speed tags on inbound and client pages (#5930)
* fix(frontend): add shared stable speed-tag style

Give live up/down rate tags a fixed width, centered layout, nowrap,
and tabular numerals so digit/unit changes cannot reflow the Speed column.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* fix(frontend): stabilize InboundSpeedTag and ClientSpeedTag layout

Apply the shared speed-tag class/style to both live rate tags and lock
the behavior with a focused component test for small and large rates.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* fix(frontend): align speed columns with stable tag width

Widen inbound/client Speed columns to match the fixed tag and apply the
same stable style to idle dash cells so active/idle swaps do not jitter.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* fix(frontend): scope stable speed tags to table cells and fit content

---------

Co-authored-by: x06579 <x06579@ai-dashboard>
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-14 13:00:25 +02:00
Sangeeth Thilakarathna 658e6ab3d3 feat(frontend): show client comments on mobile cards (#5942)
* feat(frontend): show client comments on mobile cards

* fix(frontend): bound mobile comment height

---------

Co-authored-by: sanmaxdev <sanmaxdev@users.noreply.github.com>
2026-07-14 12:59:55 +02:00
Matt Van Horn ae0da4c51f fix: stop forcing port 53 on DoH/DoQ DNS server entries (#5950)
Object-form DNS server entries always received port: 53, because
DnsServerObjectInnerSchema defaulted the port unconditionally and the
DnsServerModal wire adapter always wrote it. Per Xray-core, encrypted
schemes must not carry a port field; a non-standard port is embedded in
the URL instead.

Default the port to 53 only for non-encrypted addresses and omit it for
the encrypted DNS schemes Xray dispatches without a port - https,
https+local, h2c, h2c+local and quic+local - both in the Zod schema and
in the modal's valuesToWire adapter. Schemes are matched
case-insensitively to mirror Xray-core's EqualFold comparison. A shared
isEncryptedDnsAddress helper backs both paths.

Fixes #5920

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
2026-07-14 12:55:10 +02:00
MHSanaei 814cda3fb4 feat(xray): update xray-core to v26.7.11 and adapt panel
Bump xtls/xray-core to 50231eaf (v26.7.11) and the three binary pins
(DockerInit.sh, release.yml x2) in lockstep.

Adapt the panel to the upstream changes:

- Shadowsocks "none"/"plain" and VMess "none"/"zero" were removed from
  the core. A migration rewrites stored none/plain SS methods to a
  supported cipher and none/zero VMess security to "auto" (on both the
  clients column and inbound settings JSON); the SS build-time heal does
  the same so a row injected after boot cannot brick startup. The removed
  values are dropped from every frontend option list, schema and adapter,
  and coerced to "auto" at the Go link/sub/Clash emit sites and both link
  importers. Fix the CipherType_NONE sentinel that no longer compiles.

- Unencrypted vless/trojan outbounds to a public address are now refused
  by the core. Validate outbounds through the vendored config loader when
  saving the xray template and when storing/merging outbound
  subscriptions, so one such outbound cannot keep the core from starting.

- New TCP finalmask type "xmc" (Minecraft mimicry): add it to the sub
  link allowlist, the frontend enum and the FinalMask form (hostname,
  usernames, required password), and document it.

- streamSettings gained a "method" alias for "network"; canonicalize it
  to "network" at inbound save time and in the form adapters/schema so a
  method-keyed config keeps its transport.

- New root "env" config key is passed through xray.Config, compared in
  Equals, and forces a restart in the hot diff.

- REALITY now defaults minClientVer to 26.3.27; update the form
  placeholder.
2026-07-12 00:30:47 +02:00
MHSanaei 201d4731de fix(xhttp): stop XMUX maxConcurrency from reverting on save
XHttpXmuxSchema defaulted maxConnections to 6 (added to mirror xray-core
v26.6.27's anti-RKN client default), so load-time hydration backfilled a
non-zero maxConnections onto every config whose saved xmux lacked the
key. Since maxConnections and maxConcurrency are mutually exclusive on
the wire, the save-time exclusivity rule then saw both fields set and
silently deleted the user's maxConcurrency; the missing key came back as
the '16-32' schema default on the next load, so edits appeared to never
save.

Revert the bare schema default to 0 and seed the anti-RKN
maxConnections=6 only when XMUX is freshly toggled on
(XMUX_FRESH_DEFAULTS, with maxConcurrency left blank — xray-core parses
an empty range string as 0), so the two strategies never start out
conflicting. The inbound and outbound XMUX forms now also clear the
opposing field live as soon as the user sets one, so whichever strategy
was actually typed is the one persisted.

Closes #5864
2026-07-11 20:43:47 +02:00
Sentiago 142dab9ee8 feat(balancer): add balancer-to-balancer fallback support (#5586)
* feat(balancer): add balancer-to-balancer fallback support

Xray does not natively support using a balancer as fallbackTag for
another balancer. This feature automates the loopback workaround:
when a user selects a balancer as fallback, the panel generates a
loopback outbound + routing rule in the template.

How it works:
- User picks fallback balancer from dropdown
- Panel creates loopback outbound _bl_{target} + routing rule
- Balancer fallbackTag set to _bl_{target}
- Traffic: Balancer A → loopback _bl_B → routing rule → Balancer B

Key features:
- Dedup: multiple balancers sharing same fallback reuse one loopback
- DFS cycle detection at edit time and on save
- Self-reference guard (cannot select own balancer)
- Delete protection (blocks if used as fallback by others)
- Cleans up routing rules referencing deleted balancers
- Override resolves balancer tags through loopback mechanism
- All live status tags resolved for display
- Internal _bl_ objects filtered from Outbounds/Routing UI
- Backward-compatible with old _bl_ naming format
- Translations for all 13 locales

* fix(review): override regression, save payload sync, i18n completeness

- OverrideBalancer: only resolve to loopback when resolution succeeds,
  pass original target through for plain outbound tags
- onSaveAll: serialize cleaned template before save to ensure the
  healed/cleaned config is what gets persisted
- Add reservedPrefix translation key to all 12 non-English locales
- Restore trailing newlines in all 13 translation JSON files

* fix(test): update balancer form modal tests after cycle-detection guard

The okButtonProps disabled guard (added in 56d5825c) prevents the modal
from firing onOk when the form is invalid. The old tests clicked the
button expecting validation errors to appear, but antd Modal never calls
onOk on a disabled button — causing false failures.

Rewrite to test the actual guard behavior:
- Button starts disabled (empty form)
- Stays disabled with tag only (selector still empty)
- Stays disabled for duplicate tag
- Disabled button does not trigger onConfirm

---------

Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
2026-07-09 01:59:51 +02:00
Rouzbeh† ea24ef0a69 feat(xray): default outbound in basic routing (#5815)
* feat(xray): default outbound picker in basic routing

Let panel users choose which outbound handles unmatched traffic by
moving it to the first position in the template outbounds list.

* fix(xray): keep direct/blocked outbounds when changing default

* style(routing): revert incidental whitespace churn

Drop double blank lines and the reformatted function signature so the default-outbound diff stays focused on behavior.
2026-07-09 01:55:47 +02:00
Rouzbeh† b8a654967f Add encrypted DNS presets (#5837) 2026-07-09 00:45:35 +02:00
Sanaei 61e12e4c29 Frontend dev tooling (Husky, lint-staged, MSW, Storybook) + full React Hook Form migration (#5859)
* chore(frontend): add husky + lint-staged pre-commit gate

Wire a local pre-commit gate that runs eslint --fix on staged
frontend TypeScript via lint-staged. Because the only package.json
lives in frontend/ while the git root is one level up, the prepare
script installs husky hooks at frontend/.husky from the repo root
(cd .. && husky frontend/.husky), and the pre-commit hook cd's into
frontend/ before invoking lint-staged so node_modules resolves.

* test(frontend): add MSW request mocking

Add Mock Service Worker so tests can exercise the real http-init.ts
request pipeline (CSRF acquisition, 403 refetch-and-retry, body
parsing) instead of only stubbing HttpUtil. A node setupServer is
started for the vitest unit project with onUnhandledRequest bypass so
the existing HttpUtil spies and 55 component tests are untouched; the
browser worker is copied to public/ for Storybook and dev use.

* chore(frontend): add Storybook + component stories

Set up Storybook 10 on the React-Vite builder (compatible with the
pinned Vite 8.1.3 and React 19). The preview decorator mirrors the
vitest component harness: an Ant Design ConfigProvider with a
light/dark toolbar toggle and an en-US i18next instance. main.ts
neutralizes the app vite config bits that do not belong in a component
workshop (the three-entry rollup input, renderBuiltUrl, and the shared
dist outDir) so build-storybook can never clobber internal/web/dist.
Seeds stories across the presentational library (viz, ui, clients,
feedback). build-storybook is a local tool and is not wired into the
CI gate.

* feat(frontend): add React Hook Form primitives

Introduce the shared RHF layer that AntD inputs bind through, ahead of
migrating the forms off Ant Design's Form store:
- FormField wraps a Controller in an Ant Design Form.Item shell,
  reconciling the value/onChange shapes of Input, Switch, InputNumber,
  Select and friends via normalizeAntdOnChange, with input/output
  transforms and Zod-issue-key error messages resolved through t().
- useZodForm wires zodResolver (Zod 4) with the AntD-matching modes
  (validate on submit, then live) and shouldUnregister false so hidden
  and unmounted-tab fields keep their values.
- rhfZodValidate covers the rare per-field rule sites.
Covered by a FormField test exercising normalization, transforms, and
resolver error surfacing.

* refactor(frontend): migrate Pattern-B leaf forms to React Hook Form

Move the controlled-useState leaf forms onto RHF via the FormField
primitive, keeping Ant Design components and each form's exact submit
behaviour (same safeParse, same toast on the first Zod issue, same
payload building):
- clients: ClientBulkAdjustModal, BulkAddToGroupModal, ClientBulkAddModal
- xray: RuleFormModal, BalancerFormModal, WarpModal, NordModal

Multi-control widgets that don't fit a single input (inbound dual
select, subId regen, expiry branches, the balancer tag warning) stay as
explicit Controller/setValue. Derived visibility now reads live values
through useWatch. FormField gains a required prop so migrated fields keep
their required-asterisk affordance.

Settings tabs are intentionally excluded: they are control-panel
components that live-patch a parent AllSetting via SettingListItem, not
Ant Design Form submit-forms.

* refactor(frontend): migrate LoginPage to React Hook Form

Replace the Ant Design Form store + antdRule per-field validation with
useForm + FormField. The AntD Form stays as the layout/submit wrapper,
now driving methods.handleSubmit(onSubmit) via onFinish. Username and
password validate through rhfZodValidate(LoginFormSchema.shape.*); the
two-factor field keeps its conditional required rule (only registered
when 2FA is enabled). Submit posts the same values to /login.

* refactor(frontend): migrate ClientFormModal to React Hook Form

Move the client add/edit form off controlled useState onto RHF while
preserving exact submit behaviour (same ClientFormSchema /
ClientCreateFormSchema safeParse, same toast, same payload + attach/
detach diff + external-links build). expiryDate is stored as an epoch
number (never a Dayjs) to survive RHF's value cloning, converted at the
DateTimePicker boundary. externalLinks uses useFieldArray with stable
ids. inboundIds and the derived show*/ss2022 visibility read live via
useWatch. Space.Compact button-group widgets stay manual Controllers so
the joined borders keep working.

* refactor(frontend): migrate Node and DNS modals to React Hook Form

Both are self-contained Pattern-A forms (no shared fragments). Replace
Form.useForm with useForm + FormProvider, Form.useWatch with useWatch,
setFieldValue with setValue, and partial validateFields([...]) with
methods.trigger([...]). Per-field antdRule becomes rhfZodValidate rules;
the Node scheme->tlsVerify cascade moves to FormField onAfterChange; the
DNS domains/expectIPs/unexpectIPs string arrays are driven by
useWatch + setValue. Submit runs through handleSubmit on the modal OK
button, preserving each form's exact validation, payload build, and
save/onConfirm behaviour.

* refactor(frontend): migrate HostFormModal to React Hook Form

The host external-proxy editor's outer form moves to useForm +
FormProvider. Security/tab visibility reads via useWatch; the three
json-form editors (HostMuxForm/HostSockoptForm/HostFinalMaskForm) are
bound as value/onChange black boxes through a Controller (their own
internal forms are unchanged). remark/inboundId keep their validation
via rhfZodValidate; submit runs through handleSubmit and builds the
same payload (isDisabled = !enable) and save call.

* refactor(frontend): migrate OutboundFormModal + fragments to React Hook Form

Move the outbound form cluster off Ant Design's Form store onto RHF.
The parent uses useForm + FormProvider with a watch() subscription for
the protocol reseed cascade and setValue-based network/security/xmux
cascades; the JSON<->Basic bridge and the formValuesToWirePayload
submit are preserved exactly. Every outbound transport/protocol/security
fragment now binds through FormField/useWatch via context.

The shared config editors stay untouched and are bound through small
value/onChange adapters (src/lib/xray/forms/fields: FinalMaskField,
SniffingField, SockoptCustomField) via Controller; HeaderMapEditor binds
directly. The host json-form wrappers that reuse the outbound MuxForm/
SockoptForm (HostMuxForm, HostSockoptForm, OutboundSubtreeJsonForm) move
to a local RHF provider to match. Outbound render/link tests pass
unchanged.

* refactor(frontend): migrate InboundFormModal + fragments to React Hook Form

Move the inbound add/edit form (the largest form in the panel) and its
transport/protocol/security fragments off Ant Design's Form store onto
RHF, mirroring the outbound migration. The parent uses useForm +
FormProvider with a watch() subscription for the protocol reseed
cascade (type==='change' guard so programmatic resets don't reseed) and
setValue-based network/security cascades; useSecurityActions drives the
TLS/Reality keypair + scan through setValue. Hidden pass-through
Form.Items are dropped (their values ride in the reset object and
survive via shouldUnregister:false), so getValues() still returns the
settings.clients subtree untouched. accounts / certificates / tun lists
use useFieldArray; the shared FinalMask/Sniffing/Sockopt editors bind
through the value/onChange adapters.

Submit keeps the manual InboundFormSchema.safeParse + formatInboundValidation
toast + formValuesToWirePayload exactly. The golden link/full fixtures
pass byte-for-byte, confirming identical wire output. inbound-form-blocks
test harness rewritten from a Form.useForm harness to an RHF provider.

* refactor(frontend): retire antdRule; document the RHF form pattern

All forms now build on React Hook Form, so the AntD-Form Zod adapter
antdRule (src/utils/zodForm.ts) has no remaining callers — remove it.
Update frontend/CLAUDE.md: forms use useZodForm + FormField from
components/form/rhf with zodResolver/rhfZodValidate validation; AntD
<Form> is layout-only; the shared FinalMask/Sniffing/Sockopt editors
stay AntD islands wrapped as value/onChange adapters bound via a
Controller.

* chore(frontend): cover esbuild in the allowScripts allowlist

esbuild (pulled in transitively by Vite/Vitest/Storybook) ships a
postinstall that npm's allow-scripts flags as uncovered on every
install. Its platform binary is delivered through the @esbuild/<platform>
optionalDependencies, so the postinstall isn't needed here; deny it like
the other entries to silence the warning.

* fix(frontend): restore label layout in Sniffing/FinalMask field adapters

The value/onChange adapters that wrap the shared SniffingFields and
FinalMaskForm editors put them in their own isolated AntD Form, but that
Form was missing the label layout the fields used to inherit from the
inbound/outbound parent form. Their labels rendered full-width instead
of the compact right-aligned column, so the Sniffing tab and the TCP
Masks / QUIC Params sections looked broken. Give both adapter forms the
same colon=false, labelCol/wrapperCol span 8/14, labelWrap layout.

* ci: add least-privilege permissions to Docs CI workflow

The docs-ci workflow had no explicit permissions block, so it inherited
the repository default for GITHUB_TOKEN. The build job only checks out
and builds the docs, so restrict it to contents: read, resolving the
CodeQL actions/missing-workflow-permissions alert.
2026-07-08 13:28:37 +02:00
MHSanaei bc309ed9f8 refactor(frontend): replace axios with the native Fetch API
Drop the axios (and qs) dependencies in favor of a native fetch wrapper.
axios only ever handled same-origin JSON/form calls, a CSRF header, a 401
redirect, and a 403-retry, all of which the platform now provides directly.

- New src/api/http-init.ts (replaces axios-init.ts) reimplements the
  request/response interceptors on fetch: base-path prefixing,
  X-Requested-With, same-origin credentials, the CSRF token on unsafe
  methods, a single 403 retry with token refresh, and the 401
  redirect-and-latch. A small encodeForm() reproduces qs's
  arrayFormat:'repeat' encoding, so the request wire format is unchanged.
- HttpUtil (src/utils/index.ts) keeps its public signatures and the Msg
  envelope, so the ~49 API call sites are untouched. HttpOptions is now
  hand-rolled instead of extending AxiosRequestConfig.
- PanelUpdateModal drops its lone direct axios.get in favor of HttpUtil.get
  with { silent, timeout }.
- Add tests for the fetch core (CSRF header, form/JSON/FormData bodies,
  base-path prefix, 403 retry, 401 redirect, tolerant body parse) and for
  HttpUtil's envelope unwrap / toast / error mapping; this logic was
  previously untested.
- Remove the vendor-axios manualChunks branch and the qs type shim, and
  reword stale "axios" mentions in docs and route comments.
2026-07-08 01:09:18 +02:00
ecgang 6e75938c61 [Feature]: Add a tooltip/hint to the "Password" field in the client form clarifying which protocols use it (#5809)
* feat(clients): clarify which protocols use the Password and Hysteria Auth fields

Add tooltips to the Password and Hysteria Auth Form.Items in the client
form, explaining that Password is only consumed by Trojan and Shadowsocks
(ignored for VLESS, VMess, Hysteria, WireGuard) and that Hysteria Auth is
the credential Hysteria actually uses. Adds passwordDesc/hysteriaAuthDesc
keys to all 13 locale files, following the existing limitIpDesc/totalGBDesc
tooltip convention.

Closes #5803

* test(clients): assert Password/Hysteria Auth tooltip hints render
2026-07-07 14:43:09 +02:00
MHSanaei 27fd19895a fix(mtproto): drop the remark fragment from tg proxy deep links
genMtprotoLink appended the panel remark as a URL fragment (tg://proxy?...&secret=...#remark). Because secret/server is the last query value, lenient Telegram parsers fold the "#remark" into it and the imported proxy breaks with "incorrect client random". Telegram proxy deep links have no name field, so emit a clean link on both the backend (internal/sub) and frontend (inbound-link.ts). The remark still shows as a separate tag in the inbound info modal, which reads it from genAllLinks, not the URL.

Guards: Go TestGenMtprotoLinkFields asserts no fragment; the frontend mtproto link test asserts no '#'.
2026-07-06 17:55:35 +02:00
MHSanaei d97bd8643e feat(mtproto): adopt dolonet/mtg-multi and make MTProto inbounds multi-client
Replace the upstream 9seconds/mtg sidecar with the dolonet/mtg-multi fork so a single MTProto inbound can serve many per-user secrets. Each panel client is now one named FakeTLS secret in the fork's [secrets] section: clients are first-class (attach/detach, limits, expiry, per-client tg:// links) exactly like every other protocol, mirroring the WireGuard multi-client model. Per-client traffic and online status come from the fork's /stats JSON API (its Prometheus output has no per-user label), fed into the existing email-keyed client_traffics accumulator; an optional throttle caps concurrent connections. A one-time seeder converts each legacy single-secret inbound into a one-client inbound.

The fork ships only linux/darwin amd64/arm64 binaries but is pure Go, so provisioning builds it from source for every supported platform (release.yml, DockerInit.sh) while keeping the panel-expected mtg-<os>-<arch> filename and the 'run' verb, so process.go is untouched. Also fixes a pre-existing update.sh gap that never renamed the mtg binary for armv6/armv7 updates.
2026-07-06 16:04:32 +02:00
MHSanaei 9d1a21b484 fix(ui): keep an explicit zero happy-eyeballs delay across the round trip
Follow-up found in review: the wire normalizer still stripped
tryDelayMs when it equaled 0, but with the schema default now 250 a
reload rehydrates the missing field as 250 - a user who explicitly set
0 ("disabled", per the field's own placeholder) would see 250 and any
subsequent save would silently enable a delay they turned off. Keep
tryDelayMs on the wire unconditionally; it is the one happy-eyeballs
field whose presence changes xray's behavior.

Refs #5780
2026-07-05 21:17:12 +02:00
MHSanaei 0753f5ee83 fix(link): reject non-finite and clamp out-of-range quicParams from fm=
Follow-up hardening of the fm= sanitizer found in review. ParseFloat
accepts "inf"/"NaN", and a non-finite float64 makes json.Marshal fail
later - the subscription refresh discards that error and blanks the
stored outbound set, so one poisoned link could wipe a subscription's
outbounds. Values that coerce fine but sit outside xray-core's accepted
ranges (keepAlivePeriod 0 or 2-60, maxIdleTimeout 0 or 4-120,
maxIncomingStreams 0 or >= 8) still killed the config load, and huge
magnitudes serialize in exponent notation that xray's integer fields
reject. Coerced values are now stored as integers, clamped into the
accepted ranges, and dropped when negative, non-finite, or absurdly
large; the TS import parser mirrors the same rules.

Refs #5783
2026-07-05 21:16:56 +02:00
MHSanaei 11e45e81b6 fix(link): sanitize numeric quicParams taken from a share link's fm= param
The fm= finalmask blob was JSON-decoded and attached to streamSettings
verbatim, both by the Go parser (outbound subscriptions) and the
frontend import. Some providers emit duration strings for the strictly
integer quicParams fields (e.g. keepAlivePeriod "10s"), and xray-core
then refuses to load the whole config at startup - one bad subscription
entry took the panel's Xray down on the next refresh. Coerce numeric
strings, convert duration strings to whole seconds, and drop values
that cannot be represented as integers; genuinely string-typed fields
(congestion, bbrProfile, brutalUp/Down, udpHop) pass through untouched.

Closes #5783
2026-07-05 20:16:57 +02:00
MHSanaei 579a9daaa0 fix(ui): make the Happy Eyeballs toggle produce a config xray actually enables
Toggling Happy Eyeballs on filled the object with schema defaults, and
tryDelayMs defaulted to 0. That broke the feature twice over: xray-core
treats tryDelayMs=0 as happy-eyeballs-off, and the wire normalizer
strips every field that equals its default, leaving an empty object it
then deletes - so the switch silently flipped back off on reopen (the
"disabled when Prefer IPv6 is off" symptom; prioritizeIPv6=true was the
one non-default that let the object survive). Default tryDelayMs to the
recommended 250ms so an enabled config survives serialization and is
functional in the core.

Closes #5780
2026-07-05 20:12:34 +02:00
MHSanaei 0add63984f fix(ui): align the subUpdates limit with the backend and show the range
The hand-written settings schema capped subUpdates at 168 while the
backend (and the generated schema mirrored from it) accepts 0-525600.
Anyone upgrading from 2.x with a stored value above 168 could no longer
save any settings tab: the whole settings object is validated on every
save, so the stale field blocked everything with an unexplained
"Invalid input". Match the backend bounds and put them on the input so
the limit is discoverable.

Closes #5821
2026-07-05 20:12:21 +02:00
Grigoriy f90e4a6962 fix(panel): use the hosting node address for WireGuard client configs (#5679)
* fix(panel): use the hosting node address for WireGuard client configs

The clients page rendered a node-managed WireGuard inbound's config with the
master panel's host in Endpoint instead of the hosting node's address, so the
copied/QR config pointed at the wrong server. The subscription path already
resolves this via resolveInboundAddress; the UI generator did not.

Expose the share-host resolution inputs (node address, listen, share-address
strategy/address) on InboundOption and route buildWireguardClientConfig through
the same canonical resolver the inbounds-page share links use, extracted as
resolveShareHost. This also brings local inbounds with a shareable listen or a
listen/custom share strategy into parity with the subscription Endpoint; the
common listen=0.0.0.0 case still falls back to the panel host.

* fix(frontend): keep a raw fallback host and refresh node-fed inbound options

Code review of the WireGuard node-endpoint change surfaced two gaps.
resolveShareHost normalized its last-resort fallbackHostname, so a panel
reached via a hostname the share-host grammar rejects (underscore label,
trailing-dot FQDN) emitted a broken 'Endpoint = :51820'; the fallback now
stays verbatim when normalization empties it. Node mutations only
invalidated the nodes query, leaving the staleTime-Infinity inbound
options cache serving an edited node address until the sync job
broadcast (never, for disabled/offline nodes); they now invalidate the
options key too.

Also folds the ShareHostFields projections into direct structural passes,
elides the default node shareAddrStrategy so omitempty drops it, and
replaces the nullable node-address scan with COALESCE.

---------

Co-authored-by: STRENCH0 <17428017+STRENCH0@users.noreply.github.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-07-03 01:12:32 +02:00
MHSanaei 258d8b7344 feat(frontend): add targetStrategy field to the outbound editor
Xray-core added a top-level targetStrategy to OutboundObject that
controls how the destination domain is resolved before dialing
(AsIs/UseIP*/ForceIP*, any protocol). The panel neither offered a
control for it nor preserved the key across the modal's JSON round
trip, so hand-written values were silently dropped on save.

The form now carries targetStrategy next to sendThrough as a select
of the 11 canonical values; the adapter normalizes wire values to
canonical case (the core matches case-insensitively) and omits the
key when unset. Freedom settings additionally read the new
settings-level targetStrategy with domainStrategy as fallback,
mirroring the core, while still emitting the legacy domainStrategy
key so configs keep working on older cores.
2026-07-02 23:03:43 +02:00
nima1024m ade74eb321 fix(balancers): keep mixed strategies on one observer (#5674)
* fix(balancers): keep mixed strategies on one observer

Xray resolves Observatory and Burst Observatory through the same global observer feature. When any burst-required strategy is present, keep all observer-backed balancer selectors on burstObservatory and remove the regular observatory so mixed leastPing configs cannot generate two competing observer blocks.

* test(balancers): cover observer strategy combinations

Exercise the observer sync matrix for random, round-robin, leastPing, and leastLoad balancers. Include mixed and stale-observer cases so the panel keeps only the observer type that Xray should consume.

* fix(balancers): clarify observer empty state

Update the Observatory tab empty hint to describe the actual auto-managed cases. Least Ping, Least Load, and fallback Random or Round-robin balancers now explain why an observer is added before the balancer can choose a target.

* fix(balancers): remove mixed observer switch

Show only the observer settings panel that matches the current balancer requirements. Legacy configs that still contain both observatory blocks now display a warning instead of a tab switch, since saving balancers normalizes the config back to one global observer.

* test(balancers): cover observer cleanup on deletion

Add direct balancer deletion and outbound cascade cases for leastLoad, fallback, and mixed leastPing scenarios. These tests pin that the final unneeded observer is removed, burst switches back to regular observatory when only leastPing remains, and burst remains when a burst-required balancer survives.
2026-07-02 18:18:30 +02:00
MHSanaei 97e2c9e7ba fix(web): sync the VLESS generate-key dropdown with the encryption field
The auth-kind dropdown in the VLESS "Generate Key" block was hardcoded to
x25519 on mount, while the "Already selected" text next to it was derived
independently from settings.encryption. Editing an inbound whose encryption
uses another kind (e.g. ML-KEM-768) showed a mismatched dropdown, and
clicking Generate without noticing would produce a keypair of the wrong
kind for the inbound.

Extract the encryption-string parsing into a shared pure helper
(lib/xray/vless-encryption), use it both for the selected-auth label and to
initialize/sync the dropdown, so the two can no longer diverge. When the
encryption is none or unparseable the dropdown keeps its x25519 default.

Closes #5744
2026-07-02 17:37:04 +02:00
MHSanaei c8ef1b1f68 feat(reality): derive a stable per-client spiderX for shared links
The inbound's spiderX now acts as a per-client seed: exports emit
sha256(seed|subKey) truncated to a 15-hex "/path", so a client's spx no
longer changes on every subscription fetch (#5718) while different
clients stop sharing one fingerprintable value. The form gains a
regenerate button that rotates every client's path at once.

The frontend link builders derive through the same function
(lib/xray/spider-x.ts, @noble/hashes) keyed on subId-then-email like
the Go subKey, so panel QR/copy links and subscription output agree —
cross-language vector tests lock both sides byte-for-byte. streamData
now tolerates malformed stored stream settings (unparseable JSON, null
tls/reality settings) instead of panicking the subscription request.
2026-07-02 12:53:08 +02:00
MHSanaei 539bcc897c fix(inbounds): apply the legacy xhttp session-key migration when editing
rawInboundToFormValues injected the stored xhttpSettings blob into the form
store without running it through XHttpStreamSettingsSchema, so the
sessionPlacement/sessionKey -> sessionIDPlacement/sessionIDKey rename from
xray-core v26.6.22 (and the v3.4.0 field defaults) never applied on the
edit path. Inbounds saved before the rename opened with blank session
fields, and the stale keys could ride back on save even though the core no
longer reads them. Parse the sub-object through the schema on load, and
lift any stale legacy keys in normalizeXhttpForWire as a backstop.

Closes #5621
2026-07-01 23:11:58 +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
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
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 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