* fix(xray): keep balancer overrides across routing reloads
A balancer override set from the panel lived only inside the running
core. Every routing hot-apply (any Xray-settings save touching rules or
balancers) and every node test through an outbound calls
RoutingService.AddRule with shouldAppend=false, which rebuilds all
balancers, so the override vanished silently even though the API docs
promised it lasted until an Xray restart.
The panel now records the overrides it sets, per core process, and
re-applies them after its own routing reloads. Targets are validated
against the running config: the core accepts any tag and then closes
every connection routed to one it lacks, so an unknown target is
refused, and an override whose outbound is removed is cleared instead
of black-holing the balancer. Balancer targets resolve to their _bl_
loopback from the running config rather than the saved template.
Restarting Xray still clears overrides, as documented.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KrJrzn9LKaPhMq9h3Fb5TV
* fix(xray): serialize overrides and the node bridge with config applies
Review of #6779 found two races. Neither path took the package lock that
RestartXray holds while it changes the running core:
- An override landing mid hot-apply was checked against the config
snapshot the apply was replacing, so it could pin a balancer to an
outbound the core had just removed. OverrideBalancer now takes the lock.
- The node bridge's teardown checked overrides against the snapshot it
took before the probe, which can run for seconds, so it dropped an
override set meanwhile onto a newly saved outbound.
The same stale snapshot drove the bridge's routing restore. That restore
silently reverted rule changes saved during a probe. After a restart, it
also pushed the old core's rules and overrides into the new core through
the shared API port. The bridge is now built and torn down under the lock,
which is not held while the probe runs. At teardown it restores the
routing that the core's config holds then, and it leaves alone a core that
was restarted meanwhile.
With every caller under the package lock, balancerOverrideSet drops its
own mutex.
VLESS inbounds whose settings carry no "encryption" key (rows saved by
panels before the field existed, or inbounds added over the API with only
"decryption":"none", as the API docs example does) produced a JSON
subscription outbound with "encryption": "". Xray refuses to load that
config ("VLESS users: please add/set \"encryption\":\"none\" for every
user"), so every VLESS entry of the JSON subscription was unusable. The
share link left the parameter out instead.
Treat a missing or empty value as "none" for both outputs, matching the
frontend inbound schema default and the docs link generator.
* fix(amneziawg): respect tunnel MTU when writing UDP replies
Send replies through gVisor FindRoute/Route.WritePacket instead of WriteRawPacket so the network endpoints apply the tunnel MTU and fragment oversized IPv4/IPv6 datagrams. Preserve UDP checksums including the zero-complement IPv6 case and validate lengths before narrowing to uint16.
Regression tests inspect and reassemble actual channel-backed TUN output at and above the MTU, verify checksums and exercise concurrent sources.
* docs(review): put the tally before Design and name who can store a trigger
xray-core opens an XDRIVE stream's storage only when the inbound starts
listening, so a Google Drive inbound saved with the form's defaults (no
folder, no secrets), a local one without a folder, or a template one
whose template xray cannot use stopped xray and every other inbound with
it. validateXDriveStorage applies the core's storage rules at save, on
inbound add/update and on the xray template's inbounds and outbounds:
the service must be local, Google Drive or template; local and Google
Drive need a remoteFolder; Google Drive needs its three non-empty
secrets; a template must pass newTemplateStorage's checks (put, get,
list and delete urls, and a list namesRegex with one capture group).
A VLESS client kept its xtls-rprx-vision flow when its inbound moved to
a transport that cannot carry it, and GetXrayConfig only honoured
DisableFlow, so the server demanded Vision there and refused every
client. GetXrayConfig and the outbound form now apply the Vision gate,
and that gate now follows the core in the backend, the subscription
service and the frontend: TLS/REALITY on raw TCP, or VLESS encryption on
any transport (xray runs Vision inside encryption.CommonConn whatever
the transport, not only on XHTTP). The outbound modal passes the VLESS
encryption into the gate and gates Mux on the effective flow.
chunkStrings/chunkInts sized their result with (len+size-1)/size, so a
size near MaxInt wrapped negative and make panicked with "cap out of
range"; count the chunks with div+remainder instead. NormalizeUDPHop's
len-hops+1 is bounded by len(masks) since hops >= 1, so cap at that.
Resolves CodeQL go/allocation-size-overflow alerts #119 and #120.
emailUsedByOtherInbounds and emailsUsedByOtherInbounds always queried
through database.GetDB(), yet five callers run them inside a transaction:
both node traffic sync sweeps (setRemoteTrafficLocked), the inbound edit
stats sync (updateClientTraffics) and both email-drop branches of
UpdateInboundClient. Each call needed a second pool connection while the
transaction held row locks on client_traffics/node_client_traffics. On
PostgreSQL, once subscription fetches (UPDATE client_traffics SET
last_sub_fetch) blocked on those locks filled the rest of the 25-connection
pool, the lookup waited forever, the transaction never ended, and the panel
hung until restart.
Both helpers now take the *gorm.DB to query on; transactional callers pass
tx and the rest pass the pool handle they already hold. The new tests cap
the pool at one connection, so any query that escapes the transaction
blocks; each went red before the fix on SQLite and (node sync) on a live
PostgreSQL 16, and passes after.
Closes#6789
xray-core builds a Shadowsocks-2022 inbound with users only for the AES
methods: the multi-user identity header is AES-only, so the config
builder and the multi-user server both reject chacha20. Every panel
inbound carries clients, so picking 2022-blake3-chacha20-poly1305 in the
inbound form kept the whole core from starting; its links also failed
on the client with "multi-key is not supported".
The inbound form no longer offers the method and AddInbound/UpdateInbound
reject it. Startup moves stored inbounds to 2022-blake3-aes-256-gcm,
which takes the same 32-byte keys, so every stored key stays valid.
Outbounds keep the method, since a single-key outbound to such a server
works. The share-link generator drops the single-user branch that only
this method reached.
xray-core parses the inbound-level Shadowsocks-2022 key with ParseKey and
refuses one of the wrong size while building the inbound, which keeps the
whole core from starting, not only that inbound. Only client keys were
checked, so an API caller posting a 32-byte key on aes-128 (or keeping a
16-byte key after switching to aes-256/chacha20) saved fine and took
Xray down.
AddInbound and UpdateInbound now reject such a key with a message that
names the method and the size it needs. The check uses the core's own
GetCipherMethod/ParseKey, so a raw non-base64 key the core accepts stays
valid. Startup also replaces a stored wrong-size server key: that inbound
never worked, so no client holds a working copy of it.
The panel scheduler runs WithSeconds(), whose parser rejects a standard
five-field crontab such as "30 8 * * *". An admin who typed one into the
Telegram/Discord notify time or the LDAP sync schedule saved it without
error, AddJob then failed at startup, and the job silently never ran.
Admin-typed schedules now go through internal/util/cronspec, which takes
an optional seconds column plus the @descriptors and hands the scheduler
a parsed cron.Schedule. CheckValid rejects an unparseable schedule on
save, and a stored legacy value falls back to the job's default instead
of dropping the job.
xray-core needs a salamander (and gecko) password of at least 4 bytes
(smPSKMinLen), but checks it only when a socket opens: xray run -test
reports Configuration OK. On an inbound the listener then fails and Xray
does not start at all, taking every other inbound down; on a client the
dial fails. The panel saved any password, and the finalmask editor
starts a new salamander mask with an empty one.
maskcompat.CheckSalamanderPasswords applies the core's byte rule, pinned
against the core's own ListenPacket, and every path that stores a
finalmask refuses a short password: inbound add and update, hosts, the
subscription's global finalmask, and the inbounds and outbounds of the
Xray template. The editor flags it inline as it is typed.
xray-core reverses finalmask.udp and refuses, at dial time, a mask that
dials its own socket (udphop) anywhere but the socket end: "incorrect
index". xray run -test still says Configuration OK, so the failure only
shows as a client that never connects.
UDPHopClientMaskFix turned a global subJsonFinalMask hop and a host hop
into udphop masks; the JSON subscription then appended both, leaving two
hoppers with the first one misplaced, and a host mask merged after a
global hop pushed it off the end too. A hand-ordered template outbound
or an imported fm= could carry the same order.
maskcompat.NormalizeUDPHop keeps the last-listed udphop alone at the end
of udp, and runs wherever a client finalmask is produced: each JSON
subscription endpoint, share-link fm=, the Go and frontend link
importers and the template outbound heal in GetXrayConfig. The JSON
subscription resolves competing hop ranges most specific first: a host
hop wins over the inbound's own, which wins over the global default.
MASQUE logs a user in with the email as the Basic-auth user name, so
xray-core refuses the whole inbound (MASQUE: invalid "email") over one
email holding ":", and with it the Xray start. The panel accepts such an
email for every other protocol, so attaching or creating that client on
a MASQUE inbound took every inbound down on the next restart.
syncInboundClients is the write every create, attach, bulk, import and
inbound-edit path shares inside its transaction, before the runtime
push, so the per-protocol check there now refuses the email for MASQUE
next to the existing TUIC identity check, loading the protocol once.
UpgradeLegacyUDPHop deleted udpHop from the quicParams map it was handed
in place. The JSON subscription runs it once per endpoint on a finalmask
that mergeFinalMask (host masks) or withLegacyFragmentRanges cloned only
shallowly, so the first endpoint emptied the inbound's shared quicParams
and every later endpoint got {"quicParams":{}} and no udphop mask: a
hysteria inbound with port hopping and two hosts hopped on the first
host's config only.
The upgrade now replaces quicParams and udp on the finalmask it is given
instead of writing into values a merged finalmask shares, as the
frontend's upgradeLegacyUdpHop already does.
xray-core 26.9 added three TUN settings the inbound form could not set:
- desc, the tunnel type a new Wintun adapter is created with on Windows.
The settings schema did not list it, so the parse stripped it and a
value entered as JSON vanished on the next save;
- autoSystemDnsToGateway (Linux), which points systemd-resolved at the
TUN gateway;
- autoSystemWfpBlockLeak (Windows), whose dns / misconfigtun filters keep
traffic from leaking past the TUN.
The tooltips name the platform each one acts on and what it needs, since
the core refuses to build or start the TUN otherwise.
xray-core 26.10.10 can hand DNS and routing to Lua: dns.script and
routing.script name a .lua file (absolute, or found next to the Xray
binary) that defines HandleDNSQuery / HandleRoute, and a DNS server's id
is how a DNS script picks it. The keys round-tripped through the JSON
template but had no field.
- DNS: a Lua Script field, and an ID field in the server modal;
- Basics: a Routing Script field beside the routing strategy;
- Routing: a warning above the rules while a script is set, since the
core then routes only through the script and skips every rule, the
panel's api rule included.
The descriptions say the script must still send the api inbound to the
api outbound, and that Xray will not start if the file is missing. An
empty field drops the key, because the core rejects an empty path.
xray-core 26.6 grew the finalmask surface the editor could not express,
and the finalmask watch drops any key without a registered field, so a
config written by hand lost these keys on the next save:
- header-custom items can capture their bytes into a variable, reuse one
or emit a transform expression; a Kind select keeps each item to exactly
one kind, as the core requires, and only parsed JSON reaches transform;
- TCP header-custom gains the errors group (the reply sent when a client
group does not match), UDP header-custom its prefix/standalone mode;
- sudoku is offered as a UDP mask, and share links keep it in fm=;
- the noise reset is an Int32Range, so it now takes "60-120" as well.
The new golden fixture is saved unchanged by the outbound editor and
built by xray-core in TestGoldenStreamFixturesBuildInXray.
xray-core 26.9.9 dropped finalmask.quicParams.udpHop for the client-only
"udphop" UDP mask {mode, interval, remoteIPs, remotePorts}. The panel kept
writing the old key, so hysteria/xhttp outbounds and JSON-subscription
clients silently stopped hopping ports.
An inbound keeps quicParams.udpHop: it is panel metadata advertised as the
link's mport, and a udphop mask on a listener fails with "udphop: client
only". The conversion (maskcompat.UpgradeLegacyUDPHop, intervalRemote mode)
runs only where a client config is produced:
- UDPHopClientMaskFix seeder over hosts, sub-JSON finalmask, cached
outbound-subscription outbounds and template outbounds (never inbounds);
- GetXrayConfig heals template outbounds at build time;
- the JSON subscription upgrades each generated client stream;
- the Go and frontend link importers upgrade a legacy fm= param.
The finalmask editor gains a side prop: the client side lifts the dead key
on mount and offers a udphop mask editor (also for xhttp's HTTP/3 dial);
the inbound side keeps the UDP Hop switch, labelled as advertised.
The inbound add and update handlers bind the body into model.Inbound and
validate Protocol against a oneof list that predates MASQUE, so every
save of a MASQUE inbound from the panel or the API failed validation
even though the rest of the stack (b04039c2) handles the protocol.
Add masque to the list and regenerate the schemas and OpenAPI document
that mirror it.
xray-core's masque transport reaches Cloudflare WARP with an enrolled
ECDSA P-256 key, the endpoint's public key and the assigned tunnel
addresses, which only a WARP enrollment hands out.
A new regMasque action on /panel/api/xray/warp/:action registers a
separate WARP device and enrolls a secp256r1 key for the masque tunnel
(the API revision and client version the WARP apps enroll MASQUE keys
with). Enrolling replaces a device's WireGuard key, so it never touches
the stored WireGuard registration. It returns the PEM key pair, the
addresses and the endpoint, and the WARP modal turns them into a
warp-masque outbound (HTTP/3, Cloudflare's MASQUE SNI), or refreshes
the one already there.
xray-core v26.10.10 serves MASQUE: CONNECT-IP over HTTP/3 or HTTP/2,
always behind TLS, as an inbound and an outbound protocol riding a
dedicated "masque" transport. The panel could not create either.
Inbound: MASQUE is a multi-user protocol whose users log in with their
email and password over Basic auth. The panel keeps the client's
"password" like trojan and GenXrayInboundConfig hands it to the core as
"pass", so the full-config path, the live AddInbound path and node
pushes all emit what the core reads; one user with an empty pass makes
the core refuse the whole inbound. Live user adds build a masque.Account
from either key, client-only changes diff per user so a new client does
not drop every connected tunnel, and client creation, copy, validation
and edits key on the password. The address pool and MTU get form
fields, TLS is mandatory, and the port check follows the core's
listener choice: HTTP/2 on TCP when the ALPN offers h2, HTTP/3 on UDP
when it offers h3 or not h2. Nodes may host MASQUE from v3.9.1, the
first panel release that knows to rename the password.
MASQUE has no share-link format, so links and Clash skip it; the JSON
subscription builds the client's masque outbound with its email and
password, and the subscription allowlist now lets a subId see MASQUE
inbounds at all. The IP-limit job, client pages and traffic tracking
list the protocol.
Outbound: the form edits the server, remote DNS, and the transport's
host, path, Basic credentials, headers and WARP block (enrolled key,
endpoint public key, tunnel addresses), which xray-core refuses next to
user/pass. The outbound test probes it through the core, since its
default HTTP/3 dial is UDP.
xray-core v26.10.10 tunnels a stream through files in shared cloud
storage ("network": "xdrive"): Google Drive, a local folder, or any HTTP
storage API described by a template. The panel could not select it, and
the stream schema rejected a pasted xdrive stream outright.
The inbound and outbound forms now offer XDRIVE with one shared editor:
service, folder, the three Google Drive secrets in the order the core
reads them, a template JSON editor with its {secretN} values, the
optional domain front (the stream-level address/port the storage client
dials), and the core's tuning knobs with their defaults as placeholders.
Switching an inbound to XDRIVE resets security to none, because the
core would otherwise read the inbound's TLS/REALITY as the storage API's
client TLS.
XDRIVE has no share-link format, and a link carrying the storage
secrets would print them into every QR code, so both link generators
emit none for it; the JSON subscription already passes the stream
through intact, and Clash drops the unknown transport.
Move the three Xray binary pins (DockerInit.sh, both release.yml
fetches) to v26.10.10, in lockstep with the xtls/xray-core module that
82c77111 already bumped to 701af60772cd.
xdns finalmask (#7090): a domain now takes a "names" list instead of a
single "name", and a resolver is an "addrs" list of udp:// or tcp://
URLs instead of {type, settings:{addr}}. The loader ignores the old
keys rather than failing, so every mask the 26.9.30 seeder wrote would
load with no domain and no resolver and the tunnel would carry nothing.
maskcompat.UpgradeLegacyXdns now lifts both the pre-26.9.30 string lists
and the 26.9.30 objects straight to names/addrs, dropping entries the
old loader refused. A new seeder (XdnsFinalmaskNamesAddrsFix) reruns it
over every stored finalmask, and the save path, GetXrayConfig, both
link importers and the mask editor's mount lift go through it. Template
outbounds now get the build-time heal too: a 26.9.30 template pasted
after the seeder passes the save check, since the core loads that shape
without error. The editor edits names and addrs as tag lists and no
longer requires record types, which the core now defaults (TXT on a
client, A/CNAME/TXT/AAAA on a server). The wire format did not change,
but clients on an older core cannot read the new config shape.
Lua scripts (#6823): dns.script, routing.script and dns servers[].id
are new. The xray page parsed the dns block with a strict schema, so
loading it dropped script and id and the next save deleted them; the
schema now keeps both and the DNS server modal carries the id through
an edit. The hot routing apply built routing in-process, and that build
now resolves the script file from the panel's working directory, so a
script beside the xray binary failed every rule change into a full
restart and broke the node bridge. RoutingService cannot change the
script anyway, so the panel no longer resolves it there.
TLS client roots (#7105): the core now verifies against its bundled
Mozilla roots instead of the OS store unless tlsSettings.useSystemCA is
set. The outbound TLS form gets a switch for it, so an outbound to a
server signed by a privately installed CA can opt back in.
* test: stop swapping http.DefaultTransport in the Happ network test
The Happ local-generation test replaced the http.DefaultTransport global to
refuse sockets. resolvePublicIPsInBackground fires a fire-and-forget resolver
(ServerService.GetStatus -> resolvePublicIPs -> getPublicIP), and that
goroutine can outlive the test that scheduled it, so a later test writes the
global while the resolver is reading it: a data race that `make race`
(-shuffle=on) only reports when the status test happens to run first.
Route the lookup transport through an atomic swap point and count in-flight
lookups, so the test stubs its own path and waits for earlier resolvers to
settle before installing it. That also stops another test's dials from
landing in this test's zero-network tally.
Reproduced on the parent commit with -shuffle=6 (read in getPublicIP,
previous write in TestHappGenerateLocallyWithoutNetwork); the same seed is
clean with this change, across repeats and for the whole package under -race.
* test: prove the Happ network guard intercepts panel egress
The PR review pointed out that HappService.Generate never reaches a stub
installed only on the public-IP lookup, so the "zero network attempts"
assertion could not fail. Move the override onto the panel's shared egress
seam — getPublicIP and SettingService.NewProxiedHTTPClient — and gate the
test on a canary request that must be refused and must move the tally, so a
future outbound call from local generation is reported instead of passing
unnoticed.
* test(service): stop the cold-status test leaking a public-IP resolver
The race CI hit (run 37345781756) came from TestCurrentStatusSamplesBeforeFirstTick:
its CurrentStatus call starts resolvePublicIPsInBackground, a goroutine that
makes real internet requests and outlives the test. On a runner without
IPv6 every lookup waits out its 3s timeout, so the goroutine is still
building clients from http.DefaultTransport when the Happ test swaps it.
Pre-settling the IP cache keeps that test from starting the resolver at
all, so no unit test reaches the internet or leaks the goroutine. This
replaces the earlier panelEgressTransport / panelEgressLookups seam, which
added test-only hooks to production code without removing the leak.
Reproduced in golang:1.27-bookworm with egress routed to a blackhole
(HTTPS_PROXY=http://10.255.255.1:9, -race -count=3 on the two tests): the
same server.go:406 race as CI before, clean over -count=5 after.
---------
Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
* 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>
The WireGuard branch now copies PublicKey from the matching inbound
settings client. When that entry's KeepAlive is nil the peer keepalive
is cleared, and when it is set the integer is copied into a new pointer.
The dual-tunnel test seeds different keys and keepalives, checks that
the shared row still has the AmneziaWG identity, and expects the emitted
WireGuard peer to use the WireGuard public key and keepalive, including
a case where the WireGuard settings entry has no keepalive and the peer
omits it.
Fixes#6731
* 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>
Keep sniffed TLS domains available for routing without replacing the destination address requested by the TUIC client. Add a regression assertion for the generated relay sniffing settings.
When a node snapshot reports an inbound under a tag that matches neither
the central tag nor an origin/port alias, SetRemoteTraffic adopts it as a
new central inbound and, in the same tick, sweeps the old one. The sweep
deleted the old inbound's client_traffics rows even though the snapshot
still reported those emails under the new inbound. existingEmails was
computed before the sweep, so the rows were not recreated that tick; on
the next tick the inbound was no longer new, the rows came back seeded at
0/0 and the node baseline jumped to the node's totals. Quota accounting
lost every byte the clients had used.
The sweep now moves rows for emails the snapshot still reports to the
inbound now carrying them, instead of deleting them. Only emails the node
no longer reports at all lose their row, mirroring the existing baseline
rule.
The alias check also treated a master-created node inbound that had not
been tag-matched yet (empty origin_node_guid) as foreign, so a renamed tag
on its first sync replaced it instead of aliasing it. An empty origin now
counts as this node.
PostgreSQL lane not run locally; the change is a plain UPDATE through GORM.
Closes#6749
The JSON subscription flattens tlsSettings into xray's client shape before
the per-host loop, then applyExternalProxyTLSToStream writes the host's
echConfigList, verifyPeerCertByName, pinnedPeerCertSha256 and
allowInsecure into the panel-only tlsSettings.settings map. Xray ignores
that map, so a host's ECH (and its pins / verify name) never reached the
client and connections through the host failed.
After the host overrides are applied, lift that map through the same
writer tlsData uses for the inbound's own TLS: fields land at the top of
tlsSettings, pins are joined into the comma string xray parses, and
allowInsecure is dropped exactly as it is for the inbound (removed from
xray). The Clash renderer still reads the nested map and is unchanged.
The helper-level subtest that pinned the nested location as the "json"
shape is replaced by an end-to-end GetJson test on a host-backed inbound.
Closes#6743
Invariant: a node snapshot may move a master client's traffic state only
for emails the reporting inbound's settings still list.
A master detach calls the node's /detach endpoint, which keeps the stat
row (keepTraffic), and the node's GetInbounds preloads ClientStats by
inbound_id alone, so the node goes on reporting the detached email under
that inbound. setRemoteTrafficLocked merged every reported entry: it
copied the node's stale total onto the master's per-email row, after
which the matching limits made the node's enable=false look genuine and
latched the client disabled on the master. A master quota edit only
reaches inbounds the client is attached to, so the next sync (every 5s)
wrote the stale quota back.
The same unchecked loop also created a fresh client_traffics row for a
reported email the master no longer had once its tombstone expired.
Snapshot entries are now gated on the inbound's settings.clients. When
the settings cannot be parsed, membership is unknown and every entry is
merged as before.
Closes#6724
* 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(sub): append host serverDescription to hysteria links
A host's Description reached vless/trojan/ss through buildEndpointLinks, but
genHysteriaLink renders the fragment in its own externalProxy loop and never
added the suffix, so Happ fell back to its "Hysteria | hysteria | TLS" caption
for every Hysteria server on a host that also serves VLESS (#6738).
Reuse appendHappServerDescription with the description the endpoint map already
carries, so no key lookup is duplicated and a host with no description emits the
same bytes as before. genTuicLink (service.go:912) has the same gap; left alone
to keep this diff to the reported protocol.
Regression test is red without the fix for both hysteria:// and hysteria2://.
* chore(sub): trim the hysteria serverDescription regression test
The no-description test passed with and without the #6738 fix: the empty
description branch is already pinned by TestAppendHappServerDescription, so
it certified nothing about this change. Also cut the remaining test's comment
block to the two-line limit CLAUDE.md sets.
---------
Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
* fix(node): don't delete clients when a node reports an empty snapshot
A node snapshot that comes back with zero clients for an inbound the hub
still has clients on was treated as authoritative: SyncInbound strips
every link for that inbound, the orphan sweep marks the now-linkless
clients, and ReapSyncOrphans hard-deletes them once the grace period
elapses. But a zero-client snapshot is indistinguishable from a degraded
node — one that was just deleted, reset, restarted, or answered before
its config loaded. On 2026-10-04 this deleted clients across the whole
hub when a single node was removed.
Treat a zero-client snapshot for an inbound that still has clients as
non-authoritative: skip the link rebuild and the orphan sweep for that
inbound (the same handling a failed SyncInbound already gets) and wait
for a snapshot that carries clients. Removing a node's last client is
done from the hub (which updates links and pushes); a node still serving
other clients prunes a removed one through the existing partial path.
The two orphan tests that drove removal via an empty snapshot now drive
it via a partial snapshot (node alive, still serving another client),
the authoritative path. New tests in node_degraded_snapshot_test.go
cover the guard and its narrowness.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(node): keep hub settings on an empty node snapshot; address review
The empty-snapshot guard skipped only the link rebuild and orphan sweep,
but Phase A had already adopted the node's `{"clients":[]}` blob into
`inbounds.settings`. Reconcile builds each push from that blob, so the hub
kept re-pushing an empty client list and a reset/restarted node never got
its clients back — the guard fired forever. Phase A now refuses to blank the
settings of an inbound the hub still populates, so the hub stays
authoritative and reconcile re-pushes the real clients (recovery). A
node-side removal of the last client is therefore hub-authoritative by
design; the partial-snapshot path still prunes an inbound that reports other
clients.
- test: assert the inbound's settings survive an empty snapshot (fails
without the Phase A fix).
- drop TestSetRemoteTraffic_EmptySnapshotSurvivesReap (no branch the core
test doesn't already cover) and the duplicate orphanMark helper
(readOrphanMark already exists); trim the comment blocks to the 2-line
CLAUDE.md limit.
- fix a pre-existing -race/-shuffle flake: TestGetAmneziaWGLogs owned no DB
and relied on the ambient global one, which a sibling's dbtest cleanup
closes under shuffle, panicking on nil in amneziawgLogActivity. It now
owns a throwaway DB.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(node): re-push hub clients to a node that reports an empty snapshot
23c51074 stopped the empty snapshot from blanking inbounds.settings, but
nothing re-sent those settings: the node was never marked dirty, and the
reconcile fingerprint from the last good push still matched, so
ReconcileInbound skipped the inbound. A reset node stayed empty while the
hub kept handing out links to it. The ClientStats sweep also deleted the
node baseline on that tick, so traffic used until recovery was lost.
The empty snapshot is now classified once, before adoption: its settings
and wire fields are not adopted, the ClientStats sweep, link rebuild and
orphan sweep skip it, the node is marked dirty and the inbound's pushed
fingerprint is dropped (Remote.ForgetPushedInbound) so reconcile re-sends
the hub's clients.
---------
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
Co-authored-by: Yuriy Khachaturian <y.khachaturian@souzmult.ru>
Invariant: every request runtime.Remote sends to a node is accepted under
the node-sync scope, except /server/updatePanel, which #6201 withholds on
purpose.
The allowlist was written on 2026-08-15 and three Remote calls arrived
after it without being added: /clients/activeInbounds (#6164, same day),
/inbounds/:id/subSortIndex (#6179) and /clients/bulkResetTraffic
(4210a50c). A master enrolled with a node-sync token or mTLS got 403 on
each: the subscription sort order and multi-client traffic resets never
reached the node, and online attribution silently fell back to email-only
because FetchTrafficSnapshot logs that failure at debug level.
TestMasterNodeContract drives every exported Remote method through the
production router, once per enrollment scope, fails on any 401/403 the
node returns even when Remote swallows it, and fails when a Remote method
has no cell. It replaces TestNodeSyncScopeAllowlistMatchesRemoteInventory,
a hand-copied duplicate of the allowlist that never read Remote and so
missed all three; its updatePanel rule stays in
TestNodeSyncScopeUsesFullPathPatterns. It also supersedes
TestMasterPushWithAdminTokenAppliesClients (its UpdateInbound cell); that
file's removal landed in dae91276 by a staging slip.
Each enrollment scope owns its panels, ports and temp dirs, so the two run
side by side; the only shared state, the process-global database handle the
harness borrows for setup and for simulating a lost inbound, is now behind a
mutex. On Linux the suite drops from 188s to 95s.
CLAUDE.md now names the fast contract layer (node_contract_test.go, in
make test-go) next to the multi-tick nodee2e layer.
Node sync had no test with two real panels: the single-panel tests either
call the node's controller in-process or hand-set the node-sync scope, so
823db059 shipped a regression no test could see (an admin-token node
dropped every client and enable flag its master pushed).
internal/nodee2e starts a master and a node as separate panel processes
(the DB is a process-wide global, so one process cannot be both) and walks
17 operations per enrollment scope (admin token, node-sync token): adopt,
create/edit on the master, small and bulk attach, client disable, detach,
client delete, inbound disable, traffic pull and reset, node-side delete
mirrored centrally (#6219), master-side delete, node down, node-lost
inbound re-created, create/edit while down, node disabled on the master,
and selected sync mode leaving unselected inbounds alone.
Against the build before 2ffc694a it fails 6 cells for an admin-token node
and 1 for a node-sync one; on main all 34 pass, on Windows and on Linux.
`make node-e2e` builds a stub-dist binary and runs it; ci.yml runs it as
its own job where a SKIP fails the build. Xray is not started, so cells
assert the node's stored state, not traffic through the core.
Invariant: every inbound the master holds for a node, with its clients and
enable flag, converges onto that node at the next push or reconcile.
823db059 made an inbound save keep the stored clients and enable unless the
request is a master push, recognised only by a node-sync token scope. Nodes
are usually enrolled with an admin token (the -getApiToken and install
default), so their nodes treated every master push as a stale form save:
clients attached on the master (Attach existing clients, bulk attach above
the per-client threshold, any dirty-node reconcile) never reached the node,
yet the push was recorded as successful so reconcile stopped retrying.
Remote now marks every request with X-3x-Master-Push, and the node honours
it like the node-sync scope. The browser form never sends it, so the
stale-form protection for panel saves and plain API scripts is unchanged.
Separately, an inbound deleted on the node kept its tag->id in the
master's cache, so reconcile sent update/<deleted id> forever instead of
re-creating it. When the node reports no form of the tag, ReconcileInbound
now drops the cached id so the resolve re-reads the node and falls back to
add. Two runtime tests pinned the old update-to-cached-id path for an
absent inbound; they now count the add, or report the inbound present.
The race job failed in TestAudit3ManagerEnsureActualSendersWithPersistentTraffic
with "close called for canceled stream 14". The server parses one command
per uni stream and then calls CancelRead, as the quinn reference server does
on drop, so its STOP_SENDING can reach the client before the client's own
Close and quic-go reports that Close as an error. The data was already read.
Every test that wrote a command on a uni stream and required Close to
succeed shared this race. closeUniStream accepts only a remote StreamError
on the stream's context, so any other Close failure still fails the test.
The race job failed on TestServerUDPDatagramE2E with Up:0 Down:1300.
BytesUp is added on the sending goroutine after the relay Send returns,
while BytesDown is added on the response goroutine, so the mock echo can
be counted and delivered before the upload is. The test drained the
counters once right after the reply and assumed both were present.
Production is unaffected: deltas left for the next collection window are
still summed. The TCP E2E test made the same assumption, so both now
accumulate drained deltas until up and down reach the payload size.
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.
A SQL dump replay only has to rebuild the tables of the database it
restores into. Run it on a single connection whose attached-database
limit is zero, so the script cannot open or create any other file.
* 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>
The bot read the panel-egress bridge once at start, so when Xray came up
after the bot (or Panel Outbound was set later) it kept dialing Telegram
directly until restarted - on a filtered host it never connected.
With no dedicated bot proxy, the fasthttp client now resolves the bridge on
every new connection and falls back to a direct dial when it is absent.
Raised in #6682.
* Write config.json after a hot apply
tryHotApply only updated the in-memory snapshot, so bin/config.json stayed
stale until the next cold start and the Telegram/Discord config backups
uploaded old rules. Persist the new config once the API calls succeed; a
write failure is logged and does not restart the running core.
* Test that a hot apply refreshes config.json
* Match non-ASCII capitals in the clients group filter and search
SQLite LOWER() only folds ASCII, so a group or search term with a Cyrillic,
Persian or other non-ASCII capital never matched after being lower-cased in
Go. Also compare the value as typed.
* Match lower, upper and title-case spellings for non-ASCII search and group filter
* Bound the syslog view with a journalctl timeout
* fix(syslog): bound the journal scan window and soften the timeout message
Limit journalctl to the last 30 days so a rare -p level cannot scan the whole
journal, and drop the guessed cause and the host-wide vacuum advice from the
timeout message.
* fix(syslog): drop --since from the journalctl call and test the timeout
On systemd 249/252 (Ubuntu 22.04, Debian 12) journalctl seeks to --since
and reads forward when both --since and -n are given, so the Syslog view
showed the oldest 200 lines of the 30-day window instead of the newest.
Reproduced in debian:12, ubuntu:22.04 and ubuntu:24.04 containers on a
synthetic journal; only 255 kept the newest lines. The window also bought
nothing: on a 340 MB journal every variant (with or without --since, rare
-p level or not) answered in ~10 ms on 252 and 255.
Keep the 15s deadline as the guard against a stalled journalctl and cover
it with a fake journalctl on PATH; the args test pinned the broken flag
and is removed.
---------
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>