mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-03 20:53:41 +03:00
0054e671f88362696a0cb791e0ae7e4f18f7ed75
506 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
0054e671f8 |
feat(tuic): implement native in-process Go TUIC v5 server (#6577)
* 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> |
||
|
|
40ca2cd72f | Reformat clientSearchCols slice literal | ||
|
|
bb18734c77 |
fix(tgbot): resolve the panel egress bridge per connection
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. |
||
|
|
3948b83405 |
Write config.json after a hot apply (#6686)
* 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 |
||
|
|
7802167443 |
Fix clients group filter and search for non-ASCII capitals (#6685)
* 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 |
||
|
|
5366eb0d29 |
Bound the panel syslog view with a journalctl timeout (#6689)
* 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> |
||
|
|
98db0710c9 |
fix(runtime): reset node inbound traffic by node-side id (#6717)
* fix(runtime): reset node inbound traffic by node-side id Remote.ResetInboundTraffic posted to the master's inbound id (ib.Id), while every other node-side inbound call resolves the id assigned by the node from the tag. The reset therefore hit an unrelated inbound or failed silently (warning only), so the panel reported success either way. Resolve the id through resolveRemoteID(ctx, ib.Tag) and fail before posting when the tag cannot be resolved. Fixes #6713 * fix(job): list the inbound on the periodic-reset fake nodes Remote.ResetInboundTraffic now resolves the node-side id from the tag via panel/api/inbounds/list before posting resetTraffic. The fake nodes in periodic_traffic_reset_nodes_test.go answered that list with no obj, so the tag never resolved, no reset reached a node, and TestPeriodicResetReachesInboundNodesConcurrently failed (green on main, red after merging the node-side-id fix). Each fake node now lists the inbound it hosts, so the test again measures concurrent resets against a node shaped like a real one. --------- Co-authored-by: Кот <kot@zeroclaw.local> Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com> |
||
|
|
ed31ee432c |
feat(inbounds): deploy AmneziaWG, TUIC and MTProto inbounds to nodes
The node gate assumed a node-assigned sidecar row would never converge,
because the master's reconcile loops only read node_id IS NULL rows. But
a pushed row is local on the node's own panel, whose AmneziaWG, TUIC and
mtg loops run it like any other; node-adopted rows of these protocols
already worked. Only creating or cloning them from the master was blocked.
Open the three protocols on both lists and fix what assumed the master's
host for a node row:
- A node older than the release that introduced the protocol (MTProto
v3.5.0, AmneziaWG v3.7.0, TUIC v3.8.0) would hand it to Xray as-is, so
add and protocol-changing update refuse it, and a node that has not
reported its version yet. Dev builds ("dev+<sha>") track main and pass.
- MTProto's routeXrayPort is a loopback port on the host running mtg.
The master no longer allocates one, nor forwards a cloned source's, for
a node row; the node allocates its own and node sync adopts it back.
- AmneziaWG forwardedPorts were checked against the master's inbounds,
web port and id-derived relay ports. A node row is now checked against
its own node's inbounds; the node re-checks what only it knows.
UpdateInbound restores the stored nodeId before that check.
Verified on a docker master+node pair: AWG, routed MTProto and TUIC
created and cloned from the master start on the node (interface, mtg,
tuic-server); an AWG client added and an MTProto client edited on the
master apply on the node; the node-chosen egress port survives edits.
Closes #6306
|
||
|
|
97bee832f4 |
refactor(util): move panel version comparison into a shared package
The node-assignment path in the service package needs the same MAJOR.MINOR.PATCH comparison the panel updater uses, but service/panel imports service, so the helper cannot stay there. |
||
|
|
05a083eaef |
fix(api-token): keep a token's scope when -getApiToken regenerates it, add -tokenScope (#6700)
* fix(api-token): keep a token's scope when the CLI regenerates it RecreateByName deleted the named row and created a new one without a Scope, so the insert took the column default of admin. Since -tokenName lets the CLI regenerate any token, rotating a monitor or node-sync token silently turned it into a full-access one. The replacement now takes the scope of the row it replaces, and a new name still gets admin as before. A stored scope this build does not know, as after a downgrade, fails the rotation and leaves the row alone instead of guessing. Assisted-by: Claude Code:claude-opus-5-5 (mostly) * feat(cli): let -getApiToken choose the scope of the token it issues -tokenScope sets the scope on both branches of -getApiToken: the token minted on a fresh panel and the one regenerated on a populated panel. Without the flag a regenerated token keeps its scope and a new one gets admin, so every existing invocation, install.sh included, behaves as before. An unknown scope is refused before anything is deleted, so a typo cannot revoke the token it meant to rotate. Assisted-by: Claude Code:claude-opus-5-5 (mostly) * fix(api-token): keep a token's expiry when the CLI regenerates it RecreateByName built the replacement row with ExpiresAt 0, so running `x-ui setting -getApiToken -tokenName <name>` on a token issued through the API with a deadline handed back one that never expires, and said nothing about it - the same silent widening this branch fixed for scope. The replacement now carries the replaced row's ExpiresAt. A token whose deadline has already passed is refused instead of rotated, since keeping the deadline would mint a dead token and dropping it would revive an expired credential without limit; the expired row is left untouched. --------- Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com> |
||
|
|
62423cacd1 |
feat(xray): update xray-core to v26.9.30 and adapt panel
Bump xtls/xray-core to b26a91de4f (v26.9.30) and the three binary pins (DockerInit.sh, release.yml Linux + Windows) in lockstep. No deleted symbols; the sing and sing-shadowsocks indirect deps drop out with the SS2022 rewrite. XDNS finalmask (#6718) replaced its string lists with objects: domains are {name, types, edns0, lenLimit, labelLimit} and client resolvers {type, settings.addr}. The loader no longer parses the old lists, so a single stored xdns mask keeps the whole core from starting. The new leaf package internal/util/maskcompat converts them: "name[:type]" becomes a domain and "name[:type]+udp://addr" a domain plus a udp resolver. A bare name maps to TXT, the type legacy clients queried by default, and each converted domain keeps the 1232-byte EDNS0 the old code always used (without it the server caps answers at 512). It runs from: - the XdnsFinalmaskObjectsFix seeder, over inbound streams, hosts, the xray template, the global sub-JSON mask and cached subscription outbounds; - inbound save (normalizeStreamSettings) and GetXrayConfig, for rows that never went through the seeder; - both link importers, since fm= from an older panel carries the lists. The finalmask form edits the object shape (every key needs a registered field, or the finalmask watch drops it on save) and lifts legacy masks on open. The udp-mask golden fixture moves to the object shape, which TestGoldenStreamFixturesBuildInXray now builds through the core. The wire format changed as well, so pre-upgrade clients need the new core. WireGuard outbound (#6771) dropped settings.domainStrategy and the remoteDNS "local" mode. The endpoint lookup now follows sockopt.domainStrategy and in-tunnel targets the outbound's targetStrategy. The old key is silently ignored, which undid the IPv4-first endpoint lookup the WARP outbound depends on (#5205), and "local" now panics the core at startup because remoteDNS goes through netip.MustParseAddr. The WireguardDomainStrategyFix seeder moves a stored family preference to both keys (a value already set wins) and turns "local" into targetStrategy; the outbound form lifts legacy rows the same way and drops its select, the WARP modal writes the new placement, and the inbound form loses a field the server never read. ValidateOutboundConfig now refuses a non-IP remoteDNS entry, which conf.Build() lets through, on template save and for outbound subscriptions. Noise finalmask items accept type "exp" (#6862), a tag expression. The form offers it for noise items only: header-custom items go through the core's PraseByteSlice, which refuses it. TUN gained autoSystemDnsToGateway (Linux) and autoSystemWfpBlockLeak (Windows). Both pass through the settings schema so a value set in JSON survives the next form save. MASQUE (inbound, outbound, transport) and the XDRIVE transport are new protocols the panel does not offer yet; their new loader refusals only cover configs the panel never generates. The FakeDNS IPv6 pool default, the SS2022 rewrite (same gRPC account; emails are now deduped case-insensitively, as the panel already does), the restored udphop interval default and the rest change no panel-facing config. |
||
|
|
823db05966 |
fix(inbounds): keep the stored client list and enable on inbound save
Invariant: saving an inbound's configuration never changes which clients it holds nor whether it is enabled; both have their own endpoints. The edit modal posts back the clients and the enable flag it loaded when it opened. A client added meanwhile (another admin, the bot, the API, LDAP) was detached and its stats deleted; a client deleted meanwhile came back with its credentials, restoring access that had been revoked; an inbound switched off meanwhile was switched back on. For every save but a master's node-sync push, UpdateInbound now takes the client list and enable from the row it re-reads inside the writer; this replaces the lifecycle-only carry from the previous commit. Client validation (renewal schedule, Hysteria auth, TUIC credentials) moves after that swap so it judges the clients actually saved: a protocol switch keeps the stored clients, and #6268's refusal must apply to them. The edit form no longer loads or sends clients, so neither the JSON editor nor validation sees a copy the server ignores, and the enable switch shows only when adding; the list toggle (/setEnable) covers existing inbounds. Tests that added or re-keyed clients through a panel inbound save pinned the old rule; they now drive the master-push path, where payload clients still apply. |
||
|
|
fb7418f7bd |
fix(mtproto): zero sidecar quotas only for clients whose usage was reset
Invariant: the sidecar's quota counter for a client is zeroed exactly when the panel zeroes that client's usage. InboundService.ResetAllTraffics resets only inbound counters, yet it cleared every MTProto client's sidecar quota - on the master and, through its node propagation, on every node - handing out a fresh quota while the panel still counted the old usage. ResetAllClientTraffics for one inbound likewise cleared the quotas of MTProto clients on every other inbound. The inbound-level reset no longer touches sidecar quotas, and the per-inbound client reset zeroes only the clients it reset. |
||
|
|
4210a50cb4 |
fix(traffic): make a client reset reach every counter enforcing its quota
Invariant: a client traffic reset zeroes every counter that enforces the client's quota - the master's, each hosting node's, and the local MTProto sidecar's. A node cuts a client on its own local counters, and the master adopts that verdict when both judged the same limits (#4917). Node counters: ResetClientTraffic tried the node once and dropped a failure ("nothing replays a reset"); BulkResetTraffic, ResetAllClientTraffics and ClientService.ResetAllTraffics never told the node at all. The node kept its pre-reset usage, switched the client off again on its next tick, and the master latched that - a client shown at zero usage stayed disabled. Every reset path now queues a node_pending_resets row per hosting node in its own transaction. It is delivered right after commit (per-client endpoint up to the push threshold, bulkResetTraffic above) and replayed by the node sync ahead of its snapshot, and dropped only once the node accepted it. While one is owed, the merge takes only that client's usage from the node, not its enable or limits. Deliveries to a node are serialized so a reset is not sent twice. Sidecar quota: BulkResetTraffic, ClientService.ResetAllTraffics and auto-renew zeroed the panel counters but not mtg's own quota counter, so the sidecar kept refusing a renewed or reset MTProto client. They now reset it too, scoped to the affected clients. |
||
|
|
7c84ca9689 |
fix(runtime): drop depleted clients by email, not by stale inbound_id
Invariant: a client whose stats row is switched off is served by no local runtime of any inbound it is attached to. client_traffics is email-keyed, and AddClientStat re-points its inbound_id at the last inbound attached, yet the runtime push builder and the MTProto, TUIC and AmneziaWG desired- instance builders looked the row up by inbound_id. On every other inbound of a multi-inbound client the depletion filter saw nothing, so a depleted client stayed served there whenever its settings entry still read enabled - the state the stale settings writes left in existing databases. All four now resolve the flag through trafficDisabledEmails, keyed by the emails the inbound actually lists. GetXrayConfig already backfills sibling rows by email (backfillClientStats) and is unchanged. |
||
|
|
1110caaa65 |
fix(inbounds): keep stored client lifecycle and counters on inbound save
Invariant: saving an inbound's configuration never changes a client's enable, expiry, quota or renewal state, nor the inbound's own traffic counters. The inbound modal posts back the whole settings.clients list it loaded when it opened, and UpdateInbound stored it, synced it into the client records and wrote it into client_traffics. Any client renewed, depleted or reset while the modal was open was reverted - a renewed client came back disabled with its old expiry. The row itself was read before the serialized tx and saved whole, so traffic the poll added in between was rolled back and a depleted inbound could be re-enabled. UpdateInbound now re-reads the row inside the writer and, for clients the inbound already holds, keeps enable, expiryTime, totalGB, reset, resetDay, resetWeekday and resetMax from it; those change through the client endpoints. A master's node-sync push stays authoritative. Existing clients' lifecycle fields are no longer editable through the inbound JSON editor or /inbounds/update, which the API docs now state. |
||
|
|
feb8451bd1 |
fix(clients): zero traffic before re-enabling a client on reset
Invariant: a traffic reset leaves a quota-disabled client enabled everywhere. ResetTrafficByEmail and BulkResetTraffic enabled the client first and zeroed its counters afterwards. A traffic tick landing between the two still saw the client depleted and switched it off again in client_traffics, the record and settings. Update's direct record write then set the record back to enabled and the reset zeroed the counters, leaving the settings entry disabled: the client showed enabled with zero usage but was dropped from the runtime. The periodic reset job goes through ResetTrafficByEmail for every depleted client on its cycle, and a node push inside Update widens the window to seconds. Zero first, then enable: with the counters at zero the depletion predicate no longer matches, so the tick has nothing to undo. UpdateInboundClient re-adds the enabled user to the runtime itself. |
||
|
|
63ffc083e4 |
fix(clients): stop client ops from reverting a renewal committed mid-op
Invariant: an operation on an inbound's clients writes back only what it changed, onto the settings as committed when it writes. Every client op (add, edit, delete, bulk adjust/detach/delete/set-enable) read the inbound outside the serial traffic writer and then tx.Save'd the whole row inside it. A traffic tick that committed in between - auto-renew re-enabling a client, the delayed-start conversion, a node adoption - was overwritten with the stale copy. A renewed neighbour ended up enable=false with its old expiry in settings while client_traffics said enabled, so the next runtime rebuild dropped a healthy client nobody had touched. The bulk ops then ran a full SyncInbound from those stale settings, copying enable=false into the client record too. Each site now commits through commitInboundClientSettings: inside the serialized tx it three-way merges the op's edit (read -> output, per client and per field) onto the committed settings and updates only the settings column, which also stops the stale up/down/enable inbound columns being written back. advancePushedInbound now records what the per-client push delivered rather than the merged settings, so the node's reconcile-skip fingerprint cannot claim a renewal it never received. |
||
|
|
15d82a5e47 |
fix(inbounds): accept v2.x client fields on inbound import
Panels up to v2.x stored a client's tgId as a string, "" when unset. The startup migration heals copies already in the database, but an exported inbound imported into a current panel goes straight to AddInbound, whose typed client parse failed with "cannot unmarshal string into Go struct field .0.tgId of type int64", so every such import was refused. AddInbound now runs the legacy normalizer the clients-table seeder already used (moved from database to model so both share it) over the incoming settings before parsing them. String numbers become integers and empty ones are dropped, and the settings are stored in that shape. The same applies to /inbounds/add callers still sending the old types. The seeder change is a pure move; the PostgreSQL lane was not run (no Docker on this host) and is left to CI. Closes #6663 |
||
|
|
c8a182b6fb |
fix(wireguard): reject allowedIPs that overlap another client's range
xray's WireGuard inbound credits a packet to the first peer whose allowedIPs
contain its source address, and routes replies by the same table. The panel
only rejected an allowedIPs entry that was string-equal to another client's,
so a pre-assigned address typed in interface notation (10.10.2.9/24, as other
WireGuard tools export it) was accepted and claimed the whole /24: other
clients' traffic and online IPs were credited to that one email, and replies
went to a peer with no endpoint ("no known endpoint for peer").
The collision check now compares masked ranges on every path that uses it:
add, edit, the cross-inbound recheck inside the write transaction, and
AmneziaWG. Auto-allocation skips any address inside a prefix another client
holds. A /0 default route still claims nothing, as before, because legacy
migrated peers carry one. Clients already saved with overlapping ranges keep
working as they do today until edited. The API docs describing the error are
updated, including the stale claim that cross-inbound duplicates are accepted.
Closes #6623
|
||
|
|
4df570b3b0 |
fix(tgbot): build a client's individual links in-process
The bot's "individual links" and QR actions fetched the client's own subscription over HTTP from the public URL it builds for display. With no sub or web domain set that URL falls back to the machine's hostname, which usually does not resolve (`lookup exhausted-reply: no such host`), and even a resolvable name fails behind NAT, a firewall or a disabled sub server. Both actions now ask InboundService.GetSubLinks, the in-process provider the panel's links API already uses, with the host taken from that same URL so the link addresses are unchanged. The now-unused pooled HTTP client goes too. Closes #6597 |
||
|
|
3308c816db |
fix(i18n): scope the empty Min Client Ver hint to cores that honour it
The REALITY Min Client Ver hint promised that an empty field accepts every client version. That is only true on Xray-core v26.9.8+, but the panel's core switcher still installs v26.7.11 to v26.9.7, where an empty field falls back to a built-in 26.3.27 minimum and silently diverts older and third-party clients (Mihomo, sing-box) to the REALITY target. Operators on those cores were told the field was not the cause. The hint now names the version boundary in all 13 locales, matching the docs site. Text-only: no test can reach it; the change is visible in the inbound form's REALITY tooltip after `npm run build`. Closes #6568 |
||
|
|
09617f04f5 |
feat(panel): let a sponsor slot start at a scheduled time
sponsors.json only had an end date, so a booked placement had to be added to the file on the day it started. An optional `from` now hides a sponsor (and its logo) until that instant; entries without it show immediately as before. |
||
|
|
75f3702dd3 |
fix(amneziawg): fall back to a free egress port when 64900 is refused
The panel's SOCKS5 egress for AmneziaWG outbounds bound the fixed 127.0.0.1:64900, and every generated socks bridge dialed that constant. 64900 sits inside Windows' dynamic port range, where the OS can reserve whole blocks (this host excludes 64885-64984), so on the Windows builds release.yml ships the listener could stay down and every AmneziaWG outbound with it. Listen now tries 64900 first and falls back to any free loopback port; bridges, the outbound probe and the port-conflict check use EgressPort(), the port actually held. Bridges are generated apart from the listener, so BuildSocksBridge records the port it wrote and the AmneziaWG job requests an Xray restart while the listener holds a different one. Where 64900 is free nothing changes. The job's restart request is two lines of wiring no test reaches; the staleness it acts on is pinned by TestBridgesStaleUntilRegeneratedForTheBoundPort. |
||
|
|
8f47b53879 |
style(settings): fold the Happ settings into four tabs
Seven tabs, several holding one or two settings, made the page hard to scan. The encrypted-links switch moves above the tabs under auto-detection, the colour profile joins the banners under Appearance & Theme, and Android per-app proxy joins Network & TUN Engine. The QR modal's settings link no longer needs a happTab selector, and the three orphaned tab-label keys are dropped from every locale. |
||
|
|
ff322f901a |
fix(panel): skip the proxy env forwarding test on Windows
Windows env var names are case-insensitive, so https_proxy and HTTPS_PROXY resolve to the same variable there and updateProxyEnvVars forwards it under both names. The helper only runs on Linux - startUpdate refuses every other platform - so the test's case-sensitive expectations only hold, and only matter, on Linux. |
||
|
|
7aa5fc085f |
feat(tgbot): access levels and /start account binding (#6518)
* feat(tgbot): gate the bot behind three user levels Every Telegram account that found the bot could run /help, /status and /usage, and tap any client button it could forge: nothing separated an account no admin had bound from a customer. Each update now resolves to stranger, client or admin, and commands are allowlisted per level so a command added later stays admin-only until it is listed. A stranger may run /start and /id only, and /start answers with the ChatID an admin needs to bind it; a stranger's callbacks are answered and dropped. Client detection reads the same tgId lookup as clientOwnedByTgUser, so the level gate and the ownership check agree. The bot also ignores everything outside private chats: authorization keys on the sender while wizard state keys on the chat, and the two are the same identity only in a private chat. * feat(tgbot): bind Telegram accounts through /start deep links Linking a customer meant the customer sending /id and an admin copying the ChatID into the client by hand, which does not scale past a few customers and is easy to get wrong. The admin client card now offers an invite link, t.me/<bot>?start=<subId>, and the first account to open it is bound through the existing SetClientTelegramUserID. A subId already grants the subscription, so binding gives the holder nothing the token did not. A subscription that spans several clients binds all of them, and is refused if any part belongs to another account; re-opening your own link is idempotent. Unknown and already-claimed tokens share one reply, so the link cannot be used to probe for valid subIds. * fix(tgbot): harden invite claims after review Review of the access-level and binding change found five problems: - Concurrent claims of one link all read the client as unbound, all bound and all were told so, while only the last write held. Resolving and binding now share one lock, and a bind that fails part-way through a multi-client subscription undoes the bindings it already made. - A subId has no minimum strength and the bot needs only its public username, so /start was an unthrottled guessing oracle. Non-admin claim attempts are capped at five per account per hour, the first refused one notifies the admins, and the Subscription ID field now says it doubles as the bot invite code. - levelOf expanded every inbound's client JSON on every non-admin update. It now reads the indexed tg_id column of the clients table. - A button tapped in a group chat was dropped unanswered and kept spinning, with nothing logged. It is answered now, and each ignored chat is logged once. - The subId was pasted raw into the t.me link, so '#' or '&' truncated it and Telegram rejects anything outside A-Za-z0-9_-. The payload is now base64url, and a subId too long for the 64-character limit is refused. * fix(tgbot): answer group chats again and make the claim race test bite ignoredChat dropped every non-private chat because wizard state was keyed by chat while authorization keyed on the sender. #6604 on main re-keyed that state by (chat, user) so admins can drive the bot from a group, so after the merge the drop only took the whole bot away from those admins, report keyboards sent to a group included. The level gate already keys on the sender, so group chats need no special case. TestConcurrentClaimsBindOnlyOneAccount passed with inviteClaimMu removed: the first claimant took the pool's idle connection and bound before the rest had opened theirs, so no two ever raced. It now holds the inbound write the binds need until every claimant has resolved, and fails without the lock ("6 accounts told they bound"). TestCommandAllowed restated the commandsByLevel map; TestGateCommand drives the same allowlist through gateCommand. TestIgnoredChat goes with the code it pinned. * docs(tgbot): document access levels and invite links The command table still said /help and /status answer anyone. An account no admin has linked now reaches only /start and /id, and a customer is linked through the client card's Invite Link, whose token is the Subscription ID. Updated in en, fa, ru and zh. --------- Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com> |
||
|
|
6f40a75909 |
feat(inbound): excludeFromSub hides links without disabling (#6463)
* feat(inbound): excludeFromSub hides links without disabling Add a per-inbound flag that omits subscription output while keeping the inbound enabled for Xray, auth, and traffic accounting. Fixes #6435. * fix(inbound): excludeFromSub review follow-ups gofumpt model.go, sync docs OpenAPI, keep excludeFromSub master-authored on node mirror, and exercise the legacy add-column migration path in tests. * fix(sub): keep excluded inbounds' clients in the usage header The excludeFromSub filter sat in getInboundsBySubId's SQL, so an excluded inbound's clients never reached seenEmails in the raw, Clash or JSON renderer. A client that lives only on a hidden inbound (one client per inbound sharing a subId) dropped out of the Subscription-Userinfo usage, quota and expiry and out of the info-node state, while the inbound kept serving it and counting its traffic. The query returns every enabled inbound again; each renderer skips an excluded inbound's links but still counts its clients, the same rule the Clash renderer already applies to external links it cannot express. --------- Co-authored-by: mrchatam <mrchatam@users.noreply.github.com> Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com> |
||
|
|
12d51d7195 |
perf(tests): copy a migrated template DB instead of migrating per test
Most tests opened a throwaway panel DB with database.InitDB, which runs the full AutoMigrate + seed on an empty file every time: ~230ms, and ~850ms under -race because GORM's reflection-heavy migration is what the detector slows most. internal/web/service does this in ~550 of its 830 tests, so the CI race job spent ~10 of its ~14.6 minutes re-migrating empty databases. internal/database/dbtest.InitDB migrates once per test process, then hands each test its own copy of that file (~130ms under -race) and registers the CloseDB cleanup. The copy then goes through InitDB like a panel restart, so every test still starts from the state a fresh install has. Tests that reopen an existing file, migrate a hand-built legacy DB or target Postgres keep calling database.InitDB. Locally under -race: internal/web/service 626s (last CI run) -> 114s, internal/sub 246s -> 35s. |
||
|
|
33a469315a |
feat(clients): preserve traffic counters in portable export/import (#6469)
* feat(clients): preserve traffic counters in portable export/import ExportAll now attaches client_traffics up/down (plus resetCount and last-seen fields) on each portable payload, and ImportClients restores them only for newly created emails so skipped/existing clients keep their live counters. Fixes #5858. * fix(clients): restore imported traffic only onto rows the import created Review of the portable-traffic export/import (#5858) found four defects: - An orphan's restored row was hand-built, dropping reset_weekday and forcing enable=true; a row kept by a keepTraffic delete kept the old client's limits. depletedClientsClause then matched a weekly-renewing over-quota orphan and DelDepleted deleted it. Orphan rows now go through AddClientStat, whose upsert refreshes config and keeps counters, so the unused traffic.total field is dropped from the export. - Created clients were inferred from Skipped emails, so a duplicate email in the file left the created copy with zero counters. bulkCreate now reports which payloads inserted a record, and only those are restored. - Each client took its own serialized-writer commit: 2000 clients spent 3.66s instead of 0.52s. Counters now apply in batched transactions (0.51s). - importClients discarded needRestart when the late restore step failed after clients were committed; it now flags and notifies first, as create already does. The /clients/export and /clients/import API docs now describe traffic. * fix(groups): keep imported traffic out of group totals Group totals keep a deleted client's usage (#5675), and the portable import restores that same usage onto the re-created client. Export, delete, re-import therefore counted it twice in ListGroups, and a fresh panel showed the migrated usage as consumption of its groups. Restored counters are usage from before the import, so the import now shifts each group's baseline up by what it restored, in the same transaction. A group total no longer moves at import time; only traffic consumed afterwards counts. The baseline shift reuses the #5675 helper, now signed. --------- Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com> |
||
|
|
0ef94b686e |
feat(tgbot): add /broadcast to relay an admin message to all clients (#6510)
* feat(tgbot): add /broadcast to relay an admin message to all clients Admins had no way to reach every client at once: notifications only cover exhausted quotas, so an operator had to copy a message to each client chat by hand. Add an admin-only /broadcast flow to the bot: - /broadcast asks for a message; any message the admin sends — text, rich text, photo, video, file, sticker or a whole album — becomes the broadcast by reference (admin chat + message ids), and a preview self-copy shows the admin exactly what recipients will get while rejecting content Telegram cannot copy before the run starts. - The draft references the original instead of parsing its content, so copyMessage/copyMessages deliver everything 1:1 on behalf of the bot with no forward header (the admin's identity stays private), no caption length pitfalls, and future Telegram message types work without new parsing. - A media group arrives as separate updates; its ids are buffered with a short debounce, sorted, and delivered as one copyMessages call so recipients see the original album. - Delivery runs in a background goroutine (common.GoRecover): sequential sends with a small pause, 429 retry_after honored per recipient, failures counted without stopping the run, progress edited into one card at most every 25 sends or 3 seconds, a cancel button checked between sends, and a final delivered/failed/skipped summary. The summary is edited into the card (only sent separately if the card is gone), so it is never duplicated. - Recipients repeat the notifyExhausted walk: clients with a linked tg_id, deduplicated, admins excluded — they already receive the reports. The message content is never logged. New i18n keys are added to all 13 locales. * fix(tgbot): harden broadcast composition per review - Key the composition per admin chat instead of one process-wide draft: two admins can now compose at once without dropping each other's drafts, and one admin's /broadcast no longer wipes another chat's half-collected album. - Bind each preview card to its own draft via a random token carried in the confirm callback, so a stale Send tap is answered with an error instead of delivering a newer, unapproved draft. - Ignore non-admin senders while a chat composes: the awaiting state is keyed by chat id, and in a group that chat is shared. - Check the cancel flag inside the flood-control retry loop, so a 429 with a long retry_after no longer holds the single broadcast slot after the admin cancelled. - Scale the per-recipient pause by the copied batch size, so an album keeps the same per-second ceiling as a single message. - Trim the comment blocks that exceeded the two-line cap. * fix(tgbot): reset broadcast state on stop and classify 403 as skipped - Clear compositions and cancel the active run from StopBot, next to the per-chat draft resets: an album debounce timer, a confirmable token or a held runner slot must not outlive the receiver that created them. - Sleep flood-control waits in 5 s slices and re-check cancel and bot state between them, so a minutes-long retry_after no longer parks the single-runner slot after the admin cancelled or the bot stopped. - Count Telegram 403 (the chat never started the bot, or blocked it) as skipped instead of failed, log it at debug rather than one warning per recipient, and append one line to the summary naming the reason. - Trim the remaining comment blocks over the two-line cap. * fix(tgbot): count unreachable recipients in broadcast progress throttle The progress card refresh was keyed on sent+failed, which a 403 does not advance since unreachable chats were split out of the failure count. A streak of unreachable recipients while that sum sat on a multiple of broadcastProgressEvery (0 included, so from the very first recipient) edited the card once per chat, doubling the request rate the send delay is sized for and defeating the throttle. Count processed recipients. * fix(tgbot): key broadcast compositions by admin, not chat After #6604 moved conversation state to the admin (chatUser), the broadcast draft map stayed keyed by chat. Two admins composing in one group then shared a slot: the second admin's message dropped the first admin's draft, whose Send tap answered "went wrong" while only the other draft could go out - the same class #6604 fixed for the add-client wizard. Drafts, album buffers and confirm tokens now live under the admin who ran /broadcast. The router now hands handleBroadcastInput only the admin whose own /broadcast is awaiting input, so its sender re-check and the test that fed it a non-admin message directly (an input no route can deliver) are removed. --------- Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com> |
||
|
|
71e38367c1 |
feat(sub): add Incy app-management parameters (#6650)
* feat(sub): add Incy app-management parameters The panel already pushes a set of Happ headers, but INCY documents its own lowercase header names and its own value domains, so a Happ-shaped payload gets ignored by the client (per-app mode is bypass|proxy, not on|bypass, and per-app-proxy-enable has no Happ counterpart at all). Add a sibling Incy path that emits exactly the documented headers. Covered, per https://docs.incy.cc/en/app-management/: - profile-description, sort-order, support-email, announce-url, premium-url - banner text/button/URL and the two hex colours - hide-url, hide-check, no-limit-enabled - per-app split tunnelling (enable/mode/list) - TCP fragmentation (enable/length/interval/packets) - UDP noise packets (enable/type/packet/delay) - DoH pre-resolution (enable/domain/IP) Each string setting is tri-state: an empty value omits the header, so an untouched panel never overrides the subscriber's own choice in the app. Values are validated against the documented domains and dropped when they do not match, and non-ASCII text is base64-wrapped the way the docs require for Cyrillic. INCY identifies itself as INCY/<version>/<platform>, which gates the headers behind the same auto-detect switch the Happ path uses. Headers the panel already emits for every client (Profile-Title, Support-Url, Profile-Web-Page-Url, Announce, Profile-Update-Interval, Subscription-Userinfo) and Incy's routing line are left as they are. The Premium API (theme, defaultPingProtocol, fallbackHosts, ...) is a separate encrypted endpoint and stays out of scope here. * fix(sub): keep Incy per-app list entries separate on the wire The Incy settings textarea takes one package per line, as Incy documents for per-app-proxy-list, but the header path ran the value through sanitizeHeaderValue, which deletes CR/LF. "com.google.chrome\norg.telegram.messenger" reached the client as the single bogus package "com.google.chromeorg.telegram.messenger", so per-app split tunnelling silently matched no app. Join comma- or line-separated entries as CSV instead. Also drop three tests that could not fail: TestIncyExcludesHappOnlyHeaders (ApplyIncyHeaders has no path that emits Happ headers, and the non-Happ UA gate is already pinned by TestApplyHappHeaders_Gating) and two UI tests that only asserted updateSetting received the key the JSX passes it. --------- Co-authored-by: DIMFLIX <dimflix@users.noreply.github.com> Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com> |
||
|
|
bd9ccde1f4 |
feat(sub): make external subscription fetch User-Agent configurable (#6613)
* feat(sub): make external subscription fetch User-Agent configurable Some providers reject fetches that do not send a known client User-Agent. Expose externalSubUserAgent as a panel setting (default v2rayNG/1.8.5) and use it when fetching client external subscription URLs. Fixes #6383 * ci: retrigger frontend after npm registry maintenance The frontend job failed solely on `npm audit` while registry.npmjs.org returned 503 (Service Under Maintenance). Lint, typecheck, vitest, vite build, and storybook all passed. Local `npm audit --omit=dev --audit-level=high` now reports 0 vulnerabilities. * fix(sub): fall back to the default UA when the DB is not initialised externalSubUserAgent read the setting through SettingService.getSetting, which calls Model() on database.GetDB() and panics on a nil *gorm.DB. The fetch path's other DB read, service.ExternalSubscriptionHwid, already treats a nil DB as unreachable and sends no header; the new UA lookup did not, so any fetch before InitDB panicked instead of sending the historical v2rayNG/1.8.5. Production initialises the DB before the sub server starts, but the internal/sub fetch tests run without one: under make test-go's -shuffle=on, whenever one of them ran before the first InitDB test the panic aborted the whole package. Reproduced deterministically with go test -run '^TestDoFetchSubscriptionLinks_RejectsOversizedBody$'. --------- Co-authored-by: mrchatam <mrchatam@users.noreply.github.com> Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com> |
||
|
|
9672249edb |
feat(clients): add calendar weekly renewal and schedule previews (#6524)
* feat(clients): add calendar weekly renewal and schedule previews Expose fixed-day, calendar-weekly, calendar-monthly, and disabled renewal through one shared selector in individual and bulk client forms. Store the weekly weekday separately (Monday 1 through Sunday 7) and use panel-local calendar dates rather than a fixed 168-hour duration. Resolve skipped or repeated midnights to the first valid instant of the selected date, and skip an entirely nonexistent calendar date rather than changing the weekday. Reuse the existing renewal writer and share its boundary alignment and per-period catch-up calculation with an authenticated, read-only preview. Keep monthly precedence for legacy records, fixed-day interval semantics, maximum renewal allowances, first-use durations, and operator-disabled settings unchanged. Selecting a mode does not rewrite an existing cutoff; an unset calendar cutoff requires an explicit action to choose the first. The last-valid-second preview uses the stored exclusive expiry, even when the billing calculation aligns a legacy last-second cutoff up to midnight. Carry weekly schedules through client persistence, paging, enable toggles, inbound settings, and node traffic reconciliation. Migrate missing or nullable weekday columns to disabled by default without altering existing limits, and include the new isolated-schema PostgreSQL regression in the live CI gate. Regenerate API contracts and reference documentation, add lifecycle and form regressions, and document timezone, quota-reset, and upgrade considerations. All participating nodes must be upgraded before weekly mode is enabled; older binaries ignore the new field. Independent periodic traffic resets and the optional month-end subscription-header display are not changed. * fix(clients): validate renewal schedules across inbound write paths Reject conflicting weekly/interval/monthly schedules and out-of-range weekdays on inbound creation and edits, legacy one-client apply paths, record/link synchronization, and traffic metadata writes. Validate imported traffic snapshots as well, before any inbound or client is persisted, so an inbound API cannot create a client that the clients page cannot toggle. Merge a weekly-related schedule as one timestamp-selected tuple rather than filling its zero fields from another renewal mode. Preserve empty migration snapshots and the existing non-weekly monthly/interval merge semantics. Renewal caps, counters, credentials, and deadlines are unchanged. Add regressions for nine write paths, unchanged records and runtime calls after rejection, valid inbound clients remaining editable, and duplicate record merges between individually valid renewal modes. * docs(clients): clarify depleted-client deletion risks on downgrade Explain in English and Chinese that older versions not only stop weekly renewal: their depleted-client cleanup can delete a weekly-only client once its expiry or quota is exhausted. This is conditional on cleanup, not an automatic deletion caused by downgrade itself. Recommend backing up and converting weekly schedules to a mode supported by every participating version before rollback, and avoiding cleanup while mixed versions or unconverted clients remain. Merely disabling weekly renewal does not restore the old binary's missing purge protection. * fix(clients): bound weekly renewal date searches Limit the search for a valid weekly calendar date to eight candidates so an unusual timezone cannot monopolize the single traffic writer. Exhaustion returns the original instant, allowing the existing catch-up forward-progress guard to stop without advancing expiry, consuming an allowance, resetting traffic, or falling back to a fixed-duration schedule that can drift. Reject a non-future calendar suggestion in the read-only preview instead of offering an immediately expired initial cutoff. Also report failed weekly catch-up as a search error when allowances remain, not as cap exhaustion. Existing preview errors use the form's current warning; no API schema or locale changes are needed. Exercise exhaustion with a synthetic valid TZif containing twelve skipped Sundays. This fault-injection case was red without the bound; it is not a claim that a production IANA timezone was observed hanging. Keep the Havana and Apia regressions for real skipped/repeated midnights and absent dates. * fix(tests): isolate weekly renewal preview timezone Stop the weekly search regression from replacing process-global time.Local. CI caught that assignment and its cleanup racing with background timer reads through time.Now, even though the top-level tests do not use t.Parallel. Pass the timezone and current instant into the unchanged preview calculation. The public service still validates the request and resolves the panel timezone; API responses, renewal accounting, and persisted client data are unchanged. Use fixed dates for both suggestion and catch-up exhaustion, removing the test's dependency on today's date and its unnecessary database setup. Keep a bounded-lifetime background clock reader to expose future global-timezone mutations under the existing race gate rather than disabling that check. * ci: retrigger PR checks Create an empty commit to request a fresh pull-request CI run after release dependency downloads failed with network errors. No source, dependency, or workflow changes are included. Retry the existing checks without bypassing them. * ci: retry PR checks and record deferred download hardening Request another CI run after the amd64 release job compiled successfully but failed during dependency fetching with exit code 4 (network failure). Record possible follow-up improvements for the Linux release fetch helper: - Print each download URL and destination, and preserve error details. - Reuse the existing curl configuration with up to five retries; add connection and per-attempt timeouts and a bounded retry window. - Download to a temporary file and promote it to the final filename only after a successful, non-empty transfer. Keep the job failing if downloads ultimately fail. - Validate successful downloads, recovery after a temporary failure, and correct failure after persistent errors before shipping such a change. These improvements are intentionally deferred, not implemented or tested by this commit. This commit is empty: renewal logic, dependencies, workflow configuration, check requirements, and TLS verification remain unchanged. --------- Co-authored-by: JacktheRanger <219502738+JacktheRanger@users.noreply.github.com> |
||
|
|
5e15120cec |
feat(happ): add routing editor, optional ad blocking, and LAN bypass preset (#6545)
* feat(happ): make ad blocking optional in routing presets Add an independent AdBlock toggle for Iran, China, and global presets, applied only when generating routing rules. Update the China preset to Bypass-CN and cover preset behavior and localized controls. * feat: add visual routing editor with JSON support and localization updates - Implemented a new modal for editing routing profiles with basic and advanced tabs. - Added functionality to load, parse, and generate routing profiles in JSON format. - Enhanced user experience with validation and error handling for JSON input. - Updated translations for Russian, Turkish, Ukrainian, Vietnamese, Chinese (Simplified and Traditional) to include new routing editor terms. - Created helper functions for managing routing profiles and generating deep links. - Added unit tests for routing editor functionalities and JSON handling. * feat: update routing editor to preserve null lists in profiles and enhance validation messages |
||
|
|
788b76c544 |
fix(amneziawg): sniff the relay with routeOnly; scope the v6 egress to IPv6 (#6654)
* fix(amneziawg): sniff the relay with routeOnly The embedded AmneziaWG relay sniffed without routeOnly, so a sniffed SNI replaced the dial target. Telegram's FakeTLS recovery dials 194.221.250.50:443 with SNI www.google.com; the rewrite sent it to real Google and the client looped on "TLS hash mismatch", stuck on "Connecting". Sniffing here exists only so domain routing rules can match; routeOnly keeps that and dials the IP the peer resolved. Fake-pool targets are still rewritten (the dispatcher ignores routeOnly for fakedns). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(amneziawg): send only IPv6 targets through the peer's v6 egress The per-peer IPv6 egress rule matched every flow of that peer, but its freedom outbound binds a v6 sendThrough and cannot dial an IPv4 target, so IPv4 DNS and any other unsniffed IPv4 traffic of such a peer failed. Sniffed TLS/HTTP only worked because the sniffed domain replaced the IP; with routeOnly on the relay that no longer happens. Limiting the rule to ::/0 keeps the peer's IPv6 identity for IPv6 targets and lets IPv4 targets take the regular outbound. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Kirill Rudenko <rudenko@npp-energy.ru> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com> |
||
|
|
6ab718f813 |
fix(sub): make Happ require auth on its local SOCKS/HTTP proxy (#6628)
Happ ships its local SOCKS5 (127.0.0.1:10808) and HTTP inbounds with authorization disabled by default. Any app on the same device can then connect to that proxy, bypassing Android's per-app VPN routing, and learn the VPN server address - the leak publicly described in March-April 2026 for Happ, v2rayNG and other VLESS clients. Happ fixed its Xray API exposure, but the unauthenticated local proxy remained. Happ exposes a standard subscription header for this (no Provider ID required): socks-auth-mode / http-auth-mode = auto|manual|from-json| disable. A new subscription setting, subHappLocalProxyAuth (default "auto"), sends both headers to Happ clients. Like every other Happ header it is emitted only when Happ auto-detect is enabled and the User-Agent is Happ, so panels that never opted into the Happ integration see no change. An empty value sends nothing and keeps the client's own setting. Verified on Happ Android 4.4.1 (Xray 26.7.28): a subscription carrying socks-auth-mode manual + a test user/password switched the client's Inbounds screen to Manual with those credentials on "refresh subscription", and "auto" switched it to Auto with generated credentials. Co-authored-by: Kirill Rudenko <rudenko@npp-energy.ru> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com> |
||
|
|
8979072bd9 |
fix(amneziawg): bound S1-S3 by the receive buffer, reject overlapping H (#6642)
* fix(amneziawg): bound S1-S3 by the receive buffer, reject overlapping H The native AmneziaWG validator, both Zod schemas, both forms and the docs now follow the rules amneziawg-go actually enforces. S1-S3. A padded handshake message is 148+S1, 92+S2 or 64+S3 bytes (device/send.go). The peer reads each datagram into a [MaxMessageSize]byte buffer, where MaxMessageSize = MaxSegmentSize (device/pools.go, constants.go). MaxSegmentSize is 65535 on Linux/Android, 2016 on Windows and 1700 on iOS (device/queueconstants_*.go). The limits are therefore S1 <= 1552, S2 <= 1608 and S3 <= 1636. Before, S1/S2 allowed 65535, which iOS peers silently drop, and S3 was capped at 64, a number inherited from the coinman-dev/3ax-ui port in #6105 with no stated reason. That cap blocked real configs such as Amnezia Premium's S3=1045. RandomTrailers only tops a packet up to 500 bytes (DefaultUdpWindow), so it never pushes a message past these limits. H1-H4. amneziawg-go refuses the whole device when the header ranges overlap ("headers must not overlap", device/uapi.go mergeWithDevice), and so does the kernel module (src/netlink.c). The panel did not check this, so an inbound with overlapping ranges saved and then failed to apply. A blank H is never sent, so the engine keeps its default, WireGuard's own type 1-4; the check treats blank fields that way. The docs said 1-4 "must not be used". They are valid and are the engine default, only unobfuscated without a HeaderProtectionKey. The docs also said amneziawg-go rejects S1+56 == S2. It does not (IpcSet accepts it). The panel keeps that rule as a fingerprint guard, and the docs now say so. Tests: the new params_test cases and the Zod bounds fail on the old code. TestValidatedObfuscationAlwaysApplies runs every accepted set through a real amneziawg-go IpcSet and now covers overlap, blank-H defaults, H=1-4, the exact S bounds and the full Amnezia Premium set. Before this fix it failed with "headers must not overlap". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(amneziawg): bound only inbound padding by the iOS receive buffer The 1700-byte iOS buffer limits what an inbound's clients can receive, but ValidateObfuscation also runs for outbounds, and the Xray template save re-validates every AmneziaWG outbound. An outbound whose remote server uses S1 above 1552 would have blocked every Xray settings save, though its values come from that server and are received on Linux. ValidateObfuscation keeps amneziawg-go's uint16 UAPI width for S1-S3; ValidateServerObfuscation adds the receive-buffer bounds and is what inbounds call. The outbound schema and form follow the same split. --------- Co-authored-by: Kirill Rudenko <rudenko@npp-energy.ru> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com> |
||
|
|
f3b100282a |
fix: allow IPv4 and IPv6 inbounds to share a port (#6603)
* fix: distinguish IPv4 and IPv6 listen conflicts * fix(ports): let an IPv4 address share a port only with a v6only wildcard xray listens on tcp/udp, and Go opens every wildcard listen, 0.0.0.0 included, as one dual-stack socket unless sockopt.v6only is set. Treating :: and 0.0.0.0 as separate families let the panel save pairs the core then fails to bind, and it broke main's own TestListenOverlaps. listenOverlaps now takes the inbound's sockopt.v6only: a wildcard claims both families, or only IPv6 with v6only, so :: with v6only may share its port with an IPv4 address while a plain :: or 0.0.0.0 still may not. --------- Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com> |
||
|
|
bd01f923fb |
fix(tgbot): unstick the add-client wizard's inbound picker (#6621)
* fix(tgbot): unstick the add-client wizard's inbound picker Tapping "➕ Новый клиент" always sent the "choose inbound" message, even when the button list ended up empty after protocol filtering. getInboundsAddClient checked len(inbounds)==0 before filtering but never re-checked after, so an admin whose every inbound was excluded got a message with nothing to tap and no further feedback. WireGuard and AmneziaWG were excluded outright too, a holdover from the wizard's original 2025 implementation, before defaultWireguardClients and defaultAmneziaWGClients existed. Both now auto-generate a keypair + AllowedIPs for a client with none set, and the subscription server already emits wireguard:// and vpn:// share links for them, so both inbound types flow through the same generic Create path as VLESS/Trojan already used by the bot. Mixed/HTTP/Tunnel stay excluded: they have no per-client model in this codebase. - getInboundsAddClient now returns getInboundsFailed when the button list is empty after filtering, instead of sending an unusable keyboard - WireGuard/AmneziaWG removed from the exclusion list in both getInboundsAddClient and getInboundsAttachPicker - the previously duplicated excludedProtocols map is now a single package-level addClientExcludedProtocols shared by both functions * test(tgbot): pin which inbounds the add-client picker offers The picker change had no test. One drives a database holding WireGuard, AmneziaWG, VLESS and Mixed inbounds and wants the first three offered; the other holds only Mixed, HTTP and Tunnel and wants getInboundsFailed instead of an empty keyboard. Both fail on the previous picker. --------- Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com> |
||
|
|
8f1201553e |
fix(ip-limit): CAS-retry inbound_client_ips merges under Postgres (#6612)
* fix(ip-limit): CAS-retry inbound_client_ips merges under Postgres Two writers RMW the same ips JSON blob; on PostgreSQL a lost update drops remote IPs that partitionLiveIps only sees through that blob (#6587). Compare-and-set on the previous blob with re-merge on miss, matching the repo's conditional Where+RowsAffected pattern. Fixes #6587 * ci: retrigger frontend after npm registry maintenance The frontend job failed solely on `npm audit` while registry.npmjs.org returned 503 (Service Under Maintenance). Lint, typecheck, vitest, vite build, and storybook all passed. Local `npm audit --omit=dev --audit-level=high` now reports 0 vulnerabilities. * test(ip-limit): cover the scan's CAS against a mid-scan node sync The job-side compare-and-set had no test of its own. A write injected between the scan's read and its update now has to keep the node's remote IP; main's blind Save drops it. Also keeps the new comments to two lines, as CLAUDE.md requires. --------- Co-authored-by: mrchatam <mrchatam@users.noreply.github.com> Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com> |
||
|
|
a2ca023336 |
fix(sub): send panel guid as X-HWID on outbound subscription fetch (#6579)
* fix(panel): accept 2FA codes from adjacent TOTP windows CheckUser compared only gotp.Now(), so a code submitted at the end of its 30s window (or with slight client/server clock drift) failed with 'invalid 2fa code', while the immediate retry in the next window succeeded. Accept current +/-1 window, the standard TOTP skew tolerance. Fixes MHSanaei/3x-ui#6535 * fix(panel): share TOTP skew tolerance with VerifyTwoFactorCode Move the +/-1 window helper to internal/util/totp so both 2FA acceptance points use it: login (CheckUser) and disable/rebind plus username/password changes (VerifyTwoFactorCode). Also shrink comments to the 2-line house rule and anchor the unit test mid-window to avoid a step-boundary flake. Addresses review on #6546 (MEDIUM + 2 LOWs). * fix(sub): send panel guid as X-HWID on outbound subscription fetch Outbound subscriptions hit the same HWID-limited donor 404 as client external links (#6559/#6567). Identify this panel with GetPanelGuid plus X-Device-OS, honoring the externalSubSendHwid opt-out. Fixes MHSanaei/3x-ui#6574 * fix(sub): send the external-subscription X-HWID from outbound fetches too The outbound fetch used panelGuid while client external links send the externalSubHwid id from #6567, so an HWID-limited provider counted one panel as two devices. It also re-added the externalSubSendHwid opt-out that #6567 dropped. Move the id into service.ExternalSubscriptionHwid, keeping the externalSubHwid row so existing installs keep their slot, and send it from both paths. The outbound test now fails on the panelGuid version. --------- Co-authored-by: sdhfsl <sdhfsl@users.noreply.github.com> Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com> |
||
|
|
169cd86e00 |
fix(sub): never use X-Real-IP as the subscription host (#6608)
ResolveRequest and the panel's resolveHost fell back to X-Real-IP for the host when a trusted proxy sent no X-Forwarded-Host. X-Real-IP names the visitor, so behind nginx with only that header set, subscription and exported links advertised the subscriber's own public IP as the server. The host now comes from a trusted X-Forwarded-Host, else the dialed request Host. X-Real-IP stays a client-IP source only. Fixes #6589. |
||
|
|
66df77665f |
test(database): give each package its own schema when tests run on PostgreSQL (#6594)
With XUI_DB_TYPE=postgres every test package shared one database and worked in public. Go runs package test binaries concurrently, so migrations raced and rows a previous run left behind leaked into the next. testpg.IsolatePackage creates a schema for the calling package, puts it first on search_path and drops it when the package finishes. It returns at once unless XUI_DB_TYPE is postgres. internal/web/service's TestMain adopts it. |
||
|
|
ee2ff48c81 |
fix(tgbot): scope the add-client wizard to the admin, not the chat (#6604)
Two admins in one group chat shared one draft and one wizard step: clientDrafts and userStateStore were keyed by chat id alone, so the second admin's wizard opened on the first one's email and limits, and whichever of them tapped a control last decided what the other created. Key both stores by (chat, user) instead. A private chat is unaffected: its two ids are equal, so the key matches what the chat alone used to be. A message with no sender (a channel post) keys to user 0, which no admin holds. Fixes #6593. |
||
|
|
3b9ca47a4e |
fix(database): keep the legacy tag cleanup from colliding with an existing tag (#6592)
* fix(database): avoid legacy inbound tag cleanup collisions * test(database): assert the legacy tag cleanup keeps the migration green The collision guard's test asserted only that the colliding tag was left alone, which an unguarded cleanup also produces: the UPDATE fails on the unique index and the row is unchanged either way. The cleanup shares a transaction with every other requirement, so that failure rolls all of them back on every boot and only reaches the log. Assert the call itself succeeds, which is what actually distinguishes the two. --------- Co-authored-by: n0ctal <n0ctal@users.noreply.github.com> |
||
|
|
c0c0136037 |
fix(hwid): serialize the device-limit write with its trim (#6591)
setClientLimitHwidByEmail wrote clients.limit_hwid and then trimmed client_hwids as two independent statements. A traffic-cycle Save that read the record before the limit changed could write the stale value back after it, and a failed trim committed the new limit anyway. Both halves now run inside runSerializedTx, the transaction the traffic writer already owns. setClientLimitHwidByEmailTx and clearClientHwidsBySubIDTx refuse a handle that is not that transaction (errClientHwidWriteNotSerialized) instead of falling back to the shared handle. Client delete moves onto the same writer, and BulkCreate withdraws a re-created client's tombstone before applying its optional HWID limit. TestSetClientLimitHwidIsSerializedWithSyncInbound holds a stale traffic-cycle Save open across the limit change and fails without the serialization (limit_hwid = 5, want 1). |
||
|
|
d86a3def85 |
fix(ip-limit): append fail2ban lines only after the scan commits (#6590)
updateInboundClientIps wrote the [LIMIT_IP] lines that drive the jail while the scan's transaction was still open, and marked the addresses in bannedSeen at the same time. A commit failure after that point rolls the database back but takes nothing back from the log: fail2ban proceeds to ban addresses the panel never recorded, and the in-memory bannedSeen entry makes the next scan skip them, so the rollback is never repaired. Selection stays inside the transaction. processObserved now collects one pendingBan per enforced client and publishes after the commit succeeds, disconnecting only the clients whose lines actually reached the log. The Xray disconnects already ran after the commit for the same reason. Recording moved with the write rather than with the decision: selectAdvancedSinceLastBan no longer mutates anything, and recordBannedSeen runs once a line is on disk. It also runs for clients with nothing to ban, because that is the pass that forgets addresses a client no longer exceeds its limit with - pruning used to be a side effect of the filter, and skipping it left a stale entry that suppressed the next legitimate ban. The log file is opened once per scan instead of once per client, the write error is checked instead of discarded, and Close is reported. updateInboundClientIps no longer reports shouldCleanLog, because the only thing that set it was the ban branch that moved out; processObserved sets it when a publication actually happens. disAllowedIps went with the write it served. Tests: a transaction failed at COMMIT through a deferred foreign key leaves no line and no bannedSeen entry; a publication that cannot open the log leaves the address retryable; a client returning under its limit has its entry forgotten, so going over again is banned a second time; a committed over-limit scan publishes and reports; and writeBanLines surfaces a write error rather than swallowing it. |
||
|
|
dcaadd4857 |
fix(panel): validate sponsor logo name before any file or network use
The public /sponsors/logo/:name route only accepted names matching an active sponsor's logo, which was already regex-filtered, but that guard was indirect. Checking sponsorLogoRe on the name itself makes the path/URL safety local and clears CodeQL alerts #113 (go/request-forgery) and #114 (go/path-injection). |
||
|
|
fd7b3559bc |
feat(panel): add sponsor slots fed from sponsors.sanaei.dev
Monthly sponsor placements need to change without cutting a panel release. Panels now read 3X/sponsors.json from the MHSanaei/sponsors repo (GitHub Pages on sponsors.sanaei.dev) and show active sponsors in four slots: an overview banner, a rotating sidebar card (max three), the login page and a new Sponsors page that also lists open placements. An entry shows only while enable is not false and until is in the future; links must be https and logos are png/webp/jpg by name only. The list is cached for an hour and the last good copy survives upstream failures; logos are proxied through /sponsors/logo/:name with failures cached, so CSP stays 'self' and admin browsers never reach a third party. Admins can hide a slot for 24h. Under XUI_DEBUG the panel reads a sibling ../sponsors/3X checkout so edits can be previewed before push. |