Commit Graph

3784 Commits

Author SHA1 Message Date
CHANGAO MA 0516a1f87c feat(api): add client lookup by subscription ID (#6908)
Integrations could only find a client by subscription ID through a substring
search over the paged client list followed by a second lookup by email.
GET /panel/api/clients/get/subId/:subId now returns the full client records
(settings, attached inbounds, external links, traffic, tunnel addresses) for an
exact subId match, as a list because older rows may share an ID. It sits behind
the same admin login or admin-token check as the email lookup.
2026-10-24 04:54:14 +02:00
ericanannn-blip 3051aebb28 fix(middleware): log rejected CSRF requests with the trusted client address (#6692)
A panel or API request rejected for a missing or wrong CSRF token got a bare
403 and left nothing in the log, so an operator chasing failed logins from a
stale tab or a script had no trace. CSRFMiddleware now logs a warning with the
method, path and client address. The address comes from getRemoteIp, which
honours the trusted-proxy list, instead of gin's ClientIP, which trusts any
peer's X-Forwarded-For.
2026-10-24 04:51:21 +02:00
Murphy Shannon 864cbd234a fix(sub): pass numeric XMUX counts to Clash reuse-settings (#6843)
Inbound XMUX counts such as maxConnections are stored as JSON numbers, but
buildXhttpClashOpts kept only string values, so the panel default
maxConnections: 3 never reached Clash/Mihomo clients. Numeric values are now
rendered as plain integers (FormatFloat, so 1000000 does not become 1e+06) and
zero values are still dropped.
2026-10-24 04:51:16 +02:00
Grigoriy 2f62dadd25 feat(notifications): add a failed-check threshold for node.down alerts (#6768)
One lost heartbeat on a lossy link flipped a node offline and sent node.down,
and the next probe sent node.up, so a flaky node flooded Telegram, email and
Discord with alert pairs.

A new nodeDownThreshold setting (default 1, today's behaviour) works like
outboundDownThreshold: node.down fires once the failed-check streak reaches it,
once per outage, and node.up only after a node.down was sent. The announcement
is persisted in nodes.down_notified, so a restart inside the threshold window
neither drops the outage's node.down nor re-announces nodes already reported
offline; nodes offline at upgrade are seeded as announced.
2026-10-24 04:26:13 +02:00
almirus c3037fc716 fix(i18n): name the Russian download throughput label as a noun (#6890)
pages.index.download labels the download-speed figure on the dashboard next to
"Загрузка" (upload). The imperative "Скачать" read as a button; the noun
"Скачивание" matches what the label shows. CI does not run on translation-only
changes; the file still parses as JSON.
2026-10-24 04:23:46 +02:00
Jay Hemnani 1dbb37432c fix(traffic): re-enable quota-disabled clients on inbound and reset-all resets (#6833)
Running out of quota switches a client off in client_traffics, in its client
record and in the inbound settings JSON. The inbound's periodic reset and the
Reset All Client Traffic button only set client_traffics.enable back, so the
client showed zero usage but stayed out of the running core every period.

Both paths now read the disabled, depleted emails inside the transaction that
zeroes the counters, on the traffic writer, and re-enable them through
BulkSetEnable after the commit, like every other reset path. Reading them inside
that transaction keeps a traffic tick from disabling a client between the read
and the reset.
2026-10-24 04:21:11 +02:00
Duxxie c3394fefed feat(sub): opt-in dns and sniffer from remote Clash rules (#6868)
Public Mihomo templates ship rules, rule-providers and proxy-groups together
with a dns block those rules depend on (fake-ip ranges, nameserver policy).
Pasting a template inline keeps its dns, but pointing the global routing rules
at its URL dropped it, so URL users had to give up auto-refresh to keep it.

A new switch, subClashRemoteDNS (off by default), also takes the remote dns and
sniffer maps. It is opt-in because a remote dns block decides where clients send
every lookup. A malformed dns or sniffer value is skipped, not the whole template.
The component-test teardown now outlasts AntD Form's 10ms error debounce, which
otherwise fired after jsdom was gone.
2026-10-24 04:21:04 +02:00
Farhan Zare 3acdfd9dea fix(hosts): carry an inbound update's externalProxy change onto its imported hosts (#6831)
Subscriptions build an inbound's links from its Host rows, and those rows are
imported from streamSettings.externalProxy only when the inbound is added. An
update that moved the external proxy returned 200, but every subscription kept
handing out the old address, port and security.

An update now re-syncs only the externalProxy entries it changed onto hosts that
are still exactly as imported. Unchanged entries are set aside first, so a host
the admin edited, disabled, deleted or shared is never re-imported next to the
admin's version, and a stream without the externalProxy key leaves hosts alone.
2026-10-24 04:20:50 +02:00
MHSanaei d50c419e28 fix(web): read the client address from the proxy end of forwarded headers
getRemoteIp took a trusted proxy's X-Real-IP first, otherwise the leftmost
X-Forwarded-For entry. A proxy that forwards a client-sent X-Real-IP
unchanged (Caddy, HAProxy) or appends to X-Forwarded-For leaves both values
to the client. X-Forwarded-For is now read from the right, skipping trusted
proxies. X-Real-IP is believed only when it agrees with that hop or stands
alone, and a disagreement falls back to the proxy's own address.

The documented Caddyfile and the docs reverse-proxy generator now overwrite
X-Real-IP with the address Caddy saw.
2026-10-24 01:39:23 +02:00
MHSanaei 1df8ab29f8 fix(database): create the first panel user with random credentials
The first user is now created when the server starts, with a random
username and password printed once to its log (docker logs, the journal),
instead of a fixed pair seeded by InitDB. CLI calls that initialize the
database are often piped away, as the Docker entrypoint's port lookup is, so
only the server's own log reliably reaches the operator.

`x-ui setting -show` keeps reporting hasDefaultCredential: true until a user
exists, so install.sh still sets its own credentials on a fresh install.
Existing databases are not changed. Verified on a built binary: fresh
setting -show, install.sh-style credential set, first start, login and
restart. The PostgreSQL lane was not run locally; the change adds no query.
2026-10-24 01:26:05 +02:00
MHSanaei 0faad4f4f3 fix(wireguard): check every line of generated client configs
The WireGuard and AmneziaWG .conf builders (subscription service, clients
page, inbound modal and subscription page) checked only a few of the values
they write. Each builder now assembles the file line by line and renders
nothing when a line holds a control character, so every value is covered
whatever stored it. A decoded vpn:// payload is refused when it carries a
wg-quick hook directive, which no panel builder writes, and the subscription
page hides a config block that rendered nothing.

The inbound form now validates a WireGuard/AmneziaWG client's allowedIPs on
create and on update, including the stored clients a protocol switch keeps,
as the client endpoints already did.
2026-10-24 01:10:09 +02:00
MHSanaei 57fd61790c chore(docs): bump docs dependencies and pnpm
Update the docs site to fumadocs 16.17.x, next 16.4.3, react 19.3.1, zod 4.6.7, oxfmt/oxlint and vitest 5.0.5, and bump pnpm to 12.15.0.

Add a lightningcss>=1.33.0 override because @tailwindcss/node 4.3.3 pins 1.32.0, which rejects fumadocs-ui's ::highlight(fd-search) selector.

Refresh minimumReleaseAgeExclude for the new package versions and set minimumReleaseAgeExcludePrune. The lockfile is regenerated accordingly.
2026-10-24 00:25:48 +02:00
Jay Hemnani aedd8fac1c fix(traffic): re-enable a quota-disabled inbound on its periodic reset (#6834)
An inbound with a total traffic cap is switched off once up + down
reaches it. Its periodic traffic reset then zeroes the counters but
never sets enable back, so after the first used-up period the inbound
stays off for good and the reset schedule does nothing for it.

The reset job now notes, before resetting, whether a local inbound was
off because of its own cap (and not expired), and switches it back on
through SetInboundEnable once the counters are zero. An inbound switched
off below its cap, or past its expiry, stays off.
2026-10-23 19:59:41 +02:00
Farhan Zare 47be06358e fix(clients): withdraw delete tombstones for clients any serialized write re-creates
Deleting a client tombstones its email for 90s, so a node snapshot taken
before the delete reached the node cannot resurrect it. Client create and
bulk create withdrew that tombstone, but a client re-created through
InboundService.AddInbound (SyncInbound / syncInboundClients) did not. On a
master, re-adding a node inbound with a just-deleted email therefore let
the next node merge filter the new client out of the snapshot, unlink it
from its inbound and mark it a sync orphan; ReapSyncOrphans then
hard-deleted it 15 minutes later and the node lost it.

syncInboundClients now records the emails it creates on the serialized
transaction, and runSerializedTx withdraws their tombstones only after the
commit, still on the writer, so no merge runs in between and a rolled-back
create leaves the tombstone standing. The per-call withdraws in Create and
bulkCreate are covered by this and removed.

Co-authored-by: FZ1010 <FZ1010@users.noreply.github.com>
2026-10-23 19:30:13 +02:00
25f2002654 f172bc3f10 fix(node-sync): do not latch node disable when master allows (#6870) (#6876)
* fix(node-sync): do not latch node disable when master allows (#6870)

nodeDisableIsStale trusted any node-reported enable=false whose limits matched
the master's, treating it as a genuine verdict and latching it on the master.
But matching limits no longer prove the verdict: a single stray false
(a depleted snapshot the master later re-enabled, a stale
disableInvalidClients hit, or a push that lagged a quota reset) leaves the
client stuck disabled on the node, and every master tick flips the client's
state again. This is the loop reported in #6870.

Make nodeDisableIsStale a single rule: stale iff the master's own deadline
and quota still permit the client. A genuine combined-quota or expiry
disable (masterLimitsAllowClient == false) still latches, preserving #4917.
Mirror the same rule in ClientTrafficEnableMergeExpr so the SQL UPDATE and
the Go in-memory replay cannot drift.

* fix(node-sync): keep node usage over quota latching a node disable

Dropping the matching-limits latch (#6870) let the master re-enable a
client the node had genuinely counted past its quota whenever the
master's own counters lagged, e.g. after a seeded-at-zero adoption,
reopening #4917. A node's enable=false now stays latched when the
node's own usage already spends the master's quota; otherwise the
master's limits decide, so a stray disable no longer loops.

Restores TestNodeQuotaDisable_SameExpiryStillLatches as it was on main.

---------

Co-authored-by: Alireza Ahmadi <alireza7@gmail.com>
2026-10-23 19:04:46 +02:00
Alireza Ahmadi e2184777a0 fix(frontend): make the panel usable with screen readers (#6907)
* fix(frontend): make the panel usable with screen readers

* fix(frontend): keep mobile tab names and the inbound expiry hint

Review of #6907 found two gaps in the screen-reader pass:

- On phone-width screens catTabLabel renders only the icon and names the
  tab by putting aria-label on it, but callers now pass that icon with
  aria-hidden for the desktop label, so every Settings, Xray and Host
  category tab lost its name under TalkBack. catTabLabel now clears
  aria-hidden on the icon it uses as the name.
- DateTimePicker forwarded id and aria-label but not aria-describedby,
  so the inbound expiry hint ("leave blank to never expire") was never
  attached. It now forwards it on both the Gregorian and Jalali input.
2026-10-23 18:29:47 +02:00
MHSanaei 775b2b0b26 chore(deps): update Go and frontend dependencies
Refresh frontend libraries and tooling, and update Go to 1.27.3 with current module dependencies.
2026-10-22 22:01:46 +02:00
MHSanaei 61b129d5b3 feat(xray): update xray-core to v26.10.21 and adapt panel
Bump xtls/xray-core to 1b1647cd9ddf (v26.10.21) and move the three
binary pins (DockerInit.sh, both release.yml fetches) in lockstep. The
v26.10.10 release was pulled upstream, so the old pins already 404.

XDRIVE (#7182): the core now runs XDRIVE only for VLESS inside VLESS
encryption, with no flow, and an outbound only over Mux; it refuses the
whole config otherwise. The panel offers XDRIVE only for VLESS, refuses
to save an inbound without decryption, never emits a client flow on an
XDRIVE stream (config, JSON subscription, forms), and turns Mux on when
an outbound picks XDRIVE. An inbound or template outbound stored before
the upgrade that the core would refuse is left out of the generated
config with a warning, so it no longer stops every other inbound. The
service "Google Drive" is now "google-drive" (old name read as a
deprecated alias); saves, the schema and both form adapters store the
new name. XDRIVE and MASQUE gain a client xmux; both schemas keep it
instead of stripping it on an inbound save.

Lua scripts (#7124, #7136): the core refuses a routing script beside
rules or domainStrategy, and a dns script beside the fallback/parallel
switches or a domain-scoped server. The panel always keeps its api
routing rule, so a template save now refuses any routing script and a
dns script in a refused combination, and a stored one is dropped from
the generated config so the core still starts. Noise masks gain a Lua
script that the loader compiles inside Build: the in-process build
(template save check, hot add) now resolves a relative script the way
the xray child does, beside the xray binary, instead of failing it. The
mask editor gets a Script field that replaces the noise list.

XDNS (#7140): a resolver takes https:// DoH addresses with worker,
tlsConfig and quicConfig. The mask editor registered only addrs, so a
save dropped the rest; it now edits all four.

Finalmask filter (#7206): a new TCP/UDP mask screens an inbound's source
IPs. The inbound mask editor offers it with its allow and block lists;
it is a no-op on a dialer and an older client core refuses the type, so
the share link and JSON subscription drop it like the Go link already
does.
2026-10-22 17:10:11 +02:00
libmur-dev a8d0700bf2 fix(auth): add node-admin token scope for panel updates (#6666)
* feat(auth): add node-admin API token scope

Keep ordinary node-sync credentials unable to self-update a remote panel while allowing explicitly elevated node administrators to inherit synchronization access and invoke updatePanel.

* style(docs): format API scope table

* fix(auth): preserve mTLS node-sync semantics

Allow only an explicit node-admin bearer to elevate a verified mTLS request. Existing admin, monitor, and node-sync bearer values continue to use the historical node-sync identity on mTLS connections.

* chore(ci): rerun flaky checks

---------

Co-authored-by: libmur-dev <333915961+libmur-dev@users.noreply.github.com>
2026-10-17 23:45:26 +02:00
Anthony Ilinykh a1f2589eb5 fix(frontend): remove unused MSW browser worker (#6859)
The frontend uses MSW only through its Node test interceptor, but the unused browser worker requires synchronization after dependency updates and currently breaks make verify.

Remove the unused asset, worker-directory configuration, and freshness checks while preserving Node MSW tests and the separate PWA worker. Update tooling descriptions to match.

Fixes #6858
2026-10-17 22:09:48 +02:00
MHSanaei 608ef4e401 v3.10.2 v3.10.2 2026-10-14 17:18:43 +02:00
MHSanaei 35b5e0989a fix(sponsor): fetch sponsor data from raw.githubusercontent.com
The server-side fetch used sponsors.sanaei.dev, a hostname unique to
3x-ui. Without ECH it travels in plaintext SNI, so an on-path observer
could tag a server as running the panel from that one connection.
raw.githubusercontent.com is shared by countless tools and reveals
nothing. The path stays inside TLS. The data source is unchanged: the
same MHSanaei/sponsors repo.

Refs #6824
2026-10-14 17:10:02 +02:00
MHSanaei b177b98ce8 feat(mtproto): expose mtg-multi's per-IP pending-handshake limit
mtg-multi v1.16.0 adds [defense.pending-handshakes] max-per-ip, which
closes new connections from an IP already holding that many
unauthenticated handshakes, so one address cannot pin the workers other
clients need. The MTProto inbound form now carries it as
pendingHandshakesMaxPerIp; the key is written only when set above 0, so
existing inbounds render a byte-identical config and are not restarted.
A change to it moves the structural fingerprint and restarts the sidecar.

dry-run is left out on purpose: it only feeds the pending_handshake_limit
Prometheus metric, which the panel does not surface. The hint warns that
clients behind carrier NAT share the limit.
2026-10-14 16:46:20 +02:00
Jay Hemnani 6bd1b8676a fix(link): read the XHTTP fields a vmess link carries inline (#6808)
A vmess link has no extra blob: the sub emitter writes the XHTTP extra
fields (session/seq/uplink placement, padding obfs, xmux, ...) at the
top level of the vmess JSON. The Go importer only read xPaddingBytes,
scMaxEachPostBytes and scMinPostsIntervalMs from there, so a vmess
XHTTP inbound with, for example, sessionIDPlacement=header or
xPaddingObfsMode came back from an outbound subscription or a client
external link as an outbound that cannot connect.

Read the same keys the TS importer already reads from vmess JSON,
including the pre-rename sessionPlacement/sessionKey aliases.
2026-10-14 15:34:35 +02:00
Jay Hemnani ff3f42ee2a fix(link): read fm and the mKCP fields from vmess links (#6809)
#6480 taught the vless/trojan importers to rebuild the mKCP header and
seed and left the vmess importers ignoring fm. The vmess emitter writes
the finalmask as fm, the mKCP header as type and the seed as path, plus
mtu/tti. Both importers dropped all of them, so a vmess mKCP inbound
with a seed, a header or any finalmask UDP mask (salamander, ...) came
back from an outbound subscription, a client external link or the
outbound form's link import as an outbound that cannot connect.

Feed those fields through the same finalmask and kcp helpers the
url-param protocols use, in the Go and TS importers.
2026-10-14 15:34:06 +02:00
MHSanaei 760f428c41 fix(xray): capture whole core crash reports instead of the first pipe chunk
LogWriter inspected each pipe chunk on its own. The Go runtime writes
"panic: " with a separate write before the message and traceback, so the
crash report was written from that first chunk alone and the panel left
7-byte core_crash_*.log files holding only "panic: ". Any ordinary log
line mentioning panic/exception/stack trace was also treated as a crash
and dropped from the panel log.

The writer now splits output into lines, starts a crash only on a line
beginning with the Go runtime's "panic: " / "fatal error: " header, keeps
everything after it, and writes one report from Flush, which
waitForCommand calls after cmd.Wait has delivered all output. The header
line becomes LastLine so GetResult shows the cause.

The report filename format "_000000000" is not a Go fractional-seconds
layout and always produced literal zeros, so reports from the same second
overwrote each other; os.CreateTemp now gives every report a unique name.
2026-10-14 15:31:09 +02:00
MHSanaei c45ffcdafa chore(windows): update OpenSSL installer
Replace the OpenSSL Light 3.6.0 executable with the 4.0.3 MSI installer.
2026-10-14 14:49:28 +02:00
MHSanaei cb4f2ec92f fix(sub): export Hysteria2 Gecko obfs in Clash/Mihomo subscriptions
A salamander finalmask carrying a packetSize range is Gecko obfs. The
hysteria2:// link generators already export it, but buildHysteriaProxy
copied only the password, so Clash/Mihomo received plain salamander and
could never complete the handshake against a Gecko server.

Emit mihomo's gecko form instead: obfs: gecko plus integer
obfs-min-packet-size / obfs-max-packet-size, validated by the same
parseHysteriaPacketSize bounds the URI export uses. A password-only mask
keeps the plain salamander output.

Closes #6822
2026-10-14 14:42:00 +02:00
MHSanaei db8b01b8ed feat(release): ship the Windows build as 3x-ui.exe with version info and icon
Kaspersky (Trojan.Win64.Agent.sb / .smhqgz) and Defender flag the
Windows build heuristically. A stripped, unsigned Go binary with no
VERSIONINFO, icon or manifest scores high on those heuristics, so the
release job now generates a resource .syso with go-winres from
winres/winres.json, stamped with internal/config/version, before
go build. The .syso is a build artifact and is gitignored.

The Windows zip now carries 3x-ui.exe instead of x-ui.exe, matching the
project name, and the executable embeds the 3X logo as its icon. The
icon is a square 256px crop of media/3x-ui-dark.png, since the source
logo is 400x200 and Windows icons must be square.
2026-10-14 14:16:49 +02:00
MHSanaei a66b360c09 fix(xray): apply shadowsocks client changes in place instead of rebuilding
The per-user hot path only covered vless, vmess, trojan, hysteria and
masque, so every client change on a multi-user shadowsocks inbound - a
node push for an auto-disable, renew, reset or add, a local Edit Inbound
save, or a config-diff hot apply - fell through to DelInbound +
AddInbound, closing the listener and dropping every live connection on
the port.

Shadowsocks joins userDiffableProtocols. The emitted user ops also carry
the inbound's method as "cipher": 2022 clients have no method of their
own, and without it AddUser cannot build the account, which would leave
2022 inbounds rebuilding anyway. Changes outside settings.clients
(method, 2022 server key, network) still differ in the rest of the
settings and still rebuild.

Verified against the pinned core: TestXrayAPI_E2E_Users and the
shadowsocks e2e tests pass with XRAY_E2E_BINARY set.

Closes #6820
2026-10-14 14:16:48 +02:00
MHSanaei f8b72de995 fix(node-sync): write only changed client rows in the node traffic merge
Every 5s node merge rewrote one client_traffics row and one
node_client_traffics baseline per (inbound, client) pair the node
reported, even when nothing had moved: a client is reported once per
inbound it is on, and idle clients every tick. On top of that each
inbound's SyncInbound wrote its own per-inbound values onto the shared
clients record, so a client on a flow and a non-flow inbound (or on a
WireGuard tunnel plus any other inbound) had its record flipped back
and forth twice per merge. With 7 nodes and ~20k client links that is
tens of thousands of statements per tick, all inside transactions on
the single serialized writer, holding row locks for seconds. A renew
of a client on 38 inbounds takes one writer turn per inbound, queued
behind those merges each time, and outlived the 30s HTTP write timeout
even though it committed; last_sub_fetch updates waited on the same
locks.

The merge now computes the row its UPDATE would leave, mirroring the
SQL merge expressions, and skips the write when nothing changes; the
baseline upsert is skipped when the node counter has not moved. The
record's per-inbound fields get one owner each, so repeated syncs
agree: flow is settled to the effective flow (lowest-id link carrying
one) for clients whose links changed, and WireGuard/AmneziaWG peer
fields come from the client's lowest-id tunnel. A repeated unchanged
snapshot now writes no client row; one active client writes two.

Verified beyond make verify: the CI Postgres lane, the merge and flow
tests run against PostgreSQL 16, and make node-e2e.

Closes #6815
2026-10-14 00:37:00 +02:00
MHSanaei 6f1dd0d757 feat(sponsors): drop the sidebar slot and simplify the Sponsors page
The sidebar no longer carries a sponsor card. The backend stops serving
the "sidebar" slot, so a sponsors.json entry that still lists it keeps
only its other slots and one listing nothing else is dropped. Its
rotation, three-sponsor cap and collapsed-rail logo rendering go with it.

The Sponsors page now lists only real sponsors: the "Your brand here"
card shows only as the empty state instead of sitting in the grid and
repeating the header's Become a sponsor button, and the Available
placements section and its slot-occupancy helper are removed. Cards are
larger, two per row on wide screens. Unused i18n keys are dropped from
every locale.
2026-10-13 23:32:56 +02:00
MHSanaei 319e693276 fix(sponsor): keep long sponsor copy inside every slot
The Sponsors page card sized its body to max-content because the title
never wrapped, so a long title spilled out of the card onto the next one.
The compact card (sidebar, login) put logo, copy and close button in one
row, leaving the copy ~110px of the 220px sidebar and clamping it to two
lines; the dashboard banner cut its title to a single line.

Titles and descriptions now wrap, breaking unbroken strings too. The
compact card stacks logo and tag above copy that spans the full card
width, keeping a bounded height in the sidebar with a 2-line title and
4-line description clamp and the full copy in the link tooltip.
2026-10-13 22:42:59 +02:00
Matt Van Horn 61885c9126 fix(sub): isolate Incy subscription routing from Happ headers (#6810)
* sub: isolate Incy subscription routing from Happ headers

The resolved Incy line is written to the body only when IsIncyClient
matches the subscription User-Agent, Incy routing is enabled, and the
resolved text is non-blank. ApplyCommonHeaders computes that same Incy
check once and, for a matching client, skips the Happ routing-header
block, leaving Routing-Enable, the Happ Routing value, and the JSON
routing header unset. ApplyIncyHeaders is called with that incyClient
flag. The new test requests the subscription as INCY/1.0.0/ios and as
Happ/1.2.0 and expects the Incy response to end with the incy:// line
and to have empty Routing headers, and the Happ response to keep the
happ:// Routing header with Routing-Enable true and no incy:// line in
the body.

Fixes #6062

* fix(sub): keep the shared JSON routing profile for INCY clients

The INCY isolation wrapped the whole Routing block, JSON fallback
included, in !incyClient. INCY therefore lost the subJsonRoutingRules
profile that #6402 sends as the Routing header so Happ and INCY fetch
the geo files the baked rules reference, on the raw, JSON and Clash
endpoints alike, while every other client kept it, incy:// deeplinks
included.

INCY accepts a routing link of any scheme in that header
(docs.incy.cc/en/routing), so only the Happ tab is Happ-only: an INCY
request now reads it as blank and falls back to the shared profile,
as the setting text promises. Where the raw body carries INCY's own
profile the header is dropped, because INCY ranks a Routing header
above a base64 body line (docs.incy.cc/en/app-management) and the
shared profile would override it, which is #6062 again.

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
2026-10-13 16:01:32 +02:00
MHSanaei 74c3cc3882 fix(database): blank boolean-era verifyPeerCertByName values on hosts
A host's verify_peer_cert_by_name must only hold a server name an
operator typed. The column started life as a boolean, and the migration
that was meant to discard those values runs after AutoMigrate, which has
already cast the column to text: SQLite's table rebuild stores 0/1 as
"0"/"1", and Postgres' USING col::text stores "false"/"true". The
migration then sees a text column and leaves the values alone.

Since 3.10.0 the JSON subscription lifts host TLS verification fields to
the level xray reads, so that leftover "0" is now sent as the cert name to
verify and clients such as Happ refuse the server certificate.

A one-shot HostVerifyPeerCertByNameBoolFix seeder blanks exactly the four
cast values on existing databases; real names are untouched. The
Postgres variant joins the CI schema job, since that cast only happens
there.
v3.10.0
2026-10-12 18:16:22 +02:00
MHSanaei 6843583706 fix(xray): fall back to the userspace TUN where kernel TUN cannot be set up
The core puts a WireGuard outbound on a kernel TUN whenever it holds
CAP_NET_ADMIN and noKernelTun is unset, the default for a hand-added outbound.
That needs a writable /proc/sys/net and /dev/net/tun, and where either is
missing the core does not fall back: it aborts the whole start. The shipped
docker-compose grants NET_ADMIN for fail2ban while Docker keeps /proc/sys
read-only and exposes no /dev/net/tun, so one such outbound took every inbound
down with the #6796 error; any unit or drop-in that keeps /proc/sys/net
read-only did the same.

xray.KernelTunBlocked probes once, from the panel process whose capabilities
and mounts the core inherits, whether the core would pick a kernel TUN it
cannot create. GetXrayConfig then sets noKernelTun on every WireGuard outbound,
so cold starts and hot reloads both get it. Hosts that can create the device
keep the kernel TUN.

Verified with the pinned core's "run -test" on the generated config, in a
NET_ADMIN container as docker-compose runs it and under systemd 257: the
container and the v3.10.0 unit report blocked and pass on gVisor, the container
fails with the #6796 error when the GetXrayConfig hook is bypassed, and the new
unit keeps the kernel TUN.

Refs #6796
2026-10-12 17:52:45 +02:00
MHSanaei 1f2738eda5 fix(systemd): let the WireGuard kernel TUN write rp_filter under the sandbox
ProtectKernelTunables=yes (#6718) mounts all of /proc/sys read-only, and a
WireGuard outbound in kernel-TUN mode zeroes net.ipv4.conf.all.rp_filter (and
its ipv6 and per-device siblings) while Xray creates its handlers, so the core
aborted with "failed to disable ipv4 rp_filter for all: ... read-only file
system". The unit is reinstalled on every update, so a hand edit did not stick.

Keep ProtectKernelTunables and reopen only /proc/sys/net. Turning the
protection off (#6797) also lets the core start, but leaves kernel.core_pattern
writable to the root panel, which turns any panel-level code execution into a
way out of the whole sandbox. net.* is per network namespace and grants nothing
beyond the CAP_NET_ADMIN the panel already holds.

Verified on systemd 257 (Debian trixie) with the shipped unit and the pinned
core: the v3.10.0 unit reproduces the error, the new one starts the kernel TUN,
and kernel.core_pattern stays read-only for the service.

Closes #6796
2026-10-12 17:52:21 +02:00
MHSanaei 097bb7646a fix(ci): move the issue analyst's static instructions out of the workflow
GitHub evaluates a `with:` value holding ${{ }} as one expression and
rejects the whole workflow when it passes 21,000 characters. The analyst
prompt was 19,883; 6eeaaa91 took it to 21,389 and 99d971ff to 23,257, so
since that push the file is invalid: no issue gets an analysis, and every
push records a failed, job-less run named after the file. actionlint does
not check this limit, which is how it shipped.

The static parts - repository context pointer, issue forms, how to
investigate, resuming and maintainer-request modes, evidence discipline,
the severity/confidence/verdict scales, the security exception, labels and
closing, and the comment format - move verbatim to
.github/claude/issue-analyst-instructions.md, which the prompt tells the
analyst to read in full first, as it already does for
issue-analyst-context.md. The prompt keeps everything that needs an
expression or guards the run: the trigger mode, CURRENT ISSUE, the fenced
untrusted data, RULES and the marker. It is now 5,597 characters. The
stale "code changes happen when the maintainer mentions @claude" rule is
replaced: no workflow here changes code, and @claude analyze now exists.

TestBotPromptsFitGitHubExpressionLimit in bot_context_test.go reads every
claude-*.yml and fails on a ${{ }} value over the limit; it is red on
99d971ff (23,257). The instructions file joins the analyst paths pinned by
TestAnalystContextNamesRealPaths, and the file's header now names
everything it pins. actionlint 1.7.7 passes.
2026-10-12 17:05:37 +02:00
MHSanaei 544907f4e2 v3.10.0 2026-10-12 16:43:44 +02:00
MHSanaei 99d971ff83 feat(ci): let a maintainer re-run the issue analyst with @claude analyze
The analyst ran only on a new issue or on the reporter's reply to a
"clarification needed" question, and a comment mentioning @claude was
explicitly skipped. A maintainer had no way to get a fresh analysis after
the code moved, after the prompt improved, or to point it at a lead; the
only option was re-running the old job, which replays the old commit and
the old prompt.

A comment starting `@claude analyze` from an OWNER, MEMBER or COLLABORATOR
on an issue (open or closed, never a PR) now runs it as
`maintainer_request`, mirroring `@claude review` on PRs. In that mode the
analyst re-checks its earlier analysis against the current checkout, treats
text after the command as a steer on what to investigate (never as a
change to its rules), posts a complete standalone analysis that says what
changed, does not tag the maintainer, and leaves the issue's open/closed
state alone. The reporter path is unchanged and still skips @claude
comments, so one comment never starts both.

No test reaches a workflow trigger; actionlint 1.7.7 passes on the file,
and the first `@claude analyze` after the push is the demonstration.
2026-10-12 16:42:02 +02:00
MHSanaei 6eeaaa912a fix(ci): make the issue analyst map the bug class before suggesting a fix
The analysis of the node inbound rebuild traced the reported path
correctly but stopped there: the same sink (the node's Del+Add on
UpdateInbound) was also reached by auto-renew, traffic reset, delete
depleted, the reconcile after a master restart and bulk ops past their
threshold, and its suggested per-client push ignored the 32-push cap the
codebase already enforces. A maintainer following it would have fixed one
caller and left the rest dropping connections.

Step 4 now asks the analyst to name the broken invariant, find the sink
and grep every other path into it; a new `Same class:` field carries what
it found; `Suggested fix:` must target the layer that closes all of them
and be checked against existing caps, batch endpoints and mixed-version
peers. `Not determined:` is no longer for a file the analyst did not open.

No test reaches a prompt; the YAML still parses (checked with the
frontend's yaml package) and the next analyst run is the demonstration.
2026-10-12 16:22:54 +02:00
MHSanaei 440833382d fix(node): apply a pushed inbound's client changes in place instead of rebuilding it
A master pushes the whole inbound to its node whenever a client on it is
auto-disabled, renewed or reset, and the node-sync reconcile re-sends every
inbound whose payload changed (all of them after a master restart). The
node's UpdateInbound answered every push with DelInbound + AddInbound, so
Xray closed the listener and every open connection on that port dropped.
On a node inbound with ~2,700 clients that was dozens of times a day.

UpdateInbound now diffs the config Xray is running against the new one.
When only settings.clients differ (vless, vmess, trojan, hysteria, masque)
it applies per-user RemoveUser/AddUser through the runtime, and when
nothing the core sees changed it does nothing. Any other change, or any
per-user failure (a handler the core lost included), still rebuilds as
before. AmneziaWG joins MTProto and TUIC on rt.UpdateInbound, whose
in-place Ensure keeps the device and its peers; Del+Add tore it down.

A pushed removal does not consult the node's own
restartXrayOnClientDisable: it defaults to on, and the master already
restarts its nodes from its own setting (restartRemoteNodesOnDisable).
Nodes on an older release keep rebuilding until they update; the master
side needs no change.

Verified: the new service tests, TestXrayAPI_E2E_Users against a real
core (pins the missing-user vs missing-handler error texts) and make
node-e2e. make race not run: no goroutine, lock or shared state changed.

Closes #6795
2026-10-12 16:22:30 +02:00
Ali Karami 1edb8b31bc fix(xray): keep balancer overrides across routing reloads (#6779)
* 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.
2026-10-12 14:59:26 +02:00
MHSanaei 532392850f fix(ci): drop id-token from the Claude workflows and tighten the review comment
CodeQL alert #121 flags the pr-head checkout in the PR review job. The
checkout is deliberate and nothing in the job executes it, but the job still
carried `id-token: write`, and so did the issue analyst. claude-code-action
only uses OIDC to trade for a Claude GitHub App token when no github_token is
passed - that app token asks for contents, pull_requests and issues write -
or for workload identity, which neither workflow uses. Both pass
github_token, so the permission did nothing for them, yet any code running
in either job could have minted that write token, undoing the review's
read-only split.

Three reviews posted on one morning (#6666, #6675, #6752) also showed rubric
gaps. REVIEW.md said both "open with the verdict" and "lead with No blocking
issues", so the comments opened three ways and two tallies dropped
severities; the opening is now a fixed order - tag line only when blocking,
verdict paragraph, one status line whose tally names all three severities.
#6675 filed a spoofable X-Forwarded-For IP, which that PR first shows to an
admin approving a login, under Design; Design now holds approaches and
trade-offs only, and a concrete consequence the change makes reachable or
worse is a finding. The coverage list ran to some 25 nested lines on #6752;
it is now ten lines at most, one per area, no nested bullets.
2026-10-12 13:39:29 +02:00
Javier Guerrero c321933a54 perf: reduce traffic formatter allocations (#6790)
Co-authored-by: Javier Guerrero <javier.guerrero@github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-10-12 11:05:40 +02:00
Jay Hemnani d43e0c7ead fix(sub): default VLESS client encryption to none when the inbound has none stored (#6781)
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.
2026-10-12 11:03:47 +02:00
mrdarkerr f55938ba0a fix(amneziawg): respect tunnel MTU when writing UDP replies (#6780)
* 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
2026-10-12 03:11:09 +02:00
MHSanaei a4699696ef docs(review): put the tally before Design and name who can store a trigger 2026-10-12 03:01:18 +02:00
MHSanaei 5939fda397 fix(ci): let the PR review save its body, and scope the write-check bypass
Run 38187893539 reviewed PR #6784 in full but posted nothing: the model's
Write to /tmp/review.md was refused. Claude Code checks file permissions
against Edit(path) rules only and never consults a Write(path) rule, so the
allow rule was dead - and so were the Write(//home/**) and Write(//opt/**)
deny rules meant to keep $GITHUB_ENV out of reach. All three are Edit rules
now; a local headless run confirms Write(path) is refused, Edit(path) lets
the file through, and an Edit deny still blocks a Write under it.

allowed_non_write_users="*" skipped the action's write-permission check for
every actor and warned each run. Only a pull_request_target from a fork
needs it; an `@claude review` requester is already OWNER/MEMBER/COLLABORATOR
and passes the action's own check.
2026-10-12 02:32:55 +02:00
MHSanaei 9130f6dd75 feat(ci): make the PR review judge the claim and never post code
The review only hunted defects: a correct fix got no word on whether the
reported problem was real or actually solved, and a feature got no design
feedback. The comment now opens with a verdict for every kind of change -
for a fix, whether the failure exists on base, whether the head removes its
root cause (checked against the issue-analyst's analysis) and whether the
test proves it; for a feature, whether it overlaps what the panel already
has, which deployment shapes it misses, and a prose-only Design section;
for refactor, perf, dependency bumps, docs and CI, a question of their own.
The trace scales to the change, so a typo is not traced like a runtime
change. It also searches for duplicate merged or in-flight work, and runs at
high effort since settling whether a bug is real takes tracing base and head.

No code may appear anywhere in the comment, and the model no longer holds a
write token. The workflow is three jobs: `prepare` (ack, pin the head, skip a
reviewed PR), `review` (read-only token; the model writes /tmp/review.md) and
`post` (no model, no checkout), which strips any fenced block, keeps the
Reviewed head marker the skip relies on, and only then posts one plain
comment. Before, `gh api` with a pull-requests: write token could touch any
PR in the repository, `sed -i` could write $GITHUB_ENV, and the inline-comment
tool told the model to use suggestion blocks. Concurrency stays on the review
job: at workflow level any unrelated comment on the PR would replace a
pending review in the queue.

Restores three guards the earlier claude-bot review had: all author-controlled
input, including text inside the checked-out code, is data and a diff adding
text aimed at a reviewer or bot is a HIGH finding; a blocking finding tags the
repository owner; and a diff too large to read whole has a reading order and
must name what went unread.
2026-10-12 02:23:19 +02:00