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>
This change bumps the frontend and Go dependency set to newer patch/minor releases, including Vite, react-hook-form, zod, and the x/* Go modules. It also fixes a compatibility issue in the settings UI by switching Ant Design's Space usage from the deprecated `direction` prop to the current `orientation` prop.
* Feat(tuic): Implement native TUIC v5 protocol support via Rust sidecar daemon
- Add internal/tuic package for official tuic-server sidecar lifecycle management, configuration generation, and graceful process control
- Bridge decrypted TUIC QUIC traffic into loopback Xray SOCKS5 inbounds (63200+id) for traffic accounting, statistics, and routing rules
- Implement periodic reconciliation job (cadence @every 10s) and immediate runtime synchronization on inbound/client mutations
- Add TUIC inbound & multi-user client settings (UUID + Password authentication) in Web UI with SNI auto-fill and panel certificate loader
- Integrate tuic:// subscription links and Clash.Meta (Mihomo) proxy generation for TUIC
- Update install.sh to automatically download and install official tuic-server release for x86_64, aarch64, and armv7
- Add full localization for TUIC protocol across all 13 supported languages
* Feat(install): Support custom repository and branch in install and update scripts
* Ci(release): Enable publish-dev for feature branch and workflow dispatch
* Feat(sub): Add TUIC to subscription resolution and client QR config generator
- Add 'tuic' to getInboundsBySubId SQL allowlist to resolve TUIC inbounds in subscriptions and sub links
- Enhance buildTuicProxy in Clash subscription generator with robust host and credentials resolution
- Add tuicConfig.ts to generate standalone Clash/Mihomo YAML configuration
- Add dedicated TUIC Config tab in ClientQrModal with QR code and .yaml download button
- Add localization keys for TUIC config across all 13 supported languages
* Fix(tuic): Exclude TUIC from native Xray inbounds and strip udp_relay_mode from server config
- Exclude model.TUIC from native Xray inbounds in GetXrayConfig to prevent Xray startup failure
- Remove udp_relay_mode from tuic-server JSON configuration builder
- Update install.sh to install tuic-server binary to both xui_folder/bin and /usr/local/bin
* Fix(install): Fallback to dev-latest when releases/latest is not present on fork
* Feat(tuic): Add real-time online status and LastOnline tracking for TUIC clients
- Track client activity by mapping client UUID in tuic-server logs to email
- Integrate TUIC active clients into XrayTrafficJob to refresh local online clients
- Bump LastOnline timestamp in database and broadcast live online status over WebSocket
* Feat(tuic): Implement real-time traffic statistics and live speed reporting for TUIC
- Collect precise I/O traffic deltas for tuic-server child processes via /proc/<pid>/io
- Aggregate and attribute TUIC traffic deltas per client in tuic Manager
- Integrate TUIC traffic deltas into XrayTrafficJob to update database and broadcast live speed
* Feat(tuic): Finalize TUIC v5 integration with 1:1 traffic counting and orphan process cleanup
- Use exact 1:1 byte delta accounting from /proc/<pid>/io
- Add killStrayTuicProcesses to terminate orphan sidecars on panel startup
- Fully integrate TUIC with subscriptions, live speed meter, and all 13 locales
* Feat(frontend): Polish TUIC UI, support bulk operations, and update translations
- Align TUIC inbound certificate form with standard 3X-UI layout (Set Default Cert, Clear)
- Remove extra subtitle hint text from TUIC inbound form fields
- Support TUIC in client bulk attach/detach and bulk add modals
- Add TUIC badge color to client info modal, clients table, and host list
- Update password tooltip across all 13 locales to include TUIC
- Remove obsolete dead translation keys across all 13 locales
* Chore(ci): Finalize TUIC v5 bundling across release workflow, Docker, and scripts
* Feat(openapi): Update OpenAPI generator and schemas for TUIC types
* Fix(backend): Address core review findings for TUIC types, port checks, and xray bridge
* Refactor(traffic): Isolate proc reading with build tags and decouple TUIC metering into TuicJob
* Feat(client): Add TuicServer to InboundOption, fix config export and clean share links
* Fix(frontend): Register TUIC in multi-user helpers, tracked protocols, and tag derivation
* Chore(openapi): Re-generate OpenAPI specification and sync Zod schemas
* Chore(scripts): Add Alpine musl binaries, 386 and Windows packaging, and anchor pkill
* Fix(review): Remove stale import, correct binary names, switch to musl, and drop unreachable relay gate
* Feat(frontend): Show share link in Inbound Info and display UDP tag for TUIC
* Docs: Add TUIC v5 configuration guide and link specifications
* Docs(tuic): Correct Clash Meta configuration parameter to reduce-rtt
* Fix(tuic): Generate client credentials on copy, enforce ID/password validation, and add i386 to DockerInit
* Fix(tuic): drop unused relay, fix traffic accounting, and honor host endpoints
- Drop unused loopback SOCKS relay and eliminate port collision with AmneziaWG
- Correct inbound traffic calculation without double-counting
- Drop heuristic client traffic division while retaining online tracking
- Support externalProxy host fan-out and conditional parameters in share links
- Scope orphan process termination to managed config directory
* Fix(tuic): enforce client quotas, decouple Xray restart, and sync openapi schemas
- Regenerate OpenAPI, Zod schemas, and TypeScript types without route_through_xray
- Populate clientTraffics in TuicJob to enforce client quotas and first-use expiry
- Split process I/O delta into up and down in Process.CollectTraffic
- Remove SetNeedRestart from updateTuicInbound to prevent Xray session drops
- Use InstanceFromInbound for default ALPN and UDP relay mode in tuic:// share links
- Support allow_insecure on externalProxy host endpoints without parameter collision
* Fix(tuic): attribute client traffic only on single-user inbounds and sync link defaults
- Attribute I/O deltas to the client only when the inbound has exactly one configured client, avoiding false billing and disablings on multi-user inbounds
- Aggregate client traffic by email in TuicJob so clients on multiple inbounds don't lose deltas
- Match frontend genTuicLink defaults for alpn and udp_relay_mode with backend subscription links
* Fix(tuic): gate client traffic by total sidecar clients and require client email
* Fix(tuic): enforce inbound-only traffic limits and disable client totalGB
* fix(tuic): restore delayed start, remove client totalGB rejection, and document linux-only limits
* fix(tuic): anchor pkill, fix io baseline/split, escape yaml, and deduplicate start errors
* fix(tuic): prevent traffic double-counting, ensure info log level for delayed start, and broaden pkill matching
* fix(tuic): address review round 11 findings
- internal/sub/json_service: skip tuic protocol in json subscription to prevent direct routing leak
- internal/sub/clash_service: honor externalProxy/host row allowInsecure, sni, and alpn in buildTuicProxy
- internal/web/runtime: decouple tuic inbound add/delete from xray restart
- internal/tuic/config: restore user log-level options (warn, error) without forced info clamp
- frontend/src/lib/xray/inbound-link: fix duplicate remark suffix and apply externalProxy TLS overrides
- frontend/src/schemas/protocols/stream/external-proxy: propagate allowInsecure through host mapping
- tests: add coverage for json sub skip, clash proxy overrides, and link generation
* fix(tuic): meter inbound traffic through a UDP relay and bracket IPv6 binds
Review repairs on the TUIC v5 sidecar integration:
- Inbound traffic was read from the sidecar's /proc/<pid>/io rchar, but
the kernel only counts read()/write() there and tuic-server moves its
sockets with recvfrom/recvmmsg/sendmmsg/sendto, so an inbound's up/down
stayed at 0 forever and inbound total limits never tripped (measured:
12 MiB relayed, rchar delta 0). The panel now owns the inbound's public
UDP port with a small relay and runs tuic-server behind it on a loopback
port, counting up/down exactly on every OS. tuic-server therefore logs
127.0.0.1 as every client's address; per-client attribution stays
unsupported since QUIC is opaque.
- Instance.BindTo formatted an IPv6 listen address as ":::8443", which
tuic-server rejects with "invalid socket address syntax", so an inbound
listening on "::" or any IPv6 literal never started. It now uses
net.JoinHostPort; IPv4 output is unchanged.
- The log level is passed to the sidecar as chosen. Online status,
last-online and delayed start are read from its Info lines, so the Log
Level field now says that Warn and Error switch them off for the
inbound, and the docs say the same.
- Drop two frontend tests that only exercised a getter and a set lookup,
and strip the trailing blank line that made gofumpt fail on two of the
new Go test files.
* fix(tuic): harden tag updates, runtime routing, and relay stability
---------
Co-authored-by: poise52 <equipoise52@gmail.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
Some subscription providers require a client-specific User-Agent before returning outbound links. Persist an optional value per subscription and use it for refreshes and previews while preserving the existing default for blank values.
* feat(sub): add read-only HWID device-slot status endpoint
Closes#6357
A client with an HWID limit had no way to tell a subscriber how many device
slots were left: /{subPath}/{subId} only exposes the gate as a boolean through
X-Hwid-* headers on a 404, and ?format=info carries no limitHwid or registered
count. Every "why can't I connect on my new phone" case therefore had to be
answered by the operator by hand.
GET /{subPath}/{subId}/hwid-status now returns the aggregate counters:
{"active":true,"limit":2,"registered":1,"remaining":1,"full":false}
- SELECT-only. It never registers an hwid, never touches last_seen and never
calls the enforcement path, so asking about a slot cannot spend one.
- Counters only: no hwid value or hash, no email, no device metadata, no IP,
no User-Agent, and none of the X-Hwid-* gate headers.
- The subscription id is already the bearer secret for /{subPath}/{subId}, so
no admin token and no new auth mechanism.
- Unknown and disabled subscriptions both answer a bare 404, with identical
status, headers and body, so the route cannot be used to probe which
subscription ids exist.
- No HWID limit configured returns {"active":false,"limit":0,...}.
- No schema change and no migration.
Scoped to enabled clients exactly like effectiveHwidLimitForSubID, so the
reported limit is always the limit the gate enforces on a shared sub_id, and
remaining clamps at zero when the effective limit drops below the number of
registered devices. A separate route leaves /{subPath}/{subId}, ?format=info
and the JSON/Clash routes byte-for-byte unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(sub): document hwid-status as the bare object it returns
The OpenAPI operation for GET /{subPath}/{subId}/hwid-status inherited the
{success,msg,obj} panel envelope from build-openapi.mjs's default 200
response, while the handler writes the HwidSlotStatus struct bare. A client
generated from the spec would read `obj` and never find the counters, and
the description prose contradicted the schema with a hand-written example.
HwidSlotStatus now sits in openapigen's StructAllow with example: tags, the
entry references the generated schema through a `responses` block, and
build-openapi.mjs attaches the generated example to any `responses` entry
that $refs a generated schema, so no example is hand-written. The HEAD
variant the controller registers is documented like its siblings, and the
summary follows the "path prefix is configured by subPath" wording now that
fresh panels randomise the prefix.
Regenerated frontend/public/openapi.json, docs/public/openapi.json and the
subscription-server MDX. openapi-runtime-contracts.test.ts pins the bare
schema, the generated example and the HEAD operation.
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
* feat(clients): let admins set PersistentKeepalive on tunnel clients
model.Client already carries KeepAlive, and every AmneziaWG/WireGuard client
config emitter already writes PersistentKeepalive when it is above zero -- but
nothing in the UI could set it, so it stayed 0 and the line was never emitted.
Without it a peer that goes quiet has nothing to trigger a handshake: WireGuard
only initiates when it has data to send. An idle client stays disconnected
after any interruption -- a NAT mapping timing out, a device sleeping, the
panel restarting -- until the user generates traffic themselves.
New clients default to 25, the conventional value, which also keeps the NAT
mapping open. Existing clients keep whatever they have, and 0 remains valid and
means "do not send keepalives".
* fix(clients): let an explicit 0 actually disable PersistentKeepalive
Addresses review feedback on the previous commit.
UpdateInboundClient carries a stored keepalive forward whenever the incoming
one is zero, so the settings JSON and the running peer survive a metadata-only
edit that omits the field. That was a 0 -> 0 no-op while no UI could set a
nonzero value. Now that the client form can, the carry-forward became reachable
in the other direction: a client created at the form's default of 25 could
never be returned to 0, and the hint text shipped to all 13 locales -- "0
disables it" -- described something the backend silently refused. The save even
reported success, because a settings blob that came back byte-identical skips
the transaction entirely.
The zero value cannot carry that distinction, so model.Client.KeepAlive becomes
*int: nil means the field was never sent, &0 means "send no keepalives". The
pointer survives the internal marshal in ClientService.Update, which is where an
explicit 0 was being erased by omitempty before UpdateInboundClient ever saw it.
ClientRecord.KeepAlive stays a plain int -- it is the stored column, where
"unset" has no meaning -- and the conversions bridge the two.
Two tests, both red before this change in the direction they cover: an explicit
0 must reach wg_keep_alive, and an update that omits the field must still leave
a stored 25 alone.
Also adds the output transform every other numeric field in the client form
already has, so a cleared box sends 0 rather than null.
* fix(clients): repair the keepalive pointer conversion after the main merge
Merging main brought buildAmneziaWGProxy (#6326) in beside the
Client.KeepAlive int -> *int change without reconciling the new call site,
so internal/sub stopped compiling and took every package importing it with
it. The two sides touched different lines, so git merged them without a
conflict -- the green `make verify` on 112b19a8 predates the break.
ToClient also wrapped a stored 0 in a pointer, so omitempty stopped
omitting: a VLESS client's settings JSON gained "keepAlive": 0 on the
attach and bulk-attach paths, and that JSON reaches xray-core verbatim
through GenXrayInboundConfig. wg_keep_alive cannot tell "off" from "never
set", so a stored 0 now stays nil.
Also copies the regenerated openapi.json over the docs mirror, which
nothing in CI checks, and trims two comment blocks to the two-line cap.
---------
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
The "no group" em dash in the clients table and in two client-picker
modals was drawn with an inline color: rgba(0,0,0,0.45). The panel renders
every page under antd's darkAlgorithm as well, so on a dark container that
near-black placeholder is effectively invisible.
All three now use Typography.Text type="secondary", the idiom the rest of
the panel already uses for muted text — GroupAddClientsModal itself uses it
58 lines further down.
Mid-greys such as #888 elsewhere in the panel stay legible in both themes
and are deliberately left alone.
* feat(ui): add global command palette (Ctrl+K) for fast navigation and search
* fix(ui): address review feedback for shortcut listener, i18n parity, and search deep links
* fix(ui): resolve search routing, translation keys, and palette state reset
* fix(ui): improve command palette styling and sidebar transitions
* fix(ui): address review feedback for typecheck, codegen, debouncing, and state reset
* fix(ui): resolve effect state update warning and debounce reset in command palette
* fix(ui): address review feedback for stale client search results and theme action
* style(ui): apply oxfmt formatting to command palette and tests
* fix(deps): update js-yaml override to resolve audit advisory
* docs(api): sync the docs OpenAPI copy with the new InboundOption fields
Adding Network/Security to InboundOption regenerated
frontend/public/openapi.json, but docs/public/openapi.json is a
hand-kept copy of that file and nothing checks it: make verify never
reaches docs/, and docs-ci.yml fires only on docs/**. The two files were
byte-identical on main and had diverged here, so the published API
reference described a response shape the panel no longer returns.
Regenerating the MDX under docs/content/docs/en/reference/api/ produced
no change — the schema is read from the JSON at render time.
* fix(ui): unnest the command palette row control and label its shortcut
The palette row was a <button> wrapping the copy-subscription <button>.
Nested interactive content is invalid HTML and React 19 logs two errors
for it on every client result. The row is now a role="button" div using
activateOnKey, the pattern the rest of the panel already uses, with
line-height pinned so dropping the UA button style does not grow every
row. Its keydown handler ignores events bubbling from the nested button:
activateOnKey preventDefaults Enter, which would otherwise cancel the
browser's Enter-to-click on the copy button and navigate instead.
The sidebar chip hardcoded the Mac glyph while the handler accepts Ctrl
as well, so Linux and Windows operators were shown a key they do not
have; it now picks the modifier from the platform.
Also restores the comment on ClientsPage's debouncedSearch that the
deep-link change removed — the code it explains is unchanged.
* feat(sub): parse generic Happ/INCY routing payloads for the JSON subscription
Accepts the routing-rules format emitted for Happ and INCY (inline JSON,
happ:// or incy:// deeplink, or a remote https:// URL resolved through the
existing remote routing cache). The JSON subscription will bake these
rules into its documents so header-ignoring clients still get routing.
* feat(sub): bake Happ/INCY routing profiles into JSON subscription documents
When subJsonRoutingRules is set, every emitted document (per-inbound and
balancer alike) carries the profile's dns and routing rules baked in, so
header-ignoring clients like Happ and INCY still get routing; the legacy
simple-rules merge only applies when no profile is set. The balancer
document builder keeps rewriting proxy-tag rules to the balancer.
* feat(sub): add the subJsonRoutingRules setting
Plumbed from the settings store through the subscription server into
SubJsonService, so admins can set a routing profile once and every JSON
subscription document carries it.
* chore(api): regenerate OpenAPI artifacts for subJsonRoutingRules
* feat(web): routing profile editor for the JSON subscription
A textarea inside the JSON card accepts the routing profile (inline JSON,
happ/incy deeplink, or https URL) with a remote-source badge; the badge
helper moves to a shared module. Keys added to all 13 locales.
* fix(sub): warm and lazily resolve the baked JSON routing source
The routing profile was resolved once at service construction: a remote
URL that was cold at that moment baked default routing forever, and the
cron job never warmed it. The job now warms the subJsonRoutingRules URL,
and the profile resolves per request with an in-memory memo (a failed
resolve is not cached), so a warmed cache takes effect without a restart.
* feat(sub): fall back to the JSON routing profile for the Routing header
Happ and INCY download the geo files a routing profile references
through the Routing response header. When the Happ header setting was
blank the header stayed unset, and clients fetched no geo files even
though a JSON routing profile was configured. A blank setting now falls
back to the JSON profile: happ/incy deeplinks pass through, inline JSON
and remote URLs are normalized to a happ:// deeplink; an unusable or
oversized value leaves the header unset. Locale captions mention the
fallback.
* fix(sub): pass routingRules arg at call sites added by main
Main gained four NewSubJsonService call sites after this branch forked;
update them to the five-arg signature so internal/sub builds again.
* fix(sub): address code review findings on the baked JSON routing
The memoised baked template never invalidated, so an edited remote
profile kept serving the superseded dns/routing subtrees until a panel
restart; bakedTemplate now re-resolves the spec per request and rebuilds
only when the payload actually changed (regression-tested).
subJsonRoutingRules shared the happ persistence row with
subRoutingRules, so only the last-written setting survived a restart;
it now resolves under its own jsonhapp kind with the same validation
and size caps. The setting also joins validateSettingsURLs, so remote
values are canonicalised and bad URLs are rejected on save.
Also: drop the unreachable half of the remote-source guard, cut the
overlong comment blocks to the two-line convention, and deduplicate
remoteSourceBadge in the General tab. Merges upstream/main (call sites
for the widened NewSubJsonService signature).
* style(sub): gofumpt the json_routing imports
* fix(sub): accept happ add/ deeplinks and bound the routing warning
The baked-JSON routing parser only recognised happ://routing/onadd/, but
normalizeHappRouting treats happ://routing/add/ as an equally valid routing
deeplink. An operator pasting the add/ form got the Routing header set, so
the panel looked configured, while every JSON subscription document silently
carried the default routing instead of their profile.
resolveJsonRoutingSpec logged one warning per call and bakedTemplate calls it
once per emitted document, so a single fetch of an unusable profile wrote one
identical warning per document. On the public subscription server that floods
the 10240-entry buffer the panel's log view reads, evicting real entries. Log
only when the message changes, and reset on a successful resolve so a profile
that recovers and fails again is still reported.
Also resolve the template once in buildBalancerConfig: two resolves could
straddle a profile refresh and pair one revision's dns with the other's
routing.
* feat(clients): support setting HWID limit and MTProto ad-tag in bulk adjust
Add HWID device limit and Telegram MTProto sponsor channel (ad-tag)
support to the bulk client adjustment flow in both the panel API
and frontend ClientBulkAdjustModal.
Co-Authored-By: Claude Code <noreply@anthropic.com>
* fix(clients): gate adTag to MTProto inbounds and avoid inbound rewrite for limitHwid
Co-Authored-By: Claude Code <noreply@anthropic.com>
* fix(clients): stamp updated_at only on the clients a bulk adjust changed
The updated_at write was gated on hasInboundChanges, which accumulates
over the whole inbound instead of describing the client in hand. Once any
client in the settings array changed, every client after it was re-stamped
as well, so whether an untouched client kept its own updated_at depended on
its position in the array. That field feeds node-snapshot conflict
resolution, where a spurious bump lets a stale snapshot value win over the
stored record.
Track the change per client and fold it into the inbound-level flag where
the stamp is written, so the early return still skips a save whose settings
JSON would be unchanged.
Also condenses the BulkAdjust doc comment back to the two-line maximum.
* docs(api): regenerate the bulkAdjust reference for limitHwid and adTag
frontend/public/openapi.json was copied to docs/public/, but pnpm gen:api
was never re-run, so the API reference page's heading, anchor id and search
index still described bulkAdjust without limitHwid or adTag. docs-ci.yml
fires only on docs/**, and that path had been touched, so nothing flagged
the stale MDX.
The externalLinks hunks are the generator rewrapping lines main had left
stale, not a content change.
* fix(i18n): stop enumerating fields in the bulk-adjust empty-form message
bulkAdjustNothing listed the fields the form accepts, so it went stale
every time one was added: only en-US ever gained "flow", leaving the other
twelve locales describing days and traffic alone, and limitHwid and adTag
would have repeated that. Say that one field is required instead of naming
which, so the message cannot drift again.