mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-05 04:14:21 +00:00
679d2e1cca
* fix(node): never re-add a node's full counter on reset/restart (#5456, #5476, #5390) When a node's per-client counter dips below the master's stored baseline (node reboot, xray restart, or a reset propagated to the node), the delta accounting clamped delta to the node's whole current counter and re-added it to the master total — double-counting a client's lifetime usage in a single sync and often pushing them over quota. Treat a backward-moving counter as a reset: add 0 and rebaseline to the reported value, so only genuine post-reset usage accrues. Resets also now clear the per-node NodeClientTraffic baseline (ResetClient TrafficByEmail, resetClientTrafficLocked, BulkResetTraffic, resetAllClient TrafficsLocked), mirroring the delete paths. Without this the node's pre-reset cumulative — including traffic it had counted but not yet synced — leaks back onto the master after a reset, which is the 'reset reverts after a while' report. The next sync then takes the clean delta=0 + rebaseline path regardless of node state. Updates TestNodeCounterReset (was _Clamped, now _NoReAdd) to assert rebaseline instead of re-add, and adds TestCentralResetClearsNodeBaseline_NoLeak. * fix(inbound): keep persisted node share strategy on edit (#5375) Opening the edit modal silently reverted shareAddrStrategy from 'node' to 'listen'. The downgrade effect fires before the form settles: availableNodes is an empty placeholder until /nodes/list resolves, and Form.useWatch('protocol') is briefly empty on the first edit render — both transiently make the node option look unavailable, so the effect clobbered the saved value. Gate the downgrade on availableNodesFetched (threaded from useNodesQuery through InboundsPage) and on the protocol watch being settled, so a persisted strategy is only downgraded when the node option is genuinely unavailable. Adds a rerender-based regression test covering the nodes-loading race. * <3 * perf(traffic): skip cross-panel quota subquery when no globals exist (#5392, #5389) disableInvalidClients ran a correlated EXISTS against client_global_traffics on the full client_traffics table every 5s. On a panel no master pushes to, that table is empty so the subquery can never match — yet it forced a full scan that pegged Postgres at 100% CPU on large client counts. Probe the table first and drop the EXISTS branch when it's empty (the common case), and add an idx_client_global_email index so the subquery is an index lookup when globals are present. Cross-panel enforcement is unchanged (TestGlobalUsage_DisablesClient). This also relieves #5389 ('traffic writer queue full' / panel freeze): the heavy query runs inside the serialized traffic write, so a slow DB backs the shared writer queue up until request handlers block. * fix(sub): don't advertise a leaked client IP for local wildcard inbounds (#5425) For a local inbound with no node, no custom share address, and a wildcard/blank listen, resolveInboundAddress fell straight through to the subscriber's request host. Behind NAT/proxy/CDN that Host can be the requesting client's own IP, so the subscription wrote the client's address into the inbound instead of the server's — while the panel's own share link (which doesn't use the request host) stayed correct. Prefer the admin's configured public host (Sub/Web domain) over the raw request host for this last-resort fallback. With no configured host the request host still stands, so existing single-domain setups are unaffected.
166 lines
5.9 KiB
Go
166 lines
5.9 KiB
Go
package sub
|
|
|
|
import (
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
)
|
|
|
|
func initSubDB(t *testing.T) {
|
|
t.Helper()
|
|
if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
|
|
t.Fatalf("InitDB: %v", err)
|
|
}
|
|
// Close the handle before t.TempDir cleanup so Windows doesn't refuse to
|
|
// remove the still-open sqlite file.
|
|
t.Cleanup(func() { _ = database.CloseDB() })
|
|
}
|
|
|
|
// The subscription page's Copy URL must be built from the same host the
|
|
// subscriber reached the page on (after PrepareForRequest normalizes away a
|
|
// loopback/bind address) — never the raw listen IP. A subscriber that hit a
|
|
// loopback bind should see "localhost", not "127.0.0.1".
|
|
func TestBuildURLs_NormalizesListenIP(t *testing.T) {
|
|
initSubDB(t)
|
|
s := &SubService{}
|
|
s.PrepareForRequest("127.0.0.1")
|
|
|
|
subURL, _, _ := s.BuildURLs("/sub/", "/json/", "/clash/", "ABC")
|
|
|
|
if strings.Contains(subURL, "127.0.0.1") {
|
|
t.Fatalf("listen IP leaked into Copy URL: %q", subURL)
|
|
}
|
|
if !strings.Contains(subURL, "localhost") {
|
|
t.Fatalf("Copy URL = %q, want a localhost host", subURL)
|
|
}
|
|
if !strings.HasSuffix(subURL, "/sub/ABC") {
|
|
t.Fatalf("Copy URL = %q, want it to end with /sub/ABC", subURL)
|
|
}
|
|
}
|
|
|
|
// A subscriber arriving on a real domain gets that exact domain in the Copy
|
|
// URL, with the configured sub port — matching the Client Information page.
|
|
func TestBuildURLs_UsesSubscriberDomain(t *testing.T) {
|
|
initSubDB(t)
|
|
s := &SubService{}
|
|
s.PrepareForRequest("sub.example.com")
|
|
|
|
subURL, jsonURL, clashURL := s.BuildURLs("/sub/", "/json/", "/clash/", "ABC")
|
|
|
|
if subURL != "http://sub.example.com:2096/sub/ABC" {
|
|
t.Fatalf("subURL = %q", subURL)
|
|
}
|
|
if jsonURL != "http://sub.example.com:2096/json/ABC" {
|
|
t.Fatalf("jsonURL = %q", jsonURL)
|
|
}
|
|
if clashURL != "http://sub.example.com:2096/clash/ABC" {
|
|
t.Fatalf("clashURL = %q", clashURL)
|
|
}
|
|
}
|
|
|
|
// A local wildcard inbound (no node, no custom share address, blank/0.0.0.0
|
|
// listen) must not advertise the raw request host when it carries a client IP
|
|
// that leaked in behind NAT/proxy. The admin's configured panel host wins for
|
|
// this last-resort fallback; without a configured host the request host stands.
|
|
func TestResolveInboundAddress_PrefersConfiguredHostOverClientIP(t *testing.T) {
|
|
initSubDB(t)
|
|
local := &model.Inbound{Listen: "", ShareAddrStrategy: "node"}
|
|
|
|
s := &SubService{}
|
|
s.PrepareForRequest("192.168.1.50") // a client LAN IP that reached the panel
|
|
if got := s.resolveInboundAddress(local); got != "192.168.1.50" {
|
|
t.Fatalf("with no configured host the request host stands, got %q", got)
|
|
}
|
|
|
|
if err := database.GetDB().Create(&model.Setting{Key: "subDomain", Value: "panel.example.com"}).Error; err != nil {
|
|
t.Fatalf("set subDomain: %v", err)
|
|
}
|
|
s2 := &SubService{}
|
|
s2.PrepareForRequest("192.168.1.50")
|
|
if got := s2.resolveInboundAddress(local); got != "panel.example.com" {
|
|
t.Fatalf("configured host must win over the leaked client IP, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestBuildURLs_EmptySubId(t *testing.T) {
|
|
initSubDB(t)
|
|
s := &SubService{}
|
|
s.PrepareForRequest("sub.example.com")
|
|
a, b, c := s.BuildURLs("/sub/", "/json/", "/clash/", "")
|
|
if a != "" || b != "" || c != "" {
|
|
t.Fatalf("empty subId must yield empty URLs, got %q %q %q", a, b, c)
|
|
}
|
|
}
|
|
|
|
func TestForRequestDoesNotMutateSharedService(t *testing.T) {
|
|
initSubDB(t)
|
|
base := &SubService{}
|
|
|
|
first := base.ForRequest("first.example.com")
|
|
second := base.ForRequest("second.example.com")
|
|
|
|
if base.address != "" || base.nodesByID != nil {
|
|
t.Fatalf("ForRequest mutated the shared service: address=%q nodes=%v", base.address, base.nodesByID)
|
|
}
|
|
|
|
firstURL, _, _ := first.BuildURLs("/sub/", "/json/", "/clash/", "ABC")
|
|
secondURL, _, _ := second.BuildURLs("/sub/", "/json/", "/clash/", "ABC")
|
|
if !strings.Contains(firstURL, "first.example.com") {
|
|
t.Fatalf("first request URL = %q, want first.example.com", firstURL)
|
|
}
|
|
if !strings.Contains(secondURL, "second.example.com") {
|
|
t.Fatalf("second request URL = %q, want second.example.com", secondURL)
|
|
}
|
|
}
|
|
|
|
// A subscriber arriving via a reverse proxy (subURI configured with full
|
|
// HTTPS URL) must see the same scheme+host in the JSON and Clash Copy
|
|
// URLs as in the main subURL — not the raw sub-server port 2096.
|
|
func TestBuildURLs_DerivesJsonFromConfiguredSubURI(t *testing.T) {
|
|
initSubDB(t)
|
|
s := &SubService{}
|
|
s.PrepareForRequest("sub.example.com")
|
|
|
|
// Simulate the admin having set subURI (reverse-proxy setup).
|
|
database.GetDB().Exec(
|
|
"INSERT INTO settings (key, value) VALUES (?, ?)",
|
|
"subURI", "https://example.com/sub-xxx/")
|
|
|
|
subURL, jsonURL, clashURL := s.BuildURLs("/sub-xxx/", "/json/", "/clash/", "ABC")
|
|
|
|
if subURL != "https://example.com/sub-xxx/ABC" {
|
|
t.Fatalf("subURL = %q", subURL)
|
|
}
|
|
if jsonURL != "https://example.com/json/ABC" {
|
|
t.Fatalf("jsonURL = %q (should derive scheme+host from subURI), want %q", jsonURL, "https://example.com/json/ABC")
|
|
}
|
|
if clashURL != "https://example.com/clash/ABC" {
|
|
t.Fatalf("clashURL = %q (should derive scheme+host from subURI), want %q", clashURL, "https://example.com/clash/ABC")
|
|
}
|
|
}
|
|
|
|
// A malformed subURI (no scheme/host) must not leak a broken base into the
|
|
// JSON/Clash URLs; BuildURLs should fall back to the request-derived base.
|
|
func TestBuildURLs_MalformedSubURIFallsBackToRequestBase(t *testing.T) {
|
|
initSubDB(t)
|
|
s := &SubService{}
|
|
s.PrepareForRequest("sub.example.com")
|
|
|
|
// A value with no scheme can't yield a usable scheme+host.
|
|
database.GetDB().Exec(
|
|
"INSERT INTO settings (key, value) VALUES (?, ?)",
|
|
"subURI", "example.com/sub-xxx/")
|
|
|
|
_, jsonURL, clashURL := s.BuildURLs("/sub-xxx/", "/json/", "/clash/", "ABC")
|
|
|
|
if jsonURL != "http://sub.example.com:2096/json/ABC" {
|
|
t.Fatalf("jsonURL = %q, want fallback to request base %q", jsonURL, "http://sub.example.com:2096/json/ABC")
|
|
}
|
|
if clashURL != "http://sub.example.com:2096/clash/ABC" {
|
|
t.Fatalf("clashURL = %q, want fallback to request base %q", clashURL, "http://sub.example.com:2096/clash/ABC")
|
|
}
|
|
}
|