* fix(panel): default TLS ALPN to http/1.1 for WebSocket transport
WebSocket bootstraps over HTTP/1.1: leaving ALPN at the schema default
['h2','http/1.1'] makes the server negotiate h2 and the WS handshake
fails. See issue #6782.
* style(frontend): keep comments within the two-line limit
* style(frontend): keep comments within the two-line limit
* fix(clients): balance renewal form layout and mode guidance
Move renewal out of the traffic-reset half-column so its help and preview
no longer leave a tall empty area beside the reset selector. Keep the
mode, active schedule field and renewal limit in equal desktop columns,
stack them on small screens, and preserve the bulk form's stacked layout.
Keep monthly traffic-reset controls together in their own row and allow
the first-cutoff button to wrap without shrinking its click target.
Show fixed-duration guidance only for interval renewal and the month-end
boundary note only for monthly renewal. Synchronize all thirteen locales
and the client docs, remove obsolete zero-value mode-switching tooltips,
and cover the help transitions through the real client form.
Keep preview markup and existing monthly copy intact so the independent
date-display work in #6662 can merge without overlapping these edits.
This is presentation-only: saved values, expiry selection, renewal and
traffic-reset calculations, APIs and database formats are unchanged.
* refactor(clients): drop the renewal CSS that #6662 made redundant
#6662 landed the full-width preview Space and the wrapping first-cutoff
Button as inline styles, so ClientRenewalFields.css and its wrapper div
styled the same two elements a second time; both are removed.
* test(tuic): wait for downstream association activity refresh
The UDP lifecycle regression can receive its QUIC response before the
server's sending goroutine updates the association timestamp. Reading
the protected field once then mistakes a valid scheduling order for a
missing refresh, intermittently failing the race-enabled CI job.
Wait for lastActive to advance under the registry mutex, bounded by a
one-second deadline, before checking idle reaping. Retain the payload,
association ID, idle-survival and shutdown assertions so a missing
activity update still fails the test.
This only changes test synchronization. Production relay behavior,
renewal logic, network timeouts and CI configuration remain unchanged.
---------
Co-authored-by: JacktheRanger <219502738+JacktheRanger@users.noreply.github.com>
Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
* fix(clients): clarify renewal preview times and estimates
The preview rendered RFC3339 strings directly and labeled the server's Local
zone without explaining which clock it represented. Its renewal count also
looked like a record of renewals already performed.
Display readable dates with an explicit UTC offset while preserving the
server's wall time, per-date DST offset and fractional cutoff precision.
Explain the panel/server timezone and read-only preview, and label the
renewal count as estimated allowance consumption in all 13 locales.
Let the first-cutoff button wrap on narrow screens and update the English
and Chinese client documentation plus display/form regression coverage.
This changes presentation only: renewal calculations, stored expiry values,
API payloads and first-use behavior remain unchanged.
* test(clients): remove redundant renewal preview copy checks
The locale-only preview case repeated the Local timezone branch without
covering a distinct behavior. Exact textContent comparisons after matching
the same text with getByText also only pinned constant wording, contrary
to the repository's testing guidance.
Remove that duplicate case, its locale setup and the redundant copy
assertions. Keep the UTC, per-boundary DST offset, fractional precision
and original datetime checks, together with the existing real form-save
tests. Production rendering, translations and renewal behavior are unchanged.
---------
Co-authored-by: JacktheRanger <219502738+JacktheRanger@users.noreply.github.com>
The Geodata Auto-Update actions row is a non-wrapping flex row with no
gap. Its three buttons fit in English, but the longer Russian, Ukrainian
and Turkish labels make the row wider than the default-width modal, so
the buttons spill out of it. The row now wraps with an 8px gap.
.actions-row is a global class defined identically in VersionModal.css
and PanelUpdateModal.css, so both copies change to keep the cascade
order irrelevant. Verified in Chromium with a temporary Storybook story
rendering VersionModal in ru-RU: the first button sat 88px outside the
modal before the change and inside it after.
Closes#6737
* fix(tuic): restore client speed and certificate layout
* docs(tuic): clarify native runtime and protocol behavior
* fix(websocket): preserve traffic updates from independent sources
* fix(docs): sync websocket traffic schema and drop restating TUIC tests
docs/public/openapi.json still described the old traffic event, without
clientTrafficSource/clientTrafficIntervalMs or the TUIC oneOf branch, so
the docs site showed a payload the panel no longer sends. No check
covers that copy.
The TUIC certificate layout test and the TUIC speed payload test only
read back the literals the code writes, so neither could fail on a real
regression. Both are removed, along with the className that existed only
for the layout test.
---------
Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
* fix(inbounds): list clients in the detach modal when it mounts open
DetachClientsModal seeded its synced-source state with the open source,
so a modal first mounted with open and a source saw no change on its
first render and never read the client rows. The table stayed empty
until the modal was closed and reopened.
Seed the state with null so the first open render loads the rows, as
the reset-during-render check already expects. The test mounts the
modal already open and fails without this change.
Fixes#6733
* fix(inbounds): list clients in the attach modal when it mounts open
AttachClientsModal seeds its synced-source state the same way
DetachClientsModal did, so a modal first mounted with open and a source
saw no change on its first render and never read the client rows. The
table stayed empty, and nothing was pre-selected, until the modal was
closed and reopened.
Seed the state with null here too. The test mounts the attach modal
already open and fails without this change.
Refs #6733
---------
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
Invariant: an antd toast lives inside the React tree of the component that
raised it, so it takes the theme from ConfigProvider and unmounts with it.
Four components called antd's static message.* (Happ settings, TUIC form,
routing tab, command palette); the other 40 use message.useMessage. A static
toast renders in a detached root that RTL's cleanup never unmounts, so its
rAF-driven close timer kept updating state for ~3s after a test file ended.
happ-routing-editor.test.tsx logged 419 act() warnings, 226 of them after
its last test; on a slow runner the worker closed first and vitest failed
the job with "Closing rpc while onUserConsoleLog was pending" (CI runs
37075337071, 37303631843). In the product the same toasts ignored the
dark/ultra theme.
The command palette keeps its holder mounted while closed: restartXray
closes the palette before its success toast. The Happ editor test now
asserts its toast unmounts with the component (red on the static API), and
no-static-message.test.ts fails on any static call in src. The spy that
silenced message.success in happ-settings-presets.test.tsx intercepted
nothing after the change and is gone.
The remote certificate fetch now dials through the same netsafe guard
as the REALITY target scan. A private or loopback endpoint is refused
unless the request carries allowPrivate; the inbound form asks the
operator to confirm and retries with the opt-in.
* feat(tuic): implement native in-process Go TUIC v5 server
- Implement native TUIC v5 protocol server on pure Go using quic-go
- Bridge decrypted TCP/UDP traffic into Xray-core via loopback SOCKS5 inbound
- Support full Xray routing rules (geosite/geoip) and cascading outbounds
- Implement atomic per-client traffic accounting with TotalGB and ExpiryTime
- Add automatic legacy cleanup for older Rust tuic-server binaries, configs, and orphaned processes
- Eliminate external Rust tuic-server downloads from install/CI scripts
* fix(tuic): address traffic accounting, client reload, and socket lifecycle issues
* fix(service): update checkTuicSocksReverseConflict to use bindAddr for listenOverlaps
* fix(tuic): resolve traffic double-accounting, UDP fragmentation, and socket lifecycle issues
* feat(tuic): complete native Go integration and address audit findings
- Integrate an isolated QUIC fork pinned to a specific commit
- Preserve original QUIC dependencies for Xray, Hysteria and Gin
- Apply BBR, CUBIC and Reno to server connections and exported client profiles
- Bridge Xray BBR with correct monotonic time and congestion type conversions
- Handle congestion sender recreation after PMTU changes
- Update congestion control for new connections without restarting the listener
- Preserve existing connections and their selected congestion controller
- Apply per-inbound log levels through the shared panel logger
- Add lifecycle, authentication and TCP/UDP relay events without exposing secrets
- Rate-limit repeated authentication and relay warnings
- Support native and QUIC UDP relay modes on the same listener
- Recover UDP associations after relay worker failures
- Fix TCP relay cancellation, idle shutdown and half-close handling
- Close active sessions when client credentials are revoked or disabled
- Track traffic by immutable client statistics IDs across email and UUID changes
- Prevent ambiguous accounting and duplicate UUIDs within TUIC inbounds
- Persist pending traffic in a durable shutdown journal
- Replay journal batches transactionally without duplicate accounting
- Report server shutdown failures through the shared logger
- Preserve legacy flat and nested TUIC settings compatibility
- Normalize congestion controller values consistently across backend and frontend
- Preserve controller, UDP mode and SNI in client links and subscriptions
- Separate client profile options from server settings in the TUIC form
- Keep certificate path autofill explicit when changing client SNI
- Align UDP packet size validation with protocol limits
- Simplify and localize TUIC field hints and certificate autofill messages
- Add controller, TCP/UDP, logging and live settings update tests
- Add accounting identity, journal replay and shutdown regression tests
- Add relay recovery, session revocation and legacy frontend form tests
* fix(service): alias the TUIC duplicate-UUID subquery for PostgreSQL < 16
syncInboundClients runs a COUNT(*) FROM (subquery) for every client sync,
whatever the protocol. PostgreSQL before 16 rejects a FROM subquery with
no alias, so on the distro PostgreSQL install.sh provisions (14 on Ubuntu
22.04, 15 on Debian 12) every client add or edit failed with SQLSTATE
42601. Reproduced against postgres:15 with the new env-gated test.
* fix(database): create tuic_traffic_receipts through the model migration
AddTuicTrafficBatch issued CREATE TABLE IF NOT EXISTS at runtime, a schema
change outside db.go. The table was invisible to allModels and
migrationModels, so x-ui migrate-db dropped the receipts and a retained
journal could be counted twice after a SQLite to PostgreSQL move. It is
now a GORM model in both lists, and the insert uses OnConflict DoNothing.
* chore(tuic): skip the ICMP-dependent relay test on Windows, drop dead collectors
Go disables SIO_UDP_CONNRESET on Windows, so a dead UDP bridge never fails
a read there and TestAudit3UDPAssociationMustRecoverAfterBridgeReadFailure
was red on every Windows run. Server.CollectTotalTraffic and
Manager.CollectTraffic had no caller.
* refactor(tuic): serve TUIC on apernet/quic-go instead of a personal fork
The native server depended on github.com/poise52/quic-go, a personal fork
of apernet/quic-go patched only to pick the congestion controller before
the handshake. That put a second QUIC/TLS stack in the binary that no
upstream security fix reaches. apernet/quic-go is already in the graph
through xray-core and exposes SetCongestionControl, so BBR is now installed
on each accepted connection with Xray's own congestion.UseBBR; the
cross-module BBR adapter is gone.
apernet ships New Reno as its only built-in sender, so a cubic setting is
served as new_reno server-side (clients still get cubic in their profile).
The test inspectors now read the sender under congestionMutex, which the
post-handshake install writes under.
Linux loopback, single stream through Xray: 2428 -> 3383 Mbit/s (bbr).
---------
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
Bump xtls/xray-core to b26a91de4f (v26.9.30) and the three binary pins
(DockerInit.sh, release.yml Linux + Windows) in lockstep. No deleted
symbols; the sing and sing-shadowsocks indirect deps drop out with the
SS2022 rewrite.
XDNS finalmask (#6718) replaced its string lists with objects: domains
are {name, types, edns0, lenLimit, labelLimit} and client resolvers
{type, settings.addr}. The loader no longer parses the old lists, so a
single stored xdns mask keeps the whole core from starting. The new leaf
package internal/util/maskcompat converts them: "name[:type]" becomes a
domain and "name[:type]+udp://addr" a domain plus a udp resolver. A bare
name maps to TXT, the type legacy clients queried by default, and each
converted domain keeps the 1232-byte EDNS0 the old code always used
(without it the server caps answers at 512). It runs from:
- the XdnsFinalmaskObjectsFix seeder, over inbound streams, hosts, the
xray template, the global sub-JSON mask and cached subscription
outbounds;
- inbound save (normalizeStreamSettings) and GetXrayConfig, for rows
that never went through the seeder;
- both link importers, since fm= from an older panel carries the lists.
The finalmask form edits the object shape (every key needs a registered
field, or the finalmask watch drops it on save) and lifts legacy masks
on open. The udp-mask golden fixture moves to the object shape, which
TestGoldenStreamFixturesBuildInXray now builds through the core. The
wire format changed as well, so pre-upgrade clients need the new core.
WireGuard outbound (#6771) dropped settings.domainStrategy and the
remoteDNS "local" mode. The endpoint lookup now follows
sockopt.domainStrategy and in-tunnel targets the outbound's
targetStrategy. The old key is silently ignored, which undid the
IPv4-first endpoint lookup the WARP outbound depends on (#5205), and
"local" now panics the core at startup because remoteDNS goes through
netip.MustParseAddr. The WireguardDomainStrategyFix seeder moves a stored
family preference to both keys (a value already set wins) and turns
"local" into targetStrategy; the outbound form lifts legacy rows the same
way and drops its select, the WARP modal writes the new placement, and
the inbound form loses a field the server never read.
ValidateOutboundConfig now refuses a non-IP remoteDNS entry, which
conf.Build() lets through, on template save and for outbound
subscriptions.
Noise finalmask items accept type "exp" (#6862), a tag expression. The
form offers it for noise items only: header-custom items go through the
core's PraseByteSlice, which refuses it.
TUN gained autoSystemDnsToGateway (Linux) and autoSystemWfpBlockLeak
(Windows). Both pass through the settings schema so a value set in JSON
survives the next form save.
MASQUE (inbound, outbound, transport) and the XDRIVE transport are new
protocols the panel does not offer yet; their new loader refusals only
cover configs the panel never generates. The FakeDNS IPv6 pool default,
the SS2022 rewrite (same gRPC account; emails are now deduped
case-insensitively, as the panel already does), the restored udphop
interval default and the rest change no panel-facing config.
Invariant: an inbound's enabled Hosts are the endpoints every client config
for it advertises, whichever surface renders that config.
WireGuard and AmneziaWG broke it. Their raw generators ignored the
externalProxy entries Hosts are injected as and always emitted
resolveInboundAddress, so the raw subscription, the sub page .conf, the
clients links API and "export all links" gave out the panel address while
the JSON and Clash formats of the same inbound used the Host.
advertisedEndpoints now states the fan-out once for mtproto, wireguard and
amneziawg.
The browser-built configs had the same gap. The Clients page WireGuard and
AmneziaWG config blocks and QR panels, and its TUIC Clash config, used the
panel hostname next to server links that already used Hosts; the Inbounds
page peer configs, QR and export ignored them too. withMtprotoHostEndpoints
becomes withHostEndpoints over a shared hostEndpointsFor mirror of the
backend, the tunnel fan-outs render one config per Host, and the clients
page waits for the hosts list the way the inbounds page does, so an empty
list means "no hosts" rather than "not loaded yet".
Invariant: saving an inbound's configuration never changes which clients it
holds nor whether it is enabled; both have their own endpoints. The edit
modal posts back the clients and the enable flag it loaded when it opened.
A client added meanwhile (another admin, the bot, the API, LDAP) was
detached and its stats deleted; a client deleted meanwhile came back with
its credentials, restoring access that had been revoked; an inbound
switched off meanwhile was switched back on.
For every save but a master's node-sync push, UpdateInbound now takes the
client list and enable from the row it re-reads inside the writer; this
replaces the lifecycle-only carry from the previous commit. Client
validation (renewal schedule, Hysteria auth, TUIC credentials) moves after
that swap so it judges the clients actually saved: a protocol switch keeps
the stored clients, and #6268's refusal must apply to them.
The edit form no longer loads or sends clients, so neither the JSON editor
nor validation sees a copy the server ignores, and the enable switch shows
only when adding; the list toggle (/setEnable) covers existing inbounds.
Tests that added or re-keyed clients through a panel inbound save pinned
the old rule; they now drive the master-push path, where payload clients
still apply.
Invariant: saving an inbound's configuration never changes a client's
enable, expiry, quota or renewal state, nor the inbound's own traffic
counters. The inbound modal posts back the whole settings.clients list it
loaded when it opened, and UpdateInbound stored it, synced it into the
client records and wrote it into client_traffics. Any client renewed,
depleted or reset while the modal was open was reverted - a renewed client
came back disabled with its old expiry. The row itself was read before the
serialized tx and saved whole, so traffic the poll added in between was
rolled back and a depleted inbound could be re-enabled.
UpdateInbound now re-reads the row inside the writer and, for clients the
inbound already holds, keeps enable, expiryTime, totalGB, reset, resetDay,
resetWeekday and resetMax from it; those change through the client
endpoints. A master's node-sync push stays authoritative. Existing clients'
lifecycle fields are no longer editable through the inbound JSON editor or
/inbounds/update, which the API docs now state.
xray's WireGuard inbound credits a packet to the first peer whose allowedIPs
contain its source address, and routes replies by the same table. The panel
only rejected an allowedIPs entry that was string-equal to another client's,
so a pre-assigned address typed in interface notation (10.10.2.9/24, as other
WireGuard tools export it) was accepted and claimed the whole /24: other
clients' traffic and online IPs were credited to that one email, and replies
went to a peer with no endpoint ("no known endpoint for peer").
The collision check now compares masked ranges on every path that uses it:
add, edit, the cross-inbound recheck inside the write transaction, and
AmneziaWG. Auto-allocation skips any address inside a prefix another client
holds. A /0 default route still claims nothing, as before, because legacy
migrated peers carry one. Clients already saved with overlapping ranges keep
working as they do today until edited. The API docs describing the error are
updated, including the stale claim that cross-inbound duplicates are accepted.
Closes#6623
sponsors.json only had an end date, so a booked placement had to be
added to the file on the day it started. An optional `from` now hides
a sponsor (and its logo) until that instant; entries without it show
immediately as before.
Seven tabs, several holding one or two settings, made the page hard to
scan. The encrypted-links switch moves above the tabs under
auto-detection, the colour profile joins the banners under Appearance &
Theme, and Android per-app proxy joins Network & TUN Engine. The QR
modal's settings link no longer needs a happTab selector, and the three
orphaned tab-label keys are dropped from every locale.
The renewal block took a full-width column, pushing Traffic Reset onto
its own line. Each now gets a half-width column like the other fields,
with its follow-up inputs stacked under it.
* feat(tgbot): gate the bot behind three user levels
Every Telegram account that found the bot could run /help, /status and
/usage, and tap any client button it could forge: nothing separated an
account no admin had bound from a customer.
Each update now resolves to stranger, client or admin, and commands are
allowlisted per level so a command added later stays admin-only until it
is listed. A stranger may run /start and /id only, and /start answers with
the ChatID an admin needs to bind it; a stranger's callbacks are answered
and dropped. Client detection reads the same tgId lookup as
clientOwnedByTgUser, so the level gate and the ownership check agree.
The bot also ignores everything outside private chats: authorization keys
on the sender while wizard state keys on the chat, and the two are the
same identity only in a private chat.
* feat(tgbot): bind Telegram accounts through /start deep links
Linking a customer meant the customer sending /id and an admin copying
the ChatID into the client by hand, which does not scale past a few
customers and is easy to get wrong.
The admin client card now offers an invite link, t.me/<bot>?start=<subId>,
and the first account to open it is bound through the existing
SetClientTelegramUserID. A subId already grants the subscription, so
binding gives the holder nothing the token did not. A subscription that
spans several clients binds all of them, and is refused if any part
belongs to another account; re-opening your own link is idempotent.
Unknown and already-claimed tokens share one reply, so the link cannot
be used to probe for valid subIds.
* fix(tgbot): harden invite claims after review
Review of the access-level and binding change found five problems:
- Concurrent claims of one link all read the client as unbound, all bound
and all were told so, while only the last write held. Resolving and
binding now share one lock, and a bind that fails part-way through a
multi-client subscription undoes the bindings it already made.
- A subId has no minimum strength and the bot needs only its public
username, so /start was an unthrottled guessing oracle. Non-admin claim
attempts are capped at five per account per hour, the first refused one
notifies the admins, and the Subscription ID field now says it doubles
as the bot invite code.
- levelOf expanded every inbound's client JSON on every non-admin update.
It now reads the indexed tg_id column of the clients table.
- A button tapped in a group chat was dropped unanswered and kept
spinning, with nothing logged. It is answered now, and each ignored chat
is logged once.
- The subId was pasted raw into the t.me link, so '#' or '&' truncated it
and Telegram rejects anything outside A-Za-z0-9_-. The payload is now
base64url, and a subId too long for the 64-character limit is refused.
* fix(tgbot): answer group chats again and make the claim race test bite
ignoredChat dropped every non-private chat because wizard state was
keyed by chat while authorization keyed on the sender. #6604 on main
re-keyed that state by (chat, user) so admins can drive the bot from a
group, so after the merge the drop only took the whole bot away from
those admins, report keyboards sent to a group included. The level gate
already keys on the sender, so group chats need no special case.
TestConcurrentClaimsBindOnlyOneAccount passed with inviteClaimMu
removed: the first claimant took the pool's idle connection and bound
before the rest had opened theirs, so no two ever raced. It now holds
the inbound write the binds need until every claimant has resolved,
and fails without the lock ("6 accounts told they bound").
TestCommandAllowed restated the commandsByLevel map; TestGateCommand
drives the same allowlist through gateCommand. TestIgnoredChat goes
with the code it pinned.
* docs(tgbot): document access levels and invite links
The command table still said /help and /status answer anyone. An
account no admin has linked now reaches only /start and /id, and a
customer is linked through the client card's Invite Link, whose token
is the Subscription ID. Updated in en, fa, ru and zh.
---------
Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
* feat(inbound): excludeFromSub hides links without disabling
Add a per-inbound flag that omits subscription output while keeping the
inbound enabled for Xray, auth, and traffic accounting. Fixes#6435.
* fix(inbound): excludeFromSub review follow-ups
gofumpt model.go, sync docs OpenAPI, keep excludeFromSub master-authored
on node mirror, and exercise the legacy add-column migration path in tests.
* fix(sub): keep excluded inbounds' clients in the usage header
The excludeFromSub filter sat in getInboundsBySubId's SQL, so an excluded
inbound's clients never reached seenEmails in the raw, Clash or JSON
renderer. A client that lives only on a hidden inbound (one client per
inbound sharing a subId) dropped out of the Subscription-Userinfo usage,
quota and expiry and out of the info-node state, while the inbound kept
serving it and counting its traffic.
The query returns every enabled inbound again; each renderer skips an
excluded inbound's links but still counts its clients, the same rule the
Clash renderer already applies to external links it cannot express.
---------
Co-authored-by: mrchatam <mrchatam@users.noreply.github.com>
Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
* feat(clients): preserve traffic counters in portable export/import
ExportAll now attaches client_traffics up/down (plus resetCount and
last-seen fields) on each portable payload, and ImportClients restores
them only for newly created emails so skipped/existing clients keep
their live counters. Fixes#5858.
* fix(clients): restore imported traffic only onto rows the import created
Review of the portable-traffic export/import (#5858) found four defects:
- An orphan's restored row was hand-built, dropping reset_weekday and
forcing enable=true; a row kept by a keepTraffic delete kept the old
client's limits. depletedClientsClause then matched a weekly-renewing
over-quota orphan and DelDepleted deleted it. Orphan rows now go
through AddClientStat, whose upsert refreshes config and keeps counters,
so the unused traffic.total field is dropped from the export.
- Created clients were inferred from Skipped emails, so a duplicate email
in the file left the created copy with zero counters. bulkCreate now
reports which payloads inserted a record, and only those are restored.
- Each client took its own serialized-writer commit: 2000 clients spent
3.66s instead of 0.52s. Counters now apply in batched transactions
(0.51s).
- importClients discarded needRestart when the late restore step failed
after clients were committed; it now flags and notifies first, as
create already does.
The /clients/export and /clients/import API docs now describe traffic.
* fix(groups): keep imported traffic out of group totals
Group totals keep a deleted client's usage (#5675), and the portable
import restores that same usage onto the re-created client. Export,
delete, re-import therefore counted it twice in ListGroups, and a fresh
panel showed the migrated usage as consumption of its groups.
Restored counters are usage from before the import, so the import now
shifts each group's baseline up by what it restored, in the same
transaction. A group total no longer moves at import time; only traffic
consumed afterwards counts. The baseline shift reuses the #5675 helper,
now signed.
---------
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
* feat(sub): add Incy app-management parameters
The panel already pushes a set of Happ headers, but INCY documents its own
lowercase header names and its own value domains, so a Happ-shaped payload gets
ignored by the client (per-app mode is bypass|proxy, not on|bypass, and
per-app-proxy-enable has no Happ counterpart at all). Add a sibling Incy path
that emits exactly the documented headers.
Covered, per https://docs.incy.cc/en/app-management/:
- profile-description, sort-order, support-email, announce-url, premium-url
- banner text/button/URL and the two hex colours
- hide-url, hide-check, no-limit-enabled
- per-app split tunnelling (enable/mode/list)
- TCP fragmentation (enable/length/interval/packets)
- UDP noise packets (enable/type/packet/delay)
- DoH pre-resolution (enable/domain/IP)
Each string setting is tri-state: an empty value omits the header, so an
untouched panel never overrides the subscriber's own choice in the app. Values
are validated against the documented domains and dropped when they do not
match, and non-ASCII text is base64-wrapped the way the docs require for
Cyrillic. INCY identifies itself as INCY/<version>/<platform>, which gates the
headers behind the same auto-detect switch the Happ path uses.
Headers the panel already emits for every client (Profile-Title, Support-Url,
Profile-Web-Page-Url, Announce, Profile-Update-Interval, Subscription-Userinfo)
and Incy's routing line are left as they are.
The Premium API (theme, defaultPingProtocol, fallbackHosts, ...) is a separate
encrypted endpoint and stays out of scope here.
* fix(sub): keep Incy per-app list entries separate on the wire
The Incy settings textarea takes one package per line, as Incy documents for
per-app-proxy-list, but the header path ran the value through
sanitizeHeaderValue, which deletes CR/LF. "com.google.chrome\norg.telegram.messenger"
reached the client as the single bogus package
"com.google.chromeorg.telegram.messenger", so per-app split tunnelling silently
matched no app. Join comma- or line-separated entries as CSV instead.
Also drop three tests that could not fail: TestIncyExcludesHappOnlyHeaders
(ApplyIncyHeaders has no path that emits Happ headers, and the non-Happ UA
gate is already pinned by TestApplyHappHeaders_Gating) and two UI tests that
only asserted updateSetting received the key the JSX passes it.
---------
Co-authored-by: DIMFLIX <dimflix@users.noreply.github.com>
Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
* feat(sub): make external subscription fetch User-Agent configurable
Some providers reject fetches that do not send a known client User-Agent.
Expose externalSubUserAgent as a panel setting (default v2rayNG/1.8.5)
and use it when fetching client external subscription URLs.
Fixes#6383
* 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.
* fix(sub): fall back to the default UA when the DB is not initialised
externalSubUserAgent read the setting through SettingService.getSetting,
which calls Model() on database.GetDB() and panics on a nil *gorm.DB.
The fetch path's other DB read, service.ExternalSubscriptionHwid, already
treats a nil DB as unreachable and sends no header; the new UA lookup
did not, so any fetch before InitDB panicked instead of sending the
historical v2rayNG/1.8.5.
Production initialises the DB before the sub server starts, but the
internal/sub fetch tests run without one: under make test-go's
-shuffle=on, whenever one of them ran before the first InitDB test the
panic aborted the whole package. Reproduced deterministically with
go test -run '^TestDoFetchSubscriptionLinks_RejectsOversizedBody$'.
---------
Co-authored-by: mrchatam <mrchatam@users.noreply.github.com>
Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
* feat(clients): add calendar weekly renewal and schedule previews
Expose fixed-day, calendar-weekly, calendar-monthly, and disabled renewal
through one shared selector in individual and bulk client forms. Store the
weekly weekday separately (Monday 1 through Sunday 7) and use panel-local
calendar dates rather than a fixed 168-hour duration. Resolve skipped or
repeated midnights to the first valid instant of the selected date, and skip
an entirely nonexistent calendar date rather than changing the weekday.
Reuse the existing renewal writer and share its boundary alignment and
per-period catch-up calculation with an authenticated, read-only preview.
Keep monthly precedence for legacy records, fixed-day interval semantics,
maximum renewal allowances, first-use durations, and operator-disabled
settings unchanged. Selecting a mode does not rewrite an existing cutoff;
an unset calendar cutoff requires an explicit action to choose the first.
The last-valid-second preview uses the stored exclusive expiry, even when
the billing calculation aligns a legacy last-second cutoff up to midnight.
Carry weekly schedules through client persistence, paging, enable toggles,
inbound settings, and node traffic reconciliation. Migrate missing or nullable
weekday columns to disabled by default without altering existing limits, and
include the new isolated-schema PostgreSQL regression in the live CI gate.
Regenerate API contracts and reference documentation, add lifecycle and form
regressions, and document timezone, quota-reset, and upgrade considerations.
All participating nodes must be upgraded before weekly mode is enabled;
older binaries ignore the new field. Independent periodic traffic resets and
the optional month-end subscription-header display are not changed.
* fix(clients): validate renewal schedules across inbound write paths
Reject conflicting weekly/interval/monthly schedules and out-of-range
weekdays on inbound creation and edits, legacy one-client apply paths,
record/link synchronization, and traffic metadata writes. Validate imported
traffic snapshots as well, before any inbound or client is persisted, so
an inbound API cannot create a client that the clients page cannot toggle.
Merge a weekly-related schedule as one timestamp-selected tuple rather
than filling its zero fields from another renewal mode. Preserve empty
migration snapshots and the existing non-weekly monthly/interval merge
semantics. Renewal caps, counters, credentials, and deadlines are unchanged.
Add regressions for nine write paths, unchanged records and runtime calls
after rejection, valid inbound clients remaining editable, and duplicate
record merges between individually valid renewal modes.
* docs(clients): clarify depleted-client deletion risks on downgrade
Explain in English and Chinese that older versions not only stop weekly
renewal: their depleted-client cleanup can delete a weekly-only client once
its expiry or quota is exhausted. This is conditional on cleanup, not an
automatic deletion caused by downgrade itself.
Recommend backing up and converting weekly schedules to a mode supported
by every participating version before rollback, and avoiding cleanup while
mixed versions or unconverted clients remain. Merely disabling weekly
renewal does not restore the old binary's missing purge protection.
* fix(clients): bound weekly renewal date searches
Limit the search for a valid weekly calendar date to eight candidates so
an unusual timezone cannot monopolize the single traffic writer. Exhaustion
returns the original instant, allowing the existing catch-up forward-progress
guard to stop without advancing expiry, consuming an allowance, resetting
traffic, or falling back to a fixed-duration schedule that can drift.
Reject a non-future calendar suggestion in the read-only preview instead of
offering an immediately expired initial cutoff. Also report failed weekly
catch-up as a search error when allowances remain, not as cap exhaustion.
Existing preview errors use the form's current warning; no API schema or
locale changes are needed.
Exercise exhaustion with a synthetic valid TZif containing twelve skipped
Sundays. This fault-injection case was red without the bound; it is not a
claim that a production IANA timezone was observed hanging. Keep the Havana
and Apia regressions for real skipped/repeated midnights and absent dates.
* fix(tests): isolate weekly renewal preview timezone
Stop the weekly search regression from replacing process-global time.Local.
CI caught that assignment and its cleanup racing with background timer reads
through time.Now, even though the top-level tests do not use t.Parallel.
Pass the timezone and current instant into the unchanged preview calculation.
The public service still validates the request and resolves the panel timezone;
API responses, renewal accounting, and persisted client data are unchanged.
Use fixed dates for both suggestion and catch-up exhaustion, removing the
test's dependency on today's date and its unnecessary database setup. Keep a
bounded-lifetime background clock reader to expose future global-timezone
mutations under the existing race gate rather than disabling that check.
* ci: retrigger PR checks
Create an empty commit to request a fresh pull-request CI run after release dependency downloads failed with network errors.
No source, dependency, or workflow changes are included. Retry the existing checks without bypassing them.
* ci: retry PR checks and record deferred download hardening
Request another CI run after the amd64 release job compiled successfully but failed during dependency fetching with exit code 4 (network failure).
Record possible follow-up improvements for the Linux release fetch helper:
- Print each download URL and destination, and preserve error details.
- Reuse the existing curl configuration with up to five retries; add connection and per-attempt timeouts and a bounded retry window.
- Download to a temporary file and promote it to the final filename only after a successful, non-empty transfer. Keep the job failing if downloads ultimately fail.
- Validate successful downloads, recovery after a temporary failure, and correct failure after persistent errors before shipping such a change.
These improvements are intentionally deferred, not implemented or tested by this commit. This commit is empty: renewal logic, dependencies, workflow configuration, check requirements, and TLS verification remain unchanged.
---------
Co-authored-by: JacktheRanger <219502738+JacktheRanger@users.noreply.github.com>
* 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>
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.
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.
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.
* 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.
* 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.
* 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>
* 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)