* feat(happ): make ad blocking optional in routing presets
Add an independent AdBlock toggle for Iran, China, and global presets, applied only when generating routing rules. Update the China preset to Bypass-CN and cover preset behavior and localized controls.
* feat: add visual routing editor with JSON support and localization updates
- Implemented a new modal for editing routing profiles with basic and advanced tabs.
- Added functionality to load, parse, and generate routing profiles in JSON format.
- Enhanced user experience with validation and error handling for JSON input.
- Updated translations for Russian, Turkish, Ukrainian, Vietnamese, Chinese (Simplified and Traditional) to include new routing editor terms.
- Created helper functions for managing routing profiles and generating deep links.
- Added unit tests for routing editor functionalities and JSON handling.
* feat: update routing editor to preserve null lists in profiles and enhance validation messages
Happ ships its local SOCKS5 (127.0.0.1:10808) and HTTP inbounds with
authorization disabled by default. Any app on the same device can then
connect to that proxy, bypassing Android's per-app VPN routing, and learn
the VPN server address - the leak publicly described in March-April 2026
for Happ, v2rayNG and other VLESS clients. Happ fixed its Xray API
exposure, but the unauthenticated local proxy remained.
Happ exposes a standard subscription header for this (no Provider ID
required): socks-auth-mode / http-auth-mode = auto|manual|from-json|
disable. A new subscription setting, subHappLocalProxyAuth (default
"auto"), sends both headers to Happ clients. Like every other Happ header
it is emitted only when Happ auto-detect is enabled and the User-Agent is
Happ, so panels that never opted into the Happ integration see no change.
An empty value sends nothing and keeps the client's own setting.
Verified on Happ Android 4.4.1 (Xray 26.7.28): a subscription carrying
socks-auth-mode manual + a test user/password switched the client's
Inbounds screen to Manual with those credentials on "refresh subscription",
and "auto" switched it to Auto with generated credentials.
Co-authored-by: Kirill Rudenko <rudenko@npp-energy.ru>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
* fix(amneziawg): bound S1-S3 by the receive buffer, reject overlapping H
The native AmneziaWG validator, both Zod schemas, both forms and the docs now
follow the rules amneziawg-go actually enforces.
S1-S3. A padded handshake message is 148+S1, 92+S2 or 64+S3 bytes
(device/send.go). The peer reads each datagram into a [MaxMessageSize]byte
buffer, where MaxMessageSize = MaxSegmentSize (device/pools.go,
constants.go). MaxSegmentSize is 65535 on Linux/Android, 2016 on Windows and
1700 on iOS (device/queueconstants_*.go). The limits are therefore
S1 <= 1552, S2 <= 1608 and S3 <= 1636. Before, S1/S2 allowed 65535, which
iOS peers silently drop, and S3 was capped at 64, a number inherited from the
coinman-dev/3ax-ui port in #6105 with no stated reason. That cap blocked real
configs such as Amnezia Premium's S3=1045. RandomTrailers only tops a packet
up to 500 bytes (DefaultUdpWindow), so it never pushes a message past these
limits.
H1-H4. amneziawg-go refuses the whole device when the header ranges overlap
("headers must not overlap", device/uapi.go mergeWithDevice), and so does the
kernel module (src/netlink.c). The panel did not check this, so an inbound
with overlapping ranges saved and then failed to apply. A blank H is never
sent, so the engine keeps its default, WireGuard's own type 1-4; the check
treats blank fields that way. The docs said 1-4 "must not be used". They are
valid and are the engine default, only unobfuscated without a
HeaderProtectionKey. The docs also said amneziawg-go rejects S1+56 == S2. It
does not (IpcSet accepts it). The panel keeps that rule as a fingerprint
guard, and the docs now say so.
Tests: the new params_test cases and the Zod bounds fail on the old code.
TestValidatedObfuscationAlwaysApplies runs every accepted set through a real
amneziawg-go IpcSet and now covers overlap, blank-H defaults, H=1-4, the
exact S bounds and the full Amnezia Premium set. Before this fix it failed
with "headers must not overlap".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(amneziawg): bound only inbound padding by the iOS receive buffer
The 1700-byte iOS buffer limits what an inbound's clients can receive,
but ValidateObfuscation also runs for outbounds, and the Xray template
save re-validates every AmneziaWG outbound. An outbound whose remote
server uses S1 above 1552 would have blocked every Xray settings save,
though its values come from that server and are received on Linux.
ValidateObfuscation keeps amneziawg-go's uint16 UAPI width for S1-S3;
ValidateServerObfuscation adds the receive-buffer bounds and is what
inbounds call. The outbound schema and form follow the same split.
---------
Co-authored-by: Kirill Rudenko <rudenko@npp-energy.ru>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
* fix(amneziawg): stop AAAA fallback on v4-only tunnels and expose I2–I5
Gate tunnel DNS queries to address families the device can actually dial,
reject undialable literal IPs early, and surface I2–I5 on the outbound form.
Fixes#6570
* ci: retrigger frontend after npm registry maintenance
The frontend job failed solely on `npm audit` while registry.npmjs.org
returned 503 (Service Under Maintenance). Lint, typecheck, vitest, vite
build, and storybook all passed. Local `npm audit --omit=dev
--audit-level=high` now reports 0 vulnerabilities.
* style(amneziawg): keep the tunnel DNS family comments to two lines
CLAUDE.md caps a comment block at two lines.
---------
Co-authored-by: mrchatam <mrchatam@users.noreply.github.com>
Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
* fix(sub): preserve external VLESS encryption in Clash subscriptions
Copy non-empty, non-none encryption from parsed external VLESS settings,
matching local proxy export. This prevents merged Clash/Mihomo subscriptions
from losing the encryption parameters of externally added nodes.
Cover encryption normalization and omission, plus merged YAML from pasted
links and HTTPS subscriptions containing plain or Base64 share-link lists.
Validation: regression cases fail before the fix and pass after it; the full
subscription package and go build ./... pass. Four unrelated packages still
fail on Windows, with the same failures reproduced using the original code.
Refs: MHSanaei/3x-ui#6572
* test(nodes): wait for chart effects in history panel assertions
The DOM can commit its accessible labels before Sparkline updates the refs used by uPlot range callbacks. Wait for the existing assertions together so the test does not read the empty-data range.
Reproduced the original CI failure locally on attempt 5. The fixed test passed 12 consecutive runs; lint, format and TypeScript checks pass. The full frontend suite passed 1607 of 1608 tests, including Storybook. The unrelated input-number guard fails on Windows because execFileSync cannot launch the extensionless oxlint shim (ENOENT); invoking oxlint.cmd reports all three expected diagnostics.
Monthly sponsor placements need to change without cutting a panel
release. Panels now read 3X/sponsors.json from the MHSanaei/sponsors
repo (GitHub Pages on sponsors.sanaei.dev) and show active sponsors in
four slots: an overview banner, a rotating sidebar card (max three), the
login page and a new Sponsors page that also lists open placements.
An entry shows only while enable is not false and until is in the
future; links must be https and logos are png/webp/jpg by name only.
The list is cached for an hour and the last good copy survives upstream
failures; logos are proxied through /sponsors/logo/:name with failures
cached, so CSP stays 'self' and admin browsers never reach a third
party. Admins can hide a slot for 24h. Under XUI_DEBUG the panel reads
a sibling ../sponsors/3X checkout so edits can be previewed before push.
Zod 4: use the `error` param instead of the deprecated `message`.
lint:deprecated missed these because tsgolint's no-deprecated does not
resolve object-literal properties on a `string | Params` union.
Geodata: key geo entry rows by page position. antd deprecates rowKey's
index argument, and kind:value repeats within a page because the reader
drops domain attributes (22 pairs in geosite_IR.dat, 108 in geosite_RU).
Nord/PIA: the "All cities/regions" option used a null value, which antd
warns on. Map it through a sentinel at the Select boundary so form state
stays null, with tests that fail when the sentinel is not mapped back.
Tests:
- Run the oxlint guard through node; .bin/oxlint is a sh shim Windows
cannot spawn, and the swallowed error left both guard cases vacuous.
- Start unit workers with --no-experimental-webstorage; msw's localStorage
probe made Node 25+ warn once per forked worker.
- Set IS_REACT_ACT_ENVIRONMENT, which RTL never sets with globals: false,
and settle the async updates it exposed inside act(). The row-cells
memo test now fails when memo is removed.
- Disable antd's click wave in Storybook; it re-rendered inside the next
story's act() and tripped "not configured to support act".
- Assert InboundFormModal's validation log instead of leaking it, and
give the rule-form test a well-formed clients/list response.
Raise the frontend baseline to Node 26/npm 11 and refresh contributor documentation. Update frontend, documentation-site, and Go dependencies with regenerated lockfiles and module checksums.
The inbound TLS form offered cipherSuites as a closed single-choice list,
but xray reads the value as a colon-separated list and accepts any name Go
knows, so several suites or one missing from the list could not be set.
Both the inbound and the new host field now use a tag picker that keeps
the stored value as the colon-joined string xray expects; old single
values open unchanged.
A host's cipher suites replace the inbound's in the JSON subscription
stream, and a blank field inherits them. Share links and Clash carry no
cipher suite parameter, so their output is unchanged.
The node history panel passed its Net Up / Net Down series to Sparkline
without valueMax or yFormatter, so they inherited the percentage defaults:
a fixed 0-100 scale and a "%" label. Any node above 100 KB/s drew off the
top of the chart and every axis tick and tooltip read as a percentage.
A Sparkline fed non-percentage data has to declare its own scale and unit;
every other call site already did, only the two node net series did not.
The info page was a long key/value table followed by every link and two
app dropdowns, and it rendered left-to-right even for Persian and Arabic.
It now leads with a usage ring, the remaining quota and a stats grid, and
splits the rest into Subscription / Apps / Configs tabs.
- Status tells expired, data-used-up and disabled apart instead of one
"Inactive", replacing the hard-coded English expiry chip.
- The Apps tab keeps every Android and iOS app with its existing deep
link, preselects the visitor's platform and adds Windows: Hiddify and
Clash Verge Rev import directly, v2rayN copies the link.
- fa-IR and ar-EG render right-to-left; URLs, IDs and sizes stay LTR.
- The footer shows the support link and the client refresh interval, so
subPageContext now carries subUpdates (also in ?format=info).
- Status, days-left and app deep-link logic lives in subPageModel.ts,
with unit tests pinning the deep links the page already shipped.
The page stacked the WebSocket event cards above every Panel API operation
in one long scroll. The WebSocket events and the 3X-UI Panel API now sit in
separate tabs, and the Panel API shows one OpenAPI tag at a time through
section tabs placed between the Authorize bar and the operations.
The section tabs replace Swagger UI's FilterContainer and wrap the
taggedOperations selector, so all sections share one Swagger instance and
keep authorization and try-it-out state. Swagger's own filter matches tags
by substring ("Settings" would also show "Xray Settings") and does nothing
until set, so the wrapper matches the exact tag and defaults to the first.
Tag names come from the loaded spec rather than importing endpoints.ts,
which would have grown the page chunk from 23 kB to 119 kB.
Each card on the Clients page now toggles its status bucket as the sole
filter, and the Clients card clears it. The bucket filters used to be
wider than the card counts: "active" still included clients near
depletion and "deactive" included disabled clients that had run out, so
a filtered list could disagree with the number on the card. Both filters
now reuse the summary expressions, and a test pins each card's count to
the size of its filtered list.
The client form body is capped at the viewport and scrolls internally
(49ef1449). Every tab ends with a Form.Item that keeps antd's 24px bottom
margin, so when the fields themselves fit, that empty margin alone pushed
the body past the cap: 752px of content in 740px at a 900px window. The
last item of each tab now drops the margin, so the body scrolls only when
real content overflows.
When it does scroll, the bar was painted light inside the dark modal: the
dark themes set body.dark and data-theme but never color-scheme, which is
what native scrollbars read. applyDom (panel, login and subscription
bundles) and the Storybook decorator now set it on the root element.
A node's "update available" tag compares its reported panel version with the
master's latest, and any non-semver side fell back to string inequality. A
dev build reports dev+<sha> (config.GetPanelVersion), so a node moved to the
dev channel from a master on the stable channel kept the tag forever; the
reverse, a stable node under a master on the dev channel, was flagged too and
the tag's default stable update installed nothing new.
A dev label and a release tag carry no order, so the comparison now only
decides within one channel; dev-to-dev still compares commits, which keeps a
node on the current dev-latest commit untagged as config.go intends.
rc-table re-runs every cell renderer whenever the Table re-renders, and
NodeList rebuilt its columns and table props on every render (the relative
time formatter was a fresh function each time). Any re-render of the Nodes
page therefore re-rendered all rows even when no node had changed: about
390ms per re-render for 150 nodes in jsdom.
The formatter is now stable and the table element is memoized on its
inputs, so a re-render that leaves the nodes untouched costs 0.5ms. A
heartbeat push that does change the nodes still re-renders every row.
Every client_stats push carries the totals of all inbounds, and
applyClientStatsEvent rebuilt each row it listed, so every push replaced
all rows, re-ran the client rollup (a JSON parse of every inbound's
settings) and re-rendered the whole table even when no number moved. Every
traffic push also built new online and active maps, re-running the same
rollup.
Rows are now rebuilt only when their totals or a client's numbers change,
and the previous maps are kept when a push repeats the same sets. Measured
in jsdom with 450 inbounds of 50 clients each: an unchanged client_stats
push went from 7.9ms to 0.6ms with no row rebuilt, and a repeated traffic
push from 13.5ms to 8.3ms without the rollup.
The core reads a dns rule's qType as a PortList, which drops a bare numeric
0 (infra/conf/common.go: `if number != 0`), and a rule with no qTypes
matches every query. A stored `"qType": 0` therefore does not target query
type 0: it drops, refuses or hijacks all DNS through that outbound.
A qType the panel writes has to be read by the core as exactly the query
types it names. Four writers broke that:
- DNSOutboundLegacyKeysFix rewrote a lone blockTypes [0] into "qType": 0,
so "block type 0" became "block everything" on upgrade.
- That seeder shipped in v3.8.0 and is recorded as done, so fixing it does
not reach installs that already ran it. DNSOutboundQTypeZeroFix spells
any stored numeric qType 0 as "0" once, protocol id matched like the core.
- The outbound form adapter turned a typed "0" into the number 0.
- The Xray template editor saves raw JSON past that adapter; the save now
applies the same rewrite.
Each writer is pinned by a test that fails without its part. The rewrite
and the repair compare policies as the pinned core builds them, and the
repair runs through runSeeders over a database whose legacy-keys seeder
already ran, on SQLite and PostgreSQL 16.
* fix(xray): refuse a config the running core cannot bind
RestartXray stopped a working core before handing it a config whose listens
collide, so the failed bind exited the whole process (main/run.go:94) and the
one-second watchdog retried it in a loop: every protocol down, cause only in
the logs. The save-time port guards cannot cover this -- SetInboundEnable, the
AmneziaWG relay created on the first peer, template and bridge edits all reach
a colliding config with no guard on that path.
Probe the generated config at the single restart funnel instead. Collisions the
running core already serves are excused, so an established setup is never
refused by a static read being wrong about it, and the port-bucketed pass costs
nothing on a clean config.
* fix(xray): surface a refused config and re-key the bind excuse set
Round-1 findings on this PR. Refusing the swap left the running core on its
previous config with nothing but a log line to show for it, so the status
response now carries the reason while the core runs and the overview marks it;
the node list picks the same field up through that response. The excuse set is
keyed on the two listens, the port and the shared transports instead of the tag
pair, so a pair whose listen moves onto the other's address is refused again,
while the same two sockets stay excused however the generator orders them.
TestBindConflicts/excused_pair_whose_listen_changed_into_a_real_collision fails
without the key change -- watched red first.
Update Ant Design, React i18n, Zod, testing utilities, Oxc tooling, GORM Postgres, Pion transport, and sing dependencies to their latest specified versions.
* fix(panel): read the outbound protocol id in the address column like the core
outboundAddresses switched on the raw id, so a row the core runs normally but
spelled "VMess", "Trojan" or "WireGuard" fell through to default and rendered
an empty Address column in the outbounds table, the card view and the
subscription table -- a populated server that looks absent, which is what
sends an operator to recreate a correct outbound.
The id is folded once before the switch, the way isUdpOutbound already folds
the transport name.
* fix(panel): fill the outbound address column from one protocol-id rule
outboundAddresses folded the id inline while isUntestable, two functions
below it in the same file, reads it through isOutboundProtocol — so the
"the core lowercases the id" rule lived in two places and two tests. It
now routes through the shared helper, which keeps the rule with the
module that owns it.
Two further gaps in the same switch, reported in the same review:
hysteria and amneziawg are both selectable in the outbound form but had
no case, so a canonically spelled row rendered a blank Address cell that
case folding could not reach; and the VLESS branch returned a bare ":"
for a row whose servers sit in vnext, which this change newly reached
for a "VLESS" spelling.
Tests: the hysteria/amneziawg cases and the bare-separator case are red
on the pre-fix switch.
* fix(panel): read the vnext shape of a vless outbound in the address column
The vless branch read only the flat settings.address/port, so a row whose
servers sit in vnext — the shape the probe's extractor reads first
(internal/web/service/outbound/outbound.go:259-269) — rendered a bare ":"
separator, or nothing at all before this branch folded the id. It now
reads vnext first and falls back to the flat pair, the order the
extractor uses, which also makes it agree with what a probe of that row
would say.
Test: "reads the vnext server of a vless row" is red on the pre-fix
branch.
* fix(panel): read the protocol id of the outbound stream tags like the core
The identity cell gated the network and security tags on an exact-match
includes() over four ids, so the same "VMess" row whose address this
branch now shows still rendered without its ws/tls tags — the row was
half-readable. It now asks the shared isOutboundProtocol, the rule every
other reader on the page uses.
Test: "renders the stream tags and the address of a VMess row" is red
without this change (['VMess'] vs ['VMess','ws','tls']).
A direct, DNS, loopback or blackhole outbound is not a proxy, so the probe
must reject it instead of measuring the panel host's own reachability. The
gate compared the protocol id exactly while the core lowercases it in
LoadWithID before resolving the handler, so "Freedom" and "DNS" were not
recognised: the HTTP probe ran through the direct outbound and returned
Success=true with a full egress block, and the row's Test button stayed
enabled because isUntestable compared exactly as well. The operator reads the
panel host's own country and delay as a working tunnel.
The batch gate now folds the id once before its switch, and isUntestable goes
through the shared isOutboundProtocol helper.
* fix(panel): match the probe and egress readers to what the core loads
Two readers left over from the case-sensitivity sweep still disagreed with
the core, both raised reviewing #6523.
isUdpOutbound compared the protocol id and the transport name exactly. The
core lowercases both before it resolves them (infra/conf/loader.go:46 for
the id, TransportProtocol.Build at infra/conf/transport_internet.go:16-17
for the name), so an outbound spelled "WireGuard" or a stream named "KCP"
still built a UDP handler but was probed with a dial-only TCP request, and
Test All Outbounds reported a working outbound as down.
The mtproto egress picker asked for outbound tags without excludeBlackhole,
so the block outbound stayed selectable there. Choosing it looks like a
working selection and discards that inbound's Telegram traffic.
* fix(panel): recognise the mkcp transport alias and pin the picker's field id
Review findings on #6525.
TransportProtocol.Build resolves both "kcp" and "mkcp" to the same mKCP
transport, so comparing the transport name against "kcp" alone left a
template spelling "network": "mkcp" in the TCP lane and reported a working
outbound as down — the same trigger this PR already fixed for the "KCP"
capitalisation.
The egress picker now carries an explicit id, the way the inbound form's
protocol select does, so the test addresses that field rather than the first
searchable select on the page and reuses the shared dropdown helper instead
of duplicating it.
* fix(panel): read an outbound protocol id the way the core does
xray-core lowercases a protocol id before it resolves the handler, so a
template that spells the direct outbound "Freedom" is that outbound. The
outbound editor fell through to the vless default and rendered it as an
empty vless server, and the Basics tab did not find it and appended a
second "direct", which the core refuses to load with "existing tag found".
* fix(panel): never leave the direct tag on two outbounds
A "direct" tag held by a non-freedom egress made both Basics-tab setters
push a fresh freedom outbound, and the core refuses a config whose tags
repeat ("existing tag found: direct"). The tag is now checked on its own
before anything is added, matching setDefaultOutboundTag.
* fix(panel): disable the freedom controls when direct is held elsewhere
When a non-freedom outbound holds the "direct" tag, both Basics setters
drop the edit so the core never sees the tag twice, but the Freedom
Strategy select and the Happy Eyeballs switch stayed enabled and snapped
back with no sign of why. isDirectTagTaken now disables both controls in
that state.
The find-or-create-plus-guard was also copied into BasicsTab's happy
eyeballs setter with no test of its own; ensureDirectFreedomOutbound now
owns the lookup, the guard and the creation for both setters, so the
existing helper tests cover that path too.
---------
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
Six more readers compared an outbound's protocol id exactly while the core
lowercases it, so an outbound spelled "Blackhole" passed every
excludeBlackhole filter (offered as an mtproto egress, a dialerProxy
target and the geodata download egress, all of which then drop the
traffic) and one spelled "Freedom" was queued by Test All Outbounds. They
now share isOutboundProtocol.
Offer monthly calendar subscriptions an explicit last-valid-second display
without moving their real billing boundary or spending renewal allowances.
Keep the option off by default and limit conversion to a shared fixed
day-1 midnight cutoff at an actual month transition in the panel timezone.
Use the authoritative client calendar mode when aggregating node traffic,
and share the header formatter across raw, JSON, and Clash exports.
Expose the setting in the existing settings API/UI, regenerate its schemas,
and document that clients may report expiry one second early or format the
date differently in another timezone. Add HTTP, settings, and DST coverage.
Stored deadlines, access enforcement, info/remark expiry values, and renewal
accounting remain unchanged.
Refs: #6516
Co-authored-by: JacktheRanger <219502738+JacktheRanger@users.noreply.github.com>
* fix(xray): place the freedom domain strategy where the core reads it
freedom resolves through the socket layer, so xray-core reads
sockopt.domainStrategy and treats both other placements as legacy: it warns on
every config load for the outbound-root targetStrategy it migrates itself, and
again for the settings-level domainStrategy it deprecates. The panel wrote
exactly those two keys from its Freedom Protocol Strategy select, the outbound
form card, and the IPv4 routing helper, so any install that had configured a
strategy logged a deprecation warning on every start.
The strategy now travels in streamSettings.sockopt everywhere the panel emits
it: the Basics select, the outbound form (including the JSON tab, which shares
the same adapter), the shipped default template, and the IPv4 outbound the
routing helper injects. Reading mirrors the loader's own order — root
targetStrategy, then the settings keys, then sockopt — so the card keeps showing
the value the core would actually run with, and saving drops the legacy keys
instead of leaving them behind.
A seeder moves the keys for configs already stored in the database, following
OutboundRemovedKeysFix. The shared outbound-root Target Strategy field is hidden
for freedom, since the core migrates that key into the very sockopt value the
card writes and two knobs for one value would race.
Tests: placement round-trips and the migration table run through the real
vendored core (a captured log handler proves the warning is gone after the
rewrite and present before it), and the modal asserts freedom offers a single
strategy field.
* test(database): seed the template row the seeder test needs
A fresh InitDB creates no xrayTemplateConfig row — the panel's setting defaults
live in the service layer — so the test has to insert the legacy template itself
and then assert the seeder's history gate stops a second pass from rewriting it.
* fix(xray): keep one strategy control per outbound, seed the row in tests
Review findings: the Transport tab's Sockopts block renders for freedom too, so
its Domain Strategy select and the freedom card wrote one sockopt value between
them and the card won on save — the field is hidden for freedom now, leaving the
card as the single control. The seeder is also pre-marked on a fresh install so
it does not run on the second start, and the seeder test seeds the template row
itself (a fresh InitDB has none) and asserts the rewrite structurally instead of
grepping for a key name that sockopt also uses.
* fix(link): restore mKCP seed and headerType on share-link import
applyTransport / applyTransportParams ignored kcp query params that
applyKcpShareParams emits, so re-imported outbounds lost seed and
header and could not talk to the inbound. Mirror those fields (plus
mtu/tti) into kcpSettings in both Go and TS importers.
Fixes#6476
* fix(link): restore mKCP header/seed via finalmask mkcp-legacy
* fix(link): split mKCP header and seed into separate masks on import
Both importers folded a share link's headerType and seed into one
mkcp-legacy mask {header, value}. xray-core's MkcpLegacy.Build ignores
value once header is set (and reads it as the fake DNS domain for
header=dns), so an imported outbound carried the header mask but no
AES-128-GCM seed while the emitting inbound has both, and could not
connect — the failure #6476 reports, now for every link carrying both
params. Emit one mask per field, seed first: the finalmask array's
first item is the innermost layer, which puts the header around the
cipher as legacy mKCP did.
Also bound mtu/tti to KCPConfig.Build's accepted ranges (mtu >= 21,
tti 10..1000, decimal digits only on both importers) so a pasted link
cannot fail the whole Xray config load, and look header types up as
own properties so a prototype key such as "constructor" is not mapped.
---------
Co-authored-by: mrchatam <287639636+mrchatam@users.noreply.github.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
* feat(inbounds): show linked host remarks in inbound list
Join Host Group remarks from the existing hosts list onto each inbound
row client-side so multiple endpoints (IPv4/IPv6/CDN) are visible without
opening the inbound. Truncate long lists with a tooltip for the full set.
Fixes#6026
* fix(inbounds): skip disabled host groups in inbound list remarks
buildHostRemarksByInboundId joined every host group from /hosts/list
onto its inbounds, so a group toggled off on the Hosts page still read
as a live endpoint in the inbound remark cell and matched the search
box. A disabled group serves nothing: internal/sub/host_sub.go filters
it out of subscription output and withMtprotoHostEndpoints skips it for
MTProto share links. Skip it here the same way, and drop the unread
`truncated` field from formatHostRemarksLabel.
---------
Co-authored-by: mrchatam <287639636+mrchatam@users.noreply.github.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
* fix(web): restart panel after ImportDB so subPath routes match (#6446)
ImportDB only restarted Xray, leaving the subscription HTTP server on
startup-registered paths. Schedule the same in-process restart hook used
by restartPanel so restored subPath (and related) routes take effect
without relying on a browser follow-up that can fail after session invalidation.
* fix(web): schedule the post-import panel restart once, via PanelService
ImportDB grew a private copy of PanelService.RestartPanel (same hook check,
same Windows bail-out, same SIGHUP fallback, already diverging in log
severity) while BackupModal kept POSTing restartPanel after a successful
import, so one restore bounced the panel and the public sub server twice
back to back. The service package cannot reuse PanelService (panel imports
service), so the importDB controller now calls the existing
RestartPanel(3s) after ImportDB succeeds, the duplicated helper is dropped,
and the browser follow-up is removed; it waits out the restart and reloads.
Test drives the importDB handler against a stub xray binary and fails when
no restart is scheduled through the global restart hook.
---------
Co-authored-by: mrchatam <mrchatam@users.noreply.github.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
Carry the hydrated reset cycle and day into the enable update payload so toggling a client does not normalize its schedule to never. Cover both toggle directions with a regression test.
* feat(geodata): add standard source presets
Expose the existing geofile allowlist to the Geodata editor so administrators can configure the supported scheduled downloads without copying URLs manually
Tested with npm run test, npm run lint, npm run typecheck, npm run format:check, and go test ./internal/web/service ./internal/web/controller -run '^(TestStandardGeodataSources|TestGeodata)' -count=1
Assisted-by: OpenCode:openai/gpt-5.6-terra (mostly)
* fix(geodata): preserve custom source entries
Add missing standard sources instead of replacing existing custom entries.
Assisted-by: OpenCode:openai/gpt-5.6-terra (mostly)
* fix(link): rebuild shadowsocks tcp/http obfuscation on import
genShadowsocksLink encodes tcp/http obfuscation only as the SIP002
plugin=obfs-local;obfs=http;obfs-host=... parameter, deleting type, headerType,
path and host in the process, because SIP002 clients ignore those and read
`plugin` alone. ParseLink read none of them, so importing a link the panel had
just exported produced a plain tcp outbound with header.type none: the
obfuscation the inbound requires was gone, and the client could not connect to
the very inbound the link came from.
The plugin is now mapped back onto the header it stands for. Credentials and
every other parameter are untouched, and other plugin values are left as they
were because Xray has no equivalent for them.
* fix(link): map the SIP002 plugin in both importers
The panel parses share links twice: link.ParseLink in Go, which the external
subscriptions use, and parseShadowsocksLink in outbound-link-parser.ts, which
the Add Outbound button calls. Mapping the plugin in Go alone left the UI path
still saving header.type none for a link the panel had exported itself, so one
panel answered the same link with two different outbounds.
The unencoded plugin=obfs-local;obfs=http;... form maps as well now: stdlib
drops any query pair whose value holds a literal semicolon, and that is the
shape clients which skip percent-encoding emit, so the raw query is read as a
fallback when the parsed parameter is missing.
* fix(link): read the vmess certificate checks on import
applyVmessTLSParams writes ech, vcn and pcs into the vmess share object, but
parseVmess only read sni, fp and alpn back. Importing a link the panel had just
exported therefore dropped all three: no pinned certificate, no verify-by-name,
no ECH. On a server whose certificate is only trusted through a pin, the
imported outbound falls back to public-CA verification against the system roots
and cannot connect to the inbound the link came from.
The url-param protocols already read the same three in applySecurity, and the
core takes pinnedPeerCertSha256 as one joined string there, so the vmess path
now fills them the same way.
* fix(frontend): read the vmess certificate checks on import
The panel parses share links twice: link.ParseLink in Go and
parseVmessLink in outbound-link-parser.ts, which is what the Add Outbound
button calls. Reading ech, vcn and pcs in Go alone would have made the two
sides disagree on one link, leaving the UI path — the one an operator uses by
hand — still dropping the pin the panel had just exported.
* feat(settings): allow customizing Reality scan candidate list
Persist a realityScanCandidates panel setting (defaulting to the previous
hardcoded list), expose it in General Settings with i18n, and have the
Find Targets scanner use it when the search box is empty.
Fixes#5847
* style(frontend): oxfmt realityScanCandidates in setting.ts
* Fix locale JSON syntax
This change removes the malformed duplicate key and missing comma in the Android per-app proxy translations across the bundled locale files. The JSON now parses correctly while preserving the translated labels for each language.
* docs(i18n): update Happ translations
Localize the remaining Happ subscription settings strings across the translation files and refine the English copy. This aligns the labels and descriptions with the current Happ behavior for notifications, TUN options, HWID enforcement, routing presets, and per-app proxy settings.
* refactor(reality): drop test-only scaffolding from the candidate setting
TestDefaultRealityScanCandidatesCSV compared the CSV against its own
initializer and a defaultValueMap lookup, and
TestRealityScanCandidateTokensFallsBackWithoutDB drove a no-database
state no production caller reaches (the only caller is the
scanRealityTargets handler, served after InitDB). The
s != nil && GetDB() != nil guard existed only for that second test.
None of them could fail except in lockstep with the code they restate.
* docs(api): describe the setting-driven scanRealityTargets fallback
An empty targets value now probes the realityScanCandidates setting,
but the endpoint summary, parameter description and handler comment
still promised the built-in seed list, so API consumers were told the
wrong target set. Regenerated openapi.json and synced the docs copy,
which also lacked the new AllSetting field in the settings reference.
---------
Co-authored-by: mrchatam <287639636+mrchatam@users.noreply.github.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
* feat(discord): add Discord notification bot service, settings UI, and event subscriber
- internal/web/service/discord: implement lightweight Discord REST API v10 client and EventBus subscriber
- internal/web/service/setting: add discordBotEnable, discordBotToken, discordChannelId, discordEnabledEvents, discordCpu, discordMemory settings and secret protection
- internal/web/controller: register POST /panel/api/setting/testDiscord endpoint
- frontend: add Discord settings tab, notifications configuration, sidebar navigation, and command palette integration
- translation: add localization keys across all 13 locales
- tests: add comprehensive unit tests with httptest server and verify route/i18n contracts
* fix(discord): address PR review findings on concurrency, linting, i18n, and stories
- subscriber: eliminate unbounded goroutines, sending inline per EventBus contract
- discord: accept context.Context in SendMessage, SendEmbed, SendTest with http.NewRequestWithContext
- format: apply gofumpt to controller and entity struct alignments
- i18n: localize testDiscord controller responses across all 13 locales
- storybook: add DiscordNotifications.stories.tsx component story
* docs: add Discord bot setup and operations guide
- add docs/content/docs/en/operations/discord-bot.mdx with setup steps, event indicators, settings, and troubleshooting
- add docs/content/docs/ru/operations/discord-bot.mdx with localized instructions
- update operations/meta.json across en, ru, zh, fa
- link Discord bot from panel configuration overview
* feat(discord): add discordLang, discordRunTime, discordBotBackup settings and update settings UI
- internal/web/entity: add DiscordRunTime, DiscordBotBackup, DiscordLang fields to AllSetting
- internal/web/service/setting: add defaultValueMap entries, getters, and setters
- frontend: update AllSetting schema, model defaults, and generate OpenAPI / Zod contracts
- frontend: extract shared NotifyTimeField component and update DiscordTab with General and Notifications tabs
- translation: add localization keys across all 13 locales
* feat(discord): implement scheduled status reports and database backup attachments
- internal/web/service/discord: add SendMessageWithFiles supporting multipart uploads
- internal/web/service/discord: implement BuildReport and SendReport generating rich status embeds
- internal/web/service/discord: attach database backup (and config.json) when discordBotBackup is enabled
- internal/web/job: implement DiscordNotifyJob scheduled via robfig/cron
- internal/web/locale: add LocalizerFor and I18nForLang helpers
- internal/web/controller: trigger reloadDiscordFunc to dynamically reschedule cron upon setting changes
- internal/web/web: register and reschedule DiscordNotifyJob
- tests: comprehensive unit tests for multipart uploads, status reporting, and job execution
* feat(discord): add interactive bot commands via Gateway WebSocket and update documentation
- internal/web/service/discord/gateway: connect to Discord Gateway v10 via WebSocket (gorilla/websocket)
- internal/web/service/discord/gateway: handle heartbeat loop, reconnection, and command dispatch
- commands: implement !status, !report, !backup, !usage <email>, !inbounds, !restart, !help (with ! and / prefixes)
- internal/web/web: start/stop Gateway client with server and reload dynamically on setting updates
- docs: update operations guide (en, ru) with scheduled reports, backups, commands, and privileged intents
- tests: add end-to-end WebSocket Gateway test verifying command handling
* style(discord): fix goimports formatting and add 3x-ui to gitignore
* fix(discord): stop gateway panics, reconnect storms and proxy bypass
The Gateway client wrote to its websocket from both the heartbeat ticker
and the read loop answering server-requested op 1 heartbeats. gorilla
panics on concurrent writes and neither goroutine recovers, so a colliding
heartbeat took the whole panel process down; writes now share writeMu.
It also reconnected every 5s forever after close codes Discord marks
non-reconnectable (4004 bad token, 4010-4014, including 4014 when Message
Content Intent is off), re-identifying and logging a warning each time.
The loop now stops on those codes; the docs say to restart the panel.
The gateway dialed with websocket.DefaultDialer, bypassing the panel
egress proxy the REST client already uses, so where Discord is filtered
notifications arrived but commands never connected.
* fix(discord): deliver the scheduled report when the backup upload fails
SendReport posted the report embed and the x-ui.db/config.json attachments
in one multipart request. Once the database outgrows Discord's upload cap
(20 MiB by default) the request is rejected and the report embed is lost
with it on every run, leaving only a log warning. Send the embed first and
the attachments as a second message.
* chore(discord): delete tests that pass whether or not the code works
TestDiscordNotifyJob_NilServiceNoPanic and TestHandleEvent_NilDiscordService
feed a nil DiscordService that web.go never passes, and
TestDiscordNotifyJob_DisabledNoPanic passes with or without the enable
guard because Xray is not running under test.
* fix(discord): require admin user IDs for bot commands and honor discordLang
Any member who could post in the configured channel could run !backup
(the whole x-ui.db and config.json, even with discordBotBackup off),
!restart and !usage. Commands now run only for the Discord user IDs in
the new discordAdminIds setting; an empty list turns commands off.
discordLang was saved and offered in the UI, but nothing read it, so
every embed stayed English. The test message, alerts, the scheduled
report and command replies now render through I18nForLang in the chosen
language, with a discord section in all 13 locales. InitLocalizer takes
an fs.FS so tests load the real translation files.
---------
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
* feat(clients): add stateless Happ link generator
Generate Happ provider links from the current effective subscription source without caching results. Reject unsafe provider responses and redact failure diagnostics.
* fix(clients): reject duplicate Happ provider fields
Parse Happ provider objects token by token so duplicate supported keys cannot be silently overwritten by encoding/json.
* feat(clients): expose on-demand Happ link API
Expose a no-store client endpoint backed by the Happ link generator and keep its generated OpenAPI contract synchronized.
* fix(openapi): exclude service interfaces from generated types
Keep dependency-injection interfaces out of the frontend API surface while preserving allowed response schemas.
* feat(clients): add stateless Happ QR presentation
Generate Happ links only for the active modal scope and retire late responses so Standard remains immediately available. Add focused component coverage and localized retry guidance across every locale.
* fix(clients): cover overlapping Happ generations
Prove the cancellation cleanup is required by resolving a retired request while its replacement remains pending. Also wait for Regenerate to leave loading state before exercising the existing action.
* fix(clients): harden Happ link handling
Validate generated responses before rendering and hide actions during unresolved requests. Strengthen route, redirect, timeout, and lint regression coverage with mutation-sensitive tests.
* fix(clients): gate Happ link generation behind operator opt-in
- add a fail-closed happLinkEnable setting
- enforce the gate before and after provider requests
- add locked Happ QR state with privacy disclosure and settings link
- cover backend, frontend, settings, and i18n regressions
* fix(frontend): guard oversized Happ QR codes
Keep valid long crypt5 links copyable while suppressing QR rendering and image actions above the encoder's UTF-8 byte limit. Add localized guidance and boundary coverage.
* fix(clients): log the sanitized transport error for Happ link failures
Every fail() call in HappService.Generate passed a string literal as the
detail, so the sanitizer written for provider errors only ever saw
constants, and an operator following the QR modal's "check Logs" hint
found nothing beyond reason=transport. Transport and body-read errors now
flow through sanitizeHappDetail, which also redacts cookie/session pairs.
Drop TestHappLinkEnableDefaultsOffWithoutPersistingRow: it pinned a getter
and its constant default, which the Generate gate test already drives.
* fix(frontend): size the Happ QR cap to level L and keep the QR modal mounted on close
HAPP_QR_MAX_BYTES was the level-M capacity (2331) while QrPanel encodes at
errorLevel "L", whose version-40 byte-mode capacity is 2953, so valid links
between 2332 and 2953 bytes lost their QR. The cap now matches the encoder
and a test renders the real QrPanel at the boundary.
Keying the modal content on `open` remounted it on every close, which cut
the Modal's exit transition and made the openSubId sync unreachable, so
`loading` never turned on for the subLinks fetch and a client without a
subscription link flashed noLinks on reopen. `open` leaves the key and the
sync block now also resets the Happ state.
* chore(clients): request Happ crypt5 links from api-v3
crypto.happ.su serves api-v2.php and api-v3.php side by side. Probed with
the same payloads, both take {"url"} over a JSON POST, answer
{"encrypted_link":"happ://crypt5/..."} of identical length with the same
crypt5 key marker, and fail the same way: 400 "No url provided.",
500 "Invalid URL format.", 405 on GET. Happ's own generator page is
branded "URL Encryption v3", so the panel follows it. The parser and the
link validator are unchanged.
* feat: add local generation of encrypted Happ links
- Implemented functionality to generate encrypted Happ links locally without network dependency.
- Added validation for URL length and format to ensure compliance with processing limits.
- Introduced new error handling for invalid URLs and control characters.
- Updated translations for various languages to reflect changes in Happ link generation.
- Created unit tests to validate the encryption process and ensure session keys and nonces are unique.
* fix(frontend): match the tuic memo deps to the non-optional subSettings
The Happ branch reads subSettings non-optionally in ClientQrModalContent
(happLinkEnable and the WireGuard/AmneziaWG publicHost memos), so React
Compiler infers subSettings.publicHost. The TUIC memo merged in from main
still listed subSettings?.publicHost, which fails oxlint's
preserve-manual-memoization rule and makes the compiler skip optimizing
the component. make verify stopped at lint-fe on the branch head.
* chore(happ): trim the pinned-key provenance comment to two lines
CLAUDE.md caps a comment block at two lines. The bare URL line repeated
the repository and file the next line already names, so it is folded
into that line (review LOW on happ_crypto.go).
---------
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
* feat(sub): let the panel set the JSON subscription DNS servers
A baked routing profile (#6402) carries only the DNS its preset defines, so an
operator who wants their own resolvers has to override the whole profile or
patch the subscription behind a proxy.
Add the subJsonDns setting: either a full xray dns block or a bare array of
servers. It wins over the profile's DNS while leaving the profile's routing
rules intact, and reaches per-inbound, balancer and info-node documents alike.
The value is validated with xray's own schema (internal/xray/dnsconf): a block
the client could not load is rejected when the settings are saved and ignored
with a warning at request time, instead of being baked into every document.
Both the sub server and the settings API share that validator, so a stored
value can never be silently dropped.
xray's Build() is deliberately not used for validation: it resolves geosite
tokens from the geodata files and would reject valid configs whenever those
are absent from the panel's working directory.
* style(dnsconf): drop the ineffectual initial map assignment
golangci's ineffassign flagged the zero-value map whose value both paths
overwrite: the object branch now assigns the decoded map directly.
* docs(sub): scope the DNS setting to the documents it rewrites
The Routing header mirrored to Happ/INCY keeps the routing profile's own
resolvers, so the setting description and the header-source comment now say
so instead of claiming the profile's DNS is replaced everywhere.
Also trims two comments in the new dnsconf package to the repo's two-line cap.
* fix(link): preserve Shadowsocks TLS query params on import
Mirror trojan/vless stream parsing so Xray-native type/security/sni/alpn/fp
query params on ss:// links survive into streamSettings on both Go and TS importers.
Fixes#6094
* fix(link): drop extra blank line so oxfmt passes
---------
Co-authored-by: mrchatam <287639636+mrchatam@users.noreply.github.com>
* feat(settings): add Block tab for JSON subscription routing rules
Expose the existing blackhole outbound in the subscription formats UI so
operators can add block domain/IP rules without editing subJsonRules by
hand. Scope Direct/Block helpers by outboundTag so the tabs keep separate
rule objects, and keep block rules ahead of direct for Xray match order.
* fix(settings): preserve rule order and clear foreign leftovers
Stop sorting the whole subJsonRules array on every write. Prepend block
defaults only when enabling Block. Clearing the last managed tag also
drops foreign-tag leftovers so the panel can reach an empty setting.
Fixes oxfmt on SubscriptionFormatsTab.
---------
Co-authored-by: mrchatam <287639636+mrchatam@users.noreply.github.com>
* fix(inbounds): allow negative subSortIndex for subscription order
Preserve explicitly set negative indices so primary inbounds can sort
ahead of the default without renumbering peers; keep 0/omitted → 1.
* fix(inbounds): gofumpt model.go and trim subSortIndex comments
---------
Co-authored-by: mrchatam <287639636+mrchatam@users.noreply.github.com>
* feat(reality): warn when target cert chain is too small for ML-DSA-65
Expose peer cert-chain DER size from the REALITY scanner and surface a UI
warning when ML-DSA-65 is enabled but the chain is under xray-core's 3500-byte
minimum, so silent fallback failures are easier to catch.
Fixes#5973
* fix(reality): gate scanner ML-DSA tag and sync docs OpenAPI
Only warn on short cert chains in the target scanner when ML-DSA-65 is
enabled. Copy frontend/public/openapi.json to docs/public/openapi.json
and fix oxfmt wrapping in the new test.
---------
Co-authored-by: mrchatam <287639636+mrchatam@users.noreply.github.com>
* feat(clients): show short HWID fingerprint in admin device list
Expose a 12-char prefix of the stored hwid_hash in the admin HWID list API and UI so admins can distinguish devices without querying the database. Full hashes and raw HWIDs remain unexposed.
Fixes#6359
* ci: retrigger release matrix after 386 dependency download flake
---------
Co-authored-by: mrchatam <mrchatam@users.noreply.github.com>
Co-authored-by: mrchatam <287639636+mrchatam@users.noreply.github.com>
* feat(clients): add Generate button for WireGuard/AmneziaWG PresharedKey
Matches existing key regenerate controls on the client form. Value is
32 random bytes base64 via Wireguard.generatePresharedKey (same as
wg genpsk). Field stays optional.
Fixes#6343
* fix(clients): keep FormField for WireGuard PresharedKey generate
Restore RHF FormField (noStyle inside Space.Compact) so the regenerate
control matches inbound reality patterns and satisfies oxfmt.
---------
Co-authored-by: mrchatam <287639636+mrchatam@users.noreply.github.com>