From 4167597a152a70d8016094a14e64ffd826511e05 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 31 Oct 2026 08:10:36 +0000 Subject: [PATCH 001/125] fix(database): replace the stored session secret once on upgrade The session cookie key is generated once and kept forever. Keys stored by older releases came from a weaker generator, and a stored value cannot be told apart from a current one, so drop it once through a seeder and let GetSecret store a fresh crypto/rand key on the next start. Admins log in again once after upgrading; fresh installs record the seeder up front. --- internal/database/db.go | 19 +++++++++++- internal/database/db_seed_test.go | 50 +++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/internal/database/db.go b/internal/database/db.go index 36569361e..f051e44cd 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -1527,7 +1527,7 @@ func runSeeders(isUsersEmpty bool) error { } if empty && isUsersEmpty { - seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix2", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "FreedomFinalRulesPrivateEgressBlock", "UppercaseFreedomFinalRulesFix", "InboundRealityFinalmaskTcpStrip", "ApiTokensHash", "LegacyProxySettingsCleanup", "OutboundRemovedKeysFix", "FreedomDomainStrategyFix", "DNSOutboundLegacyKeysFix", "DNSOutboundQTypeZeroFix", "WireguardDomainStrategyFix", "XdnsFinalmaskNamesAddrsFix", "UDPHopClientMaskFix", "HostVerifyPeerCertByNameBoolFix", "HostMihomoX25519Reset", "WireguardPeersToClients", "MtprotoSecretsToClients", "NodeInboundsAdopted", "NodeDownNotified", "ResetIpLimitNoFail2ban"} + seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix2", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "FreedomFinalRulesPrivateEgressBlock", "UppercaseFreedomFinalRulesFix", "InboundRealityFinalmaskTcpStrip", "ApiTokensHash", "LegacyProxySettingsCleanup", "SessionSecretRotation", "OutboundRemovedKeysFix", "FreedomDomainStrategyFix", "DNSOutboundLegacyKeysFix", "DNSOutboundQTypeZeroFix", "WireguardDomainStrategyFix", "XdnsFinalmaskNamesAddrsFix", "UDPHopClientMaskFix", "HostVerifyPeerCertByNameBoolFix", "HostMihomoX25519Reset", "WireguardPeersToClients", "MtprotoSecretsToClients", "NodeInboundsAdopted", "NodeDownNotified", "ResetIpLimitNoFail2ban"} for _, name := range seeders { if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil { return err @@ -1638,6 +1638,12 @@ func runSeeders(isUsersEmpty bool) error { } } + if !slices.Contains(seedersHistory, "SessionSecretRotation") { + if err := dropPreUpgradeSessionSecret(); err != nil { + return err + } + } + if !slices.Contains(seedersHistory, "OutboundRemovedKeysFix") { if err := migrateOutboundRemovedKeys(); err != nil { return err @@ -1899,6 +1905,17 @@ func clearLegacyProxySettings() error { }) } +// Secrets stored before v2.8.3 came from math/rand (a 31-bit clock seed up to v2.1.3); +// GetSecret draws a crypto/rand one on the next start. +func dropPreUpgradeSessionSecret() error { + return db.Transaction(func(tx *gorm.DB) error { + if err := tx.Where("key = ?", "secret").Delete(&model.Setting{}).Error; err != nil { + return err + } + return tx.Create(&model.HistoryOfSeeders{SeederName: "SessionSecretRotation"}).Error + }) +} + func migrateOutboundRemovedKeys() error { var setting model.Setting err := db.Model(model.Setting{}).Where("key = ?", "xrayTemplateConfig").First(&setting).Error diff --git a/internal/database/db_seed_test.go b/internal/database/db_seed_test.go index c65e74bf5..85ceef985 100644 --- a/internal/database/db_seed_test.go +++ b/internal/database/db_seed_test.go @@ -107,6 +107,56 @@ func TestEnsureFirstUserCreatesOneWorkingCredential(t *testing.T) { } } +// Old releases drew the cookie-signing secret from a clock-seeded math/rand, and a +// stored value cannot tell its generator, so an upgrade must replace it exactly once. +func TestSessionSecretRotatesOnceOnUpgrade(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "x-ui.db") + if err := InitDB(dbPath); err != nil { + t.Fatalf("InitDB: %v", err) + } + t.Cleanup(func() { _ = CloseDB() }) + reopen := func() []string { + t.Helper() + if err := CloseDB(); err != nil { + t.Fatalf("CloseDB: %v", err) + } + if err := InitDB(dbPath); err != nil { + t.Fatalf("reopen InitDB: %v", err) + } + var secrets []string + if err := db.Model(&model.Setting{}).Where("key = ?", "secret").Pluck("value", &secrets).Error; err != nil { + t.Fatalf("read secret: %v", err) + } + return secrets + } + storeSecret := func(value string) { + t.Helper() + if err := db.Where("key = ?", "secret").Delete(&model.Setting{}).Error; err != nil { + t.Fatalf("clear secret: %v", err) + } + if err := db.Create(&model.Setting{Key: "secret", Value: value}).Error; err != nil { + t.Fatalf("store secret: %v", err) + } + } + + storeSecret("kept-on-a-fresh-install") + if got := reopen(); len(got) != 1 || got[0] != "kept-on-a-fresh-install" { + t.Fatalf("fresh install secret = %q after restart, want it kept", got) + } + + storeSecret("rfBd56ti2SMtYvSgD5xAV0YU99zampta") + if err := db.Where("seeder_name = ?", "SessionSecretRotation").Delete(&model.HistoryOfSeeders{}).Error; err != nil { + t.Fatalf("clear seeder history: %v", err) + } + if got := reopen(); len(got) != 0 { + t.Fatalf("secret = %q after upgrade, want the pre-upgrade secret dropped", got) + } + storeSecret("drawn-after-upgrade") + if got := reopen(); len(got) != 1 || got[0] != "drawn-after-upgrade" { + t.Fatalf("secret = %q, want the rotated secret kept so restarts do not log everyone out", got) + } +} + func TestSeedClientsFromInboundJSON_IsIdempotentAgainstExistingClients(t *testing.T) { dbDir := t.TempDir() t.Setenv("XUI_DB_FOLDER", dbDir) From 1fa8cc785dd3d7e348ccd4676ac115ebae09a538 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 31 Oct 2026 08:19:44 +0000 Subject: [PATCH 002/125] fix(outbound): keep imported links to remote-server settings A share link describes a remote server, but extra=, fm= and the vmess JSON copy provider objects verbatim, so a link could carry keys that only make sense on the importing host. Both link parsers now drop dial-redirect and host file keys (matched case-folded, as the core binds them). Outbound subscriptions also refuse outbounds that dial this host or its network without allowPrivate, any that target the core's own API or metrics port, and any that take their server from DNS records. Rows stored earlier are filtered the same way when the config is built. --- frontend/src/lib/xray/outbound-link-parser.ts | 25 ++++- .../src/test/outbound-link-parser.test.ts | 29 +++++ internal/util/link/outbound.go | 34 +++++- internal/util/link/outbound_test.go | 45 ++++++++ internal/web/service/outbound_subscription.go | 103 ++++++++++++++++-- .../web/service/outbound_subscription_test.go | 51 +++++++++ 6 files changed, 276 insertions(+), 11 deletions(-) diff --git a/frontend/src/lib/xray/outbound-link-parser.ts b/frontend/src/lib/xray/outbound-link-parser.ts index 6e83d988f..bcf2f314b 100644 --- a/frontend/src/lib/xray/outbound-link-parser.ts +++ b/frontend/src/lib/xray/outbound-link-parser.ts @@ -859,12 +859,31 @@ export function parseWireguardLink(link: string): Raw | null { export function parseOutboundLink(link: string): Raw | null { const trimmed = link.trim(); if (!trimmed) return null; - return ( + const outbound = parseVmessLink(trimmed) ?? parseVlessLink(trimmed) ?? parseTrojanLink(trimmed) ?? parseShadowsocksLink(trimmed) ?? parseHysteria2Link(trimmed) ?? - parseWireguardLink(trimmed) - ); + parseWireguardLink(trimmed); + if (outbound) dropHostLocalKeys(outbound); + return outbound; +} + +// Keys that redirect the core's dial or name files on the importing host; extra=, fm= and +// vmess JSON copy provider objects verbatim, and the core binds keys case-folded. +const HOST_LOCAL_KEYS = new Set(['dialerproxy', 'certificatefile', 'keyfile', 'masterkeylog']); + +function dropHostLocalKeys(value: unknown): void { + if (Array.isArray(value)) { + value.forEach(dropHostLocalKeys); + return; + } + if (!value || typeof value !== 'object') return; + const obj = value as Raw; + for (const key of Object.keys(obj)) { + // Upper-casing first folds the long s and the Kelvin sign the way Go's decoder does. + if (HOST_LOCAL_KEYS.has(key.toUpperCase().toLowerCase())) delete obj[key]; + else dropHostLocalKeys(obj[key]); + } } diff --git a/frontend/src/test/outbound-link-parser.test.ts b/frontend/src/test/outbound-link-parser.test.ts index 0b6d06d24..830ed8f2c 100644 --- a/frontend/src/test/outbound-link-parser.test.ts +++ b/frontend/src/test/outbound-link-parser.test.ts @@ -1124,3 +1124,32 @@ describe('obfs=gecko packetSize validation', () => { expect(packetSizeOf(link)).toBeUndefined(); }); }); + +describe('parseOutboundLink — host-local keys', () => { + it('drops keys that redirect the dial or name host files, however they are cased', () => { + const extra = encodeURIComponent( + JSON.stringify({ + downloadSettings: { + address: '8.8.8.8', + port: 443, + sockopt: { dialerProxy: 'api', tcpFastOpen: true }, + tlsSettings: { + serverName: 'cdn.example.com', + certificates: [{ CERTIFICATEFILE: '/dev/zero', keyFile: '/etc/shadow' }], + maſterKeyLog: '/root/keys', + }, + }, + }), + ); + const fm = encodeURIComponent( + JSON.stringify({ tcp: [{ type: 'header-custom', settings: { DialerProxy: 'api' } }] }), + ); + const out = parseOutboundLink( + `vless://uuid@8.8.8.8:443?type=xhttp&security=tls&extra=${extra}&fm=${fm}#node`, + ); + const json = JSON.stringify(out); + expect(json).not.toMatch(/dialerproxy|certificatefile|keyfile|maſterkeylog/i); + expect(json).toContain('"tcpFastOpen":true'); + expect(json).toContain('"serverName":"cdn.example.com"'); + }); +}); diff --git a/internal/util/link/outbound.go b/internal/util/link/outbound.go index c543dbfc4..34b3e3b7f 100644 --- a/internal/util/link/outbound.go +++ b/internal/util/link/outbound.go @@ -12,6 +12,7 @@ import ( "math" "net/url" "regexp" + "slices" "strconv" "strings" "time" @@ -118,7 +119,15 @@ func splitLines(s string) []string { // - hysteria2:// (also hy2://) // - wireguard:// (also wg://) func ParseLink(link string) (*ParseResult, error) { - link = strings.TrimSpace(link) + res, err := parseLinkByScheme(strings.TrimSpace(link)) + if err != nil { + return nil, err + } + dropHostLocalKeys(res.Outbound) + return res, nil +} + +func parseLinkByScheme(link string) (*ParseResult, error) { switch { case strings.HasPrefix(link, "vmess://"): return parseVmess(link) @@ -137,6 +146,29 @@ func ParseLink(link string) (*ParseResult, error) { } } +// hostLocalKeys redirect the core's dial or name files on the importing host; extra=, fm= and +// vmess JSON copy provider keys verbatim, and the core binds keys case-folded. +var hostLocalKeys = []string{"dialerProxy", "certificateFile", "keyFile", "masterKeyLog"} + +func dropHostLocalKeys(v any) { + switch v := v.(type) { + case map[string]any: + for key, child := range v { + if slices.ContainsFunc(hostLocalKeys, func(k string) bool { return strings.EqualFold(k, key) }) { + delete(v, key) + continue + } + dropHostLocalKeys(child) + } + case Outbound: + dropHostLocalKeys(map[string]any(v)) + case []any: + for _, child := range v { + dropHostLocalKeys(child) + } + } +} + // --- vmess --- func parseVmess(link string) (*ParseResult, error) { diff --git a/internal/util/link/outbound_test.go b/internal/util/link/outbound_test.go index ed8c5e6a9..9de81beb4 100644 --- a/internal/util/link/outbound_test.go +++ b/internal/util/link/outbound_test.go @@ -612,3 +612,48 @@ func TestParseLink_PutsUDPHopMaskLast(t *testing.T) { t.Fatalf("imported finalmask\n got: %s\nwant: %s", got, want) } } + +// A share link describes a remote server; keys that redirect the dial or name files on +// the importing host must not survive, however the core's case-folding decoder spells them. +func TestParseLinkDropsHostLocalKeys(t *testing.T) { + extra := url.QueryEscape(`{"downloadSettings":{"address":"8.8.8.8","port":443,"network":"xhttp",` + + `"sockopt":{"dialerProxy":"api","tcpFastOpen":true},` + + `"tlsSettings":{"serverName":"cdn.example.com","CERTIFICATES":[{"certificateFile":"/dev/zero","keyFile":"/etc/shadow"}],"maſterKeyLog":"/root/keys"}}}`) + fm := url.QueryEscape(`{"tcp":[{"type":"header-custom","settings":{"DialerProxy":"api"}}]}`) + res, err := ParseLink("vless://uuid@8.8.8.8:443?type=xhttp&security=tls&sni=cdn.example.com&extra=" + extra + "&fm=" + fm + "#node") + if err != nil { + t.Fatalf("parse vless: %v", err) + } + raw, err := json.Marshal(res.Outbound) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var hostLocal []string + var walk func(v any) + walk = func(v any) { + switch v := v.(type) { + case map[string]any: + for k, child := range v { + for _, banned := range []string{"dialerProxy", "certificateFile", "keyFile", "masterKeyLog"} { + if strings.EqualFold(k, banned) { + hostLocal = append(hostLocal, k) + } + } + walk(child) + } + case []any: + for _, child := range v { + walk(child) + } + } + } + var decoded any + _ = json.Unmarshal(raw, &decoded) + walk(decoded) + if len(hostLocal) > 0 { + t.Fatalf("outbound keeps host-local keys %v: %s", hostLocal, raw) + } + if !strings.Contains(string(raw), `"tcpFastOpen":true`) || !strings.Contains(string(raw), `"serverName":"cdn.example.com"`) { + t.Fatalf("remote-server settings were dropped too: %s", raw) + } +} diff --git a/internal/web/service/outbound_subscription.go b/internal/web/service/outbound_subscription.go index 47f4c90fc..0cf103972 100644 --- a/internal/web/service/outbound_subscription.go +++ b/internal/web/service/outbound_subscription.go @@ -7,8 +7,10 @@ import ( "errors" "fmt" "io" + "net" "net/http" "regexp" + "slices" "strconv" "strings" "time" @@ -423,13 +425,19 @@ func (s *OutboundSubscriptionService) fetchAndStore(sub *model.OutboundSubscript } } - // Drop core-rejected links before tagging: prevTagByIndex indexes the persisted - // (filtered) list, so positions must be counted in that same list. - var droppedByCore []string + // Drop refused and core-rejected links before tagging: prevTagByIndex indexes the + // persisted (filtered) list, so positions must be counted in that same list. + var dropped []string + ownPorts := xrayOwnPorts() keptLinks, keptIdentities := parsed[:0], identities[:0] for i, ob := range parsed { - if _, dropped := filterOutboundsRejectedByCore(fmt.Sprintf("outbound sub %d", sub.Id), []any{map[string]any(ob)}); len(dropped) > 0 { - droppedByCore = append(droppedByCore, dropped...) + if reason := internalDialTarget(map[string]any(ob), sub.AllowPrivate, ownPorts); reason != "" { + logger.Warningf("outbound sub %d: dropping outbound %.64q: %s", sub.Id, ob["tag"], reason) + dropped = append(dropped, fmt.Sprintf("%.64s: %s", ob["tag"], reason)) + continue + } + if _, rejected := filterOutboundsRejectedByCore(fmt.Sprintf("outbound sub %d", sub.Id), []any{map[string]any(ob)}); len(rejected) > 0 { + dropped = append(dropped, rejected...) continue } keptLinks = append(keptLinks, ob) @@ -461,8 +469,8 @@ func (s *OutboundSubscriptionService) fetchAndStore(sub *model.OutboundSubscript sub.LinkIdentities = string(identJSON) sub.LastUpdated = time.Now().Unix() sub.LastError = "" - if len(droppedByCore) > 0 { - sub.LastError = fmt.Sprintf("dropped %d outbound(s) the xray core rejects: %s", len(droppedByCore), droppedByCore[0]) + if len(dropped) > 0 { + sub.LastError = fmt.Sprintf("dropped %d outbound(s): %s", len(dropped), dropped[0]) } if err := database.GetDB().Save(sub).Error; err != nil { @@ -561,6 +569,7 @@ func (s *OutboundSubscriptionService) activeOutboundsSplit() (prepend []any, app if err := db.Where("enabled = ?", true).Order("priority asc, id asc").Find(&subs).Error; err != nil { return nil, nil, err } + ownPorts := xrayOwnPorts() for _, sub := range subs { if strings.TrimSpace(sub.LastFetchedOutbounds) == "" { continue @@ -570,6 +579,14 @@ func (s *OutboundSubscriptionService) activeOutboundsSplit() (prepend []any, app logger.Warningf("outbound sub %d has corrupt LastFetchedOutbounds: %v", sub.Id, err) continue } + // Rows stored before the fetch refused these, or while allowPrivate was on. + arr = slices.DeleteFunc(arr, func(ob any) bool { + reason := internalDialTarget(ob, sub.AllowPrivate, ownPorts) + if reason != "" { + logger.Warningf("outbound sub %d: dropping a stored outbound: %s", sub.Id, reason) + } + return reason != "" + }) arr, _ = filterOutboundsRejectedByCore(fmt.Sprintf("outbound sub %d", sub.Id), arr) if sub.Prepend { prepend = append(prepend, arr...) @@ -672,3 +689,75 @@ Consequences for balancers / routing: We deliberately do *not* mutate the saved xrayTemplateConfig. Subscription outbounds are always injected at runtime in GetXrayConfig. */ + +// xrayOwnPorts lists the ports of Xray's gRPC API and metrics listeners, which ask +// for no credentials: being reachable from loopback only is their whole protection. +func xrayOwnPorts() []string { + ports := []string{strconv.Itoa(reservedAPIPort())} + metrics, _ := (&XrayMetricsService{}).discoverListen() + if _, port, err := net.SplitHostPort(metrics); err == nil { + ports = append(ports, port) + } + return ports +} + +// internalDialTarget says why a subscription outbound must not be loaded, or "". Keys +// match at any depth (xhttp downloadSettings is a second server) and case-folded like the core. +func internalDialTarget(outbound any, allowPrivate bool, ownPorts []string) string { + switch v := outbound.(type) { + case map[string]any: + for key, child := range v { + text, isText := child.(string) + switch { + case strings.EqualFold(key, "port"): + // A public name may resolve to loopback by dial time, so the port alone decides. + if slices.Contains(ownPorts, fmt.Sprint(child)) { + return "its port belongs to Xray's own API or metrics listener" + } + case strings.EqualFold(key, "addressPortStrategy"): + if text != "" && !strings.EqualFold(text, "none") { + return "addressPortStrategy takes its server from DNS records" + } + case strings.EqualFold(key, "address"), strings.EqualFold(key, "endpoint"): + // WireGuard's settings.address is a list of tunnel addresses, never dialled. + if !isText { + break + } + if _, port, err := net.SplitHostPort(strings.TrimSpace(text)); err == nil && slices.Contains(ownPorts, port) { + return "its port belongs to Xray's own API or metrics listener" + } + if !allowPrivate && namesInternalHost(text) { + return fmt.Sprintf("it connects to the private/internal address %.64q and allowPrivate is off", text) + } + } + if reason := internalDialTarget(child, allowPrivate, ownPorts); reason != "" { + return reason + } + } + case []any: + for _, child := range v { + if reason := internalDialTarget(child, allowPrivate, ownPorts); reason != "" { + return reason + } + } + } + return "" +} + +// namesInternalHost reports whether host, with or without a port, names this host or +// its network once xray-core and Go's dialer have read it. +func namesInternalHost(host string) bool { + host = strings.TrimSpace(host) + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + host = strings.Trim(host, "[] ") + host, _, _ = strings.Cut(host, "%") + if ip := net.ParseIP(host); ip != nil { + return isBlockedIP(ip) + } + // An empty host is the local system and a name without a dot comes from + // /etc/hosts or the search domain ("env:" has none either). + host = strings.ToLower(strings.TrimSuffix(host, ".")) + return !strings.Contains(host, ".") || host == "localhost" || strings.HasSuffix(host, ".localhost") +} diff --git a/internal/web/service/outbound_subscription_test.go b/internal/web/service/outbound_subscription_test.go index 3054ce86f..d5488c55a 100644 --- a/internal/web/service/outbound_subscription_test.go +++ b/internal/web/service/outbound_subscription_test.go @@ -8,6 +8,7 @@ import ( "maps" "net/http" "net/http/httptest" + "net/url" "slices" "strings" "testing" @@ -453,3 +454,53 @@ func outboundsContainTag(outbounds []any, tag string) bool { } return false } + +// The core dials a subscription outbound's server itself, so a provider must not point +// one at Xray's credential-less API, whatever allowPrivate says, nor steer the dial past the check. +func TestOutboundSubscriptionRefreshDropsLinksIntoXrayListeners(t *testing.T) { + setupSettingTestDB(t) + const uuid = "00000000-0000-4000-8000-000000000000" + srv := url.QueryEscape(`{"downloadSettings":{"address":"8.8.8.8","port":443,"network":"xhttp","sockopt":{"addressPortStrategy":"SrvPortAndAddress"}}}`) + subID := serveOutboundSubscription(t, "p-", func(int) string { + return "vless://" + uuid + "@127.0.0.1:62789?security=tls&type=tcp#Api\n" + + "vless://" + uuid + "@8.8.8.8:443?security=tls&type=tcp#B\n" + + "vless://" + uuid + "@8.8.8.8:443?security=tls&type=xhttp&extra=" + srv + "#Srv" + }) + tags, _ := refreshOutboundTags(t, subID) + if !slices.Equal(tags, []string{"p-b"}) { + t.Fatalf("stored tags = %v, want only p-b", tags) + } + sub, err := (&OutboundSubscriptionService{}).Get(subID) + if err != nil || !strings.Contains(sub.LastError, "dropped 2 outbound(s)") { + t.Fatalf("lastError = %q (%v), want the two refused links reported", sub.LastError, err) + } +} + +func TestActiveOutboundsDropStoredPrivateDialTargets(t *testing.T) { + setupSettingTestDB(t) + stored := `[{"tag":"p-lan","protocol":"vless","settings":{"address":"10.0.0.7","port":443,"id":"00000000-0000-4000-8000-000000000000","encryption":"none"},"streamSettings":{"network":"tcp","security":"tls"}},` + + `{"tag":"p-pub","protocol":"vless","settings":{"address":"8.8.8.8","port":443,"id":"00000000-0000-4000-8000-000000000000","encryption":"none"},"streamSettings":{"network":"tcp","security":"tls"}}]` + sub := &model.OutboundSubscription{Url: "https://provider.example/sub", TagPrefix: "p-", Enabled: true, LastFetchedOutbounds: stored} + if err := database.GetDB().Create(sub).Error; err != nil { + t.Fatalf("seed subscription: %v", err) + } + obs, err := (&OutboundSubscriptionService{}).AllActiveOutbounds() + if err != nil { + t.Fatalf("AllActiveOutbounds: %v", err) + } + if outboundsContainTag(obs, "p-lan") || !outboundsContainTag(obs, "p-pub") { + t.Fatalf("active outbounds = %v, want the stored LAN target dropped and the public one kept", obs) + } +} + +func TestNamesInternalHost(t *testing.T) { + for host, want := range map[string]bool{ + "127.0.0.1": true, " [::1]:62789 ": true, "fe80::1%eth0": true, "10.1.2.3:80": true, + "localhost": true, "api.localhost.": true, "intranet": true, "": true, "env:HOME": true, + "8.8.8.8": false, "cdn.example.com": false, "[2001:4860:4860::8888]:443": false, + } { + if got := namesInternalHost(host); got != want { + t.Errorf("namesInternalHost(%q) = %v, want %v", host, got, want) + } + } +} From 2d72d9c7158c9c70df4a3b430e725f488fe75a48 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 31 Oct 2026 08:23:24 +0000 Subject: [PATCH 003/125] fix(xray): check outbounds the way the core's handlers will ValidateOutboundConfig ran only conf.Build(), but the core parses some fields again in its handler constructors, and a failure there stops the whole core at start: wireguard local addresses and peers, the vless encryption keys and padding, and shadowsocks 2022 keys. Mirror those parses with the core's own helpers, and turn a panic in the loader into a rejection so one bad link cannot unwind the caller. --- internal/xray/api.go | 97 +++++++++++++++++++++-- internal/xray/outbound_validation_test.go | 64 +++++++++++++++ 2 files changed, 154 insertions(+), 7 deletions(-) diff --git a/internal/xray/api.go b/internal/xray/api.go index 0e0e68ddc..906b348f9 100644 --- a/internal/xray/api.go +++ b/internal/xray/api.go @@ -5,6 +5,7 @@ package xray import ( "context" + "encoding/base64" "encoding/json" "fmt" "math" @@ -36,6 +37,8 @@ import ( "github.com/xtls/xray-core/proxy/shadowsocks_2022" "github.com/xtls/xray-core/proxy/trojan" "github.com/xtls/xray-core/proxy/vless" + vlessEncryption "github.com/xtls/xray-core/proxy/vless/encryption" + vlessOutbound "github.com/xtls/xray-core/proxy/vless/outbound" "github.com/xtls/xray-core/proxy/vmess" wireguard "github.com/xtls/xray-core/proxy/wireguard" "google.golang.org/grpc" @@ -180,8 +183,14 @@ func (x *XrayAPI) DelInbound(tag string) error { // xray-core config loader, surfacing the exact error the core would raise at // startup — notably v26.7.11's refusal of unencrypted vless/trojan outbounds // whose server address is a public IP or domain. -func ValidateOutboundConfig(outbound []byte) error { +func ValidateOutboundConfig(outbound []byte) (err error) { ensureXrayAssetLocation() + // The loader panics on some third-party values (a short vless encryption, a bad spiderX). + defer func() { + if r := recover(); r != nil { + err = common.NewErrorf("xray-core config loader panicked: %v", r) + } + }() detour := new(conf.OutboundDetourConfig) if err := json.Unmarshal(outbound, detour); err != nil { @@ -192,12 +201,12 @@ func ValidateOutboundConfig(outbound []byte) error { if err != nil { return err } - return validateWireguardRemoteDNS(built.ProxySettings) + return validateHandlerSettings(built.ProxySettings) } -// validateWireguardRemoteDNS refuses what conf.Build() lets through but the core feeds to -// netip.MustParseAddr at startup: a non-IP remoteDNS entry, like "local" before 26.9.30. -func validateWireguardRemoteDNS(settings *serial.TypedMessage) error { +// validateHandlerSettings refuses what conf.Build() lets through but the core parses only +// in the handler constructor, where one failure aborts the whole core start. +func validateHandlerSettings(settings *serial.TypedMessage) error { if settings == nil { return nil } @@ -205,10 +214,42 @@ func validateWireguardRemoteDNS(settings *serial.TypedMessage) error { if err != nil { return nil } - device, ok := instance.(*wireguard.DeviceConfig) - if !ok || !device.IsClient { + switch config := instance.(type) { + case *wireguard.DeviceConfig: + return validateWireguardClient(config) + case *vlessOutbound.Config: + return validateVlessEncryption(config) + case *shadowsocks_2022.ClientConfig: + return validateShadowsocks2022Key(config) + } + return nil +} + +// validateWireguardClient mirrors proxy/wireguard.NewClient; remoteDNS goes to +// netip.MustParseAddr there, so a non-IP entry ("local" before 26.9.30) is a crash. +func validateWireguardClient(device *wireguard.DeviceConfig) error { + if !device.IsClient { return nil } + if len(device.Peers) == 0 { + return common.NewError("wireguard outbound has no peers") + } + for _, peer := range device.Peers { + if peer.PublicKey == "" { + return common.NewError("wireguard peer without publicKey") + } + if peer.Endpoint == "" { + return common.NewError("wireguard peer without endpoint") + } + } + for _, local := range device.Endpoint { + if _, err := netip.ParseAddr(local); err == nil { + continue + } + if _, err := netip.ParsePrefix(local); err != nil { + return common.NewErrorf("wireguard address entry %q is neither an IP nor a prefix", local) + } + } for _, server := range device.DNS { if _, err := netip.ParseAddr(server); err != nil { return common.NewErrorf("wireguard remoteDNS entry %q is not an IP address", server) @@ -217,6 +258,48 @@ func validateWireguardRemoteDNS(settings *serial.TypedMessage) error { return nil } +// validateVlessEncryption mirrors proxy/vless/outbound.New, which parses the padding spec +// and the X25519 / ML-KEM-768 keys of "encryption" through ClientInstance.Init. +func validateVlessEncryption(config *vlessOutbound.Config) error { + if config.Vnext == nil || config.Vnext.User == nil { + return nil + } + user, err := config.Vnext.User.ToMemoryUser() + if err != nil { + return err + } + account, ok := user.Account.(*vless.MemoryAccount) + if !ok || account.Encryption == "" || account.Encryption == "none" { + return nil + } + var keys [][]byte + for part := range strings.SplitSeq(account.Encryption, ".") { + key, _ := base64.RawURLEncoding.DecodeString(part) + keys = append(keys, key) + } + if err := (&vlessEncryption.ClientInstance{}).Init(keys, account.XorMode, account.Seconds, account.Padding); err != nil { + return common.NewErrorf("vless encryption is unusable: %v", err) + } + return nil +} + +// validateShadowsocks2022Key mirrors shadowsocks_2022.NewClient: every ":"-separated key +// is base64 of the cipher's key length, and chacha20-poly1305 takes a single key. +func validateShadowsocks2022Key(config *shadowsocks_2022.ClientConfig) error { + method, err := shadowsocks_2022.GetCipherMethod(config.Method) + if err != nil { + return err + } + keys, err := shadowsocks_2022.ParsePSKList(config.Key, method.KeySaltLength) + if err != nil { + return common.NewErrorf("shadowsocks 2022 key is unusable for %s: %v", config.Method, err) + } + if method.IsChaCha && len(keys) > 1 { + return common.NewError("shadowsocks 2022 chacha20-poly1305 does not support multiple keys") + } + return nil +} + // AddOutbound adds a new outbound configuration to the Xray core via gRPC. func (x *XrayAPI) AddOutbound(outbound []byte) error { if x.HandlerServiceClient == nil { diff --git a/internal/xray/outbound_validation_test.go b/internal/xray/outbound_validation_test.go index 11a506f3b..34d2717e7 100644 --- a/internal/xray/outbound_validation_test.go +++ b/internal/xray/outbound_validation_test.go @@ -1,10 +1,13 @@ package xray import ( + "encoding/json" "os" "path/filepath" "strings" "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/util/link" ) // TestValidateOutboundConfig_RejectsUnencryptedPublicVless covers xray-core @@ -86,3 +89,64 @@ func TestValidateOutboundConfig_FindsANoiseScriptBesideTheXrayBinary(t *testing. t.Fatalf("ValidateOutboundConfig = %v, want the script beside the xray binary found", err) } } + +// The core's loader panics on some third-party values instead of returning an error; +// a subscription refresh must count that link as rejected, not unwind the whole job. +func TestValidateOutboundConfig_ReturnsTheLoaderPanicAsAnError(t *testing.T) { + for name, outbound := range map[string]string{ + "vless encryption": `{"protocol": "vless", + "settings": {"address": "1.2.3.4", "port": 443, "id": "b831381d-6324-4d53-ad4f-8cda48b30811", "encryption": "mlkem768x25519plus.native.0rtt.AAAA"}, + "streamSettings": {"network": "tcp", "security": "tls", "tlsSettings": {"serverName": "example.com"}}}`, + "reality spiderX": `{"protocol": "vless", + "settings": {"address": "1.2.3.4", "port": 443, "id": "b831381d-6324-4d53-ad4f-8cda48b30811", "encryption": "none"}, + "streamSettings": {"network": "tcp", "security": "reality", "realitySettings": { + "serverName": "example.com", "fingerprint": "chrome", "spiderX": "/%zz", "publicKey": "xTIBA5rboUvnH4htodjb6e697QjLERt1NAB4mZqp8Dg"}}}`, + } { + if err := ValidateOutboundConfig([]byte(outbound)); err == nil || !strings.Contains(err.Error(), "panicked") { + t.Fatalf("%s: ValidateOutboundConfig = %v, want the loader's panic as an error", name, err) + } + } +} + +// conf.Build() lets these through, but the handler constructor parses them at core +// start, and one failure there takes every inbound down with it. +func TestValidateOutboundConfig_RejectsLinksTheHandlerConstructorRefuses(t *testing.T) { + const wg = "wireguard://JOSLNr3Kk8BViwrrTJ2M19gRBbGttnRfApY3nQ3fdT8%3D@203.0.113.10:51820?" + const wgPeer = "publickey=biSnuvUOX2pmtzf249FK8UGSq2PQslGSdoj4UqH4yew%3D" + const vless = "vless://b831381d-6324-4d53-ad4f-8cda48b30811@203.0.113.10:443?security=tls&sni=a&encryption=mlkem768x25519plus.native.1rtt." + x25519 := strings.Repeat("A", 43) + // ss userinfo: "2022-blake3-aes-128-gcm:shortkey", "hello world" (method guessed), a 16-byte key + const ssShort = "ss://MjAyMi1ibGFrZTMtYWVzLTEyOC1nY206c2hvcnRrZXk@203.0.113.10:8388" + const ssGuessed = "ss://aGVsbG8gd29ybGQ@203.0.113.10:8388" + const ssValid = "ss://MjAyMi1ibGFrZTMtYWVzLTEyOC1nY206QndjSEJ3Y0hCd2NIQndjSEJ3Y0hCdz09@203.0.113.10:8388" + for _, tc := range []struct{ name, link, wantErr string }{ + {"wireguard address", wg + wgPeer + "&address=garbage", `address entry "garbage"`}, + {"wireguard no public key", wg + "address=10.0.0.2/32", "peer without publicKey"}, + {"wireguard valid", wg + wgPeer + "&address=10.0.0.2/32", ""}, + {"vless padding", vless + "abc." + x25519, "vless encryption"}, + {"vless mlkem key", vless + strings.Repeat("_", 1579), "vless encryption"}, + {"vless valid", vless + x25519, ""}, + {"ss2022 short key", ssShort, "shadowsocks 2022 key"}, + {"ss guessed 2022 method", ssGuessed, "shadowsocks 2022 key"}, + {"ss2022 valid", ssValid, ""}, + } { + t.Run(tc.name, func(t *testing.T) { + parsed, err := link.ParseLink(tc.link) + if err != nil { + t.Fatalf("ParseLink: %v", err) + } + parsed.Outbound["tag"] = "t" + raw, _ := json.Marshal(parsed.Outbound) + err = ValidateOutboundConfig(raw) + if tc.wantErr == "" { + if err != nil { + t.Fatalf("ValidateOutboundConfig(%s) = %v, want nil", raw, err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("ValidateOutboundConfig(%s) = %v, want an error containing %q", raw, err, tc.wantErr) + } + }) + } +} From f1444bec3fd95e93626f43e8d3a8cb20efc9a9cc Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 31 Oct 2026 08:31:03 +0000 Subject: [PATCH 004/125] fix(outbound): store subscription refreshes without trusting the row read The refresh rewrote the whole row it read before a download whose length the provider controls, so an admin's delete, disable or edit made in the meantime was undone; Update had the mirror problem. Both now write only their own columns, and a refresh of a row that was deleted or re-pointed stores nothing. Around the same path: - the preview fetches without creating (and leaking) a scratch row, and Create keeps enabled=false instead of falling back to the column default; - fetch errors name the host but not the subscription URL, which is a credential and ends up in the log and in lastError; - each fetch closes its transport's idle connections; - one document loads at most 2000 links, and the cut is reported; - wireguard outbounds from a subscription always run on the userspace TUN; - editing a subscription schedules an Xray reload like delete and move do. --- internal/util/link/outbound.go | 7 + internal/web/controller/xray_setting.go | 16 +- .../web/controller/xray_setting_warp_test.go | 30 +++ internal/web/service/outbound_subscription.go | 160 +++++++++++---- .../web/service/outbound_subscription_test.go | 191 ++++++++++++++++++ 5 files changed, 353 insertions(+), 51 deletions(-) diff --git a/internal/util/link/outbound.go b/internal/util/link/outbound.go index 34b3e3b7f..aa6258bff 100644 --- a/internal/util/link/outbound.go +++ b/internal/util/link/outbound.go @@ -32,6 +32,10 @@ type ParseResult struct { Identity string } +// MaxSubscriptionOutbounds caps the outbounds one document yields: each is held, validated +// and merged into the core's config, and the byte cap alone allows hundreds of thousands. +const MaxSubscriptionOutbounds = 2000 + // ParseSubscriptionBody accepts the raw body returned by a subscription URL. // It handles the common case where the body is a base64-encoded blob of // newline-separated links, and also tolerates an already-decoded text body. @@ -54,6 +58,9 @@ func ParseSubscriptionBody(body []byte) ([]Outbound, []string, error) { seen := map[string]int{} for _, ln := range lines { + if len(outbounds) >= MaxSubscriptionOutbounds { + break + } ln = strings.TrimSpace(ln) if ln == "" || strings.HasPrefix(ln, "#") { continue diff --git a/internal/web/controller/xray_setting.go b/internal/web/controller/xray_setting.go index 16ea5fb36..ec067c073 100644 --- a/internal/web/controller/xray_setting.go +++ b/internal/web/controller/xray_setting.go @@ -565,6 +565,8 @@ func (a *XraySettingController) updateOutboundSub(c *gin.Context) { jsonMsg(c, "Failed to update outbound subscription", err) return } + // enabled and prepend decide which outbounds are merged and where. + a.XrayService.SetToNeedRestart() jsonObj(c, "", nil) } @@ -630,19 +632,7 @@ func (a *XraySettingController) parseOutboundSubURL(c *gin.Context) { allowPrivate := c.PostForm("allowPrivate") == "true" allowInsecure := c.PostForm("allowInsecure") == "true" userAgent := c.PostForm("userAgent") - // Use a throw-away service instance; it only needs the settingService for proxy. - svc := service.OutboundSubscriptionService{} - // We don't have a direct "fetch once" that returns without storing, so we - // temporarily create a disabled row, refresh it, then delete. Cleaner would - // be to expose a pure ParseURL on the service, but this keeps the surface small. - tmp, err := svc.Create("preview", rawURL, "", userAgent, false, 600, allowPrivate, false, allowInsecure) - if err != nil { - jsonMsg(c, "Failed to preview subscription", err) - return - } - obs, err := svc.Refresh(tmp.Id) - // best-effort cleanup - _ = svc.Delete(tmp.Id) + obs, err := a.OutboundSubscriptionService.Preview(rawURL, userAgent, allowPrivate, allowInsecure) if err != nil { jsonMsg(c, "Failed to fetch/parse subscription", err) return diff --git a/internal/web/controller/xray_setting_warp_test.go b/internal/web/controller/xray_setting_warp_test.go index 45749708a..ca9caf07f 100644 --- a/internal/web/controller/xray_setting_warp_test.go +++ b/internal/web/controller/xray_setting_warp_test.go @@ -6,6 +6,7 @@ import ( "net/http/httptest" "net/url" "path/filepath" + "strconv" "strings" "testing" @@ -67,3 +68,32 @@ func TestWarpIntervalReportsClockPersistenceFailure(t *testing.T) { t.Fatalf("response omitted clock persistence error: %s", w.Body.String()) } } + +// enabled and prepend decide which subscription outbounds the core runs, so an edit +// must schedule a reload like the sibling handlers do. +func TestUpdateOutboundSubSchedulesXrayReload(t *testing.T) { + gin.SetMode(gin.TestMode) + dbtest.InitDB(t, filepath.Join(t.TempDir(), "x-ui.db")) + sub := &model.OutboundSubscription{Url: "https://provider.example/sub", TagPrefix: "p-", Enabled: true} + if err := database.GetDB().Create(sub).Error; err != nil { + t.Fatalf("seed subscription: %v", err) + } + engine := gin.New() + engine.Use(func(c *gin.Context) { + c.Set("I18n", func(_ locale.I18nType, key string, _ ...string) string { return key }) + c.Next() + }) + a := NewXraySettingController(engine.Group("/panel/api")) + a.XrayService.IsNeedRestartAndSetFalse() + form := url.Values{"url": {"https://1.1.1.1/sub"}, "enabled": {"false"}} + req := httptest.NewRequest(http.MethodPost, "/panel/api/xray/outbound-subs/"+strconv.Itoa(sub.Id), strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, req) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"success":true`) { + t.Fatalf("update = %d %s", rec.Code, rec.Body.String()) + } + if !a.XrayService.IsNeedRestartAndSetFalse() { + t.Fatalf("disabling an outbound subscription did not schedule an Xray reload") + } +} diff --git a/internal/web/service/outbound_subscription.go b/internal/web/service/outbound_subscription.go index 0cf103972..230b54c5d 100644 --- a/internal/web/service/outbound_subscription.go +++ b/internal/web/service/outbound_subscription.go @@ -9,6 +9,7 @@ import ( "io" "net" "net/http" + "net/url" "regexp" "slices" "strconv" @@ -171,7 +172,7 @@ func (s *OutboundSubscriptionService) nextDefaultSubPrefix(excludeId int) (strin func (s *OutboundSubscriptionService) Create(remark, rawURL, tagPrefix, userAgent string, enabled bool, updateInterval int, allowPrivate, prepend, allowInsecure bool) (*model.OutboundSubscription, error) { cleanURL, err := SanitizePublicHTTPURL(rawURL, allowPrivate) if err != nil { - return nil, common.NewError("invalid subscription URL:", err) + return nil, common.NewError("invalid subscription URL:", withoutRequestURL(err)) } if cleanURL == "" { return nil, common.NewError("subscription URL is required") @@ -206,6 +207,12 @@ func (s *OutboundSubscriptionService) Create(remark, rawURL, tagPrefix, userAgen if err := database.GetDB().Create(sub).Error; err != nil { return nil, err } + // GORM leaves a zero value out of the INSERT, and the column defaults to enabled. + if !enabled { + if err := database.GetDB().Model(sub).Update("enabled", false).Error; err != nil { + return nil, err + } + } return sub, nil } @@ -217,7 +224,7 @@ func (s *OutboundSubscriptionService) Update(id int, remark, rawURL, tagPrefix, } cleanURL, err := SanitizePublicHTTPURL(rawURL, allowPrivate) if err != nil { - return common.NewError("invalid subscription URL:", err) + return common.NewError("invalid subscription URL:", withoutRequestURL(err)) } if cleanURL == "" { return common.NewError("subscription URL is required") @@ -232,16 +239,19 @@ func (s *OutboundSubscriptionService) Update(id int, remark, rawURL, tagPrefix, return err } } - sub.Remark = strings.TrimSpace(remark) - sub.Url = cleanURL - sub.Enabled = enabled - sub.AllowPrivate = allowPrivate - sub.AllowInsecure = allowInsecure - sub.UserAgent = strings.TrimSpace(userAgent) - sub.Prepend = prepend - sub.TagPrefix = prefix - sub.UpdateInterval = updateInterval - return database.GetDB().Save(sub).Error + // Only the editable columns: a whole-row save would put back fetch results that a + // refresh stored meanwhile, or re-create a row deleted meanwhile. + return database.GetDB().Model(&model.OutboundSubscription{}).Where("id = ?", sub.Id).Updates(map[string]any{ + "remark": strings.TrimSpace(remark), + "url": cleanURL, + "enabled": enabled, + "allow_private": allowPrivate, + "allow_insecure": allowInsecure, + "user_agent": strings.TrimSpace(userAgent), + "prepend": prepend, + "tag_prefix": prefix, + "update_interval": updateInterval, + }).Error } // Delete removes a subscription. @@ -332,22 +342,75 @@ func (s *OutboundSubscriptionService) subscriptionFetchClient(timeout time.Durat return client } -// fetchAndStore does the actual network + parse + stability + persist work. +// subscriptionFetch is what one download of a subscription produces, before it is stored. +type subscriptionFetch struct { + url string + outbounds []any + identities string + lastError string +} + +// fetchAndStore downloads a subscription and stores the result on its row. func (s *OutboundSubscriptionService) fetchAndStore(sub *model.OutboundSubscription) ([]any, error) { + readURL := sub.Url + fetched, err := s.fetchOutbounds(sub) + if err != nil { + s.recordError(sub, err) + return nil, err + } + obsJSON, _ := json.Marshal(fetched.outbounds) + // Only the fetch's own columns, and only while the row still points where it was read: + // the download takes as long as the provider likes, and the admin may edit or delete meanwhile. + res := database.GetDB().Model(&model.OutboundSubscription{}). + Where("id = ? AND url = ?", sub.Id, readURL). + Updates(map[string]any{ + "url": fetched.url, + "last_fetched_outbounds": string(obsJSON), + "link_identities": fetched.identities, + "last_updated": time.Now().Unix(), + "last_error": fetched.lastError, + }) + if res.Error != nil { + return nil, res.Error + } + if res.RowsAffected == 0 { + return nil, common.NewError("subscription was deleted or pointed at another URL during the fetch") + } + return fetched.outbounds, nil +} + +// Preview downloads and parses a subscription URL without storing anything. +func (s *OutboundSubscriptionService) Preview(rawURL, userAgent string, allowPrivate, allowInsecure bool) ([]any, error) { + prefix, err := s.nextDefaultSubPrefix(0) + if err != nil { + return nil, err + } + fetched, err := s.fetchOutbounds(&model.OutboundSubscription{ + Url: rawURL, UserAgent: userAgent, AllowPrivate: allowPrivate, AllowInsecure: allowInsecure, TagPrefix: prefix, + }) + if err != nil { + return nil, err + } + return fetched.outbounds, nil +} + +// fetchOutbounds does the network, parse, filter and tag-stability work for sub. +func (s *OutboundSubscriptionService) fetchOutbounds(sub *model.OutboundSubscription) (*subscriptionFetch, error) { // Re-sanitize on every fetch (handles legacy rows + defense in depth against // any direct DB tampering). Private targets are blocked unless this // subscription was explicitly created with AllowPrivate. cleanURL, err := SanitizePublicHTTPURL(sub.Url, sub.AllowPrivate) if err != nil { - s.recordError(sub, err) - return nil, err + return nil, withoutRequestURL(err) } if cleanURL == "" { return nil, common.NewError("subscription has no valid URL") } - sub.Url = cleanURL // persist the cleaned version client := s.subscriptionFetchClient(30*time.Second, sub.AllowInsecure) + // The transport lives for this fetch only; left alone, the provider decides how long + // the connections it parks stay open. + defer client.CloseIdleConnections() // Re-validate every redirect hop: the initial host is checked above, but a // redirect could still point at a private/internal address (SSRF). Cap the // redirect chain as well. @@ -364,10 +427,9 @@ func (s *OutboundSubscriptionService) fetchAndStore(sub *model.OutboundSubscript } reqCtx := netsafe.ContextWithAllowPrivate(context.Background(), sub.AllowPrivate) - req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, sub.Url, nil) + req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, cleanURL, nil) if err != nil { - s.recordError(sub, err) - return nil, err + return nil, withoutRequestURL(err) } userAgent := strings.TrimSpace(sub.UserAgent) if userAgent == "" { @@ -381,25 +443,20 @@ func (s *OutboundSubscriptionService) fetchAndStore(sub *model.OutboundSubscript resp, err := client.Do(req) if err != nil { - s.recordError(sub, err) - return nil, err + return nil, withoutRequestURL(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - err := fmt.Errorf("http %d", resp.StatusCode) - s.recordError(sub, err) - return nil, err + return nil, fmt.Errorf("http %d", resp.StatusCode) } body, err := readBoundedOutboundSubscriptionBody(resp.Body) if err != nil { - s.recordError(sub, err) return nil, err } parsed, identities, err := link.ParseSubscriptionBody(body) if err != nil { - s.recordError(sub, err) return nil, err } @@ -436,6 +493,7 @@ func (s *OutboundSubscriptionService) fetchAndStore(sub *model.OutboundSubscript dropped = append(dropped, fmt.Sprintf("%.64s: %s", ob["tag"], reason)) continue } + forceUserspaceWireguard(map[string]any(ob)) if _, rejected := filterOutboundsRejectedByCore(fmt.Sprintf("outbound sub %d", sub.Id), []any{map[string]any(ob)}); len(rejected) > 0 { dropped = append(dropped, rejected...) continue @@ -462,22 +520,45 @@ func (s *OutboundSubscriptionService) fetchAndStore(sub *model.OutboundSubscript kept[i] = map[string]any(keptLinks[i]) } - // Persist the outbounds (as compact JSON array) - obsJSON, _ := json.Marshal(kept) - - sub.LastFetchedOutbounds = string(obsJSON) - sub.LinkIdentities = string(identJSON) - sub.LastUpdated = time.Now().Unix() - sub.LastError = "" + fetched := &subscriptionFetch{url: cleanURL, outbounds: kept, identities: string(identJSON)} if len(dropped) > 0 { - sub.LastError = fmt.Sprintf("dropped %d outbound(s): %s", len(dropped), dropped[0]) + fetched.lastError = fmt.Sprintf("dropped %d outbound(s): %s", len(dropped), dropped[0]) } - - if err := database.GetDB().Save(sub).Error; err != nil { - return nil, err + if len(parsed) >= link.MaxSubscriptionOutbounds { + fetched.lastError = strings.TrimPrefix(fetched.lastError+"; ", "; ") + + fmt.Sprintf("only the first %d links of the document are loaded", link.MaxSubscriptionOutbounds) } + return fetched, nil +} - return kept, nil +// withoutRequestURL keeps only the host of the URL net/url and net/http quote in their +// errors: a subscription URL is a credential, and these errors are logged and stored. +func withoutRequestURL(err error) error { + var urlErr *url.Error + if !errors.As(err, &urlErr) { + return err + } + if u, parseErr := url.Parse(urlErr.URL); parseErr == nil && u.Host != "" { + return fmt.Errorf("%s %s: %w", urlErr.Op, u.Host, urlErr.Err) + } + return fmt.Errorf("%s: %w", urlErr.Op, urlErr.Err) +} + +// forceUserspaceWireguard keeps a provider's wireguard fields off the host: the kernel +// TUN path would install its addresses and routes on the panel's machine. +func forceUserspaceWireguard(outbound any) { + ob, ok := outbound.(map[string]any) + if !ok { + return + } + if protocol, _ := ob["protocol"].(string); !strings.EqualFold(protocol, "wireguard") { + return + } + if settings, ok := ob["settings"].(map[string]any); ok { + settings["noKernelTun"] = true + } else { + ob["settings"] = map[string]any{"noKernelTun": true} + } } func (s *OutboundSubscriptionService) recordError(sub *model.OutboundSubscription, err error) { @@ -587,6 +668,9 @@ func (s *OutboundSubscriptionService) activeOutboundsSplit() (prepend []any, app } return reason != "" }) + for _, ob := range arr { + forceUserspaceWireguard(ob) + } arr, _ = filterOutboundsRejectedByCore(fmt.Sprintf("outbound sub %d", sub.Id), arr) if sub.Prepend { prepend = append(prepend, arr...) diff --git a/internal/web/service/outbound_subscription_test.go b/internal/web/service/outbound_subscription_test.go index d5488c55a..0909fed0c 100644 --- a/internal/web/service/outbound_subscription_test.go +++ b/internal/web/service/outbound_subscription_test.go @@ -6,12 +6,16 @@ import ( "errors" "fmt" "maps" + "net" "net/http" "net/http/httptest" "net/url" "slices" + "strconv" "strings" + "sync/atomic" "testing" + "time" "gorm.io/gorm" @@ -504,3 +508,190 @@ func TestNamesInternalHost(t *testing.T) { } } } + +// The provider decides when its answer arrives, so an admin's delete or disable in the +// meantime must survive the refresh that read the row before the download. +func TestOutboundSubscriptionRefreshKeepsAdminChangesMadeDuringTheFetch(t *testing.T) { + setupSettingTestDB(t) + const body = "vless://00000000-0000-4000-8000-000000000000@8.8.8.8:443?security=tls&type=tcp#B" + var during func(id int) + var subID int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + during(subID) + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(server.Close) + svc := &OutboundSubscriptionService{} + create := func() { + t.Helper() + sub := &model.OutboundSubscription{Url: server.URL, AllowPrivate: true, TagPrefix: "p-", Enabled: true} + if err := database.GetDB().Create(sub).Error; err != nil { + t.Fatalf("seed subscription: %v", err) + } + subID = sub.Id + } + + create() + during = func(id int) { _ = svc.Delete(id) } + if _, err := svc.Refresh(subID); err == nil { + t.Fatalf("Refresh of a subscription deleted mid-fetch succeeded") + } + var count int64 + database.GetDB().Model(&model.OutboundSubscription{}).Where("id = ?", subID).Count(&count) + if count != 0 { + t.Fatalf("subscription deleted during the fetch was re-created") + } + + create() + during = func(id int) { + database.GetDB().Model(&model.OutboundSubscription{}).Where("id = ?", id).Update("enabled", false) + } + if _, err := svc.Refresh(subID); err != nil { + t.Fatalf("Refresh: %v", err) + } + got, err := svc.Get(subID) + if err != nil || got.Enabled || !strings.Contains(got.LastFetchedOutbounds, "8.8.8.8") { + t.Fatalf("after disable mid-fetch: enabled=%v outbounds=%q (%v), want disabled with the fetch stored", got.Enabled, got.LastFetchedOutbounds, err) + } +} + +// A subscription URL is the credential for the provider account; fetch errors are +// logged and stored, so they may name the host but never the path or query. +func TestOutboundSubscriptionFetchErrorsKeepTheURLSecret(t *testing.T) { + setupSettingTestDB(t) + closed := httptest.NewServer(http.NotFoundHandler()) + closed.Close() + const token = "s3cr3t-token" + sub := &model.OutboundSubscription{Url: closed.URL + "/sub/" + token + "?key=" + token, AllowPrivate: true, TagPrefix: "p-"} + if err := database.GetDB().Create(sub).Error; err != nil { + t.Fatalf("seed subscription: %v", err) + } + _, err := (&OutboundSubscriptionService{}).Refresh(sub.Id) + if err == nil || strings.Contains(err.Error(), token) { + t.Fatalf("Refresh error = %v, want a failure that does not quote the URL", err) + } + stored, _ := (&OutboundSubscriptionService{}).Get(sub.Id) + if stored.LastError == "" || strings.Contains(stored.LastError, token) { + t.Fatalf("lastError = %q, want a failure that does not quote the URL", stored.LastError) + } + _, err = (&OutboundSubscriptionService{}).Create("r", "https://provider.example/sub/"+token+"%zz", "", "", true, 600, false, false, false) + if err == nil || strings.Contains(err.Error(), token) { + t.Fatalf("Create error = %v, want a parse failure that does not quote the URL", err) + } +} + +func TestOutboundSubscriptionPreviewStoresNothing(t *testing.T) { + setupSettingTestDB(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("vless://00000000-0000-4000-8000-000000000000@8.8.8.8:443?security=tls&type=tcp#B")) + })) + t.Cleanup(server.Close) + obs, err := (&OutboundSubscriptionService{}).Preview(server.URL, "", true, false) + if err != nil || len(obs) != 1 { + t.Fatalf("Preview = %v, %v; want the one outbound", obs, err) + } + var count int64 + database.GetDB().Model(&model.OutboundSubscription{}).Count(&count) + if count != 0 { + t.Fatalf("Preview left %d subscription row(s) behind", count) + } +} + +// Each fetch builds its own transport; whatever it leaves parked would otherwise live +// for as long as the provider keeps the connection open. +func TestOutboundSubscriptionRefreshLeavesNoIdleConnection(t *testing.T) { + setupSettingTestDB(t) + var open atomic.Int32 + server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("vless://00000000-0000-4000-8000-000000000000@8.8.8.8:443?security=tls&type=tcp#B")) + })) + server.Config.ConnState = func(_ net.Conn, state http.ConnState) { + switch state { + case http.StateNew: + open.Add(1) + case http.StateClosed, http.StateHijacked: + open.Add(-1) + } + } + server.Start() + t.Cleanup(server.Close) + sub := &model.OutboundSubscription{Url: server.URL, AllowPrivate: true, TagPrefix: "p-"} + if err := database.GetDB().Create(sub).Error; err != nil { + t.Fatalf("seed subscription: %v", err) + } + for range 3 { + if _, err := (&OutboundSubscriptionService{}).Refresh(sub.Id); err != nil { + t.Fatalf("Refresh: %v", err) + } + } + deadline := time.Now().Add(2 * time.Second) + for open.Load() != 0 && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + if n := open.Load(); n != 0 { + t.Fatalf("%d connection(s) to the provider still open after the refreshes", n) + } +} + +// One document must not turn into an unbounded number of outbounds: each is held, +// validated and merged into the core's config. +func TestOutboundSubscriptionRefreshCapsTheNumberOfLinks(t *testing.T) { + setupSettingTestDB(t) + subID := serveOutboundSubscription(t, "p-", func(int) string { + return strings.Repeat("ss://YWVzLTEyOC1nY206cA@8.8.8.8:8388#s\n", link.MaxSubscriptionOutbounds+5) + }) + obs, err := (&OutboundSubscriptionService{}).Refresh(subID) + if err != nil { + t.Fatalf("Refresh: %v", err) + } + if len(obs) != link.MaxSubscriptionOutbounds { + t.Fatalf("stored %d outbounds, want the cap of %d", len(obs), link.MaxSubscriptionOutbounds) + } + sub, _ := (&OutboundSubscriptionService{}).Get(subID) + if !strings.Contains(sub.LastError, strconv.Itoa(link.MaxSubscriptionOutbounds)) { + t.Fatalf("lastError = %q, want the cut reported", sub.LastError) + } +} + +// A provider's wireguard fields must not configure the host: the kernel TUN path +// installs its addresses and routes, so subscription outbounds stay in userspace. +func TestOutboundSubscriptionWireguardStaysOffKernelTun(t *testing.T) { + setupSettingTestDB(t) + subID := serveOutboundSubscription(t, "p-", func(int) string { + return "wireguard://JOSLNr3Kk8BViwrrTJ2M19gRBbGttnRfApY3nQ3fdT8%3D@8.8.8.8:51820?publickey=biSnuvUOX2pmtzf249FK8UGSq2PQslGSdoj4UqH4yew%3D&address=10.0.0.2%2F32#wg" + }) + obs, err := (&OutboundSubscriptionService{}).Refresh(subID) + if err != nil || len(obs) != 1 { + t.Fatalf("Refresh = %v, %v", obs, err) + } + stored := `[{"tag":"p-old","protocol":"wireguard","settings":{"secretKey":"JOSLNr3Kk8BViwrrTJ2M19gRBbGttnRfApY3nQ3fdT8=","address":["10.0.0.2/32"],"peers":[{"publicKey":"biSnuvUOX2pmtzf249FK8UGSq2PQslGSdoj4UqH4yew=","endpoint":"8.8.8.8:51820"}]}}]` + old := &model.OutboundSubscription{Url: "https://provider.example/sub", TagPrefix: "q-", Enabled: true, LastFetchedOutbounds: stored} + if err := database.GetDB().Create(old).Error; err != nil { + t.Fatalf("seed subscription: %v", err) + } + if err := database.GetDB().Model(&model.OutboundSubscription{}).Where("id = ?", subID).Update("enabled", true).Error; err != nil { + t.Fatalf("enable: %v", err) + } + active, err := (&OutboundSubscriptionService{}).AllActiveOutbounds() + if err != nil || len(active) != 2 { + t.Fatalf("AllActiveOutbounds = %v, %v", active, err) + } + for _, ob := range append(obs, active...) { + settings, _ := ob.(map[string]any)["settings"].(map[string]any) + if settings["noKernelTun"] != true { + t.Fatalf("subscription wireguard outbound without noKernelTun: %v", ob) + } + } +} + +func TestOutboundSubscriptionCreateKeepsItDisabled(t *testing.T) { + setupSettingTestDB(t) + sub, err := (&OutboundSubscriptionService{}).Create("r", "https://1.1.1.1/sub", "", "", false, 600, false, false, false) + if err != nil { + t.Fatalf("Create: %v", err) + } + stored, err := (&OutboundSubscriptionService{}).Get(sub.Id) + if err != nil || stored.Enabled { + t.Fatalf("stored enabled = %v (%v), want the subscription created disabled", stored.Enabled, err) + } +} From db20e491e6d86376f093e234bfe2aacdb39d234e Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 31 Oct 2026 08:32:58 +0000 Subject: [PATCH 005/125] fix(net): stop per-request clients parking connections to remote hosts The node certificate probe builds a transport for one request and dropped it with the connection still parked, and the remote routing client kept idle connections with no timeout; either stayed open for as long as the remote side wanted. Close the probe's idle connections when it returns, and turn keep-alive off for routing downloads, which are minutes apart. The routing client dials only public hosts, so no loopback test reaches it; TestFetchCertFingerprintLeavesNoIdleConnection covers the probe. --- internal/sub/remote_routing.go | 4 +++- internal/web/service/node.go | 1 + internal/web/service/node_test.go | 38 +++++++++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/internal/sub/remote_routing.go b/internal/sub/remote_routing.go index 90113e4ed..b455e0be2 100644 --- a/internal/sub/remote_routing.go +++ b/internal/sub/remote_routing.go @@ -486,7 +486,9 @@ func newRemoteRoutingHTTPClient() *http.Client { ForceAttemptHTTP2: true, TLSHandshakeTimeout: 4 * time.Second, ResponseHeaderTimeout: 5 * time.Second, - TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}, + // Downloads are minutes apart, and a parked connection lives as long as the host likes. + DisableKeepAlives: true, + TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}, } return &http.Client{ Timeout: remoteRoutingHTTPTimeout, diff --git a/internal/web/service/node.go b/internal/web/service/node.go index 0b6442097..a3463966e 100644 --- a/internal/web/service/node.go +++ b/internal/web/service/node.go @@ -91,6 +91,7 @@ func (s *NodeService) FetchCertFingerprint(ctx context.Context, n *model.Node) ( TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // lgtm[go/disabled-certificate-check] }, } + defer client.CloseIdleConnections() resp, err := client.Do(req) if err != nil { return "", err diff --git a/internal/web/service/node_test.go b/internal/web/service/node_test.go index ddddc0b2a..bd18c9c82 100644 --- a/internal/web/service/node_test.go +++ b/internal/web/service/node_test.go @@ -1,7 +1,15 @@ package service import ( + "context" + "net" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "sync/atomic" "testing" + "time" "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/web/runtime" @@ -250,3 +258,33 @@ func TestFilterNodeSnapshotKeepsAdoptedAlias(t *testing.T) { t.Fatalf("filtered snapshot = %#v, want adopted alias only", snap.Inbounds) } } + +// The probe builds a transport per call; what it parks would stay open for as long +// as the node, an untrusted peer, keeps the connection. +func TestFetchCertFingerprintLeavesNoIdleConnection(t *testing.T) { + var open atomic.Int32 + server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + server.Config.ConnState = func(_ net.Conn, state http.ConnState) { + switch state { + case http.StateNew: + open.Add(1) + case http.StateClosed, http.StateHijacked: + open.Add(-1) + } + } + server.StartTLS() + t.Cleanup(server.Close) + u, _ := url.Parse(server.URL) + port, _ := strconv.Atoi(u.Port()) + node := &model.Node{Scheme: "https", Address: u.Hostname(), Port: port, AllowPrivateAddress: true} + if _, err := (&NodeService{}).FetchCertFingerprint(context.Background(), node); err != nil { + t.Fatalf("FetchCertFingerprint: %v", err) + } + deadline := time.Now().Add(2 * time.Second) + for open.Load() != 0 && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + if n := open.Load(); n != 0 { + t.Fatalf("%d connection(s) to the node still open after the probe", n) + } +} From 61ee20275071d6d099a2fef52da0824441ed82f5 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 31 Oct 2026 08:43:30 +0000 Subject: [PATCH 006/125] fix(xray): bound what the in-process config loader is asked to expand The panel runs xray-core's conf.Build() in its own process on inbounds and on outbounds that come from pushes and subscriptions. Some values make it allocate or loop without bound (an XHTTP sessionIDLength, udphop port lists, any number of finalmask entries, each xmc one costing an RSA key), and certificate paths are read whole whatever they name. Refuse those before any build: sessionIDLength above 1024, more than one port range's worth of udphop ports per stream, more than 16 masks per stream, more than 64 xmc masks per outbound subscription, and certificate or key files that are not regular files of at most 1 MiB. Inbound saves, the TUIC readers and the certificate-pin helper apply the file rule too; the pin helper also stops looking at paths that name files on a node. --- internal/tuic/server.go | 7 + internal/tuic/types.go | 3 +- internal/tuic/types_test.go | 21 ++ internal/web/service/inbound.go | 18 ++ internal/web/service/inbound_tls_test.go | 32 +++ internal/web/service/outbound_subscription.go | 32 ++- .../web/service/outbound_subscription_test.go | 22 +++ internal/web/service/server.go | 7 + .../service/server_remote_cert_hash_test.go | 24 +++ internal/xray/api.go | 9 + internal/xray/build_cost.go | 183 ++++++++++++++++++ internal/xray/outbound_validation_test.go | 34 ++++ 12 files changed, 390 insertions(+), 2 deletions(-) create mode 100644 internal/xray/build_cost.go diff --git a/internal/tuic/server.go b/internal/tuic/server.go index ab4c4841c..1245b335c 100644 --- a/internal/tuic/server.go +++ b/internal/tuic/server.go @@ -19,6 +19,7 @@ import ( "github.com/xtls/xray-core/transport/internet/hysteria/congestion/bbr" "github.com/mhsanaei/3x-ui/v3/internal/logger" + "github.com/mhsanaei/3x-ui/v3/internal/xray" ) // Server is an in-process native Go TUIC v5 server terminating QUIC @@ -1089,6 +1090,12 @@ func loadCertificate(certInput, keyInput string) (tls.Certificate, error) { if strings.Contains(certInput, "-----BEGIN CERTIFICATE-----") { return tls.X509KeyPair([]byte(certInput), []byte(keyInput)) } + // A pushed inbound names these paths, and both are read whole. + for _, path := range []string{certInput, keyInput} { + if err := xray.CheckCertFile(path); err != nil { + return tls.Certificate{}, err + } + } return tls.LoadX509KeyPair(certInput, keyInput) } diff --git a/internal/tuic/types.go b/internal/tuic/types.go index f06904e3c..fba294046 100644 --- a/internal/tuic/types.go +++ b/internal/tuic/types.go @@ -11,6 +11,7 @@ import ( "strings" "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/xray" ) type TuicServerSettings struct { @@ -73,7 +74,7 @@ func (inst Instance) StructuralFingerprint() string { func fingerprintMaterial(value string) string { material := []byte(value) - if value != "" && !strings.Contains(value, "-----BEGIN ") { + if value != "" && !strings.Contains(value, "-----BEGIN ") && xray.CheckCertFile(value) == nil { if file, err := os.ReadFile(value); err == nil { material = file } diff --git a/internal/tuic/types_test.go b/internal/tuic/types_test.go index a3ae70015..0d29f15de 100644 --- a/internal/tuic/types_test.go +++ b/internal/tuic/types_test.go @@ -3,6 +3,7 @@ package tuic import ( "os" "path/filepath" + "strings" "testing" "github.com/mhsanaei/3x-ui/v3/internal/database/model" @@ -158,3 +159,23 @@ func TestBindTo(t *testing.T) { }) } } + +// A pushed TUIC inbound names any path on this host, and both readers take the file +// whole, so a device or an oversized file is refused before either opens it. +func TestCertificateReadersRefuseWhatIsNoBoundedRegularFile(t *testing.T) { + big := filepath.Join(t.TempDir(), "big.pem") + if err := os.WriteFile(big, make([]byte, 1<<20+1), 0o600); err != nil { + t.Fatal(err) + } + if _, err := loadCertificate(big, big); err == nil || !strings.Contains(err.Error(), "regular file") { + t.Fatalf("loadCertificate(oversized) = %v, want the path refused before the read", err) + } + inst := Instance{Certificate: big} + before := inst.StructuralFingerprint() + if err := os.WriteFile(big, append(make([]byte, 1<<20), 1, 2), 0o600); err != nil { + t.Fatal(err) + } + if inst.StructuralFingerprint() != before { + t.Fatal("the oversized certificate file was read into the fingerprint") + } +} diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go index 3bfdbc9d8..27d10dbee 100644 --- a/internal/web/service/inbound.go +++ b/internal/web/service/inbound.go @@ -713,6 +713,18 @@ func canonicalizeStreamNetworkKey(streamSettings string) string { return string(out) } +// validateInboundBuildInput refuses what xray-core's in-process build would expand without +// bound; certificate paths are checked only where this host is the one opening them. +func validateInboundBuildInput(inbound *model.Inbound) error { + if err := xray.CheckStreamBuildCost([]byte(inbound.StreamSettings)); err != nil { + return err + } + if inbound.NodeID != nil { + return nil + } + return xray.CheckConfigCertFiles([]byte(inbound.StreamSettings)) +} + // validateInboundTLSCertificates rejects incomplete TLS credentials before a save // can restart Xray. File paths belong to the node, so only presence is checked. func validateInboundTLSCertificates(streamSettings string) error { @@ -1290,6 +1302,9 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo return inbound, false, err } } + if err := validateInboundBuildInput(inbound); err != nil { + return inbound, false, err + } if err := validateFinalMaskRealityCombo(inbound.StreamSettings); err != nil { return inbound, false, err } @@ -2007,6 +2022,9 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, } inbound.SubSortIndex = normalizeSubSortIndex(inbound.SubSortIndex) + if err := validateInboundBuildInput(inbound); err != nil { + return inbound, false, err + } // Grandfather a row that was already stored incomplete so it stays editable; // only a save that breaks a previously valid TLS block is refused. if !s.FromNodeSync { diff --git a/internal/web/service/inbound_tls_test.go b/internal/web/service/inbound_tls_test.go index 105ca2400..8c1a5f947 100644 --- a/internal/web/service/inbound_tls_test.go +++ b/internal/web/service/inbound_tls_test.go @@ -1,6 +1,7 @@ package service import ( + "path/filepath" "reflect" "strings" "testing" @@ -183,3 +184,34 @@ func TestUpdateInboundAllowsUntouchedLegacyTLSCertificates(t *testing.T) { }) } } + +// A push names any path on this host, and xray-core reads it whole in the panel process +// (a device never ends, /dev/zero never stops growing), so it is refused before storing. +func TestAddInboundRefusesACertificatePathThatIsNoRegularFile(t *testing.T) { + setupConflictDB(t) + mgr := useTestRuntimeManager(t) + fake := &fakeNodeRuntime{} + mgr.SetLocalRuntimeOverride(fake) + + dir := filepath.ToSlash(t.TempDir()) + inbound := &model.Inbound{ + Tag: "tls-device-44311", + Enable: true, + Listen: "0.0.0.0", + Port: 44311, + Protocol: model.VLESS, + StreamSettings: `{"network":"tcp","security":"tls","tlsSettings":{"certificates":[{"certificateFile":"` + dir + `","KeyFile":"` + dir + `"}]}}`, + Settings: `{"clients":[]}`, + } + _, _, err := (&InboundService{FromNodeSync: true}).AddInbound(inbound) + if err == nil || !strings.Contains(err.Error(), "regular file") { + t.Fatalf("AddInbound = %v, want the certificate path refused", err) + } + var count int64 + if err := database.GetDB().Model(&model.Inbound{}).Count(&count).Error; err != nil || count != 0 { + t.Fatalf("refused push stored %d row(s) (%v)", count, err) + } + if fake.addInbound.Load() != 0 { + t.Fatal("refused push reached the runtime") + } +} diff --git a/internal/web/service/outbound_subscription.go b/internal/web/service/outbound_subscription.go index 230b54c5d..99973ca1a 100644 --- a/internal/web/service/outbound_subscription.go +++ b/internal/web/service/outbound_subscription.go @@ -486,9 +486,14 @@ func (s *OutboundSubscriptionService) fetchOutbounds(sub *model.OutboundSubscrip // persisted (filtered) list, so positions must be counted in that same list. var dropped []string ownPorts := xrayOwnPorts() + xmcBudget := maxSubscriptionXMCMasks keptLinks, keptIdentities := parsed[:0], identities[:0] for i, ob := range parsed { - if reason := internalDialTarget(map[string]any(ob), sub.AllowPrivate, ownPorts); reason != "" { + reason := internalDialTarget(map[string]any(ob), sub.AllowPrivate, ownPorts) + if reason == "" && !spendXMCBudget(map[string]any(ob), &xmcBudget) { + reason = fmt.Sprintf("the subscription uses more than %d xmc masks", maxSubscriptionXMCMasks) + } + if reason != "" { logger.Warningf("outbound sub %d: dropping outbound %.64q: %s", sub.Id, ob["tag"], reason) dropped = append(dropped, fmt.Sprintf("%.64s: %s", ob["tag"], reason)) continue @@ -531,6 +536,27 @@ func (s *OutboundSubscriptionService) fetchOutbounds(sub *model.OutboundSubscrip return fetched, nil } +// maxSubscriptionXMCMasks bounds the RSA keys one subscription costs the core per build; +// the masks can be spread over links, so a per-stream cap alone does not bound it. +const maxSubscriptionXMCMasks = 64 + +func spendXMCBudget(outbound any, budget *int) bool { + ob, ok := outbound.(map[string]any) + if !ok { + return true + } + stream, err := json.Marshal(ob["streamSettings"]) + if err != nil { + return true + } + n := xray.CountXMCMasks(stream) + if n > *budget { + return false + } + *budget -= n + return true +} + // withoutRequestURL keeps only the host of the URL net/url and net/http quote in their // errors: a subscription URL is a credential, and these errors are logged and stored. func withoutRequestURL(err error) error { @@ -652,6 +678,7 @@ func (s *OutboundSubscriptionService) activeOutboundsSplit() (prepend []any, app } ownPorts := xrayOwnPorts() for _, sub := range subs { + xmcBudget := maxSubscriptionXMCMasks if strings.TrimSpace(sub.LastFetchedOutbounds) == "" { continue } @@ -663,6 +690,9 @@ func (s *OutboundSubscriptionService) activeOutboundsSplit() (prepend []any, app // Rows stored before the fetch refused these, or while allowPrivate was on. arr = slices.DeleteFunc(arr, func(ob any) bool { reason := internalDialTarget(ob, sub.AllowPrivate, ownPorts) + if reason == "" && !spendXMCBudget(ob, &xmcBudget) { + reason = "over the subscription's xmc mask budget" + } if reason != "" { logger.Warningf("outbound sub %d: dropping a stored outbound: %s", sub.Id, reason) } diff --git a/internal/web/service/outbound_subscription_test.go b/internal/web/service/outbound_subscription_test.go index 0909fed0c..62b4383db 100644 --- a/internal/web/service/outbound_subscription_test.go +++ b/internal/web/service/outbound_subscription_test.go @@ -695,3 +695,25 @@ func TestOutboundSubscriptionCreateKeepsItDisabled(t *testing.T) { t.Fatalf("stored enabled = %v (%v), want the subscription created disabled", stored.Enabled, err) } } + +// The core derives an RSA key per xmc mask on every build, so the masks a document may +// spread over its links are budgeted per subscription, before anything is built. +func TestOutboundSubscriptionRefreshCapsXMCMasks(t *testing.T) { + setupSettingTestDB(t) + const xmc = `{"type":"xmc","settings":{"password":"p","profiles":[{"username":"abc","uuid":"00000000-0000-0000-0000-000000000000","texturesValue":"a","texturesSignature":"a"}]}}` + server := func(address string, masks ...string) string { + return "vless://00000000-0000-4000-8000-000000000000@" + address + ":443?security=tls&type=tcp&fm=" + + url.QueryEscape(`{"tcp":[`+strings.Join(masks, ",")+`]}`) + "#" + address + "\n" + } + four := []string{xmc, xmc, xmc, xmc} + subID := serveOutboundSubscription(t, "p-", func(int) string { + return server("1.1.1.1", slices.Repeat([]string{xmc}, 400)...) + + strings.Repeat(server("1.1.1.3", four...), maxSubscriptionXMCMasks/4) + + server("1.1.1.4", xmc) + + server("1.1.1.5", `{"type":"sudoku","settings":{"password":"p"}}`) + }) + tags, byAddress := refreshOutboundTags(t, subID) + if _, plain := byAddress["1.1.1.5"]; len(tags) != maxSubscriptionXMCMasks/4+1 || len(byAddress) != 2 || !plain { + t.Fatalf("kept %d outbounds, tags by address = %v", len(tags), byAddress) + } +} diff --git a/internal/web/service/server.go b/internal/web/service/server.go index 6a6a15b93..cab19c6df 100644 --- a/internal/web/service/server.go +++ b/internal/web/service/server.go @@ -2687,6 +2687,9 @@ func (s *ServerService) GetCertHash(certFile string, certContent string) ([]stri if !ok { return nil, common.NewError("certificate file is not referenced by any inbound or panel setting") } + if err := xray.CheckCertFile(known); err != nil { + return nil, err + } b, err := os.ReadFile(known) if err != nil { return nil, err @@ -2760,6 +2763,10 @@ func (s *ServerService) knownCertFiles() []string { } if inbounds, err := s.inboundService.GetAllInbounds(); err == nil { for _, inbound := range inbounds { + // A node row's path names a file on the node's host, not on this one. + if inbound.NodeID != nil { + continue + } files = collectCertFiles(inbound.StreamSettings, files) } } diff --git a/internal/web/service/server_remote_cert_hash_test.go b/internal/web/service/server_remote_cert_hash_test.go index 21f446437..542ac94bc 100644 --- a/internal/web/service/server_remote_cert_hash_test.go +++ b/internal/web/service/server_remote_cert_hash_test.go @@ -6,9 +6,14 @@ import ( "errors" "net/http" "net/http/httptest" + "os" + "path/filepath" + "strconv" "strings" "testing" + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/util/netsafe" ) @@ -36,3 +41,22 @@ func TestGetRemoteCertHashGuardsPrivateTargets(t *testing.T) { } }) } + +// A node reports its inbounds' certificate paths; they name files on the node, so the +// master must not read its own file of that name when the admin asks for a pin. +func TestGetCertHashIgnoresPathsANodeReports(t *testing.T) { + setupSettingTestDB(t) + certFile := filepath.Join(t.TempDir(), "cert.pem") + if err := os.WriteFile(certFile, []byte("not a certificate"), 0o600); err != nil { + t.Fatalf("write cert: %v", err) + } + nodeID := 1 + stream := `{"security":"tls","tlsSettings":{"certificates":[{"certificateFile":` + strconv.Quote(certFile) + `}]}}` + if err := database.GetDB().Create(&model.Inbound{Tag: "node-in", Port: 44312, Protocol: model.VLESS, NodeID: &nodeID, StreamSettings: stream}).Error; err != nil { + t.Fatalf("seed node inbound: %v", err) + } + _, err := (&ServerService{}).GetCertHash(certFile, "") + if err == nil || !strings.Contains(err.Error(), "not referenced") { + t.Fatalf("GetCertHash(node-reported path) = %v, want it treated as unknown", err) + } +} diff --git a/internal/xray/api.go b/internal/xray/api.go index 906b348f9..2a38b137e 100644 --- a/internal/xray/api.go +++ b/internal/xray/api.go @@ -150,6 +150,9 @@ func (x *XrayAPI) AddInbound(inbound []byte) error { logger.Debug("Failed to unmarshal inbound:", err) return err } + if err := checkUntrustedBuildInput(conf.StreamSetting, inbound); err != nil { + return err + } localizeNoiseScripts(conf.StreamSetting) config, err := conf.Build() if err != nil { @@ -196,6 +199,9 @@ func ValidateOutboundConfig(outbound []byte) (err error) { if err := json.Unmarshal(outbound, detour); err != nil { return err } + if err := checkUntrustedBuildInput(detour.StreamSetting, outbound); err != nil { + return err + } localizeNoiseScripts(detour.StreamSetting) built, err := detour.Build() if err != nil { @@ -314,6 +320,9 @@ func (x *XrayAPI) AddOutbound(outbound []byte) error { logger.Debug("Failed to unmarshal outbound:", err) return err } + if err := checkUntrustedBuildInput(conf.StreamSetting, outbound); err != nil { + return err + } localizeNoiseScripts(conf.StreamSetting) config, err := conf.Build() if err != nil { diff --git a/internal/xray/build_cost.go b/internal/xray/build_cost.go new file mode 100644 index 000000000..1ff35b810 --- /dev/null +++ b/internal/xray/build_cost.go @@ -0,0 +1,183 @@ +package xray + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + + "github.com/mhsanaei/3x-ui/v3/internal/util/common" + + "github.com/xtls/xray-core/common/platform" + "github.com/xtls/xray-core/infra/conf" +) + +const ( + // A session ID travels in a path segment, a header, a cookie or a query value. + maxXHTTPSessionIDLength = 1024 + maxUDPHopPorts = 65536 + maxStreamMasks = 16 + maxCertFileBytes = 1 << 20 +) + +// CheckStreamBuildCost refuses stream settings that conf.Build(), which runs in the panel +// process, would expand without bound; settings the core cannot decode are left to it. +func CheckStreamBuildCost(raw []byte) error { + if len(raw) == 0 { + return nil + } + stream := new(conf.StreamConfig) + if json.Unmarshal(raw, stream) != nil { + return nil + } + return checkStreamBuildCost(stream) +} + +func checkStreamBuildCost(stream *conf.StreamConfig) error { + masks, hopPorts := 0, 0 + return walkStreams(stream, func(s *conf.StreamConfig, xhttp *conf.SplitHTTPConfig) error { + if xhttp != nil { + if length := xhttp.SessionIDLength; length.From < 0 || length.To > maxXHTTPSessionIDLength { + return common.NewErrorf("xhttp sessionIDLength %s is outside 0-%d", length.String(), maxXHTTPSessionIDLength) + } + } + if s.FinalMask == nil { + return nil + } + // Every mask costs a build, and an xmc one an RSA key derivation. + if masks += len(s.FinalMask.Tcp) + len(s.FinalMask.Udp); masks > maxStreamMasks { + return common.NewErrorf("finalmask lists more than %d masks", maxStreamMasks) + } + for _, mask := range s.FinalMask.Udp { + if !strings.EqualFold(mask.Type, "udphop") || mask.Settings == nil { + continue + } + hop := new(conf.UDPHop) + if json.Unmarshal(*mask.Settings, hop) != nil { + continue + } + for _, r := range hop.RemotePorts.Range { + // A bare number skips the core's range check, and its uint32 loop never ends on the largest. + if r.To > 65535 { + return common.NewErrorf("udphop remotePorts names port %d", r.To) + } + if r.From <= r.To { + hopPorts += int(r.To-r.From) + 1 + } + if hopPorts > maxUDPHopPorts { + return common.NewErrorf("udphop remotePorts names more than %d ports", maxUDPHopPorts) + } + } + } + return nil + }) +} + +// CountXMCMasks counts the xmc tcp masks of a stream, nested download streams included: +// each costs the core a fresh RSA key on every build. +func CountXMCMasks(raw []byte) int { + stream := new(conf.StreamConfig) + if len(raw) == 0 || json.Unmarshal(raw, stream) != nil { + return 0 + } + count := 0 + _ = walkStreams(stream, func(s *conf.StreamConfig, _ *conf.SplitHTTPConfig) error { + if s.FinalMask != nil { + for _, mask := range s.FinalMask.Tcp { + if strings.EqualFold(mask.Type, "xmc") { + count++ + } + } + } + return nil + }) + return count +} + +// walkStreams visits stream and the download streams nested in its xhttp settings, with the +// xhttp settings the core builds: xhttpSettings over splithttpSettings, "extra" over both. +func walkStreams(stream *conf.StreamConfig, visit func(*conf.StreamConfig, *conf.SplitHTTPConfig) error) error { + for stream != nil { + xhttp := stream.XHTTPSettings + if xhttp == nil { + xhttp = stream.SplitHTTPSettings + } + if xhttp != nil && xhttp.Extra != nil { + extra := new(conf.SplitHTTPConfig) + if json.Unmarshal(xhttp.Extra, extra) != nil { + extra = nil + } + xhttp = extra + } + if err := visit(stream, xhttp); err != nil { + return err + } + stream = nil + if xhttp != nil { + stream = xhttp.DownloadSettings + } + } + return nil +} + +// CheckConfigCertFiles refuses a certificateFile or keyFile, at any depth and in any key +// case, that is not a bounded regular file: the core reads it whole inside the panel. +func CheckConfigCertFiles(raw []byte) error { + var decoded any + if len(raw) == 0 || json.Unmarshal(raw, &decoded) != nil { + return nil + } + return checkCertFileKeys(decoded) +} + +func checkCertFileKeys(v any) error { + switch v := v.(type) { + case map[string]any: + for key, child := range v { + if path, ok := child.(string); ok && path != "" && (strings.EqualFold(key, "certificateFile") || strings.EqualFold(key, "keyFile")) { + if !filepath.IsAbs(path) { + path = platform.GetCertLocation(path) + } + if err := CheckCertFile(path); err != nil { + return err + } + } + if err := checkCertFileKeys(child); err != nil { + return err + } + } + case []any: + for _, child := range v { + if err := checkCertFileKeys(child); err != nil { + return err + } + } + } + return nil +} + +// CheckCertFile refuses a path that exists but is a device, FIFO or oversized file; a +// missing one is left to the reader, whose open fails at once. +func CheckCertFile(path string) error { + if path == "" { + return nil + } + info, err := os.Stat(path) + if err != nil { + return nil + } + // procfs nodes such as /proc/kmsg report a regular file of size 0. + if !info.Mode().IsRegular() || info.Size() == 0 || info.Size() > maxCertFileBytes { + return common.NewErrorf("certificate file %q must be a regular file of at most %d bytes", path, maxCertFileBytes) + } + return nil +} + +// checkUntrustedBuildInput runs before every in-process conf.Build(): inbounds arrive from +// node pushes and outbounds from third-party subscriptions. +func checkUntrustedBuildInput(stream *conf.StreamConfig, raw []byte) error { + if err := checkStreamBuildCost(stream); err != nil { + return err + } + return CheckConfigCertFiles(raw) +} diff --git a/internal/xray/outbound_validation_test.go b/internal/xray/outbound_validation_test.go index 34d2717e7..d882355c8 100644 --- a/internal/xray/outbound_validation_test.go +++ b/internal/xray/outbound_validation_test.go @@ -150,3 +150,37 @@ func TestValidateOutboundConfig_RejectsLinksTheHandlerConstructorRefuses(t *test }) } } + +// conf.Build() runs inside the panel; these values make it allocate or loop without +// bound, so they must be refused before it, whoever supplied the outbound. +func TestValidateOutboundConfig_RefusesWhatTheLoaderWouldExpandWithoutBound(t *testing.T) { + big := filepath.Join(t.TempDir(), "big.pem") + if err := os.WriteFile(big, make([]byte, maxCertFileBytes+1), 0o600); err != nil { + t.Fatalf("write cert: %v", err) + } + vless := func(stream string) string { + return `{"protocol": "vless", "settings": {"address": "1.2.3.4", "port": 443, "id": "b831381d-6324-4d53-ad4f-8cda48b30811", "encryption": "none"}, "streamSettings": ` + stream + `}` + } + hop := `{"type": "udphop", "settings": {"mode": "intervalRemote", "remotePorts": "0-65535"}}` + masks := strings.TrimSuffix(strings.Repeat(`{"type": "noise"},`, maxStreamMasks+1), ",") + for name, tc := range map[string]struct{ outbound, wantErr string }{ + "xhttp sessionIDLength in extra": {vless(`{"network": "xhttp", "security": "tls", "xhttpSettings": {"extra": {"sessionIDTable": "abcdefgh", "sessionIDLength": "2147483647"}}}`), "sessionIDLength"}, + "nested download sessionIDLength": {vless(`{"network": "xhttp", "security": "tls", "xhttpSettings": {"downloadSettings": {"network": "xhttp", "xhttpSettings": {"sessionIDTable": "ab", "sessionIDLength": "64-100000"}}}}`), "sessionIDLength"}, + "udphop ports across masks": {vless(`{"network": "tcp", "security": "tls", "finalmask": {"udp": [` + hop + `,` + hop + `]}}`), "udphop"}, + "udphop bare port": {vless(`{"network": "tcp", "security": "tls", "finalmask": {"UDP": [{"type": "UDPHOP", "settings": {"mode": "intervalRemote", "remotePorts": 4294967295}}]}}`), "udphop"}, + "too many masks": {vless(`{"network": "tcp", "security": "tls", "finalmask": {"tcp": [` + masks + `]}}`), "masks"}, + "certificate is a directory": {vless(`{"network": "tcp", "security": "tls", "tlsSettings": {"certificates": [{"CertificateFile": "` + filepath.ToSlash(t.TempDir()) + `"}]}}`), "regular file"}, + "oversized key file": {vless(`{"network": "tcp", "security": "tls", "tlsSettings": {"certificates": [{"keyFile": "` + filepath.ToSlash(big) + `"}]}}`), "regular file"}, + } { + if err := ValidateOutboundConfig([]byte(tc.outbound)); err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Errorf("%s: ValidateOutboundConfig = %v, want an error naming %q", name, err, tc.wantErr) + } + } +} + +func TestCountXMCMasksFollowsTheStreamsTheCoreBuilds(t *testing.T) { + stream := `{"finalmask": {"tcp": [{"type": "XMC"}, {"type": "noise"}]}, "xhttpSettings": {"extra": {"downloadSettings": {"finalmask": {"tcp": [{"type": "xmc"}]}}}}}` + if got := CountXMCMasks([]byte(stream)); got != 2 { + t.Fatalf("CountXMCMasks = %d, want 2 (one top-level, one in the download stream inside extra)", got) + } +} From 3cdb7e2b49e153d06feaccbae00793ddacb0be33 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 31 Oct 2026 08:46:13 +0000 Subject: [PATCH 007/125] fix(traffic): keep client accounting independent of outbound counters The traffic poll read every Xray counter in one QueryStats reply under gRPC's default 4 MiB receive limit; past it the whole poll failed and no client was counted, so quotas stopped being enforced. Outbound counters carry tags derived from third-party link remarks, so one long remark was enough. Poll the user, inbound and outbound families separately with a 64 MiB limit, keep counting the families that answer, and cap a subscription link's slug at 64 runes so the tag stays small to begin with. --- internal/util/link/outbound.go | 6 ++ internal/util/link/outbound_test.go | 4 ++ internal/xray/api.go | 86 ++++++++++++++++++++--------- internal/xray/api_traffic_test.go | 59 ++++++++++++++++++-- 4 files changed, 123 insertions(+), 32 deletions(-) diff --git a/internal/util/link/outbound.go b/internal/util/link/outbound.go index aa6258bff..98c924fb8 100644 --- a/internal/util/link/outbound.go +++ b/internal/util/link/outbound.go @@ -1255,6 +1255,8 @@ func base64DecodeFlexible(s string) (string, error) { // replacing every other run of characters with a single dash. var slugRe = regexp.MustCompile(`[^\p{L}\p{N}]+`) +const maxSlugRunes = 64 + func SlugRemark(remark string) string { s := strings.ToLower(strings.TrimSpace(remark)) s = slugRe.ReplaceAllString(s, "-") @@ -1266,6 +1268,10 @@ func SlugRemark(remark string) string { for strings.Contains(s, "--") { s = strings.ReplaceAll(s, "--", "-") } + // The tag names Xray counters, a traffic row and the config; a link author picks the remark. + if r := []rune(s); len(r) > maxSlugRunes { + s = strings.TrimRight(string(r[:maxSlugRunes]), "-") + } return s } diff --git a/internal/util/link/outbound_test.go b/internal/util/link/outbound_test.go index 9de81beb4..d93fa943b 100644 --- a/internal/util/link/outbound_test.go +++ b/internal/util/link/outbound_test.go @@ -533,6 +533,10 @@ func TestSlugAndSuggest(t *testing.T) { if got := SuggestTag("ru-", "Сервер 2", 0); got != "ru-сервер-2" { t.Errorf("unicode suggest tag got %q", got) } + // The tag names Xray counters and an outbound_traffics row; a provider picks the remark. + if got := SlugRemark(strings.Repeat("Ab ", 1<<20)); got != strings.Repeat("ab-", 21)+"a" { + t.Errorf("megabyte remark slug = %.80q (%d bytes), want the first 64 runes", got, len(got)) + } } // The obfs-local plugin the panel exports carries the only description of diff --git a/internal/xray/api.go b/internal/xray/api.go index 2a38b137e..a8d00abc5 100644 --- a/internal/xray/api.go +++ b/internal/xray/api.go @@ -7,6 +7,7 @@ import ( "context" "encoding/base64" "encoding/json" + "errors" "fmt" "math" "net" @@ -54,6 +55,14 @@ var ( clientTrafficRegex = regexp.MustCompile(`user>>>([^>]+)>>>traffic>>>(downlink|uplink)`) ) +// statsFamilies are polled one query each: outbound counters are named after third-party +// subscription tags, and their reply must not take the clients' counters down with it. +var statsFamilies = []string{"user>>>", "inbound>>>", "outbound>>>"} + +// maxStatsReplyBytes replaces gRPC's 4 MiB receive default, which a stats reply of about +// 45k clients reaches alone; the peer is the panel's own core on loopback. +const maxStatsReplyBytes = 64 << 20 + // XrayAPI is a gRPC client for managing Xray core configuration, inbounds, outbounds, and statistics. type XrayAPI struct { HandlerServiceClient *command.HandlerServiceClient @@ -62,6 +71,7 @@ type XrayAPI struct { grpcClient *grpc.ClientConn isConnected bool StatsLastValues map[string]int64 + statsBaselined map[string]bool } func getRequiredUserString(user map[string]any, key string) (string, error) { @@ -99,7 +109,10 @@ func (x *XrayAPI) Init(apiPort int) error { } addr := fmt.Sprintf("127.0.0.1:%d", apiPort) - conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials())) + conn, err := grpc.NewClient(addr, + grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(maxStatsReplyBytes)), + ) if err != nil { return fmt.Errorf("failed to connect to Xray API: %w", err) } @@ -108,6 +121,7 @@ func (x *XrayAPI) Init(apiPort int) error { x.isConnected = true if x.StatsLastValues == nil { x.StatsLastValues = make(map[string]int64) + x.statsBaselined = make(map[string]bool, len(statsFamilies)) } hsClient := command.NewHandlerServiceClient(conn) @@ -975,41 +989,61 @@ func (x *XrayAPI) GetTraffic() ([]*Traffic, []*ClientTraffic, error) { return nil, nil, common.NewError("xray StatusServiceClient is not initialized") } - resp, err := (*x.StatsServiceClient).QueryStats(ctx, &statsService.QueryStatsRequest{Reset_: false}) - if err != nil { + tagTrafficMap := make(map[string]*Traffic) + emailTrafficMap := make(map[string]*ClientTraffic) + if x.statsBaselined == nil { + x.statsBaselined = make(map[string]bool, len(statsFamilies)) + } + + var live []*statsService.Stat + var failed []error + for _, family := range statsFamilies { + resp, err := (*x.StatsServiceClient).QueryStats(ctx, &statsService.QueryStatsRequest{Pattern: family, Reset_: false}) + if err != nil { + failed = append(failed, fmt.Errorf("%s: %w", family, err)) + continue + } + baselinePass := !x.statsBaselined[family] + x.statsBaselined[family] = true + for _, stat := range resp.GetStat() { + // The core matches Pattern anywhere in the name; the prefix keeps it to one family. + if !strings.HasPrefix(stat.Name, family) { + continue + } + live = append(live, stat) + lastValue, ok := x.StatsLastValues[stat.Name] + x.StatsLastValues[stat.Name] = stat.Value + if baselinePass { + continue + } + if !ok || stat.Value < lastValue { + lastValue = 0 + } + value := stat.Value - lastValue + if matches := trafficRegex.FindStringSubmatch(stat.Name); len(matches) == 4 { + processTraffic(matches, value, tagTrafficMap) + } else if matches := clientTrafficRegex.FindStringSubmatch(stat.Name); len(matches) == 3 { + processClientTraffic(matches, value, emailTrafficMap) + } + } + } + if len(failed) == len(statsFamilies) { + err := errors.Join(failed...) logger.Debug("Failed to query Xray stats:", err) return nil, nil, err } - - tagTrafficMap := make(map[string]*Traffic) - emailTrafficMap := make(map[string]*ClientTraffic) - - baselinePass := len(x.StatsLastValues) == 0 - - for _, stat := range resp.GetStat() { - lastValue, ok := x.StatsLastValues[stat.Name] - x.StatsLastValues[stat.Name] = stat.Value - if baselinePass { - continue - } - if !ok || stat.Value < lastValue { - lastValue = 0 - } - value := stat.Value - lastValue - if matches := trafficRegex.FindStringSubmatch(stat.Name); len(matches) == 4 { - processTraffic(matches, value, tagTrafficMap) - } else if matches := clientTrafficRegex.FindStringSubmatch(stat.Name); len(matches) == 3 { - processClientTraffic(matches, value, emailTrafficMap) - } + for _, err := range failed { + logger.Warning("Failed to query Xray stats, counting the other families:", err) } // Drop delta baselines for stats that no longer exist (deleted inbounds or // clients), which otherwise linger until the next Xray restart. Only rebuild // when the map has drifted past 2x the live set, so the steady-state hot path // stays allocation-free. - if n := len(resp.GetStat()); n > 0 && len(x.StatsLastValues) > 2*n { + // A family that failed is not gone, so its baselines stay. + if n := len(live); n > 0 && len(failed) == 0 && len(x.StatsLastValues) > 2*n { pruned := make(map[string]int64, n) - for _, stat := range resp.GetStat() { + for _, stat := range live { pruned[stat.Name] = x.StatsLastValues[stat.Name] } x.StatsLastValues = pruned diff --git a/internal/xray/api_traffic_test.go b/internal/xray/api_traffic_test.go index 8682356be..937ebf316 100644 --- a/internal/xray/api_traffic_test.go +++ b/internal/xray/api_traffic_test.go @@ -3,10 +3,13 @@ package xray import ( "context" "net" + "strings" "testing" statsService "github.com/xtls/xray-core/app/stats/command" "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) // fakeStatsServer serves a scripted sequence of QueryStats responses, one per @@ -14,16 +17,28 @@ import ( type fakeStatsServer struct { statsService.UnimplementedStatsServiceServer rounds [][]*statsService.Stat - calls int + calls map[string]int + fail map[string]error } -func (f *fakeStatsServer) QueryStats(context.Context, *statsService.QueryStatsRequest) (*statsService.QueryStatsResponse, error) { - round := f.calls - f.calls++ +// QueryStats answers each pattern from its own round counter and, like the core, matches +// the pattern anywhere in the name. +func (f *fakeStatsServer) QueryStats(_ context.Context, req *statsService.QueryStatsRequest) (*statsService.QueryStatsResponse, error) { + if err := f.fail[req.Pattern]; err != nil { + return nil, err + } + round := f.calls[req.Pattern] + f.calls[req.Pattern]++ if round >= len(f.rounds) { round = len(f.rounds) - 1 } - return &statsService.QueryStatsResponse{Stat: f.rounds[round]}, nil + var matched []*statsService.Stat + for _, s := range f.rounds[round] { + if strings.Contains(s.Name, req.Pattern) { + matched = append(matched, s) + } + } + return &statsService.QueryStatsResponse{Stat: matched}, nil } func stat(name string, value int64) *statsService.Stat { @@ -34,12 +49,18 @@ func stat(name string, value int64) *statsService.Stat { // to it. func startFakeStats(t *testing.T, rounds [][]*statsService.Stat) *XrayAPI { t.Helper() + return startFakeStatsServer(t, &fakeStatsServer{rounds: rounds}) +} + +func startFakeStatsServer(t *testing.T, fake *fakeStatsServer) *XrayAPI { + t.Helper() + fake.calls = map[string]int{} lis, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } srv := grpc.NewServer() - statsService.RegisterStatsServiceServer(srv, &fakeStatsServer{rounds: rounds}) + statsService.RegisterStatsServiceServer(srv, fake) go func() { _ = srv.Serve(lis) }() t.Cleanup(srv.Stop) @@ -190,3 +211,29 @@ func TestGetTrafficSkipsAPIInboundAndPrunes(t *testing.T) { t.Fatal("baselines for stats that no longer exist were not pruned") } } + +// Outbound counters are named after subscription tags a third party chooses; however +// large that family's reply grows, the clients' counters must still be counted. +func TestGetTrafficCountsClientsPastAnOversizedOutboundFamily(t *testing.T) { + huge := stat("outbound>>>"+strings.Repeat("x", 5<<20)+">>>traffic>>>uplink", 1) + rounds := [][]*statsService.Stat{ + {stat("user>>>alice>>>traffic>>>uplink", 100), huge}, + {stat("user>>>alice>>>traffic>>>uplink", 400), huge}, + } + for name, fake := range map[string]*fakeStatsServer{ + "reply over gRPC's default limit": {rounds: rounds}, + "family that fails": {rounds: rounds, fail: map[string]error{"outbound>>>": status.Error(codes.ResourceExhausted, "too large")}}, + } { + api := startFakeStatsServer(t, fake) + if _, _, err := api.GetTraffic(); err != nil { + t.Fatalf("%s: GetTraffic (baseline): %v", name, err) + } + _, clients, err := api.GetTraffic() + if err != nil { + t.Fatalf("%s: GetTraffic: %v", name, err) + } + if alice := clientTrafficByEmail(t, clients)["alice"]; alice == nil || alice.Up != 300 { + t.Fatalf("%s: alice = %+v, want 300 bytes up", name, alice) + } + } +} From 534afe13abd4960c396d1755defb84ed56fd2306 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 31 Oct 2026 08:53:27 +0000 Subject: [PATCH 008/125] fix(sub): treat external subscription providers as untrusted input A client's external subscription is fetched from a third party on every subscriber refresh, and the fetcher trusted it more than its siblings: - it dialled any address, redirects included; it now goes through the SSRF guard like the outbound subscription and remote routing fetchers; - it forwarded any line containing "://"; it now keeps only server share links, so app deeplinks and plain URLs never reach the subscriber's app; - it bounded the body but not the link count (now 1000) or the headers (now 64 KiB), and stored the raw error, URL included, on every row; the stored error is now host-only and at most 1 KiB. The link parser also refuses links that nest JSON past 16 levels, since the JSON subscription pretty-prints it at quadratic cost, and external configs are marshalled compactly until the final, single indent pass. --- internal/sub/external_config.go | 3 +- internal/sub/external_config_test.go | 13 ++++ internal/sub/external_hwid_test.go | 1 + internal/sub/external_subscription.go | 46 ++++++++--- internal/sub/external_subscription_test.go | 78 +++++++++++++++++++ internal/sub/json_service.go | 2 +- internal/util/common/format.go | 21 ++++- internal/util/link/outbound.go | 28 +++++++ internal/util/link/outbound_test.go | 14 ++++ internal/web/service/outbound_subscription.go | 18 ++--- 10 files changed, 203 insertions(+), 21 deletions(-) diff --git a/internal/sub/external_config.go b/internal/sub/external_config.go index ed60be12f..165d5a083 100644 --- a/internal/sub/external_config.go +++ b/internal/sub/external_config.go @@ -203,7 +203,8 @@ func parsedExternalOutbound(rawLink string) json_util.RawMessage { return nil } ob["tag"] = "proxy" - b, err := json.MarshalIndent(ob, "", " ") + // Compact: the response is indented once at the end, and each extra pass costs per level. + b, err := json.Marshal(ob) if err != nil { return nil } diff --git a/internal/sub/external_config_test.go b/internal/sub/external_config_test.go index e41cde482..c3f8d6321 100644 --- a/internal/sub/external_config_test.go +++ b/internal/sub/external_config_test.go @@ -3,6 +3,7 @@ package sub import ( "encoding/base64" "net/url" + "slices" "strings" "testing" "time" @@ -176,3 +177,15 @@ func TestClashProxyFromExternalTrojanReality(t *testing.T) { t.Fatalf("expected reality→tls true, got %v", proxy["tls"]) } } + +// Only server links pass: an app deeplink or a plain URL from the provider would reach the +// subscriber's app as a directive (a routing profile, an import) the operator never chose. +func TestDecodeSubscriptionBodyKeepsOnlyServerShareLinks(t *testing.T) { + body := []byte("VLESS://u@a:1#a\nincy://autorouting/onadd/https://p.example/r.json\nhttps://p.example/page\n" + + "happ://routing/add/x\nvpn://payload\ntuic://u:p@a:1#t\nhy2://p@a:1#h\nnot a link://x\n") + got := decodeSubscriptionBody(body) + want := []string{"VLESS://u@a:1#a", "vpn://payload", "tuic://u:p@a:1#t", "hy2://p@a:1#h"} + if !slices.Equal(got, want) { + t.Fatalf("decoded links = %q, want %q", got, want) + } +} diff --git a/internal/sub/external_hwid_test.go b/internal/sub/external_hwid_test.go index ff39b1a95..a19f89285 100644 --- a/internal/sub/external_hwid_test.go +++ b/internal/sub/external_hwid_test.go @@ -13,6 +13,7 @@ import ( // #6559: the Master panel must send a stable X-HWID when fetching external // subscriptions, otherwise an HWID-limited donor answers 404. func TestFetchSendsStableHwid(t *testing.T) { + allowLoopbackProvider(t) dbtest.InitDB(t, filepath.Join(t.TempDir(), "x-ui.db")) var gotHwid string diff --git a/internal/sub/external_subscription.go b/internal/sub/external_subscription.go index f775fb6b2..adf7f4a21 100644 --- a/internal/sub/external_subscription.go +++ b/internal/sub/external_subscription.go @@ -5,6 +5,7 @@ import ( "encoding/base64" "io" "net/http" + "slices" "strings" "sync" "time" @@ -12,6 +13,8 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/logger" + "github.com/mhsanaei/3x-ui/v3/internal/util/common" + "github.com/mhsanaei/3x-ui/v3/internal/util/netsafe" "github.com/mhsanaei/3x-ui/v3/internal/web/service" ) @@ -21,10 +24,21 @@ import ( const ( subscriptionCacheTTL = 5 * time.Minute subscriptionMaxBytes = 2 << 20 // 2 MiB + subscriptionMaxLinks = 1000 subscriptionCacheCapacity = 256 ) -var subscriptionHTTPClient = &http.Client{Timeout: 6 * time.Second} +// The provider picks every redirect target, so each hop is resolved, checked and dialed by +// the SSRF guard; its headers are bounded like its body. +var subscriptionHTTPClient = &http.Client{ + Timeout: 6 * time.Second, + Transport: &http.Transport{ + DialContext: netsafe.SSRFGuardedDialContext, + TLSHandshakeTimeout: 5 * time.Second, + MaxResponseHeaderBytes: 64 << 10, + IdleConnTimeout: 90 * time.Second, + }, +} type subscriptionCacheEntry struct { links []string @@ -129,7 +143,7 @@ func recordExternalSubscriptionFetch(rawURL string, fetchErr error) { } lastFetchError := "" if fetchErr != nil { - lastFetchError = fetchErr.Error() + lastFetchError = common.TruncateStatusText(service.WithoutRequestURL(fetchErr).Error()) } if err := database.GetDB(). Model(&model.ClientExternalLink{}). @@ -138,14 +152,14 @@ func recordExternalSubscriptionFetch(rawURL string, fetchErr error) { "last_fetch_at": time.Now().UnixMilli(), "last_fetch_error": lastFetchError, }).Error; err != nil { - logger.Warningf("sub: recording fetch status for external subscription %q: %v", rawURL, err) + logger.Warningf("sub: recording fetch status for an external subscription: %v", err) } } func doFetchSubscriptionLinks(rawURL string) ([]string, error) { req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, rawURL, nil) if err != nil { - return nil, err + return nil, service.WithoutRequestURL(err) } // Some providers gate the link body on a known client User-Agent. req.Header.Set("User-Agent", externalSubUserAgent()) @@ -155,7 +169,7 @@ func doFetchSubscriptionLinks(rawURL string) ([]string, error) { } resp, err := subscriptionHTTPClient.Do(req) if err != nil { - return nil, err + return nil, service.WithoutRequestURL(err) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { @@ -168,12 +182,17 @@ func doFetchSubscriptionLinks(rawURL string) ([]string, error) { if len(body) > subscriptionMaxBytes { return nil, errSubscriptionBodyTooLarge } - return decodeSubscriptionBody(body), nil + links := decodeSubscriptionBody(body) + if len(links) > subscriptionMaxLinks { + return nil, errSubscriptionTooManyLinks + } + return links, nil } var ( errBadStatus = &subError{"non-2xx subscription response"} errSubscriptionBodyTooLarge = &subError{"subscription response body exceeds size limit"} + errSubscriptionTooManyLinks = &subError{"subscription lists more than 1000 share links"} ) // externalSubUserAgent returns the panel setting for external subscription @@ -193,8 +212,16 @@ type subError struct{ msg string } func (e *subError) Error() string { return e.msg } -// decodeSubscriptionBody handles the common base64-encoded newline list as well -// as a plain-text body, returning only the lines that look like share links. +// serverShareLinkSchemes are the server links a raw subscription carries on: what the link +// parser reads, what the panel itself writes, and common third-party server schemes. +var serverShareLinkSchemes = []string{ + "vmess", "vless", "trojan", "ss", "ssr", "hysteria", "hysteria2", "hy2", "tuic", "wireguard", "wg", + "vpn", "socks", "socks5", "anytls", "juicity", "naive+https", "tg", +} + +// decodeSubscriptionBody handles the common base64-encoded newline list as well as a +// plain-text body, keeping only server share links: app deeplinks and plain URLs would +// reach the subscriber's app as directives the operator never chose. func decodeSubscriptionBody(body []byte) []string { text := strings.TrimSpace(string(body)) if text == "" { @@ -210,7 +237,8 @@ func decodeSubscriptionBody(body []byte) []string { if ln == "" || strings.HasPrefix(ln, "#") { continue } - if strings.Contains(ln, "://") { + scheme, _, found := strings.Cut(ln, "://") + if found && slices.ContainsFunc(serverShareLinkSchemes, func(s string) bool { return strings.EqualFold(s, scheme) }) { out = append(out, ln) } } diff --git a/internal/sub/external_subscription_test.go b/internal/sub/external_subscription_test.go index 1b0508d1e..e1565a37d 100644 --- a/internal/sub/external_subscription_test.go +++ b/internal/sub/external_subscription_test.go @@ -2,6 +2,7 @@ package sub import ( "errors" + "net" "net/http" "net/http/httptest" "path/filepath" @@ -17,6 +18,17 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/database/model" ) +// allowLoopbackProvider lets a test serve its provider from httptest on 127.0.0.1, which +// the guarded production dialer refuses; every other transport limit stays. +func allowLoopbackProvider(t *testing.T) { + t.Helper() + previous := subscriptionHTTPClient + transport := previous.Transport.(*http.Transport).Clone() + transport.DialContext = (&net.Dialer{}).DialContext + subscriptionHTTPClient = &http.Client{Timeout: previous.Timeout, Transport: transport} + t.Cleanup(func() { subscriptionHTTPClient = previous }) +} + func resetSubscriptionCache(t *testing.T) { t.Helper() subscriptionCache.Lock() @@ -34,6 +46,7 @@ func resetSubscriptionCache(t *testing.T) { } func TestFetchSubscriptionLinksSharesConcurrentRefresh(t *testing.T) { + allowLoopbackProvider(t) resetSubscriptionCache(t) var requests atomic.Int32 release := make(chan struct{}) @@ -69,6 +82,7 @@ func TestFetchSubscriptionLinksSharesConcurrentRefresh(t *testing.T) { } func TestFetchSubscriptionLinksBoundsCacheSize(t *testing.T) { + allowLoopbackProvider(t) resetSubscriptionCache(t) var requests atomic.Int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -96,6 +110,7 @@ func TestFetchSubscriptionLinksBoundsCacheSize(t *testing.T) { } func TestFetchSubscriptionLinksSharesStaleResultAfterRefreshFailure(t *testing.T) { + allowLoopbackProvider(t) resetSubscriptionCache(t) stale := []string{"vless://stale@example.com:443"} release := make(chan struct{}) @@ -150,6 +165,7 @@ func TestFetchSubscriptionLinksSharesStaleResultAfterRefreshFailure(t *testing.T } func TestDoFetchSubscriptionLinks_RejectsOversizedBody(t *testing.T) { + allowLoopbackProvider(t) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(strings.Repeat("a", subscriptionMaxBytes+1))) })) @@ -165,6 +181,7 @@ func TestDoFetchSubscriptionLinks_RejectsOversizedBody(t *testing.T) { } func TestDoFetchSubscriptionLinks_AcceptsBodyAtLimit(t *testing.T) { + allowLoopbackProvider(t) link := "vless://example" body := link + "\n" + strings.Repeat("#", subscriptionMaxBytes-len(link)-1) if len(body) != subscriptionMaxBytes { @@ -185,6 +202,7 @@ func TestDoFetchSubscriptionLinks_AcceptsBodyAtLimit(t *testing.T) { } func TestRecordExternalSubscriptionFetchStampsEveryRowForTheURL(t *testing.T) { + allowLoopbackProvider(t) initMutDB(t) resetSubscriptionCache(t) db := database.GetDB() @@ -302,6 +320,7 @@ func TestExpandEntryCacheHitWritesNothing(t *testing.T) { } func TestFetchUsesConfiguredExternalSubUserAgent(t *testing.T) { + allowLoopbackProvider(t) resetSubscriptionCache(t) dbtest.InitDB(t, filepath.Join(t.TempDir(), "ua.db")) @@ -330,6 +349,7 @@ func TestFetchUsesConfiguredExternalSubUserAgent(t *testing.T) { } func TestFetchFallsBackToDefaultExternalSubUserAgent(t *testing.T) { + allowLoopbackProvider(t) resetSubscriptionCache(t) dbtest.InitDB(t, filepath.Join(t.TempDir(), "ua-default.db")) @@ -348,3 +368,61 @@ func TestFetchFallsBackToDefaultExternalSubUserAgent(t *testing.T) { t.Fatalf("User-Agent = %q, want default v2rayNG/1.8.5", gotUA) } } + +// The provider chooses where the fetch goes, every redirect included, so the dial itself +// must refuse this host and its network. +func TestDoFetchSubscriptionLinksRefusesPrivateTargets(t *testing.T) { + var hits atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + _, _ = w.Write([]byte("vless://uuid@example.com:443#internal")) + })) + defer srv.Close() + if links, err := doFetchSubscriptionLinks(srv.URL + "/admin"); err == nil || links != nil { + t.Fatalf("fetch of a loopback target = %v, %v; want it refused", links, err) + } + if hits.Load() != 0 { + t.Fatalf("the loopback target received %d request(s)", hits.Load()) + } +} + +// Fetch errors are stored on every row holding the URL and shown in the panel: they may +// name the host, never the URL (a credential), and a provider must not size them. +func TestExternalSubscriptionFetchErrorIsBoundedAndKeepsTheURLSecret(t *testing.T) { + initMutDB(t) + resetSubscriptionCache(t) + allowLoopbackProvider(t) + const token = "s3cr3t-token" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Junk", strings.Repeat("j", 128<<10)) + _, _ = w.Write([]byte("vless://uuid@example.com:443#Node")) + })) + defer srv.Close() + rawURL := srv.URL + "/sub/" + token + if err := database.GetDB().Create(&model.ClientExternalLink{Kind: model.ExternalLinkKindSubscription, Value: rawURL}).Error; err != nil { + t.Fatalf("seed link: %v", err) + } + result := fetchSubscriptionLinks(rawURL) + if result.err == nil { + t.Fatal("a provider with 128 KiB of headers was accepted") + } + recordExternalSubscriptionFetch(rawURL, result.err) + var row model.ClientExternalLink + if err := database.GetDB().Where("value = ?", rawURL).First(&row).Error; err != nil { + t.Fatalf("load link: %v", err) + } + if row.LastFetchError == "" || strings.Contains(row.LastFetchError, token) || len(row.LastFetchError) > 1100 { + t.Fatalf("last_fetch_error = %.200q (%d bytes), want a short error without the URL", row.LastFetchError, len(row.LastFetchError)) + } +} + +func TestDoFetchSubscriptionLinksRefusesTooManyLinks(t *testing.T) { + allowLoopbackProvider(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(strings.Repeat("trojan://p@a:1\n", subscriptionMaxLinks+1))) + })) + defer srv.Close() + if links, err := doFetchSubscriptionLinks(srv.URL); !errors.Is(err, errSubscriptionTooManyLinks) || links != nil { + t.Fatalf("fetch = %d links, %v; want errSubscriptionTooManyLinks", len(links), err) + } +} diff --git a/internal/sub/json_service.go b/internal/sub/json_service.go index 7ea81c84a..c17edb2fa 100644 --- a/internal/sub/json_service.go +++ b/internal/sub/json_service.go @@ -268,7 +268,7 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo maps.Copy(newConfigJson, s.bakedTemplate()) newConfigJson["outbounds"] = newOutbounds newConfigJson["remarks"] = remark - newConfig, _ := json.MarshalIndent(newConfigJson, "", " ") + newConfig, _ := json.Marshal(newConfigJson) configArray = append(configArray, newConfig) } } diff --git a/internal/util/common/format.go b/internal/util/common/format.go index f75c90bf8..b290c60b6 100644 --- a/internal/util/common/format.go +++ b/internal/util/common/format.go @@ -1,6 +1,9 @@ package common -import "strconv" +import ( + "strconv" + "unicode/utf8" +) // FormatTraffic formats traffic bytes into human-readable units (B, KB, MB, GB, TB, PB). func FormatTraffic(trafficBytes int64) string { @@ -14,3 +17,19 @@ func FormatTraffic(trafficBytes int64) string { } return strconv.FormatFloat(size, 'f', 2, 64) + units[unitIndex] } + +// maxStatusTextBytes bounds an error text a third party shapes before it is stored as a +// row's status; legitimate messages are a line or two. +const maxStatusTextBytes = 1024 + +// TruncateStatusText cuts s to maxStatusTextBytes on a UTF-8 boundary. +func TruncateStatusText(s string) string { + if len(s) <= maxStatusTextBytes { + return s + } + cut := maxStatusTextBytes + for cut > 0 && !utf8.RuneStart(s[cut]) { + cut-- + } + return s[:cut] + "... (truncated)" +} diff --git a/internal/util/link/outbound.go b/internal/util/link/outbound.go index 98c924fb8..cda31b192 100644 --- a/internal/util/link/outbound.go +++ b/internal/util/link/outbound.go @@ -131,9 +131,37 @@ func ParseLink(link string) (*ParseResult, error) { return nil, err } dropHostLocalKeys(res.Outbound) + if !nestsWithin(map[string]any(res.Outbound), maxOutboundDepth) { + return nil, fmt.Errorf("link nests JSON deeper than %d levels", maxOutboundDepth) + } return res, nil } +// maxOutboundDepth bounds the JSON a link can nest into its outbound (the deepest real shape, +// an xhttp download stream's finalmask, is about 11): pretty-printing costs depth squared. +const maxOutboundDepth = 16 + +func nestsWithin(v any, depth int) bool { + if depth < 0 { + return false + } + switch v := v.(type) { + case map[string]any: + for _, child := range v { + if !nestsWithin(child, depth-1) { + return false + } + } + case []any: + for _, child := range v { + if !nestsWithin(child, depth-1) { + return false + } + } + } + return true +} + func parseLinkByScheme(link string) (*ParseResult, error) { switch { case strings.HasPrefix(link, "vmess://"): diff --git a/internal/util/link/outbound_test.go b/internal/util/link/outbound_test.go index d93fa943b..4bf101f12 100644 --- a/internal/util/link/outbound_test.go +++ b/internal/util/link/outbound_test.go @@ -661,3 +661,17 @@ func TestParseLinkDropsHostLocalKeys(t *testing.T) { t.Fatalf("remote-server settings were dropped too: %s", raw) } } + +// Pretty-printing costs the square of the nesting depth, and extra=, fm= and the vmess +// JSON copy provider JSON into the outbound; a link may not nest past real shapes. +func TestParseLinkRejectsDeeplyNestedJSON(t *testing.T) { + deep := strings.Repeat(`{"a":`, 40) + `1` + strings.Repeat(`}`, 40) + if _, err := ParseLink("vless://uuid@8.8.8.8:443?type=xhttp&security=tls&extra=" + url.QueryEscape(`{"xmux":`+deep+`}`) + "#n"); err == nil { + t.Fatal("a link nesting JSON 40 levels deep was accepted") + } + real := url.QueryEscape(`{"downloadSettings":{"address":"8.8.8.8","port":443,"network":"xhttp","xhttpSettings":{"headers":{"a":"b"}},` + + `"finalmask":{"udp":[{"type":"noise","settings":{"noise":[{"type":"rand","packet":"10-20"}]}}]}}}`) + if _, err := ParseLink("vless://uuid@8.8.8.8:443?type=xhttp&security=tls&extra=" + real + "#n"); err != nil { + t.Fatalf("a real download stream with a finalmask was refused: %v", err) + } +} diff --git a/internal/web/service/outbound_subscription.go b/internal/web/service/outbound_subscription.go index 99973ca1a..759847370 100644 --- a/internal/web/service/outbound_subscription.go +++ b/internal/web/service/outbound_subscription.go @@ -172,7 +172,7 @@ func (s *OutboundSubscriptionService) nextDefaultSubPrefix(excludeId int) (strin func (s *OutboundSubscriptionService) Create(remark, rawURL, tagPrefix, userAgent string, enabled bool, updateInterval int, allowPrivate, prepend, allowInsecure bool) (*model.OutboundSubscription, error) { cleanURL, err := SanitizePublicHTTPURL(rawURL, allowPrivate) if err != nil { - return nil, common.NewError("invalid subscription URL:", withoutRequestURL(err)) + return nil, common.NewError("invalid subscription URL:", WithoutRequestURL(err)) } if cleanURL == "" { return nil, common.NewError("subscription URL is required") @@ -224,7 +224,7 @@ func (s *OutboundSubscriptionService) Update(id int, remark, rawURL, tagPrefix, } cleanURL, err := SanitizePublicHTTPURL(rawURL, allowPrivate) if err != nil { - return common.NewError("invalid subscription URL:", withoutRequestURL(err)) + return common.NewError("invalid subscription URL:", WithoutRequestURL(err)) } if cleanURL == "" { return common.NewError("subscription URL is required") @@ -368,7 +368,7 @@ func (s *OutboundSubscriptionService) fetchAndStore(sub *model.OutboundSubscript "last_fetched_outbounds": string(obsJSON), "link_identities": fetched.identities, "last_updated": time.Now().Unix(), - "last_error": fetched.lastError, + "last_error": common.TruncateStatusText(fetched.lastError), }) if res.Error != nil { return nil, res.Error @@ -401,7 +401,7 @@ func (s *OutboundSubscriptionService) fetchOutbounds(sub *model.OutboundSubscrip // subscription was explicitly created with AllowPrivate. cleanURL, err := SanitizePublicHTTPURL(sub.Url, sub.AllowPrivate) if err != nil { - return nil, withoutRequestURL(err) + return nil, WithoutRequestURL(err) } if cleanURL == "" { return nil, common.NewError("subscription has no valid URL") @@ -429,7 +429,7 @@ func (s *OutboundSubscriptionService) fetchOutbounds(sub *model.OutboundSubscrip reqCtx := netsafe.ContextWithAllowPrivate(context.Background(), sub.AllowPrivate) req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, cleanURL, nil) if err != nil { - return nil, withoutRequestURL(err) + return nil, WithoutRequestURL(err) } userAgent := strings.TrimSpace(sub.UserAgent) if userAgent == "" { @@ -443,7 +443,7 @@ func (s *OutboundSubscriptionService) fetchOutbounds(sub *model.OutboundSubscrip resp, err := client.Do(req) if err != nil { - return nil, withoutRequestURL(err) + return nil, WithoutRequestURL(err) } defer resp.Body.Close() @@ -557,9 +557,9 @@ func spendXMCBudget(outbound any, budget *int) bool { return true } -// withoutRequestURL keeps only the host of the URL net/url and net/http quote in their +// WithoutRequestURL keeps only the host of the URL net/url and net/http quote in their // errors: a subscription URL is a credential, and these errors are logged and stored. -func withoutRequestURL(err error) error { +func WithoutRequestURL(err error) error { var urlErr *url.Error if !errors.As(err, &urlErr) { return err @@ -588,7 +588,7 @@ func forceUserspaceWireguard(outbound any) { } func (s *OutboundSubscriptionService) recordError(sub *model.OutboundSubscription, err error) { - sub.LastError = err.Error() + sub.LastError = common.TruncateStatusText(err.Error()) _ = database.GetDB().Model(sub).Update("last_error", sub.LastError).Error } From 8359a0f72e03c909251dabf2220fbcde52c07fd6 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 31 Oct 2026 08:56:24 +0000 Subject: [PATCH 009/125] fix(sub): keep every Clash scalar on its own line The YAML encoder writes multi-line strings as block scalars and gets CR and the Unicode line breaks (NEL, LS, PS) wrong inside them, so a remark, path or header name with such characters could continue as YAML outside its scalar. Strings with a non-printable rune or invalid UTF-8 are now written double-quoted with escapes, map keys included. --- internal/sub/clash_yaml.go | 49 +++++++++++++++++++++++++++++---- internal/sub/clash_yaml_test.go | 29 +++++++++++++++++++ 2 files changed, 73 insertions(+), 5 deletions(-) diff --git a/internal/sub/clash_yaml.go b/internal/sub/clash_yaml.go index 99b067d9b..cf411d499 100644 --- a/internal/sub/clash_yaml.go +++ b/internal/sub/clash_yaml.go @@ -3,7 +3,10 @@ package sub import ( "reflect" "regexp" + "strconv" "strings" + "unicode" + "unicode/utf8" "github.com/goccy/go-yaml" ) @@ -41,30 +44,66 @@ func yamlScalarIsAmbiguous(s string) bool { return yamlNonStringWords[s] || yamlNonStringNumber.MatchString(s) } +// yamlEscapedString is written double-quoted on one line: the encoder's block scalars +// mishandle CR and the Unicode line breaks, so the text could continue as YAML. +type yamlEscapedString string + +func (s yamlEscapedString) MarshalYAML() ([]byte, error) { + return []byte(strconv.Quote(string(s))), nil +} + +func yamlNeedsEscaping(s string) bool { + if !utf8.ValidString(s) { + return true + } + return strings.ContainsFunc(s, func(r rune) bool { return !unicode.IsPrint(r) }) +} + +func quoteYAMLString(s string) any { + switch { + case yamlNeedsEscaping(s): + return yamlEscapedString(s) + case yamlScalarIsAmbiguous(s): + return yamlQuotedString(s) + } + return s +} + func quoteAmbiguousYAMLScalars(v any) any { if v == nil { return nil } if s, ok := v.(string); ok { - if yamlScalarIsAmbiguous(s) { - return yamlQuotedString(s) - } - return s + return quoteYAMLString(s) } rv := reflect.ValueOf(v) switch rv.Kind() { case reflect.Map: out := make(map[string]any, rv.Len()) + escapedKeys := false iter := rv.MapRange() for iter.Next() { key, ok := iter.Key().Interface().(string) if !ok { return v } + escapedKeys = escapedKeys || yamlNeedsEscaping(key) out[key] = quoteAmbiguousYAMLScalars(iter.Value().Interface()) } - return out + if !escapedKeys { + return out + } + // Only marshaler-typed keys escape, and only a map[any]any key keeps its type. + keyed := make(map[any]any, len(out)) + for key, value := range out { + if yamlNeedsEscaping(key) { + keyed[yamlEscapedString(key)] = value + } else { + keyed[key] = value + } + } + return keyed case reflect.Slice, reflect.Array: if rv.Kind() == reflect.Slice && rv.Type().Elem().Kind() == reflect.Uint8 { return v diff --git a/internal/sub/clash_yaml_test.go b/internal/sub/clash_yaml_test.go index 710fc01f9..7a8850282 100644 --- a/internal/sub/clash_yaml_test.go +++ b/internal/sub/clash_yaml_test.go @@ -127,3 +127,32 @@ func TestQuotedScalarEscapesQuotes(t *testing.T) { t.Errorf("password round-tripped to %#v\n%s", got, out) } } + +// Node-reported paths, remarks and header names reach the profile; a line break the +// encoder writes bare would start a top-level key of the subscriber's Clash config. +func TestLineBreaksStayInsideTheirScalar(t *testing.T) { + for _, value := range []string{ + "/ws\r\n\r\nmode:\r direct", "/ws\n\n\n\n\n\n\rmode:\r direct", "a\nb\nc\u0085mode: direct", + "a\nb\nc
mode: direct", "a\nb\nc
mode: direct", "2026-01-01\n\n\rmode: direct", + } { + profile := map[string]any{"proxies": []any{map[string]any{ + "name": "n", + "ws-opts": map[string]any{"path": value, "headers": map[string]any{value: "v"}}, + }}} + out, err := marshalClashYAML(profile) + if err != nil { + t.Fatalf("marshal %q: %v", value, err) + } + if strings.ContainsAny(string(out), "\r\u0085

") || strings.Contains(string(out), "\nmode:") { + t.Fatalf("value %q escaped its scalar:\n%q", value, out) + } + var back map[string]any + if err := yaml.Unmarshal(out, &back); err != nil { + t.Fatalf("read back %q: %v\n%q", value, err, out) + } + opts := back["proxies"].([]any)[0].(map[string]any)["ws-opts"].(map[string]any) + if opts["path"] != value || opts["headers"].(map[string]any)[value] != "v" { + t.Fatalf("value %q did not round-trip:\n%q", value, out) + } + } +} From 661a16904551db2d7a97809773ecdc62605c71ed Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 31 Oct 2026 08:59:23 +0000 Subject: [PATCH 010/125] fix(sub): bound what a remote routing profile renders to Remote routing sources are fetched documents the panel re-renders on every subscription request. A Clash YAML document stays small through aliases, which decode to one shared value but are written out in full, so it now has to fit a 4 MiB written-size budget and 16 levels of nesting when it is accepted (and when a persisted copy is loaded). A JSON routing profile could repeat route groups, each copy repeating every site list; a group now counts once, since a repeat can never match. --- internal/sub/json_routing.go | 5 +++- internal/sub/json_routing_test.go | 12 ++++++++ internal/sub/remote_routing.go | 45 +++++++++++++++++++++++++++++ internal/sub/remote_routing_test.go | 30 +++++++++++++++++++ 4 files changed, 91 insertions(+), 1 deletion(-) diff --git a/internal/sub/json_routing.go b/internal/sub/json_routing.go index 738a9615b..58eb73411 100644 --- a/internal/sub/json_routing.go +++ b/internal/sub/json_routing.go @@ -210,7 +210,10 @@ func buildJsonRoutingSpec(object map[string]any) (jsonRoutingSpec, error) { for _, segment := range strings.Split(order, "-") { switch segment { case "block", "proxy", "direct": - spec.RouteOrder = append(spec.RouteOrder, segment) + // A repeat can never match (the first rule wins) and would copy every list again. + if !slices.Contains(spec.RouteOrder, segment) { + spec.RouteOrder = append(spec.RouteOrder, segment) + } } } return spec, nil diff --git a/internal/sub/json_routing_test.go b/internal/sub/json_routing_test.go index 89cdcb2dd..bfdce9c8c 100644 --- a/internal/sub/json_routing_test.go +++ b/internal/sub/json_routing_test.go @@ -94,6 +94,18 @@ func TestParseJsonRoutingSpecRouteOrderUnknownSegments(t *testing.T) { } } +// Every group copy repeats the profile's whole site lists in the rendered routing, and a +// later copy can never match, so a remote profile repeating groups must not multiply them. +func TestParseJsonRoutingSpecRouteOrderKeepsEachGroupOnce(t *testing.T) { + spec, _, err := parseJsonRoutingSpec(`{"RouteOrder":"` + strings.Repeat("proxy-direct-block-", 500) + `proxy"}`) + if err != nil { + t.Fatalf("parse: %v", err) + } + if strings.Join(spec.RouteOrder, ",") != "proxy,direct,block" { + t.Fatalf("RouteOrder = %d segments, want proxy,direct,block once each", len(spec.RouteOrder)) + } +} + func TestParseJsonRoutingSpecDeeplinks(t *testing.T) { payload := mustMarshal(t, fullRoutingPayload()) cases := []string{ diff --git a/internal/sub/remote_routing.go b/internal/sub/remote_routing.go index b455e0be2..1b15b7cc6 100644 --- a/internal/sub/remote_routing.go +++ b/internal/sub/remote_routing.go @@ -39,6 +39,11 @@ const ( remoteRoutingHappMaxBody = 16 << 10 // 16 KiB; Happ emits the result in a response header remoteRoutingHappMaxValue = 8 << 10 // normalized Routing header value remoteRoutingClashMaxBody = 2 << 20 // 2 MiB + + // What a document may marshal to, aliases copied out; the encoder re-indents a node's text + // once per enclosing level, hence the depth bound. + remoteRoutingClashMaxWritten = 4 << 20 + remoteRoutingClashMaxDepth = 16 ) // isHappPayloadKind reports whether the kind carries a happ-payload source: @@ -448,6 +453,9 @@ func normalizeClashRouting(body []byte) (string, map[string]any, error) { if len(document) == 0 { return "", nil, errors.New("Clash routing response must be a YAML map") } + if budget := remoteRoutingClashMaxWritten; !clashFitsWritten(document, 0, &budget) { + return "", nil, errors.New("Clash routing response is too large or too deeply nested once written out") + } hasSupportedKey := false for key := range document { if remoteClashKeyClasses[key] == remoteClashRouteGraph { @@ -471,6 +479,43 @@ func normalizeClashRouting(body []byte) (string, map[string]any, error) { return text, document, nil } +// clashFitsWritten charges what the encoder writes for v: every string per line at its +// indentation, every node its indentation. It stops as soon as the budget is spent. +func clashFitsWritten(v any, depth int, budget *int) bool { + if depth > remoteRoutingClashMaxDepth { + return false + } + indent := 2*depth + 2 + switch v := v.(type) { + case string: + *budget -= len(v) + (strings.Count(v, "\n")+1)*indent + case []byte: + // The encoder writes !!binary values as a list with one item per byte. + *budget -= len(v) * (indent + 6) + case map[string]any: + for key, child := range v { + if *budget -= len(key) + indent; *budget < 0 || !clashFitsWritten(child, depth+1, budget) { + return false + } + } + case map[any]any: + for key, child := range v { + if *budget -= indent; *budget < 0 || !clashFitsWritten(key, depth+1, budget) || !clashFitsWritten(child, depth+1, budget) { + return false + } + } + case []any: + for _, child := range v { + if *budget -= indent; *budget < 0 || !clashFitsWritten(child, depth+1, budget) { + return false + } + } + default: + *budget -= indent + 24 + } + return *budget >= 0 +} + func resolveIncyRoutingSource(raw string) (string, bool, error) { source, remote, err := common.ParseRemoteRoutingURL(raw) if err != nil || !remote { diff --git a/internal/sub/remote_routing_test.go b/internal/sub/remote_routing_test.go index a22ad7566..f993ff71f 100644 --- a/internal/sub/remote_routing_test.go +++ b/internal/sub/remote_routing_test.go @@ -3,6 +3,7 @@ package sub import ( "encoding/base64" "errors" + "fmt" "io" "net/http" "net/http/httptest" @@ -748,3 +749,32 @@ rules: t.Fatalf("requests=%d, want 1", requests.Load()) } } + +// Aliases decode to one shared value but the encoder writes every copy out, on each Clash +// request, so a small remote document must not be accepted if it marshals out of bounds. +func TestNormalizeClashRoutingRefusesWhatMarshalsOutOfBounds(t *testing.T) { + var laughs strings.Builder + laughs.WriteString("a0: &a0 [\"" + strings.Repeat("x", 64) + "\"]\n") + for i := 1; i <= 9; i++ { + fmt.Fprintf(&laughs, "a%d: &a%d [", i, i) + for j := range 10 { + if j > 0 { + laughs.WriteString(",") + } + fmt.Fprintf(&laughs, "*a%d", i-1) + } + laughs.WriteString("]\n") + } + bomb := laughs.String() + "rule-providers:\n p: {type: inline, behavior: domain, payload: *a9}\nrules:\n - RULE-SET,p,DIRECT\n - MATCH,DIRECT\n" + if _, _, err := normalizeClashRouting([]byte(bomb)); err == nil || !strings.Contains(err.Error(), "too large") { + t.Fatalf("alias bomb = %v, want it refused as too large", err) + } + deep := "rules:\n - MATCH,DIRECT\nrule-providers:\n p: {type: inline, behavior: domain, payload: " + strings.Repeat("[", 40) + strings.Repeat("]", 40) + "}\n" + if _, _, err := normalizeClashRouting([]byte(deep)); err == nil { + t.Fatal("a document nested 40 levels deep was accepted") + } + anchored := "x: &common {type: http, behavior: domain, interval: 86400}\nrule-providers:\n ads: {<<: *common, url: \"https://example.com/ads.yaml\"}\nrules:\n - RULE-SET,ads,REJECT\n - MATCH,DIRECT\n" + if _, _, err := normalizeClashRouting([]byte(anchored)); err != nil { + t.Fatalf("an ordinary anchored document was refused: %v", err) + } +} From 8e8541a5d84166f50c861db44fdb59011c6997ab Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 31 Oct 2026 09:04:23 +0000 Subject: [PATCH 011/125] fix(web): count login attempts per address and while they are checked The login limiter counted failures per (address, username) only after the check finished, so attempts sent together all passed before the first failure was recorded, and one address could own every record of the bounded table and push out the one holding its own failures. An admitted attempt now holds a slot until it reports back, and an address is blocked as a whole after five times the per-username limit spread over any names, which also caps the records one peer can own. --- internal/web/controller/login_limiter.go | 121 +++++++++++++----- internal/web/controller/login_limiter_test.go | 42 ++++++ 2 files changed, 130 insertions(+), 33 deletions(-) diff --git a/internal/web/controller/login_limiter.go b/internal/web/controller/login_limiter.go index 6c9edaed2..760f85d88 100644 --- a/internal/web/controller/login_limiter.go +++ b/internal/web/controller/login_limiter.go @@ -14,6 +14,9 @@ const ( // caller-supplied username, so an unauthenticated attacker rotating // usernames would otherwise grow the map without bound. loginLimitMaxRecords = 10000 + // Failures one address may spread over all usernames, as a multiple of the per-username + // limit, before the address is blocked as a whole: bounds the records one peer can own. + loginLimitIPFactor = 5 ) var defaultLoginLimiter = newLoginLimiter(loginLimitMaxFailures, loginLimitWindow, loginLimitCooldown) @@ -25,10 +28,14 @@ type loginLimiter struct { window time.Duration cooldown time.Duration attempts map[string]*loginLimitRecord + ipAttempts map[string]*loginLimitRecord } +// loginLimitRecord counts failures and admitted attempts still being checked: a burst +// sent at once would otherwise all pass before bcrypt reports the first failure. type loginLimitRecord struct { failures []time.Time + inFlight []time.Time blockedUntil time.Time } @@ -39,27 +46,47 @@ func newLoginLimiter(maxFailures int, window, cooldown time.Duration) *loginLimi window: window, cooldown: cooldown, attempts: make(map[string]*loginLimitRecord), + ipAttempts: make(map[string]*loginLimitRecord), } } +// allow admits an attempt and reserves a slot for it in both the (ip, username) and the +// address-wide record; the caller reports the outcome through registerFailure or registerSuccess. func (l *loginLimiter) allow(ip, username string) (time.Time, bool) { l.mu.Lock() defer l.mu.Unlock() - key := loginLimitKey(ip, username) - record := l.attempts[key] - if record == nil { - return time.Time{}, true - } now := l.now() + ipKey := strings.TrimSpace(ip) + if blockedUntil, ok := l.admit(l.ipAttempts, ipKey, l.maxFailures*loginLimitIPFactor, now); !ok { + return blockedUntil, false + } + if blockedUntil, ok := l.admit(l.attempts, loginLimitKey(ip, username), l.maxFailures, now); !ok { + l.ipAttempts[ipKey].dropReservation() + return blockedUntil, false + } + return time.Time{}, true +} + +// admit refuses a blocked key, or one whose failures and pending attempts already fill the +// budget (starting its cooldown), and otherwise reserves a slot. Callers hold l.mu. +func (l *loginLimiter) admit(records map[string]*loginLimitRecord, key string, limit int, now time.Time) (time.Time, bool) { + record := records[key] + if record == nil { + l.evictForRoom(records, now) + record = &loginLimitRecord{} + records[key] = record + } if now.Before(record.blockedUntil) { return record.blockedUntil, false } record.blockedUntil = time.Time{} - record.failures = pruneLoginFailures(record.failures, now.Add(-l.window)) - if len(record.failures) == 0 { - delete(l.attempts, key) + record.prune(now.Add(-l.window)) + if len(record.failures)+len(record.inFlight) >= limit { + record.block(now.Add(l.cooldown)) + return record.blockedUntil, false } + record.inFlight = append(record.inFlight, now) return time.Time{}, true } @@ -68,18 +95,26 @@ func (l *loginLimiter) registerFailure(ip, username string) (time.Time, bool) { defer l.mu.Unlock() now := l.now() - key := loginLimitKey(ip, username) - record := l.attempts[key] - if record == nil { - l.evictForRoom(now) - record = &loginLimitRecord{} - l.attempts[key] = record + ipBlockedUntil, ipBlocked := l.fail(l.ipAttempts, strings.TrimSpace(ip), l.maxFailures*loginLimitIPFactor, now) + blockedUntil, blocked := l.fail(l.attempts, loginLimitKey(ip, username), l.maxFailures, now) + if blocked { + return blockedUntil, true } - record.failures = pruneLoginFailures(record.failures, now.Add(-l.window)) + return ipBlockedUntil, ipBlocked +} + +func (l *loginLimiter) fail(records map[string]*loginLimitRecord, key string, limit int, now time.Time) (time.Time, bool) { + record := records[key] + if record == nil { + l.evictForRoom(records, now) + record = &loginLimitRecord{} + records[key] = record + } + record.prune(now.Add(-l.window)) + record.dropReservation() record.failures = append(record.failures, now) - if len(record.failures) >= l.maxFailures { - record.failures = nil - record.blockedUntil = now.Add(l.cooldown) + if len(record.failures) >= limit { + record.block(now.Add(l.cooldown)) return record.blockedUntil, true } return time.Time{}, false @@ -89,39 +124,59 @@ func (l *loginLimiter) registerSuccess(ip, username string) { l.mu.Lock() defer l.mu.Unlock() delete(l.attempts, loginLimitKey(ip, username)) + if record := l.ipAttempts[strings.TrimSpace(ip)]; record != nil { + record.dropReservation() + } } -// evictForRoom keeps the attempts map bounded before inserting a new record. +func (r *loginLimitRecord) prune(cutoff time.Time) { + r.failures = pruneLoginFailures(r.failures, cutoff) + r.inFlight = pruneLoginFailures(r.inFlight, cutoff) +} + +func (r *loginLimitRecord) block(until time.Time) { + r.failures = nil + r.inFlight = nil + r.blockedUntil = until +} + +func (r *loginLimitRecord) dropReservation() { + if r != nil && len(r.inFlight) > 0 { + r.inFlight = r.inFlight[1:] + } +} + +// evictForRoom keeps a records map bounded before inserting a new record. // It first reclaims records that are no longer blocked and whose failures have // aged out of the window; if the map is still at the ceiling (a genuine -// broad flood), it drops one arbitrary record so memory can never grow past the -// cap. Callers hold l.mu. -func (l *loginLimiter) evictForRoom(now time.Time) { - if len(l.attempts) < loginLimitMaxRecords { +// broad flood), it drops one record that is neither blocked nor awaiting an +// outcome, then any, so memory can never grow past the cap. Callers hold l.mu. +func (l *loginLimiter) evictForRoom(records map[string]*loginLimitRecord, now time.Time) { + if len(records) < loginLimitMaxRecords { return } cutoff := now.Add(-l.window) - for key, record := range l.attempts { + for key, record := range records { if now.Before(record.blockedUntil) { continue } - record.failures = pruneLoginFailures(record.failures, cutoff) - if len(record.failures) == 0 { - delete(l.attempts, key) + record.prune(cutoff) + if len(record.failures) == 0 && len(record.inFlight) == 0 { + delete(records, key) } } - if len(l.attempts) < loginLimitMaxRecords { + if len(records) < loginLimitMaxRecords { return } - for key, record := range l.attempts { - if now.Before(record.blockedUntil) { + for key, record := range records { + if now.Before(record.blockedUntil) || len(record.inFlight) > 0 { continue } - delete(l.attempts, key) + delete(records, key) return } - for key := range l.attempts { - delete(l.attempts, key) + for key := range records { + delete(records, key) return } } diff --git a/internal/web/controller/login_limiter_test.go b/internal/web/controller/login_limiter_test.go index 7420a851d..166c941e2 100644 --- a/internal/web/controller/login_limiter_test.go +++ b/internal/web/controller/login_limiter_test.go @@ -118,3 +118,45 @@ func TestLoginLimiterSeparatesIPAndUsername(t *testing.T) { t.Fatal("different username should not be blocked") } } + +// Without a per-address bound, one address can fill the table with its own blocked +// records and evict the record holding its admin failures, resetting the count. +func TestLoginLimiterBlocksAnAddressSpreadingFailuresOverUsernames(t *testing.T) { + now := time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC) + limiter := newLoginLimiter(5, 5*time.Minute, 15*time.Minute) + limiter.now = func() time.Time { return now } + + for i := range 5 * loginLimitIPFactor { + if _, ok := limiter.allow("192.0.2.10", "decoy-"+strconv.Itoa(i)); !ok { + t.Fatalf("attempt %d refused before the address limit", i+1) + } + limiter.registerFailure("192.0.2.10", "decoy-"+strconv.Itoa(i)) + } + if _, ok := limiter.allow("192.0.2.10", "admin"); ok { + t.Fatal("an address past its failure budget may still try the admin account") + } + if _, ok := limiter.allow("192.0.2.11", "admin"); !ok { + t.Fatal("another address was blocked too") + } +} + +// The count must cover attempts admitted and still being checked: bcrypt is slow, and +// a burst sent at once would otherwise all pass before the first failure is recorded. +func TestLoginLimiterCountsAttemptsStillBeingChecked(t *testing.T) { + now := time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC) + limiter := newLoginLimiter(5, 5*time.Minute, 15*time.Minute) + limiter.now = func() time.Time { return now } + + for i := range 5 { + if _, ok := limiter.allow("192.0.2.10", "admin"); !ok { + t.Fatalf("attempt %d of a burst refused", i+1) + } + } + if _, ok := limiter.allow("192.0.2.10", "admin"); ok { + t.Fatal("a sixth concurrent attempt was admitted") + } + now = now.Add(20 * time.Minute) + if _, ok := limiter.allow("192.0.2.10", "admin"); !ok { + t.Fatal("attempts that never reported back still block after the window and cooldown") + } +} From 2e14a2a51a275246abb63b9a4faffadb6fa72714 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 31 Oct 2026 09:04:27 +0000 Subject: [PATCH 012/125] fix(web): refuse oversized login usernames before recording them A failed login keeps its username in the in-memory log buffer, the limiter table and the notifier queue, and the route accepted any body the global cap allowed. The login route now takes at most 16 KiB and answers a username over 256 bytes with the generic failure before any of those see it; the user setters refuse such a name too, so no stored username can be one the login route turns away. --- internal/web/controller/csrf_log_test.go | 53 ++++++++++++++++++++++++ internal/web/controller/index.go | 9 +++- internal/web/service/panel/user.go | 10 +++++ internal/web/service/panel/user_test.go | 26 ++++++++++++ 4 files changed, 97 insertions(+), 1 deletion(-) diff --git a/internal/web/controller/csrf_log_test.go b/internal/web/controller/csrf_log_test.go index e436bcf39..90b3d2fdb 100644 --- a/internal/web/controller/csrf_log_test.go +++ b/internal/web/controller/csrf_log_test.go @@ -1,8 +1,11 @@ package controller import ( + "encoding/json" "net/http" "net/http/httptest" + "net/url" + "path/filepath" "strings" "testing" @@ -10,7 +13,11 @@ import ( "github.com/gin-contrib/sessions/cookie" "github.com/gin-gonic/gin" + "github.com/mhsanaei/3x-ui/v3/internal/database/dbtest" "github.com/mhsanaei/3x-ui/v3/internal/logger" + "github.com/mhsanaei/3x-ui/v3/internal/web/locale" + "github.com/mhsanaei/3x-ui/v3/internal/web/service/panel" + "github.com/mhsanaei/3x-ui/v3/internal/web/session" ) func TestLoginCSRFRejectionLogsPeerIPNotForwardedHeader(t *testing.T) { @@ -39,3 +46,49 @@ func TestLoginCSRFRejectionLogsPeerIPNotForwardedHeader(t *testing.T) { t.Fatalf("CSRF rejection log = %q, want the untrusted peer 198.51.100.9 and not the forwarded header", line) } } + +// A failed login keeps its username in the log buffer, the limiter table and the +// notifier queue; an unauthenticated caller must not be able to make that unbounded. +func TestLoginRefusesOversizedUsernameBeforeRecordingIt(t *testing.T) { + gin.SetMode(gin.TestMode) + dbtest.InitDB(t, filepath.Join(t.TempDir(), "x-ui.db")) + engine := gin.New() + engine.Use(sessions.Sessions("3x-ui", cookie.NewStore([]byte("login-length-test-secret")))) + engine.Use(func(c *gin.Context) { + c.Set("I18n", func(_ locale.I18nType, key string, _ ...string) string { return key }) + c.Next() + }) + NewIndexController(engine.Group("/")) + + tokenReq := httptest.NewRequest(http.MethodGet, "/csrf-token", nil) + tokenRec := httptest.NewRecorder() + engine.ServeHTTP(tokenRec, tokenReq) + var token struct{ Obj string } + if err := json.Unmarshal(tokenRec.Body.Bytes(), &token); err != nil || token.Obj == "" { + t.Fatalf("csrf token: %v %s", err, tokenRec.Body.String()) + } + username := strings.Repeat("u", panel.MaxUsernameLength+1) + form := url.Values{"username": {username}, "password": {"wrong"}} + req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) + req.RemoteAddr = "198.51.100.10:40000" + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set(session.CSRFHeaderName, token.Obj) + for _, cookie := range tokenRec.Result().Cookies() { + req.AddCookie(cookie) + } + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, req) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"success":false`) { + t.Fatalf("login = %d %s, want the generic failure", rec.Code, rec.Body.String()) + } + for _, line := range logger.GetLogs(100, "DEBUG") { + if strings.Contains(line, username) { + t.Fatal("the oversized username reached the log buffer") + } + } + defaultLoginLimiter.mu.Lock() + defer defaultLoginLimiter.mu.Unlock() + if _, kept := defaultLoginLimiter.attempts[loginLimitKey("198.51.100.10", username)]; kept { + t.Fatal("the oversized username was kept in the limiter table") + } +} diff --git a/internal/web/controller/index.go b/internal/web/controller/index.go index 0433e2ed8..ba0bae364 100644 --- a/internal/web/controller/index.go +++ b/internal/web/controller/index.go @@ -47,7 +47,8 @@ func (a *IndexController) initRouter(g *gin.RouterGroup) { g.GET("/sponsors", a.sponsors) g.GET("/sponsors/logo/:name", a.sponsorLogo) - g.POST("/login", middleware.CSRFMiddleware(getRemoteIp), a.login) + // The route carries three short fields; anything longer is refused unread. + g.POST("/login", middleware.MaxBodyBytes(16<<10), middleware.CSRFMiddleware(getRemoteIp), a.login) g.POST("/logout", middleware.CSRFMiddleware(getRemoteIp), a.logout) g.POST("/getTwoFactorEnable", middleware.CSRFMiddleware(getRemoteIp), a.getTwoFactorEnable) } @@ -102,6 +103,12 @@ func (a *IndexController) login(c *gin.Context) { } remoteIP := getRemoteIp(c) + // No stored name is longer; the log buffer, the limiter and the notifiers keep what follows. + if len(form.Username) > panel.MaxUsernameLength { + logger.Warningf("failed login: IP=%q, reason=%q", remoteIP, "username too long") + pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.wrongUsernameOrPassword")) + return + } safeUser := template.HTMLEscapeString(form.Username) timeStr := time.Now().Format("2006-01-02 15:04:05") if blockedUntil, ok := defaultLoginLimiter.allow(remoteIP, form.Username); !ok { diff --git a/internal/web/service/panel/user.go b/internal/web/service/panel/user.go index dd6970d86..8c0bf9e7d 100644 --- a/internal/web/service/panel/user.go +++ b/internal/web/service/panel/user.go @@ -2,6 +2,7 @@ package panel import ( "errors" + "fmt" "time" "gorm.io/gorm" @@ -128,6 +129,9 @@ func (s *UserService) BumpLoginEpoch() error { } func (s *UserService) UpdateUser(id int, username string, password string) error { + if len(username) > MaxUsernameLength { + return fmt.Errorf("username can not be longer than %d bytes", MaxUsernameLength) + } db := database.GetDB() hashedPassword, err := crypto.HashPasswordAsBcrypt(password) if err != nil { @@ -157,6 +161,8 @@ func (s *UserService) UpdateUser(id int, username string, password string) error func (s *UserService) UpdateFirstUser(username string, password string) error { if username == "" { return errors.New("username can not be empty") + } else if len(username) > MaxUsernameLength { + return fmt.Errorf("username can not be longer than %d bytes", MaxUsernameLength) } else if password == "" { return errors.New("password can not be empty") } @@ -181,3 +187,7 @@ func (s *UserService) UpdateFirstUser(username string, password string) error { user.LoginEpoch++ return db.Save(user).Error } + +// MaxUsernameLength bounds a panel username: the login route refuses longer names before +// logging or recording them, so no stored name may exceed it. +const MaxUsernameLength = 256 diff --git a/internal/web/service/panel/user_test.go b/internal/web/service/panel/user_test.go index ba79578d4..70e26f5b3 100644 --- a/internal/web/service/panel/user_test.go +++ b/internal/web/service/panel/user_test.go @@ -2,6 +2,7 @@ package panel import ( "path/filepath" + "strings" "testing" "github.com/mhsanaei/3x-ui/v3/internal/database" @@ -48,3 +49,28 @@ func TestHasDefaultCredential(t *testing.T) { }) } } + +// The login route refuses names over MaxUsernameLength unread, so storing one would lock +// the admin out of their own panel. +func TestUserSettersRefuseNamesTheLoginRouteRefuses(t *testing.T) { + dbtest.InitDB(t, filepath.Join(t.TempDir(), "x-ui.db")) + svc := &UserService{} + if err := svc.UpdateFirstUser("admin", "s3cret-Pass"); err != nil { + t.Fatalf("UpdateFirstUser: %v", err) + } + var user model.User + if err := database.GetDB().First(&user).Error; err != nil { + t.Fatalf("load user: %v", err) + } + long := strings.Repeat("a", MaxUsernameLength+1) + if err := svc.UpdateFirstUser(long, "s3cret-Pass"); err == nil { + t.Fatal("UpdateFirstUser stored a name the login route refuses") + } + if err := svc.UpdateUser(user.Id, long, "s3cret-Pass"); err == nil { + t.Fatal("UpdateUser stored a name the login route refuses") + } + var stored model.User + if err := database.GetDB().First(&stored, user.Id).Error; err != nil || stored.Username != "admin" { + t.Fatalf("stored username = %q (%v), want it unchanged", stored.Username, err) + } +} From ce532e8df719197e9cb183b6a5810303e5505c27 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 31 Oct 2026 09:05:28 +0000 Subject: [PATCH 013/125] fix(web): start a fresh session at login SetLoginUser added the user to whatever session the login request carried, so its CSRF token outlived the login. Anyone can fetch an anonymous session with its token, and a host on a sibling domain can plant that cookie in the admin's browser. Clear the session before the user is stored; the next page load or token fetch mints a new token. --- internal/web/session/session.go | 3 +++ internal/web/session/session_test.go | 30 ++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/internal/web/session/session.go b/internal/web/session/session.go index ef2f07afa..e29a8d406 100644 --- a/internal/web/session/session.go +++ b/internal/web/session/session.go @@ -29,6 +29,9 @@ func SetLoginUser(c *gin.Context, user *model.User) error { return nil } s := sessions.Default(c) + // Anyone can mint the anonymous session this request carried and plant it in a browser, + // so nothing in it, the CSRF token above all, outlives the privilege change. + s.Clear() s.Set(loginUserKey, user.Id) s.Set(loginEpochKey, user.LoginEpoch) return s.Save() diff --git a/internal/web/session/session_test.go b/internal/web/session/session_test.go index ca7117956..870cb3509 100644 --- a/internal/web/session/session_test.go +++ b/internal/web/session/session_test.go @@ -45,3 +45,33 @@ func TestSessionUserIDSupportsLegacyUserPayload(t *testing.T) { t.Fatalf("legacy pointer session payload resolved to (%d, %v), want (12, true)", id, ok) } } + +// Anyone can fetch an anonymous session with its CSRF token and plant that cookie in a +// browser from a sibling host; the token must not survive the login it was planted for. +func TestSetLoginUserReplacesThePreLoginCSRFToken(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(sessions.Sessions(sessionCookieName, cookie.NewStore([]byte("01234567890123456789012345678901")))) + var planted, afterLogin string + router.GET("/csrf", func(c *gin.Context) { + planted, _ = EnsureCSRFToken(c) + c.Status(http.StatusNoContent) + }) + router.GET("/login", func(c *gin.Context) { + if err := SetLoginUser(c, &model.User{Id: 7}); err != nil { + t.Fatal(err) + } + afterLogin, _ = EnsureCSRFToken(c) + c.Status(http.StatusNoContent) + }) + first := httptest.NewRecorder() + router.ServeHTTP(first, httptest.NewRequest(http.MethodGet, "/csrf", nil)) + req := httptest.NewRequest(http.MethodGet, "/login", nil) + for _, cookie := range first.Result().Cookies() { + req.AddCookie(cookie) + } + router.ServeHTTP(httptest.NewRecorder(), req) + if planted == "" || afterLogin == "" || afterLogin == planted { + t.Fatalf("CSRF token before login %q, after login %q; want a fresh token", planted, afterLogin) + } +} From b1108a9e815f5b61419beba50981ebb8797ca63e Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 31 Oct 2026 09:06:41 +0000 Subject: [PATCH 014/125] fix(web): match the WebSocket origin on scheme and port, not only host The live-update socket accepted any Origin whose host name matched, but cookies do not separate ports or schemes, so a page served by another service on the same host name could open it with the admin's session. Only http(s) origins are accepted now (https when the panel terminates TLS itself), and the port must match whenever the Host header has one. --- internal/web/controller/websocket.go | 23 +++++++++++-- internal/web/controller/websocket_test.go | 42 +++++++++++++++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 internal/web/controller/websocket_test.go diff --git a/internal/web/controller/websocket.go b/internal/web/controller/websocket.go index 49447b416..fc6007536 100644 --- a/internal/web/controller/websocket.go +++ b/internal/web/controller/websocket.go @@ -34,7 +34,12 @@ func checkSameOrigin(r *http.Request) bool { if err != nil || u.Hostname() == "" { return false } - host, _, err := net.SplitHostPort(r.Host) + // Cookies ignore scheme and port, so both must match too; behind a TLS-terminating + // proxy the request arrives as http and the browser's https origin still passes. + if u.Scheme != "https" && (u.Scheme != "http" || r.TLS != nil) { + return false + } + host, port, err := net.SplitHostPort(r.Host) if err != nil { // IPv6 literals without a port arrive as "[::1]"; net.SplitHostPort // fails in that case while url.Hostname() returns the address without @@ -44,7 +49,21 @@ func checkSameOrigin(r *http.Request) bool { host = host[1 : len(host)-1] } } - return strings.EqualFold(u.Hostname(), host) + if !strings.EqualFold(u.Hostname(), host) { + return false + } + return port == "" || port == originPort(u) +} + +// originPort is the Origin's port, or the default a browser leaves out for its scheme. +func originPort(u *url.URL) string { + if p := u.Port(); p != "" { + return p + } + if u.Scheme == "https" { + return "443" + } + return "80" } // WebSocketController handles the HTTP→WebSocket upgrade for real-time updates. diff --git a/internal/web/controller/websocket_test.go b/internal/web/controller/websocket_test.go new file mode 100644 index 000000000..3ebc823f8 --- /dev/null +++ b/internal/web/controller/websocket_test.go @@ -0,0 +1,42 @@ +package controller + +import ( + "crypto/tls" + "net/http" + "net/http/httptest" + "testing" +) + +// Cookies do not separate ports or schemes, so another service on the panel's host name +// must not be able to open the admin's live-update socket from the admin's browser. +func TestCheckSameOriginComparesSchemeAndPort(t *testing.T) { + for _, tc := range []struct { + name, host, origin string + tls bool + want bool + }{ + {"no origin header", "panel.example.com:2053", "", false, true}, + {"same host and port", "panel.example.com:2053", "http://panel.example.com:2053", false, true}, + {"default port omitted by the browser", "panel.example.com:443", "https://panel.example.com", true, true}, + {"behind a proxy without a port", "panel.example.com", "https://panel.example.com", false, true}, + {"bare IPv6 host", "[::1]", "http://[::1]", false, true}, + {"another port on the same host", "panel.example.com:2053", "http://panel.example.com:8080", false, false}, + {"plain http origin on a TLS panel", "panel.example.com:2053", "http://panel.example.com:2053", true, false}, + {"non-web scheme", "panel.example.com:2053", "chrome-extension://panel.example.com:2053", false, false}, + {"another host", "panel.example.com:2053", "http://evil.example.com:2053", false, false}, + } { + req := httptest.NewRequest(http.MethodGet, "/ws", nil) + req.Host = tc.host + if tc.origin != "" { + req.Header.Set("Origin", tc.origin) + } + if tc.tls { + req.TLS = &tls.ConnectionState{} + } else { + req.TLS = nil + } + if got := checkSameOrigin(req); got != tc.want { + t.Errorf("%s: checkSameOrigin(Host %q, Origin %q) = %v, want %v", tc.name, tc.host, tc.origin, got, tc.want) + } + } +} From cea69a4117bb55ad0d386922829fc23654412eca Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 31 Oct 2026 09:09:06 +0000 Subject: [PATCH 015/125] fix(api): accept config envelopes only on requests that carry a body The envelope middleware buffered and decoded any request carrying its headers, GETs included, which the router-wide body cap skips and which read-only tokens may call; its raw read had no cap of its own. Refuse an envelope on safe methods and read at most the decoded-size limit. --- internal/web/middleware/config_envelope.go | 14 ++++++++- .../web/middleware/config_envelope_test.go | 31 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/internal/web/middleware/config_envelope.go b/internal/web/middleware/config_envelope.go index 2f2acaaf4..163a64d4c 100644 --- a/internal/web/middleware/config_envelope.go +++ b/internal/web/middleware/config_envelope.go @@ -39,11 +39,23 @@ func ConfigEnvelopeMiddleware() gin.HandlerFunc { return } - raw, err := io.ReadAll(c.Request.Body) + // Safe methods carry no config: an envelope there is never legitimate, and read-only + // (monitor) callers must not reach the decoder. + switch c.Request.Method { + case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodTrace: + c.AbortWithStatus(http.StatusBadRequest) + return + } + + raw, err := io.ReadAll(io.LimitReader(c.Request.Body, maxDecodedConfigBytes+1)) if err != nil { c.AbortWithStatus(http.StatusBadRequest) return } + if len(raw) > maxDecodedConfigBytes { + c.AbortWithStatus(http.StatusRequestEntityTooLarge) + return + } _ = c.Request.Body.Close() if enc == wirecodec.EncodingZstd { diff --git a/internal/web/middleware/config_envelope_test.go b/internal/web/middleware/config_envelope_test.go index 023fd6e66..4f081eced 100644 --- a/internal/web/middleware/config_envelope_test.go +++ b/internal/web/middleware/config_envelope_test.go @@ -80,3 +80,34 @@ func TestConfigEnvelope_PlainPassesThrough(t *testing.T) { t.Fatalf("a request with no envelope headers must pass through unchanged: code=%d body=%q", w.Code, w.Body.String()) } } + +// A read-only (monitor) token reaches GET routes only; an envelope never rides on one, so +// it must not make the panel buffer and decode a body there. +func TestConfigEnvelope_RefusesAnEnvelopeOnASafeMethod(t *testing.T) { + called := false + engine := envelopeTestEngine(t, nil) + engine.GET("/status", func(c *gin.Context) { called = true }) + body := bytes.Repeat([]byte("A"), 1<<20) + req := httptest.NewRequest(http.MethodGet, "/status", bytes.NewReader(wirecodec.Compress(body))) + req.Header.Set("Content-Encoding", wirecodec.EncodingZstd) + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest || called { + t.Fatalf("GET with an envelope = %d (handler called %v), want 400 before the handler", w.Code, called) + } +} + +// The router-wide body cap does not cover every route, so the envelope reads at most +// what it would accept once decoded. +func TestConfigEnvelope_CapsTheRawBody(t *testing.T) { + called := false + engine := envelopeTestEngine(t, func() { called = true }) + body := bytes.Repeat([]byte("A"), maxDecodedConfigBytes+1) + req := httptest.NewRequest(http.MethodPost, "/echo", bytes.NewReader(body)) + req.Header.Set(wirecodec.HashHeader, wirecodec.Sha256Hex(body)) + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + if w.Code != http.StatusRequestEntityTooLarge || called { + t.Fatalf("oversized envelope body = %d (handler called %v), want 413", w.Code, called) + } +} From e3736564a3e9f035f015852bab03ba4f165f9706 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 31 Oct 2026 09:09:22 +0000 Subject: [PATCH 016/125] perf(wirecodec): decode a config envelope into one buffer The shared zstd decoder grew its output per frame, copying everything decoded so far for each extra frame, so 30 KB of tiny frames cost about 8 GB of allocation and seconds of CPU per request. The output buffer is now sized once from the first frame's declared size (Compress writes a single frame) with decoding capped at that capacity, and callers waiting for a decoder worker no longer each hold a buffer. --- internal/util/wirecodec/wirecodec.go | 34 +++++++++++++++++++---- internal/util/wirecodec/wirecodec_test.go | 15 ++++++++++ 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/internal/util/wirecodec/wirecodec.go b/internal/util/wirecodec/wirecodec.go index 0b3a130a1..7a69bfd1f 100644 --- a/internal/util/wirecodec/wirecodec.go +++ b/internal/util/wirecodec/wirecodec.go @@ -7,6 +7,7 @@ import ( "crypto/sha256" "encoding/hex" "errors" + "runtime" "github.com/klauspost/compress/zstd" ) @@ -35,8 +36,13 @@ const ( // EncodeAll/DecodeAll on these shared instances are safe for concurrent use. var ( + decoderWorkers = runtime.GOMAXPROCS(0) zstdEncoder, _ = zstd.NewWriter(nil) - zstdDecoder, _ = zstd.NewReader(nil, zstd.WithDecoderMaxMemory(maxDecodeBytes)) + zstdDecoder, _ = zstd.NewReader(nil, zstd.WithDecoderMaxMemory(maxDecodeBytes), + zstd.WithDecodeAllCapLimit(true), zstd.WithDecoderConcurrency(decoderWorkers)) + // decodeSlots keeps callers waiting for a decoder worker from each holding an output buffer. + decodeSlots = make(chan struct{}, decoderWorkers) + errDecodedTooLarge = errors.New("wirecodec: decompressed body exceeds limit") ) // Compress zstd-compresses b. @@ -47,12 +53,30 @@ func Compress(b []byte) []byte { // Decompress zstd-decompresses src, rejecting output larger than maxOut (and any // input that would blow the in-memory bomb ceiling). func Decompress(src []byte, maxOut int) ([]byte, error) { - out, err := zstdDecoder.DecodeAll(src, nil) - if err != nil { + if maxOut <= 0 || maxOut > maxDecodeBytes { + maxOut = maxDecodeBytes + } + var header zstd.Header + if err := header.Decode(src); err != nil { return nil, err } - if maxOut > 0 && len(out) > maxOut { - return nil, errors.New("wirecodec: decompressed body exceeds limit") + // The output buffer is sized once, from the first frame (Compress writes a single + // frame): letting it grow per frame copied all output so far for every extra frame. + size := maxOut + if header.HasFCS { + if header.FrameContentSize > uint64(maxOut) { + return nil, errDecodedTooLarge + } + size = int(header.FrameContentSize) + } + decodeSlots <- struct{}{} + defer func() { <-decodeSlots }() + out, err := zstdDecoder.DecodeAll(src, make([]byte, 0, size)) + if errors.Is(err, zstd.ErrDecoderSizeExceeded) { + return nil, errDecodedTooLarge + } + if err != nil { + return nil, err } return out, nil } diff --git a/internal/util/wirecodec/wirecodec_test.go b/internal/util/wirecodec/wirecodec_test.go index 8bd9137df..08ad22b30 100644 --- a/internal/util/wirecodec/wirecodec_test.go +++ b/internal/util/wirecodec/wirecodec_test.go @@ -2,6 +2,7 @@ package wirecodec import ( "bytes" + "runtime" "strings" "testing" ) @@ -49,3 +50,17 @@ func TestSha256HexStableAndSensitive(t *testing.T) { t.Fatalf("expected 64 hex chars, got %d", len(a)) } } + +// Every extra frame made the decoder copy all output so far into a larger buffer, so a +// few KiB of tiny frames cost gigabytes of allocation for one authenticated request. +func TestDecompressOfManyFramesAllocatesTheOutputOnce(t *testing.T) { + frame := Compress(bytes.Repeat([]byte("A"), 4096)) + src := bytes.Repeat(frame, 2000) + var before, after runtime.MemStats + runtime.ReadMemStats(&before) + _, _ = Decompress(src, 8<<20) + runtime.ReadMemStats(&after) + if allocated := after.TotalAlloc - before.TotalAlloc; allocated > 64<<20 { + t.Fatalf("decoding %d bytes of frames allocated %d MiB", len(src), allocated>>20) + } +} From f1e1b0fb0ca84f2463c5f019751f212d79cdbae8 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 31 Oct 2026 09:11:54 +0000 Subject: [PATCH 017/125] fix(i18n): publish the translation bundle only once it is complete InitLocalizer filled the shared bundle in place, and the Telegram bot runs it again while the panel is serving, so a request could read the bundle's maps while they were being written and the process died on a concurrent map access. Build the bundle privately and publish it, and the bot and web localizers, through atomic pointers; the lazy load in the middleware publishes only if nothing else did first. --- internal/web/locale/locale.go | 48 ++++++++++++------- internal/web/locale/locale_test.go | 48 +++++++++++++++++++ internal/web/service/tgbot/screens_test.go | 5 +- .../web/service/tgbot/tgbot_broadcast_test.go | 5 +- .../service/tgbot/tgbot_client_expiry_test.go | 5 +- .../service/tgbot/tgbot_draft_render_test.go | 5 +- .../tgbot/tgbot_report_batching_test.go | 5 +- 7 files changed, 89 insertions(+), 32 deletions(-) create mode 100644 internal/web/locale/locale_test.go diff --git a/internal/web/locale/locale.go b/internal/web/locale/locale.go index c3a0a0c37..a5d48d9a6 100644 --- a/internal/web/locale/locale.go +++ b/internal/web/locale/locale.go @@ -7,6 +7,7 @@ import ( "io/fs" "os" "strings" + "sync/atomic" "github.com/mhsanaei/3x-ui/v3/internal/logger" @@ -15,10 +16,11 @@ import ( "golang.org/x/text/language" ) +// Each is published whole: InitLocalizer runs again (Telegram bot start) while requests read them. var ( - i18nBundle *i18n.Bundle - LocalizerWeb *i18n.Localizer - LocalizerBot *i18n.Localizer + i18nBundle atomic.Pointer[i18n.Bundle] + localizerWeb atomic.Pointer[i18n.Localizer] + localizerBot atomic.Pointer[i18n.Localizer] ) // I18nType represents the type of interface for internationalization. @@ -37,13 +39,14 @@ type SettingService interface { // InitLocalizer initializes the internationalization system with embedded translation files. func InitLocalizer(i18nFS fs.FS, settingService SettingService) error { // set default bundle to English - i18nBundle = i18n.NewBundle(language.MustParse("en-US")) - i18nBundle.RegisterUnmarshalFunc("json", json.Unmarshal) + bundle := i18n.NewBundle(language.MustParse("en-US")) + bundle.RegisterUnmarshalFunc("json", json.Unmarshal) // parse files - if err := parseTranslationFiles(i18nFS, i18nBundle); err != nil { + if err := parseTranslationFiles(i18nFS, bundle); err != nil { return err } + i18nBundle.Store(bundle) // setup bot locale if err := initTGBotLocalizer(settingService); err != nil { @@ -77,9 +80,9 @@ func I18n(i18nType I18nType, key string, params ...string) string { switch i18nType { case "bot": - localizer = LocalizerBot + localizer = localizerBot.Load() case "web": - localizer = LocalizerWeb + localizer = localizerWeb.Load() default: logger.Errorf("Invalid type for I18n: %s", i18nType) return "" @@ -106,10 +109,11 @@ func I18n(i18nType I18nType, key string, params ...string) string { // LocalizerFor returns a new Localizer for the given language tag using the global bundle. func LocalizerFor(lang string) *i18n.Localizer { - if i18nBundle == nil { + bundle := i18nBundle.Load() + if bundle == nil { return nil } - return i18n.NewLocalizer(i18nBundle, lang) + return i18n.NewLocalizer(bundle, lang) } // I18nForLang retrieves a localized message for a specific language tag with optional params. @@ -136,7 +140,7 @@ func initTGBotLocalizer(settingService SettingService) error { return err } - LocalizerBot = i18n.NewLocalizer(i18nBundle, botLang) + localizerBot.Store(i18n.NewLocalizer(i18nBundle.Load(), botLang)) return nil } @@ -147,13 +151,17 @@ func initTGBotLocalizer(settingService SettingService) error { func LocalizerMiddleware() gin.HandlerFunc { return func(c *gin.Context) { // Ensure bundle is initialized so creating a Localizer won't panic - if i18nBundle == nil { - i18nBundle = i18n.NewBundle(language.MustParse("en-US")) - i18nBundle.RegisterUnmarshalFunc("json", json.Unmarshal) + bundle := i18nBundle.Load() + if bundle == nil { + bundle = i18n.NewBundle(language.MustParse("en-US")) + bundle.RegisterUnmarshalFunc("json", json.Unmarshal) // Try lazy-load from disk when running sub server without InitLocalizer - if err := loadTranslationsFromDisk(i18nBundle); err != nil { + if err := loadTranslationsFromDisk(bundle); err != nil { logger.Warning("i18n lazy load failed:", err) } + if !i18nBundle.CompareAndSwap(nil, bundle) { + bundle = i18nBundle.Load() + } } var lang string @@ -163,9 +171,10 @@ func LocalizerMiddleware() gin.HandlerFunc { lang = c.GetHeader("Accept-Language") } - LocalizerWeb = i18n.NewLocalizer(i18nBundle, lang) + localizer := i18n.NewLocalizer(bundle, lang) + localizerWeb.Store(localizer) - c.Set("localizer", LocalizerWeb) + c.Set("localizer", localizer) c.Set("I18n", I18n) c.Next() } @@ -216,3 +225,8 @@ func parseTranslationFiles(i18nFS fs.FS, i18nBundle *i18n.Bundle) error { return nil } + +// SwapBotLocalizer installs l as the bot localizer and returns the previous one. +func SwapBotLocalizer(l *i18n.Localizer) *i18n.Localizer { + return localizerBot.Swap(l) +} diff --git a/internal/web/locale/locale_test.go b/internal/web/locale/locale_test.go new file mode 100644 index 000000000..aadad2dca --- /dev/null +++ b/internal/web/locale/locale_test.go @@ -0,0 +1,48 @@ +package locale + +import ( + "net/http" + "net/http/httptest" + "sync" + "testing" + "testing/fstest" + + "github.com/gin-gonic/gin" +) + +type fixedLang string + +func (l fixedLang) GetTgLang() (string, error) { return string(l), nil } + +// The Telegram bot re-runs InitLocalizer while the panel serves requests; a request must +// never read the bundle while it is being filled (a concurrent map access kills the process). +func TestInitLocalizerWhileServingRequests(t *testing.T) { + gin.SetMode(gin.TestMode) + translations := fstest.MapFS{"translation/en_US.json": {Data: []byte(`{"hello": "Hello"}`)}} + engine := gin.New() + engine.Use(LocalizerMiddleware()) + engine.GET("/", func(c *gin.Context) { c.String(http.StatusOK, I18nForLang("en-US", "hello")) }) + + var wg sync.WaitGroup + wg.Go(func() { + for range 200 { + if err := InitLocalizer(translations, fixedLang("en-US")); err != nil { + t.Errorf("InitLocalizer: %v", err) + return + } + } + }) + for range 4 { + wg.Go(func() { + for range 200 { + engine.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil)) + } + }) + } + wg.Wait() + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + if rec.Body.String() != "Hello" { + t.Fatalf("after re-initialisation the bundle answers %q, want Hello", rec.Body.String()) + } +} diff --git a/internal/web/service/tgbot/screens_test.go b/internal/web/service/tgbot/screens_test.go index 97fd648fb..34ccb68ae 100644 --- a/internal/web/service/tgbot/screens_test.go +++ b/internal/web/service/tgbot/screens_test.go @@ -636,9 +636,8 @@ func localizeWithRealBundle(t *testing.T) { if _, err := bundle.ParseMessageFileBytes(data, path); err != nil { t.Fatalf("parse %s: %v", path, err) } - orig := locale.LocalizerBot - locale.LocalizerBot = i18n.NewLocalizer(bundle, "en-US") - t.Cleanup(func() { locale.LocalizerBot = orig }) + orig := locale.SwapBotLocalizer(i18n.NewLocalizer(bundle, "en-US")) + t.Cleanup(func() { locale.SwapBotLocalizer(orig) }) } // seeding a client bound to a Telegram user, which the client screens key on diff --git a/internal/web/service/tgbot/tgbot_broadcast_test.go b/internal/web/service/tgbot/tgbot_broadcast_test.go index 9d3487d9f..5d80bed38 100644 --- a/internal/web/service/tgbot/tgbot_broadcast_test.go +++ b/internal/web/service/tgbot/tgbot_broadcast_test.go @@ -121,9 +121,8 @@ func broadcastLocalizer(t *testing.T) { &i18n.Message{ID: "tgbot.messages.broadcastCanceled", Other: "canceled"}, &i18n.Message{ID: "tgbot.messages.broadcastUnreachable", Other: "ℹ️ {{ .Count }} recipients cannot be messaged — ask them to press Start."}, ) - orig := locale.LocalizerBot - t.Cleanup(func() { locale.LocalizerBot = orig }) - locale.LocalizerBot = i18n.NewLocalizer(bundle, "en-US") + orig := locale.SwapBotLocalizer(i18n.NewLocalizer(bundle, "en-US")) + t.Cleanup(func() { locale.SwapBotLocalizer(orig) }) } func createBroadcastInbound(t *testing.T, tag, settings string) { diff --git a/internal/web/service/tgbot/tgbot_client_expiry_test.go b/internal/web/service/tgbot/tgbot_client_expiry_test.go index efa9b8c92..1ac6c41ce 100644 --- a/internal/web/service/tgbot/tgbot_client_expiry_test.go +++ b/internal/web/service/tgbot/tgbot_client_expiry_test.go @@ -27,9 +27,8 @@ func clientInfoLocalizer(t *testing.T) { &i18n.Message{ID: "tgbot.messages.expire", Other: "Expire Date: {{ .Time }}\r\n"}, &i18n.Message{ID: "tgbot.wentWrong", Other: "went wrong"}, ) - orig := locale.LocalizerBot - t.Cleanup(func() { locale.LocalizerBot = orig }) - locale.LocalizerBot = i18n.NewLocalizer(bundle, "en-US") + orig := locale.SwapBotLocalizer(i18n.NewLocalizer(bundle, "en-US")) + t.Cleanup(func() { locale.SwapBotLocalizer(orig) }) } // Regression test: a start-after-first-use client is stored as a negative duration, diff --git a/internal/web/service/tgbot/tgbot_draft_render_test.go b/internal/web/service/tgbot/tgbot_draft_render_test.go index aacc14be2..9871bc928 100644 --- a/internal/web/service/tgbot/tgbot_draft_render_test.go +++ b/internal/web/service/tgbot/tgbot_draft_render_test.go @@ -52,9 +52,8 @@ func draftLocalizer(t *testing.T, msgs ...*i18n.Message) { bundle := i18n.NewBundle(language.MustParse("en-US")) bundle.RegisterUnmarshalFunc("json", json.Unmarshal) _ = bundle.AddMessages(language.MustParse("en-US"), msgs...) - orig := locale.LocalizerBot - t.Cleanup(func() { locale.LocalizerBot = orig }) - locale.LocalizerBot = i18n.NewLocalizer(bundle, "en-US") + orig := locale.SwapBotLocalizer(i18n.NewLocalizer(bundle, "en-US")) + t.Cleanup(func() { locale.SwapBotLocalizer(orig) }) } // Regression test: the wizard's own prompts are HTML-parsed as well, so the diff --git a/internal/web/service/tgbot/tgbot_report_batching_test.go b/internal/web/service/tgbot/tgbot_report_batching_test.go index 3adcf9c6a..ba0166ba9 100644 --- a/internal/web/service/tgbot/tgbot_report_batching_test.go +++ b/internal/web/service/tgbot/tgbot_report_batching_test.go @@ -291,9 +291,8 @@ func TestNotifyExhaustedLooksUpEachIDOnce(t *testing.T) { func TestServerUsageInfoShowsUnavailableUptime(t *testing.T) { bundle := i18n.NewBundle(language.English) _ = bundle.AddMessages(language.English, &i18n.Message{ID: "tgbot.messages.serverUpTime", Other: "Uptime: {{.UpTime}} {{.Unit}}\n"}, &i18n.Message{ID: "tgbot.days", Other: "days"}) - oldLocale := locale.LocalizerBot - locale.LocalizerBot = i18n.NewLocalizer(bundle, "en") - t.Cleanup(func() { locale.LocalizerBot = oldLocale }) + oldLocale := locale.SwapBotLocalizer(i18n.NewLocalizer(bundle, "en")) + t.Cleanup(func() { locale.SwapBotLocalizer(oldLocale) }) statusCache.mutex.Lock() oldStatus, oldTime := statusCache.data, statusCache.timestamp statusCache.mutex.Unlock() From 08482154c2d63955a4abbcff2a9e24de5f996f8c Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 31 Oct 2026 09:14:57 +0000 Subject: [PATCH 018/125] fix(node): keep master requests on the node they were sent to The master's node clients followed redirects to any host, presenting the same panel-wide credentials (the mTLS client certificate) and replaying the request there, so one node could have the master push to another. Node clients and the certificate-pin probe now stop at a redirect off the original host:port and report the 3xx as the node's answer. The probe change is wiring; TestNodeClientsDoNotFollowARedirectToAnotherHost covers the guard. --- internal/web/runtime/tls_client.go | 12 +++++++++- internal/web/runtime/tls_client_test.go | 31 +++++++++++++++++++++++++ internal/web/service/node.go | 2 ++ 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/internal/web/runtime/tls_client.go b/internal/web/runtime/tls_client.go index 77073856c..a9db7260a 100644 --- a/internal/web/runtime/tls_client.go +++ b/internal/web/runtime/tls_client.go @@ -152,7 +152,16 @@ func newNodeTransport(tlsCfg *tls.Config) *http.Transport { // defaultNodeHTTPClient reaches nodes trusting the system CA store ("verify" // mode or plain http); shared so connections pool across nodes. -var defaultNodeHTTPClient = &http.Client{Transport: newNodeTransport(nil)} +var defaultNodeHTTPClient = &http.Client{Transport: newNodeTransport(nil), CheckRedirect: NoCrossHostRedirect} + +// NoCrossHostRedirect keeps a request on the host:port it was sent to: the master presents +// the same credentials to every node, so a node's Location must not carry them elsewhere. +func NoCrossHostRedirect(req *http.Request, via []*http.Request) error { + if len(via) >= 10 || !strings.EqualFold(req.URL.Host, via[0].URL.Host) { + return http.ErrUseLastResponse + } + return nil +} // nodeClients caches one client per node: heartbeat and traffic sync reach it // every few seconds, and a rebuilt client would open its own empty pool. @@ -214,6 +223,7 @@ func HTTPClientForNode(n *model.Node, proxyURL string) (*http.Client, error) { if err != nil { return nil, err } + client.CheckRedirect = NoCrossHostRedirect nodeClientsMu.Lock() if entry, ok := nodeClientsCache[key]; ok { diff --git a/internal/web/runtime/tls_client_test.go b/internal/web/runtime/tls_client_test.go index 2d151b81c..1bae8063f 100644 --- a/internal/web/runtime/tls_client_test.go +++ b/internal/web/runtime/tls_client_test.go @@ -662,3 +662,34 @@ func TestDecodeCertPin(t *testing.T) { }) } } + +// The master presents the same credentials to every node; a node's redirect must not +// carry a request (a push, the mTLS certificate) to another host. +func TestNodeClientsDoNotFollowARedirectToAnotherHost(t *testing.T) { + var otherHit atomic.Int32 + other := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { otherHit.Add(1) })) + defer other.Close() + hostile := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, other.URL+"/panel/api/inbounds/update/1", http.StatusTemporaryRedirect) + })) + defer hostile.Close() + u, _ := url.Parse(hostile.URL) + port, _ := strconv.Atoi(u.Port()) + for _, node := range []*model.Node{ + {Id: 41, Address: u.Hostname(), Port: port, Scheme: "https", TlsVerifyMode: "skip", AllowPrivateAddress: true}, + {Id: 42, Address: u.Hostname(), Port: port, Scheme: "http", AllowPrivateAddress: true}, + } { + client, err := HTTPClientForNode(node, "") + if err != nil { + t.Fatalf("HTTPClientForNode: %v", err) + } + req, _ := http.NewRequestWithContext(netsafe.ContextWithAllowPrivate(context.Background(), true), + http.MethodPost, hostile.URL+"/panel/api/inbounds/list", strings.NewReader("{}")) + if resp, err := client.Do(req); err == nil { + _ = resp.Body.Close() + } + } + if otherHit.Load() != 0 { + t.Fatalf("a node's redirect reached another host %d time(s)", otherHit.Load()) + } +} diff --git a/internal/web/service/node.go b/internal/web/service/node.go index a3463966e..ad0e1a3b1 100644 --- a/internal/web/service/node.go +++ b/internal/web/service/node.go @@ -90,6 +90,8 @@ func (s *NodeService) FetchCertFingerprint(ctx context.Context, n *model.Node) ( DialContext: netsafe.SSRFGuardedDialContext, TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // lgtm[go/disabled-certificate-check] }, + // The pin must be the certificate of the host asked, not of wherever it redirects. + CheckRedirect: runtime.NoCrossHostRedirect, } defer client.CloseIdleConnections() resp, err := client.Do(req) From 5a1ea427855640e30168446291c8dacfd0538ed3 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 31 Oct 2026 09:19:51 +0000 Subject: [PATCH 019/125] fix(node): bound what a node's answers can cost the master Node responses are untrusted input on the master, and a few shapes still cost it without bound under the 64 MiB body cap: - a success=false message became an error (logged, stored as lastError) in full; it is now cut to the 8 KiB the non-200 diagnostic already uses; - each "{}" of a reported list decodes into a whole struct, so lists are now decoded entry by entry with per-endpoint caps (1024 descendants, 65536 inbounds and host groups, 1M client-IP rows), and the GUID-keyed trees and maps are capped at 1M values; - a TLS handshake the node never answers kept a socket and goroutines alive forever, since the transport keeps dialing for its pool after the request gives up; node transports now give up after 10 s. --- internal/web/runtime/remote.go | 47 +++++++------- internal/web/runtime/remote_decode.go | 81 +++++++++++++++++++++++++ internal/web/runtime/remote_test.go | 28 +++++++++ internal/web/runtime/tls_client.go | 5 ++ internal/web/runtime/tls_client_test.go | 37 +++++++++++ 5 files changed, 172 insertions(+), 26 deletions(-) create mode 100644 internal/web/runtime/remote_decode.go diff --git a/internal/web/runtime/remote.go b/internal/web/runtime/remote.go index a156027c7..5a4b9a6f9 100644 --- a/internal/web/runtime/remote.go +++ b/internal/web/runtime/remote.go @@ -327,6 +327,7 @@ func (r *Remote) do(ctx context.Context, method, path string, body any) (*envelo return nil, fmt.Errorf("decode envelope: %w", err) } if !env.Success { + env.Msg = truncateNodeMessage(env.Msg) return &env, &remoteAPIError{msg: env.Msg} } return &env, nil @@ -422,8 +423,8 @@ func (r *Remote) ListInboundOptions(ctx context.Context) ([]RemoteInboundOption, if err != nil { return nil, err } - var list []RemoteInboundOption - if err := json.Unmarshal(env.Obj, &list); err != nil { + list, err := decodeCappedList[RemoteInboundOption](env.Obj, maxNodeInbounds) + if err != nil { return nil, fmt.Errorf("decode inbound list: %w", err) } return list, nil @@ -434,11 +435,11 @@ func (r *Remote) refreshRemoteIDs(ctx context.Context) error { if err != nil { return err } - var list []struct { + list, err := decodeCappedList[struct { Id int `json:"id"` Tag string `json:"tag"` - } - if err := json.Unmarshal(env.Obj, &list); err != nil { + }](env.Obj, maxNodeInbounds) + if err != nil { return fmt.Errorf("decode inbound list: %w", err) } next := make(map[string]int, len(list)) @@ -750,11 +751,9 @@ func (r *Remote) GetDescendants(ctx context.Context) ([]model.NodeSummary, error if err != nil { return nil, err } - var out []model.NodeSummary - if len(env.Obj) > 0 { - if err := json.Unmarshal(env.Obj, &out); err != nil { - return nil, fmt.Errorf("decode descendants: %w", err) - } + out, err := decodeCappedList[model.NodeSummary](env.Obj, maxNodeDescendants) + if err != nil { + return nil, fmt.Errorf("decode descendants: %w", err) } return out, nil } @@ -813,11 +812,9 @@ func (r *Remote) FetchHostGroups(ctx context.Context) ([]*entity.HostGroup, erro if err != nil { return nil, err } - var groups []*entity.HostGroup - if len(env.Obj) > 0 { - if err := json.Unmarshal(env.Obj, &groups); err != nil { - return nil, fmt.Errorf("decode host groups: %w", err) - } + groups, err := decodeCappedList[*entity.HostGroup](env.Obj, maxNodeHostGroups) + if err != nil { + return nil, fmt.Errorf("decode host groups: %w", err) } return groups, nil } @@ -829,7 +826,7 @@ func (r *Remote) FetchTrafficSnapshot(ctx context.Context) (*TrafficSnapshot, er if err != nil { return nil, err } - if err := json.Unmarshal(envList.Obj, &snap.Inbounds); err != nil { + if snap.Inbounds, err = decodeCappedList[*model.Inbound](envList.Obj, maxNodeInbounds); err != nil { return nil, fmt.Errorf("decode inbound list: %w", err) } @@ -837,14 +834,14 @@ func (r *Remote) FetchTrafficSnapshot(ctx context.Context) (*TrafficSnapshot, er // node is an old build without the per-GUID endpoint (#4983). envTree, err := r.do(ctx, http.MethodPost, "panel/api/clients/onlinesByGuid", nil) if err == nil && len(envTree.Obj) > 0 { - _ = json.Unmarshal(envTree.Obj, &snap.OnlineTree) + _ = decodeCappedValues(envTree.Obj, &snap.OnlineTree, maxNodeMapValues) } if len(snap.OnlineTree) == 0 { envOnlines, err := r.do(ctx, http.MethodPost, "panel/api/clients/onlines", nil) if err != nil { logger.Warning("remote", r.node.Name, "onlines fetch failed:", err) } else if len(envOnlines.Obj) > 0 { - _ = json.Unmarshal(envOnlines.Obj, &snap.OnlineEmails) + _ = decodeCappedValues(envOnlines.Obj, &snap.OnlineEmails, maxNodeMapValues) } } @@ -852,14 +849,14 @@ func (r *Remote) FetchTrafficSnapshot(ctx context.Context) (*TrafficSnapshot, er if err != nil { logger.Warning("remote", r.node.Name, "lastOnline fetch failed:", err) } else if len(envLastOnline.Obj) > 0 { - _ = json.Unmarshal(envLastOnline.Obj, &snap.LastOnlineMap) + _ = decodeCappedValues(envLastOnline.Obj, &snap.LastOnlineMap, maxNodeMapValues) } envActiveInbounds, err := r.do(ctx, http.MethodPost, "panel/api/clients/activeInbounds", nil) if err != nil { logger.Debugf("remote %s active inbounds fetch failed: %v", r.node.Name, err) } else if len(envActiveInbounds.Obj) > 0 { - _ = json.Unmarshal(envActiveInbounds.Obj, &snap.ActiveInboundTree) + _ = decodeCappedValues(envActiveInbounds.Obj, &snap.ActiveInboundTree, maxNodeMapValues) } return snap, nil @@ -989,11 +986,9 @@ func (r *Remote) FetchAllClientIps(ctx context.Context) ([]model.InboundClientIp if err != nil { return nil, err } - var ips []model.InboundClientIps - if len(env.Obj) > 0 { - if err := json.Unmarshal(env.Obj, &ips); err != nil { - return nil, fmt.Errorf("decode client ips: %w", err) - } + ips, err := decodeCappedList[model.InboundClientIps](env.Obj, maxNodeClientIPs) + if err != nil { + return nil, fmt.Errorf("decode client ips: %w", err) } return ips, nil } @@ -1014,7 +1009,7 @@ func (r *Remote) FetchClientIpsByGuid(ctx context.Context) (map[string]map[strin } out := map[string]map[string][]model.ClientIpEntry{} if len(env.Obj) > 0 { - if err := json.Unmarshal(env.Obj, &out); err != nil { + if err := decodeCappedValues(env.Obj, &out, maxNodeMapValues); err != nil { return nil, fmt.Errorf("decode client ips by guid: %w", err) } } diff --git a/internal/web/runtime/remote_decode.go b/internal/web/runtime/remote_decode.go new file mode 100644 index 000000000..2bd2be435 --- /dev/null +++ b/internal/web/runtime/remote_decode.go @@ -0,0 +1,81 @@ +package runtime + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "unicode/utf8" +) + +// Per-endpoint caps on node-reported lists: each "{}" decodes into a whole struct, so the +// byte cap alone still lets a node's answer expand into gigabytes. +const ( + maxNodeDescendants = 1024 + maxNodeInbounds = 65536 + maxNodeHostGroups = 65536 + maxNodeClientIPs = 1 << 20 + // maxNodeMapValues bounds every value of the GUID-keyed trees and maps a node reports. + maxNodeMapValues = 1 << 20 +) + +var errRemoteListTooLong = errors.New("remote list exceeds entry limit") + +// decodeCappedList decodes a JSON array entry by entry and stops at the first entry +// past maxEntries; null decodes to a nil slice. +func decodeCappedList[T any](raw json.RawMessage, maxEntries int) ([]T, error) { + raw = bytes.TrimSpace(raw) + if len(raw) == 0 || bytes.Equal(raw, []byte("null")) { + return nil, nil + } + dec := json.NewDecoder(bytes.NewReader(raw)) + if tok, err := dec.Token(); err != nil { + return nil, err + } else if delim, ok := tok.(json.Delim); !ok || delim != '[' { + return nil, fmt.Errorf("expected a JSON array, got %v", tok) + } + var out []T + for dec.More() { + if len(out) >= maxEntries { + return nil, fmt.Errorf("%w (%d)", errRemoteListTooLong, maxEntries) + } + var item T + if err := dec.Decode(&item); err != nil { + return nil, err + } + out = append(out, item) + } + return out, nil +} + +// decodeCappedValues unmarshals raw into v after checking it holds at most maxValues +// JSON values in all, nested ones included. +func decodeCappedValues(raw json.RawMessage, v any, maxValues int) error { + dec := json.NewDecoder(bytes.NewReader(raw)) + for count := 0; ; count++ { + tok, err := dec.Token() + if err != nil { + break + } + if delim, ok := tok.(json.Delim); ok && (delim == ']' || delim == '}') { + continue + } + if count > maxValues { + return fmt.Errorf("%w (%d values)", errRemoteListTooLong, maxValues) + } + } + return json.Unmarshal(raw, v) +} + +// truncateNodeMessage bounds a node-supplied message the master turns into an error, +// which is logged and stored as the node's lastError. +func truncateNodeMessage(msg string) string { + if len(msg) <= errBodyDiagBytes { + return msg + } + cut := errBodyDiagBytes + for cut > 0 && !utf8.RuneStart(msg[cut]) { + cut-- + } + return msg[:cut] + "... (truncated)" +} diff --git a/internal/web/runtime/remote_test.go b/internal/web/runtime/remote_test.go index a3cacf3db..1b79bd577 100644 --- a/internal/web/runtime/remote_test.go +++ b/internal/web/runtime/remote_test.go @@ -510,3 +510,31 @@ func TestRemoteAdoptedAliasYieldsToNodeReportedTag(t *testing.T) { t.Fatalf("central tag resolved to %d via a stale alias, want 5 (the id the node reports)", id) } } + +// A node's success=false message becomes an error the master logs and stores as the +// node's lastError; it must not carry the whole response body there. +func TestRemoteDo_BoundsTheNodeErrorMessage(t *testing.T) { + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"success":false,"msg":"` + strings.Repeat("m", 1<<20) + `"}`)) + })) + defer srv.Close() + r := NewRemote(nodeForServer(t, srv, "skip", ""), nil) + _, err := r.do(context.Background(), http.MethodGet, "/probe", nil) + if err == nil || len(err.Error()) > errBodyDiagBytes+64 { + t.Fatalf("error = %d bytes, want at most the %d-byte diagnostic", len(err.Error()), errBodyDiagBytes) + } +} + +// Each "{}" of a node-reported list decodes into a whole struct, so a body under the +// byte cap could still expand into gigabytes; lists are capped by entry count. +func TestRemoteNodeListsAreCappedByEntryCount(t *testing.T) { + descendants := "[" + strings.TrimSuffix(strings.Repeat("{},", maxNodeDescendants+1), ",") + "]" + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"success":true,"obj":` + descendants + `}`)) + })) + defer srv.Close() + r := NewRemote(nodeForServer(t, srv, "skip", ""), nil) + if got, err := r.GetDescendants(context.Background()); !errors.Is(err, errRemoteListTooLong) { + t.Fatalf("GetDescendants = %d entries, %v; want errRemoteListTooLong", len(got), err) + } +} diff --git a/internal/web/runtime/tls_client.go b/internal/web/runtime/tls_client.go index a9db7260a..4836e4b52 100644 --- a/internal/web/runtime/tls_client.go +++ b/internal/web/runtime/tls_client.go @@ -140,8 +140,13 @@ const ( maxIdleNodeConnsPerHost = 8 ) +// nodeTLSHandshakeTimeout ends a handshake the node never answers: the transport keeps +// dialing for its pool after the request gives up, so without it the socket lives forever. +var nodeTLSHandshakeTimeout = remoteHTTPTimeout + func newNodeTransport(tlsCfg *tls.Config) *http.Transport { return &http.Transport{ + TLSHandshakeTimeout: nodeTLSHandshakeTimeout, MaxIdleConns: maxIdleNodeConns, MaxIdleConnsPerHost: maxIdleNodeConnsPerHost, IdleConnTimeout: 60 * time.Second, diff --git a/internal/web/runtime/tls_client_test.go b/internal/web/runtime/tls_client_test.go index 1bae8063f..1b11f175b 100644 --- a/internal/web/runtime/tls_client_test.go +++ b/internal/web/runtime/tls_client_test.go @@ -16,6 +16,7 @@ import ( "sync" "sync/atomic" "testing" + "time" "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/util/crypto" @@ -693,3 +694,39 @@ func TestNodeClientsDoNotFollowARedirectToAnotherHost(t *testing.T) { t.Fatalf("a node's redirect reached another host %d time(s)", otherHit.Load()) } } + +// The transport keeps dialing for its pool after a request gives up, so a node that +// never answers the TLS handshake would hold a socket and two goroutines forever. +func TestNodeTransportGivesUpOnAStalledTLSHandshake(t *testing.T) { + previous := nodeTLSHandshakeTimeout + nodeTLSHandshakeTimeout = 200 * time.Millisecond + t.Cleanup(func() { nodeTLSHandshakeTimeout = previous }) + + lis, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer lis.Close() + closed := make(chan struct{}) + go func() { + conn, err := lis.Accept() + if err != nil { + return + } + defer conn.Close() + _, _ = io.Copy(io.Discard, conn) // returns once the master hangs up + close(closed) + }() + client := &http.Client{Transport: newNodeTransport(&tls.Config{InsecureSkipVerify: true})} + ctx, cancel := context.WithTimeout(netsafe.ContextWithAllowPrivate(context.Background(), true), 50*time.Millisecond) + defer cancel() + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "https://"+lis.Addr().String()+"/", nil) + if resp, err := client.Do(req); err == nil { + _ = resp.Body.Close() + } + select { + case <-closed: + case <-time.After(3 * time.Second): + t.Fatal("the stalled handshake's connection is still open") + } +} From 9cf8c87e8da3697117e35a95415664071556cd67 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 31 Oct 2026 09:19:54 +0000 Subject: [PATCH 020/125] fix(netsafe): give a guarded dial one time budget for every address SSRFGuardedDialContext tried each address of a name in turn with a fresh 10 s, so a name resolving to thousands of unanswering addresses held a dial (and the caller's goroutine) for hours; a node can choose such a name for itself. The whole call now shares one 10 s budget, divided over the remaining addresses with net.Dialer's 2 s floor. Multi-address DNS answers cannot be produced in a unit test here, so no test reaches it. --- internal/util/netsafe/netsafe.go | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/internal/util/netsafe/netsafe.go b/internal/util/netsafe/netsafe.go index 689425d65..fd656623c 100644 --- a/internal/util/netsafe/netsafe.go +++ b/internal/util/netsafe/netsafe.go @@ -65,11 +65,18 @@ func AllowPrivateFromContext(ctx context.Context) bool { var defaultDialer = &net.Dialer{Timeout: 10 * time.Second} +// minDialAttempt is net.Dialer's floor for one address of a name. +const minDialAttempt = 2 * time.Second + func SSRFGuardedDialContext(ctx context.Context, network, addr string) (net.Conn, error) { host, port, err := net.SplitHostPort(addr) if err != nil { return nil, err } + // One budget for the whole call, shared by the addresses as net.Dialer does: a name the + // peer controls may resolve to thousands of addresses that never answer. + ctx, cancel := context.WithTimeout(ctx, defaultDialer.Timeout) + defer cancel() allowPrivate := AllowPrivateFromContext(ctx) var ips []net.IPAddr if ip := net.ParseIP(host); ip != nil { @@ -81,12 +88,20 @@ func SSRFGuardedDialContext(ctx context.Context, network, addr string) (net.Conn } } var lastErr, blockedErr error - for _, ipAddr := range ips { + for i, ipAddr := range ips { if !allowPrivate && IsBlockedIP(ipAddr.IP) { blockedErr = fmt.Errorf("%w %s", ErrPrivateAddressBlocked, ipAddr.IP) continue } - conn, derr := defaultDialer.DialContext(ctx, network, net.JoinHostPort(ipAddr.IP.String(), port)) + deadline, _ := ctx.Deadline() + remaining := time.Until(deadline) + if remaining <= 0 { + break + } + attempt := max(remaining/time.Duration(len(ips)-i), min(minDialAttempt, remaining)) + attemptCtx, attemptCancel := context.WithTimeout(ctx, attempt) + conn, derr := defaultDialer.DialContext(attemptCtx, network, net.JoinHostPort(ipAddr.IP.String(), port)) + attemptCancel() if derr == nil { return conn, nil } From 87f3f54ea6626331429b5d62f29e53499cd93aa5 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 31 Oct 2026 09:22:24 +0000 Subject: [PATCH 021/125] fix(node): bound the panel GUIDs a peer reports A peer's panelGuid is copied into nodes.guid and into every inbound row it reports (an indexed column), a forwarded origin GUID likewise, and a GUID-keyed IP tree or a master's global-traffic push keys one row per client. One oversized value therefore multiplied into gigabytes stored. GUIDs over 64 bytes (panels mint 36-character UUIDs) now fail the heartbeat, fall back to the reporting node's key, or are dropped. --- internal/web/service/global_traffic_test.go | 15 +++++++++++++ internal/web/service/inbound_node.go | 2 +- internal/web/service/inbound_node_ips.go | 4 ++++ .../web/service/inbound_traffic_global.go | 2 +- internal/web/service/node.go | 8 +++++++ internal/web/service/node_origin_guid_test.go | 22 +++++++++++++++++++ .../web/service/node_probe_body_cap_test.go | 16 ++++++++++++++ 7 files changed, 67 insertions(+), 2 deletions(-) diff --git a/internal/web/service/global_traffic_test.go b/internal/web/service/global_traffic_test.go index c4ce99c9b..83c935aee 100644 --- a/internal/web/service/global_traffic_test.go +++ b/internal/web/service/global_traffic_test.go @@ -1,6 +1,7 @@ package service import ( + "strings" "testing" "time" @@ -233,3 +234,17 @@ func TestSnapshotListNotOverlaid_SlimOverlaid(t *testing.T) { t.Errorf("slim list should carry the global overlay, got up=%d", slim[0].ClientStats[0].Up) } } + +// The master GUID keys one stored row per client, so an oversized one is dropped. +func TestAcceptGlobalTrafficDropsAnOversizedMasterGuid(t *testing.T) { + db := initTrafficTestDB(t) + seedClientRow(t, "alice", 1, 0, 0, 0) + if err := (&InboundService{}).AcceptGlobalTraffic(strings.Repeat("m", maxPanelGuidLen+1), []*xray.ClientTraffic{{Email: "alice", Up: 1, Down: 1}}); err != nil { + t.Fatalf("AcceptGlobalTraffic: %v", err) + } + var rows int64 + db.Model(&model.ClientGlobalTraffic{}).Count(&rows) + if rows != 0 { + t.Fatalf("stored %d row(s) under an oversized master GUID", rows) + } +} diff --git a/internal/web/service/inbound_node.go b/internal/web/service/inbound_node.go index 4e8035af9..938512196 100644 --- a/internal/web/service/inbound_node.go +++ b/internal/web/service/inbound_node.go @@ -561,7 +561,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi selfKey := effectiveNodeKey(&model.Node{Id: nodeID, Guid: nodeRow.Guid}) guidShared := nodeRow.Guid != "" && selfKey != nodeRow.Guid originGuidFor := func(snapIb *model.Inbound) string { - if snapIb.OriginNodeGuid != "" && snapIb.OriginNodeGuid != nodeRow.Guid { + if snapIb.OriginNodeGuid != "" && snapIb.OriginNodeGuid != nodeRow.Guid && len(snapIb.OriginNodeGuid) <= maxPanelGuidLen { return snapIb.OriginNodeGuid } return selfKey diff --git a/internal/web/service/inbound_node_ips.go b/internal/web/service/inbound_node_ips.go index 43882d55d..7ef267cab 100644 --- a/internal/web/service/inbound_node_ips.go +++ b/internal/web/service/inbound_node_ips.go @@ -139,6 +139,10 @@ func (s *InboundService) MergeClientIpsByGuid(node *model.Node, trees map[string } } for guid, perEmail := range trees { + // Stored once per email: an oversized key would multiply into every row. + if len(guid) > maxPanelGuidLen { + continue + } if err := upsertNodeClientIps(guid, perEmail); err != nil { return err } diff --git a/internal/web/service/inbound_traffic_global.go b/internal/web/service/inbound_traffic_global.go index 46b9e3dea..43eea2022 100644 --- a/internal/web/service/inbound_traffic_global.go +++ b/internal/web/service/inbound_traffic_global.go @@ -19,7 +19,7 @@ const globalTrafficHeartbeatInterval = time.Hour // Identical usage refreshes hourly; client_traffics remains local-only for delta accounting. func (s *InboundService) AcceptGlobalTraffic(masterGuid string, traffics []*xray.ClientTraffic) error { masterGuid = strings.TrimSpace(masterGuid) - if masterGuid == "" { + if masterGuid == "" || len(masterGuid) > maxPanelGuidLen { return nil } emails := make([]string, 0, len(traffics)) diff --git a/internal/web/service/node.go b/internal/web/service/node.go index ad0e1a3b1..8803005e8 100644 --- a/internal/web/service/node.go +++ b/internal/web/service/node.go @@ -1285,6 +1285,10 @@ func (b *outboundBridge) removeInbound() { // broken node from dictating the master's allocation on every heartbeat. const maxProbeBodyBytes = 1 << 20 // 1 MiB +// maxPanelGuidLen bounds a GUID a peer reports: panels mint 36-character UUIDs, and the +// value is copied into every inbound row and indexed columns. +const maxPanelGuidLen = 64 + func (s *NodeService) probe(ctx context.Context, n *model.Node, proxyURL string) (HeartbeatPatch, error) { patch := HeartbeatPatch{LastHeartbeat: time.Now().Unix()} @@ -1392,6 +1396,10 @@ func (s *NodeService) probe(ctx context.Context, n *model.Node, proxyURL string) patch.XrayState = o.Xray.State patch.XrayError = o.Xray.ErrorMsg patch.PanelVersion = o.PanelVersion + if len(o.PanelGuid) > maxPanelGuidLen { + patch.LastError = fmt.Sprintf("remote panel reported a %d-byte panelGuid", len(o.PanelGuid)) + return patch, errors.New(patch.LastError) + } patch.Guid = o.PanelGuid patch.UptimeSecs = o.Uptime patch.NetUp = o.NetIO.Up diff --git a/internal/web/service/node_origin_guid_test.go b/internal/web/service/node_origin_guid_test.go index dc3a8d79a..c577db363 100644 --- a/internal/web/service/node_origin_guid_test.go +++ b/internal/web/service/node_origin_guid_test.go @@ -2,6 +2,7 @@ package service import ( "slices" + "strings" "testing" "github.com/mhsanaei/3x-ui/v3/internal/database" @@ -448,3 +449,24 @@ func TestSetRemoteTraffic_DefaultsShareAddressFieldsForNewCentralInbound(t *test t.Fatalf("new central inbound share fields = (%q, %q), want (node, empty)", ib.ShareAddrStrategy, ib.ShareAddr) } } + +// An origin GUID is copied into the inbound row and its index; one a node forwards over +// the GUID bound is attributed to the reporting node instead of being stored. +func TestSetRemoteTraffic_RefusesAnOversizedForwardedOrigin(t *testing.T) { + setupConflictDB(t) + db := database.GetDB() + if err := db.Create(&model.Node{Id: 1, Name: "node2", Address: "10.0.0.2", Port: 2053, ApiToken: "t", Guid: "node2-guid"}).Error; err != nil { + t.Fatalf("create node: %v", err) + } + snap := &runtime.TrafficSnapshot{Inbounds: []*model.Inbound{{ + Tag: "in-443-tcp", Enable: true, Port: 443, Protocol: model.VLESS, Settings: `{"clients":[]}`, + OriginNodeGuid: strings.Repeat("g", maxPanelGuidLen+1), + }}} + if _, err := (&InboundService{}).setRemoteTrafficLocked(1, snap, false, false); err != nil { + t.Fatalf("setRemoteTrafficLocked: %v", err) + } + var ib model.Inbound + if err := db.Where("tag = ?", "in-443-tcp").First(&ib).Error; err != nil || ib.OriginNodeGuid != "node2-guid" { + t.Fatalf("origin = %d bytes (%v), want the reporting node's GUID", len(ib.OriginNodeGuid), err) + } +} diff --git a/internal/web/service/node_probe_body_cap_test.go b/internal/web/service/node_probe_body_cap_test.go index 14ce485df..13e84df1a 100644 --- a/internal/web/service/node_probe_body_cap_test.go +++ b/internal/web/service/node_probe_body_cap_test.go @@ -48,3 +48,19 @@ func TestProbeRejectsOversizedStatusBody(t *testing.T) { t.Fatalf("LastError = %q, want a \"decode response: \" rejection", patch.LastError) } } + +// A panelGuid is copied into every inbound the node reports and into indexed columns, so +// one oversized value multiplies into gigabytes of stored copies. +func TestProbeRefusesAnOversizedPanelGuid(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"success":true,"obj":{"cpuPct":1,"panelGuid":"` + strings.Repeat("g", maxPanelGuidLen+1) + `"}}`)) + })) + defer srv.Close() + u, _ := url.Parse(srv.URL) + port, _ := strconv.Atoi(u.Port()) + n := &model.Node{Id: 1, Scheme: "http", Address: u.Hostname(), Port: port, BasePath: "/", Enable: true, AllowPrivateAddress: true} + patch, err := (&NodeService{}).Probe(context.Background(), n) + if err == nil || patch.Guid != "" || !strings.Contains(patch.LastError, "panelGuid") { + t.Fatalf("Probe = guid %d bytes, %v (%q); want the heartbeat refused", len(patch.Guid), err, patch.LastError) + } +} From d4b80b5d80f9473afd0abdfd90d1b91bfd2e76a6 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 31 Oct 2026 09:26:31 +0000 Subject: [PATCH 022/125] fix(node): merge each node inbound once and sweep what the node dropped Two shapes of a node's inbound list cost the master without bound: - an inbound listed many times was merged once per listing, every pass walking its whole client set again; the first listing now wins and the repeats are dropped; - disabled inbounds were never swept, an exemption meant for inbounds the master created but has not delivered yet, so a node could report any number of them and the master kept them all; the exemption now covers only rows without an origin, i.e. never reported by the node. --- internal/web/service/inbound_node.go | 20 +++++-- internal/web/service/node_sweep_guard_test.go | 53 +++++++++++++++++++ 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/internal/web/service/inbound_node.go b/internal/web/service/inbound_node.go index 938512196..e830009ac 100644 --- a/internal/web/service/inbound_node.go +++ b/internal/web/service/inbound_node.go @@ -716,8 +716,12 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi newInboundIDs := make(map[int]struct{}) + // Every pass below walks a central inbound's clients once per snapshot entry that + // resolves to it, so only the first such entry is merged; repeats are dropped. + mergedCentral := make(map[int]struct{}, len(central)) + snapTags := make(map[string]struct{}, len(snap.Inbounds)) - for _, snapIb := range snap.Inbounds { + for snapIdx, snapIb := range snap.Inbounds { if snapIb == nil { continue } @@ -761,6 +765,13 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi return false, fmt.Errorf("setRemoteTraffic: inbound %q has ambiguous compatible central aliases", snapIb.Tag) } } + if ok { + if _, merged := mergedCentral[c.Id]; merged { + snap.Inbounds[snapIdx] = nil + continue + } + mergedCentral[c.Id] = struct{}{} + } if !ok { if dirty { continue @@ -839,6 +850,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi } } newInboundIDs[newIb.Id] = struct{}{} + mergedCentral[newIb.Id] = struct{}{} structuralChange = true continue } @@ -929,9 +941,9 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi if dirty { continue } - // A node inbound created disabled is never delivered, so its absence from - // the snapshot is ambiguous rather than evidence of a node-side delete. - if !c.Enable { + // A node inbound created disabled is never delivered, so its absence from the snapshot + // is ambiguous; a copy the node once reported (it has an origin) is not. + if !c.Enable && c.OriginNodeGuid == "" { continue } if len(snapTags) == 0 { diff --git a/internal/web/service/node_sweep_guard_test.go b/internal/web/service/node_sweep_guard_test.go index 76d1cace4..f006ad748 100644 --- a/internal/web/service/node_sweep_guard_test.go +++ b/internal/web/service/node_sweep_guard_test.go @@ -2,6 +2,7 @@ package service import ( "fmt" + "slices" "testing" "github.com/mhsanaei/3x-ui/v3/internal/database" @@ -160,3 +161,55 @@ func TestSyncInboundStoresTrimmedEmail(t *testing.T) { t.Fatalf("links after re-sync = %d, want 1", links) } } + +// Only a master-created inbound not yet delivered is exempt from the sweep; otherwise a +// node could report endless disabled inbounds that the master keeps forever. +func TestDisabledInboundTheNodeReportedIsSwept(t *testing.T) { + db := initTrafficTestDB(t) + svc := &InboundService{} + seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true}) + keep := snapshotWithClients(t, "keep", `{"clients":[]}`) + reported := &runtime.TrafficSnapshot{Inbounds: append(keep.Inbounds, + &model.Inbound{Tag: "junk", Port: 41003, Protocol: model.VLESS, Enable: false, Settings: `{"clients":[]}`})} + if _, err := svc.setRemoteTrafficLocked(1, reported, false, false); err != nil { + t.Fatalf("first sync: %v", err) + } + nid := 1 + if err := db.Create(&model.Inbound{UserId: 1, Tag: "undelivered", Port: 41004, Protocol: model.VLESS, NodeID: &nid, Settings: `{"clients":[]}`}).Error; err != nil { + t.Fatalf("create undelivered inbound: %v", err) + } + if err := db.Model(&model.Inbound{}).Where("tag = ?", "undelivered").Update("enable", false).Error; err != nil { + t.Fatalf("disable: %v", err) + } + if _, err := svc.setRemoteTrafficLocked(1, snapshotWithClients(t, "keep", `{"clients":[]}`), false, false); err != nil { + t.Fatalf("second sync: %v", err) + } + var tags []string + db.Model(&model.Inbound{}).Order("tag").Pluck("tag", &tags) + if !slices.Equal(tags, []string{"keep", "undelivered"}) { + t.Fatalf("inbounds after the node stopped reporting junk = %v, want [keep undelivered]", tags) + } +} + +// A node listing one inbound many times made every pass repeat its whole client set; +// the first entry for an inbound is merged and the repeats are dropped. +func TestRepeatedSnapshotEntriesMergeTheInboundOnce(t *testing.T) { + db := initTrafficTestDB(t) + svc := &InboundService{} + seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true}) + snap := &runtime.TrafficSnapshot{Inbounds: []*model.Inbound{ + {Tag: "dup", Port: 41005, Protocol: model.VLESS, Enable: true, Remark: "first", Settings: `{"clients":[]}`}, + {Tag: "dup", Port: 41005, Protocol: model.VLESS, Enable: true, Remark: "second", Settings: `{"clients":[]}`}, + }} + for range 2 { + if _, err := svc.setRemoteTrafficLocked(1, snap, false, false); err != nil { + t.Fatalf("sync: %v", err) + } + snap.Inbounds[1] = &model.Inbound{Tag: "dup", Port: 41005, Protocol: model.VLESS, Enable: true, Remark: "second", Settings: `{"clients":[]}`} + } + var remarks []string + db.Model(&model.Inbound{}).Where("tag = ?", "dup").Pluck("remark", &remarks) + if !slices.Equal(remarks, []string{"first"}) { + t.Fatalf("remarks = %v, want the first entry merged once", remarks) + } +} From fd3fa869820366da14f12665d5f2b5130e0eeff0 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 31 Oct 2026 09:28:39 +0000 Subject: [PATCH 023/125] fix(node): drop a swept inbound's bookkeeping from the node runtime The node Remote keeps a fingerprint, a remote id and possibly an alias per inbound tag. When the master swept an inbound the node stopped reporting, those entries stayed, so a node rotating tags grew the maps for as long as the master ran. The sweep now hands every removed tag to Remote.ForgetInbound, which drops all of it in both tag forms, and a master-driven delete uses it too. The sweep's call is wiring; TestRemoteForgetInboundDropsEveryTrace covers what it drops. --- internal/web/node_contract_test.go | 1 + internal/web/runtime/remote.go | 19 ++++++++++++++++++- internal/web/runtime/remote_test.go | 16 ++++++++++++++++ internal/web/service/inbound_node.go | 7 ++++++- 4 files changed, 41 insertions(+), 2 deletions(-) diff --git a/internal/web/node_contract_test.go b/internal/web/node_contract_test.go index 4b1bd9f9e..01819e495 100644 --- a/internal/web/node_contract_test.go +++ b/internal/web/node_contract_test.go @@ -363,6 +363,7 @@ var remoteMethodsOutsideContract = map[string]string{ "AdoptedInboundAliases": "local alias bookkeeping", "AdvancePushedInbound": "local fingerprint bookkeeping", "ForgetPushedInbound": "local fingerprint bookkeeping", + "ForgetInbound": "local bookkeeping for an inbound the node no longer has", "UpdatePanel": "replaces the node binary; node-sync is denied it on purpose (#6201)", } diff --git a/internal/web/runtime/remote.go b/internal/web/runtime/remote.go index 5a4b9a6f9..f65aa47c2 100644 --- a/internal/web/runtime/remote.go +++ b/internal/web/runtime/remote.go @@ -498,7 +498,7 @@ func (r *Remote) DelInbound(ctx context.Context, ib *model.Inbound) error { if _, err := r.do(ctx, http.MethodPost, "panel/api/inbounds/del/"+strconv.Itoa(id), nil); err != nil { return err } - r.cacheDel(ib.Tag) + r.ForgetInbound(ib.Tag) return nil } @@ -581,6 +581,23 @@ func (r *Remote) ForgetPushedInbound(tag string) { r.mu.Unlock() } +// ForgetInbound drops everything kept under an inbound the node no longer has, in either +// tag form: a node rotating tags would otherwise grow these maps without bound. +func (r *Remote) ForgetInbound(tag string) { + prefix := nodeInboundTagPrefix(r.node.Id) + bare := strings.TrimPrefix(tag, prefix) + r.mu.Lock() + defer r.mu.Unlock() + for _, t := range []string{bare, prefix + bare} { + if alias, ok := r.adoptedAliases[t]; ok { + delete(r.remoteIDByTag, alias) + delete(r.adoptedAliases, t) + } + delete(r.remoteIDByTag, t) + delete(r.pushedFP, t) + } +} + // AdoptInboundAlias records a deployed alias without mutating either panel. // The runtime association is rediscovered after a master restart. func (r *Remote) AdoptInboundAlias(ib *model.Inbound, remote RemoteInboundOption) { diff --git a/internal/web/runtime/remote_test.go b/internal/web/runtime/remote_test.go index 1b79bd577..1720514a1 100644 --- a/internal/web/runtime/remote_test.go +++ b/internal/web/runtime/remote_test.go @@ -538,3 +538,19 @@ func TestRemoteNodeListsAreCappedByEntryCount(t *testing.T) { t.Fatalf("GetDescendants = %d entries, %v; want errRemoteListTooLong", len(got), err) } } + +// A node rotating inbound tags must not grow the per-tag maps forever: an inbound the +// master swept leaves nothing behind in either tag form, its adopted alias included. +func TestRemoteForgetInboundDropsEveryTrace(t *testing.T) { + n := &model.Node{Id: 7, Name: "n"} + r := NewRemote(n, nil) + ib := &model.Inbound{Tag: "n7-in-443", Port: 443, Protocol: model.VLESS} + r.AdoptInboundAlias(ib, RemoteInboundOption{Id: 3, Tag: "in-443"}) + r.RecordAdoptedInbound(&model.Inbound{Tag: "in-443", Port: 443, Protocol: model.VLESS}) + r.ForgetInbound("in-443") + r.mu.RLock() + defer r.mu.RUnlock() + if len(r.pushedFP)+len(r.remoteIDByTag)+len(r.adoptedAliases) != 0 { + t.Fatalf("left behind: pushedFP=%v ids=%v aliases=%v", r.pushedFP, r.remoteIDByTag, r.adoptedAliases) + } +} diff --git a/internal/web/service/inbound_node.go b/internal/web/service/inbound_node.go index e830009ac..e0839f905 100644 --- a/internal/web/service/inbound_node.go +++ b/internal/web/service/inbound_node.go @@ -704,6 +704,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi structuralChange := false lifecycleLifted := false + var sweptTags []string var adoptedInbounds []*model.Inbound type pendingAdopt struct { @@ -1036,6 +1037,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi return false, err } delete(tagToCentral, c.Tag) + sweptTags = append(sweptTags, c.Tag) structuralChange = true } @@ -1480,13 +1482,16 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi } } - if len(adoptedInbounds) > 0 { + if len(adoptedInbounds) > 0 || len(sweptTags) > 0 { if mgr := runtime.GetManager(); mgr != nil { if rt, rtErr := mgr.RuntimeFor(&nodeID); rtErr == nil { if rem, ok := rt.(*runtime.Remote); ok { for _, ib := range adoptedInbounds { rem.RecordAdoptedInbound(ib) } + for _, tag := range sweptTags { + rem.ForgetInbound(tag) + } } } } From 8ca321138ba9cfb5138bd5d6eb5b07989914a8ca Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 31 Oct 2026 10:34:35 +0100 Subject: [PATCH 024/125] fix(traffic): keep lifecycle statements under SQLite's variable limit Three statement shapes bound one SQL variable per row and failed past SQLite's limit, which a node can reach by itself with the clients and inbounds it reports: the depleted-client lookup and disable in disableInvalidClients (stopping every expiry and quota check), the whole-slice Saves of renewed inbounds and traffics (stopping renewals), and Preload("ClientStats") over all inbounds (stopping config builds). The IN lists are chunked, the Saves go 100 rows at a time, and client stats are attached in chunks instead of preloaded. --- internal/web/service/global_traffic_test.go | 74 +++++++++++++++++++++ internal/web/service/inbound.go | 20 ++++-- internal/web/service/inbound_disable.go | 12 ++-- internal/web/service/inbound_migration.go | 2 +- internal/web/service/inbound_traffic.go | 6 +- internal/web/service/inbound_util.go | 44 +++++++++++- 6 files changed, 145 insertions(+), 13 deletions(-) diff --git a/internal/web/service/global_traffic_test.go b/internal/web/service/global_traffic_test.go index 83c935aee..8412f0768 100644 --- a/internal/web/service/global_traffic_test.go +++ b/internal/web/service/global_traffic_test.go @@ -248,3 +248,77 @@ func TestAcceptGlobalTrafficDropsAnOversizedMasterGuid(t *testing.T) { t.Fatalf("stored %d row(s) under an oversized master GUID", rows) } } + +// A node can report tens of thousands of its own clients over quota; one IN list with a +// bound variable per email overran SQLite's limit and stopped every expiry and quota check. +func TestDisableInvalidClientsPastTheSQLVariableLimit(t *testing.T) { + db := initTrafficTestDB(t) + const n = 33000 + if err := db.Create(&model.Inbound{UserId: 1, Tag: "big", Port: 41100, Protocol: model.VLESS, Enable: true, Settings: `{"clients":[]}`}).Error; err != nil { + t.Fatalf("seed inbound: %v", err) + } + for _, stmt := range []string{ + `WITH RECURSIVE seq(i) AS (SELECT 1 UNION ALL SELECT i+1 FROM seq WHERE i < ?) + INSERT INTO client_traffics (inbound_id, email, enable, total, up, down) SELECT 1, 'c' || i, true, 1, 1, 1 FROM seq`, + `WITH RECURSIVE seq(i) AS (SELECT 1 UNION ALL SELECT i+1 FROM seq WHERE i < ?) + INSERT INTO clients (email, enable, sub_id) SELECT 'c' || i, true, 's' || i FROM seq`, + } { + if err := db.Exec(stmt, n).Error; err != nil { + t.Fatalf("seed: %v", err) + } + } + if err := db.Exec(`INSERT INTO client_inbounds (client_id, inbound_id) SELECT id, 1 FROM clients`).Error; err != nil { + t.Fatalf("link clients: %v", err) + } + _, count, _, err := (&InboundService{}).disableInvalidClients(db, newTrafficMutationBatch()) + if err != nil || count != n { + t.Fatalf("disableInvalidClients = %d, %v; want all %d disabled", count, err, n) + } +} + +// Preloading client stats binds one variable per inbound, so past SQLite's limit the +// master could not even build Xray's config; nodes report inbounds into the same table. +func TestGetAllInboundsPastTheSQLVariableLimit(t *testing.T) { + db := initTrafficTestDB(t) + const n = 33000 + if err := db.Exec(`WITH RECURSIVE seq(i) AS (SELECT 1 UNION ALL SELECT i+1 FROM seq WHERE i < ?) + INSERT INTO inbounds (user_id, tag, port, protocol, enable, settings, stream_settings, sniffing, listen, remark) + SELECT 1, 'in-' || i, i, 'vless', true, '{}', '', '', '', '' FROM seq`, n).Error; err != nil { + t.Fatalf("seed inbounds: %v", err) + } + seedClientRow(t, "alice", n, 0, 0, 0) + inbounds, err := (&InboundService{}).GetAllInbounds() + if err != nil || len(inbounds) != n { + t.Fatalf("GetAllInbounds = %d inbounds, %v; want %d", len(inbounds), err, n) + } + if last := inbounds[len(inbounds)-1]; len(last.ClientStats) != 1 || last.ClientStats[0].Email != "alice" { + t.Fatalf("last inbound's client stats = %v, want alice's row", last.ClientStats) + } +} + +// Saving every renewed inbound in one statement binds every column of every row, which +// overran SQLite's variable limit once enough inbounds held a client due for renewal. +func TestAutoRenewClientsPastTheSQLVariableLimit(t *testing.T) { + db := initTrafficTestDB(t) + const n = 1400 + for _, stmt := range []string{ + `WITH RECURSIVE seq(i) AS (SELECT 1 UNION ALL SELECT i+1 FROM seq WHERE i < ?) + INSERT INTO inbounds (user_id, tag, port, protocol, enable, settings, stream_settings, sniffing, listen, remark) + SELECT 1, 'in-' || i, i, 'vless', true, '{"clients":[{"email":"c' || i || '","enable":true,"expiryTime":1000,"reset":30}]}', '', '', '', '' FROM seq`, + `WITH RECURSIVE seq(i) AS (SELECT 1 UNION ALL SELECT i+1 FROM seq WHERE i < ?) + INSERT INTO clients (email, enable, sub_id, expiry_time, reset) SELECT 'c' || i, true, 's' || i, 1000, 30 FROM seq`, + `WITH RECURSIVE seq(i) AS (SELECT 1 UNION ALL SELECT i+1 FROM seq WHERE i < ?) + INSERT INTO client_traffics (inbound_id, email, enable, expiry_time, reset) SELECT i, 'c' || i, true, 1000, 30 FROM seq`, + } { + if err := db.Exec(stmt, n).Error; err != nil { + t.Fatalf("seed: %v", err) + } + } + if err := db.Exec(`INSERT INTO client_inbounds (client_id, inbound_id) SELECT c.id, i.id FROM clients c JOIN inbounds i ON i.tag = 'in-' || substr(c.email, 2)`).Error; err != nil { + t.Fatalf("link clients: %v", err) + } + _, count, err := (&InboundService{}).autoRenewClients(db, newTrafficMutationBatch()) + if err != nil || count != n { + t.Fatalf("autoRenewClients = %d, %v; want all %d renewed", count, err, n) + } +} diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go index 27d10dbee..24c67327f 100644 --- a/internal/web/service/inbound.go +++ b/internal/web/service/inbound.go @@ -192,7 +192,10 @@ func normalizeInboundShareAddressColumns(tx *gorm.DB) error { func (s *InboundService) GetInbounds(userId int) ([]*model.Inbound, error) { db := database.GetDB() var inbounds []*model.Inbound - err := db.Model(model.Inbound{}).Preload("ClientStats").Where("user_id = ?", userId).Order("id ASC").Find(&inbounds).Error + err := db.Model(model.Inbound{}).Where("user_id = ?", userId).Order("id ASC").Find(&inbounds).Error + if err == nil { + err = attachClientStats(db, inbounds) + } if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err } @@ -235,7 +238,10 @@ func (s *InboundService) annotateLocalOriginGuid(inbounds []*model.Inbound) { func (s *InboundService) GetInboundsSlim(userId int) ([]*model.Inbound, error) { db := database.GetDB() var inbounds []*model.Inbound - err := db.Model(model.Inbound{}).Preload("ClientStats").Where("user_id = ?", userId).Order("id ASC").Find(&inbounds).Error + err := db.Model(model.Inbound{}).Where("user_id = ?", userId).Order("id ASC").Find(&inbounds).Error + if err == nil { + err = attachClientStats(db, inbounds) + } if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err } @@ -552,7 +558,10 @@ func inboundMtprotoDomain(protocol string, settings string) string { func (s *InboundService) GetAllInbounds() ([]*model.Inbound, error) { db := database.GetDB() var inbounds []*model.Inbound - err := db.Model(model.Inbound{}).Preload("ClientStats").Find(&inbounds).Error + err := db.Model(model.Inbound{}).Find(&inbounds).Error + if err == nil { + err = attachClientStats(db, inbounds) + } if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err } @@ -2620,7 +2629,10 @@ func (s *InboundService) GetClientReverseTags() (string, error) { func (s *InboundService) SearchInbounds(query string) ([]*model.Inbound, error) { db := database.GetDB() var inbounds []*model.Inbound - err := db.Model(model.Inbound{}).Preload("ClientStats").Where("remark like ?", "%"+query+"%").Find(&inbounds).Error + err := db.Model(model.Inbound{}).Where("remark like ?", "%"+query+"%").Find(&inbounds).Error + if err == nil { + err = attachClientStats(db, inbounds) + } if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err } diff --git a/internal/web/service/inbound_disable.go b/internal/web/service/inbound_disable.go index 72264a422..c29b68fda 100644 --- a/internal/web/service/inbound_disable.go +++ b/internal/web/service/inbound_disable.go @@ -105,7 +105,10 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB, mutationBatch *traff Email string } var targets []target - if len(depletedEmails) > 0 { + // Chunked like every other statement here: a node alone can report more depleted + // clients than SQLite binds variables, which would stop the whole tick. + for _, batch := range chunkStrings(depletedEmails, sqlInChunk) { + var found []target err = tx.Raw(` SELECT inbounds.id AS inbound_id, inbounds.node_id AS node_id, inbounds.tag AS tag, clients.email AS email @@ -113,10 +116,11 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB, mutationBatch *traff JOIN client_inbounds ON client_inbounds.client_id = clients.id JOIN inbounds ON inbounds.id = client_inbounds.inbound_id WHERE clients.email IN ? - `, depletedEmails).Scan(&targets).Error + `, batch).Scan(&found).Error if err != nil { return false, 0, nil, err } + targets = append(targets, found...) } byInbound := make(map[int][]target) @@ -167,9 +171,9 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB, mutationBatch *traff count += result.RowsAffected } - if len(depletedEmails) > 0 { + for _, batch := range chunkStrings(depletedEmails, sqlInChunk) { if err := tx.Model(&model.ClientRecord{}). - Where("email IN ?", depletedEmails). + Where("email IN ?", batch). Updates(map[string]any{"enable": false, "updated_at": now}).Error; err != nil { return false, count, nil, err } diff --git a/internal/web/service/inbound_migration.go b/internal/web/service/inbound_migration.go index 3030c4531..e3e96e192 100644 --- a/internal/web/service/inbound_migration.go +++ b/internal/web/service/inbound_migration.go @@ -196,7 +196,7 @@ func (s *InboundService) MigrationRequirements() (err error) { return } } - if err = tx.Save(inbounds).Error; err != nil { + if err = saveInChunks(tx, inbounds); err != nil { return } diff --git a/internal/web/service/inbound_traffic.go b/internal/web/service/inbound_traffic.go index fb8129377..6456aa1a9 100644 --- a/internal/web/service/inbound_traffic.go +++ b/internal/web/service/inbound_traffic.go @@ -335,7 +335,7 @@ func (s *InboundService) adjustTraffics(tx *gorm.DB, dbClientTraffics []*xray.Cl } } - err = tx.Save(inbounds).Error + err = saveInChunks(tx, inbounds) if err != nil { logger.Warning("AddClientTraffic update inbounds ", err) logger.Error(inbounds) @@ -533,7 +533,7 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB, mutationBatch *trafficMut } inbounds[inbound_index].Settings = string(newSettings) } - err = tx.Save(inbounds).Error + err = saveInChunks(tx, inbounds) if err != nil { return false, 0, err } @@ -550,7 +550,7 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB, mutationBatch *trafficMut logger.Warning("autoRenewClients sync clients: SyncInbound failed", syncErr) } } - err = tx.Save(traffics).Error + err = saveInChunks(tx, traffics) if err != nil { return false, 0, err } diff --git a/internal/web/service/inbound_util.go b/internal/web/service/inbound_util.go index 2917612d0..3f2d5c22d 100644 --- a/internal/web/service/inbound_util.go +++ b/internal/web/service/inbound_util.go @@ -1,6 +1,13 @@ package service -import "strings" +import ( + "strings" + + "gorm.io/gorm" + + "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/xray" +) // sqliteMaxVars is a safe ceiling for the number of bind parameters in a // single SQL statement. SQLite's SQLITE_MAX_VARIABLE_NUMBER is 999 on builds @@ -102,3 +109,38 @@ func chunkCount(n, size int) int { } return count } + +// attachClientStats fills each inbound's ClientStats in chunks: GORM's Preload binds one +// variable per inbound, and nodes alone can report more inbounds than SQLite binds. +func attachClientStats(db *gorm.DB, inbounds []*model.Inbound) error { + byID := make(map[int]*model.Inbound, len(inbounds)) + ids := make([]int, 0, len(inbounds)) + for _, ib := range inbounds { + byID[ib.Id] = ib + ids = append(ids, ib.Id) + } + for _, batch := range chunkInts(ids, sqliteMaxVars) { + var stats []xray.ClientTraffic + if err := db.Where("inbound_id IN ?", batch).Order("id ASC").Find(&stats).Error; err != nil { + return err + } + for _, st := range stats { + if ib := byID[st.InboundId]; ib != nil { + ib.ClientStats = append(ib.ClientStats, st) + } + } + } + return nil +} + +// saveInChunks saves rows a slice at a time: one slice Save binds every column of every +// row in a single statement, past SQLite's variable limit once there are enough rows. +func saveInChunks[T any](tx *gorm.DB, rows []T) error { + const rowsPerStatement = 100 + for start := 0; start < len(rows); start += rowsPerStatement { + if err := tx.Save(rows[start:min(start+rowsPerStatement, len(rows))]).Error; err != nil { + return err + } + } + return nil +} From a2030569493ac37fdb55c8ebfef49b8879a5ea8f Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 31 Oct 2026 10:39:13 +0100 Subject: [PATCH 025/125] fix(node): rewrite a node's settings blob without decoding all of it Dedupe, tombstone stripping and the lifecycle lift decoded a node's whole settings blob into generic maps and wrote it back indented, so a deeply nested value anywhere in it cost the square of its depth on every merge (10 KB became 50 MB). They now split the blob one level deep, read only email, expiryTime and enable of each client entry, keep everything else as raw text and write the result compactly. --- .../web/service/client_add_dedupe_test.go | 21 ++ internal/web/service/client_locks.go | 201 +++++++++--------- 2 files changed, 123 insertions(+), 99 deletions(-) diff --git a/internal/web/service/client_add_dedupe_test.go b/internal/web/service/client_add_dedupe_test.go index ae3c95f5c..3d7054475 100644 --- a/internal/web/service/client_add_dedupe_test.go +++ b/internal/web/service/client_add_dedupe_test.go @@ -2,12 +2,14 @@ package service import ( "encoding/json" + "strings" "testing" "github.com/google/uuid" "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/xray" ) func mustUnmarshal(t *testing.T, raw string, v any) { @@ -93,3 +95,22 @@ func TestDedupeSettingsClients(t *testing.T) { t.Fatal("invalid settings must not be rewritten") } } + +// A node's settings blob may nest any value deeply beside its clients; decoding the whole +// blob and re-indenting it cost the square of the depth for every one of these rewrites. +func TestSettingsClientRewritesStayLinearInTheBlob(t *testing.T) { + deep := strings.Repeat("[", 5000) + strings.Repeat("]", 5000) + settings := `{"decoy":` + deep + `,"clients":[{"email":"a@x","expiryTime":-86400000,"enable":true},{"email":"A@x"}]}` + out, changed := dedupeSettingsClients(settings) + if !changed || len(out) > 2*len(settings) { + t.Fatalf("dedupe changed=%v, output %d bytes from %d", changed, len(out), len(settings)) + } + lifted, changed := liftClientLifecycleInSettings(out, map[string]*xray.ClientTraffic{"a@x": {Email: "a@x", ExpiryTime: 1700000000000}}) + if !changed || len(lifted) > 2*len(settings) || !strings.Contains(lifted, `"expiryTime":1700000000000`) || !strings.Contains(lifted, `"enable":false`) { + t.Fatalf("lift changed=%v, output %d bytes: %.120s", changed, len(lifted), lifted) + } + clients, err := ParseInboundSettingsClients(lifted) + if err != nil || len(clients) != 1 || clients[0].Email != "a@x" { + t.Fatalf("clients after the rewrites = %+v, %v", clients, err) + } +} diff --git a/internal/web/service/client_locks.go b/internal/web/service/client_locks.go index e77bfce6e..faf5976fe 100644 --- a/internal/web/service/client_locks.go +++ b/internal/web/service/client_locks.go @@ -2,6 +2,7 @@ package service import ( "encoding/json" + "strconv" "strings" "sync" "time" @@ -168,46 +169,75 @@ func isClientEmailTombstoned(email string) bool { return true } +// settingsClientEntry is what these rewrites read of a client entry. A node's blob may +// nest any value deeply, so entries stay raw text and only these fields are decoded. +type settingsClientEntry struct { + Email string `json:"email"` + ExpiryTime json.RawMessage `json:"expiryTime"` + Enable json.RawMessage `json:"enable"` +} + +// splitSettingsClients decodes a settings blob one level deep; ok is false when it has +// no client array to work on. +func splitSettingsClients(settings string) (members map[string]json.RawMessage, entries []json.RawMessage, ok bool) { + if settings == "" || json.Unmarshal([]byte(settings), &members) != nil { + return nil, nil, false + } + if json.Unmarshal(members["clients"], &entries) != nil { + return nil, nil, false + } + return members, entries, true +} + +func readSettingsClient(entry json.RawMessage) (settingsClientEntry, bool) { + var c settingsClientEntry + if len(entry) == 0 || entry[0] != '{' || json.Unmarshal(entry, &c) != nil { + return c, false + } + return c, true +} + +// joinSettingsClients writes the blob back compactly; indenting it again would cost the +// square of whatever depth the node nested into it. +func joinSettingsClients(settings string, members map[string]json.RawMessage, entries []json.RawMessage) (string, bool) { + clients, err := json.Marshal(entries) + if err != nil { + return settings, false + } + members["clients"] = clients + b, err := json.Marshal(members) + if err != nil { + return settings, false + } + return string(b), true +} + // dedupeSettingsClients collapses duplicate same-email client entries inside a // settings JSON blob, keeping the first occurrence. Node snapshots produced by // builds without the addInboundClient duplicate guard can carry duplicates // (#5770); adopting them verbatim would copy the duplication into the central // inbound. Returns the filtered JSON and whether anything was removed. func dedupeSettingsClients(settings string) (string, bool) { - if settings == "" { + members, entries, ok := splitSettingsClients(settings) + if !ok || len(entries) < 2 { return settings, false } - var parsed map[string]any - if err := json.Unmarshal([]byte(settings), &parsed); err != nil { - return settings, false - } - clients, _ := parsed["clients"].([]any) - if len(clients) < 2 { - return settings, false - } - seen := make(map[string]struct{}, len(clients)) - kept := make([]any, 0, len(clients)) - for _, c := range clients { - if cm, ok := c.(map[string]any); ok { - if email, _ := cm["email"].(string); email != "" { - key := strings.ToLower(email) - if _, dup := seen[key]; dup { - continue - } - seen[key] = struct{}{} + seen := make(map[string]struct{}, len(entries)) + kept := make([]json.RawMessage, 0, len(entries)) + for _, entry := range entries { + if c, ok := readSettingsClient(entry); ok && c.Email != "" { + key := strings.ToLower(c.Email) + if _, dup := seen[key]; dup { + continue } + seen[key] = struct{}{} } - kept = append(kept, c) + kept = append(kept, entry) } - if len(kept) == len(clients) { + if len(kept) == len(entries) { return settings, false } - parsed["clients"] = kept - b, err := json.MarshalIndent(parsed, "", " ") - if err != nil { - return settings, false - } - return string(b), true + return joinSettingsClients(settings, members, kept) } // stripTombstonedClients drops just-deleted client entries from a node @@ -215,125 +245,98 @@ func dedupeSettingsClients(settings string) (string, bool) { // the central inbound while the delete tombstone is live. Returns the filtered // JSON and whether anything was removed. func stripTombstonedClients(settings string) (string, bool) { - if settings == "" { + members, entries, ok := splitSettingsClients(settings) + if !ok || len(entries) == 0 { return settings, false } - var parsed map[string]any - if err := json.Unmarshal([]byte(settings), &parsed); err != nil { - return settings, false - } - clients, _ := parsed["clients"].([]any) - if len(clients) == 0 { - return settings, false - } - kept := make([]any, 0, len(clients)) - for _, c := range clients { - if cm, ok := c.(map[string]any); ok { - if email, _ := cm["email"].(string); email != "" && isClientEmailTombstoned(email) { - continue - } + kept := make([]json.RawMessage, 0, len(entries)) + for _, entry := range entries { + if c, ok := readSettingsClient(entry); ok && c.Email != "" && isClientEmailTombstoned(c.Email) { + continue } - kept = append(kept, c) + kept = append(kept, entry) } - if len(kept) == len(clients) { + if len(kept) == len(entries) { return settings, false } - parsed["clients"] = kept - b, err := json.MarshalIndent(parsed, "", " ") - if err != nil { - return settings, false - } - return string(b), true + return joinSettingsClients(settings, members, kept) } // liftClientLifecycleInSettings rewrites adopted settings from master traffic // so a lagging node blob cannot store pre-extension expiry/enable (#6228). func liftClientLifecycleInSettings(settings string, trafficByEmail map[string]*xray.ClientTraffic) (string, bool) { - if settings == "" || len(trafficByEmail) == 0 { + if len(trafficByEmail) == 0 { return settings, false } - var parsed map[string]any - if err := json.Unmarshal([]byte(settings), &parsed); err != nil { - return settings, false - } - clients, _ := parsed["clients"].([]any) - if len(clients) == 0 { + members, entries, ok := splitSettingsClients(settings) + if !ok || len(entries) == 0 { return settings, false } changed := false - for i := range clients { - cm, ok := clients[i].(map[string]any) - if !ok { + for i, entry := range entries { + c, ok := readSettingsClient(entry) + if !ok || c.Email == "" { continue } - email, _ := cm["email"].(string) - if email == "" { - continue - } - tr := trafficByEmail[email] + tr := trafficByEmail[c.Email] if tr == nil { continue } - nodeExpiry, hasExpiry := jsonClientInt64(cm["expiryTime"]) + nodeExpiry, hasExpiry := jsonClientInt64(c.ExpiryTime) if !hasExpiry { continue } merged := mergeActivationExpiry(tr.ExpiryTime, nodeExpiry) - if merged != nodeExpiry { - cm["expiryTime"] = merged - changed = true - } + var nodeEnable bool + _ = json.Unmarshal(c.Enable, &nodeEnable) // tr is the already-merged master row, authoritative in both directions: // a lagging blob must not re-enable a disabled client either (#4917). - if nodeEnable, _ := cm["enable"].(bool); nodeEnable != tr.Enable { - cm["enable"] = tr.Enable - changed = true + if merged == nodeExpiry && nodeEnable == tr.Enable { + continue } - clients[i] = cm + var fields map[string]json.RawMessage + if json.Unmarshal(entry, &fields) != nil { + continue + } + fields["expiryTime"] = json.RawMessage(strconv.FormatInt(merged, 10)) + fields["enable"] = json.RawMessage(strconv.FormatBool(tr.Enable)) + rewritten, err := json.Marshal(fields) + if err != nil { + continue + } + entries[i] = rewritten + changed = true } if !changed { return settings, false } - parsed["clients"] = clients - b, err := json.MarshalIndent(parsed, "", " ") - if err != nil { - return settings, false - } - return string(b), true + return joinSettingsClients(settings, members, entries) } // settingsClientAbsoluteExpiries indexes the absolute (>0) expiryTime of every // client in a settings blob. Never nil, so callers can cache it per inbound. func settingsClientAbsoluteExpiries(settings string) map[string]int64 { out := map[string]int64{} - if settings == "" { + _, entries, ok := splitSettingsClients(settings) + if !ok { return out } - var parsed map[string]any - if err := json.Unmarshal([]byte(settings), &parsed); err != nil { - return out - } - clients, _ := parsed["clients"].([]any) - for _, c := range clients { - cm, ok := c.(map[string]any) - if !ok { + for _, entry := range entries { + c, ok := readSettingsClient(entry) + if !ok || c.Email == "" { continue } - email, _ := cm["email"].(string) - if email == "" { - continue - } - if exp, has := jsonClientInt64(cm["expiryTime"]); has && exp > 0 { - out[email] = exp + if exp, has := jsonClientInt64(c.ExpiryTime); has && exp > 0 { + out[c.Email] = exp } } return out } -func jsonClientInt64(v any) (int64, bool) { - // json.Unmarshal into map[string]any yields float64 for numbers. - n, ok := v.(float64) - if !ok { +// jsonClientInt64 reads a JSON number, as encoding/json would into a float64. +func jsonClientInt64(raw json.RawMessage) (int64, bool) { + var n float64 + if len(raw) == 0 || json.Unmarshal(raw, &n) != nil { return 0, false } return int64(n), true From f67111a08a20b64d0217d912613e4c80400ee4a8 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 31 Oct 2026 10:43:40 +0100 Subject: [PATCH 026/125] fix(traffic): keep enforcing limits when one inbound's settings are broken Disabling depleted clients rewrote each affected inbound's settings and aborted the whole lifecycle tick when one blob did not parse, so a node storing a malformed blob on the master switched expiry and quota checks off for every inbound. Unparseable node settings are now treated like a snapshot that drops the hub's clients (the hub keeps its copy), and an unparseable stored blob is left as it is while the client's rows are still disabled; the rewrite also works one level deep like the others. --- internal/web/service/global_traffic_test.go | 33 ++++++++++++++ internal/web/service/inbound_disable.go | 45 ++++++++++++------- internal/web/service/inbound_node.go | 4 +- internal/web/service/node_sweep_guard_test.go | 21 +++++++++ 4 files changed, 85 insertions(+), 18 deletions(-) diff --git a/internal/web/service/global_traffic_test.go b/internal/web/service/global_traffic_test.go index 8412f0768..b195f7cdd 100644 --- a/internal/web/service/global_traffic_test.go +++ b/internal/web/service/global_traffic_test.go @@ -322,3 +322,36 @@ func TestAutoRenewClientsPastTheSQLVariableLimit(t *testing.T) { t.Fatalf("autoRenewClients = %d, %v; want all %d renewed", count, err, n) } } + +// One inbound whose stored settings do not parse (a node can send such a blob) must not +// stop the master disabling depleted clients everywhere else. +func TestDisableInvalidClientsSurvivesAnInboundWithBrokenSettings(t *testing.T) { + db := initTrafficTestDB(t) + for _, ib := range []*model.Inbound{ + {UserId: 1, Tag: "broken", Port: 41201, Protocol: model.VLESS, Enable: true, Settings: `{"clients":[`}, + {UserId: 1, Tag: "local", Port: 41202, Protocol: model.VLESS, Enable: true, Settings: `{"clients":[{"email":"bob","enable":true}]}`}, + } { + if err := db.Create(ib).Error; err != nil { + t.Fatalf("seed inbound: %v", err) + } + } + for i, email := range []string{"alice", "bob"} { + seedClientRow(t, email, i+1, 10, 10, 5) + rec := &model.ClientRecord{Email: email, Enable: true, SubID: "s-" + email} + if err := db.Create(rec).Error; err != nil { + t.Fatalf("seed client: %v", err) + } + if err := db.Create(&model.ClientInbound{ClientId: rec.Id, InboundId: i + 1}).Error; err != nil { + t.Fatalf("link client: %v", err) + } + } + _, count, _, err := (&InboundService{}).disableInvalidClients(db, newTrafficMutationBatch()) + if err != nil || count != 2 { + t.Fatalf("disableInvalidClients = %d, %v; want both depleted clients disabled", count, err) + } + var bob model.Inbound + db.Where("tag = ?", "local").First(&bob) + if !strings.Contains(bob.Settings, `"enable":false`) { + t.Fatalf("local inbound settings = %s, want bob disabled", bob.Settings) + } +} diff --git a/internal/web/service/inbound_disable.go b/internal/web/service/inbound_disable.go index c29b68fda..45e2632f1 100644 --- a/internal/web/service/inbound_disable.go +++ b/internal/web/service/inbound_disable.go @@ -3,9 +3,11 @@ package service import ( "encoding/json" "slices" + "strconv" "time" "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/logger" "github.com/mhsanaei/3x-ui/v3/internal/xray" "gorm.io/gorm" @@ -197,39 +199,48 @@ func (s *InboundService) markClientsDisabledInSettings(tx *gorm.DB, inboundID in } snapshot := ib - settings := map[string]any{} - if err := json.Unmarshal([]byte(ib.Settings), &settings); err != nil { - return nil, nil, err + // A blob that does not parse (a node can send one) leaves the settings as they are: the + // stats and client rows still disable the client, and one inbound must not stop the tick. + members, entries, ok := splitSettingsClients(ib.Settings) + if !ok { + logger.Warningf("disable clients: inbound %d settings do not parse; leaving them unchanged", inboundID) + return &snapshot, &ib, nil } - clients, _ := settings["clients"].([]any) now := time.Now().Unix() * 1000 mutated := false - for i := range clients { - entry, ok := clients[i].(map[string]any) + for i, entry := range entries { + c, ok := readSettingsClient(entry) if !ok { continue } - email, _ := entry["email"].(string) - if _, hit := emails[email]; !hit { + if _, hit := emails[c.Email]; !hit { continue } - if cur, _ := entry["enable"].(bool); !cur { + var enabled bool + if json.Unmarshal(c.Enable, &enabled) != nil || !enabled { continue } - entry["enable"] = false - entry["updated_at"] = now - clients[i] = entry + var fields map[string]json.RawMessage + if json.Unmarshal(entry, &fields) != nil { + continue + } + fields["enable"] = json.RawMessage("false") + fields["updated_at"] = json.RawMessage(strconv.FormatInt(now, 10)) + rewritten, err := json.Marshal(fields) + if err != nil { + continue + } + entries[i] = rewritten mutated = true } if !mutated { return &snapshot, &ib, nil } - settings["clients"] = clients - bs, marshalErr := json.MarshalIndent(settings, "", " ") - if marshalErr != nil { - return nil, nil, marshalErr + joined, ok := joinSettingsClients(ib.Settings, members, entries) + if !ok { + return &snapshot, &ib, nil } - ib.Settings = string(bs) + ib.Settings = joined if err := tx.Model(&model.Inbound{}).Where("id = ?", inboundID). Update("settings", ib.Settings).Error; err != nil { return nil, nil, err diff --git a/internal/web/service/inbound_node.go b/internal/web/service/inbound_node.go index e0839f905..1b888a0e8 100644 --- a/internal/web/service/inbound_node.go +++ b/internal/web/service/inbound_node.go @@ -468,7 +468,9 @@ func adoptedWireInbound(c, snapIb *model.Inbound, adoptedSettings string) *model // still links some: a reset or half-started node, never an authoritative removal. func snapshotDropsEveryHubClient(tx *gorm.DB, inboundID int, wireSettings string) bool { clients, err := ParseInboundSettingsClients(wireSettings) - if err != nil || len(clients) > 0 { + // Settings that do not parse list no client the hub could keep either; storing them + // would only hand every later reader of the row a blob it cannot use. + if err == nil && len(clients) > 0 { return false } var links int64 diff --git a/internal/web/service/node_sweep_guard_test.go b/internal/web/service/node_sweep_guard_test.go index f006ad748..74640ef6f 100644 --- a/internal/web/service/node_sweep_guard_test.go +++ b/internal/web/service/node_sweep_guard_test.go @@ -213,3 +213,24 @@ func TestRepeatedSnapshotEntriesMergeTheInboundOnce(t *testing.T) { t.Fatalf("remarks = %v, want the first entry merged once", remarks) } } + +// Settings a node sends that do not parse must not replace the hub's copy: every later +// reader of the row (expiry, quota, renewals, subscriptions) would get a blob it cannot use. +func TestUnparseableSnapshotSettingsKeepTheHubCopy(t *testing.T) { + db := initTrafficTestDB(t) + svc := &InboundService{} + seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true}) + createNodeInboundWithClient(t, db, 1, "keep", 41301, "kept@x") + good := `{"clients":[{"email":"kept@x","enable":true}]}` + if _, err := svc.setRemoteTrafficLocked(1, snapshotWithClients(t, "keep", good, xray.ClientTraffic{Email: "kept@x", Enable: true}), false, false); err != nil { + t.Fatalf("seed sync: %v", err) + } + if _, err := svc.setRemoteTrafficLocked(1, snapshotWithClients(t, "keep", `{"clients":[`, xray.ClientTraffic{Email: "kept@x", Enable: true}), false, false); err != nil { + t.Fatalf("broken sync: %v", err) + } + var ib model.Inbound + db.Where("tag = ?", "keep").First(&ib) + if _, err := ParseInboundSettingsClients(ib.Settings); err != nil { + t.Fatalf("stored settings %q no longer parse: %v", ib.Settings, err) + } +} From 469758837a768db19e1d5c325a37c1fbd377ad5b Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 31 Oct 2026 10:44:59 +0100 Subject: [PATCH 027/125] fix(node): reconcile the local core after node disables without forcing it The node traffic sync restarted the master's own Xray with force after any tick that disabled clients, so a node reporting a fresh over-quota client every few seconds dropped every local user's connections each time, though nodes serve none of them. Use the non-forced restart the local traffic job uses: an unchanged local config keeps the core, and a change still restarts when restartXrayOnClientDisable asks for it. Reaching RestartXray needs a running core, so no unit test covers this. --- internal/web/job/node_traffic_sync_job.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/web/job/node_traffic_sync_job.go b/internal/web/job/node_traffic_sync_job.go index aaa3b22b0..b81938c88 100644 --- a/internal/web/job/node_traffic_sync_job.go +++ b/internal/web/job/node_traffic_sync_job.go @@ -140,7 +140,9 @@ func (j *NodeTrafficSyncJob) Run() { } if clientsDisabled { if restartOnDisable, settingErr := j.settingService.GetRestartXrayOnClientDisable(); settingErr == nil && restartOnDisable { - if err := j.xrayService.RestartXray(true); err != nil { + // Not forced: a client only nodes serve leaves the local config unchanged, and a node + // deciding when this tick disables clients must not decide when local users drop. + if err := j.xrayService.RestartXray(false); err != nil { logger.Warning("node traffic sync: restart xray after disabling clients failed:", err) j.xrayService.SetToNeedRestart() } From d005d89a8bd686460411e3e363eb2cc0495c94ac Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 31 Oct 2026 14:01:45 +0100 Subject: [PATCH 028/125] fix(node): settle each queued node reset on its own A per-client reset the node never accepted stopped the delivery loop: resets queued before it were re-sent on every sync, each time wiping the usage made since, and those after it were never sent. Each reset now settles by itself, a refused one moves to the back of the queue, and the loop goes on until the context is spent. A node also delivered its master's reset to its own sub-nodes inline, so one silent sub-node held the master's request until it timed out and was replayed; the inline attempt now waits at most a second and the rest finishes, or stays queued, in the background. --- internal/web/service/node_reset_queue.go | 74 +++++++++++++------ .../service/node_reset_undelivered_test.go | 66 +++++++++++++++++ 2 files changed, 117 insertions(+), 23 deletions(-) diff --git a/internal/web/service/node_reset_queue.go b/internal/web/service/node_reset_queue.go index 5772a23db..1b0d41a75 100644 --- a/internal/web/service/node_reset_queue.go +++ b/internal/web/service/node_reset_queue.go @@ -8,6 +8,7 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/logger" + "github.com/mhsanaei/3x-ui/v3/internal/util/common" "github.com/mhsanaei/3x-ui/v3/internal/web/runtime" "gorm.io/gorm" @@ -94,12 +95,13 @@ func (s *InboundService) DeliverNodeResets(ctx context.Context, nodeID int, rt r defer lock.(*sync.Mutex).Unlock() db := database.GetDB() var rows []model.NodePendingReset - if err := db.Where("node_id = ?", nodeID).Order("id").Find(&rows).Error; err != nil { + if err := db.Where("node_id = ?", nodeID).Order("queued_at, id").Find(&rows).Error; err != nil { return err } if len(rows) == 0 { return nil } + var failed error bulk, canBulk := rt.(nodeBulkResetter) for start := 0; start < len(rows); start += sqlInChunk { batch := rows[start:min(start+sqlInChunk, len(rows))] @@ -107,27 +109,41 @@ func (s *InboundService) DeliverNodeResets(ctx context.Context, nodeID int, rt r for i := range batch { emails[i] = batch[i].Email } - var err error if canBulk && len(batch) > nodeBulkPushThreshold { - err = bulk.ResetClientTraffics(ctx, emails) - } else { - for _, email := range emails { - if err = rt.ResetClientTraffic(ctx, nil, email); err != nil { - break + if err := bulk.ResetClientTraffics(ctx, emails); err != nil { + return err + } + for i := range batch { + if err := settleNodeReset(db, &batch[i]); err != nil { + return err } } + continue } - if err != nil { - return err - } + // Each reset settles on its own: one the node never accepts must neither hold back + // the rest nor have them re-sent, which would wipe the usage made in between. for i := range batch { - if err := db.Where("id = ? AND queued_at = ?", batch[i].Id, batch[i].QueuedAt). - Delete(&model.NodePendingReset{}).Error; err != nil { + if err := rt.ResetClientTraffic(ctx, nil, batch[i].Email); err != nil { + failed = err + // To the back of the queue, so it cannot spend the next attempt's budget first. + db.Model(&model.NodePendingReset{}).Where("id = ? AND queued_at = ?", batch[i].Id, batch[i].QueuedAt). + Update("queued_at", time.Now().UnixNano()) + if ctx.Err() != nil { + return failed + } + continue + } + if err := settleNodeReset(db, &batch[i]); err != nil { return err } } } - return nil + return failed +} + +// settleNodeReset drops a delivered row unless it was re-queued while it was being sent. +func settleNodeReset(db *gorm.DB, row *model.NodePendingReset) error { + return db.Where("id = ? AND queued_at = ?", row.Id, row.QueuedAt).Delete(&model.NodePendingReset{}).Error } // deliverNodeResetsNow tries each node once right after a reset commits; what @@ -137,16 +153,28 @@ func (s *InboundService) deliverNodeResetsNow(nodeIDs []int) { if mgr == nil || len(nodeIDs) == 0 { return } - fanoutInboundResults(nodeIDs, nodeFanoutConcurrency, func(i int) struct{} { - rt, err := mgr.RuntimeFor(&nodeIDs[i]) - if err != nil { + done := make(chan struct{}) + common.GoRecover("node-reset-delivery", func() { + defer close(done) + fanoutInboundResults(nodeIDs, nodeFanoutConcurrency, func(i int) struct{} { + rt, err := mgr.RuntimeFor(&nodeIDs[i]) + if err != nil { + return struct{}{} + } + ctx, cancel := nodePushContext() + defer cancel() + if err := s.DeliverNodeResets(ctx, nodeIDs[i], rt); err != nil { + logger.Warning("reset delivery to", rt.Name(), "deferred to the next sync:", err) + } return struct{}{} - } - ctx, cancel := nodePushContext() - defer cancel() - if err := s.DeliverNodeResets(ctx, nodeIDs[i], rt); err != nil { - logger.Warning("reset delivery to", rt.Name(), "deferred to the next sync:", err) - } - return struct{}{} + }) }) + // A silent node must not hold the request that queued the reset (a node relaying its + // master's reset): the master would time out and replay it, wiping usage made since. + select { + case <-done: + case <-time.After(nodeResetInlineWait): + } } + +const nodeResetInlineWait = time.Second diff --git a/internal/web/service/node_reset_undelivered_test.go b/internal/web/service/node_reset_undelivered_test.go index 535157b37..f2f71ed60 100644 --- a/internal/web/service/node_reset_undelivered_test.go +++ b/internal/web/service/node_reset_undelivered_test.go @@ -275,3 +275,69 @@ func TestConcurrentNodeResetDeliveriesSendOnce(t *testing.T) { t.Fatalf("node received resets %v, want exactly [once]", got) } } + +// selectiveResetRuntime refuses the resets of the emails in refuse. +type selectiveResetRuntime struct { + resetRecordingRuntime + refuse map[string]bool +} + +func (r *selectiveResetRuntime) ResetClientTraffic(ctx context.Context, ib *model.Inbound, email string) error { + if r.refuse[email] { + return errors.New("refused") + } + return r.resetRecordingRuntime.ResetClientTraffic(ctx, ib, email) +} + +// One reset the node never accepts must not hold back the ones queued after it, nor +// have the ones before it re-sent (each re-send wipes the usage made in between). +func TestNodeResetsSettleEachClientOnItsOwn(t *testing.T) { + nodeID, _, _ := setupRecordingNode(t, false) + now := time.Now().UnixNano() + for i, email := range []string{"a", "stuck", "c"} { + if err := database.GetDB().Create(&model.NodePendingReset{NodeId: nodeID, Email: email, QueuedAt: now + int64(i)}).Error; err != nil { + t.Fatalf("queue %s: %v", email, err) + } + } + rt := &selectiveResetRuntime{refuse: map[string]bool{"stuck": true}} + for range 2 { + _ = (&InboundService{}).DeliverNodeResets(context.Background(), nodeID, rt) + } + if got := rt.delivered(); !slices.Equal(got, []string{"a", "c"}) { + t.Fatalf("node received %v, want a and c once each", got) + } + if left := pendingResetEmails(t, nodeID); !slices.Equal(left, []string{"stuck"}) { + t.Fatalf("still queued: %v, want only the refused reset", left) + } +} + +// stallingResetRuntime never answers a reset until its context ends. +type stallingResetRuntime struct{ fakeNodeRuntime } + +func (*stallingResetRuntime) ResetClientTraffic(ctx context.Context, _ *model.Inbound, _ string) error { + <-ctx.Done() + return ctx.Err() +} + +// A node relays its master's reset to its own sub-nodes inline; one silent sub-node must +// not hold that request until the master times out and sends the reset again. +func TestNodeResetDeliveryDoesNotHoldTheRequestBehindASilentNode(t *testing.T) { + setupBulkDB(t) + mgr := useTestRuntimeManager(t) + node := &model.Node{Name: "silent", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true, Status: "online"} + if err := database.GetDB().Create(node).Error; err != nil { + t.Fatalf("create node: %v", err) + } + mgr.SetRuntimeOverride(node.Id, &stallingResetRuntime{}) + ib := nodeInbound(t, node.Id, 41912, []model.Client{{Email: "held", ID: "11111111-1111-1111-1111-1111111111ab", Enable: true}}) + if err := (&InboundService{}).AddClientStat(database.GetDB(), ib.Id, &model.Client{Email: "held", Enable: true}); err != nil { + t.Fatalf("AddClientStat: %v", err) + } + start := time.Now() + if _, err := (&InboundService{}).ResetClientTraffic(ib.Id, "held"); err != nil { + t.Fatalf("ResetClientTraffic: %v", err) + } + if took := time.Since(start); took > 2*time.Second { + t.Fatalf("the reset request took %v behind a silent node", took) + } +} From 4d27313c5e7e482ceb61b0541680841442012fb7 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 31 Oct 2026 14:07:08 +0100 Subject: [PATCH 029/125] fix(node): give each node one push budget per traffic sync After a sync tick disabled clients, the master pushed every affected node inbound with its own 4 s timeout, a few at a time, and the job served no other node until all were done; a hanging node could stall it for as long as it had inbounds to report. Pushes are now grouped by node and each node shares one budget, still a few inbounds at a time; what does not make it stays for the reconcile, the node being dirty already. --- internal/web/service/inbound_traffic_apply.go | 42 +++++++++++++------ .../service/node_reset_undelivered_test.go | 30 +++++++++++++ 2 files changed, 59 insertions(+), 13 deletions(-) diff --git a/internal/web/service/inbound_traffic_apply.go b/internal/web/service/inbound_traffic_apply.go index 3d962f30d..d557e91f5 100644 --- a/internal/web/service/inbound_traffic_apply.go +++ b/internal/web/service/inbound_traffic_apply.go @@ -2,6 +2,7 @@ package service import ( "context" + "slices" "strings" "github.com/mhsanaei/3x-ui/v3/internal/database/model" @@ -62,21 +63,36 @@ func (b *trafficMutationBatch) markNodesTx(tx *gorm.DB) error { // applyTrafficRemotePlans is bounded like every per-client node push: the nodes are // already dirty, so an offline or slow one defers to the reconcile. func (s *InboundService) applyTrafficRemotePlans(plans []trafficInboundUpdatePlan) bool { - ids := make([]int, len(plans)) + byNode := make(map[int][]int) + var nodeIDs []int for i := range plans { - ids[i] = plans[i].newInbound.Id + nodeID := 0 + if plans[i].newInbound.NodeID != nil { + nodeID = *plans[i].newInbound.NodeID + } + if _, seen := byNode[nodeID]; !seen { + nodeIDs = append(nodeIDs, nodeID) + } + byNode[nodeID] = append(byNode[nodeID], i) } - failed, panics := fanoutInboundResults(ids, inboundFanoutConcurrency, func(i int) bool { - rt, push, _, err := s.nodePushPlan(&plans[i].newInbound) - if err == nil && push { - ctx, cancel := nodePushContext() - err = rt.UpdateInbound(ctx, &plans[i].oldInbound, &plans[i].newInbound) - cancel() - } - if err != nil { - logger.Debug("traffic post-commit remote apply failed:", err) - } - return err != nil + failed, panics := fanoutInboundResults(nodeIDs, nodeFanoutConcurrency, func(n int) bool { + // One budget per node, not per inbound: a node choosing how many inbounds it reports + // must not choose how long the sync job waits on it before serving other nodes. + ctx, cancel := nodePushContext() + defer cancel() + indexes := byNode[nodeIDs[n]] + nodeFailed, nodePanics := fanoutInboundResults(indexes, inboundFanoutConcurrency, func(k int) bool { + plan := &plans[indexes[k]] + rt, push, _, err := s.nodePushPlan(&plan.newInbound) + if err == nil && push { + err = rt.UpdateInbound(ctx, &plan.oldInbound, &plan.newInbound) + } + if err != nil { + logger.Debug("traffic post-commit remote apply failed:", err) + } + return err != nil + }) + return slices.Contains(nodeFailed, true) || slices.ContainsFunc(nodePanics, func(p error) bool { return p != nil }) }) needRestart := false for i := range failed { diff --git a/internal/web/service/node_reset_undelivered_test.go b/internal/web/service/node_reset_undelivered_test.go index f2f71ed60..653bbcf1b 100644 --- a/internal/web/service/node_reset_undelivered_test.go +++ b/internal/web/service/node_reset_undelivered_test.go @@ -341,3 +341,33 @@ func TestNodeResetDeliveryDoesNotHoldTheRequestBehindASilentNode(t *testing.T) { t.Fatalf("the reset request took %v behind a silent node", took) } } + +// stallingUpdateRuntime never answers an inbound update until its context ends. +type stallingUpdateRuntime struct{ fakeNodeRuntime } + +func (*stallingUpdateRuntime) UpdateInbound(ctx context.Context, _, _ *model.Inbound) error { + <-ctx.Done() + return ctx.Err() +} + +// The sync job waits on these pushes before it serves any other node, so a hanging node +// gets one push budget in all, not one per inbound it chose to report. +func TestTrafficRemotePlansWaitOneBudgetPerNode(t *testing.T) { + setupBulkDB(t) + mgr := useTestRuntimeManager(t) + node := &model.Node{Name: "hanging", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true, Status: "online"} + if err := database.GetDB().Create(node).Error; err != nil { + t.Fatalf("create node: %v", err) + } + mgr.SetRuntimeOverride(node.Id, &stallingUpdateRuntime{}) + plans := make([]trafficInboundUpdatePlan, 9) + for i := range plans { + ib := model.Inbound{Id: i + 1, Tag: fmt.Sprintf("in-%d", i), NodeID: &node.Id} + plans[i] = trafficInboundUpdatePlan{oldInbound: ib, newInbound: ib} + } + start := time.Now() + (&InboundService{}).applyTrafficRemotePlans(plans) + if took := time.Since(start); took > nodeClientPushTimeout+2*time.Second { + t.Fatalf("pushing %d inbounds to one hanging node took %v", len(plans), took) + } +} From a69f2dee13937c184456347cd8c112baf8497f74 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 31 Oct 2026 14:14:01 +0100 Subject: [PATCH 030/125] fix(node): keep a node's online and IP reports to its own clients A node's online tree and both client-IP reports were stored as sent: they could name any client of the master or another node, under any panel's GUID, dated into the future, and in any number. That marked master clients online, fed source addresses for clients the node does not serve into the IP limit and its fail2ban log, and planted rows the retention sweep never removed. Each report is now cut to the clients linked to the node's inbounds and to the node's own key and its sub-nodes' origins (never this panel's or another node's GUID); timestamps are capped at now, and one client's list from a node keeps at most its 256 newest entries. --- internal/web/job/node_traffic_sync_job.go | 2 +- internal/web/service/inbound_client_ips.go | 50 ++++++++++++++ internal/web/service/inbound_node.go | 42 +++++++++++- internal/web/service/inbound_node_ips.go | 65 +++++++++++++++++++ internal/web/service/node_shared_guid_test.go | 12 ++++ .../web/service/node_unsynced_online_test.go | 65 +++++++++++++++++++ 6 files changed, 233 insertions(+), 3 deletions(-) diff --git a/internal/web/job/node_traffic_sync_job.go b/internal/web/job/node_traffic_sync_job.go index b81938c88..2d194641a 100644 --- a/internal/web/job/node_traffic_sync_job.go +++ b/internal/web/job/node_traffic_sync_job.go @@ -452,7 +452,7 @@ func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSy nodeIps, err := rt.FetchAllClientIps(ipCtx) if err == nil && len(nodeIps) > 0 { - if err := j.inboundService.MergeInboundClientIps(nodeIps); err != nil { + if err := j.inboundService.MergeNodeClientIps(n.Id, nodeIps); err != nil { logger.Warningf("node traffic sync: merge client ips from %s failed: %v", n.Name, err) } } else if err != nil { diff --git a/internal/web/service/inbound_client_ips.go b/internal/web/service/inbound_client_ips.go index 31039af4c..82c3e80dc 100644 --- a/internal/web/service/inbound_client_ips.go +++ b/internal/web/service/inbound_client_ips.go @@ -307,3 +307,53 @@ func (s *InboundService) PruneStaleClientIps() error { } return pruneStaleNodeClientIps(cutoff) } + +// nodeAttachedEmails is every client linked to one of the node's inbounds here: what a +// node reports about any other client (online state, source IPs) is not its to report. +func nodeAttachedEmails(db *gorm.DB, nodeID int) (map[string]struct{}, error) { + var emails []string + err := db.Table("clients"). + Joins("JOIN client_inbounds ON client_inbounds.client_id = clients.id"). + Joins("JOIN inbounds ON inbounds.id = client_inbounds.inbound_id"). + Where("inbounds.node_id = ?", nodeID). + Distinct().Pluck("clients.email", &emails).Error + attached := make(map[string]struct{}, len(emails)) + for _, email := range emails { + attached[email] = struct{}{} + } + return attached, err +} + +// MergeNodeClientIps merges one node's flat client-IP report, cut to the node's clients. +func (s *InboundService) MergeNodeClientIps(nodeID int, rows []model.InboundClientIps) error { + attached, err := nodeAttachedEmails(database.GetDB(), nodeID) + if err != nil { + return err + } + now := time.Now().Unix() + kept := make([]model.InboundClientIps, 0, len(rows)) + for _, row := range rows { + if _, ok := attached[row.ClientEmail]; !ok { + continue + } + var entries []model.ClientIpEntry + if json.Unmarshal([]byte(row.Ips), &entries) != nil { + continue + } + clampClientIpEntries(entries, now) + row.Ips, _ = model.MarshalClientIpEntries(entries) + kept = append(kept, row) + } + if len(kept) == 0 { + return nil + } + return s.MergeInboundClientIps(kept) +} + +// clampClientIpEntries caps reported timestamps at now: a future-dated entry would never +// age out of the retention window and would keep counting toward the IP limit. +func clampClientIpEntries(entries []model.ClientIpEntry, now int64) { + for i := range entries { + entries[i].Timestamp = min(entries[i].Timestamp, now) + } +} diff --git a/internal/web/service/inbound_node.go b/internal/web/service/inbound_node.go index 1b888a0e8..c47ef8672 100644 --- a/internal/web/service/inbound_node.go +++ b/internal/web/service/inbound_node.go @@ -1499,7 +1499,17 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi } } + // The online report outlives this merge, so it is cut first to the node's own clients. + if len(snap.OnlineTree) > 0 || len(snap.OnlineEmails) > 0 { + attached, err := nodeAttachedEmails(db, nodeID) + if err != nil { + logger.Warningf("setRemoteTraffic: load the clients of node %d failed, dropping its online report: %v", nodeID, err) + } + keepAttachedOnlineEmails(snap, attached) + } + if process := currentXrayProcess(); process != nil { + hostedGuids := activeInboundGuidKeys(snap.Inbounds, tagToCentral, originGuidFor) tree := snap.OnlineTree switch { case len(tree) == 0 && len(snap.OnlineEmails) > 0: @@ -1514,7 +1524,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi tree = remapGuidTreeKey(tree, nodeRow.Guid, selfKey) } } - process.SetNodeOnlineTree(nodeID, tree) + process.SetNodeOnlineTree(nodeID, filterGuidTreeKeys(tree, hostedGuids)) activeTree := normalizeActiveInboundTreeTags(snap.ActiveInboundTree, tagToCentral) if guidShared && len(activeTree) > 0 { @@ -1523,7 +1533,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi } } if len(activeTree) > 0 { - activeTree = filterGuidTreeKeys(activeTree, activeInboundGuidKeys(snap.Inbounds, tagToCentral, originGuidFor)) + activeTree = filterGuidTreeKeys(activeTree, hostedGuids) } process.SetNodeActiveInboundTree(nodeID, activeTree) } @@ -1700,6 +1710,34 @@ func remapGuidTreeKey(tree map[string][]string, from, to string) map[string][]st return remapped } +// keepAttachedOnlineEmails cuts a node's online report to the clients linked to its inbounds, +// once each per GUID, into fresh slices so the reported ones are not pinned. +func keepAttachedOnlineEmails(snap *runtime.TrafficSnapshot, attached map[string]struct{}) { + keep := func(emails []string) []string { + var kept []string + seen := make(map[string]struct{}) + for _, email := range emails { + if _, ok := attached[email]; !ok { + continue + } + if _, dup := seen[email]; dup { + continue + } + seen[email] = struct{}{} + kept = append(kept, email) + } + return kept + } + snap.OnlineEmails = keep(snap.OnlineEmails) + tree := make(map[string][]string, len(snap.OnlineTree)) + for guid, emails := range snap.OnlineTree { + if kept := keep(emails); len(kept) > 0 { + tree[guid] = kept + } + } + snap.OnlineTree = tree +} + func normalizeActiveInboundTreeTags(tree map[string][]string, tagToCentral map[string]*model.Inbound) map[string][]string { if len(tree) == 0 { return nil diff --git a/internal/web/service/inbound_node_ips.go b/internal/web/service/inbound_node_ips.go index 7ef267cab..76f75fd46 100644 --- a/internal/web/service/inbound_node_ips.go +++ b/internal/web/service/inbound_node_ips.go @@ -1,13 +1,16 @@ package service import ( + "cmp" "encoding/json" + "slices" "sort" "time" "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "gorm.io/gorm" "gorm.io/gorm/clause" ) @@ -138,6 +141,13 @@ func (s *InboundService) MergeClientIpsByGuid(node *model.Node, trees map[string } } } + if node != nil { + scoped, err := scopeNodeClientIps(database.GetDB(), node, trees) + if err != nil { + return err + } + trees = scoped + } for guid, perEmail := range trees { // Stored once per email: an oversized key would multiply into every row. if len(guid) > maxPanelGuidLen { @@ -334,3 +344,58 @@ func pruneStaleNodeClientIps(cutoff int64) error { } return nil } + +// maxReportedIpsPerClient bounds one (node, client) list; the newest entries are kept. +const maxReportedIpsPerClient = 256 + +// scopeNodeClientIps keeps only what a node may report: its own key and its sub-nodes' +// origins (never this panel's or another node's), its own clients, and no future dates. +func scopeNodeClientIps(db *gorm.DB, node *model.Node, trees map[string]map[string][]model.ClientIpEntry) (map[string]map[string][]model.ClientIpEntry, error) { + allowed := map[string]struct{}{effectiveNodeKey(node): {}} + var origins []string + if err := db.Model(&model.Inbound{}).Where("node_id = ? AND origin_node_guid <> ''", node.Id). + Distinct().Pluck("origin_node_guid", &origins).Error; err != nil { + return nil, err + } + for _, origin := range origins { + allowed[origin] = struct{}{} + } + var others []*model.Node + if err := db.Where("id <> ?", node.Id).Find(&others).Error; err != nil { + return nil, err + } + for _, other := range others { + delete(allowed, effectiveNodeKey(other)) + delete(allowed, other.Guid) + } + if panelGuid, err := (&SettingService{}).GetPanelGuid(); err == nil { + delete(allowed, panelGuid) + } + attached, err := nodeAttachedEmails(db, node.Id) + if err != nil { + return nil, err + } + now := time.Now().Unix() + scoped := make(map[string]map[string][]model.ClientIpEntry, len(trees)) + for guid, perEmail := range trees { + if _, ok := allowed[guid]; !ok { + continue + } + kept := make(map[string][]model.ClientIpEntry, len(perEmail)) + for email, entries := range perEmail { + if _, ok := attached[email]; !ok { + continue + } + clampClientIpEntries(entries, now) + if len(entries) > maxReportedIpsPerClient { + slices.SortFunc(entries, func(a, b model.ClientIpEntry) int { return cmp.Compare(b.Timestamp, a.Timestamp) }) + entries = entries[:maxReportedIpsPerClient] + } + kept[email] = entries + } + if len(kept) > 0 { + scoped[guid] = kept + } + } + return scoped, nil +} diff --git a/internal/web/service/node_shared_guid_test.go b/internal/web/service/node_shared_guid_test.go index bb77f1ceb..94d7d1a79 100644 --- a/internal/web/service/node_shared_guid_test.go +++ b/internal/web/service/node_shared_guid_test.go @@ -142,6 +142,18 @@ func TestMergeClientIpsByGuid_RemapsClonedNodeSubtree(t *testing.T) { t.Fatalf("create node: %v", err) } } + // A node reports only clients linked to its own inbounds. + nodeIb := &model.Inbound{UserId: 1, Tag: "a-in", Port: 41501, Protocol: model.VLESS, NodeID: &n1.Id, Settings: `{"clients":[]}`} + client := &model.ClientRecord{Email: "u@x", Enable: true, SubID: "s-u"} + if err := db.Create(nodeIb).Error; err != nil { + t.Fatalf("create inbound: %v", err) + } + if err := db.Create(client).Error; err != nil { + t.Fatalf("create client: %v", err) + } + if err := db.Create(&model.ClientInbound{ClientId: client.Id, InboundId: nodeIb.Id}).Error; err != nil { + t.Fatalf("link client: %v", err) + } if err := svc.MergeClientIpsByGuid(n1, map[string]map[string][]model.ClientIpEntry{ "dup": {"u@x": {{IP: "1.1.1.1", Timestamp: now}}}, diff --git a/internal/web/service/node_unsynced_online_test.go b/internal/web/service/node_unsynced_online_test.go index 7a272aa1b..486c64123 100644 --- a/internal/web/service/node_unsynced_online_test.go +++ b/internal/web/service/node_unsynced_online_test.go @@ -3,7 +3,10 @@ package service import ( "fmt" "reflect" + "strconv" + "strings" "testing" + "time" "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/model" @@ -67,3 +70,65 @@ func TestSetRemoteTrafficFailureClearsNodeOnlineClients(t *testing.T) { t.Errorf("online by guid after a failed merge = %v, want none", got) } } + +// A node may report only the clients linked to its own inbounds, under its own GUID: an +// unscoped report marked any master client online and grew without bound. +func TestSetRemoteTrafficKeepsOnlyTheNodesOwnClientsOnline(t *testing.T) { + db := initTrafficTestDB(t) + useOnlineTestProcess(t) + svc := &InboundService{} + seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true, Guid: "node-guid"}) + createNodeInboundWithClient(t, db, 1, "keep", 41401, "kept@x") + settings := `{"clients":[{"email":"kept@x","enable":true}]}` + snap := snapshotWithClients(t, "keep", settings, xray.ClientTraffic{Email: "kept@x", Enable: true}) + if _, err := svc.setRemoteTrafficLocked(1, snap, false, false); err != nil { + t.Fatalf("seed sync: %v", err) + } + snap = snapshotWithClients(t, "keep", settings, xray.ClientTraffic{Email: "kept@x", Enable: true}) + snap.OnlineTree = map[string][]string{"node-guid": {"kept@x", "master-only@x", "kept@x"}, "someone-else": {"kept@x"}} + if _, err := svc.setRemoteTrafficLocked(1, snap, false, false); err != nil { + t.Fatalf("sync: %v", err) + } + if got, want := svc.GetOnlineClientsByGuid(), map[string][]string{"node-guid": {"kept@x"}}; !reflect.DeepEqual(got, want) { + t.Fatalf("online by guid = %v, want %v", got, want) + } +} + +// The client-IP reports feed the IP limit and fail2ban; a node must not plant rows for +// clients it does not serve, under other panels' GUIDs, or dated into the future. +func TestNodeClientIpReportsAreScopedToTheNode(t *testing.T) { + db := initTrafficTestDB(t) + svc := &InboundService{} + node := &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true, Guid: "node-guid"} + seedNodeRow(t, db, node) + createNodeInboundWithClient(t, db, 1, "keep", 41402, "kept@x") + settings := `{"clients":[{"email":"kept@x","enable":true}]}` + if _, err := svc.setRemoteTrafficLocked(1, snapshotWithClients(t, "keep", settings, xray.ClientTraffic{Email: "kept@x", Enable: true}), false, false); err != nil { + t.Fatalf("seed sync: %v", err) + } + future := time.Now().Add(240 * time.Hour).Unix() + entries := func(ip string) string { return fmt.Sprintf(`[{"ip":%q,"timestamp":%d}]`, ip, future) } + if err := svc.MergeNodeClientIps(1, []model.InboundClientIps{ + {ClientEmail: "kept@x", Ips: entries("198.51.100.1")}, + {ClientEmail: "master-only@x", Ips: entries("198.51.100.2")}, + }); err != nil { + t.Fatalf("MergeNodeClientIps: %v", err) + } + var flat []model.InboundClientIps + db.Find(&flat) + if len(flat) != 1 || flat[0].ClientEmail != "kept@x" || strings.Contains(flat[0].Ips, strconv.FormatInt(future, 10)) { + t.Fatalf("stored client IPs = %+v, want only kept@x, dated no later than now", flat) + } + tree := map[string]map[string][]model.ClientIpEntry{ + "node-guid": {"kept@x": {{IP: "198.51.100.3", Timestamp: future}}, "master-only@x": {{IP: "198.51.100.4", Timestamp: future}}}, + "someone-else": {"kept@x": {{IP: "198.51.100.5", Timestamp: future}}}, + } + if err := svc.MergeClientIpsByGuid(node, tree); err != nil { + t.Fatalf("MergeClientIpsByGuid: %v", err) + } + var rows []model.NodeClientIp + db.Find(&rows) + if len(rows) != 1 || rows[0].NodeGuid != "node-guid" || rows[0].Email != "kept@x" || strings.Contains(rows[0].Ips, strconv.FormatInt(future, 10)) { + t.Fatalf("stored per-node IPs = %+v, want only kept@x under node-guid, dated no later than now", rows) + } +} From 44a502a407d63894f62a4814f282d40cc3270ce8 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 31 Oct 2026 14:24:28 +0100 Subject: [PATCH 031/125] fix(clients): probe inbound settings with a short literal pattern Deleting a client by an email no record holds fell back to a settings LIKE '%email%' over every inbound with the raw route parameter, which costs SQLite the settings length times the pattern length while it holds the database; a long near-miss email froze the panel for minutes, and wildcards were not escaped. The pre-filter now uses the first 64 bytes of the email, escaped; the exact client match after it still decides. --- .../service/client_delete_tombstone_test.go | 28 +++++++++++++++++++ internal/web/service/client_lookup.go | 15 +++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/internal/web/service/client_delete_tombstone_test.go b/internal/web/service/client_delete_tombstone_test.go index 262177378..62d5e2e3d 100644 --- a/internal/web/service/client_delete_tombstone_test.go +++ b/internal/web/service/client_delete_tombstone_test.go @@ -3,7 +3,9 @@ package service import ( "errors" "fmt" + "strings" "testing" + "time" "gorm.io/gorm" @@ -197,3 +199,29 @@ func TestRolledBackCreateKeepsTombstone(t *testing.T) { t.Fatal("a create that rolled back withdrew the delete tombstone, so a stale node snapshot can resurrect the deleted client") } } + +// The email reaches a LIKE pre-filter: a long near-miss costs SQLite text times pattern +// length while holding the database, and wildcards must not match either. +func TestFindInboundIdsByClientEmailTreatsTheEmailLiterally(t *testing.T) { + setupConflictDB(t) + settings := `{"clients":[{"email":"real@x"}],"pad":"` + strings.Repeat("a", 1<<20) + `"}` + if err := database.GetDB().Create(&model.Inbound{UserId: 1, Tag: "big", Port: 41601, Protocol: model.VLESS, Settings: settings}).Error; err != nil { + t.Fatalf("seed inbound: %v", err) + } + done := make(chan []int, 1) + go func() { + ids, _ := (&ClientService{}).findInboundIdsByClientEmail(strings.Repeat("a", 10000) + "b") + done <- ids + }() + select { + case ids := <-done: + if len(ids) != 0 { + t.Fatalf("a wildcard email matched inbounds %v", ids) + } + case <-time.After(5 * time.Second): + t.Fatal("the email lookup is still running after 5 s") + } + if ids, err := (&ClientService{}).findInboundIdsByClientEmail("real@x"); err != nil || len(ids) != 1 { + t.Fatalf("lookup of a real email = %v, %v", ids, err) + } +} diff --git a/internal/web/service/client_lookup.go b/internal/web/service/client_lookup.go index 50c5cb847..29eb990d9 100644 --- a/internal/web/service/client_lookup.go +++ b/internal/web/service/client_lookup.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "strings" + "unicode/utf8" "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/model" @@ -247,14 +248,26 @@ func (s *ClientService) HasPendingNode(inboundSvc *InboundService, email string) return inboundSvc.AnyNodePending(ids) } +const settingsProbeBytes = 64 + // findInboundIdsByClientEmail returns every inbound whose settings.clients[] // JSON contains an entry with the given email. Driver-portable (no JSON // operators) by parsing in Go — fine for the rare fallback path. func (s *ClientService) findInboundIdsByClientEmail(email string) ([]int, error) { + // A literal, bounded probe: the email comes from a request, and LIKE costs the settings + // length times the pattern length; the exact match below decides. + probe := email + if len(probe) > settingsProbeBytes { + cut := settingsProbeBytes + for cut > 0 && !utf8.RuneStart(probe[cut]) { + cut-- + } + probe = probe[:cut] + } var inbounds []model.Inbound if err := database.GetDB(). Select("id, settings"). - Where("settings LIKE ?", "%"+email+"%"). + Where("settings LIKE ? ESCAPE '\\'", "%"+escapeLikeLiteral(probe)+"%"). Find(&inbounds).Error; err != nil { return nil, err } From fe5d704d6728cbd285f17c5f79901eec58c62ae4 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 31 Oct 2026 14:25:21 +0100 Subject: [PATCH 032/125] fix(api): cap the client traffic list a master pushes by entry count pushClientTraffics decoded the pushed list whole, and each "{}" became a full traffic row, so a body under the size cap could cost the panel close to a gigabyte. The list is now decoded entry by entry and refused past 262144 entries. The capped decoder the node runtime already used moves to json_util so both share it. --- internal/util/json_util/list.go | 38 +++++++++++++++++++ internal/web/controller/inbound.go | 15 ++++++-- .../web/controller/inbound_node_sync_test.go | 22 +++++++++++ internal/web/runtime/remote.go | 13 ++++--- internal/web/runtime/remote_decode.go | 32 ++-------------- 5 files changed, 82 insertions(+), 38 deletions(-) create mode 100644 internal/util/json_util/list.go diff --git a/internal/util/json_util/list.go b/internal/util/json_util/list.go new file mode 100644 index 000000000..0e5edb112 --- /dev/null +++ b/internal/util/json_util/list.go @@ -0,0 +1,38 @@ +package json_util + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" +) + +// ErrListTooLong is returned by DecodeCappedList for a list past its entry limit. +var ErrListTooLong = errors.New("list exceeds entry limit") + +// DecodeCappedList decodes a JSON array entry by entry and stops at the first entry past +// maxEntries: each "{}" becomes a whole T, so a byte cap alone does not bound the result. +func DecodeCappedList[T any](raw []byte, maxEntries int) ([]T, error) { + raw = bytes.TrimSpace(raw) + if len(raw) == 0 || bytes.Equal(raw, []byte("null")) { + return nil, nil + } + dec := json.NewDecoder(bytes.NewReader(raw)) + if tok, err := dec.Token(); err != nil { + return nil, err + } else if delim, ok := tok.(json.Delim); !ok || delim != '[' { + return nil, fmt.Errorf("expected a JSON array, got %v", tok) + } + var out []T + for dec.More() { + if len(out) >= maxEntries { + return nil, fmt.Errorf("%w (%d)", ErrListTooLong, maxEntries) + } + var item T + if err := dec.Decode(&item); err != nil { + return nil, err + } + out = append(out, item) + } + return out, nil +} diff --git a/internal/web/controller/inbound.go b/internal/web/controller/inbound.go index 7db982adb..da438fbab 100644 --- a/internal/web/controller/inbound.go +++ b/internal/web/controller/inbound.go @@ -8,6 +8,7 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/amneziawgkern" "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/util/json_util" "github.com/mhsanaei/3x-ui/v3/internal/util/wirecodec" "github.com/mhsanaei/3x-ui/v3/internal/web/middleware" "github.com/mhsanaei/3x-ui/v3/internal/web/service" @@ -395,18 +396,26 @@ func (a *InboundController) resetAllTraffics(c *gin.Context) { jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetAllTrafficSuccess"), nil) } +// maxPushedClientTraffics bounds one push: each entry decodes into a whole traffic row. +const maxPushedClientTraffics = 1 << 18 + // pushClientTraffics receives a master panel's aggregated per-client usage // (see InboundService.AcceptGlobalTraffic for the storage semantics). func (a *InboundController) pushClientTraffics(c *gin.Context) { var req struct { - MasterGuid string `json:"masterGuid"` - Traffics []*xray.ClientTraffic `json:"traffics"` + MasterGuid string `json:"masterGuid"` + Traffics json.RawMessage `json:"traffics"` } if err := c.ShouldBindJSON(&req); err != nil { jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) return } - if err := a.inboundService.AcceptGlobalTraffic(req.MasterGuid, req.Traffics); err != nil { + traffics, err := json_util.DecodeCappedList[*xray.ClientTraffic](req.Traffics, maxPushedClientTraffics) + if err != nil { + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) + return + } + if err := a.inboundService.AcceptGlobalTraffic(req.MasterGuid, traffics); err != nil { jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) return } diff --git a/internal/web/controller/inbound_node_sync_test.go b/internal/web/controller/inbound_node_sync_test.go index c90fe92fc..2b316ccc4 100644 --- a/internal/web/controller/inbound_node_sync_test.go +++ b/internal/web/controller/inbound_node_sync_test.go @@ -15,6 +15,7 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/database/dbtest" "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/util/crypto" + "github.com/mhsanaei/3x-ui/v3/internal/web/locale" "github.com/mhsanaei/3x-ui/v3/internal/web/runtime" ) @@ -103,3 +104,24 @@ func TestNodeSyncPushSkipsOperatorTLSGuard(t *testing.T) { } }) } + +// Each "{}" of the pushed list decodes into a whole traffic row, so a body under the size +// cap could still cost the panel close to a gigabyte; the list is capped by entry count. +func TestPushClientTrafficsCapsTheListByEntryCount(t *testing.T) { + gin.SetMode(gin.TestMode) + dbtest.InitDB(t, filepath.Join(t.TempDir(), "x-ui.db")) + engine := gin.New() + engine.Use(func(c *gin.Context) { + c.Set("I18n", func(_ locale.I18nType, key string, _ ...string) string { return key }) + c.Next() + }) + NewInboundController(engine.Group("/panel/api/inbounds")) + body := `{"masterGuid":"m","traffics":[` + strings.TrimSuffix(strings.Repeat("{},", maxPushedClientTraffics+1), ",") + `]}` + req := httptest.NewRequest(http.MethodPost, "/panel/api/inbounds/pushClientTraffics", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + if !strings.Contains(w.Body.String(), `"success":false`) { + t.Fatalf("an over-long traffic list was accepted: %.200s", w.Body.String()) + } +} diff --git a/internal/web/runtime/remote.go b/internal/web/runtime/remote.go index f65aa47c2..90c0dd1eb 100644 --- a/internal/web/runtime/remote.go +++ b/internal/web/runtime/remote.go @@ -22,6 +22,7 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/crypto/nodetoken" "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/logger" + "github.com/mhsanaei/3x-ui/v3/internal/util/json_util" "github.com/mhsanaei/3x-ui/v3/internal/util/netsafe" "github.com/mhsanaei/3x-ui/v3/internal/util/wirecodec" "github.com/mhsanaei/3x-ui/v3/internal/web/entity" @@ -423,7 +424,7 @@ func (r *Remote) ListInboundOptions(ctx context.Context) ([]RemoteInboundOption, if err != nil { return nil, err } - list, err := decodeCappedList[RemoteInboundOption](env.Obj, maxNodeInbounds) + list, err := json_util.DecodeCappedList[RemoteInboundOption](env.Obj, maxNodeInbounds) if err != nil { return nil, fmt.Errorf("decode inbound list: %w", err) } @@ -435,7 +436,7 @@ func (r *Remote) refreshRemoteIDs(ctx context.Context) error { if err != nil { return err } - list, err := decodeCappedList[struct { + list, err := json_util.DecodeCappedList[struct { Id int `json:"id"` Tag string `json:"tag"` }](env.Obj, maxNodeInbounds) @@ -768,7 +769,7 @@ func (r *Remote) GetDescendants(ctx context.Context) ([]model.NodeSummary, error if err != nil { return nil, err } - out, err := decodeCappedList[model.NodeSummary](env.Obj, maxNodeDescendants) + out, err := json_util.DecodeCappedList[model.NodeSummary](env.Obj, maxNodeDescendants) if err != nil { return nil, fmt.Errorf("decode descendants: %w", err) } @@ -829,7 +830,7 @@ func (r *Remote) FetchHostGroups(ctx context.Context) ([]*entity.HostGroup, erro if err != nil { return nil, err } - groups, err := decodeCappedList[*entity.HostGroup](env.Obj, maxNodeHostGroups) + groups, err := json_util.DecodeCappedList[*entity.HostGroup](env.Obj, maxNodeHostGroups) if err != nil { return nil, fmt.Errorf("decode host groups: %w", err) } @@ -843,7 +844,7 @@ func (r *Remote) FetchTrafficSnapshot(ctx context.Context) (*TrafficSnapshot, er if err != nil { return nil, err } - if snap.Inbounds, err = decodeCappedList[*model.Inbound](envList.Obj, maxNodeInbounds); err != nil { + if snap.Inbounds, err = json_util.DecodeCappedList[*model.Inbound](envList.Obj, maxNodeInbounds); err != nil { return nil, fmt.Errorf("decode inbound list: %w", err) } @@ -1003,7 +1004,7 @@ func (r *Remote) FetchAllClientIps(ctx context.Context) ([]model.InboundClientIp if err != nil { return nil, err } - ips, err := decodeCappedList[model.InboundClientIps](env.Obj, maxNodeClientIPs) + ips, err := json_util.DecodeCappedList[model.InboundClientIps](env.Obj, maxNodeClientIPs) if err != nil { return nil, fmt.Errorf("decode client ips: %w", err) } diff --git a/internal/web/runtime/remote_decode.go b/internal/web/runtime/remote_decode.go index 2bd2be435..ec4c92c78 100644 --- a/internal/web/runtime/remote_decode.go +++ b/internal/web/runtime/remote_decode.go @@ -3,9 +3,10 @@ package runtime import ( "bytes" "encoding/json" - "errors" "fmt" "unicode/utf8" + + "github.com/mhsanaei/3x-ui/v3/internal/util/json_util" ) // Per-endpoint caps on node-reported lists: each "{}" decodes into a whole struct, so the @@ -19,34 +20,7 @@ const ( maxNodeMapValues = 1 << 20 ) -var errRemoteListTooLong = errors.New("remote list exceeds entry limit") - -// decodeCappedList decodes a JSON array entry by entry and stops at the first entry -// past maxEntries; null decodes to a nil slice. -func decodeCappedList[T any](raw json.RawMessage, maxEntries int) ([]T, error) { - raw = bytes.TrimSpace(raw) - if len(raw) == 0 || bytes.Equal(raw, []byte("null")) { - return nil, nil - } - dec := json.NewDecoder(bytes.NewReader(raw)) - if tok, err := dec.Token(); err != nil { - return nil, err - } else if delim, ok := tok.(json.Delim); !ok || delim != '[' { - return nil, fmt.Errorf("expected a JSON array, got %v", tok) - } - var out []T - for dec.More() { - if len(out) >= maxEntries { - return nil, fmt.Errorf("%w (%d)", errRemoteListTooLong, maxEntries) - } - var item T - if err := dec.Decode(&item); err != nil { - return nil, err - } - out = append(out, item) - } - return out, nil -} +var errRemoteListTooLong = json_util.ErrListTooLong // decodeCappedValues unmarshals raw into v after checking it holds at most maxValues // JSON values in all, nested ones included. From 47e9ef1a071835b69c9bde361e2a8201d642ac3e Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 31 Oct 2026 14:28:18 +0100 Subject: [PATCH 033/125] fix(sub): convert far expiry dates to Jalali in constant time gregorianToJalali walked the calendar one month at a time from a 2024 reference, so an expiry near the int64 limit (a node reports expiries unchecked) cost seconds of CPU on every subscription fetch that renders {{JALALI_EXPIRE_DATE}}. Jalali leap years repeat every 33 years, so whole 33-year blocks are skipped before the walk. --- internal/sub/remark_vars.go | 7 +++++++ internal/sub/remark_vars_test.go | 16 ++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/internal/sub/remark_vars.go b/internal/sub/remark_vars.go index 28dc5b617..07a06ac41 100644 --- a/internal/sub/remark_vars.go +++ b/internal/sub/remark_vars.go @@ -423,6 +423,13 @@ func gregorianToJalali(gy, gm, gd int) (jy, jm, jd int) { refJDN := 2460311 days := int64(jdn - refJDN) jy, jm, jd = 1402, 10, 11 + // Leap years follow y % 33, so whole 33-year blocks are skipped: a node-reported expiry + // can lie hundreds of millions of years away, and the walk below goes month by month. + const jalaliCycleDays = 33*365 + 8 + if cycles := days / jalaliCycleDays; cycles != 0 { + jy += int(cycles) * 33 + days -= cycles * jalaliCycleDays + } // Walk forward for days > 0 { diff --git a/internal/sub/remark_vars_test.go b/internal/sub/remark_vars_test.go index 55bfa7dab..31684ce5f 100644 --- a/internal/sub/remark_vars_test.go +++ b/internal/sub/remark_vars_test.go @@ -4,6 +4,7 @@ import ( "net/url" "strings" "testing" + "time" "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/xray" @@ -529,6 +530,8 @@ func TestGregorianToJalali(t *testing.T) { {2024, 1, 1, 1402, 10, 11}, {2000, 3, 20, 1379, 1, 1}, {1979, 2, 11, 1357, 11, 22}, + // 2124-01-01 lies three 33-year blocks and a year past the reference. + {2124, 1, 1, 1502, 10, 11}, } for _, c := range cases { jy, jm, jd := gregorianToJalali(c.gy, c.gm, c.gd) @@ -539,6 +542,19 @@ func TestGregorianToJalali(t *testing.T) { } } +// A node reports the expiry the remark renders, so a date hundreds of millions of years +// out must not walk the calendar month by month on every subscription fetch. +func TestGregorianToJalaliFarDatesTakeConstantTime(t *testing.T) { + start := time.Now() + if jy, _, _ := gregorianToJalali(292000000, 1, 1); jy < 290000000 { + t.Fatalf("year 292000000 converted to Jalali year %d", jy) + } + _, _, _ = gregorianToJalali(-292000000, 1, 1) + if took := time.Since(start); took > 100*time.Millisecond { + t.Fatalf("converting far dates took %v", took) + } +} + func TestJalaliExpireDateLabel(t *testing.T) { if got := jalaliExpireDateLabel(0); got != "" { t.Errorf("jalaliExpireDateLabel(0) = %q, want empty", got) From 351a01ff0cd67166c863873e5150664879eff6a0 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 31 Oct 2026 14:32:31 +0100 Subject: [PATCH 034/125] fix(clients): bound the auto-renew interval at a century An interval renewal turned the client's reset days into milliseconds with no bound, so a value that wraps int64 (a node reports it unchecked) sent the catch-up loop stepping through time for seconds per client while the traffic writer held the database. Intervals above 36500 days no longer renew, and every client write refuses them. --- internal/web/service/calendar_renew.go | 7 +++++++ .../web/service/calendar_weekly_bound_test.go | 18 ++++++++++++++++++ internal/web/service/client_crud.go | 3 +++ .../client_renewal_write_validation_test.go | 1 + 4 files changed, 29 insertions(+) diff --git a/internal/web/service/calendar_renew.go b/internal/web/service/calendar_renew.go index aa5b47786..e5856151a 100644 --- a/internal/web/service/calendar_renew.go +++ b/internal/web/service/calendar_renew.go @@ -93,6 +93,10 @@ func localCalendarDateStart(date time.Time, loc *time.Location) (time.Time, bool return start, localDate(start).Equal(date) } +// maxClientResetDays bounds an interval renewal at a century: a larger one, which a node can +// report, wraps int64 once turned into milliseconds. +const maxClientResetDays = 36500 + func nextClientRenewal(expiry int64, reset, day, weekday int, loc *time.Location) int64 { if day > 0 { return nextCalendarRenewal(time.UnixMilli(expiry), day, loc).UnixMilli() @@ -100,6 +104,9 @@ func nextClientRenewal(expiry int64, reset, day, weekday int, loc *time.Location if weekday > 0 { return nextWeeklyRenewal(time.UnixMilli(expiry), weekday, loc).UnixMilli() } + if reset > maxClientResetDays { + return expiry + } return expiry + int64(reset)*86400000 } diff --git a/internal/web/service/calendar_weekly_bound_test.go b/internal/web/service/calendar_weekly_bound_test.go index 00d992646..6c90524e4 100644 --- a/internal/web/service/calendar_weekly_bound_test.go +++ b/internal/web/service/calendar_weekly_bound_test.go @@ -76,3 +76,21 @@ func TestWeeklyRenewalSearchFailsClosed(t *testing.T) { }) } } + +// A node reports the renewal interval unchecked; one that wraps int64 when turned into +// milliseconds made the catch-up loop step through time for seconds per client. +func TestCatchUpClientRenewalRefusesAnOversizedInterval(t *testing.T) { + now := time.Now().UnixMilli() + for _, reset := range []int{106751991167, maxClientResetDays + 1} { + traffic := &xray.ClientTraffic{Email: "c", Reset: reset, ExpiryTime: now - 86400000} + start := time.Now() + expiry, renewals := catchUpClientRenewal(traffic, now, time.UTC) + if expiry != traffic.ExpiryTime || renewals != 0 || time.Since(start) > 100*time.Millisecond { + t.Fatalf("reset %d: expiry %d, renewals %d after %v; want no renewal", reset, expiry, renewals, time.Since(start)) + } + } + traffic := &xray.ClientTraffic{Email: "c", Reset: maxClientResetDays, ExpiryTime: now - 86400000} + if _, renewals := catchUpClientRenewal(traffic, now, time.UTC); renewals != 1 { + t.Fatalf("a %d-day interval renewed %d times, want once", maxClientResetDays, renewals) + } +} diff --git a/internal/web/service/client_crud.go b/internal/web/service/client_crud.go index 773f0d869..c1bac93dd 100644 --- a/internal/web/service/client_crud.go +++ b/internal/web/service/client_crud.go @@ -86,6 +86,9 @@ func validateClientRenewal(client model.Client) error { if err := validateClientResetDay(client.ResetDay); err != nil { return err } + if client.Reset > maxClientResetDays { + return common.NewError("client reset must not exceed", maxClientResetDays, "days") + } if client.ResetWeekday < 0 || client.ResetWeekday > 7 { return common.NewError("client resetWeekday must be between 0 and 7, got:", client.ResetWeekday) } diff --git a/internal/web/service/client_renewal_write_validation_test.go b/internal/web/service/client_renewal_write_validation_test.go index b76d9f137..ed950f500 100644 --- a/internal/web/service/client_renewal_write_validation_test.go +++ b/internal/web/service/client_renewal_write_validation_test.go @@ -20,6 +20,7 @@ func TestClientRenewalWriteValidation(t *testing.T) { {"weekly and interval", 7, 0, 3, "client weekly renewal cannot be combined with reset or resetDay"}, {"weekly and monthly", 0, 1, 3, "client weekly renewal cannot be combined with reset or resetDay"}, {"invalid weekday", 0, 0, 8, "client resetWeekday must be between 0 and 7, got: 8"}, + {"oversized interval", maxClientResetDays + 1, 0, 0, "client reset must not exceed 36500 days"}, } { t.Run(operation+"/"+schedule.name, func(t *testing.T) { setupConflictDB(t) From d69b924bafaf415049c44ee5dc704442c982f187 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 31 Oct 2026 14:35:01 +0100 Subject: [PATCH 035/125] fix(wireguard): step over wide address claims when allocating allocateWireguardAddress walked the scope one address at a time and tested each against every wide claim, so a claim covering an IPv6 /64 (a node can report such allowedIPs) turned the next address-less client add into a 2^64-step loop that also held every later client change on that inbound. The scan now jumps past a wide claim in one step, so it is bounded by the number of claims. --- internal/web/service/client_wireguard.go | 34 +++++++++++++------ internal/web/service/client_wireguard_test.go | 3 ++ 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/internal/web/service/client_wireguard.go b/internal/web/service/client_wireguard.go index d3a64722f..0285f30d3 100644 --- a/internal/web/service/client_wireguard.go +++ b/internal/web/service/client_wireguard.go @@ -117,16 +117,15 @@ func allocateWireguardAddress(used []string, base string, allowWidening bool) (s wide = append(wide, p) } } - isTaken := func(a netip.Addr) bool { - if _, ok := taken[a]; ok { - return true - } + // wideClaimOf returns a wide claim holding a, so the scan can step over the whole claim: + // a node may report one spanning the scope, and walking it is 2^64 steps in IPv6. + wideClaimOf := func(a netip.Addr) (netip.Prefix, bool) { for _, p := range wide { if p.Contains(a) { - return true + return p, true } } - return false + return netip.Prefix{}, false } scopes := []netip.Prefix{prefix} if allowWidening && prefix.Addr().Is4() && prefix.Bits() > wireguardPoolFloorBits { @@ -136,16 +135,31 @@ func allocateWireguardAddress(used []string, base string, allowWidening bool) (s } for _, scope := range scopes { addr := scope.Masked().Addr().Next().Next() - for scope.Contains(addr) { - if !isTaken(addr) { - return addr.String() + "/" + hostBits, nil + for addr.IsValid() && scope.Contains(addr) { + if _, ok := taken[addr]; ok { + addr = addr.Next() + continue } - addr = addr.Next() + if claim, ok := wideClaimOf(addr); ok { + addr = lastPrefixAddr(claim).Next() + continue + } + return addr.String() + "/" + hostBits, nil } } return "", common.NewError("wireguard: no free address available in", scopes[len(scopes)-1].String()) } +// lastPrefixAddr is the highest address of p. +func lastPrefixAddr(p netip.Prefix) netip.Addr { + bytes := p.Masked().Addr().AsSlice() + for bit := p.Bits(); bit < len(bytes)*8; bit++ { + bytes[bit/8] |= 0x80 >> (bit % 8) + } + addr, _ := netip.AddrFromSlice(bytes) + return addr +} + // normalizeWireguardAllowedIPs validates user-supplied allowedIPs entries and // canonicalizes them: bare addresses become single-host prefixes, duplicates drop. func normalizeWireguardAllowedIPs(values []string) ([]string, error) { diff --git a/internal/web/service/client_wireguard_test.go b/internal/web/service/client_wireguard_test.go index 7fc4860e4..6c723c44d 100644 --- a/internal/web/service/client_wireguard_test.go +++ b/internal/web/service/client_wireguard_test.go @@ -24,6 +24,9 @@ func TestAllocateWireguardAddress(t *testing.T) { {name: "default base when empty", used: nil, base: "", want: "10.0.0.2/32"}, {name: "full ipv4 scope widens instead of failing", used: []string{"10.9.0.2/32", "10.9.0.3/32"}, base: "10.9.0.0/30", want: "10.9.0.4/32"}, {name: "exhausted ipv6 scope errors", used: []string{"fd00::2/128", "fd00::3/128"}, base: "fd00::/126", err: true}, + // A node can report a wide claim; the scan must step over it, not through 2^64 addresses. + {name: "wide claim over an ipv6 scope", used: []string{"fd00::/64"}, base: "fd00::/64", err: true}, + {name: "first address past a wide claim", used: []string{"10.0.0.0/25"}, base: "10.0.0.0/24", want: "10.0.0.128/32"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { From dcdc56c64bc4c8046343d47177721f541c3efe3d Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 31 Oct 2026 14:41:12 +0100 Subject: [PATCH 036/125] fix(traffic): stop adding capped counters in SQL Per-client counters are stored up to TrafficMax, which a node can reach by reporting huge usage, and the group totals, group baselines and the per-client reset lookup added two such counters inside SQL, overflowing it: every group listing and reset failed, and no client on that reset cycle was reset. The counters are now read separately and added in Go, saturating at TrafficMax. --- internal/web/service/client_crud.go | 7 +- .../web/service/client_group_reset_test.go | 18 +++ internal/web/service/client_groups.go | 131 +++++++++++------- .../web/service/client_traffic_cycle_test.go | 14 ++ 4 files changed, 115 insertions(+), 55 deletions(-) diff --git a/internal/web/service/client_crud.go b/internal/web/service/client_crud.go index c1bac93dd..5a2081a11 100644 --- a/internal/web/service/client_crud.go +++ b/internal/web/service/client_crud.go @@ -123,18 +123,19 @@ type ClientResetCycle struct { TrafficResetDay int Enable bool Total int64 - Used int64 + Up int64 + Down int64 } // Depleted reports a client the quota switched off. A reset restores that one; // a client disabled below its quota was switched off by hand and stays off. func (c ClientResetCycle) Depleted() bool { - return c.Total > 0 && c.Used >= c.Total + return c.Total > 0 && clampedTrafficAdd(c.Up, c.Down) >= c.Total } func clientResetCycles(tx *gorm.DB) *gorm.DB { return tx.Table("clients c"). - Select("c.email, c.traffic_reset_day, c.enable, COALESCE(ct.total, 0) AS total, COALESCE(ct.up, 0) + COALESCE(ct.down, 0) AS used"). + Select("c.email, c.traffic_reset_day, c.enable, COALESCE(ct.total, 0) AS total, COALESCE(ct.up, 0) AS up, COALESCE(ct.down, 0) AS down"). Joins("LEFT JOIN client_traffics ct ON ct.email = c.email") } diff --git a/internal/web/service/client_group_reset_test.go b/internal/web/service/client_group_reset_test.go index 4b7bb08d2..5ba06026a 100644 --- a/internal/web/service/client_group_reset_test.go +++ b/internal/web/service/client_group_reset_test.go @@ -227,3 +227,21 @@ func TestGroupResetStillZeroesAfterBaselineAdjustments(t *testing.T) { t.Fatalf("group reset did not zero adjusted baselines: got %+v", g) } } + +// Counters are stored up to TrafficMax (a node can report that much); adding two in SQL +// overflowed and failed every group listing, reset and baseline shift for the panel. +func TestGroupTrafficSurvivesCountersAtTheCap(t *testing.T) { + initTrafficTestDB(t) + svc := &ClientService{} + seedGroupedClient(t, "huge-a", "vip", database.TrafficMax, database.TrafficMax) + seedGroupedClient(t, "huge-b", "vip", database.TrafficMax, database.TrafficMax) + if g := groupByName(t, svc, "vip"); g.Up != database.TrafficMax || g.ClientCount != 2 { + t.Fatalf("group = %+v, want its counters held at TrafficMax", g) + } + if err := svc.ResetGroupTraffic("vip"); err != nil { + t.Fatalf("ResetGroupTraffic: %v", err) + } + if err := adjustGroupBaselinesForRemovedTraffic(database.GetDB(), []string{"huge-a", "huge-b"}); err != nil { + t.Fatalf("shift baselines: %v", err) + } +} diff --git a/internal/web/service/client_groups.go b/internal/web/service/client_groups.go index 96ab14830..504b4559a 100644 --- a/internal/web/service/client_groups.go +++ b/internal/web/service/client_groups.go @@ -2,6 +2,7 @@ package service import ( "encoding/json" + "errors" "sort" "strings" @@ -24,13 +25,8 @@ func (s *ClientService) ListGroups() ([]GroupSummary, error) { db := database.GetDB() // email is unique in both clients and client_traffics, so the LEFT JOIN // never double-counts a client's traffic. - var derived []GroupSummary - if err := db.Table("clients AS c"). - Select("c.group_name AS name, COUNT(*) AS client_count, COALESCE(SUM(ct.up + ct.down), 0) AS traffic_used, COALESCE(SUM(ct.up), 0) AS up, COALESCE(SUM(ct.down), 0) AS down"). - Joins("LEFT JOIN client_traffics ct ON ct.email = c.email"). - Where("c.group_name <> ''"). - Group("c.group_name"). - Scan(&derived).Error; err != nil { + derived, err := sumGroupTraffic(db, "c.group_name <> ''") + if err != nil { return nil, err } var stored []model.ClientGroup @@ -50,14 +46,14 @@ func (s *ClientService) ListGroups() ([]GroupSummary, error) { baseUp[g.Name] = g.ResetUp baseDown[g.Name] = g.ResetDown } - for _, g := range derived { - merged[g.Name] = groupAgg{count: g.ClientCount, up: g.Up, down: g.Down} + for name, g := range derived { + merged[name] = groupAgg{count: g.clients, up: g.up, down: g.down} } out := make([]GroupSummary, 0, len(merged)) for name, agg := range merged { - up := max(agg.up-baseUp[name], 0) - down := max(agg.down-baseDown[name], 0) - out = append(out, GroupSummary{Name: name, ClientCount: agg.count, TrafficUsed: up + down, Up: up, Down: down}) + up := max(clampedTrafficAdd(agg.up, -baseUp[name]), 0) + down := max(clampedTrafficAdd(agg.down, -baseDown[name]), 0) + out = append(out, GroupSummary{Name: name, ClientCount: agg.count, TrafficUsed: clampedTrafficAdd(up, down), Up: up, Down: down}) } sort.Slice(out, func(i, j int) bool { return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name) @@ -81,46 +77,35 @@ func shiftGroupBaselines(tx *gorm.DB, emails []string, sign int64) error { if len(emails) == 0 { return nil } - type groupDelta struct { - Name string - Up int64 - Down int64 - } - totals := make(map[string]*groupDelta) + totals := make(map[string]*groupCounters) for _, batch := range chunkStrings(emails, sqlInChunk) { - var part []groupDelta - if err := tx.Table("clients AS c"). - Select("c.group_name AS name, COALESCE(SUM(ct.up), 0) AS up, COALESCE(SUM(ct.down), 0) AS down"). - Joins("JOIN client_traffics ct ON ct.email = c.email"). - Where("c.group_name <> '' AND c.email IN ?", batch). - Group("c.group_name"). - Scan(&part).Error; err != nil { + part, err := sumGroupTraffic(tx, "c.group_name <> '' AND c.email IN ?", batch) + if err != nil { return err } - for i := range part { - if agg, ok := totals[part[i].Name]; ok { - agg.Up += part[i].Up - agg.Down += part[i].Down + for name, p := range part { + if agg, ok := totals[name]; ok { + agg.up = clampedTrafficAdd(agg.up, p.up) + agg.down = clampedTrafficAdd(agg.down, p.down) } else { - totals[part[i].Name] = &part[i] + totals[name] = p } } } for name, d := range totals { - if d.Up == 0 && d.Down == 0 { + if d.up == 0 && d.down == 0 { continue } - res := tx.Model(&model.ClientGroup{}).Where("name = ?", name).Updates(map[string]any{ - "reset_up": gorm.Expr("reset_up + ?", sign*d.Up), - "reset_down": gorm.Expr("reset_down + ?", sign*d.Down), - }) - if res.Error != nil { - return res.Error + var group model.ClientGroup + err := tx.Where("name = ?", name).First(&group).Error + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return err } - if res.RowsAffected == 0 { - if err := tx.Create(&model.ClientGroup{Name: name, ResetUp: sign * d.Up, ResetDown: sign * d.Down}).Error; err != nil { - return err - } + group.Name = name + group.ResetUp = clampedTrafficAdd(group.ResetUp, sign*d.up) + group.ResetDown = clampedTrafficAdd(group.ResetDown, sign*d.down) + if err := tx.Save(&group).Error; err != nil { + return err } } return nil @@ -151,26 +136,23 @@ func (s *ClientService) ResetGroupTraffic(name string) error { return common.NewError("group name is required") } db := database.GetDB() - var agg struct { - Up int64 - Down int64 - } - if err := db.Table("clients AS c"). - Select("COALESCE(SUM(ct.up), 0) AS up, COALESCE(SUM(ct.down), 0) AS down"). - Joins("LEFT JOIN client_traffics ct ON ct.email = c.email"). - Where("c.group_name = ?", name). - Scan(&agg).Error; err != nil { + totals, err := sumGroupTraffic(db, "c.group_name = ?", name) + if err != nil { return err } + agg := groupCounters{} + if t := totals[name]; t != nil { + agg = *t + } var count int64 if err := db.Model(&model.ClientGroup{}).Where("name = ?", name).Count(&count).Error; err != nil { return err } if count == 0 { - return db.Create(&model.ClientGroup{Name: name, ResetUp: agg.Up, ResetDown: agg.Down}).Error + return db.Create(&model.ClientGroup{Name: name, ResetUp: agg.up, ResetDown: agg.down}).Error } return db.Model(&model.ClientGroup{}).Where("name = ?", name). - Updates(map[string]any{"reset_up": agg.Up, "reset_down": agg.Down}).Error + Updates(map[string]any{"reset_up": agg.up, "reset_down": agg.down}).Error } func (s *ClientService) CreateGroup(name string) error { @@ -450,3 +432,48 @@ func (s *ClientService) replaceGroupValue(oldName, newName string) (int, error) } return len(records), nil } + +// groupCounters is a group's summed traffic, held within ±TrafficMax. +type groupCounters struct { + clients int + up, down int64 +} + +// clampedTrafficAdd adds two counters, saturating at ±TrafficMax: stored counters reach +// the cap, and SQL SUM over two of them overflowed and failed the whole query. +func clampedTrafficAdd(a, b int64) int64 { + switch { + case b > 0 && a > database.TrafficMax-b: + return database.TrafficMax + case b < 0 && a < -database.TrafficMax-b: + return -database.TrafficMax + } + return a + b +} + +// sumGroupTraffic reads one row per grouped client matching where and adds them up in Go. +func sumGroupTraffic(tx *gorm.DB, where string, args ...any) (map[string]*groupCounters, error) { + var rows []struct { + Name string + Up, Down int64 + } + if err := tx.Table("clients AS c"). + Select("c.group_name AS name, COALESCE(ct.up, 0) AS up, COALESCE(ct.down, 0) AS down"). + Joins("LEFT JOIN client_traffics ct ON ct.email = c.email"). + Where(where, args...). + Scan(&rows).Error; err != nil { + return nil, err + } + totals := make(map[string]*groupCounters) + for _, r := range rows { + agg := totals[r.Name] + if agg == nil { + agg = &groupCounters{} + totals[r.Name] = agg + } + agg.clients++ + agg.up = clampedTrafficAdd(agg.up, r.Up) + agg.down = clampedTrafficAdd(agg.down, r.Down) + } + return totals, nil +} diff --git a/internal/web/service/client_traffic_cycle_test.go b/internal/web/service/client_traffic_cycle_test.go index aa9109125..f5cdfb9b2 100644 --- a/internal/web/service/client_traffic_cycle_test.go +++ b/internal/web/service/client_traffic_cycle_test.go @@ -152,3 +152,17 @@ func TestClientTrafficResetValidation(t *testing.T) { }) } } + +// A node can report counters at TrafficMax; adding two in SQL overflowed and failed the +// lookup, so no client on that cycle was reset at all. +func TestGetClientsByTrafficResetSurvivesCountersAtTheCap(t *testing.T) { + initTrafficTestDB(t) + if err := database.GetDB().Create(&model.ClientRecord{Email: "huge", Enable: false, TrafficReset: "daily"}).Error; err != nil { + t.Fatalf("seed client: %v", err) + } + seedClientRow(t, "huge", 1, database.TrafficMax, database.TrafficMax, 100) + cycles, err := (&ClientService{}).GetClientsByTrafficReset("daily") + if err != nil || len(cycles) != 1 || !cycles[0].Depleted() { + t.Fatalf("GetClientsByTrafficReset = %+v, %v; want the client, depleted", cycles, err) + } +} From 425a2bbf08da0605ca49bf03f1690c72a7a67320 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 31 Oct 2026 14:50:18 +0100 Subject: [PATCH 037/125] fix(amneziawg): bound what a handshake allocates and the tunnel MTU Every handshake initiation builds Jc junk packets of Jmin..Jmax bytes and the I1-I5 signature packets, and the limits allowed the full uint32 range and lengths of any size, so one connection made the embedded device allocate without bound. The MTU reaches the device as an int32 and a wider value made it panic. Both ended the whole panel at the next connection, after every restart. Jc is now at most 128, Jmin/Jmax and each signature packet at most one 65535-byte datagram, and the MTU at most 65535. Saves refuse more, the device setup refuses rows stored before, and the stored MTU is capped where it is read. The form schemas carry the same limits. --- .../schemas/protocols/inbound/amneziawg.ts | 10 +-- .../schemas/protocols/outbound/amneziawg.ts | 8 +- .../src/test/amneziawg-schema-cleared.test.ts | 11 ++- internal/amneziawg/outbound.go | 3 + internal/amneziawg/params.go | 83 ++++++++++++++++--- internal/amneziawg/params_test.go | 33 ++++++++ internal/amneziawgnet/client_device.go | 4 + internal/amneziawgnet/device.go | 4 + internal/amneziawgnet/device_test.go | 13 +++ internal/web/service/inbound_amneziawg.go | 3 + 10 files changed, 146 insertions(+), 26 deletions(-) diff --git a/frontend/src/schemas/protocols/inbound/amneziawg.ts b/frontend/src/schemas/protocols/inbound/amneziawg.ts index 06576d06a..d3a1f59cb 100644 --- a/frontend/src/schemas/protocols/inbound/amneziawg.ts +++ b/frontend/src/schemas/protocols/inbound/amneziawg.ts @@ -54,7 +54,7 @@ export const AmneziawgServerSchema = z.object({ publicKey: z.string().optional(), subnetIp: z.string().default('10.8.1.0'), subnetCidr: clearedToDefault(z.number().int().min(1).max(32).default(24)), - mtu: optionalClearedInt(z.number().int().min(1)), + mtu: optionalClearedInt(z.number().int().min(1).max(65535)), primaryDns: z.string().default('8.8.8.8'), secondaryDns: z.string().default('8.8.4.4'), externalInterface: z.string().default(''), @@ -71,10 +71,10 @@ export const AmneziawgServerSchema = z.object({ // embedded userspace stack -- no Xray routing, sniffing, IPv6 or port forwarding. kernelMode: z.boolean().default(false).optional(), // Upper bounds match amneziawg-go's own UAPI parsers (device/uapi.go): - // jc/jmin/jmax are uint32, s1-s4 uint16. Wider values make IpcSet fail. - jc: clearedToDefault(z.number().int().min(0).max(4294967295).default(5)), - jmin: clearedToDefault(z.number().int().min(0).max(4294967295).default(10)), - jmax: clearedToDefault(z.number().int().min(0).max(4294967295).default(50)), + // Every handshake allocates jc packets of jmin..jmax bytes; s1-s4 are uint16. + jc: clearedToDefault(z.number().int().min(0).max(128).default(5)), + jmin: clearedToDefault(z.number().int().min(0).max(65535).default(10)), + jmax: clearedToDefault(z.number().int().min(0).max(65535).default(50)), s1: clearedToDefault(z.number().int().min(0).max(1552).default(30)), s2: clearedToDefault(z.number().int().min(0).max(1608).default(45)), s3: clearedToDefault(z.number().int().min(0).max(1636).default(10)), diff --git a/frontend/src/schemas/protocols/outbound/amneziawg.ts b/frontend/src/schemas/protocols/outbound/amneziawg.ts index 924208027..fc0fde1f0 100644 --- a/frontend/src/schemas/protocols/outbound/amneziawg.ts +++ b/frontend/src/schemas/protocols/outbound/amneziawg.ts @@ -14,14 +14,14 @@ export type AmneziaWGOutboundPeer = z.infer; export const AmneziaWGOutboundSettingsSchema = z.object({ // 0 = unset, so the backend derives MTU from S4 (amneziawg.EffectiveMTU); // a pinned 1420 fragments every full-size packet once S4 exceeds 20. - mtu: z.number().int().min(0).default(0), + mtu: z.number().int().min(0).max(65535).default(0), secretKey: z.string().default(''), address: z.array(z.string()).default([]), listenPort: z.number().int().min(0).max(65535).default(0), dns: z.string().default(''), - jc: z.number().int().min(0).default(0), - jmin: z.number().int().min(0).default(40), - jmax: z.number().int().min(0).default(100), + jc: z.number().int().min(0).max(128).default(0), + jmin: z.number().int().min(0).max(65535).default(40), + jmax: z.number().int().min(0).max(65535).default(100), // The remote server sets S1-S3; only amneziawg-go's uint16 UAPI width bounds them here. s1: z.number().int().min(0).max(65535).default(15), s2: z.number().int().min(0).max(65535).default(80), diff --git a/frontend/src/test/amneziawg-schema-cleared.test.ts b/frontend/src/test/amneziawg-schema-cleared.test.ts index 7a70d007a..65041ae6b 100644 --- a/frontend/src/test/amneziawg-schema-cleared.test.ts +++ b/frontend/src/test/amneziawg-schema-cleared.test.ts @@ -42,9 +42,10 @@ describe('AmneziawgServerSchema obfuscation bounds', () => { ['s2', 1609], ['s3', 1637], ['s4', 33], - ['jc', 4294967296], - ['jmin', 4294967296], - ['jmax', 5000000000], + ['jc', 129], + ['jmin', 65536], + ['jmax', 65536], + ['mtu', 65536], ]; it.each(overWidth)('rejects %s past what amneziawg-go can apply or receive', (field, value) => { @@ -56,7 +57,9 @@ describe('AmneziawgServerSchema obfuscation bounds', () => { ['s2', 1608], ['s3', 1636], ['s4', 32], - ['jc', 4294967295], + ['jc', 128], + ['jmax', 65535], + ['mtu', 65535], ]; it.each(atLimit)('accepts %s exactly at its limit', (field, value) => { diff --git a/internal/amneziawg/outbound.go b/internal/amneziawg/outbound.go index b5992ba60..fb780dd5b 100644 --- a/internal/amneziawg/outbound.go +++ b/internal/amneziawg/outbound.go @@ -282,6 +282,9 @@ func ValidateAmneziaWGOutbound(tag string, raw []byte) error { if err := ValidateObfuscation(parsed.Obfuscation()); err != nil { return fmt.Errorf("amneziawg outbound %q: %w", tag, err) } + if err := ValidateMTU(parsed.MTU); err != nil { + return fmt.Errorf("amneziawg outbound %q: %w", tag, err) + } for n, iv := range map[string]string{ "i1": parsed.I1, "i2": parsed.I2, "i3": parsed.I3, "i4": parsed.I4, "i5": parsed.I5, } { diff --git a/internal/amneziawg/params.go b/internal/amneziawg/params.go index f2a30ce3b..14f37652d 100644 --- a/internal/amneziawg/params.go +++ b/internal/amneziawg/params.go @@ -37,11 +37,23 @@ func randInt(min, max int) int { // link, before AmneziaWG's own S4 transport junk is prepended. const DefaultMTU = 1420 +// MaxMTU bounds the tunnel MTU: amneziawg-go keeps it as an int32, and a wider value wraps. +const MaxMTU = 65535 + +// ValidateMTU refuses an MTU past MaxMTU before it is saved; 0 means the default. +func ValidateMTU(mtu int) error { + if mtu < 0 || mtu > MaxMTU { + return fmt.Errorf("invalid mtu %d (must be 0..%d)", mtu, MaxMTU) + } + return nil +} + // EffectiveMTU is the admin's value when set, else DefaultMTU minus S4: s4 junk // is prepended to every transport packet and never clamped against the MTU. func EffectiveMTU(configuredMTU, s4 int) int { if configuredMTU > 0 { - return configuredMTU + // A row saved before ValidateMTU existed may still carry a wider value. + return min(configuredMTU, MaxMTU) } return max(DefaultMTU-max(s4, 0), 1280) } @@ -159,6 +171,35 @@ func ValidateServerObfuscation(o Obfuscation31) error { return nil } +const ( + // maxJunkPackets is the Jc range AmneziaWG documents. + maxJunkPackets = 128 + // maxPreamblePacket is amneziawg-go's MaxSegmentSize: each junk and signature packet is one datagram. + maxPreamblePacket = 65535 +) + +// ValidateHandshakePreamble bounds what every handshake initiation allocates: Jc junk +// packets of Jmin..Jmax bytes and the I1-I5 signature packets. Device setup runs it too. +func ValidateHandshakePreamble(o Obfuscation31) error { + if o.Jmin > o.Jmax { + return fmt.Errorf("invalid Jmin/Jmax: %d must not exceed %d", o.Jmin, o.Jmax) + } + for _, f := range []struct { + name string + v, max int + }{{"Jc", o.Jc, maxJunkPackets}, {"Jmin", o.Jmin, maxPreamblePacket}, {"Jmax", o.Jmax, maxPreamblePacket}} { + if f.v < 0 || f.v > f.max { + return fmt.Errorf("invalid %s value %d (must be 0..%d)", f.name, f.v, f.max) + } + } + for i, spec := range []string{o.I1, o.I2, o.I3, o.I4, o.I5} { + if err := validateObfChain(spec); err != nil { + return fmt.Errorf("invalid I%d: %w", i+1, err) + } + } + return nil +} + // ValidateObfuscation rejects malformed parameters before they are saved, so // a bad manual entry can't break the embedded amneziawg-go device's own // UAPI config apply (internal/amneziawgnet's buildUAPIConfig/IpcSet) or @@ -166,19 +207,16 @@ func ValidateServerObfuscation(o Obfuscation31) error { // are allowed (they fall back to a default); each accepts an integer or a // "100-800" range. func ValidateObfuscation(o Obfuscation31) error { - if o.Jmin > o.Jmax { - return fmt.Errorf("invalid Jmin/Jmax: %d must not exceed %d", o.Jmin, o.Jmax) + if err := ValidateHandshakePreamble(o); err != nil { + return err } - // amneziawg-go parses jc/jmin/jmax as uint32 and s1-s3 as uint16 (device/uapi.go); - // a wider value makes IpcSet reject the whole device. + // amneziawg-go parses s1-s3 as uint16 (device/uapi.go); a wider value makes IpcSet + // reject the whole device. for _, f := range []struct { name string v int max int64 }{ - {"Jc", o.Jc, math.MaxUint32}, - {"Jmin", o.Jmin, math.MaxUint32}, - {"Jmax", o.Jmax, math.MaxUint32}, {"S1", o.S1, math.MaxUint16}, {"S2", o.S2, math.MaxUint16}, {"S3", o.S3, math.MaxUint16}, @@ -187,11 +225,6 @@ func ValidateObfuscation(o Obfuscation31) error { return fmt.Errorf("invalid %s value %d (must be 0..%d)", f.name, f.v, f.max) } } - for i, spec := range []string{o.I1, o.I2, o.I3, o.I4, o.I5} { - if err := validateObfChain(spec); err != nil { - return fmt.Errorf("invalid I%d: %w", i+1, err) - } - } if o.S4 < 0 || o.S4 > 32 { return fmt.Errorf("invalid S4 value %d (must be 0..32)", o.S4) } @@ -263,6 +296,7 @@ func validateObfChain(spec string) error { return nil } remaining := spec + total := 0 for { start := strings.IndexByte(remaining, '<') if start == -1 { @@ -279,6 +313,29 @@ func validateObfChain(spec string) error { if !obfChainTags[fields[0]] { return fmt.Errorf("spec %q uses unknown tag <%s>", spec, fields[0]) } + // The packet is built once per handshake initiation, so what it allocates is bounded. + switch fields[0] { + case "r", "rc", "rd", "dz": + n := -1 + if len(fields) == 2 { + if v, err := strconv.Atoi(fields[1]); err == nil { + n = v + } + } + if n < 0 || n > maxPreamblePacket { + return fmt.Errorf("spec %q: <%s> needs a length of 0..%d", spec, fields[0], maxPreamblePacket) + } + total += n + case "b": + if len(fields) == 2 { + total += len(strings.TrimPrefix(fields[1], "0x")) / 2 + } + case "t": + total += 4 + } + if total > maxPreamblePacket { + return fmt.Errorf("spec %q builds more than %d bytes", spec, maxPreamblePacket) + } remaining = remaining[start+end+1:] } } diff --git a/internal/amneziawg/params_test.go b/internal/amneziawg/params_test.go index 1b800b001..1a3f150b6 100644 --- a/internal/amneziawg/params_test.go +++ b/internal/amneziawg/params_test.go @@ -529,3 +529,36 @@ func TestValidateObfuscationRejectsMalformedSignaturePackets(t *testing.T) { }) } } + +// Each handshake initiation sends Jc junk packets and the I1-I5 packets, allocated from +// these values; unbounded, one connection (an unauthenticated peer) killed the panel. +func TestValidateObfuscationBoundsWhatAHandshakeAllocates(t *testing.T) { + for name, mutate := range map[string]func(*Obfuscation31){ + "jc past the documented range": func(o *Obfuscation31) { o.Jc = maxJunkPackets + 1 }, + "jmax past a datagram": func(o *Obfuscation31) { o.Jmax = maxPreamblePacket + 1 }, + "random bytes past a datagram": func(o *Obfuscation31) { o.I1 = "" }, + "negative length": func(o *Obfuscation31) { o.I2 = "" }, + "spec past a datagram": func(o *Obfuscation31) { o.I3 = "" }, + } { + o := validObfuscation() + mutate(&o) + if err := ValidateObfuscation(o); err == nil { + t.Errorf("%s: accepted", name) + } + } + o := validObfuscation() + o.I1 = "" + if err := ValidateObfuscation(o); err != nil { + t.Fatalf("an ordinary I1 was refused: %v", err) + } +} + +// amneziawg-go stores the MTU as an int32; a wider value wraps and the device panics. +func TestMTUIsBoundedWhereItIsSavedAndWhereItIsRead(t *testing.T) { + if err := ValidateMTU(MaxMTU + 1); err == nil { + t.Fatal("an MTU past 65535 was accepted") + } + if got := EffectiveMTU(1<<31, 0); got != MaxMTU { + t.Fatalf("EffectiveMTU(2^31) = %d, want a stored oversized value capped at %d", got, MaxMTU) + } +} diff --git a/internal/amneziawgnet/client_device.go b/internal/amneziawgnet/client_device.go index 022f4e0fe..3f5c2bbbf 100644 --- a/internal/amneziawgnet/client_device.go +++ b/internal/amneziawgnet/client_device.go @@ -25,6 +25,10 @@ func buildClientUAPIConfig(inst amneziawg.OutboundInstance, opts DeviceOptions) } b.WriteString("replace_peers=true\n") + // Rows saved before these bounds existed would allocate without limit per handshake. + if err := amneziawg.ValidateHandshakePreamble(inst.Obfuscation); err != nil { + return "", err + } o := inst.Obfuscation fmt.Fprintf(&b, "jc=%d\njmin=%d\njmax=%d\n", o.Jc, o.Jmin, o.Jmax) fmt.Fprintf(&b, "s1=%d\ns2=%d\ns3=%d\ns4=%d\n", o.S1, o.S2, o.S3, o.S4) diff --git a/internal/amneziawgnet/device.go b/internal/amneziawgnet/device.go index 5648f4b5a..e6357f747 100644 --- a/internal/amneziawgnet/device.go +++ b/internal/amneziawgnet/device.go @@ -199,6 +199,10 @@ func buildUAPIConfig(inst amneziawg.Instance, opts DeviceOptions) (string, error // dropped instead of lingering from a previous IpcSet call. b.WriteString("replace_peers=true\n") + // Rows saved before these bounds existed would allocate without limit per handshake. + if err := amneziawg.ValidateHandshakePreamble(inst.Obfuscation); err != nil { + return "", err + } o := inst.Obfuscation fmt.Fprintf(&b, "jc=%d\njmin=%d\njmax=%d\n", o.Jc, o.Jmin, o.Jmax) fmt.Fprintf(&b, "s1=%d\ns2=%d\ns3=%d\ns4=%d\n", o.S1, o.S2, o.S3, o.S4) diff --git a/internal/amneziawgnet/device_test.go b/internal/amneziawgnet/device_test.go index 268290168..39e4cd602 100644 --- a/internal/amneziawgnet/device_test.go +++ b/internal/amneziawgnet/device_test.go @@ -679,3 +679,16 @@ func TestBuildUAPIConfigClearedHeaderProtectionKeyIsSentAsZero(t *testing.T) { t.Fatalf("a configured key must not be emitted as zero, got:\n%s", withKey) } } + +// A row stored before the handshake bounds existed must fail its own device setup instead +// of allocating without limit at the next handshake, which ended the whole panel. +func TestBuildUAPIConfigRefusesAStoredUnboundedPreamble(t *testing.T) { + priv, _, err := wireguard.GenerateWireguardKeypair() + if err != nil { + t.Fatalf("generate keypair: %v", err) + } + inst := amneziawg.Instance{PrivateKey: priv, Obfuscation: amneziawg.Obfuscation31{Jc: 1 << 31, Jmin: 10, Jmax: 20}} + if _, err := buildUAPIConfig(inst, DeviceOptions{}); err == nil { + t.Fatal("a stored Jc of 2^31 reached the device") + } +} diff --git a/internal/web/service/inbound_amneziawg.go b/internal/web/service/inbound_amneziawg.go index 578f93426..e4e099ea2 100644 --- a/internal/web/service/inbound_amneziawg.go +++ b/internal/web/service/inbound_amneziawg.go @@ -237,6 +237,9 @@ func (s *InboundService) normalizeAmneziaWGSettings(inbound *model.Inbound, old if err := amneziawg.ValidateServerObfuscation(parsed.Server.Obfuscation()); err != nil { return fmt.Errorf("amneziawg: %w", err) } + if err := amneziawg.ValidateMTU(parsed.Server.MTU); err != nil { + return fmt.Errorf("amneziawg: %w", err) + } if err := amneziawg.ValidateIPv6Subnet(parsed.Server.IPv6Enabled, parsed.Server.IPv6Subnet); err != nil { return fmt.Errorf("amneziawg: %w", err) } From 5cf2ab4f693dc9645b021e13c2c643a5e407383d Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 31 Oct 2026 14:53:20 +0100 Subject: [PATCH 038/125] fix(tuic): bound the timeout settings at a day max_idle_time and authentication_timeout are second counts multiplied into a Duration with no bound, so a stored value past the int64 range wrapped negative and the QUIC listener panicked on the first ClientHello, taking the panel down at every start until the row was fixed. Saves now refuse more than a day, and a stored row past it fails its own listener start instead. --- internal/tuic/server.go | 8 ++++++++ internal/tuic/server_settings_test.go | 16 ++++++++++++++++ internal/web/service/inbound_tuic_test.go | 6 ++++++ internal/web/service/tuic_settings.go | 13 +++++++++++++ 4 files changed, 43 insertions(+) diff --git a/internal/tuic/server.go b/internal/tuic/server.go index 1245b335c..4c0ff5e05 100644 --- a/internal/tuic/server.go +++ b/internal/tuic/server.go @@ -91,6 +91,11 @@ func NewServer(inst Instance, relay *SocksRelay) (*Server, error) { if authTimeout <= 0 { authTimeout = 3 } + // A second count past a day only wraps the Duration below; rows stored before the save + // path bounded it fail here instead of panicking the listener on its first handshake. + if maxIdle > MaxTimeoutSeconds || authTimeout > MaxTimeoutSeconds { + return nil, fmt.Errorf("tuic: max_idle_time and authentication_timeout must not exceed %d seconds", MaxTimeoutSeconds) + } maxUdpSize := inst.MaxUdpRelayPacketSize if maxUdpSize <= 0 { maxUdpSize = 1500 @@ -636,6 +641,9 @@ type packetReassembler struct { packets map[packetFragmentKey]*packetReassembly } +// MaxTimeoutSeconds bounds the second-count settings, far above any useful timeout. +const MaxTimeoutSeconds = 86400 + const ( maxSafeUdpRelayPacketSize = maxSocksUdpDatagramSize - 262 maxLegacyUdpRelayPacketSize = maxSocksUdpDatagramSize diff --git a/internal/tuic/server_settings_test.go b/internal/tuic/server_settings_test.go index f5f7eb3b8..c1d6f1458 100644 --- a/internal/tuic/server_settings_test.go +++ b/internal/tuic/server_settings_test.go @@ -127,3 +127,19 @@ func TestEnsureStartupFailureHonorsInboundLogThreshold(t *testing.T) { }) } } + +// Seconds turn into a Duration by multiplication; a pushed value past the int64 range +// wrapped negative and the QUIC listener panicked on the first ClientHello, every start. +func TestNewServerRefusesTimeoutsPastADay(t *testing.T) { + cert, key := generateTestCert(t) + for name, inst := range map[string]Instance{ + "max_idle_time": {MaxIdleTime: 1 << 40}, + "authentication_timeout": {AuthenticationTimeout: MaxTimeoutSeconds + 1}, + } { + inst.Listen, inst.Certificate, inst.PrivateKey = "127.0.0.1", string(cert), string(key) + if s, err := NewServer(inst, &SocksRelay{Addr: "127.0.0.1:1"}); err == nil { + _ = s.Close() + t.Errorf("%s past a day was accepted", name) + } + } +} diff --git a/internal/web/service/inbound_tuic_test.go b/internal/web/service/inbound_tuic_test.go index 4b076be23..84d1b06c5 100644 --- a/internal/web/service/inbound_tuic_test.go +++ b/internal/web/service/inbound_tuic_test.go @@ -359,6 +359,12 @@ func TestNormalizeTuicSettingsCanonicalizesCongestionAndPacketLimit(t *testing.T if err := normalizeTuicSettings(ib); err == nil { t.Fatal("unsupported congestion controller was accepted") } + for _, settings := range []string{`{"max_idle_time":2199023255552}`, `{"server":{"authentication_timeout":86401}}`} { + ib.Settings = settings + if err := normalizeTuicSettings(ib); err == nil { + t.Fatalf("a timeout past a day was accepted: %s", settings) + } + } } func TestTuicRelayIdentitySurvivesNativeEligibilityChanges(t *testing.T) { diff --git a/internal/web/service/tuic_settings.go b/internal/web/service/tuic_settings.go index 4d567f558..16ced47c0 100644 --- a/internal/web/service/tuic_settings.go +++ b/internal/web/service/tuic_settings.go @@ -90,6 +90,19 @@ func normalizeTuicSettingsBlock(settings map[string]json.RawMessage) error { encoded, _ := json.Marshal(value) settings["udp_relay_mode"] = encoded } + for _, key := range []string{"max_idle_time", "authentication_timeout"} { + raw, ok := settings[key] + if !ok { + continue + } + var value int64 + if err := json.Unmarshal(raw, &value); err != nil { + return fmt.Errorf("TUIC %s must be an integer: %w", key, err) + } + if value > tuic.MaxTimeoutSeconds { + return fmt.Errorf("TUIC %s must not exceed %d seconds", key, tuic.MaxTimeoutSeconds) + } + } if raw, ok := settings["max_udp_relay_packet_size"]; ok { var value int if err := json.Unmarshal(raw, &value); err != nil { From 838923058b26d806b5f65f0eae9b2a0e811f5dcd Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 31 Oct 2026 14:55:29 +0100 Subject: [PATCH 039/125] fix(tuic): end a UDP response relay with its association Dissociating a UDP association closed its SOCKS session, but the response relay could be blocked opening a stream on credit the client never grants, and only the connection's end released it. A client could leave one such goroutine per Packet/Dissociate round, past the cap on live associations. Each relay now runs under a context the association's removal cancels, whichever path removes it. --- internal/tuic/server.go | 5 +++ internal/tuic/stream_udp_lifecycle_test.go | 42 ++++++++++++++++++++++ internal/tuic/udp_associations.go | 27 +++++++++++++- 3 files changed, 73 insertions(+), 1 deletion(-) diff --git a/internal/tuic/server.go b/internal/tuic/server.go index 4c0ff5e05..f1622edf1 100644 --- a/internal/tuic/server.go +++ b/internal/tuic/server.go @@ -922,6 +922,11 @@ func (s *Server) relayUDPResponses( sess *udpRelaySession, ) { defer associations.release(assocID, association) + ctx, stop := context.WithCancel(ctx) + defer stop() + if !associations.attachResponseStop(assocID, association, stop) { + return + } buf := make([]byte, s.maxUdpRelayPacketSize+263) var nextPktID uint16 for { diff --git a/internal/tuic/stream_udp_lifecycle_test.go b/internal/tuic/stream_udp_lifecycle_test.go index 5c447b00c..87b9d3496 100644 --- a/internal/tuic/stream_udp_lifecycle_test.go +++ b/internal/tuic/stream_udp_lifecycle_test.go @@ -210,3 +210,45 @@ func TestUdpAssociationTouchDoesNotRefreshReusedID(t *testing.T) { t.Fatal("replacement association timestamp changed after stale touch") } } + +// A response relay blocked on stream credit the client never grants must still end when +// its association is dissociated; otherwise each Packet/Dissociate round leaves one behind. +func TestDissociateStopsAResponseRelayBlockedOnTheClient(t *testing.T) { + socksAddr, cleanup := startMockSocks5Server(t, "udp-dissociate@x", "lifecycle-socks-password") + defer cleanup() + server, _, clientID, _ := startLifecycleTestServer(t, socksAddr, "udp-dissociate@x") + serverConn, user := authenticatedServerConnection(t, server, clientID) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + session, err := server.relay.DialUDP(ctx, user.Email) + if err != nil { + t.Fatalf("open SOCKS UDP session: %v", err) + } + const associationID uint16 = 61245 + association := &udpAssociation{ + responseTransport: packetTransportStream, + relay: &udpRelaySession{relay: session, responseTransport: packetTransportStream}, + lastActive: time.Now(), + } + registry := newUdpAssociationRegistry(maxUdpRelayPacketSize) + registry.associations[associationID] = association + responseDone := make(chan struct{}) + go func() { + defer close(responseDone) + server.relayUDPResponses(ctx, serverConn, user, associationID, association, registry, association.relay) + }() + target := &Address{Type: AddrTypeIPv4, IP: net.IPv4(8, 8, 8, 8), Port: 53} + for range 150 { + if _, err := session.Send(target, []byte("echo")); err != nil { + t.Fatalf("send SOCKS datagram: %v", err) + } + } + time.Sleep(300 * time.Millisecond) + registry.dissociate(associationID) + select { + case <-responseDone: + case <-time.After(2 * time.Second): + t.Fatal("the response relay is still running after its association was dissociated") + } +} diff --git a/internal/tuic/udp_associations.go b/internal/tuic/udp_associations.go index 9e7de4e7b..98d62e36d 100644 --- a/internal/tuic/udp_associations.go +++ b/internal/tuic/udp_associations.go @@ -20,7 +20,10 @@ type udpAssociation struct { lastActive time.Time opening bool openCancel context.CancelFunc - pending []pendingUdpPacket + // stopResponses ends the response relay, which may be blocked on stream credit the + // client never grants; closing the SOCKS side alone does not reach it. + stopResponses context.CancelFunc + pending []pendingUdpPacket } type udpAssociationRegistry struct { @@ -122,6 +125,7 @@ func (r *udpAssociationRegistry) dissociate(assocID uint16) bool { association := r.associations[assocID] if association != nil { r.clearPendingLocked(association) + association.stopResponsesLocked() } delete(r.associations, assocID) r.datagramReassembler.clearAssociation(assocID) @@ -160,6 +164,7 @@ func (r *udpAssociationRegistry) expireLocked(now time.Time) { } delete(r.associations, assocID) r.clearPendingLocked(association) + association.stopResponsesLocked() r.datagramReassembler.clearAssociation(assocID) r.streamReassembler.clearAssociation(assocID) if association.relay != nil { @@ -172,6 +177,7 @@ func (r *udpAssociationRegistry) closeAll() { r.mu.Lock() for _, association := range r.associations { r.clearPendingLocked(association) + association.stopResponsesLocked() if association.relay != nil { _ = association.relay.relay.Close() } @@ -186,6 +192,7 @@ func (r *udpAssociationRegistry) release(id uint16, expected *udpAssociation) { r.mu.Lock() if r.associations[id] == expected { r.clearPendingLocked(expected) + expected.stopResponsesLocked() delete(r.associations, id) r.datagramReassembler.clearAssociation(id) r.streamReassembler.clearAssociation(id) @@ -195,3 +202,21 @@ func (r *udpAssociationRegistry) release(id uint16, expected *udpAssociation) { _ = expected.relay.relay.Close() } } + +// attachResponseStop records the response relay's cancel on a live association; false +// means the association is already gone and the relay must not start. +func (r *udpAssociationRegistry) attachResponseStop(assocID uint16, expected *udpAssociation, stop context.CancelFunc) bool { + r.mu.Lock() + defer r.mu.Unlock() + if r.associations[assocID] != expected { + return false + } + expected.stopResponses = stop + return true +} + +func (a *udpAssociation) stopResponsesLocked() { + if a.stopResponses != nil { + a.stopResponses() + } +} From e51a32b04560593dc55c28e7bf937ea16d8a4e5c Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 31 Oct 2026 14:59:20 +0100 Subject: [PATCH 040/125] fix(tuic): bound one client's connections and UDP relays The UDP association cap applied per QUIC connection, and nothing limited how many connections one credential could authenticate, so a single client could hold enough UDP relays (each a host port) to exhaust the host's ephemeral range. A client now gets at most 32 connections and 512 open UDP relays across all of them; past that a connection is refused at authentication and a relay is not opened. --- internal/tuic/auth.go | 9 ++++++ internal/tuic/server.go | 12 ++++++-- internal/tuic/socks_bridge.go | 5 ++++ internal/tuic/stream_udp_lifecycle_test.go | 34 ++++++++++++++++++++++ internal/tuic/udp_associations.go | 8 +++++ 5 files changed, 66 insertions(+), 2 deletions(-) diff --git a/internal/tuic/auth.go b/internal/tuic/auth.go index f33ed4afc..a0aca19b8 100644 --- a/internal/tuic/auth.go +++ b/internal/tuic/auth.go @@ -17,6 +17,13 @@ var ( ErrInvalidTLSState = errors.New("tuic: TLS connection state not available") ) +// One credential's limits across all its connections: each UDP relay holds a host port, so +// a per-connection cap alone lets one client take the host's whole ephemeral range. +const ( + maxUserSessions = 32 + maxUserUdpRelays = 512 +) + // User represents a configured TUIC client for authentication and billing. type User struct { TrafficID int @@ -26,6 +33,8 @@ type User struct { Email string Traffic *UserTraffic sessions atomic.Int64 + // udpRelays counts the open UDP relays over all the client's connections. + udpRelays atomic.Int64 } type UserTraffic struct { diff --git a/internal/tuic/server.go b/internal/tuic/server.go index f1622edf1..c73b1f677 100644 --- a/internal/tuic/server.go +++ b/internal/tuic/server.go @@ -196,14 +196,18 @@ func (s *Server) applyCongestionControl(conn *quic.Conn) { } } -func (s *Server) registerConn(user *User, conn *quic.Conn) { +func (s *Server) registerConn(user *User, conn *quic.Conn) bool { s.activeConnsMu.Lock() defer s.activeConnsMu.Unlock() + if user.sessions.Load() >= maxUserSessions { + return false + } if s.activeConns[user.UUID] == nil { s.activeConns[user.UUID] = make(map[*quic.Conn]*User) } s.activeConns[user.UUID][conn] = user user.sessions.Add(1) + return true } func (s *Server) unregisterConn(user *User, conn *quic.Conn) { @@ -379,8 +383,12 @@ func (s *Server) handleConn(conn *quic.Conn) { if !authState.CompareAndSwap(0, 1) { return authState.Load() == 1 && authUser.Load() == user } + if !s.registerConn(user, conn) { + authState.Store(2) + s.logLimited(tuicLogWarn, "tuic-sessions", 30*time.Second, "client refused: more than %d connections", maxUserSessions) + return false + } authUser.Store(user) - s.registerConn(user, conn) s.markActive(user.Email) authTimer.Stop() s.logf(tuicLogInfo, "client authenticated") diff --git a/internal/tuic/socks_bridge.go b/internal/tuic/socks_bridge.go index f64ad86f7..cf2d14321 100644 --- a/internal/tuic/socks_bridge.go +++ b/internal/tuic/socks_bridge.go @@ -180,6 +180,8 @@ type SocksUDPSession struct { targetEP net.Addr user string closed atomic.Bool + // onClose runs once when the session closes, releasing its place in a budget. + onClose func() } // DialUDP establishes a SOCKS5 UDP ASSOCIATE tunnel to Xray. @@ -312,6 +314,9 @@ func (s *SocksUDPSession) Close() error { if s.closed.Swap(true) { return nil } + if s.onClose != nil { + s.onClose() + } _ = s.udpConn.Close() return s.ctrl.Close() } diff --git a/internal/tuic/stream_udp_lifecycle_test.go b/internal/tuic/stream_udp_lifecycle_test.go index 87b9d3496..aa2d2dd6c 100644 --- a/internal/tuic/stream_udp_lifecycle_test.go +++ b/internal/tuic/stream_udp_lifecycle_test.go @@ -252,3 +252,37 @@ func TestDissociateStopsAResponseRelayBlockedOnTheClient(t *testing.T) { t.Fatal("the response relay is still running after its association was dissociated") } } + +// The per-connection association cap means nothing if one credential may open any number +// of connections: each UDP relay holds a host port, so the host's whole range ran out. +func TestOneClientsSessionsAndUDPRelaysAreBounded(t *testing.T) { + socksAddr, cleanup := startMockSocks5Server(t, "udp-bound@x", "lifecycle-socks-password") + defer cleanup() + server, _, clientID, _ := startLifecycleTestServer(t, socksAddr, "udp-bound@x") + _, user := authenticatedServerConnection(t, server, clientID) + + for range maxUserSessions + 5 { + server.registerConn(user, nil) + } + if got := user.sessions.Load(); got > maxUserSessions { + t.Fatalf("one client holds %d sessions, want at most %d", got, maxUserSessions) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + registry := newUdpAssociationRegistry(maxUdpRelayPacketSize) + user.udpRelays.Store(maxUserUdpRelays) + association := &udpAssociation{responseTransport: packetTransportDatagram, lastActive: time.Now()} + registry.associations[1] = association + if _, _, err := registry.ensureRelay(ctx, 1, association, user, server.relay); err == nil { + t.Fatal("a client past its UDP relay budget opened another relay") + } + user.udpRelays.Store(0) + if _, created, err := registry.ensureRelay(ctx, 1, association, user, server.relay); err != nil || !created { + t.Fatalf("ensureRelay under budget = %v, %v", created, err) + } + registry.dissociate(1) + if got := user.udpRelays.Load(); got != 0 { + t.Fatalf("closed relay still counted: %d", got) + } +} diff --git a/internal/tuic/udp_associations.go b/internal/tuic/udp_associations.go index 98d62e36d..85ba2089f 100644 --- a/internal/tuic/udp_associations.go +++ b/internal/tuic/udp_associations.go @@ -14,6 +14,8 @@ const ( var errUdpAssociationClosed = errors.New("tuic: UDP association is closed") +var errUserUdpRelayBudget = errors.New("tuic: the client has too many open UDP relays") + type udpAssociation struct { responseTransport uint8 relay *udpRelaySession @@ -98,10 +100,16 @@ func (r *udpAssociationRegistry) ensureRelay(ctx context.Context, assocID uint16 } r.mu.Unlock() + if user.udpRelays.Add(1) > maxUserUdpRelays { + user.udpRelays.Add(-1) + return nil, false, errUserUdpRelayBudget + } socksSession, err := relay.DialUDP(ctx, user.Email) if err != nil { + user.udpRelays.Add(-1) return nil, false, err } + socksSession.onClose = func() { user.udpRelays.Add(-1) } r.mu.Lock() defer r.mu.Unlock() From 6d90dcfe0c8f670452525dd444b98cda258b2fa3 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 31 Oct 2026 15:03:21 +0100 Subject: [PATCH 041/125] fix(tuic): bound what unauthenticated connections cost Anyone reaching a TUIC port could start QUIC handshakes without ever reading a reply, each holding state and goroutines, and an unauthenticated connection got a handler goroutine per stream it opened. Both grew until the panel ran out of memory. The listener now counts connections from their first packet until they authenticate or close: past 64 they must answer a stateless Retry, and past 1024 in total or 32 per verified source (IPv4 address or IPv6 /64) they are refused. Before authenticating, a connection may start at most 16 handlers per stream kind; further streams are reset. --- internal/tuic/pending_conns.go | 112 ++++++++++++++++++++++++++++ internal/tuic/pending_conns_test.go | 82 ++++++++++++++++++++ internal/tuic/server.go | 51 +++++++++++-- 3 files changed, 240 insertions(+), 5 deletions(-) create mode 100644 internal/tuic/pending_conns.go create mode 100644 internal/tuic/pending_conns_test.go diff --git a/internal/tuic/pending_conns.go b/internal/tuic/pending_conns.go new file mode 100644 index 000000000..c829cabf4 --- /dev/null +++ b/internal/tuic/pending_conns.go @@ -0,0 +1,112 @@ +package tuic + +import ( + "context" + "errors" + "net" + "net/netip" + "sync" +) + +// A connection is pending from its first Initial packet until it authenticates or closes; +// each one holds handshake state and goroutines, so an unauthenticated flood is bounded here. +const ( + // pendingRetryThreshold is where new connections must first answer a stateless Retry, + // so a sender that never reads replies, or forges its source, creates no state. + pendingRetryThreshold = 64 + maxPendingConns = 1024 + // maxPendingPerSource counts verified sources only (an IPv4 address or IPv6 /64). + maxPendingPerSource = 32 + // maxPreAuthStreams bounds the stream handlers one connection may start per stream + // kind before it authenticates. + maxPreAuthStreams = 16 +) + +var errTooManyPendingConns = errors.New("tuic: too many connections awaiting authentication") + +type pendingConns struct { + mu sync.Mutex + total int + bySource map[netip.Prefix]int +} + +type pendingSlotKey struct{} + +// pendingSlot is one connection's place in the count, released exactly once. +type pendingSlot struct { + once sync.Once + release func() +} + +func (p *pendingSlot) done() { + if p != nil { + p.once.Do(p.release) + } +} + +func (p *pendingConns) needsRetry(net.Addr) bool { + p.mu.Lock() + defer p.mu.Unlock() + return p.total >= pendingRetryThreshold +} + +// admit takes a slot for a new connection, or refuses it at the ceilings; the slot frees +// when ctx (the connection's own) ends or the connection authenticates. +func (p *pendingConns) admit(ctx context.Context, info connInfo) (context.Context, error) { + source, counted := pendingSource(info) + p.mu.Lock() + if p.total >= maxPendingConns || (counted && p.bySource[source] >= maxPendingPerSource) { + p.mu.Unlock() + return ctx, errTooManyPendingConns + } + p.total++ + if counted { + if p.bySource == nil { + p.bySource = make(map[netip.Prefix]int) + } + p.bySource[source]++ + } + p.mu.Unlock() + slot := &pendingSlot{release: func() { + p.mu.Lock() + defer p.mu.Unlock() + p.total-- + if counted { + if p.bySource[source]--; p.bySource[source] <= 0 { + delete(p.bySource, source) + } + } + }} + context.AfterFunc(ctx, slot.done) + return context.WithValue(ctx, pendingSlotKey{}, slot), nil +} + +// connInfo is what admit needs of quic.ClientInfo. +type connInfo struct { + remote net.Addr + addressVerified bool +} + +func pendingSource(info connInfo) (netip.Prefix, bool) { + udp, ok := info.remote.(*net.UDPAddr) + if !ok || !info.addressVerified { + return netip.Prefix{}, false + } + addr, ok := netip.AddrFromSlice(udp.IP) + if !ok { + return netip.Prefix{}, false + } + addr = addr.Unmap() + bits := 32 + if addr.Is6() { + bits = 64 + } + prefix, err := addr.Prefix(bits) + return prefix, err == nil +} + +func releasePendingSlot(ctx context.Context) { + if slot, ok := ctx.Value(pendingSlotKey{}).(*pendingSlot); ok { + slot.done() + } +} diff --git a/internal/tuic/pending_conns_test.go b/internal/tuic/pending_conns_test.go new file mode 100644 index 000000000..bcb3bac0f --- /dev/null +++ b/internal/tuic/pending_conns_test.go @@ -0,0 +1,82 @@ +package tuic + +import ( + "context" + "crypto/tls" + "errors" + "net" + "runtime" + "testing" + "time" + + clientquic "github.com/quic-go/quic-go" +) + +// Connections awaiting authentication are counted from their first packet: past the +// threshold they must prove their address, and past the ceilings they are refused. +func TestPendingConnsRetryAndCeilings(t *testing.T) { + var p pendingConns + verified := func(ip string) connInfo { + return connInfo{remote: &net.UDPAddr{IP: net.ParseIP(ip), Port: 1}, addressVerified: true} + } + ctx, cancel := context.WithCancel(context.Background()) + for i := range pendingRetryThreshold { + if p.needsRetry(nil) { + t.Fatalf("Retry demanded with only %d pending", i) + } + if _, err := p.admit(ctx, connInfo{remote: &net.UDPAddr{IP: net.IPv4(203, 0, 113, byte(i)), Port: 1}}); err != nil { + t.Fatalf("admit %d: %v", i, err) + } + } + if !p.needsRetry(nil) { + t.Fatal("no Retry demanded at the threshold") + } + for range maxPendingPerSource { + if _, err := p.admit(ctx, verified("2001:db8::1")); err != nil { + t.Fatalf("admit from one source: %v", err) + } + } + if _, err := p.admit(ctx, verified("2001:db8::2")); !errors.Is(err, errTooManyPendingConns) { + t.Fatalf("one /64 past its share = %v, want refused", err) + } + cancel() + deadline := time.Now().Add(time.Second) + for { + p.mu.Lock() + total := p.total + p.mu.Unlock() + if total == 0 { + break + } + if time.Now().After(deadline) { + t.Fatalf("%d slots still held after their connections ended", total) + } + time.Sleep(5 * time.Millisecond) + } +} + +// A peer that never authenticates must not get a handler goroutine per stream it opens. +func TestUnauthenticatedStreamsShareASmallHandlerBudget(t *testing.T) { + server, _, _, _ := startLifecycleTestServer(t, "127.0.0.1:1", "preauth@x") + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + peer, err := clientquic.DialAddr(ctx, server.packetConn.LocalAddr().String(), + &tls.Config{InsecureSkipVerify: true, NextProtos: []string{"h3"}}, &clientquic.Config{}) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer func() { _ = peer.CloseWithError(0, "") }() + time.Sleep(100 * time.Millisecond) + before := runtime.NumGoroutine() + for range 90 { + stream, err := peer.OpenStreamSync(ctx) + if err != nil { + break + } + _, _ = stream.Write([]byte{ProtocolVersion, CmdConnect}) + } + time.Sleep(300 * time.Millisecond) + if grown := runtime.NumGoroutine() - before; grown > 2*maxPreAuthStreams { + t.Fatalf("90 unauthenticated streams started %d goroutines", grown) + } +} diff --git a/internal/tuic/server.go b/internal/tuic/server.go index c73b1f677..ec83540fa 100644 --- a/internal/tuic/server.go +++ b/internal/tuic/server.go @@ -38,10 +38,12 @@ type Server struct { users *UserRegistry relay *SocksRelay - tlsConfig *tls.Config - quicConfig *quic.Config - quicListener *quic.Listener - packetConn net.PacketConn + tlsConfig *tls.Config + quicConfig *quic.Config + quicListener *quic.Listener + quicTransport *quic.Transport + pending pendingConns + packetConn net.PacketConn lastOnline sync.Map // email string -> time.Time logThrottle sync.Map // event name -> *atomic.Int64 timestamp @@ -146,12 +148,21 @@ func (s *Server) Start() error { } s.packetConn = pConn - ln, err := quic.Listen(pConn, s.tlsConfig, s.quicConfig) + transport := &quic.Transport{ + Conn: pConn, + VerifySourceAddress: s.pending.needsRetry, + ConnContext: func(ctx context.Context, info *quic.ClientInfo) (context.Context, error) { + return s.pending.admit(ctx, connInfo{remote: info.RemoteAddr, addressVerified: info.AddrVerified}) + }, + } + ln, err := transport.Listen(s.tlsConfig, s.quicConfig) if err != nil { + _ = transport.Close() _ = pConn.Close() return fmt.Errorf("tuic: quic listen on %s: %w", s.listenAddr, err) } s.quicListener = ln + s.quicTransport = transport s.running.Store(true) s.logf(tuicLogInfo, "listener started on %s", s.listenAddr) @@ -391,6 +402,7 @@ func (s *Server) handleConn(conn *quic.Conn) { authUser.Store(user) s.markActive(user.Email) authTimer.Stop() + releasePendingSlot(conn.Context()) s.logf(tuicLogInfo, "client authenticated") authOnce.Do(func() { close(authSignal) }) return true @@ -430,6 +442,19 @@ func (s *Server) handleConn(conn *quic.Conn) { defer cleanup() var innerWg sync.WaitGroup + // Before authentication each stream kind may start only a few handlers; a peer that never + // authenticates would otherwise get a goroutine per stream it opens. + var preAuthUni, preAuthBi atomic.Int32 + preAuthSlot := func(count *atomic.Int32) (release func(), ok bool) { + if authState.Load() == 1 { + return func() {}, true + } + if count.Add(1) > maxPreAuthStreams { + count.Add(-1) + return nil, false + } + return func() { count.Add(-1) }, true + } // Loop 1: Unidirectional streams innerWg.Add(1) go func() { @@ -439,9 +464,15 @@ func (s *Server) handleConn(conn *quic.Conn) { if err != nil { return } + release, ok := preAuthSlot(&preAuthUni) + if !ok { + uniStream.CancelRead(0x100) + continue + } innerWg.Add(1) go func(stream *quic.ReceiveStream) { defer innerWg.Done() + defer release() s.handleUniStream(sessCtx, conn, stream, authenticate, waitForAuth, udpAssociations, &relayWg) }(uniStream) } @@ -456,9 +487,16 @@ func (s *Server) handleConn(conn *quic.Conn) { if err != nil { return } + release, ok := preAuthSlot(&preAuthBi) + if !ok { + biStream.CancelRead(0x100) + biStream.CancelWrite(0x100) + continue + } innerWg.Add(1) go func(stream *quic.Stream) { defer innerWg.Done() + defer release() s.handleBiStream(sessCtx, conn, stream, authenticate, waitForAuth) }(biStream) } @@ -1032,6 +1070,9 @@ func (s *Server) Close() error { if s.quicListener != nil { err = s.quicListener.Close() } + if s.quicTransport != nil { + _ = s.quicTransport.Close() + } if s.packetConn != nil { _ = s.packetConn.Close() } From 53ee53ed9c8b3147ec8fbfaddc25c079532c70c7 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 31 Oct 2026 15:05:12 +0100 Subject: [PATCH 042/125] fix(amneziawg): take a forwarded TCP connection's source from the request The TCP forwarder handler read the tunnel client's address from the accepted connection's RemoteAddr, which asks the live endpoint and is nil once the peer's RST has been processed. A client sending SYN, ACK and RST through its own tunnel could hit that window and crash the panel on a goroutine with no recover. The forwarder now hands the handler the source from the request's endpoint ID, as it already did for the destination. --- internal/amneziawgnet/bench_test.go | 2 +- internal/amneziawgnet/device_test.go | 13 +-- internal/amneziawgnet/diagnostics_test.go | 2 +- internal/amneziawgnet/forwarder.go | 10 +- internal/amneziawgnet/forwarder_test.go | 110 ++++++++++++++++++++++ internal/amneziawgnet/manager.go | 9 +- internal/amneziawgnet/relay_e2e_test.go | 9 +- 7 files changed, 127 insertions(+), 28 deletions(-) create mode 100644 internal/amneziawgnet/forwarder_test.go diff --git a/internal/amneziawgnet/bench_test.go b/internal/amneziawgnet/bench_test.go index b10ef3724..34b32f965 100644 --- a/internal/amneziawgnet/bench_test.go +++ b/internal/amneziawgnet/bench_test.go @@ -134,7 +134,7 @@ func newBenchTunnel(b *testing.B, listenPort int, serverAddr, clientAddr string) if err != nil { b.Fatalf("newUnconfiguredDevice: %v", err) } - AttachTCPForwarder(dev.Stack, func(conn *gonet.TCPConn, dest netip.AddrPort) { + AttachTCPForwarder(dev.Stack, func(conn *gonet.TCPConn, _, dest netip.AddrPort) { defer conn.Close() switch dest.Port() { case benchDiscardPort: diff --git a/internal/amneziawgnet/device_test.go b/internal/amneziawgnet/device_test.go index 39e4cd602..6e4f82e62 100644 --- a/internal/amneziawgnet/device_test.go +++ b/internal/amneziawgnet/device_test.go @@ -80,14 +80,9 @@ func TestNewDeviceHandshakeForwarderAndIdentity(t *testing.T) { // purely from the decapsulated packet, not from any routing table. wantDest := netip.MustParseAddrPort("10.201.9.9:9999") - AttachTCPForwarder(dev.Stack, func(conn *gonet.TCPConn, dest netip.AddrPort) { + AttachTCPForwarder(dev.Stack, func(conn *gonet.TCPConn, src, dest netip.AddrPort) { defer conn.Close() - srcAddrPort, parseErr := netip.ParseAddrPort(conn.RemoteAddr().String()) - var peer amneziawg.Peer - var ok bool - if parseErr == nil { - peer, ok = idx.Lookup(srcAddrPort.Addr().Unmap()) - } + peer, ok := idx.Lookup(src.Addr().Unmap()) got <- recovered{email: peer.Email, ok: ok, dest: dest} io.Copy(io.Discard, conn) }) @@ -283,7 +278,7 @@ func TestNewDeviceHeaderProtectionAndContentPaddingRoundTrip(t *testing.T) { const wantRequest = "hello from client" const wantReply = "hello from server" serverDone := make(chan error, 1) - AttachTCPForwarder(dev.Stack, func(conn *gonet.TCPConn, dest netip.AddrPort) { + AttachTCPForwarder(dev.Stack, func(conn *gonet.TCPConn, _, dest netip.AddrPort) { defer conn.Close() buf := make([]byte, len(wantRequest)) if _, err := io.ReadFull(conn, buf); err != nil { @@ -461,7 +456,7 @@ func TestNewDeviceRandomTrailersAndDisableCookiesRoundTrip(t *testing.T) { const wantRequest = "hello from client, with a trailer" const wantReply = "hello from server, with a trailer" serverDone := make(chan error, 1) - AttachTCPForwarder(dev.Stack, func(conn *gonet.TCPConn, dest netip.AddrPort) { + AttachTCPForwarder(dev.Stack, func(conn *gonet.TCPConn, _, dest netip.AddrPort) { defer conn.Close() buf := make([]byte, len(wantRequest)) if _, err := io.ReadFull(conn, buf); err != nil { diff --git a/internal/amneziawgnet/diagnostics_test.go b/internal/amneziawgnet/diagnostics_test.go index 41b6af011..231ff128b 100644 --- a/internal/amneziawgnet/diagnostics_test.go +++ b/internal/amneziawgnet/diagnostics_test.go @@ -77,7 +77,7 @@ func TestDiagnoseDeviceReportsListenPortAndPeerState(t *testing.T) { // Registering the forwarder doesn't require any peer to be configured // yet, so this ordering is free; it's Configure's IpcSet that must // never run before the forwarder is registered. - AttachTCPForwarder(dev.Stack, func(conn *gonet.TCPConn, _ netip.AddrPort) { + AttachTCPForwarder(dev.Stack, func(conn *gonet.TCPConn, _, _ netip.AddrPort) { defer conn.Close() io.Copy(io.Discard, conn) }) diff --git a/internal/amneziawgnet/forwarder.go b/internal/amneziawgnet/forwarder.go index 63c13496d..b86700f65 100644 --- a/internal/amneziawgnet/forwarder.go +++ b/internal/amneziawgnet/forwarder.go @@ -12,15 +12,18 @@ import ( // AttachTCPForwarder attaches a TCP forwarder to gstack in promiscuous + // spoofing mode, so it accepts connections addressed to any destination -- // not just the stack's own configured local address -- and hands the -// handler both the accepted connection and the tunnel client's real, +// handler the accepted connection, the tunnel client's source and its real, // dynamically-arbitrary destination (recovered from the connection's own // TransportEndpointID, not from any preconfigured routing table). This is // the mechanism the whole embedded-AmneziaWG design depends on: what the // handler does with that destination (dial it directly, relay it into // Xray's SOCKS5 inbound, ...) is entirely up to the caller. // +// Handlers must identify the peer by src, never conn.RemoteAddr(): that asks +// the live endpoint and is nil once a RST is processed, possibly before the call. +// // Adapted from xtls/xray-core's proxy/wireguard/tun.go createForwarder (MIT). -func AttachTCPForwarder(gstack *stack.Stack, handler func(conn *gonet.TCPConn, dest netip.AddrPort)) { +func AttachTCPForwarder(gstack *stack.Stack, handler func(conn *gonet.TCPConn, src, dest netip.AddrPort)) { enablePromiscuousRouting(gstack) fwd := tcp.NewForwarder(gstack, 0, 65535, func(r *tcp.ForwarderRequest) { @@ -33,8 +36,9 @@ func AttachTCPForwarder(gstack *stack.Stack, handler func(conn *gonet.TCPConn, d r.Complete(true) return } + src := netip.AddrPortFrom(addrFromTcpip(id.RemoteAddress), id.RemotePort) dest := netip.AddrPortFrom(addrFromTcpip(id.LocalAddress), id.LocalPort) - handler(gonet.NewTCPConn(&wq, ep), dest) + handler(gonet.NewTCPConn(&wq, ep), src, dest) ep.Close() r.Complete(false) }(r) diff --git a/internal/amneziawgnet/forwarder_test.go b/internal/amneziawgnet/forwarder_test.go new file mode 100644 index 000000000..66f48561a --- /dev/null +++ b/internal/amneziawgnet/forwarder_test.go @@ -0,0 +1,110 @@ +package amneziawgnet + +import ( + "net" + "net/netip" + "testing" + "time" + + awgtun "github.com/amnezia-vpn/amneziawg-go/v3/tun" + "gvisor.dev/gvisor/pkg/tcpip" + "gvisor.dev/gvisor/pkg/tcpip/adapters/gonet" + "gvisor.dev/gvisor/pkg/tcpip/checksum" + "gvisor.dev/gvisor/pkg/tcpip/header" +) + +func rawTCPSegment(src, dst netip.AddrPort, seq, ack uint32, flags header.TCPFlags) []byte { + srcIP, dstIP := tcpip.AddrFromSlice(src.Addr().AsSlice()), tcpip.AddrFromSlice(dst.Addr().AsSlice()) + packet := make([]byte, header.IPv4MinimumSize+header.TCPMinimumSize) + ip := header.IPv4(packet) + ip.Encode(&header.IPv4Fields{ + TotalLength: uint16(len(packet)), + TTL: 64, + Protocol: uint8(header.TCPProtocolNumber), + SrcAddr: srcIP, + DstAddr: dstIP, + }) + ip.SetChecksum(^ip.CalculateChecksum()) + tcp := header.TCP(packet[header.IPv4MinimumSize:]) + tcp.Encode(&header.TCPFields{ + SrcPort: src.Port(), + DstPort: dst.Port(), + SeqNum: seq, + AckNum: ack, + DataOffset: header.TCPMinimumSize, + Flags: flags, + WindowSize: 65535, + }) + xsum := header.PseudoHeaderChecksum(header.TCPProtocolNumber, srcIP, dstIP, header.TCPMinimumSize) + tcp.SetChecksum(^tcp.CalculateChecksum(checksum.Checksum(nil, xsum))) + return packet +} + +func injectFromPeer(t *testing.T, tun awgtun.Device, packet []byte) { + t.Helper() + if _, err := tun.Write([][]byte{packet}, 0); err != nil { + t.Fatalf("inject packet: %v", err) + } +} + +// A peer's RST can beat the handler, and a reset endpoint has no RemoteAddr: +// deriving the source from it panicked the panel on a goroutine with no recover. +func TestTCPForwarderHandsOverSourceOfAnAlreadyResetConnection(t *testing.T) { + tun, gstack, err := createNetTUNWithStack([]netip.Addr{netip.MustParseAddr("10.9.0.1")}, 1420) + if err != nil { + t.Fatalf("createNetTUNWithStack: %v", err) + } + defer tun.Close() + + peer := netip.MustParseAddrPort("10.9.0.2:40000") + target := netip.MustParseAddrPort("203.0.113.7:443") + + type handedOver struct { + src, dest netip.AddrPort + remoteAddr net.Addr + } + accepted, resetSent := make(chan struct{}), make(chan struct{}) + got := make(chan handedOver, 1) + AttachTCPForwarder(gstack, func(conn *gonet.TCPConn, src, dest netip.AddrPort) { + defer conn.Close() + close(accepted) + <-resetSent + // Returns only once the stack has processed the RST. + _, _ = conn.Read(make([]byte, 1)) + got <- handedOver{src: src, dest: dest, remoteAddr: conn.RemoteAddr()} + }) + + const peerISN = 1000 + injectFromPeer(t, tun, rawTCPSegment(peer, target, peerISN, 0, header.TCPFlagSyn)) + bufs, sizes := [][]byte{make([]byte, 2048)}, make([]int, 1) + if _, err := tun.Read(bufs, sizes, 0); err != nil { + t.Fatalf("read SYN-ACK: %v", err) + } + synAck := header.TCP(bufs[0][header.IPv4MinimumSize:sizes[0]]) + if synAck.Flags() != header.TCPFlagSyn|header.TCPFlagAck { + t.Fatalf("first reply has flags %s, want SYN-ACK", synAck.Flags()) + } + injectFromPeer(t, tun, rawTCPSegment(peer, target, peerISN+1, synAck.SequenceNumber()+1, header.TCPFlagAck)) + select { + case <-accepted: + case <-time.After(5 * time.Second): + t.Fatal("handler never ran") + } + injectFromPeer(t, tun, rawTCPSegment(peer, target, peerISN+1, 0, header.TCPFlagRst)) + close(resetSent) + + select { + case h := <-got: + if h.remoteAddr != nil { + t.Fatalf("RemoteAddr() = %v after the RST, want nil: the test no longer reaches the reset state", h.remoteAddr) + } + if h.src != peer { + t.Errorf("src = %s, want %s", h.src, peer) + } + if h.dest != target { + t.Errorf("dest = %s, want %s", h.dest, target) + } + case <-time.After(5 * time.Second): + t.Fatal("handler never saw the reset") + } +} diff --git a/internal/amneziawgnet/manager.go b/internal/amneziawgnet/manager.go index 03eb4d702..f9bae32c2 100644 --- a/internal/amneziawgnet/manager.go +++ b/internal/amneziawgnet/manager.go @@ -205,15 +205,10 @@ func (m *Manager) ensureLocked(d Desired) error { structFP: structFP, } next.peers.Store(NewPeerIndex(inst.Peers)) - AttachTCPForwarder(dev.Stack, func(conn *gonet.TCPConn, dest netip.AddrPort) { - srcAddrPort, err := netip.ParseAddrPort(conn.RemoteAddr().String()) - if err != nil { - conn.Close() - return - } + AttachTCPForwarder(dev.Stack, func(conn *gonet.TCPConn, src, dest netip.AddrPort) { // Reload for every connection: in-place reconfiguration swaps the peer // index without reattaching handlers and may hold the lifecycle lock. - peer, ok := next.lookupPeer(srcAddrPort.Addr().Unmap()) + peer, ok := next.lookupPeer(src.Addr().Unmap()) if !ok { conn.Close() return diff --git a/internal/amneziawgnet/relay_e2e_test.go b/internal/amneziawgnet/relay_e2e_test.go index ce4ba4ea7..01a715be5 100644 --- a/internal/amneziawgnet/relay_e2e_test.go +++ b/internal/amneziawgnet/relay_e2e_test.go @@ -161,13 +161,8 @@ func TestSocksRelayAgainstRealXray(t *testing.T) { udpRelay := NewUDPRelay(relay, dev.Stack) defer udpRelay.Close() - AttachTCPForwarder(dev.Stack, func(conn *gonet.TCPConn, dest netip.AddrPort) { - srcAddrPort, err := netip.ParseAddrPort(conn.RemoteAddr().String()) - if err != nil { - conn.Close() - return - } - peer, ok := idx.Lookup(srcAddrPort.Addr().Unmap()) + AttachTCPForwarder(dev.Stack, func(conn *gonet.TCPConn, src, dest netip.AddrPort) { + peer, ok := idx.Lookup(src.Addr().Unmap()) if !ok { conn.Close() return From 0ea11e16437a268e23eabd42aeb402594d1b3a70 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 31 Oct 2026 15:06:34 +0100 Subject: [PATCH 043/125] fix(amneziawg): keep a port forward accepting after a failed accept A TCP port forward's accept loop returned on any accept error, leaving the socket bound and the forward registered as open, so nothing served the port again until a panel restart; briefly pushing the panel to its open-file limit was enough. Forward and egress accept loops now log, wait 100 ms (or until closed) and keep accepting. --- internal/amneziawgnet/egress.go | 3 ++ internal/amneziawgnet/portfwd.go | 22 ++++++++++++++- internal/amneziawgnet/portfwd_test.go | 40 +++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 1 deletion(-) diff --git a/internal/amneziawgnet/egress.go b/internal/amneziawgnet/egress.go index 28eb41682..1f2d7e793 100644 --- a/internal/amneziawgnet/egress.go +++ b/internal/amneziawgnet/egress.go @@ -218,6 +218,9 @@ func (s *socks5EgressServer) acceptLoop(ln net.Listener, closing chan struct{}) default: } logger.Warningf("amneziawgnet: egress accept: %v", err) + if !waitAcceptRetry(closing) { + return + } continue } select { diff --git a/internal/amneziawgnet/portfwd.go b/internal/amneziawgnet/portfwd.go index 9f5d6c48c..80cbef491 100644 --- a/internal/amneziawgnet/portfwd.go +++ b/internal/amneziawgnet/portfwd.go @@ -270,6 +270,21 @@ func openPortForwardListener(gstack *stack.Stack, inboundID int, key portForward const portForwardDialTimeout = 10 * time.Second +// acceptRetryDelay paces an accept loop's retries, so an error that lasts +// (EMFILE, until descriptors free up) neither spins it nor floods the log. +const acceptRetryDelay = 100 * time.Millisecond + +// waitAcceptRetry sleeps acceptRetryDelay after a failed Accept; false means +// closing fired meanwhile and the loop should return instead of retrying. +func waitAcceptRetry(closing <-chan struct{}) bool { + select { + case <-closing: + return false + case <-time.After(acceptRetryDelay): + return true + } +} + // tunnelNetwork returns the gVisor network protocol number matching addr's // address family, for dialing toward it inside the embedded stack. func tunnelNetwork(addr netip.Addr) tcpip.NetworkProtocolNumber { @@ -325,7 +340,12 @@ func (l *tcpForwardListener) acceptLoop(gstack *stack.Stack, inboundID int, key default: } logger.Warningf("amneziawgnet: port-forward: inbound %d peer %q: accept tcp :%d: %v", inboundID, key.email, key.port, err) - return + // Keep accepting: returning would strand a bound socket nobody + // serves, under a key Reconcile still sees as open and never reopens. + if !waitAcceptRetry(l.closing) { + return + } + continue } go relayTCPForward(gstack, conn, inboundID, key, target) } diff --git a/internal/amneziawgnet/portfwd_test.go b/internal/amneziawgnet/portfwd_test.go index 573910719..d33405e46 100644 --- a/internal/amneziawgnet/portfwd_test.go +++ b/internal/amneziawgnet/portfwd_test.go @@ -2,11 +2,14 @@ package amneziawgnet import ( "context" + "errors" "fmt" "io" "net" "net/netip" "sync" + "sync/atomic" + "syscall" "testing" "time" @@ -222,6 +225,43 @@ func TestPortForwardSetReconcileSurvivesPreBoundPort(t *testing.T) { set.Close() } +// failFirstAccept fails the first Accept the way the kernel does once the +// process is at RLIMIT_NOFILE, then behaves like the listener it wraps. +type failFirstAccept struct { + net.Listener + failed atomic.Bool +} + +func (l *failFirstAccept) Accept() (net.Conn, error) { + if l.failed.CompareAndSwap(false, true) { + return nil, &net.OpError{Op: "accept", Net: "tcp", Addr: l.Addr(), Err: syscall.EMFILE} + } + return l.Listener.Accept() +} + +func TestTCPForwardAcceptLoopSurvivesTransientError(t *testing.T) { + host, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + l := &tcpForwardListener{ln: &failFirstAccept{Listener: host}, closing: make(chan struct{})} + defer l.Close() + noTarget := func(string) (netip.Addr, bool) { return netip.Addr{}, false } + go l.acceptLoop(newTestStack(t, "10.211.4.1"), 505, portForwardKey{email: "a@x", proto: tcpForward}, noTarget) + + conn, err := net.DialTimeout("tcp", host.Addr().String(), time.Second) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer conn.Close() + // relayTCPForward closes a connection it has no target for, so EOF proves + // the loop accepted it; a dead loop leaves it queued in the backlog. + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + if _, err := conn.Read(make([]byte, 1)); !errors.Is(err, io.EOF) { + t.Fatalf("read after a transient accept error = %v, want io.EOF from an accepted connection", err) + } +} + // --- Real round trip: a genuine amneziawg-go client handshakes against a // real server Device, PortForwardSet opens a real host listener, and a real // external-side dial (this test's own process) round-trips bytes through From ac896a9f955a23d84b5aad8da4765a87f61ae547 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 31 Oct 2026 15:10:32 +0100 Subject: [PATCH 044/125] fix(amneziawg): drop a removed client's flows before its address is reused Removing or re-keying a client reconfigured the device in place but left its TCP and UDP flows standing, and withdrawing a port forward stopped only new connections. The next client given the same tunnel address inherited those flows: it could read what outside hosts sent to the former client and write back into those connections. After an in-place reconfigure, every stack endpoint and UDP relay session pinned to an address whose peer was removed or changed is aborted, and closing a port forward cuts the connections it is relaying. --- internal/amneziawgnet/manager.go | 24 +++++- internal/amneziawgnet/manager_test.go | 112 ++++++++++++++++++++++++++ internal/amneziawgnet/netstack.go | 14 ++++ internal/amneziawgnet/portfwd.go | 33 ++++---- internal/amneziawgnet/portfwd_test.go | 15 +++- internal/amneziawgnet/relay.go | 13 +++ 6 files changed, 195 insertions(+), 16 deletions(-) diff --git a/internal/amneziawgnet/manager.go b/internal/amneziawgnet/manager.go index f9bae32c2..898741b0d 100644 --- a/internal/amneziawgnet/manager.go +++ b/internal/amneziawgnet/manager.go @@ -70,6 +70,27 @@ func (m *managed) handleUDP(src, dst netip.AddrPort, payload []byte) { m.udpRelay.Handle(src, dst, peer.Email, payload) } +// dropReassignedFlows aborts every stack flow pinned to a tunnel address its +// peer in before lost (removed or re-keyed): the next client inherits the address. +func (m *managed) dropReassignedFlows(before *PeerIndex) { + reassigned := func(addr netip.Addr) bool { + was, ok := before.Lookup(addr) + if !ok { + return false + } + now, ok := m.lookupPeer(addr) + return !ok || now.PublicKey != was.PublicKey + } + // Abort, not Close: a closed endpoint keeps retransmitting unacknowledged + // data, and that would reach whoever holds the address next. + for _, ep := range m.dev.Stack.RegisteredEndpoints() { + if remote, ok := endpointRemoteAddr(ep); ok && reassigned(remote) { + ep.Abort() + } + } + m.udpRelay.CloseSessions(reassigned) +} + func (m *managed) close() { m.portForwards.Close() // Stop packet delivery before closing the relay so an in-flight handler @@ -177,11 +198,12 @@ func (m *Manager) ensureLocked(d Desired) error { if err := cur.dev.IpcSet(conf); err != nil { return fmt.Errorf("amneziawgnet: reconfigure inbound %d: %w", inst.Id, err) } - cur.peers.Store(NewPeerIndex(inst.Peers)) + oldPeers := cur.peers.Swap(NewPeerIndex(inst.Peers)) cur.inst = inst cur.uapiConfig = conf applyV6Aliases(diffV6Aliases(oldInst, inst)) cur.portForwards.Reconcile(inst) + cur.dropReassignedFlows(oldPeers) return nil } diff --git a/internal/amneziawgnet/manager_test.go b/internal/amneziawgnet/manager_test.go index 6baaeb751..497b4360d 100644 --- a/internal/amneziawgnet/manager_test.go +++ b/internal/amneziawgnet/manager_test.go @@ -1,6 +1,7 @@ package amneziawgnet import ( + "context" "fmt" "net" "net/netip" @@ -8,6 +9,7 @@ import ( "time" "github.com/amnezia-vpn/amneziawg-go/v3/device" + "gvisor.dev/gvisor/pkg/tcpip/adapters/gonet" "github.com/mhsanaei/3x-ui/v3/internal/amneziawg" "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard" @@ -330,6 +332,116 @@ func TestForwardedPortsOnlyChangeStillReconcilesPortForwards(t *testing.T) { conn.Close() } +// The stack survives an in-place reconfiguration, so a flow pinned to a removed +// peer's address would otherwise reach whichever client gets that address next. +func TestEnsureDropsFlowsOfReassignedTunnelAddress(t *testing.T) { + priv, pub, err := wireguard.GenerateWireguardKeypair() + if err != nil { + t.Fatalf("generate server keypair: %v", err) + } + var peerPubs [3]string + for i := range peerPubs { + if _, peerPubs[i], err = wireguard.GenerateWireguardKeypair(); err != nil { + t.Fatalf("generate peer keypair: %v", err) + } + } + reused, kept := netip.MustParseAddr("10.206.0.2"), netip.MustParseAddr("10.206.0.3") + + m := &Manager{ifaces: map[int]*managed{}} + inst := amneziawg.Instance{ + Id: 9, + InterfaceName: "awgtest9", + ListenPort: 58720, + PrivateKey: priv, + PublicKey: pub, + Address: []string{"10.206.0.1/24"}, + MTU: 1420, + Obfuscation: amneziawg.Obfuscation31{ + Jc: 4, Jmin: 40, Jmax: 70, + S1: 20, S2: 30, S3: 20, S4: 20, + }, + Peers: []amneziawg.Peer{ + {Email: "old@test", PublicKey: peerPubs[0], AllowedIPs: []string{reused.String() + "/32"}}, + {Email: "other@test", PublicKey: peerPubs[1], AllowedIPs: []string{kept.String() + "/32"}}, + }, + } + defer m.StopAll() + if err := m.Ensure(Desired{Instance: inst}); err != nil { + t.Fatalf("Ensure (create): %v", err) + } + cur := m.ifaces[inst.Id] + + endpointsTo := func(addr netip.Addr) int { + n := 0 + for _, ep := range cur.dev.Stack.RegisteredEndpoints() { + if remote, ok := endpointRemoteAddr(ep); ok && remote == addr { + n++ + } + } + return n + } + waitEndpointsTo := func(addr netip.Addr, want int) { + t.Helper() + for deadline := time.Now().Add(2 * time.Second); endpointsTo(addr) != want; time.Sleep(5 * time.Millisecond) { + if time.Now().After(deadline) { + t.Fatalf("endpoints connected to %s = %d, want %d", addr, endpointsTo(addr), want) + } + } + } + + for _, addr := range []netip.Addr{reused, kept} { + raddr := tunnelFullAddress(addr, 8080) + udpConn, err := gonet.DialUDP(cur.dev.Stack, nil, &raddr, tunnelNetwork(addr)) + if err != nil { + t.Fatalf("DialUDP %s: %v", addr, err) + } + defer udpConn.Close() + + ctrl, _ := net.Pipe() + relayConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + if err != nil { + t.Fatalf("ListenUDP: %v", err) + } + cur.udpRelay.sessions[netip.AddrPortFrom(addr, 1234)] = &socks5UDPSession{ctrl: ctrl, udpConn: relayConn} + } + dialCtx, cancelDial := context.WithCancel(context.Background()) + dialed := make(chan struct{}) + go func() { + defer close(dialed) + if conn, err := gonet.DialContextTCP(dialCtx, cur.dev.Stack, tunnelFullAddress(reused, 8080), tunnelNetwork(reused)); err == nil { + conn.Close() + } + }() + defer func() { cancelDial(); <-dialed }() + waitEndpointsTo(reused, 2) + waitEndpointsTo(kept, 1) + + next := inst + next.Peers = []amneziawg.Peer{ + {Email: "new@test", PublicKey: peerPubs[2], AllowedIPs: []string{reused.String() + "/32"}}, + inst.Peers[1], + } + if err := m.Ensure(Desired{Instance: next}); err != nil { + t.Fatalf("Ensure (address reassigned): %v", err) + } + if m.ifaces[inst.Id] != cur { + t.Fatal("peer change rebuilt the interface instead of reconfiguring it in place") + } + + waitEndpointsTo(reused, 0) + waitEndpointsTo(kept, 1) + cur.udpRelay.mu.Lock() + _, reusedSession := cur.udpRelay.sessions[netip.AddrPortFrom(reused, 1234)] + _, keptSession := cur.udpRelay.sessions[netip.AddrPortFrom(kept, 1234)] + cur.udpRelay.mu.Unlock() + if reusedSession { + t.Error("the removed peer's UDP relay session survived its address being reassigned") + } + if !keptSession { + t.Error("an unaffected peer's UDP relay session was torn down") + } +} + // TestEnsureHeaderProtectionKeyChangeReconfiguresInPlace is a regression // test for the Phase 3.7 AWG 3.0 wiring: proves that populating // Desired.Options with a real HeaderProtectionKey/ContentPaddingAddition diff --git a/internal/amneziawgnet/netstack.go b/internal/amneziawgnet/netstack.go index a9a08c41f..b05467f53 100644 --- a/internal/amneziawgnet/netstack.go +++ b/internal/amneziawgnet/netstack.go @@ -226,6 +226,20 @@ func enablePromiscuousRouting(gstack *stack.Stack) { gstack.SetSpoofing(1, true) } +// endpointRemoteAddr returns the address ep is connected to; false for an +// endpoint that has none (listening, unconnected). +func endpointRemoteAddr(ep stack.TransportEndpoint) (netip.Addr, bool) { + tep, ok := ep.(tcpip.Endpoint) + if !ok { + return netip.Addr{}, false + } + info, ok := tep.Info().(*stack.TransportEndpointInfo) + if !ok || info.ID.RemoteAddress.Len() == 0 { + return netip.Addr{}, false + } + return addrFromTcpip(info.ID.RemoteAddress), true +} + // addrFromTcpip converts a gVisor tcpip.Address to netip.Addr. func addrFromTcpip(a tcpip.Address) netip.Addr { if a.Len() == 4 { diff --git a/internal/amneziawgnet/portfwd.go b/internal/amneziawgnet/portfwd.go index 80cbef491..0a7aa5a4d 100644 --- a/internal/amneziawgnet/portfwd.go +++ b/internal/amneziawgnet/portfwd.go @@ -305,10 +305,11 @@ func tunnelFullAddress(addr netip.Addr, port int) tcpip.FullAddress { } // tcpForwardListener is one open host-facing TCP listener for a single -// portForwardKey. +// portForwardKey; ctx ends with it and takes every relay it started along. type tcpForwardListener struct { - ln net.Listener - closing chan struct{} + ln net.Listener + ctx context.Context + cancel context.CancelFunc } // listenPortForwardTCP opens a host-facing TCP listener on key.port and @@ -324,7 +325,8 @@ func listenPortForwardTCP(gstack *stack.Stack, inboundID int, key portForwardKey logger.Warningf("amneziawgnet: port-forward: inbound %d peer %q: listen tcp :%d: %v", inboundID, key.email, key.port, err) return nil } - l := &tcpForwardListener{ln: ln, closing: make(chan struct{})} + l := &tcpForwardListener{ln: ln} + l.ctx, l.cancel = context.WithCancel(context.Background()) logger.Infof("amneziawgnet: port-forward: inbound %d peer %q: listening tcp :%d", inboundID, key.email, key.port) go l.acceptLoop(gstack, inboundID, key, target) return l @@ -335,29 +337,29 @@ func (l *tcpForwardListener) acceptLoop(gstack *stack.Stack, inboundID int, key conn, err := l.ln.Accept() if err != nil { select { - case <-l.closing: + case <-l.ctx.Done(): return // intentional shutdown, not a real accept error default: } logger.Warningf("amneziawgnet: port-forward: inbound %d peer %q: accept tcp :%d: %v", inboundID, key.email, key.port, err) // Keep accepting: returning would strand a bound socket nobody // serves, under a key Reconcile still sees as open and never reopens. - if !waitAcceptRetry(l.closing) { + if !waitAcceptRetry(l.ctx.Done()) { return } continue } - go relayTCPForward(gstack, conn, inboundID, key, target) + go l.relayTCPForward(gstack, conn, inboundID, key, target) } } -func relayTCPForward(gstack *stack.Stack, conn net.Conn, inboundID int, key portForwardKey, target portForwardTargetFunc) { +func (l *tcpForwardListener) relayTCPForward(gstack *stack.Stack, conn net.Conn, inboundID int, key portForwardKey, target portForwardTargetFunc) { defer conn.Close() addr, ok := target(key.email) if !ok { return } - ctx, cancel := context.WithTimeout(context.Background(), portForwardDialTimeout) + ctx, cancel := context.WithTimeout(l.ctx, portForwardDialTimeout) defer cancel() tunnelConn, err := gonet.DialContextTCP(ctx, gstack, tunnelFullAddress(addr, key.port), tunnelNetwork(addr)) if err != nil { @@ -366,14 +368,17 @@ func relayTCPForward(gstack *stack.Stack, conn net.Conn, inboundID int, key port } defer tunnelConn.Close() + stop := context.AfterFunc(l.ctx, func() { + conn.Close() + tunnelConn.Close() + }) + defer stop() pipeBothWays(conn, tunnelConn) } -// Close stops accepting new connections. Already-relaying connections are -// left to finish on their own -- there's no shared state to tear down early -// for, and an abrupt cut would just look like a network error to whichever -// external client was mid-transfer. +// Close stops accepting and cuts the connections already relaying, as +// udpForwardListener.Close does: a withdrawn forward must not stay usable. func (l *tcpForwardListener) Close() { - close(l.closing) + l.cancel() l.ln.Close() } diff --git a/internal/amneziawgnet/portfwd_test.go b/internal/amneziawgnet/portfwd_test.go index d33405e46..43eeef3e1 100644 --- a/internal/amneziawgnet/portfwd_test.go +++ b/internal/amneziawgnet/portfwd_test.go @@ -244,7 +244,8 @@ func TestTCPForwardAcceptLoopSurvivesTransientError(t *testing.T) { if err != nil { t.Fatalf("listen: %v", err) } - l := &tcpForwardListener{ln: &failFirstAccept{Listener: host}, closing: make(chan struct{})} + l := &tcpForwardListener{ln: &failFirstAccept{Listener: host}} + l.ctx, l.cancel = context.WithCancel(context.Background()) defer l.Close() noTarget := func(string) (netip.Addr, bool) { return netip.Addr{}, false } go l.acceptLoop(newTestStack(t, "10.211.4.1"), 505, portForwardKey{email: "a@x", proto: tcpForward}, noTarget) @@ -463,4 +464,16 @@ primed: if string(gotUDP) != wantUDP { t.Errorf("UDP round trip = %q, want %q", gotUDP, wantUDP) } + + // Withdrawing the forward must end the connection already relaying over + // it, not just refuse new ones. + withdrawn := inst + withdrawn.Peers = []amneziawg.Peer{ + {Email: "client@test", PublicKey: clientPub, AllowedIPs: []string{clientAddr + "/32"}}, + } + set.Reconcile(withdrawn) + tcpConn.SetReadDeadline(time.Now().Add(5 * time.Second)) + if _, err := tcpConn.Read(make([]byte, 1)); !errors.Is(err, io.EOF) { + t.Errorf("read on the relayed connection after its forward was withdrawn = %v, want io.EOF", err) + } } diff --git a/internal/amneziawgnet/relay.go b/internal/amneziawgnet/relay.go index be8cb915b..45b110c8b 100644 --- a/internal/amneziawgnet/relay.go +++ b/internal/amneziawgnet/relay.go @@ -455,6 +455,19 @@ func (u *UDPRelay) pump(src netip.AddrPort, sess *socks5UDPSession) { } } +// CloseSessions tears down the sessions whose tunnel-internal source address +// matches; pump retires each one's goroutine on the resulting read error. +func (u *UDPRelay) CloseSessions(match func(netip.Addr) bool) { + u.mu.Lock() + defer u.mu.Unlock() + for k, s := range u.sessions { + if match(k.Addr()) { + s.Close() + delete(u.sessions, k) + } + } +} + // Close tears down every open session. Call when the owning Device is // closed. func (u *UDPRelay) Close() { From f988e9677f6ed3d6ca95bcd1c4bdb42620551c05 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 31 Oct 2026 15:18:43 +0100 Subject: [PATCH 045/125] fix(amneziawg): cap port-forward relays and UDP sessions A forwarded port accepts unauthenticated traffic, and every TCP connection and every new UDP source got its own relay with a goroutine and a buffer, with no limit, so anyone reaching the port could exhaust the panel's memory. Live TCP relays and UDP sessions are now capped at 2048 each across all forwards; past that a connection is closed and a datagram from a new source is dropped, while open flows keep going. --- internal/amneziawgnet/portfwd.go | 31 ++++++- internal/amneziawgnet/portfwd_test.go | 113 ++++++++++++++++++++++++++ internal/amneziawgnet/portfwd_udp.go | 6 ++ 3 files changed, 149 insertions(+), 1 deletion(-) diff --git a/internal/amneziawgnet/portfwd.go b/internal/amneziawgnet/portfwd.go index 0a7aa5a4d..9aa03dfd4 100644 --- a/internal/amneziawgnet/portfwd.go +++ b/internal/amneziawgnet/portfwd.go @@ -270,6 +270,28 @@ func openPortForwardListener(gstack *stack.Stack, inboundID int, key portForward const portForwardDialTimeout = 10 * time.Second +// portForwardMaxFlows caps live TCP relays, and separately UDP sessions, over every +// listener: senders are unauthenticated, and each flow pins a goroutine and a buffer. +const portForwardMaxFlows = 2048 + +// Process-wide on purpose: a per-listener cap still multiplies by the forwarded ports +// and by the number of forwarding peers. +var ( + portForwardTCPSlots = make(chan struct{}, portForwardMaxFlows) + portForwardUDPSlots = make(chan struct{}, portForwardMaxFlows) +) + +// acquirePortForwardSlot never blocks the accept or read loop; false means the flow is +// refused. The owner gives the slot back by receiving from slots. +func acquirePortForwardSlot(slots chan struct{}) bool { + select { + case slots <- struct{}{}: + return true + default: + return false + } +} + // acceptRetryDelay paces an accept loop's retries, so an error that lasts // (EMFILE, until descriptors free up) neither spins it nor floods the log. const acceptRetryDelay = 100 * time.Millisecond @@ -349,7 +371,14 @@ func (l *tcpForwardListener) acceptLoop(gstack *stack.Stack, inboundID int, key } continue } - go l.relayTCPForward(gstack, conn, inboundID, key, target) + if !acquirePortForwardSlot(portForwardTCPSlots) { + conn.Close() + continue + } + go func() { + defer func() { <-portForwardTCPSlots }() + l.relayTCPForward(gstack, conn, inboundID, key, target) + }() } } diff --git a/internal/amneziawgnet/portfwd_test.go b/internal/amneziawgnet/portfwd_test.go index 43eeef3e1..9a55c7f29 100644 --- a/internal/amneziawgnet/portfwd_test.go +++ b/internal/amneziawgnet/portfwd_test.go @@ -477,3 +477,116 @@ primed: t.Errorf("read on the relayed connection after its forward was withdrawn = %v, want io.EOF", err) } } + +// leavePortForwardSlots fills slots until only free remain, so a test hits the +// ceiling with a handful of flows; returns how many it holds (cleanup releases). +func leavePortForwardSlots(t *testing.T, slots chan struct{}, free int) int { + t.Helper() + held := 0 + for acquirePortForwardSlot(slots) { + held++ + } + for range free { + <-slots + held-- + } + t.Cleanup(func() { + for range held { + <-slots + } + }) + return held +} + +func waitPortForwardSlots(t *testing.T, slots chan struct{}, want int) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for len(slots) > want { + if time.Now().After(deadline) { + t.Fatalf("slots still taken = %d, want at most %d", len(slots), want) + } + time.Sleep(10 * time.Millisecond) + } +} + +func TestPortForwardUDPSessionsAreCapped(t *testing.T) { + const port = 58913 + const free, sources = 3, 10 + held := leavePortForwardSlots(t, portForwardUDPSlots, free) + + gs := newTestStack(t, "10.211.2.1") + l := listenPortForwardUDP(gs, 503, portForwardKey{email: "a@x", port: port, proto: udpForward}, func(string) (netip.Addr, bool) { + return netip.MustParseAddr("10.211.2.2"), true + }) + if l == nil { + t.Fatal("listenPortForwardUDP returned nil") + } + defer l.Close() + + for range sources { + conn, err := net.Dial("udp", fmt.Sprintf("127.0.0.1:%d", port)) + if err != nil { + t.Fatalf("dial udp: %v", err) + } + defer conn.Close() + if _, err := conn.Write([]byte("x")); err != nil { + t.Fatalf("write udp: %v", err) + } + } + + // Long enough for readLoop to have drained every datagram sent above. + most := 0 + for settle := time.Now().Add(500 * time.Millisecond); time.Now().Before(settle); time.Sleep(10 * time.Millisecond) { + l.mu.Lock() + most = max(most, len(l.sessions)) + l.mu.Unlock() + } + if most < free || most > portForwardMaxFlows-held { + t.Fatalf("sessions opened by %d sources = %d, want the %d free slots", sources, most, free) + } + + // A slot that outlived its session would shrink the ceiling for good. + l.Close() + waitPortForwardSlots(t, portForwardUDPSlots, held) +} + +func TestPortForwardTCPConnectionsAreCapped(t *testing.T) { + const port = 58914 + const free, clients = 3, 10 + held := leavePortForwardSlots(t, portForwardTCPSlots, free) + + // Parks every relayed connection at its first step, holding its slot. + release := make(chan struct{}) + gs := newTestStack(t, "10.211.3.1") + l := listenPortForwardTCP(gs, 504, portForwardKey{email: "a@x", port: port, proto: tcpForward}, func(string) (netip.Addr, bool) { + <-release + return netip.Addr{}, false + }) + if l == nil { + t.Fatal("listenPortForwardTCP returned nil") + } + defer l.Close() + + relayed := 0 + for range clients { + conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), time.Second) + if err != nil { + t.Fatalf("dial tcp: %v", err) + } + defer conn.Close() + // A refused connection is closed at once; a relayed one stays silent. + _ = conn.SetReadDeadline(time.Now().Add(200 * time.Millisecond)) + if _, err := conn.Read(make([]byte, 1)); err != nil { + var nerr net.Error + if errors.As(err, &nerr) && nerr.Timeout() { + relayed++ + } + } + } + if relayed < free || relayed > portForwardMaxFlows-held { + t.Fatalf("connections relayed out of %d = %d, want the %d free slots", clients, relayed, free) + } + + close(release) + waitPortForwardSlots(t, portForwardTCPSlots, held) +} diff --git a/internal/amneziawgnet/portfwd_udp.go b/internal/amneziawgnet/portfwd_udp.go index 1c315bcb5..0e8d4a11b 100644 --- a/internal/amneziawgnet/portfwd_udp.go +++ b/internal/amneziawgnet/portfwd_udp.go @@ -78,9 +78,14 @@ func (l *udpForwardListener) readLoop(gstack *stack.Stack, inboundID int, key po if !ok { continue } + // Dropped silently: a flood of fresh sources is when a per-datagram log line hurts. + if !acquirePortForwardSlot(portForwardUDPSlots) { + continue + } raddr := tunnelFullAddress(addr, key.port) conn, err := gonet.DialUDP(gstack, nil, &raddr, tunnelNetwork(addr)) if err != nil { + <-portForwardUDPSlots logger.Warningf("amneziawgnet: port-forward: inbound %d peer %q: dial tunnel %s:%d: %v", inboundID, key.email, addr, key.port, err) continue } @@ -109,6 +114,7 @@ func (l *udpForwardListener) pump(src netip.AddrPort, sess *udpForwardSession) { delete(l.sessions, src) l.mu.Unlock() sess.conn.Close() + <-portForwardUDPSlots }() buf := make([]byte, 65536) for { From e1950b757583605fbe21fb193f3521763474bd87 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 31 Oct 2026 15:20:50 +0100 Subject: [PATCH 046/125] fix(amneziawg): bound the egress relay's UDP sessions as a whole Every UDP destination a proxy user reached through the AmneziaWG egress opened a session with a goroutine, a port and a buffer, per association, and a Mux frame opens a new association, so one user could hold sessions without limit. All egress UDP sessions now share one budget of 4096; when it is spent the longest-idle session is closed to make room. --- internal/amneziawgnet/egress.go | 50 +++++++++++++ internal/amneziawgnet/egress_port_test.go | 85 +++++++++++++++++++++++ 2 files changed, 135 insertions(+) diff --git a/internal/amneziawgnet/egress.go b/internal/amneziawgnet/egress.go index 1f2d7e793..ab7816696 100644 --- a/internal/amneziawgnet/egress.go +++ b/internal/amneziawgnet/egress.go @@ -11,6 +11,7 @@ import ( "net" "net/netip" "sync" + "sync/atomic" "time" "gvisor.dev/gvisor/pkg/tcpip" @@ -482,6 +483,50 @@ type udpControl struct{ conn net.Conn } type egressUDPSession struct { dst netip.AddrPort conn *gonet.UDPConn + + // Unix nanoseconds of the last datagram either way; the budget evicts by it. + lastActive atomic.Int64 +} + +func (s *egressUDPSession) touch() { s.lastActive.Store(time.Now().UnixNano()) } + +// egressMaxUDPSessions bounds the sessions of all associations together: each +// pins a goroutine, a port and 64 KiB, and a Mux frame opens an association. +var egressMaxUDPSessions = 4096 + +// egressUDPBudget is process-wide, like the egress server: the socks bridge +// carries no user identity, so every user draws on the same budget. +type egressUDPBudget struct { + mu sync.Mutex + live map[*egressUDPSession]struct{} +} + +var egressUDPSessions = egressUDPBudget{live: map[*egressUDPSession]struct{}{}} + +// admit counts sess in. A spent budget closes the longest-idle session rather +// than refusing sess, so a flood's unanswered flows go before live ones. +func (b *egressUDPBudget) admit(sess *egressUDPSession) { + var idlest *egressUDPSession + b.mu.Lock() + if len(b.live) >= egressMaxUDPSessions { + for s := range b.live { + if idlest == nil || s.lastActive.Load() < idlest.lastActive.Load() { + idlest = s + } + } + delete(b.live, idlest) + } + b.live[sess] = struct{}{} + b.mu.Unlock() + if idlest != nil { + idlest.conn.Close() + } +} + +func (b *egressUDPBudget) release(sess *egressUDPSession) { + b.mu.Lock() + delete(b.live, sess) + b.mu.Unlock() } // relayUDP answers the associate request and relays datagrams to @@ -604,6 +649,8 @@ func (s *udpEgressSessions) getOrDial(dev *Device, tag string, udpConn *net.UDPC return nil } sess := &egressUDPSession{dst: dst, conn: conn} + sess.touch() // before admit: a zero stamp is the next one evicted + egressUDPSessions.admit(sess) s.m[dst] = sess go pumpUDPEgress(udpConn, client, sess, s) return sess @@ -627,6 +674,7 @@ func (s *socks5EgressServer) deliverUDPDatagram(dev *Device, tag string, udpConn if sess == nil { return } + sess.touch() if _, werr := sess.conn.Write(payload); werr != nil { logger.Warningf("amneziawgnet: egress %q: send udp to %s: %v", tag, dst, werr) } @@ -639,6 +687,7 @@ func pumpUDPEgress(udpConn *net.UDPConn, client netip.AddrPort, sess *egressUDPS sessions.mu.Lock() delete(sessions.m, sess.dst) sessions.mu.Unlock() + egressUDPSessions.release(sess) sess.conn.Close() }() buf := make([]byte, 65536) @@ -649,6 +698,7 @@ func pumpUDPEgress(udpConn *net.UDPConn, client netip.AddrPort, sess *egressUDPS if err != nil { return } + sess.touch() hdr := make([]byte, 0, 3+1+16+2+n) hdr = append(hdr, 0x00, 0x00, 0x00) // RSV RSV FRAG(=0) if sess.dst.Addr().Is4() { diff --git a/internal/amneziawgnet/egress_port_test.go b/internal/amneziawgnet/egress_port_test.go index b9dfa6cb7..f2e36c666 100644 --- a/internal/amneziawgnet/egress_port_test.go +++ b/internal/amneziawgnet/egress_port_test.go @@ -5,8 +5,10 @@ import ( "encoding/json" "errors" "net" + "net/netip" "strconv" "testing" + "time" ) // holdEgressBasePort occupies EgressBasePort the way another service would; a @@ -112,3 +114,86 @@ func TestEgressFallbackStaysBelowThePanelRanges(t *testing.T) { t.Fatalf("Port() = %d, inside the panel's reserved range", port) } } + +// TestEgressUDPSessionsShareOneBudget: a proxy user opens an association per +// Mux frame, so the session ceiling must hold across all of them together. +func TestEgressUDPSessionsShareOneBudget(t *testing.T) { + saved := egressMaxUDPSessions + egressMaxUDPSessions = 64 + t.Cleanup(func() { egressMaxUDPSessions = saved }) + + local := netip.MustParseAddr("10.211.4.1") + dev := &Device{Stack: newTestStack(t, local.String()), localAddrs: []netip.Addr{local}} + udpConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + if err != nil { + t.Fatalf("listen udp: %v", err) + } + defer udpConn.Close() + client := netip.MustParseAddrPort("127.0.0.1:9") + dst := func(port int) netip.AddrPort { + return netip.AddrPortFrom(netip.MustParseAddr("192.0.2.1"), uint16(port)) + } + var associations []*udpEgressSessions + associate := func() *udpEgressSessions { + sessions := &udpEgressSessions{m: map[netip.AddrPort]*egressUDPSession{}} + associations = append(associations, sessions) + return sessions + } + session := func(sessions *udpEgressSessions, port int) *egressUDPSession { + sessions.mu.Lock() + defer sessions.mu.Unlock() + return sessions.m[dst(port)] + } + liveSessions := func() int { + n := 0 + for _, sessions := range associations { + sessions.mu.Lock() + n += len(sessions.m) + sessions.mu.Unlock() + } + return n + } + slotsTaken := func() int { + egressUDPSessions.mu.Lock() + defer egressUDPSessions.mu.Unlock() + return len(egressUDPSessions.live) + } + waitFor := func(what string, cond func() bool) { + t.Helper() + for deadline := time.Now().Add(5 * time.Second); !cond(); time.Sleep(time.Millisecond) { + if time.Now().After(deadline) { + t.Fatalf("timed out waiting for %s (%d live sessions)", what, liveSessions()) + } + } + } + + before := slotsTaken() + others := associate() + busy := others.getOrDial(dev, "t", udpConn, client, dst(1)) + if busy == nil || others.getOrDial(dev, "t", udpConn, client, dst(2)) == nil { + t.Fatal("no session with the budget unspent") + } + // Three times the budget, spread over associations that stay small. + for range 3 * egressMaxUDPSessions / 16 { + // The oldest session of all, but never the longest idle one. + GetEgressServer().deliverUDPDatagram(dev, "t", udpConn, client, others, dst(1), []byte("x")) + flood := associate() + for port := 1; port <= 16; port++ { + if flood.getOrDial(dev, "t", udpConn, client, dst(port)) == nil { + t.Fatalf("destination %d refused: the budget must evict, not drop", port) + } + } + } + waitFor("evicted sessions to retire", func() bool { return liveSessions() <= egressMaxUDPSessions }) + if session(others, 2) != nil { + t.Error("the longest-idle session outlived a spent budget") + } + if session(others, 1) != busy { + t.Error("a session that kept relaying was evicted ahead of idle ones") + } + + for _, sessions := range associations { + sessions.closeAll() + } + waitFor("closeAll to hand every session back", func() bool { return liveSessions() == 0 && slotsTaken() <= before }) +} From f66e369e41dfa58b76e79ca17e0d58c7cc4d58e6 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 31 Oct 2026 23:30:02 +0100 Subject: [PATCH 047/125] fix(node): keep pushed inbounds off the node host's own resources A node-sync token, or a master enrolled with the mTLS certificate, may push inbounds, and the node stored them as already validated. An inbound is effectively config for a core running as root, so a push could serve the host's files (hysteria file masquerade), relay to its own services or the core API (masquerade proxy, fallback dest, REALITY target), write files (masterKeyLog), use local or templated XDRIVE storage, fetch URLs (ECH, realm masks), or run tun/http/mixed inbounds. A push now refuses those fields unless the node already stores the same value, which keeps rows configured by the node's own admin syncing: relay targets must be public or another of this node's inbounds, and only node-eligible protocols are accepted. Every caller is refused env: references, which the core expands from the panel's environment, and the inbound listen address is JSON-encoded into the config instead of quoted by hand. --- internal/database/model/model.go | 4 +- internal/web/service/inbound.go | 13 ++ internal/web/service/inbound_push_guard.go | 232 +++++++++++++++++++++ internal/web/service/inbound_tls_test.go | 64 ++++++ 4 files changed, 312 insertions(+), 1 deletion(-) create mode 100644 internal/web/service/inbound_push_guard.go diff --git a/internal/database/model/model.go b/internal/database/model/model.go index ec84a53b7..2183bde85 100644 --- a/internal/database/model/model.go +++ b/internal/database/model/model.go @@ -356,7 +356,9 @@ func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig { if listen == "" { listen = "0.0.0.0" } - listen = fmt.Sprintf("\"%v\"", listen) + // Encoded, not quoted by hand: an escaped "env:" prefix must stay literal text. + encodedListen, _ := json.Marshal(listen) + listen = string(encodedListen) protocol := string(i.Protocol) settings := i.Settings switch i.Protocol { diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go index 24c67327f..746747285 100644 --- a/internal/web/service/inbound.go +++ b/internal/web/service/inbound.go @@ -1310,6 +1310,11 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo if err := validateInboundTLSCertificates(inbound.StreamSettings); err != nil { return inbound, false, err } + } else if err := validatePushedInbound(inbound, nil); err != nil { + return inbound, false, err + } + if err := validateInboundEnvRefs(inbound); err != nil { + return inbound, false, err } if err := validateInboundBuildInput(inbound); err != nil { return inbound, false, err @@ -2034,6 +2039,14 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, if err := validateInboundBuildInput(inbound); err != nil { return inbound, false, err } + if err := validateInboundEnvRefs(inbound); err != nil { + return inbound, false, err + } + if s.FromNodeSync { + if err := validatePushedInbound(inbound, oldInbound); err != nil { + return inbound, false, err + } + } // Grandfather a row that was already stored incomplete so it stays editable; // only a save that breaks a previously valid TLS block is refused. if !s.FromNodeSync { diff --git a/internal/web/service/inbound_push_guard.go b/internal/web/service/inbound_push_guard.go new file mode 100644 index 000000000..2e6f898f7 --- /dev/null +++ b/internal/web/service/inbound_push_guard.go @@ -0,0 +1,232 @@ +package service + +import ( + "encoding/json" + "fmt" + "net" + "net/netip" + "slices" + "strconv" + "strings" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/util/common" +) + +// jsonLeaf is one scalar of a JSON document with the lowercased key path leading to it. +// Repeated keys each yield a leaf: xray-core binds keys case-folded, and the last wins. +type jsonLeaf struct { + path []string + value string +} + +func (l jsonLeaf) endsWith(suffix ...string) bool { + return len(l.path) >= len(suffix) && slices.Equal(l.path[len(l.path)-len(suffix):], suffix) +} + +// jsonLeaves walks raw token by token, so no repeated key hides behind another. +func jsonLeaves(raw string) []jsonLeaf { + dec := json.NewDecoder(strings.NewReader(raw)) + dec.UseNumber() + var leaves []jsonLeaf + var walk func(path []string) bool + walk = func(path []string) bool { + tok, err := dec.Token() + if err != nil { + return false + } + switch tok { + case json.Delim('{'): + for dec.More() { + key, err := dec.Token() + if err != nil { + return false + } + name, _ := key.(string) + if !walk(append(slices.Clone(path), strings.ToLower(name))) { + return false + } + } + _, err = dec.Token() + return err == nil + case json.Delim('['): + for dec.More() { + if !walk(append(slices.Clone(path), "[]")) { + return false + } + } + _, err = dec.Token() + return err == nil + } + leaves = append(leaves, jsonLeaf{path: path, value: fmt.Sprint(tok)}) + return true + } + walk(nil) + return leaves +} + +// isEnvRef matches what xray-core expands from the panel's environment: a whole value, or +// one entry of a comma list such as a port list. +func isEnvRef(value string) bool { + for part := range strings.SplitSeq(value, ",") { + if strings.HasPrefix(strings.TrimSpace(part), "env:") { + return true + } + } + return false +} + +// validateInboundEnvRefs refuses "env:NAME" anywhere the core would expand it: the panel's +// environment (database DSN, token key) must not reach a config or a load error quoting it. +func validateInboundEnvRefs(inbound *model.Inbound) error { + found := isEnvRef(inbound.Listen) + for _, raw := range []string{inbound.Settings, inbound.StreamSettings, inbound.Sniffing} { + for _, leaf := range jsonLeaves(raw) { + found = found || isEnvRef(leaf.value) + } + } + if found { + return common.NewError(`an inbound must not refer to environment variables ("env:")`) + } + return nil +} + +// pushReach is one field of a pushed inbound that names a resource on this host. +type pushReach struct { + field, value string +} + +// validatePushedInbound refuses, on a master's push, the fields that reach this host's own +// files, sockets and services; a value the stored row already carries was set here and stays. +func validatePushedInbound(inbound, stored *model.Inbound) error { + if (stored == nil || stored.Protocol != inbound.Protocol) && !nodeEligibleProtocols[inbound.Protocol] { + return common.NewErrorf("%s inbounds cannot be pushed to a node", inbound.Protocol) + } + reaches, err := hostReaches(inbound) + if err != nil { + return err + } + var allowed []pushReach + if stored != nil { + if allowed, err = hostReaches(stored); err != nil { + return err + } + } + for _, r := range reaches { + if !slices.Contains(allowed, r) { + return common.NewErrorf("a pushed inbound may not set %s to %q on this node", r.field, r.value) + } + } + return nil +} + +func hostReaches(inbound *model.Inbound) ([]pushReach, error) { + local, err := localRelayTargets(inbound.Id) + if err != nil { + return nil, err + } + var reaches []pushReach + add := func(field string, leaf jsonLeaf) { + reaches = append(reaches, pushReach{field: field, value: leaf.value}) + } + streamLeaves := jsonLeaves(inbound.StreamSettings) + // A whole object is one reach: a push keeping the type but moving the url or folder + // is a different reach than the one this node stores. + object := func(field string, container ...string) { + var parts []string + for _, l := range streamLeaves { + if i := slices.Index(l.path, container[len(container)-1]); i >= len(container)-1 && slices.Equal(l.path[i+1-len(container):i+1], container) { + parts = append(parts, strings.Join(l.path[i+1:], ".")+"="+l.value) + } + } + slices.Sort(parts) + reaches = append(reaches, pushReach{field: field, value: strings.Join(parts, ";")}) + } + for _, leaf := range streamLeaves { + lower := strings.ToLower(strings.TrimSpace(leaf.value)) + switch { + case leaf.endsWith("hysteriasettings", "masquerade", "type") && (lower == "file" || lower == "proxy"): + object("hysteria masquerade", "hysteriasettings", "masquerade") + case leaf.endsWith("masterkeylog") && lower != "" && lower != "none": + add("masterKeyLog", leaf) + case leaf.endsWith("xdrivesettings", "service") && (lower == "local" || lower == "template"): + object("xdrive service", "xdrivesettings") + case leaf.endsWith("echconfiglist") && strings.Contains(lower, "://"): + add("echConfigList", leaf) + case slices.Contains(leaf.path, "finalmask") && leaf.endsWith("type") && lower == "realm": + add("finalmask", leaf) + case leaf.endsWith("realitysettings", "target") || leaf.endsWith("realitysettings", "dest"): + if !isPublicRelayTarget(leaf.value) { + add("REALITY target", leaf) + } + } + } + for _, leaf := range jsonLeaves(inbound.Settings) { + switch { + case leaf.endsWith("fallbacks", "[]", "dest"): + if !isPublicRelayTarget(leaf.value) && !slices.Contains(local, normalizeRelayTarget(leaf.value)) { + add("fallback dest", leaf) + } + case leaf.endsWith("domainfronting", "ip"): + if ip, err := netip.ParseAddr(strings.TrimSpace(leaf.value)); err == nil && isBlockedIP(net.IP(ip.AsSlice())) { + add("domainFronting ip", leaf) + } + } + } + return reaches, nil +} + +// isPublicRelayTarget reports whether a relay target dials a public host, not this one: a +// bare port, a unix socket and a loopback or private host all stay on the machine. +func isPublicRelayTarget(target string) bool { + target = strings.TrimSpace(target) + if strings.HasPrefix(target, "/") || strings.HasPrefix(target, "@") { + return false + } + host, _, err := net.SplitHostPort(target) + if err != nil { + return false + } + return !namesInternalHost(host) +} + +// normalizeRelayTarget reduces a fallback dest to the form localRelayTargets lists. +func normalizeRelayTarget(target string) string { + target = strings.TrimSpace(target) + if strings.HasPrefix(target, "/") || strings.HasPrefix(target, "@") { + return target + } + if _, err := strconv.Atoi(target); err == nil { + return target + } + if _, port, err := net.SplitHostPort(target); err == nil { + return port + } + return target +} + +// localRelayTargets lists what this node's other inbounds listen on, the only local +// fallback dests a master generates; the core's own listeners are never among them. +func localRelayTargets(selfID int) ([]string, error) { + var rows []model.Inbound + if err := database.GetDB().Select("id", "listen", "port").Where("node_id IS NULL AND id <> ?", selfID).Find(&rows).Error; err != nil { + return nil, err + } + reserved := map[string]bool{strconv.Itoa(reservedAPIPort()): true} + for _, port := range xrayOwnPorts() { + reserved[port] = true + } + var targets []string + for _, ib := range rows { + port := strconv.Itoa(ib.Port) + if reserved[port] { + continue + } + targets = append(targets, port) + if listen := strings.TrimSpace(ib.Listen); strings.HasPrefix(listen, "/") || strings.HasPrefix(listen, "@") { + targets = append(targets, strings.SplitN(listen, ",", 2)[0]) + } + } + return targets, nil +} diff --git a/internal/web/service/inbound_tls_test.go b/internal/web/service/inbound_tls_test.go index 8c1a5f947..978a73d34 100644 --- a/internal/web/service/inbound_tls_test.go +++ b/internal/web/service/inbound_tls_test.go @@ -215,3 +215,67 @@ func TestAddInboundRefusesACertificatePathThatIsNoRegularFile(t *testing.T) { t.Fatal("refused push reached the runtime") } } + +// A node-sync token or the master's certificate pushes inbounds into this node's core, +// which runs as root: a push must not reach this host's files, sockets or own services. +func TestAddInboundRefusesPushedFieldsThatReachThisHost(t *testing.T) { + setupConflictDB(t) + mgr := useTestRuntimeManager(t) + mgr.SetLocalRuntimeOverride(&fakeNodeRuntime{}) + push := &InboundService{FromNodeSync: true} + if _, _, err := push.AddInbound(&model.Inbound{Tag: "web", Enable: true, Port: 44400, Protocol: model.VLESS, Settings: `{"clients":[]}`}); err != nil { + t.Fatalf("seed a local inbound: %v", err) + } + vless := func(settings, stream string) *model.Inbound { + return &model.Inbound{Enable: true, Port: 44401, Protocol: model.VLESS, Settings: settings, StreamSettings: stream} + } + for name, ib := range map[string]*model.Inbound{ + "file masquerade behind a repeated key": { + Enable: true, Port: 44401, Protocol: model.Hysteria, Settings: `{"clients":[]}`, + StreamSettings: `{"network":"hysteria","HysteriaSettings":{"masquerade":{"Type":"FILE","dir":"/"}},"hysteriaSettings":{"masquerade":{"type":"404"}}}`, + }, + "masterKeyLog": vless(`{"clients":[]}`, `{"network":"tcp","security":"tls","tlsSettings":{"masterKeyLog":"/root/.ssh/authorized_keys"}}`), + "tun inbound": {Enable: true, Port: 44401, Protocol: model.Protocol("tun"), Settings: `{}`}, + "fallback to the core API": vless(`{"clients":[],"fallbacks":[{"dest":62789}]}`, `{"network":"tcp"}`), + "REALITY target on loopback": vless(`{"clients":[]}`, `{"network":"tcp","security":"reality","realitySettings":{"target":"127.0.0.1:2053"}}`), + "local xdrive storage": vless(`{"clients":[]}`, `{"network":"xdrive","xdriveSettings":{"service":"local","remoteFolder":"/etc/x-ui"}}`), + } { + if _, _, err := push.AddInbound(ib); err == nil { + t.Errorf("%s: pushed inbound accepted", name) + } + } + for name, ib := range map[string]*model.Inbound{ + "fallback to another inbound here": vless(`{"clients":[],"fallbacks":[{"dest":44400}]}`, `{"network":"tcp"}`), + "public REALITY target": {Enable: true, Port: 44402, Protocol: model.VLESS, Settings: `{"clients":[]}`, StreamSettings: `{"network":"tcp","security":"reality","realitySettings":{"target":"www.example.com:443"}}`}, + } { + if _, _, err := push.AddInbound(ib); err != nil && strings.Contains(err.Error(), "pushed") { + t.Errorf("%s: refused: %v", name, err) + } + } + if _, _, err := (&InboundService{}).AddInbound(&model.Inbound{Enable: true, Listen: "env:XUI_DB_DSN", Port: 44403, Protocol: model.VLESS, Settings: `{"clients":[]}`}); err == nil || !strings.Contains(err.Error(), "env:") { + t.Fatalf("env: listen = %v, want refused for every caller", err) + } +} + +// What this node's own admin configured stays pushable: the master echoes the stored row +// back, and refusing it would stop that inbound syncing at all. +func TestUpdateInboundKeepsAHostReachTheNodeAlreadyStores(t *testing.T) { + setupConflictDB(t) + mgr := useTestRuntimeManager(t) + mgr.SetLocalRuntimeOverride(&fakeNodeRuntime{}) + stream := `{"network":"hysteria","hysteriaSettings":{"masquerade":{"type":"proxy","url":"https://news.example.com"}}}` + stored, _, err := (&InboundService{}).AddInbound(&model.Inbound{Tag: "hy", Enable: true, Port: 44410, Protocol: model.Hysteria, Settings: `{"clients":[]}`, StreamSettings: stream}) + if err != nil { + t.Fatalf("admin add: %v", err) + } + push := &InboundService{FromNodeSync: true} + echo := *stored + if _, _, err := push.UpdateInbound(&echo); err != nil { + t.Fatalf("pushing back the stored row: %v", err) + } + changed := *stored + changed.StreamSettings = `{"network":"hysteria","hysteriaSettings":{"masquerade":{"type":"proxy","url":"http://127.0.0.1:2053"}}}` + if _, _, err := push.UpdateInbound(&changed); err == nil { + t.Fatal("a push moved the masquerade proxy to the panel's own port") + } +} From 79078439417c1fb431a0a4142a58e9016854547f Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 00:02:12 +0100 Subject: [PATCH 048/125] fix(node): let a node change only the clients it alone hosts A node snapshot was merged into the master's client records by email, and the only ownership check filtered one derived copy of it: the node's settings were still stored and re-synced later, its stats still merged, and a record linked to the master as well as the node took every value the node reported, including credentials rendered into the master's own Xray config. Each snapshot now passes one gate before anything is written. A record also linked to the master or another node keeps the master's values apart from per-link fields and the lifecycle the merge already settles; a record linked only elsewhere, a detached record, a padded or case-variant email and a new client holding another client's subId are dropped from the stored settings as well. A soft-orphaned record can be taken back only by the node whose merge orphaned it, which the new clients.sync_orphaned_by column records. A node tunnel no longer owns the peer keys of a client a master-local tunnel also serves. --- internal/database/db.go | 5 +- internal/database/model/model.go | 2 + .../service/client_group_node_sync_test.go | 14 +- internal/web/service/client_link.go | 19 +- internal/web/service/client_locks.go | 1 + internal/web/service/client_sync_orphan.go | 6 +- internal/web/service/inbound_node.go | 112 +++---- internal/web/service/inbound_node_claims.go | 275 +++++++++++++++++ .../service/node_client_expiry_sync_test.go | 2 +- .../service/node_client_traffic_sum_test.go | 5 +- .../web/service/node_foreign_client_test.go | 278 ++++++++++++++++++ 11 files changed, 623 insertions(+), 96 deletions(-) create mode 100644 internal/web/service/inbound_node_claims.go diff --git a/internal/database/db.go b/internal/database/db.go index f051e44cd..a8a570ab9 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -405,7 +405,10 @@ func migrateSyncOrphanColumns() error { if !db.Migrator().HasColumn(&model.ClientRecord{}, "sync_orphaned_at") { return nil } - return db.Exec("UPDATE clients SET sync_orphaned_at = 0 WHERE sync_orphaned_at IS NULL").Error + if err := db.Exec("UPDATE clients SET sync_orphaned_at = 0 WHERE sync_orphaned_at IS NULL").Error; err != nil { + return err + } + return db.Exec("UPDATE clients SET sync_orphaned_by = 0 WHERE sync_orphaned_by IS NULL").Error } // The client identity checks match emails case-insensitively; without an diff --git a/internal/database/model/model.go b/internal/database/model/model.go index 2183bde85..284d6d08a 100644 --- a/internal/database/model/model.go +++ b/internal/database/model/model.go @@ -1018,6 +1018,8 @@ type ClientRecord struct { // Owned solely by the node-snapshot sweep, which soft-orphans instead of // deleting; orphans from any other cause stay at zero and are never reaped. SyncOrphanedAt int64 `json:"-" gorm:"column:sync_orphaned_at;default:0"` + // The node whose merge orphaned the record: only that node may take it back. + SyncOrphanedBy int `json:"-" gorm:"column:sync_orphaned_by;default:0"` } func (ClientRecord) TableName() string { return "clients" } diff --git a/internal/web/service/client_group_node_sync_test.go b/internal/web/service/client_group_node_sync_test.go index 28fe18f3e..942d64cad 100644 --- a/internal/web/service/client_group_node_sync_test.go +++ b/internal/web/service/client_group_node_sync_test.go @@ -38,14 +38,12 @@ func TestSetRemoteTraffic_PreservesPanelLocalGroupAndComment(t *testing.T) { t.Fatalf("create node inbound: %v", err) } - if err := db.Create(&model.ClientRecord{ - Email: email, - UUID: uid, - Enable: true, - Group: wantGroup, - Comment: wantComment, - }).Error; err != nil { - t.Fatalf("create client record: %v", err) + centralClients, err := (&InboundService{}).GetClients(central) + if err != nil { + t.Fatalf("parse central clients: %v", err) + } + if err := (&ClientService{}).SyncInbound(db, central.Id, centralClients); err != nil { + t.Fatalf("link client record: %v", err) } snap := &runtime.TrafficSnapshot{ diff --git a/internal/web/service/client_link.go b/internal/web/service/client_link.go index 3a744dd7b..912408bb2 100644 --- a/internal/web/service/client_link.go +++ b/internal/web/service/client_link.go @@ -113,7 +113,7 @@ func (s *ClientService) syncInboundClients(tx *gorm.DB, inboundId int, clients [ } var inbound model.Inbound - if err := tx.Select("protocol").Where("id = ?", inboundId).Take(&inbound).Error; err != nil { + if err := tx.Select("protocol", "node_id").Where("id = ?", inboundId).Take(&inbound).Error; err != nil { return err } if err := s.validateProtocolClients(tx, inbound.Protocol, inboundId, clients, detachEmails, prune); err != nil { @@ -156,7 +156,7 @@ func (s *ClientService) syncInboundClients(tx *gorm.DB, inboundId int, clients [ ids = append(ids, row.Id) } var err error - if onLowerTunnel, err = clientsOnLowerTunnel(tx, inboundId, ids); err != nil { + if onLowerTunnel, err = clientsOnLowerTunnel(tx, inboundId, inbound.NodeID, ids); err != nil { return err } } @@ -408,16 +408,21 @@ func keepLinkOwnedFields(row, before *model.ClientRecord, ownsTunnelPeer bool) { row.KeepAlive = before.KeepAlive } -// clientsOnLowerTunnel returns the clients also attached to a WireGuard/AmneziaWG -// inbound with a lower id than inboundId: that tunnel owns their record's peer fields. -func clientsOnLowerTunnel(tx *gorm.DB, inboundId int, clientIds []int) (map[int]struct{}, error) { +// clientsOnLowerTunnel returns the clients also attached to a higher-ranked WireGuard/AmneziaWG +// inbound, which owns their record's peer fields: a master-local one first, then the lowest id. +func clientsOnLowerTunnel(tx *gorm.DB, inboundId int, nodeID *int, clientIds []int) (map[int]struct{}, error) { + outranks := "inbounds.node_id IS NULL AND client_inbounds.inbound_id < ?" + if nodeID != nil { + outranks = "(inbounds.node_id IS NULL OR client_inbounds.inbound_id < ?)" + } out := make(map[int]struct{}) for _, batch := range chunkInts(clientIds, sqlInChunk) { var ids []int if err := tx.Table("client_inbounds"). Joins("JOIN inbounds ON inbounds.id = client_inbounds.inbound_id"). - Where("client_inbounds.client_id IN ? AND client_inbounds.inbound_id < ? AND inbounds.protocol IN ?", - batch, inboundId, []model.Protocol{model.WireGuard, model.AmneziaWG}). + Where("client_inbounds.client_id IN ? AND inbounds.protocol IN ?", + batch, []model.Protocol{model.WireGuard, model.AmneziaWG}). + Where(outranks, inboundId). Distinct().Pluck("client_inbounds.client_id", &ids).Error; err != nil { return nil, err } diff --git a/internal/web/service/client_locks.go b/internal/web/service/client_locks.go index faf5976fe..00ac0cc1c 100644 --- a/internal/web/service/client_locks.go +++ b/internal/web/service/client_locks.go @@ -173,6 +173,7 @@ func isClientEmailTombstoned(email string) bool { // nest any value deeply, so entries stay raw text and only these fields are decoded. type settingsClientEntry struct { Email string `json:"email"` + SubID json.RawMessage `json:"subId"` ExpiryTime json.RawMessage `json:"expiryTime"` Enable json.RawMessage `json:"enable"` } diff --git a/internal/web/service/client_sync_orphan.go b/internal/web/service/client_sync_orphan.go index 17b780ff8..4aa8d4275 100644 --- a/internal/web/service/client_sync_orphan.go +++ b/internal/web/service/client_sync_orphan.go @@ -25,13 +25,13 @@ func clientRecordExists(tx *gorm.DB, email string) bool { return n > 0 } -func markSyncOrphan(tx *gorm.DB, email string, nowMs int64) error { +func markSyncOrphan(tx *gorm.DB, email string, nodeID int, nowMs int64) error { if email == "" { return nil } return tx.Model(&model.ClientRecord{}). Where("email = ? AND sync_orphaned_at = 0", email). - Update("sync_orphaned_at", nowMs).Error + Updates(map[string]any{"sync_orphaned_at": nowMs, "sync_orphaned_by": nodeID}).Error } // A client that is attached again is not orphaned, whatever an earlier merge @@ -39,7 +39,7 @@ func markSyncOrphan(tx *gorm.DB, email string, nowMs int64) error { func clearSyncOrphanMarks(tx *gorm.DB) error { return tx.Model(&model.ClientRecord{}). Where("sync_orphaned_at > 0 AND EXISTS (SELECT 1 FROM client_inbounds WHERE client_inbounds.client_id = clients.id)"). - Update("sync_orphaned_at", 0).Error + Updates(map[string]any{"sync_orphaned_at": 0, "sync_orphaned_by": 0}).Error } // ReapSyncOrphans deletes the clients the node-snapshot sweep marked and no diff --git a/internal/web/service/inbound_node.go b/internal/web/service/inbound_node.go index c47ef8672..a17787e21 100644 --- a/internal/web/service/inbound_node.go +++ b/internal/web/service/inbound_node.go @@ -23,8 +23,6 @@ import ( var reportedRemoteTagConflict sync.Map -var reportedForeignClientClaim sync.Map - // nodeBulkPushThreshold caps how many per-client RPCs a single operation will // stream to a remote node. Above it, the panel marks the node dirty instead and // lets one ReconcileNode push converge the whole inbound — far cheaper than M @@ -494,46 +492,6 @@ func snapshotAttachedEmails(settings string) (map[string]struct{}, bool) { return emails, true } -// clientEmailsOwnedElsewhere returns the emails attached only to inbounds of -// other nodes: email is unique, so adopting one would overwrite a client this -// node does not serve. Attached nowhere means soft-orphaned, hence adoptable. -func clientEmailsOwnedElsewhere(tx *gorm.DB, nodeID int, emails []string) (map[string]struct{}, error) { - attachedEmails := func(nodeScoped bool) ([]string, error) { - q := tx.Table("clients"). - Joins("JOIN client_inbounds ON client_inbounds.client_id = clients.id"). - Joins("JOIN inbounds ON inbounds.id = client_inbounds.inbound_id"). - Where("clients.email IN ?", emails) - if nodeScoped { - q = q.Where("inbounds.node_id = ?", nodeID) - } - var rows []string - err := q.Pluck("clients.email", &rows).Error - return rows, err - } - attached, err := attachedEmails(false) - if err != nil { - return nil, err - } - if len(attached) == 0 { - return nil, nil - } - owned, err := attachedEmails(true) - if err != nil { - return nil, err - } - ownedSet := make(map[string]struct{}, len(owned)) - for _, email := range owned { - ownedSet[email] = struct{}{} - } - foreign := make(map[string]struct{}) - for _, email := range attached { - if _, ok := ownedSet[email]; !ok { - foreign[email] = struct{}{} - } - } - return foreign, nil -} - func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.TrafficSnapshot, dirty, justPushed bool) (bool, error) { if snap == nil || nodeID <= 0 { return false, nil @@ -704,8 +662,23 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi } }() + claims, err := loadNodeClientClaims(tx, nodeID, snap) + if err != nil { + return false, err + } + // The raw blob stays for the reconcile fingerprint, so a rewrite leaves a mismatch to + // re-push the master's values against. + rawSettings := make(map[*model.Inbound]string, len(snap.Inbounds)) + for _, snapIb := range snap.Inbounds { + if snapIb != nil { + rawSettings[snapIb] = snapIb.Settings + snapIb.Settings = claims.apply(snapIb.Settings) + } + } + structuralChange := false lifecycleLifted := false + claimsRewritten := false var sweptTags []string var adoptedInbounds []*model.Inbound @@ -713,6 +686,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi central *model.Inbound snapIb *model.Inbound wireSettings string + fingerprint string } var pendingAdopts []pendingAdopt degradedInbounds := map[int]string{} @@ -870,6 +844,11 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi if deduped, changed := dedupeSettingsClients(adoptedSettings); changed { adoptedSettings = deduped } + fingerprint := adoptedSettings + if raw := rawSettings[snapIb]; raw != snapIb.Settings { + fingerprint, _ = stripTombstonedClients(raw) + fingerprint, _ = dedupeSettingsClients(fingerprint) + } updates := map[string]any{} if !dirty && snapshotDropsEveryHubClient(tx, c.Id, adoptedSettings) { degradedInbounds[c.Id] = c.Tag @@ -877,7 +856,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi // Defer lifecycle lift until after client_traffics absorbs this tick's // deltas so quota stale-disable matches SQL (#6228). pendingAdopts = append(pendingAdopts, pendingAdopt{ - central: c, snapIb: snapIb, wireSettings: adoptedSettings, + central: c, snapIb: snapIb, wireSettings: adoptedSettings, fingerprint: fingerprint, }) updates["enable"] = snapIb.Enable updates["remark"] = snapIb.Remark @@ -1287,10 +1266,12 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi // so the adopted rows are served this tick, not after the TTL. InvalidateSubData() } - // The fingerprint stamps the un-lifted wire blob on purpose: a lift must - // leave reconcile a mismatch to re-push against. - if liftChanged || adoptedWireChanged(p.central, p.snapIb, p.wireSettings) { - adoptedInbounds = append(adoptedInbounds, adoptedWireInbound(p.central, p.snapIb, p.wireSettings)) + rewritten := p.fingerprint != p.wireSettings + claimsRewritten = claimsRewritten || rewritten + // The fingerprint stamps the node's own blob on purpose: a lift or a refused claim + // must leave reconcile a mismatch to re-push against. + if liftChanged || rewritten || adoptedWireChanged(p.central, p.snapIb, p.fingerprint) { + adoptedInbounds = append(adoptedInbounds, adoptedWireInbound(p.central, p.snapIb, p.fingerprint)) } } for _, snapIb := range snap.Inbounds { @@ -1374,29 +1355,6 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi } } } - if len(localEmails) > 0 { - foreign, err := clientEmailsOwnedElsewhere(tx, nodeID, localEmails) - if err != nil { - return false, err - } - if len(foreign) > 0 { - kept := filtered[:0] - for i := range filtered { - if _, claimed := foreign[filtered[i].Email]; !claimed { - kept = append(kept, filtered[i]) - continue - } - key := fmt.Sprintf("%d:%s", nodeID, filtered[i].Email) - if _, seen := reportedForeignClientClaim.LoadOrStore(key, struct{}{}); !seen { - logger.Warningf( - "setRemoteTraffic: node %d reported client %q, which is attached only to inbounds of another node — not adopting (rename one side to remove the duplicate email)", - nodeID, filtered[i].Email, - ) - } - } - filtered = kept - } - } if err := s.clientService.SyncInbound(tx, c.Id, filtered); err != nil { logger.Warningf("setRemoteTraffic: sync clients for tag %q failed: %v", snapIb.Tag, err) syncFailedInbounds[c.Id] = struct{}{} @@ -1436,7 +1394,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi } // "Ended the merge unattached" is true for a real remote deletion and // equally true for a bad merge, so record a strike instead of deleting. - if err := markSyncOrphan(tx, email, now); err != nil { + if err := markSyncOrphan(tx, email, nodeID, now); err != nil { logger.Warningf("setRemoteTraffic: mark orphan %q failed: %v", email, err) continue } @@ -1472,14 +1430,18 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi } } - if lifecycleLifted && !dirty { + if (lifecycleLifted || claimsRewritten) && !dirty { + reason := "lifecycle lift" + if !lifecycleLifted { + reason = "client claim rewrite" + } var already model.Node if err := database.GetDB().Select("config_dirty").Where("id = ?", nodeID).First(&already).Error; err == nil && already.ConfigDirty { - logger.Debugf("setRemoteTraffic: node %d lifecycle lift; already dirty", nodeID) + logger.Debugf("setRemoteTraffic: node %d %s; already dirty", nodeID, reason) } else { - logger.Infof("setRemoteTraffic: node %d lifecycle lift; marking dirty for re-push", nodeID) + logger.Infof("setRemoteTraffic: node %d %s; marking dirty for re-push", nodeID, reason) if err := (&NodeService{}).MarkNodeDirty(nodeID); err != nil { - logger.Warningf("setRemoteTraffic: mark node %d dirty after lifecycle lift failed: %v", nodeID, err) + logger.Warningf("setRemoteTraffic: mark node %d dirty after %s failed: %v", nodeID, reason, err) } } } diff --git a/internal/web/service/inbound_node_claims.go b/internal/web/service/inbound_node_claims.go new file mode 100644 index 000000000..b54ff91c5 --- /dev/null +++ b/internal/web/service/inbound_node_claims.go @@ -0,0 +1,275 @@ +package service + +import ( + "encoding/json" + "fmt" + "reflect" + "strings" + "sync" + + "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/logger" + "github.com/mhsanaei/3x-ui/v3/internal/web/runtime" + + "gorm.io/gorm" +) + +var reportedRefusedClientClaim sync.Map + +// nodeClientClaims is what one node snapshot may do to the master's client records: a node +// changes only records linked solely to its own inbounds, plus the new ones it creates. +type nodeClientClaims struct { + refused map[string]string + // shared records are also served by the master or another node, so they keep the + // master's values except nodeKeptSharedKeys. + shared map[string]*model.ClientRecord + // storedSubID keeps an own record's subId when the node reports one held by a + // client it does not host. + storedSubID map[string]string +} + +// nodeKeptSharedKeys stay as the node reported them: per-link values, and the lifecycle +// the merge already settles against the master's traffic row. +var nodeKeptSharedKeys = []string{"flow", "privateKey", "publicKey", "allowedIPs", "preSharedKey", "keepAlive", "enable", "expiryTime"} + +type clientLinkScope struct{ here, elsewhere bool } + +func loadNodeClientClaims(tx *gorm.DB, nodeID int, snap *runtime.TrafficSnapshot) (*nodeClientClaims, error) { + claims := &nodeClientClaims{ + refused: map[string]string{}, + shared: map[string]*model.ClientRecord{}, + storedSubID: map[string]string{}, + } + subIDByEmail := map[string]string{} + for _, snapIb := range snap.Inbounds { + if snapIb == nil { + continue + } + _, entries, ok := splitSettingsClients(snapIb.Settings) + if !ok { + continue + } + for _, entry := range entries { + c, ok := readSettingsClient(entry) + if !ok || c.Email == "" { + continue + } + if c.Email != strings.TrimSpace(c.Email) { + claims.refused[c.Email] = "its email carries surrounding white space" + continue + } + if _, seen := subIDByEmail[c.Email]; !seen { + var subID string + _ = json.Unmarshal(c.SubID, &subID) + subIDByEmail[c.Email] = subID + } + } + } + if len(subIDByEmail) == 0 { + return claims, nil + } + + lowered := make([]string, 0, len(subIDByEmail)) + for email := range subIDByEmail { + lowered = append(lowered, strings.ToLower(email)) + } + var records []model.ClientRecord + for _, batch := range chunkStrings(uniqueNonEmptyStrings(lowered), sqlInChunk) { + var rows []model.ClientRecord + if err := tx.Where("LOWER(email) IN ?", batch).Find(&rows).Error; err != nil { + return nil, err + } + records = append(records, rows...) + } + byEmail := make(map[string]*model.ClientRecord, len(records)) + byLower := make(map[string]string, len(records)) + for i := range records { + byEmail[records[i].Email] = &records[i] + byLower[strings.ToLower(records[i].Email)] = records[i].Email + } + + subIDs := make([]string, 0, len(subIDByEmail)) + for _, subID := range subIDByEmail { + if subID != "" { + subIDs = append(subIDs, subID) + } + } + var holders []model.ClientRecord + for _, batch := range chunkStrings(uniqueNonEmptyStrings(subIDs), sqlInChunk) { + var rows []model.ClientRecord + if err := tx.Select("id", "email", "sub_id").Where("sub_id IN ?", batch).Find(&rows).Error; err != nil { + return nil, err + } + holders = append(holders, rows...) + } + + ids := make([]int, 0, len(records)+len(holders)) + for i := range records { + ids = append(ids, records[i].Id) + } + for i := range holders { + ids = append(ids, holders[i].Id) + } + scopes, err := clientLinkScopes(tx, nodeID, ids) + if err != nil { + return nil, err + } + holdersBySub := make(map[string][]model.ClientRecord, len(holders)) + for _, h := range holders { + holdersBySub[h.SubID] = append(holdersBySub[h.SubID], h) + } + subHeldElsewhere := func(email, subID string) bool { + for _, h := range holdersBySub[subID] { + if !strings.EqualFold(h.Email, email) && scopes[h.Id] != (clientLinkScope{here: true}) { + return true + } + } + return false + } + + for email, subID := range subIDByEmail { + rec, exact := byEmail[email] + if !exact { + if _, variant := byLower[strings.ToLower(email)]; variant { + claims.refused[email] = "another client's email differs from it only in case" + } else if subID != "" && subHeldElsewhere(email, subID) { + claims.refused[email] = "its subId belongs to a client this node does not host" + } + continue + } + scope := scopes[rec.Id] + switch { + case scope.elsewhere && scope.here: + claims.shared[email] = rec + case scope.elsewhere: + claims.refused[email] = "it is attached only to inbounds this node does not host" + case !scope.here && (rec.SyncOrphanedAt == 0 || rec.SyncOrphanedBy != nodeID): + claims.refused[email] = "it is detached from every inbound on the master" + case subID != rec.SubID && subID != "" && subHeldElsewhere(email, subID): + claims.storedSubID[email] = rec.SubID + } + } + for email, reason := range claims.refused { + key := fmt.Sprintf("%d:%s", nodeID, email) + if _, seen := reportedRefusedClientClaim.LoadOrStore(key, struct{}{}); !seen { + logger.Warningf("setRemoteTraffic: node %d reported client %q, not adopting: %s", nodeID, email, reason) + } + } + return claims, nil +} + +// clientLinkScopes reports, per client id, whether it is linked to an inbound of this node +// and whether to any inbound outside it (the master's own or another node's). +func clientLinkScopes(tx *gorm.DB, nodeID int, ids []int) (map[int]clientLinkScope, error) { + scopes := make(map[int]clientLinkScope, len(ids)) + for _, batch := range chunkInts(uniqueInts(ids), sqlInChunk) { + var rows []struct { + ClientID int + NodeID *int + } + if err := tx.Table("client_inbounds"). + Select("client_inbounds.client_id AS client_id, inbounds.node_id AS node_id"). + Joins("JOIN inbounds ON inbounds.id = client_inbounds.inbound_id"). + Where("client_inbounds.client_id IN ?", batch). + Scan(&rows).Error; err != nil { + return nil, err + } + for _, r := range rows { + s := scopes[r.ClientID] + if r.NodeID != nil && *r.NodeID == nodeID { + s.here = true + } else { + s.elsewhere = true + } + scopes[r.ClientID] = s + } + } + return scopes, nil +} + +// apply rewrites a snapshot inbound's settings to what this node may claim. +func (c *nodeClientClaims) apply(settings string) string { + if len(c.refused) == 0 && len(c.shared) == 0 && len(c.storedSubID) == 0 { + return settings + } + members, entries, ok := splitSettingsClients(settings) + if !ok { + return settings + } + changed := false + kept := make([]json.RawMessage, 0, len(entries)) + for _, entry := range entries { + sc, ok := readSettingsClient(entry) + if !ok || sc.Email == "" { + kept = append(kept, entry) + continue + } + if _, refused := c.refused[sc.Email]; refused { + changed = true + continue + } + var override map[string]json.RawMessage + if rec, shared := c.shared[sc.Email]; shared { + override = sharedClientEntry(rec, entry) + } else if subID, keep := c.storedSubID[sc.Email]; keep { + if json.Unmarshal(entry, &override) == nil { + override["subId"], _ = json.Marshal(subID) + } + } + if override == nil { + kept = append(kept, entry) + continue + } + rewritten, err := json.Marshal(override) + if err != nil { + kept = append(kept, entry) + continue + } + changed = true + kept = append(kept, rewritten) + } + if !changed { + return settings + } + out, _ := joinSettingsClients(settings, members, kept) + return out +} + +// sharedClientEntry is nil when the node's entry already agrees with the record on every +// master-owned value, so an honest node's differing timestamps cause no re-push. +func sharedClientEntry(rec *model.ClientRecord, nodeEntry json.RawMessage) map[string]json.RawMessage { + var reported model.Client + if json.Unmarshal(nodeEntry, &reported) != nil { + return nil + } + if !sharedRecordWouldChange(rec, &reported) { + return nil + } + var fromRecord, fromNode map[string]json.RawMessage + raw, err := json.Marshal(rec.ToClient()) + if err != nil || json.Unmarshal(raw, &fromRecord) != nil || json.Unmarshal(nodeEntry, &fromNode) != nil { + return nil + } + for _, key := range nodeKeptSharedKeys { + if v, ok := fromNode[key]; ok { + fromRecord[key] = v + } else { + delete(fromRecord, key) + } + } + return fromRecord +} + +// sharedRecordWouldChange merges the reported client onto a copy of the record the way +// SyncInbound does, leaving out what the node may set and what the master restores anyway. +func sharedRecordWouldChange(rec *model.ClientRecord, reported *model.Client) bool { + incoming := reported.ToRecord() + incoming.Enable = rec.Enable + incoming.ExpiryTime = rec.ExpiryTime + incoming.Comment = rec.Comment + incoming.CreatedAt = 0 + merged := *rec + applyClientRecordMerge(&merged, incoming) + keepLinkOwnedFields(&merged, rec, false) + return !reflect.DeepEqual(merged, *rec) +} diff --git a/internal/web/service/node_client_expiry_sync_test.go b/internal/web/service/node_client_expiry_sync_test.go index bf88b14fc..1398a6537 100644 --- a/internal/web/service/node_client_expiry_sync_test.go +++ b/internal/web/service/node_client_expiry_sync_test.go @@ -1030,7 +1030,7 @@ func TestNodeActivationLiftsClientRecordExpiry(t *testing.T) { const activated = int64(1798448344010) negSettings := `{"clients":[{"email":"delayed","enable":true,"expiryTime":-2592000000}]}` - if err := db.Create(&model.ClientRecord{Email: email, Enable: true, ExpiryTime: duration}).Error; err != nil { + if err := db.Model(&model.ClientRecord{}).Where("email = ?", email).Update("expiry_time", duration).Error; err != nil { t.Fatalf("seed client record: %v", err) } diff --git a/internal/web/service/node_client_traffic_sum_test.go b/internal/web/service/node_client_traffic_sum_test.go index dece8e23b..dd8e01fd2 100644 --- a/internal/web/service/node_client_traffic_sum_test.go +++ b/internal/web/service/node_client_traffic_sum_test.go @@ -38,7 +38,7 @@ func createNodeInbound(t *testing.T, db *gorm.DB, nodeID int, tag string, port i } // createNodeInboundWithClient mirrors createNodeInbound but stores the client -// in the settings JSON, which the node sync turns into a client_inbounds link. +// in the settings JSON and links it, as attaching it on the master does. func createNodeInboundWithClient(t *testing.T, db *gorm.DB, nodeID int, tag string, port int, email string) { t.Helper() nid := nodeID @@ -47,6 +47,9 @@ func createNodeInboundWithClient(t *testing.T, db *gorm.DB, nodeID int, tag stri if err := db.Create(ib).Error; err != nil { t.Fatalf("create node inbound %q: %v", tag, err) } + if err := (&ClientService{}).SyncInbound(db, ib.Id, []model.Client{{Email: email, Enable: true}}); err != nil { + t.Fatalf("link %q to %q: %v", email, tag, err) + } } func syncNode(t *testing.T, svc *InboundService, nodeID int, tag string, stats ...xray.ClientTraffic) { diff --git a/internal/web/service/node_foreign_client_test.go b/internal/web/service/node_foreign_client_test.go index ee7c09bee..6f9d71c5e 100644 --- a/internal/web/service/node_foreign_client_test.go +++ b/internal/web/service/node_foreign_client_test.go @@ -2,9 +2,11 @@ package service import ( "fmt" + "strings" "testing" "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/web/runtime" "github.com/mhsanaei/3x-ui/v3/internal/xray" "gorm.io/gorm" @@ -88,3 +90,279 @@ func nodeInboundID(t *testing.T, db *gorm.DB, tag string) int { } return ib.Id } + +func seedMasterInbound(t *testing.T, db *gorm.DB, tag string, port int, clientsJSON string) *model.Inbound { + t.Helper() + ib := &model.Inbound{ + UserId: 1, Tag: tag, Enable: true, Port: port, Protocol: model.VLESS, + Settings: `{"clients":[` + clientsJSON + `]}`, + } + if err := db.Create(ib).Error; err != nil { + t.Fatalf("create inbound %q: %v", tag, err) + } + clients, err := (&InboundService{}).GetClients(ib) + if err != nil { + t.Fatalf("parse clients of %q: %v", tag, err) + } + if err := (&ClientService{}).SyncInbound(db, ib.Id, clients); err != nil { + t.Fatalf("attach clients of %q: %v", tag, err) + } + return ib +} + +func readClientRecord(t *testing.T, db *gorm.DB, email string) model.ClientRecord { + t.Helper() + var row model.ClientRecord + if err := db.Where("email = ?", email).First(&row).Error; err != nil { + t.Fatalf("read client %q: %v", email, err) + } + return row +} + +func countClientsWhere(t *testing.T, db *gorm.DB, query string, args ...any) int64 { + t.Helper() + var n int64 + if err := db.Model(&model.ClientRecord{}).Where(query, args...).Count(&n).Error; err != nil { + t.Fatalf("count clients: %v", err) + } + return n +} + +func storedInboundSettings(t *testing.T, db *gorm.DB, tag string) string { + t.Helper() + var ib model.Inbound + if err := db.Where("tag = ?", tag).First(&ib).Error; err != nil { + t.Fatalf("read inbound %q: %v", tag, err) + } + return ib.Settings +} + +func nodeHostsClient(t *testing.T, db *gorm.DB, tag, email string) bool { + t.Helper() + attached, err := (&ClientService{}).ListForInbound(db, nodeInboundID(t, db, tag)) + if err != nil { + t.Fatalf("list clients of %q: %v", tag, err) + } + for _, c := range attached { + if c.Email == email { + return true + } + } + return false +} + +// A client shared with a master inbound is rendered into the master's own Xray, so the +// node's copy must not move its credential, reverse tag or subscription id. +func TestNodeCannotRewriteClientSharedWithMaster(t *testing.T) { + db := initTrafficTestDB(t) + svc := &InboundService{} + seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true}) + + const legitUUID = "11111111-1111-4111-8111-111111111111" + shared := fmt.Sprintf(`{"email":"shared@x","id":%q,"subId":"shared-sub","enable":true}`, legitUUID) + seedMasterInbound(t, db, "master-in", 40001, shared) + nid := 1 + nodeIb := &model.Inbound{ + UserId: 1, Tag: "n1-in", Enable: true, Port: 41001, Protocol: model.VLESS, NodeID: &nid, + Settings: `{"clients":[` + shared + `]}`, + } + if err := db.Create(nodeIb).Error; err != nil { + t.Fatalf("create node inbound: %v", err) + } + clients, _ := svc.GetClients(nodeIb) + if err := (&ClientService{}).SyncInbound(db, nodeIb.Id, clients); err != nil { + t.Fatalf("attach node clients: %v", err) + } + + syncNodeWithSettings(t, svc, 1, "n1-in", + `{"clients":[{"email":"shared@x","id":"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz","subId":"picked-by-node","reverse":{"tag":"direct"},"enable":true}]}`, + xray.ClientTraffic{Email: "shared@x", Enable: true}) + + row := readClientRecord(t, db, "shared@x") + if row.UUID != legitUUID || row.SubID != "shared-sub" || row.Reverse != "" { + t.Fatalf("node rewrote a shared client: uuid=%q subId=%q reverse=%q", row.UUID, row.SubID, row.Reverse) + } + stored := storedInboundSettings(t, db, "n1-in") + if strings.Contains(stored, "zzzz") || strings.Contains(stored, "picked-by-node") || strings.Contains(stored, "direct") { + t.Fatalf("node inbound stored the node's values for a shared client: %s", stored) + } + if !nodeHostsClient(t, db, "n1-in", "shared@x") { + t.Fatal("the shared client lost its node link") + } +} + +// A refused claim must leave no trace: the stored settings are re-synced later by +// auto-renew, and its stats would land on the victim's traffic row. +func TestNodeRefusedClaimIsNotStoredOrCounted(t *testing.T) { + db := initTrafficTestDB(t) + svc := &InboundService{} + seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true}) + master := seedMasterInbound(t, db, "master-in", 40001, `{"email":"victim@x","id":"11111111-1111-4111-8111-111111111111","subId":"victim-sub","enable":true}`) + if err := db.Create(&xray.ClientTraffic{InboundId: master.Id, Email: "victim@x", Enable: true}).Error; err != nil { + t.Fatalf("seed victim traffic: %v", err) + } + createNodeInbound(t, db, 1, "n1-in", 41001) + + hostile := `{"clients":[{"email":"victim@x","id":"","subId":"picked-by-node","enable":true},{"email":"victim@x ","subId":"padded","enable":true},{"email":"VICTIM@X","subId":"cased","enable":true},{"email":"own@x","enable":true}]}` + for _, up := range []int64{100, 600} { + syncNodeWithSettings(t, svc, 1, "n1-in", hostile, + xray.ClientTraffic{Email: "victim@x", Up: up, Enable: true}, + xray.ClientTraffic{Email: "own@x", Enable: true}) + } + + if stored := storedInboundSettings(t, db, "n1-in"); strings.Contains(strings.ToLower(stored), "victim") { + t.Fatalf("refused claim stored on the node inbound: %s", stored) + } + if row := readClientRecord(t, db, "victim@x"); row.SubID != "victim-sub" { + t.Fatalf("victim subId = %q, want victim-sub", row.SubID) + } + if n := countClientsWhere(t, db, "LOWER(email) LIKE ?", "victim%"); n != 1 { + t.Fatalf("a padded or case-variant claim created a look-alike record: %d victim rows", n) + } + var counted int64 + db.Model(xray.ClientTraffic{}).Where("LOWER(email) LIKE ? AND up > 0", "victim%").Count(&counted) + if counted != 0 { + t.Fatalf("refused claim's stats reached a victim traffic row: %d rows", counted) + } + if !nodeHostsClient(t, db, "n1-in", "own@x") { + t.Fatal("the node's own client was not adopted") + } +} + +// A record with no inbound is the admin's (detached, or its inbound deleted), not a +// node's; only a node whose own merge orphaned it may take it back. +func TestNodeCannotAdoptDetachedClient(t *testing.T) { + db := initTrafficTestDB(t) + svc := &InboundService{} + seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true}) + seedNodeRow(t, db, &model.Node{Id: 2, Name: "n2", Address: "127.0.0.2", Port: 2096, ApiToken: "tok2", Enable: true}) + master := seedMasterInbound(t, db, "master-in", 40001, + `{"email":"detached@x","id":"11111111-1111-4111-8111-111111111111","subId":"d-sub","enable":true},`+ + `{"email":"mine@x","id":"22222222-2222-4222-8222-222222222222","enable":true},`+ + `{"email":"theirs@x","id":"33333333-3333-4333-8333-333333333333","enable":true}`) + if err := db.Exec("DELETE FROM client_inbounds WHERE inbound_id = ?", master.Id).Error; err != nil { + t.Fatalf("detach: %v", err) + } + for email, node := range map[string]int{"mine@x": 1, "theirs@x": 2} { + if err := db.Model(&model.ClientRecord{}).Where("email = ?", email). + Updates(map[string]any{"sync_orphaned_at": 1, "sync_orphaned_by": node}).Error; err != nil { + t.Fatalf("mark %s orphaned: %v", email, err) + } + } + createNodeInbound(t, db, 1, "n1-in", 41001) + + syncNodeWithSettings(t, svc, 1, "n1-in", + `{"clients":[{"email":"detached@x","subId":"picked","enable":true},{"email":"mine@x","enable":true},{"email":"theirs@x","subId":"picked2","enable":true}]}`) + + if row := readClientRecord(t, db, "detached@x"); row.SubID != "d-sub" || nodeHostsClient(t, db, "n1-in", "detached@x") { + t.Fatalf("node adopted a detached client: subId=%q", row.SubID) + } + if nodeHostsClient(t, db, "n1-in", "theirs@x") { + t.Fatal("node adopted a client another node's merge orphaned") + } + if !nodeHostsClient(t, db, "n1-in", "mine@x") { + t.Fatal("node could not take back a client its own merge orphaned") + } +} + +// The subscription server selects by subId alone, so a node's client must not join +// the subscription of a customer it does not host. +func TestNodeCannotClaimForeignSubID(t *testing.T) { + db := initTrafficTestDB(t) + svc := &InboundService{} + seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true}) + seedMasterInbound(t, db, "master-in", 40001, `{"email":"customer@x","id":"11111111-1111-4111-8111-111111111111","subId":"cust-sub","enable":true}`) + createNodeInbound(t, db, 1, "n1-in", 41001) + + syncNodeWithSettings(t, svc, 1, "n1-in", + `{"clients":[{"email":"node-own@x","id":"66666666-6666-4666-8666-666666666666","subId":"cust-sub","enable":true}]}`) + + if n := countClientsWhere(t, db, "sub_id = ?", "cust-sub"); n != 1 { + t.Fatalf("node joined a customer's subscription: %d records hold cust-sub", n) + } +} + +// A shared client's tunnel keys are rendered into the master's own WireGuard inbound, so a +// node tunnel must not own them even when its inbound id is the lower one. +func TestNodeTunnelDoesNotOwnSharedPeerKeys(t *testing.T) { + db := initTrafficTestDB(t) + svc := &InboundService{} + seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true}) + + const masterKey = "mJ3F8Yb3Kx6cdx6Sx4WbJ3qQDXB5cN3d0pH6m4kq0Ug=" + const nodeKey = "nK9Pq1Zr6s8Lw2Vb4Xc7Dm0Fh5Jt3Ny9Ru1Ea6Go2Is=" + entry := func(key, ip string) string { + return fmt.Sprintf(`{"email":"shared@x","publicKey":%q,"allowedIPs":[%q],"enable":true}`, key, ip) + } + nid := 1 + nodeIb := &model.Inbound{ + UserId: 1, Tag: "n1-wg", Enable: true, Port: 41002, Protocol: model.WireGuard, NodeID: &nid, + Settings: `{"clients":[` + entry(masterKey, "10.1.0.2/32") + `]}`, + } + masterIb := &model.Inbound{ + UserId: 1, Tag: "master-wg", Enable: true, Port: 40002, Protocol: model.WireGuard, + Settings: `{"clients":[` + entry(masterKey, "10.0.0.2/32") + `]}`, + } + for _, ib := range []*model.Inbound{nodeIb, masterIb} { + if err := db.Create(ib).Error; err != nil { + t.Fatalf("create %q: %v", ib.Tag, err) + } + clients, err := svc.GetClients(ib) + if err != nil { + t.Fatalf("parse %q: %v", ib.Tag, err) + } + if err := (&ClientService{}).SyncInbound(db, ib.Id, clients); err != nil { + t.Fatalf("attach %q: %v", ib.Tag, err) + } + } + + snap := &runtime.TrafficSnapshot{Inbounds: []*model.Inbound{{ + Tag: "n1-wg", Enable: true, Port: 41002, Protocol: model.WireGuard, + Settings: `{"clients":[` + entry(nodeKey, "10.1.0.2/32") + `]}`, + ClientStats: []xray.ClientTraffic{{Email: "shared@x", Enable: true}}, + }}} + if _, err := svc.setRemoteTrafficLocked(1, snap, false, false); err != nil { + t.Fatalf("sync: %v", err) + } + if !nodeHostsClient(t, db, "n1-wg", "shared@x") { + t.Fatal("the shared client lost its node tunnel link") + } + if got := readClientRecord(t, db, "shared@x").PublicKey; got != masterKey { + t.Fatalf("node tunnel replaced a shared client's public key: %q", got) + } +} + +// An honest node's copy of a shared client differs only in timestamps and comment; treating +// that as a refused claim would mark the node dirty and re-push it on every tick. +func TestNodeSharedClientInAgreementIsNotRewritten(t *testing.T) { + db := initTrafficTestDB(t) + svc := &InboundService{} + seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true}) + + shared := `{"email":"shared@x","id":"11111111-1111-4111-8111-111111111111","subId":"shared-sub","comment":"vip","enable":true}` + seedMasterInbound(t, db, "master-in", 40001, shared) + nid := 1 + nodeIb := &model.Inbound{ + UserId: 1, Tag: "n1-in", Enable: true, Port: 41001, Protocol: model.VLESS, NodeID: &nid, + Settings: `{"clients":[` + shared + `]}`, + } + if err := db.Create(nodeIb).Error; err != nil { + t.Fatalf("create node inbound: %v", err) + } + clients, _ := svc.GetClients(nodeIb) + if err := (&ClientService{}).SyncInbound(db, nodeIb.Id, clients); err != nil { + t.Fatalf("attach node clients: %v", err) + } + + syncNodeWithSettings(t, svc, 1, "n1-in", + `{"clients":[{"email":"shared@x","id":"11111111-1111-4111-8111-111111111111","subId":"shared-sub","enable":true,"created_at":1,"updated_at":2}]}`, + xray.ClientTraffic{Email: "shared@x", Enable: true}) + + var node model.Node + if err := db.Select("config_dirty").Where("id = ?", 1).First(&node).Error; err != nil { + t.Fatalf("read node: %v", err) + } + if node.ConfigDirty { + t.Fatal("a node in agreement with the master was marked dirty for a re-push") + } +} From 376ab34c0fe0c4fa780cd9fe35c323b756230096 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 00:02:25 +0100 Subject: [PATCH 049/125] fix(clients): purge depleted clients by their exact usage key DelDepleted turned the emails of depleted usage rows into client identities through BulkDelete, which trims each email for request bodies. A depleted row keyed by a padded email, which a node's stats could store and which belongs to no client, therefore resolved to the healthy client it resembled and deleted it. Rows whose key carries surrounding white space name no client and are skipped. --- internal/web/service/bulk_traffic_test.go | 27 +++++++++++++++++++++++ internal/web/service/client_bulk.go | 3 ++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/internal/web/service/bulk_traffic_test.go b/internal/web/service/bulk_traffic_test.go index 2fbb2a878..b3fc9fdfa 100644 --- a/internal/web/service/bulk_traffic_test.go +++ b/internal/web/service/bulk_traffic_test.go @@ -204,3 +204,30 @@ func TestGetClientTrafficByEmailReadsClientsTable(t *testing.T) { t.Fatalf("SubId not enriched from clients table, got %q", tr.SubId) } } + +// A depleted usage row keyed by a padded email belongs to no client; trimming it on the +// way to BulkDelete deleted the healthy client it resembles. +func TestDelDepletedIgnoresPaddedUsageRow(t *testing.T) { + setupBulkDB(t) + svc := &ClientService{} + inboundSvc := &InboundService{} + + source := []model.Client{{Email: "alice@x", ID: "11111111-1111-1111-1111-111111111111", SubID: "sa", Enable: true}} + ib := mkInbound(t, 21003, model.VLESS, clientsSettings(t, source)) + if err := svc.SyncInbound(nil, ib.Id, source); err != nil { + t.Fatalf("seed linkage: %v", err) + } + mkTraffic(t, ib.Id, "alice@x", 0, 0, 0, 0, true) + mkTraffic(t, ib.Id, "alice@x ", 0, 0, 0, 1, false) + + deleted, _, err := svc.DelDepleted(inboundSvc) + if err != nil { + t.Fatalf("DelDepleted: %v", err) + } + if deleted != 0 { + t.Fatalf("deleted %d clients, want 0", deleted) + } + if _, err := svc.GetRecordByEmail(nil, "alice@x"); err != nil { + t.Fatalf("alice was deleted through a look-alike usage row: %v", err) + } +} diff --git a/internal/web/service/client_bulk.go b/internal/web/service/client_bulk.go index a7d5733fe..1f500c8ac 100644 --- a/internal/web/service/client_bulk.go +++ b/internal/web/service/client_bulk.go @@ -1533,8 +1533,9 @@ func (s *ClientService) DelDepleted(inboundSvc *InboundService) (int, bool, erro seen := make(map[string]struct{}, len(rows)) emails := make([]string, 0, len(rows)) + // BulkDelete trims each email, so a padded usage row would resolve to the client it resembles. for _, r := range rows { - if r.Email == "" { + if r.Email == "" || r.Email != strings.TrimSpace(r.Email) { continue } if _, ok := seen[r.Email]; ok { From d114845e445bf906b4f697ce3dd28e9b9f7eb447 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 00:03:48 +0100 Subject: [PATCH 050/125] fix(clients): store client group names trimmed The groups page lists clients.group_name as stored, while every group action trims the name it is given. A name with surrounding white space, saved by a node snapshot or by a client edit, was listed as a separate group, and deleting, renaming or resetting that row acted on the real group of the trimmed name instead. Group names are trimmed wherever a client record is built or updated, and a one-time seeder folds stored names onto their trimmed form. --- internal/database/db.go | 28 ++++++++++++++++- internal/database/db_seed_test.go | 31 +++++++++++++++++++ internal/database/model/model.go | 2 +- internal/web/service/client_crud.go | 2 +- .../service/client_group_node_sync_test.go | 19 ++++++++++++ 5 files changed, 79 insertions(+), 3 deletions(-) diff --git a/internal/database/db.go b/internal/database/db.go index a8a570ab9..66d29d4e5 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -1530,7 +1530,7 @@ func runSeeders(isUsersEmpty bool) error { } if empty && isUsersEmpty { - seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix2", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "FreedomFinalRulesPrivateEgressBlock", "UppercaseFreedomFinalRulesFix", "InboundRealityFinalmaskTcpStrip", "ApiTokensHash", "LegacyProxySettingsCleanup", "SessionSecretRotation", "OutboundRemovedKeysFix", "FreedomDomainStrategyFix", "DNSOutboundLegacyKeysFix", "DNSOutboundQTypeZeroFix", "WireguardDomainStrategyFix", "XdnsFinalmaskNamesAddrsFix", "UDPHopClientMaskFix", "HostVerifyPeerCertByNameBoolFix", "HostMihomoX25519Reset", "WireguardPeersToClients", "MtprotoSecretsToClients", "NodeInboundsAdopted", "NodeDownNotified", "ResetIpLimitNoFail2ban"} + seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix2", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "FreedomFinalRulesPrivateEgressBlock", "UppercaseFreedomFinalRulesFix", "InboundRealityFinalmaskTcpStrip", "ApiTokensHash", "LegacyProxySettingsCleanup", "SessionSecretRotation", "OutboundRemovedKeysFix", "FreedomDomainStrategyFix", "DNSOutboundLegacyKeysFix", "DNSOutboundQTypeZeroFix", "WireguardDomainStrategyFix", "XdnsFinalmaskNamesAddrsFix", "UDPHopClientMaskFix", "HostVerifyPeerCertByNameBoolFix", "HostMihomoX25519Reset", "WireguardPeersToClients", "MtprotoSecretsToClients", "NodeInboundsAdopted", "NodeDownNotified", "ResetIpLimitNoFail2ban", "ClientGroupNameTrim"} for _, name := range seeders { if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil { return err @@ -1713,6 +1713,12 @@ func runSeeders(isUsersEmpty bool) error { } } + if !slices.Contains(seedersHistory, "ClientGroupNameTrim") { + if err := trimClientGroupNames(); err != nil { + return err + } + } + if err := seedHostsFromExternalProxy(); err != nil { return err } @@ -1908,6 +1914,26 @@ func clearLegacyProxySettings() error { }) } +// The groups page addresses a group by its trimmed name, so a padded stored name listed as +// its own group while every action on it hit the trimmed one. +func trimClientGroupNames() error { + return db.Transaction(func(tx *gorm.DB) error { + var names []string + if err := tx.Model(&model.ClientRecord{}).Distinct().Pluck("group_name", &names).Error; err != nil { + return err + } + for _, name := range names { + if trimmed := strings.TrimSpace(name); trimmed != name { + if err := tx.Model(&model.ClientRecord{}).Where("group_name = ?", name). + UpdateColumn("group_name", trimmed).Error; err != nil { + return err + } + } + } + return tx.Create(&model.HistoryOfSeeders{SeederName: "ClientGroupNameTrim"}).Error + }) +} + // Secrets stored before v2.8.3 came from math/rand (a 31-bit clock seed up to v2.1.3); // GetSecret draws a crypto/rand one on the next start. func dropPreUpgradeSessionSecret() error { diff --git a/internal/database/db_seed_test.go b/internal/database/db_seed_test.go index 85ceef985..3f928879c 100644 --- a/internal/database/db_seed_test.go +++ b/internal/database/db_seed_test.go @@ -345,3 +345,34 @@ func TestNormalizeSettingPaths_RepairsLegacyValues(t *testing.T) { } } } + +// A padded group name was listed as its own group while every action on it hit the trimmed +// one, so the upgrade folds stored names onto their trimmed form. +func TestClientGroupNamesTrimmedOnUpgrade(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "x-ui.db") + if err := InitDB(dbPath); err != nil { + t.Fatalf("InitDB: %v", err) + } + t.Cleanup(func() { _ = CloseDB() }) + for email, group := range map[string]string{"a@x": "vip ", "b@x": "\tvip", "c@x": "vip"} { + if err := db.Create(&model.ClientRecord{Email: email, Group: group, Enable: true}).Error; err != nil { + t.Fatalf("seed %s: %v", email, err) + } + } + if err := db.Where("seeder_name = ?", "ClientGroupNameTrim").Delete(&model.HistoryOfSeeders{}).Error; err != nil { + t.Fatalf("clear seeder history: %v", err) + } + if err := CloseDB(); err != nil { + t.Fatalf("CloseDB: %v", err) + } + if err := InitDB(dbPath); err != nil { + t.Fatalf("reopen InitDB: %v", err) + } + var groups []string + if err := db.Model(&model.ClientRecord{}).Distinct().Pluck("group_name", &groups).Error; err != nil { + t.Fatalf("read groups: %v", err) + } + if len(groups) != 1 || groups[0] != "vip" { + t.Fatalf("groups after upgrade = %q, want [vip]", groups) + } +} diff --git a/internal/database/model/model.go b/internal/database/model/model.go index 284d6d08a..cf93ba7fe 100644 --- a/internal/database/model/model.go +++ b/internal/database/model/model.go @@ -1288,7 +1288,7 @@ func (c *Client) ToRecord() *ClientRecord { ExpiryTime: c.ExpiryTime, Enable: c.Enable, TgID: c.TgID, - Group: c.Group, + Group: strings.TrimSpace(c.Group), Comment: c.Comment, Reset: c.Reset, ResetDay: c.ResetDay, diff --git a/internal/web/service/client_crud.go b/internal/web/service/client_crud.go index 5a2081a11..5429167cd 100644 --- a/internal/web/service/client_crud.go +++ b/internal/web/service/client_crud.go @@ -923,7 +923,7 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model // including the empty string that removes the client from its group. if err := database.GetDB().Model(&model.ClientRecord{}). Where("id = ?", id). - UpdateColumn("group_name", updated.Group).Error; err != nil { + UpdateColumn("group_name", strings.TrimSpace(updated.Group)).Error; err != nil { return needRestart, err } diff --git a/internal/web/service/client_group_node_sync_test.go b/internal/web/service/client_group_node_sync_test.go index 942d64cad..9f0f569f9 100644 --- a/internal/web/service/client_group_node_sync_test.go +++ b/internal/web/service/client_group_node_sync_test.go @@ -181,3 +181,22 @@ func TestClientUpdate_ClearsGroup(t *testing.T) { t.Errorf("inbound settings still carry a group key after removal: %s", ibAfter.Settings) } } + +// The groups page lists stored names but addresses a group by its trimmed name, so a +// padded name from a node made actions on its row hit the real group. +func TestSetRemoteTraffic_StoresTrimmedGroupName(t *testing.T) { + db := initTrafficTestDB(t) + seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true}) + createNodeInbound(t, db, 1, "n1-in", 41001) + + syncNodeWithSettings(t, &InboundService{}, 1, "n1-in", + `{"clients":[{"email":"who@x","id":"66666666-6666-4666-8666-666666666666","group":"vip\t","enable":true}]}`) + + var row model.ClientRecord + if err := db.Where("email = ?", "who@x").First(&row).Error; err != nil { + t.Fatalf("lookup client: %v", err) + } + if row.Group != "vip" { + t.Fatalf("group stored as %q, want %q", row.Group, "vip") + } +} From daf5b16eda21c398eaf7dbccba1f0a234e10bfc1 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 00:06:09 +0100 Subject: [PATCH 051/125] fix(wireguard): hold /0 allowedIPs to the overlap guard The tunnel overlap guard treated every /0 allowedIPs entry as claiming no address, while the entry was stored with its host bits and the consumers read them back: the AmneziaWG port forwarder resolves a peer's target from them and the device routes the /0 by longest prefix. An entry such as 10.8.1.2/0 therefore passed next to another client's 10.8.1.2/32, and forwarded ports reached that client's device. A written entry now counts with its whole range, so a /0 collides with every held address of its family. On the stored side only a literal 0.0.0.0/0 or ::/0 default route keeps nobody out, and a /0 with host bits claims that host. Editing a legacy peer that echoes its stored default route still works. --- .../content/docs/en/reference/api/clients.mdx | 8 +- docs/public/openapi.json | 2 +- frontend/public/openapi.json | 2 +- frontend/src/pages/api-docs/endpoints.ts | 2 +- internal/web/service/client_attach_test.go | 62 +++++++++++++ internal/web/service/client_inbound_apply.go | 2 +- internal/web/service/client_wireguard.go | 45 ++++++++-- internal/web/service/client_wireguard_test.go | 87 +++++++++++++++++++ 8 files changed, 196 insertions(+), 14 deletions(-) diff --git a/docs/content/docs/en/reference/api/clients.mdx b/docs/content/docs/en/reference/api/clients.mdx index f745c5249..5255194c7 100644 --- a/docs/content/docs/en/reference/api/clients.mdx +++ b/docs/content/docs/en/reference/api/clients.mdx @@ -609,9 +609,11 @@ _openapi: different client of that same inbound holds, or `... used by a client on ` when the holder sits on another WireGuard or AmneziaWG inbound. Ranges are compared, not strings, so `10.0.0.9/24` collides - with `10.0.0.5/32`; a `0.0.0.0/0` or `::/0` default route claims no - address. Allocation likewise skips every address inside a prefix - another client holds. The same validation runs on POST + with `10.0.0.5/32`; a `/0` entry collides with every address of its + family another client holds, and only a `0.0.0.0/0` or `::/0` default + route that is already stored (legacy peers) keeps nobody out. + Allocation likewise skips every address inside a prefix another client + holds. The same validation runs on POST /panel/api/clients/{email}/attach, where a client that already carries an address brings it along. diff --git a/docs/public/openapi.json b/docs/public/openapi.json index 86a85aca4..270316e56 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -8846,7 +8846,7 @@ ], "summary": "Create a new client and attach it to one or more inbounds in a single call. Body is JSON. Per-protocol secrets are generated server-side when omitted, so callers can send only the universal fields.", "operationId": "post_panel_api_clients_add", - "description": "Fields the server fills in when they are omitted — a valid value sent by the caller is never overwritten. Re-adding an email that already exists, with its stored `subId`, reuses the stored `id`, `password`, `auth` and `secret` instead of minting new ones, so the identity stays in sync across its inbounds.\n\n- **VLESS / VMess** — `id`, a fresh UUID\n- **Trojan** — `password`\n- **Shadowsocks** — `password`. On a `2022-blake3-*` inbound a supplied password that does not base64-decode to the key length of the cipher (16 or 32 bytes) is replaced by a generated key and the call still succeeds, so read the client back if you did not let the server pick. Legacy ciphers keep any non-empty password\n- **Hysteria** — `auth`\n- **mtproto** — `secret`, a FakeTLS secret derived from the fronting domain of the inbound, or from `www.cloudflare.com` when it has none\n- **WireGuard** — `privateKey` and `publicKey` when both are blank, or `publicKey` alone when only a `privateKey` was sent, plus `allowedIPs`: one free `/32` taken from the /24 the existing peers of that inbound already sit in, or from `10.0.0.0/24` when it has none\n\nAccepted on the same body but never generated: `preSharedKey` and `keepAlive` (WireGuard), `adTag` (mtproto).\n\nWireGuard is the only one of these that can fail. Allocation widens the search to the containing /16 before giving up with `inbound : wireguard: no free address available in `, and an `allowedIPs` supplied by the caller is validated instead of allocated: `inbound : wireguard: allowedIPs entry overlaps
used by another client` when its range overlaps an address or prefix a different client of that same inbound holds, or `... used by a client on ` when the holder sits on another WireGuard or AmneziaWG inbound. Ranges are compared, not strings, so `10.0.0.9/24` collides with `10.0.0.5/32`; a `0.0.0.0/0` or `::/0` default route claims no address. Allocation likewise skips every address inside a prefix another client holds. The same validation runs on POST /panel/api/clients/{email}/attach, where a client that already carries an address brings it along.\n\nAn `inboundIds` entry that names no existing inbound rejects the whole call before anything is written. Past that, the inbounds are applied concurrently and independently: one that fails no longer stops the others, so a `success:false` response can still have created the client on the rest. Every error names the inbound it came from (`inbound 7: `), and several failures are reported together, one per line. `limitHwid` is applied only when every inbound succeeded, so re-run the call after fixing the failure.", + "description": "Fields the server fills in when they are omitted — a valid value sent by the caller is never overwritten. Re-adding an email that already exists, with its stored `subId`, reuses the stored `id`, `password`, `auth` and `secret` instead of minting new ones, so the identity stays in sync across its inbounds.\n\n- **VLESS / VMess** — `id`, a fresh UUID\n- **Trojan** — `password`\n- **Shadowsocks** — `password`. On a `2022-blake3-*` inbound a supplied password that does not base64-decode to the key length of the cipher (16 or 32 bytes) is replaced by a generated key and the call still succeeds, so read the client back if you did not let the server pick. Legacy ciphers keep any non-empty password\n- **Hysteria** — `auth`\n- **mtproto** — `secret`, a FakeTLS secret derived from the fronting domain of the inbound, or from `www.cloudflare.com` when it has none\n- **WireGuard** — `privateKey` and `publicKey` when both are blank, or `publicKey` alone when only a `privateKey` was sent, plus `allowedIPs`: one free `/32` taken from the /24 the existing peers of that inbound already sit in, or from `10.0.0.0/24` when it has none\n\nAccepted on the same body but never generated: `preSharedKey` and `keepAlive` (WireGuard), `adTag` (mtproto).\n\nWireGuard is the only one of these that can fail. Allocation widens the search to the containing /16 before giving up with `inbound : wireguard: no free address available in `, and an `allowedIPs` supplied by the caller is validated instead of allocated: `inbound : wireguard: allowedIPs entry overlaps
used by another client` when its range overlaps an address or prefix a different client of that same inbound holds, or `... used by a client on ` when the holder sits on another WireGuard or AmneziaWG inbound. Ranges are compared, not strings, so `10.0.0.9/24` collides with `10.0.0.5/32`; a `/0` entry collides with every address of its family another client holds, and only a `0.0.0.0/0` or `::/0` default route that is already stored (legacy peers) keeps nobody out. Allocation likewise skips every address inside a prefix another client holds. The same validation runs on POST /panel/api/clients/{email}/attach, where a client that already carries an address brings it along.\n\nAn `inboundIds` entry that names no existing inbound rejects the whole call before anything is written. Past that, the inbounds are applied concurrently and independently: one that fails no longer stops the others, so a `success:false` response can still have created the client on the rest. Every error names the inbound it came from (`inbound 7: `), and several failures are reported together, one per line. `limitHwid` is applied only when every inbound succeeded, so re-run the call after fixing the failure.", "requestBody": { "required": true, "content": { diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index 86a85aca4..270316e56 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -8846,7 +8846,7 @@ ], "summary": "Create a new client and attach it to one or more inbounds in a single call. Body is JSON. Per-protocol secrets are generated server-side when omitted, so callers can send only the universal fields.", "operationId": "post_panel_api_clients_add", - "description": "Fields the server fills in when they are omitted — a valid value sent by the caller is never overwritten. Re-adding an email that already exists, with its stored `subId`, reuses the stored `id`, `password`, `auth` and `secret` instead of minting new ones, so the identity stays in sync across its inbounds.\n\n- **VLESS / VMess** — `id`, a fresh UUID\n- **Trojan** — `password`\n- **Shadowsocks** — `password`. On a `2022-blake3-*` inbound a supplied password that does not base64-decode to the key length of the cipher (16 or 32 bytes) is replaced by a generated key and the call still succeeds, so read the client back if you did not let the server pick. Legacy ciphers keep any non-empty password\n- **Hysteria** — `auth`\n- **mtproto** — `secret`, a FakeTLS secret derived from the fronting domain of the inbound, or from `www.cloudflare.com` when it has none\n- **WireGuard** — `privateKey` and `publicKey` when both are blank, or `publicKey` alone when only a `privateKey` was sent, plus `allowedIPs`: one free `/32` taken from the /24 the existing peers of that inbound already sit in, or from `10.0.0.0/24` when it has none\n\nAccepted on the same body but never generated: `preSharedKey` and `keepAlive` (WireGuard), `adTag` (mtproto).\n\nWireGuard is the only one of these that can fail. Allocation widens the search to the containing /16 before giving up with `inbound : wireguard: no free address available in `, and an `allowedIPs` supplied by the caller is validated instead of allocated: `inbound : wireguard: allowedIPs entry overlaps
used by another client` when its range overlaps an address or prefix a different client of that same inbound holds, or `... used by a client on ` when the holder sits on another WireGuard or AmneziaWG inbound. Ranges are compared, not strings, so `10.0.0.9/24` collides with `10.0.0.5/32`; a `0.0.0.0/0` or `::/0` default route claims no address. Allocation likewise skips every address inside a prefix another client holds. The same validation runs on POST /panel/api/clients/{email}/attach, where a client that already carries an address brings it along.\n\nAn `inboundIds` entry that names no existing inbound rejects the whole call before anything is written. Past that, the inbounds are applied concurrently and independently: one that fails no longer stops the others, so a `success:false` response can still have created the client on the rest. Every error names the inbound it came from (`inbound 7: `), and several failures are reported together, one per line. `limitHwid` is applied only when every inbound succeeded, so re-run the call after fixing the failure.", + "description": "Fields the server fills in when they are omitted — a valid value sent by the caller is never overwritten. Re-adding an email that already exists, with its stored `subId`, reuses the stored `id`, `password`, `auth` and `secret` instead of minting new ones, so the identity stays in sync across its inbounds.\n\n- **VLESS / VMess** — `id`, a fresh UUID\n- **Trojan** — `password`\n- **Shadowsocks** — `password`. On a `2022-blake3-*` inbound a supplied password that does not base64-decode to the key length of the cipher (16 or 32 bytes) is replaced by a generated key and the call still succeeds, so read the client back if you did not let the server pick. Legacy ciphers keep any non-empty password\n- **Hysteria** — `auth`\n- **mtproto** — `secret`, a FakeTLS secret derived from the fronting domain of the inbound, or from `www.cloudflare.com` when it has none\n- **WireGuard** — `privateKey` and `publicKey` when both are blank, or `publicKey` alone when only a `privateKey` was sent, plus `allowedIPs`: one free `/32` taken from the /24 the existing peers of that inbound already sit in, or from `10.0.0.0/24` when it has none\n\nAccepted on the same body but never generated: `preSharedKey` and `keepAlive` (WireGuard), `adTag` (mtproto).\n\nWireGuard is the only one of these that can fail. Allocation widens the search to the containing /16 before giving up with `inbound : wireguard: no free address available in `, and an `allowedIPs` supplied by the caller is validated instead of allocated: `inbound : wireguard: allowedIPs entry overlaps
used by another client` when its range overlaps an address or prefix a different client of that same inbound holds, or `... used by a client on ` when the holder sits on another WireGuard or AmneziaWG inbound. Ranges are compared, not strings, so `10.0.0.9/24` collides with `10.0.0.5/32`; a `/0` entry collides with every address of its family another client holds, and only a `0.0.0.0/0` or `::/0` default route that is already stored (legacy peers) keeps nobody out. Allocation likewise skips every address inside a prefix another client holds. The same validation runs on POST /panel/api/clients/{email}/attach, where a client that already carries an address brings it along.\n\nAn `inboundIds` entry that names no existing inbound rejects the whole call before anything is written. Past that, the inbounds are applied concurrently and independently: one that fails no longer stops the others, so a `success:false` response can still have created the client on the rest. Every error names the inbound it came from (`inbound 7: `), and several failures are reported together, one per line. `limitHwid` is applied only when every inbound succeeded, so re-run the call after fixing the failure.", "requestBody": { "required": true, "content": { diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index b8cee9666..1d5cf3bc5 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/frontend/src/pages/api-docs/endpoints.ts @@ -1195,7 +1195,7 @@ export const sections: readonly Section[] = [ summary: 'Create a new client and attach it to one or more inbounds in a single call. Body is JSON. Per-protocol secrets are generated server-side when omitted, so callers can send only the universal fields.', description: - 'Fields the server fills in when they are omitted — a valid value sent by the caller is never overwritten. Re-adding an email that already exists, with its stored `subId`, reuses the stored `id`, `password`, `auth` and `secret` instead of minting new ones, so the identity stays in sync across its inbounds.\n\n- **VLESS / VMess** — `id`, a fresh UUID\n- **Trojan** — `password`\n- **Shadowsocks** — `password`. On a `2022-blake3-*` inbound a supplied password that does not base64-decode to the key length of the cipher (16 or 32 bytes) is replaced by a generated key and the call still succeeds, so read the client back if you did not let the server pick. Legacy ciphers keep any non-empty password\n- **Hysteria** — `auth`\n- **mtproto** — `secret`, a FakeTLS secret derived from the fronting domain of the inbound, or from `www.cloudflare.com` when it has none\n- **WireGuard** — `privateKey` and `publicKey` when both are blank, or `publicKey` alone when only a `privateKey` was sent, plus `allowedIPs`: one free `/32` taken from the /24 the existing peers of that inbound already sit in, or from `10.0.0.0/24` when it has none\n\nAccepted on the same body but never generated: `preSharedKey` and `keepAlive` (WireGuard), `adTag` (mtproto).\n\nWireGuard is the only one of these that can fail. Allocation widens the search to the containing /16 before giving up with `inbound : wireguard: no free address available in `, and an `allowedIPs` supplied by the caller is validated instead of allocated: `inbound : wireguard: allowedIPs entry overlaps
used by another client` when its range overlaps an address or prefix a different client of that same inbound holds, or `... used by a client on ` when the holder sits on another WireGuard or AmneziaWG inbound. Ranges are compared, not strings, so `10.0.0.9/24` collides with `10.0.0.5/32`; a `0.0.0.0/0` or `::/0` default route claims no address. Allocation likewise skips every address inside a prefix another client holds. The same validation runs on POST /panel/api/clients/{email}/attach, where a client that already carries an address brings it along.\n\nAn `inboundIds` entry that names no existing inbound rejects the whole call before anything is written. Past that, the inbounds are applied concurrently and independently: one that fails no longer stops the others, so a `success:false` response can still have created the client on the rest. Every error names the inbound it came from (`inbound 7: `), and several failures are reported together, one per line. `limitHwid` is applied only when every inbound succeeded, so re-run the call after fixing the failure.', + 'Fields the server fills in when they are omitted — a valid value sent by the caller is never overwritten. Re-adding an email that already exists, with its stored `subId`, reuses the stored `id`, `password`, `auth` and `secret` instead of minting new ones, so the identity stays in sync across its inbounds.\n\n- **VLESS / VMess** — `id`, a fresh UUID\n- **Trojan** — `password`\n- **Shadowsocks** — `password`. On a `2022-blake3-*` inbound a supplied password that does not base64-decode to the key length of the cipher (16 or 32 bytes) is replaced by a generated key and the call still succeeds, so read the client back if you did not let the server pick. Legacy ciphers keep any non-empty password\n- **Hysteria** — `auth`\n- **mtproto** — `secret`, a FakeTLS secret derived from the fronting domain of the inbound, or from `www.cloudflare.com` when it has none\n- **WireGuard** — `privateKey` and `publicKey` when both are blank, or `publicKey` alone when only a `privateKey` was sent, plus `allowedIPs`: one free `/32` taken from the /24 the existing peers of that inbound already sit in, or from `10.0.0.0/24` when it has none\n\nAccepted on the same body but never generated: `preSharedKey` and `keepAlive` (WireGuard), `adTag` (mtproto).\n\nWireGuard is the only one of these that can fail. Allocation widens the search to the containing /16 before giving up with `inbound : wireguard: no free address available in `, and an `allowedIPs` supplied by the caller is validated instead of allocated: `inbound : wireguard: allowedIPs entry overlaps
used by another client` when its range overlaps an address or prefix a different client of that same inbound holds, or `... used by a client on ` when the holder sits on another WireGuard or AmneziaWG inbound. Ranges are compared, not strings, so `10.0.0.9/24` collides with `10.0.0.5/32`; a `/0` entry collides with every address of its family another client holds, and only a `0.0.0.0/0` or `::/0` default route that is already stored (legacy peers) keeps nobody out. Allocation likewise skips every address inside a prefix another client holds. The same validation runs on POST /panel/api/clients/{email}/attach, where a client that already carries an address brings it along.\n\nAn `inboundIds` entry that names no existing inbound rejects the whole call before anything is written. Past that, the inbounds are applied concurrently and independently: one that fails no longer stops the others, so a `success:false` response can still have created the client on the rest. Every error names the inbound it came from (`inbound 7: `), and several failures are reported together, one per line. `limitHwid` is applied only when every inbound succeeded, so re-run the call after fixing the failure.', params: [ { name: 'client', diff --git a/internal/web/service/client_attach_test.go b/internal/web/service/client_attach_test.go index cf53ada50..d1ef9b12e 100644 --- a/internal/web/service/client_attach_test.go +++ b/internal/web/service/client_attach_test.go @@ -1,6 +1,8 @@ package service import ( + "errors" + "strings" "testing" "github.com/mhsanaei/3x-ui/v3/internal/database" @@ -95,3 +97,63 @@ func TestAddressesFitAmneziaWGInbound(t *testing.T) { t.Error("an address genuinely inside the awg inbound's own configured subnet must fit") } } + +// A /0 reported by a node contains every address of the target inbound, and its +// host bits are what the AmneziaWG forwarder reads as the peer's address, so +// Attach refuses it next to another client exactly as it refuses the /32. +func TestAttachRefusesDefaultRouteNextToHeldAddress(t *testing.T) { + attaches := map[string]func(*ClientService, *InboundService, string, int) error{ + "Attach": func(s *ClientService, inboundSvc *InboundService, email string, ibId int) error { + _, err := s.AttachByEmail(inboundSvc, email, []int{ibId}) + return err + }, + "BulkAttach": func(s *ClientService, inboundSvc *InboundService, email string, ibId int) error { + result, _, err := s.BulkAttach(inboundSvc, []string{email}, []int{ibId}) + if err == nil && len(result.Errors) > 0 { + err = errors.New(strings.Join(result.Errors, "; ")) + } + return err + }, + } + for name, attach := range attaches { + for _, protocol := range []model.Protocol{model.WireGuard, model.AmneziaWG} { + t.Run(name+"/"+string(protocol), func(t *testing.T) { + setupConflictDB(t) + node := 1 + seedInboundConflict(t, "own", "0.0.0.0", 51830, protocol, ``, `{"subnetIp":"10.8.1.0","subnetCidr":24,"server":{"subnetIp":"10.8.1.0","subnetCidr":24},"clients":[`+ + `{"email":"local","subId":"s-local","enable":true,"publicKey":"QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUE=","allowedIPs":["10.8.1.2/32"]}]}`) + seedInboundConflictNode(t, "node", "0.0.0.0", 44301, protocol, ``, `{"clients":[`+ + `{"email":"host-bits","subId":"s-host-bits","enable":true,"publicKey":"QkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkI=","allowedIPs":["10.8.1.2/0"]}]}`, &node) + + s := &ClientService{} + inboundSvc := &InboundService{} + var own model.Inbound + for _, tag := range []string{"node", "own"} { + own = model.Inbound{} + if err := database.GetDB().Where("tag = ?", tag).First(&own).Error; err != nil { + t.Fatalf("read seeded %s row: %v", tag, err) + } + clients, err := inboundSvc.GetClients(&own) + if err != nil { + t.Fatalf("GetClients(%s): %v", tag, err) + } + if err := s.SyncInbound(nil, own.Id, clients); err != nil { + t.Fatalf("SyncInbound(%s): %v", tag, err) + } + } + + err := attach(s, inboundSvc, "host-bits", own.Id) + if err == nil || !strings.Contains(err.Error(), "10.8.1.2/0 overlaps 10.8.1.2/32") { + t.Errorf("attach of host-bits (10.8.1.2/0): err = %v, want an overlap with 10.8.1.2/32", err) + } + got, gErr := s.TunnelAllowedIPsByInbound(inboundSvc, "host-bits", []int{own.Id}) + if gErr != nil { + t.Fatalf("TunnelAllowedIPsByInbound: %v", gErr) + } + if _, held := got[own.Id]; held { + t.Errorf("host-bits landed on the master's inbound with %q", got[own.Id]) + } + }) + } + } +} diff --git a/internal/web/service/client_inbound_apply.go b/internal/web/service/client_inbound_apply.go index 08b2abdc0..9da44d104 100644 --- a/internal/web/service/client_inbound_apply.go +++ b/internal/web/service/client_inbound_apply.go @@ -833,7 +833,7 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo } peers = append(peers, oldClients[i].AllowedIPs...) } - if entry, taken := wireguardAllowedIPsOverlap(normalized, peers); taken != "" { + if entry, taken := wireguardAllowedIPsOverlap(withoutKeptDefaultRoutes(normalized, old.AllowedIPs), peers); taken != "" { return false, common.NewError("wireguard: allowedIPs entry", entry, "overlaps", taken, "used by another client") } clients[0].AllowedIPs = normalized diff --git a/internal/web/service/client_wireguard.go b/internal/web/service/client_wireguard.go index 0285f30d3..b7188fae2 100644 --- a/internal/web/service/client_wireguard.go +++ b/internal/web/service/client_wireguard.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "net/netip" + "slices" "strconv" "strings" @@ -188,9 +189,9 @@ func normalizeWireguardAllowedIPs(values []string) ([]string, error) { return out, nil } -// wireguardClaimedPrefix is the masked range an allowedIPs entry claims, as xray -// reads it. A /0 default route claims no tunnel address, as legacy peers carry it. -func wireguardClaimedPrefix(s string) (netip.Prefix, bool) { +// parseWireguardAllowedIP reads an allowedIPs entry unmasked, a bare address as +// a single host. +func parseWireguardAllowedIP(s string) (netip.Prefix, bool) { s = strings.TrimSpace(s) p, err := netip.ParsePrefix(s) if err != nil { @@ -200,14 +201,35 @@ func wireguardClaimedPrefix(s string) (netip.Prefix, bool) { } p = netip.PrefixFrom(a, a.BitLen()) } - if p.Bits() == 0 { + return p, true +} + +// isWireguardDefaultRoute reports whether an allowedIPs entry carries a /0 mask. +func isWireguardDefaultRoute(s string) bool { + p, ok := parseWireguardAllowedIP(s) + return ok && p.Bits() == 0 +} + +// wireguardClaimedPrefix is the range a stored allowedIPs entry keeps other clients +// out of. Only a 0.0.0.0/0 or ::/0 default route claims nothing (legacy peers carry it). +func wireguardClaimedPrefix(s string) (netip.Prefix, bool) { + p, ok := parseWireguardAllowedIP(s) + if !ok { return netip.Prefix{}, false } + if p.Bits() == 0 { + if p.Addr().IsUnspecified() { + return netip.Prefix{}, false + } + // The host bits are stored with it and read back as the peer's tunnel + // address (amneziawg.FirstIPv4 / FirstIPv6), so they are what it claims. + return netip.PrefixFrom(p.Addr(), p.Addr().BitLen()), true + } return p.Masked(), true } -// wireguardAllowedIPsOverlap returns the first entry whose range overlaps a used -// one, and that used entry; xray routes and attributes by containment, not equality. +// wireguardAllowedIPsOverlap returns the first entry whose whole range (a /0 holds its entire +// family) overlaps a used one, and that entry; xray routes by containment, not equality. func wireguardAllowedIPsOverlap(entries, used []string) (entry, taken string) { usedPrefixes := make([]netip.Prefix, len(used)) usedOK := make([]bool, len(used)) @@ -215,10 +237,11 @@ func wireguardAllowedIPsOverlap(entries, used []string) (entry, taken string) { usedPrefixes[i], usedOK[i] = wireguardClaimedPrefix(u) } for _, e := range entries { - ep, ok := wireguardClaimedPrefix(e) + ep, ok := parseWireguardAllowedIP(e) if !ok { continue } + ep = ep.Masked() for i, up := range usedPrefixes { if usedOK[i] && ep.Overlaps(up) { return e, used[i] @@ -228,6 +251,14 @@ func wireguardAllowedIPsOverlap(entries, used []string) (entry, taken string) { return "", "" } +// withoutKeptDefaultRoutes drops the /0 entries stored already holds: an edit that +// echoes a legacy peer's default route claims nothing new and must stay possible. +func withoutKeptDefaultRoutes(entries, stored []string) []string { + return slices.DeleteFunc(slices.Clone(entries), func(e string) bool { + return isWireguardDefaultRoute(e) && slices.Contains(stored, e) + }) +} + // defaultWireguardClients fills in blank WireGuard credentials for newly added // clients: a generated keypair when none was provided, a derived public key when // only a private key was given, and a unique tunnel address allocated from the diff --git a/internal/web/service/client_wireguard_test.go b/internal/web/service/client_wireguard_test.go index 6c723c44d..a437da9ac 100644 --- a/internal/web/service/client_wireguard_test.go +++ b/internal/web/service/client_wireguard_test.go @@ -5,6 +5,7 @@ import ( "strings" "testing" + "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/model" wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard" ) @@ -393,3 +394,89 @@ func TestAllocateWireguardAddressSkipsAddressesInsideUsedPrefixes(t *testing.T) t.Fatalf("got %s, want 10.0.0.4/32: .2 and .3 are both inside the used 10.0.0.2/31", got) } } + +// A /0 that is being written contains every other peer's address, and its host bits +// are read back as the peer's own tunnel address (amneziawg.FirstIPv4). Only a stored +// 0.0.0.0/0 or ::/0 keeps nobody out, as legacy peers carry it. +func TestWireguardSlashZeroIsSubjectToTheOverlapGuard(t *testing.T) { + for _, tt := range []struct { + entry, used string + taken bool + }{ + {"10.8.1.2/0", "10.8.1.2/32", true}, + {"10.8.1.3/0", "10.8.1.2/32", true}, + {"0.0.0.0/0", "10.8.1.2/32", true}, + {"::/0", "fd00::2/128", true}, + {"fd00::2/0", "fd00::2/128", true}, + {"::/0", "10.8.1.2/32", false}, + {"10.8.1.2/32", "10.8.1.2/0", true}, + {"fd00::2/128", "fd00::2/0", true}, + {"10.8.1.2/32", "10.8.1.3/0", false}, + {"10.8.1.2/32", "0.0.0.0/0", false}, + {"0.0.0.0/0", "0.0.0.0/0", false}, + } { + t.Run(tt.entry+" next to "+tt.used, func(t *testing.T) { + if _, taken := wireguardAllowedIPsOverlap([]string{tt.entry}, []string{tt.used}); (taken != "") != tt.taken { + t.Errorf("taken = %q, want a collision: %v", taken, tt.taken) + } + }) + } + + existing := []model.Client{{Email: "victim@wg", AllowedIPs: []string{"10.0.0.2/32"}}} + awgSettings := `{"server":{"subnetIp":"10.0.0.0","subnetCidr":24}}` + for _, entry := range []string{"10.0.0.2/0", "0.0.0.0/0"} { + clients := []model.Client{{Email: "other@wg", AllowedIPs: []string{entry}}} + err := defaultWireguardClients("", existing, clients, []any{map[string]any{}}, nil) + if err == nil || !strings.Contains(err.Error(), entry+" overlaps 10.0.0.2/32") { + t.Errorf("wireguard: %s next to 10.0.0.2/32: err = %v, want an overlap", entry, err) + } + err = defaultAmneziaWGClients(awgSettings, existing, clients, []any{map[string]any{}}, nil) + if err == nil || !strings.Contains(err.Error(), entry+" overlaps 10.0.0.2/32") { + t.Errorf("amneziawg: %s next to 10.0.0.2/32: err = %v, want an overlap", entry, err) + } + } + + got, err := allocateWireguardAddress([]string{"10.0.0.2/0"}, "10.0.0.0/24", true) + if err != nil || got != "10.0.0.3/32" { + t.Errorf("allocated %q (%v) next to a stored 10.0.0.2/0, want 10.0.0.3/32", got, err) + } +} + +// The edit form echoes what the client holds, so a legacy peer's default route has +// to survive a save next to other clients; no edit may bring in a new one. +func TestUpdateInboundClientKeepsButNeverAddsDefaultRoute(t *testing.T) { + setupConflictDB(t) + seedInboundConflict(t, "own-wg", "0.0.0.0", 51830, model.WireGuard, ``, `{"clients":[`+ + `{"email":"legacy","subId":"s-legacy","enable":true,"publicKey":"QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUE=","allowedIPs":["0.0.0.0/0","::/0"]},`+ + `{"email":"victim","subId":"s-victim","enable":true,"publicKey":"QkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkI=","allowedIPs":["10.0.0.2/32"]},`+ + `{"email":"other","subId":"s-other","enable":true,"publicKey":"Q0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0M=","allowedIPs":["10.0.0.3/32"]}]}`) + s, inboundSvc := &ClientService{}, &InboundService{} + var own model.Inbound + if err := database.GetDB().Where("tag = ?", "own-wg").First(&own).Error; err != nil { + t.Fatalf("read seeded row: %v", err) + } + clients, err := inboundSvc.GetClients(&own) + if err != nil { + t.Fatalf("GetClients: %v", err) + } + if err := s.SyncInbound(nil, own.Id, clients); err != nil { + t.Fatalf("SyncInbound: %v", err) + } + save := func(email, allowedIPs string) error { + data := &model.Inbound{Id: own.Id, Settings: `{"clients":[{"email":"` + email + `","subId":"s-` + email + `","enable":true,"allowedIPs":` + allowedIPs + `}]}`} + _, err := s.UpdateInboundClient(inboundSvc, data, email) + return err + } + + if err := save("legacy", `["0.0.0.0/0","::/0"]`); err != nil { + t.Errorf("saving the legacy peer as it is: %v", err) + } + for _, allowedIPs := range []string{`["0.0.0.0/0"]`, `["10.0.0.2/0"]`, `["10.0.0.3/32","0.0.0.0/0"]`} { + if err := save("other", allowedIPs); err == nil || !strings.Contains(err.Error(), "overlaps 10.0.0.2/32") { + t.Errorf("other saved with %s: err = %v, want an overlap with 10.0.0.2/32", allowedIPs, err) + } + } + if err := save("legacy", `["10.0.0.2/0","::/0"]`); err == nil || !strings.Contains(err.Error(), "10.0.0.2/0 overlaps 10.0.0.2/32") { + t.Errorf("the legacy peer took 10.0.0.2/0 next to 10.0.0.2/32: err = %v", err) + } +} From d3df3e9ea7734ee9c3cfefe96a727c199adb3860 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 00:08:35 +0100 Subject: [PATCH 052/125] fix(frontend): keep node-reported values one URL path segment InboundInfoModal put a client's email into /panel/api/clients/ips and clearIps unencoded, and the host mutations did the same with a host group id. Both values reach the master from a node's sync without the panel's own character rules, so "../" and "?" in them chose the route and query of the admin's authenticated POST; the IP-log request fires as soon as the window opens. Encode them as every other client route in the SPA already does. --- frontend/src/api/queries/useHostMutations.ts | 11 +++- .../pages/inbounds/info/InboundInfoModal.tsx | 11 +++- .../test/node-reported-url-segments.test.tsx | 63 +++++++++++++++++++ 3 files changed, 79 insertions(+), 6 deletions(-) create mode 100644 frontend/src/test/node-reported-url-segments.test.tsx diff --git a/frontend/src/api/queries/useHostMutations.ts b/frontend/src/api/queries/useHostMutations.ts index dfac21944..a71697f6d 100644 --- a/frontend/src/api/queries/useHostMutations.ts +++ b/frontend/src/api/queries/useHostMutations.ts @@ -20,14 +20,19 @@ export function useHostMutations() { const updateMut = useMutation({ mutationFn: ({ groupId, payload }: { groupId: string; payload: BulkAddHostValues }) => - HttpUtil.post(`/panel/api/hosts/update/${groupId}`, payload, JSON_HEADERS), + HttpUtil.post( + `/panel/api/hosts/update/${encodeURIComponent(groupId)}`, + payload, + JSON_HEADERS, + ), onSuccess: (msg) => { if (msg?.success) invalidate(); }, }); const removeMut = useMutation({ - mutationFn: (groupId: string) => HttpUtil.post(`/panel/api/hosts/del/${groupId}`), + mutationFn: (groupId: string) => + HttpUtil.post(`/panel/api/hosts/del/${encodeURIComponent(groupId)}`), onSuccess: (msg) => { if (msg?.success) invalidate(); }, @@ -35,7 +40,7 @@ export function useHostMutations() { const setEnableMut = useMutation({ mutationFn: ({ groupId, enable }: { groupId: string; enable: boolean }) => - HttpUtil.post(`/panel/api/hosts/setEnable/${groupId}`, { enable }), + HttpUtil.post(`/panel/api/hosts/setEnable/${encodeURIComponent(groupId)}`, { enable }), onSuccess: (msg) => { if (msg?.success) invalidate(); }, diff --git a/frontend/src/pages/inbounds/info/InboundInfoModal.tsx b/frontend/src/pages/inbounds/info/InboundInfoModal.tsx index 4e5554f82..299bf688d 100644 --- a/frontend/src/pages/inbounds/info/InboundInfoModal.tsx +++ b/frontend/src/pages/inbounds/info/InboundInfoModal.tsx @@ -69,7 +69,9 @@ export default function InboundInfoModal({ if (!clientStats?.email) return; setRefreshing(true); try { - const msg = await HttpUtil.post(`/panel/api/clients/ips/${clientStats.email}`); + const msg = await HttpUtil.post( + `/panel/api/clients/ips/${encodeURIComponent(clientStats.email)}`, + ); if (!msg?.success) { setClientIpsText((msg?.obj as string) || 'No IP record'); setClientIpsArray([]); @@ -101,7 +103,9 @@ export default function InboundInfoModal({ const clearClientIps = useCallback(async () => { if (!clientStats?.email) return; - const msg = await HttpUtil.post(`/panel/api/clients/clearIps/${clientStats.email}`); + const msg = await HttpUtil.post( + `/panel/api/clients/clearIps/${encodeURIComponent(clientStats.email)}`, + ); if (msg?.success) { setClientIpsArray([]); setClientIpsText(t('tgbot.noIpRecord')); @@ -223,7 +227,8 @@ export default function InboundInfoModal({ setClientIpsText(''); if (ipLimitEnable && (clientSet?.limitIp ?? 0) > 0 && stats?.email) { - void HttpUtil.post(`/panel/api/clients/ips/${stats.email}`).then((msg) => { + const email = encodeURIComponent(stats.email); + void HttpUtil.post(`/panel/api/clients/ips/${email}`).then((msg) => { if (!msg?.success) { setClientIpsText((msg?.obj as string) || 'No IP record'); return; diff --git a/frontend/src/test/node-reported-url-segments.test.tsx b/frontend/src/test/node-reported-url-segments.test.tsx new file mode 100644 index 000000000..f1dc24fcd --- /dev/null +++ b/frontend/src/test/node-reported-url-segments.test.tsx @@ -0,0 +1,63 @@ +import type { ReactNode } from 'react'; +import { act, render, renderHook } from '@testing-library/react'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useHostMutations } from '@/api/queries/useHostMutations'; +import { BulkAddHostSchema } from '@/schemas/api/host'; +import { DBInbound } from '@/models/dbinbound'; +import InboundInfoModal from '@/pages/inbounds/info/InboundInfoModal'; +import { makeTestQueryClient } from '@/test/test-utils'; +import { HttpUtil } from '@/utils'; + +// Client emails and host group ids adopted from a node skip the panel's own +// character rules: unencoded, "../" and "?" re-target the admin's POST. +const hostile = '../../setting/update?tgBotEnable=true'; +const encoded = encodeURIComponent(hostile); + +describe('node-reported values stay one URL path segment', () => { + const post = vi.mocked(HttpUtil.post); + + beforeEach(() => { + post.mockClear(); + }); + + it('encodes the client email in the inbound info IP-log request', () => { + const dbInbound = new DBInbound({ + id: 2, + protocol: 'http', + port: 44301, + settings: JSON.stringify({ clients: [{ email: hostile, enable: true, limitIp: 1 }] }), + streamSettings: JSON.stringify({ network: 'tcp', security: 'none' }), + sniffing: '', + clientStats: [{ email: hostile, enable: true, up: 1, down: 1, total: 0, expiryTime: 0 }], + }); + + render( {}} dbInbound={dbInbound} ipLimitEnable />); + + expect(post).toHaveBeenCalledWith(`/panel/api/clients/ips/${encoded}`); + }); + + it('encodes the host group id in the host mutations', async () => { + const queryClient = makeTestQueryClient(); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + const { result } = renderHook(() => useHostMutations(), { wrapper }); + + await act(async () => { + await result.current.update( + hostile, + BulkAddHostSchema.parse({ inboundIds: [1], remark: 'cdn' }), + ); + await result.current.setEnable(hostile, false); + await result.current.remove(hostile); + }); + + expect(post.mock.calls.map(([url]) => url)).toEqual([ + `/panel/api/hosts/update/${encoded}`, + `/panel/api/hosts/setEnable/${encoded}`, + `/panel/api/hosts/del/${encoded}`, + ]); + }); +}); From 05abd00bf654ed1d27ba2726f90ea266896443fd Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 00:10:23 +0100 Subject: [PATCH 053/125] fix(sub): encode the link host and the VLESS id in share links joinHostPort wrote an endpoint address into the authority of every URL share link unencoded, and genVlessLink alone skipped encodeUserinfo for the client id. Both can come from a node's sync (an externalProxy dest, an adopted host row, the client id), the finished link is never re-parsed, and the raw subscription is one link per line: a line break in either added lines of the node's choosing to the master's subscription, and an id carrying "@" moved the client's VLESS links on other inbounds to another server. Whitespace, control characters and URI delimiters in the host are now percent-encoded, which leaves host names, IP literals and zone ids unchanged, and the VLESS id is encoded as userinfo like every other protocol's credential. --- internal/sub/endpoint_test.go | 50 +++++++++++++++++++++++++++++++++++ internal/sub/service.go | 20 +++++++++++--- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/internal/sub/endpoint_test.go b/internal/sub/endpoint_test.go index 8983f3df8..ebfa71da7 100644 --- a/internal/sub/endpoint_test.go +++ b/internal/sub/endpoint_test.go @@ -181,3 +181,53 @@ func TestBuildEndpointVmessLinks_HostFinalMask(t *testing.T) { t.Fatalf("vmess fm = %q, want the host mask", obj["fm"]) } } + +// An endpoint address is mirrored from the node as reported: whatever it holds, one +// endpoint stays one line of the subscription body, inside its own link's authority. +func TestSub_EndpointAddressCannotAddLines(t *testing.T) { + seedSubDB(t) + dest, _ := json.Marshal("::1]:443#a\nincy://routing/onadd/e30=\r\nvless://x@[::2") + seedSubInbound(t, "s1", "n", 4490, 1, fmt.Sprintf( + `{"network":"tcp","security":"none","externalProxy":[{"forceTls":"same","dest":%s,"port":443,"remark":""}]}`, dest)) + links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com") + if err != nil { + t.Fatalf("GetSubs: %v", err) + } + want := "vless://11111111-2222-4333-8444-000000004490@" + + "[::1%5D:443%23a%0Aincy:%2F%2Frouting%2Fonadd%2Fe30=%0D%0Avless:%2F%2Fx%40%5B::2]:443" + + "?encryption=none&security=none&type=tcp#n-n%40e" + if len(links) != 1 || links[0] != want { + t.Fatalf("links = %q, want [%q]", links, want) + } +} + +func TestJoinHostPortEscapesNonHostCharacters(t *testing.T) { + for host, want := range map[string]string{ + "cdn.example.com": "cdn.example.com:443", + "203.0.113.5": "203.0.113.5:443", + "[2001:db8::1]": "[2001:db8::1]:443", + "fe80::1%eth0": "[fe80::1%eth0]:443", + "пример.рф": "пример.рф:443", + "a b\tc": "a%20b%09c:443", + "a\u0085b\u2028c": "a%C2%85b%E2%80%A8c:443", + "h?x=1#f": "h%3Fx=1%23f:443", + "u@h/p": "u%40h%2Fp:443", + } { + if got := joinHostPort(host, 443); got != want { + t.Errorf("joinHostPort(%q) = %q, want %q", host, got, want) + } + } +} + +// The VLESS id is the userinfo of its link and is escaped like every other protocol's. +func TestGenVlessLinkEscapesID(t *testing.T) { + in := &model.Inbound{ + Listen: "203.0.113.5", Port: 443, Protocol: model.VLESS, Remark: "n", + Settings: `{"clients":[{"id":"a@b#c\nd","email":"user"}],"decryption":"none"}`, + StreamSettings: `{"network":"tcp","security":"none"}`, + } + got := NewSubService("").genVlessLink(in, "user") + if !strings.HasPrefix(got, "vless://a%40b%23c%0Ad@203.0.113.5:443?") { + t.Fatalf("vless id not escaped: %q", got) + } +} diff --git a/internal/sub/service.go b/internal/sub/service.go index b2aaaeacd..89cc785f0 100644 --- a/internal/sub/service.go +++ b/internal/sub/service.go @@ -1359,7 +1359,7 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string { params, security, func(ep map[string]any, dest string, port int) string { - return fmt.Sprintf("vless://%s@%s", applyVlessRoute(uuid, hostVlessRoute(ep)), joinHostPort(dest, port)) + return fmt.Sprintf("vless://%s@%s", encodeUserinfo(applyVlessRoute(uuid, hostVlessRoute(ep))), joinHostPort(dest, port)) }, func(ep map[string]any) string { return s.endpointRemark(inbound, email, ep, streamNetwork) @@ -1367,7 +1367,7 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string { ) } - link := fmt.Sprintf("vless://%s@%s", uuid, joinHostPort(address, port)) + link := fmt.Sprintf("vless://%s@%s", encodeUserinfo(uuid), joinHostPort(address, port)) return buildLinkWithParams(link, params, s.genRemark(inbound, email, "", streamNetwork)) } @@ -1441,7 +1441,21 @@ func encodeUserinfo(s string) string { // callers don't have to normalize upstream. func joinHostPort(host string, port int) string { host = strings.Trim(host, "[]") - return net.JoinHostPort(host, strconv.Itoa(port)) + return net.JoinHostPort(escapeLinkHost(host), strconv.Itoa(port)) +} + +// escapeLinkHost percent-encodes whitespace, control characters and URI delimiters: a +// node-reported address is never re-parsed, so a raw line break would end the link's line. +func escapeLinkHost(host string) string { + var sb strings.Builder + for _, r := range host { + if r == ' ' || !strconv.IsPrint(r) || strings.ContainsRune("\"#/<>?@[\\]^`{|}", r) { + sb.WriteString(encodeURIComponent(string(r))) + } else { + sb.WriteRune(r) + } + } + return sb.String() } func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) string { From 5e6ffebdf2f828df63d2b1f2898a953a6a138278 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 00:16:04 +0100 Subject: [PATCH 054/125] fix(amneziawg): give each peer a share of the forwarder and UDP relay The embedded interface's TCP forwarder admits 65535 connections in flight for all peers together, and a forwarded request holds its slot until the handshake ends or gVisor gives up on it. One client sending bare SYNs from fresh source ports therefore took every slot and left the other clients' connections unanswered. The UDP relay likewise opened a SOCKS5 association and a host UDP socket for every fresh tunnel source port with no per-peer bound. The forwarder now names the peer on the SYN, refuses a source no peer owns and caps one peer at 4096 connections; the UDP relay caps one peer at 1024 sessions and drops the datagrams that would need more. --- internal/amneziawgnet/bench_test.go | 2 +- internal/amneziawgnet/device_test.go | 6 +- internal/amneziawgnet/diagnostics_test.go | 2 +- internal/amneziawgnet/forwarder.go | 54 ++++++++++++++++- internal/amneziawgnet/forwarder_test.go | 54 ++++++++++++++++- internal/amneziawgnet/manager.go | 7 ++- internal/amneziawgnet/relay.go | 13 +++- internal/amneziawgnet/relay_e2e_test.go | 2 +- internal/amneziawgnet/relay_test.go | 73 +++++++++++++++++++++++ 9 files changed, 202 insertions(+), 11 deletions(-) diff --git a/internal/amneziawgnet/bench_test.go b/internal/amneziawgnet/bench_test.go index 34b32f965..61bd8e50b 100644 --- a/internal/amneziawgnet/bench_test.go +++ b/internal/amneziawgnet/bench_test.go @@ -134,7 +134,7 @@ func newBenchTunnel(b *testing.B, listenPort int, serverAddr, clientAddr string) if err != nil { b.Fatalf("newUnconfiguredDevice: %v", err) } - AttachTCPForwarder(dev.Stack, func(conn *gonet.TCPConn, _, dest netip.AddrPort) { + AttachTCPForwarder(dev.Stack, ownerBySource, func(conn *gonet.TCPConn, _, dest netip.AddrPort) { defer conn.Close() switch dest.Port() { case benchDiscardPort: diff --git a/internal/amneziawgnet/device_test.go b/internal/amneziawgnet/device_test.go index 6e4f82e62..821cfc095 100644 --- a/internal/amneziawgnet/device_test.go +++ b/internal/amneziawgnet/device_test.go @@ -80,7 +80,7 @@ func TestNewDeviceHandshakeForwarderAndIdentity(t *testing.T) { // purely from the decapsulated packet, not from any routing table. wantDest := netip.MustParseAddrPort("10.201.9.9:9999") - AttachTCPForwarder(dev.Stack, func(conn *gonet.TCPConn, src, dest netip.AddrPort) { + AttachTCPForwarder(dev.Stack, ownerBySource, func(conn *gonet.TCPConn, src, dest netip.AddrPort) { defer conn.Close() peer, ok := idx.Lookup(src.Addr().Unmap()) got <- recovered{email: peer.Email, ok: ok, dest: dest} @@ -278,7 +278,7 @@ func TestNewDeviceHeaderProtectionAndContentPaddingRoundTrip(t *testing.T) { const wantRequest = "hello from client" const wantReply = "hello from server" serverDone := make(chan error, 1) - AttachTCPForwarder(dev.Stack, func(conn *gonet.TCPConn, _, dest netip.AddrPort) { + AttachTCPForwarder(dev.Stack, ownerBySource, func(conn *gonet.TCPConn, _, dest netip.AddrPort) { defer conn.Close() buf := make([]byte, len(wantRequest)) if _, err := io.ReadFull(conn, buf); err != nil { @@ -456,7 +456,7 @@ func TestNewDeviceRandomTrailersAndDisableCookiesRoundTrip(t *testing.T) { const wantRequest = "hello from client, with a trailer" const wantReply = "hello from server, with a trailer" serverDone := make(chan error, 1) - AttachTCPForwarder(dev.Stack, func(conn *gonet.TCPConn, _, dest netip.AddrPort) { + AttachTCPForwarder(dev.Stack, ownerBySource, func(conn *gonet.TCPConn, _, dest netip.AddrPort) { defer conn.Close() buf := make([]byte, len(wantRequest)) if _, err := io.ReadFull(conn, buf); err != nil { diff --git a/internal/amneziawgnet/diagnostics_test.go b/internal/amneziawgnet/diagnostics_test.go index 231ff128b..c87b79b77 100644 --- a/internal/amneziawgnet/diagnostics_test.go +++ b/internal/amneziawgnet/diagnostics_test.go @@ -77,7 +77,7 @@ func TestDiagnoseDeviceReportsListenPortAndPeerState(t *testing.T) { // Registering the forwarder doesn't require any peer to be configured // yet, so this ordering is free; it's Configure's IpcSet that must // never run before the forwarder is registered. - AttachTCPForwarder(dev.Stack, func(conn *gonet.TCPConn, _, _ netip.AddrPort) { + AttachTCPForwarder(dev.Stack, ownerBySource, func(conn *gonet.TCPConn, _, _ netip.AddrPort) { defer conn.Close() io.Copy(io.Discard, conn) }) diff --git a/internal/amneziawgnet/forwarder.go b/internal/amneziawgnet/forwarder.go index b86700f65..8d8e26691 100644 --- a/internal/amneziawgnet/forwarder.go +++ b/internal/amneziawgnet/forwarder.go @@ -2,6 +2,7 @@ package amneziawgnet import ( "net/netip" + "sync" "gvisor.dev/gvisor/pkg/tcpip/adapters/gonet" "gvisor.dev/gvisor/pkg/tcpip/stack" @@ -9,6 +10,42 @@ import ( "gvisor.dev/gvisor/pkg/waiter" ) +// One datagram or bare SYN from a fresh source port buys a flow its sockets, goroutine and +// buffers, so each peer gets a share of an interface instead of the whole of it. +const ( + maxTCPFlowsPerPeer = 4096 + maxUDPSessionsPerPeer = 1024 +) + +// peerSlots counts the flows each peer holds; every successful acquire needs one release. +type peerSlots struct { + mu sync.Mutex + limit int + held map[string]int +} + +func newPeerSlots(limit int) *peerSlots { + return &peerSlots{limit: limit, held: map[string]int{}} +} + +func (s *peerSlots) acquire(peer string) bool { + s.mu.Lock() + defer s.mu.Unlock() + if s.held[peer] >= s.limit { + return false + } + s.held[peer]++ + return true +} + +func (s *peerSlots) release(peer string) { + s.mu.Lock() + defer s.mu.Unlock() + if s.held[peer]--; s.held[peer] <= 0 { + delete(s.held, peer) + } +} + // AttachTCPForwarder attaches a TCP forwarder to gstack in promiscuous + // spoofing mode, so it accepts connections addressed to any destination -- // not just the stack's own configured local address -- and hands the @@ -22,12 +59,27 @@ import ( // Handlers must identify the peer by src, never conn.RemoteAddr(): that asks // the live endpoint and is nil once a RST is processed, possibly before the call. // +// owner names the peer a tunnel source belongs to; it is asked on the SYN, before anything +// is allocated, and refuses a source nobody owns or one whose owner holds its full share. +// // Adapted from xtls/xray-core's proxy/wireguard/tun.go createForwarder (MIT). -func AttachTCPForwarder(gstack *stack.Stack, handler func(conn *gonet.TCPConn, src, dest netip.AddrPort)) { +func AttachTCPForwarder(gstack *stack.Stack, owner func(src netip.Addr) (string, bool), handler func(conn *gonet.TCPConn, src, dest netip.AddrPort)) { enablePromiscuousRouting(gstack) + slots := newPeerSlots(maxTCPFlowsPerPeer) fwd := tcp.NewForwarder(gstack, 0, 65535, func(r *tcp.ForwarderRequest) { + peer, ok := owner(addrFromTcpip(r.ID().RemoteAddress).Unmap()) + if !ok { + r.Complete(true) + return + } + // CreateEndpoint holds the request until the handshake ends, so a bare SYN costs a slot. + if !slots.acquire(peer) { + r.Complete(false) + return + } go func(r *tcp.ForwarderRequest) { + defer slots.release(peer) var wq waiter.Queue id := r.ID() diff --git a/internal/amneziawgnet/forwarder_test.go b/internal/amneziawgnet/forwarder_test.go index 66f48561a..18b575a45 100644 --- a/internal/amneziawgnet/forwarder_test.go +++ b/internal/amneziawgnet/forwarder_test.go @@ -65,7 +65,7 @@ func TestTCPForwarderHandsOverSourceOfAnAlreadyResetConnection(t *testing.T) { } accepted, resetSent := make(chan struct{}), make(chan struct{}) got := make(chan handedOver, 1) - AttachTCPForwarder(gstack, func(conn *gonet.TCPConn, src, dest netip.AddrPort) { + AttachTCPForwarder(gstack, ownerBySource, func(conn *gonet.TCPConn, src, dest netip.AddrPort) { defer conn.Close() close(accepted) <-resetSent @@ -108,3 +108,55 @@ func TestTCPForwarderHandsOverSourceOfAnAlreadyResetConnection(t *testing.T) { t.Fatal("handler never saw the reset") } } + +// ownerBySource counts every tunnel source address as a peer of its own. +func ownerBySource(src netip.Addr) (string, bool) { + return src.String(), true +} + +// The forwarder's in-flight limit is shared by the interface, so one peer's bare SYNs used +// to take every slot and leave the other clients' connections unanswered. +func TestTCPForwarderPeerCannotStarveOthers(t *testing.T) { + tun, gstack, err := createNetTUNWithStack([]netip.Addr{netip.MustParseAddr("10.8.1.1")}, 1420) + if err != nil { + t.Fatalf("createNetTUNWithStack: %v", err) + } + defer tun.Close() + + flooderRange := netip.MustParsePrefix("10.8.2.0/24") + AttachTCPForwarder(gstack, func(src netip.Addr) (string, bool) { + if flooderRange.Contains(src) { + return "flooder", true + } + return "other", true + }, func(conn *gonet.TCPConn, _, _ netip.AddrPort) { conn.Close() }) + + other := netip.MustParseAddrPort("10.8.1.3:40000") + answered := make(chan struct{}) + go func() { + bufs, sizes := [][]byte{make([]byte, 2048)}, []int{0} + for { + if _, err := tun.Read(bufs, sizes, 0); err != nil { + return + } + reply := header.IPv4(bufs[0][:sizes[0]]) + seg := header.TCP(reply.Payload()) + if reply.DestinationAddress() == tcpip.AddrFrom4(other.Addr().As4()) && seg.Flags() == header.TCPFlagSyn|header.TCPFlagAck { + close(answered) + return + } + } + }() + + dst := netip.MustParseAddrPort("10.8.1.1:80") + for n := range 70000 { + src := netip.AddrPortFrom(netip.AddrFrom4([4]byte{10, 8, 2, byte(n)}), uint16(1024+n/256)) + injectFromPeer(t, tun, rawTCPSegment(src, dst, 1000, 0, header.TCPFlagSyn)) + } + injectFromPeer(t, tun, rawTCPSegment(other, dst, 1000, 0, header.TCPFlagSyn)) + select { + case <-answered: + case <-time.After(5 * time.Second): + t.Fatalf("no SYN-ACK for %s; dropped at the in-flight limit: %d", other, gstack.Stats().TCP.ForwardMaxInFlightDrop.Value()) + } +} diff --git a/internal/amneziawgnet/manager.go b/internal/amneziawgnet/manager.go index 898741b0d..30187bd88 100644 --- a/internal/amneziawgnet/manager.go +++ b/internal/amneziawgnet/manager.go @@ -62,6 +62,11 @@ func (m *managed) lookupPeer(addr netip.Addr) (amneziawg.Peer, bool) { return peers.Lookup(addr) } +func (m *managed) peerEmail(addr netip.Addr) (string, bool) { + peer, ok := m.lookupPeer(addr) + return peer.Email, ok +} + func (m *managed) handleUDP(src, dst netip.AddrPort, payload []byte) { peer, ok := m.lookupPeer(src.Addr()) if !ok { @@ -227,7 +232,7 @@ func (m *Manager) ensureLocked(d Desired) error { structFP: structFP, } next.peers.Store(NewPeerIndex(inst.Peers)) - AttachTCPForwarder(dev.Stack, func(conn *gonet.TCPConn, src, dest netip.AddrPort) { + AttachTCPForwarder(dev.Stack, next.peerEmail, func(conn *gonet.TCPConn, src, dest netip.AddrPort) { // Reload for every connection: in-place reconfiguration swaps the peer // index without reattaching handlers and may hold the lifecycle lock. peer, ok := next.lookupPeer(src.Addr().Unmap()) diff --git a/internal/amneziawgnet/relay.go b/internal/amneziawgnet/relay.go index 45b110c8b..f1e76f916 100644 --- a/internal/amneziawgnet/relay.go +++ b/internal/amneziawgnet/relay.go @@ -386,11 +386,12 @@ type UDPRelay struct { // session map: src.String() would allocate on every relayed datagram. mu sync.Mutex sessions map[netip.AddrPort]*socks5UDPSession + slots *peerSlots } // NewUDPRelay creates a UDPRelay for one embedded AmneziaWG Device's stack. func NewUDPRelay(relay SocksRelay, gstack *stack.Stack) *UDPRelay { - return &UDPRelay{relay: relay, gstack: gstack, sessions: map[netip.AddrPort]*socks5UDPSession{}} + return &UDPRelay{relay: relay, gstack: gstack, sessions: map[netip.AddrPort]*socks5UDPSession{}, slots: newPeerSlots(maxUDPSessionsPerPeer)} } // Handle relays one packet from src (the peer's tunnel-internal source) to @@ -404,8 +405,12 @@ func (u *UDPRelay) Handle(src, dst netip.AddrPort, email string, payload []byte) u.mu.Unlock() if !ok { + if !u.slots.acquire(email) { + return + } fresh, err := newSocks5UDPSession(u.relay.Addr, email, u.relay.Password) if err != nil { + u.slots.release(email) logger.Warningf("amneziawgnet: UDPRelay: SOCKS5 associate for %q: %v", email, err) return } @@ -415,12 +420,16 @@ func (u *UDPRelay) Handle(src, dst netip.AddrPort, email string, payload []byte) if existing, dup := u.sessions[src]; dup { u.mu.Unlock() fresh.Close() + u.slots.release(email) sess = existing } else { u.sessions[src] = fresh u.mu.Unlock() sess = fresh - go u.pump(src, fresh) + go func() { + defer u.slots.release(email) + u.pump(src, fresh) + }() } } if err := sess.sendTo(dst, payload); err != nil { diff --git a/internal/amneziawgnet/relay_e2e_test.go b/internal/amneziawgnet/relay_e2e_test.go index 01a715be5..b1a00a3d9 100644 --- a/internal/amneziawgnet/relay_e2e_test.go +++ b/internal/amneziawgnet/relay_e2e_test.go @@ -161,7 +161,7 @@ func TestSocksRelayAgainstRealXray(t *testing.T) { udpRelay := NewUDPRelay(relay, dev.Stack) defer udpRelay.Close() - AttachTCPForwarder(dev.Stack, func(conn *gonet.TCPConn, src, dest netip.AddrPort) { + AttachTCPForwarder(dev.Stack, ownerBySource, func(conn *gonet.TCPConn, src, dest netip.AddrPort) { peer, ok := idx.Lookup(src.Addr().Unmap()) if !ok { conn.Close() diff --git a/internal/amneziawgnet/relay_test.go b/internal/amneziawgnet/relay_test.go index f5adb8475..52ad1d33c 100644 --- a/internal/amneziawgnet/relay_test.go +++ b/internal/amneziawgnet/relay_test.go @@ -297,3 +297,76 @@ func TestNewSocks5UDPSessionGivesUpOnSilentServer(t *testing.T) { t.Fatal("newSocks5UDPSession still blocked 2s past the associate deadline: a silent SOCKS5 server parks the tunnel's delivery path") } } + +// startFakeSocks5UDP serves just enough no-auth SOCKS5 for UDP ASSOCIATE to +// succeed, pointing every association at one UDP socket nobody reads. +func startFakeSocks5UDP(t *testing.T) string { + t.Helper() + sink, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + if err != nil { + t.Fatalf("listen udp: %v", err) + } + t.Cleanup(func() { _ = sink.Close() }) + port := sink.LocalAddr().(*net.UDPAddr).Port + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen tcp: %v", err) + } + t.Cleanup(func() { _ = ln.Close() }) + go func() { + for { + c, err := ln.Accept() + if err != nil { + return + } + go func() { + defer c.Close() + var greeting [4]byte + var associate [10]byte + if _, err := io.ReadFull(c, greeting[:]); err != nil { + return + } + _, _ = c.Write([]byte{0x05, 0x00}) + if _, err := io.ReadFull(c, associate[:]); err != nil { + return + } + _, _ = c.Write([]byte{0x05, 0x00, 0x00, 0x01, 127, 0, 0, 1, byte(port >> 8), byte(port)}) + _, _ = io.Copy(io.Discard, c) + }() + } + }() + return ln.Addr().String() +} + +// Each fresh tunnel source port bought a SOCKS5 association and a UDP socket with no +// per-peer bound, so one client could take the host's UDP ports from everyone. +func TestUDPRelayCapsSessionsPerPeer(t *testing.T) { + relay := NewUDPRelay(SocksRelay{Addr: startFakeSocks5UDP(t), Password: "x"}, nil) + defer relay.Close() + dst := netip.MustParseAddrPort("192.0.2.1:53") + + for n := 1; n < maxUDPSessionsPerPeer; n++ { + relay.slots.acquire("greedy") + } + relay.Handle(netip.MustParseAddrPort("10.8.1.5:1000"), dst, "greedy", []byte("x")) + relay.Handle(netip.MustParseAddrPort("10.8.1.5:1001"), dst, "greedy", []byte("x")) + relay.Handle(netip.MustParseAddrPort("10.8.1.6:1000"), dst, "modest", []byte("x")) + + relay.mu.Lock() + _, last := relay.sessions[netip.MustParseAddrPort("10.8.1.5:1000")] + _, excess := relay.sessions[netip.MustParseAddrPort("10.8.1.5:1001")] + _, other := relay.sessions[netip.MustParseAddrPort("10.8.1.6:1000")] + relay.mu.Unlock() + if !last || excess || !other { + t.Fatalf("sessions: greedy's last allowed = %v, greedy's excess = %v, modest's = %v; want true, false, true", last, excess, other) + } + + relay.Close() + deadline := time.Now().Add(2 * time.Second) + for !relay.slots.acquire("greedy") { + if time.Now().After(deadline) { + t.Fatal("closing greedy's session never released its slot") + } + time.Sleep(10 * time.Millisecond) + } +} From 1810996db6f2e406d430f36cb991da0945136c94 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 00:17:33 +0100 Subject: [PATCH 055/125] fix(amneziawg): stop the TUN handoff from parking a peer's receiver stackTun.WriteNotify runs on whichever goroutine made the stack emit a packet; for echo replies and RSTs that is amneziawg-go's per-peer receiver. It waited without bound for room in the outbound queue, whose reader waits on that same receiver while the device rebinds, so a peer that flooded the stack while nothing drained the queue deadlocked the next reconfiguration of the interface. The handoff now tries once, then waits at most 5 ms and drops the packet, as channel.Endpoint already does when its own queue is full. --- internal/amneziawgnet/netstack.go | 16 +++++++++ internal/amneziawgnet/netstack_test.go | 46 ++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/internal/amneziawgnet/netstack.go b/internal/amneziawgnet/netstack.go index b05467f53..24f1937fc 100644 --- a/internal/amneziawgnet/netstack.go +++ b/internal/amneziawgnet/netstack.go @@ -8,6 +8,7 @@ import ( "os" "sync" "syscall" + "time" awgtun "github.com/amnezia-vpn/amneziawg-go/v3/tun" @@ -27,6 +28,10 @@ import ( // 1024 starved simultaneous TCP slow-starts; channel.Endpoint drops silently when full. const tunQueueDepth = 8192 +// tunQueueMaxWait bounds how long WriteNotify parks the goroutine that made the stack +// emit a packet; past it the packet is dropped, as channel.Endpoint drops when full. +const tunQueueMaxWait = 5 * time.Millisecond + // maxReceiveBuffer caps per-connection receive autotuning; gVisor's own 4 MB // cap held a single client upload near 310 Mbit/s at 50 ms RTT. const maxReceiveBuffer = 8 << 20 @@ -191,10 +196,21 @@ func (t *stackTun) WriteNotify() { view := pkt.ToView() pkt.DecRef() + select { + case t.incomingPacket <- view: + return + default: + } + // Echo replies and RSTs are emitted on amneziawg-go's per-peer receiver, which the + // reader of this queue itself waits on during a rebind: never park here for good. + timer := time.NewTimer(tunQueueMaxWait) + defer timer.Stop() // Select against done so racing dispatch abandons packet on close // without blocking Close or panicking on closed channel. select { case t.incomingPacket <- view: + case <-timer.C: + view.Release() case <-t.done: view.Release() } diff --git a/internal/amneziawgnet/netstack_test.go b/internal/amneziawgnet/netstack_test.go index f50ae42fc..bcda75d41 100644 --- a/internal/amneziawgnet/netstack_test.go +++ b/internal/amneziawgnet/netstack_test.go @@ -1,9 +1,14 @@ package amneziawgnet import ( + "net/netip" "testing" + "time" "gvisor.dev/gvisor/pkg/buffer" + "gvisor.dev/gvisor/pkg/tcpip" + "gvisor.dev/gvisor/pkg/tcpip/checksum" + "gvisor.dev/gvisor/pkg/tcpip/header" "gvisor.dev/gvisor/pkg/tcpip/link/channel" ) @@ -128,3 +133,44 @@ func TestStackTunReadReturnsViewsToPool(t *testing.T) { t.Fatalf("Read allocates %v times per packet, want <=1: drained views are not being released", allocs) } } + +// TestStackTunWriteSurvivesFullOutboundQueue: Write runs on amneziawg-go's per-peer +// receiver, so a reply the stack cannot queue must be dropped rather than wait for Read. +func TestStackTunWriteSurvivesFullOutboundQueue(t *testing.T) { + tun, _, err := createNetTUNWithStack([]netip.Addr{netip.MustParseAddr("10.9.0.1")}, 1420) + if err != nil { + t.Fatalf("createNetTUNWithStack: %v", err) + } + defer tun.Close() + + ping := make([]byte, header.IPv4MinimumSize+header.ICMPv4MinimumSize) + ip := header.IPv4(ping) + ip.Encode(&header.IPv4Fields{ + TotalLength: uint16(len(ping)), + TTL: 64, + Protocol: uint8(header.ICMPv4ProtocolNumber), + SrcAddr: tcpip.AddrFrom4([4]byte{10, 9, 0, 2}), + DstAddr: tcpip.AddrFrom4([4]byte{10, 9, 0, 1}), + }) + ip.SetChecksum(^ip.CalculateChecksum()) + icmp := header.ICMPv4(ping[header.IPv4MinimumSize:]) + icmp.SetType(header.ICMPv4Echo) + icmp.SetChecksum(^checksum.Checksum(icmp, 0)) + + // Nobody calls Read, so the echo replies fill incomingPacket and overflow it. + done := make(chan struct{}) + go func() { + defer close(done) + for range tunQueueDepth + 8 { + _, _ = tun.Write([][]byte{ping}, 0) + } + }() + select { + case <-done: + case <-time.After(10 * time.Second): + t.Fatal("Write is still waiting for room in the outbound queue: the receiver would deadlock a rebind") + } + if got := len(tun.(*stackTun).incomingPacket); got != tunQueueDepth { + t.Fatalf("incomingPacket holds %d replies, want %d: the queue never filled", got, tunQueueDepth) + } +} From bc2c64d901c28b994bc337a4390c18b7cc99876a Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 00:19:34 +0100 Subject: [PATCH 056/125] fix(amneziawg): bound the egress relay's pending tunnel DNS lookups relayUDP started a goroutine for every domain-addressed datagram and each lookup opened its own netstack UDP endpoint, so the lookups in flight were bounded only by the client's send rate: one proxy user could grow the panel by gigabytes and exhaust the outbound stack's ports, stalling every user of that outbound. One association may now have 32 datagrams waiting on DNS and drops the rest, a cached name is delivered from the reader loop without a lookup, and resolveTunnelVia takes one of 1024 process-wide slots before it opens an endpoint, which also covers the TCP CONNECT path. --- internal/amneziawgnet/dns.go | 41 +++++-- internal/amneziawgnet/egress.go | 17 ++- internal/amneziawgnet/egress_domain_test.go | 114 ++++++++++++++++++++ 3 files changed, 162 insertions(+), 10 deletions(-) diff --git a/internal/amneziawgnet/dns.go b/internal/amneziawgnet/dns.go index 15f65414a..b70bcbbfa 100644 --- a/internal/amneziawgnet/dns.go +++ b/internal/amneziawgnet/dns.go @@ -71,26 +71,49 @@ var tunnelDNSCache = struct { const ( tunnelDNSCacheTTL = 60 * time.Second tunnelDNSCacheMaxSize = 1024 + tunnelDNSMaxInFlight = 1024 ) +// tunnelDNSSlots caps lookups in flight across all outbounds: each one holds a +// netstack port until the resolver answers or the lookup times out. +var tunnelDNSSlots = make(chan struct{}, tunnelDNSMaxInFlight) + // dnsCacheKey computes cache key scoped by outbound tag, server, and host. func dnsCacheKey(tag, dnsServer, host string) string { return tag + "|" + dnsServer + "|" + host } -func resolveTunnelVia(ctx context.Context, dev *Device, tag string, dnsServer string, host string) (netip.Addr, error) { - normDNS := amneziawg.NormalizeDNSServer(dnsServer) - if normDNS == "" { - normDNS = defaultDNSFor(dev.LocalAddresses()) +// tunnelDNSServerFor normalizes dnsServer, empty meaning the family default. +func tunnelDNSServerFor(dev *Device, dnsServer string) string { + if normDNS := amneziawg.NormalizeDNSServer(dnsServer); normDNS != "" { + return normDNS } + return defaultDNSFor(dev.LocalAddresses()) +} + +// cachedTunnelAddr returns host's unexpired cache entry; it never queries. +func cachedTunnelAddr(dev *Device, tag string, dnsServer string, host string) (netip.Addr, bool) { + key := dnsCacheKey(tag, tunnelDNSServerFor(dev, dnsServer), host) + tunnelDNSCache.mu.Lock() + defer tunnelDNSCache.mu.Unlock() + e, ok := tunnelDNSCache.m[key] + return e.addr, ok && time.Now().Before(e.exp) +} + +func resolveTunnelVia(ctx context.Context, dev *Device, tag string, dnsServer string, host string) (netip.Addr, error) { + if addr, ok := cachedTunnelAddr(dev, tag, dnsServer, host); ok { + return addr, nil + } + normDNS := tunnelDNSServerFor(dev, dnsServer) key := dnsCacheKey(tag, normDNS, host) now := time.Now() - tunnelDNSCache.mu.Lock() - if e, ok := tunnelDNSCache.m[key]; ok && now.Before(e.exp) { - tunnelDNSCache.mu.Unlock() - return e.addr, nil + + select { + case tunnelDNSSlots <- struct{}{}: + defer func() { <-tunnelDNSSlots }() + case <-ctx.Done(): + return netip.Addr{}, fmt.Errorf("dns lookup %q: too many lookups in flight: %w", host, ctx.Err()) } - tunnelDNSCache.mu.Unlock() server, err := netip.ParseAddrPort(normDNS) if err != nil { diff --git a/internal/amneziawgnet/egress.go b/internal/amneziawgnet/egress.go index ab7816696..0507f447e 100644 --- a/internal/amneziawgnet/egress.go +++ b/internal/amneziawgnet/egress.go @@ -529,6 +529,10 @@ func (b *egressUDPBudget) release(sess *egressUDPSession) { b.mu.Unlock() } +// maxPendingUDPResolves caps the domain datagrams one association may have +// waiting on tunnel DNS; each holds a goroutine and a copy of the datagram. +const maxPendingUDPResolves = 32 + // relayUDP answers the associate request and relays datagrams to // per-destination tunnel endpoints until the control connection closes. func (s *socks5EgressServer) relayUDP(dev *Device, tag string, ctl udpControl, _ socksTarget) { @@ -551,6 +555,7 @@ func (s *socks5EgressServer) relayUDP(dev *Device, tag string, ctl udpControl, _ } sessions := &udpEgressSessions{m: map[netip.AddrPort]*egressUDPSession{}} + pendingResolves := make(chan struct{}, maxPendingUDPResolves) // Reader: strip per-datagram SOCKS5 headers and forward into the tunnel; // only the associated client's address is accepted. @@ -580,10 +585,20 @@ func (s *socks5EgressServer) relayUDP(dev *Device, tag string, ctl udpControl, _ if perr != nil { continue } + dnsSrv := s.currentDNSServer(tag) + if daddr, ok := cachedTunnelAddr(dev, tag, dnsSrv, name); ok { + s.deliverUDPDatagram(dev, tag, udpConn, client, sessions, netip.AddrPortFrom(daddr, port), data[hdrLen:]) + continue + } + select { + case pendingResolves <- struct{}{}: + default: + continue // enough lookups pending; UDP may drop + } // Resolve off reader loop so slow tunnel DNS lookups do not // stall other destinations on this association. go func(client netip.AddrPort, name string, port uint16, hdrLen int, datagram []byte) { - dnsSrv := s.currentDNSServer(tag) + defer func() { <-pendingResolves }() rctx, rcancel := context.WithTimeout(context.Background(), tunnelResolveTimeout) daddr, rerr := resolveTunnelVia(rctx, dev, tag, dnsSrv, name) rcancel() diff --git a/internal/amneziawgnet/egress_domain_test.go b/internal/amneziawgnet/egress_domain_test.go index f88dcab07..164cc598d 100644 --- a/internal/amneziawgnet/egress_domain_test.go +++ b/internal/amneziawgnet/egress_domain_test.go @@ -2,11 +2,14 @@ package amneziawgnet import ( "bytes" + "context" "encoding/binary" + "errors" "fmt" "io" "net" "net/netip" + "runtime" "strconv" "strings" "testing" @@ -643,6 +646,117 @@ func TestEgressUDPDatagramDomainInterleavedClients(t *testing.T) { } } +// TestEgressUDPDatagramDomainFloodBoundsPendingLookups ensures datagrams to a +// name whose lookup never completes do not pin a goroutine and a port each. +func TestEgressUDPDatagramDomainFloodBoundsPendingLookups(t *testing.T) { + tun := newPairedTunnelForTest(t) + registerEgressDeviceForTest(t, tun.client) + srv := GetEgressServer() + prev := srv.currentDNSServer() + srv.SetDNSServer(net.JoinHostPort(tun.serverIP.String(), "53")) + t.Cleanup(func() { srv.SetDNSServer(prev) }) + resetTunnelDNSCacheForTest() + + silentDNS, err := gonet.DialUDP(tun.server.Stack, &tcpip.FullAddress{NIC: 1, Port: 53}, nil, ipv4.ProtocolNumber) + if err != nil { + t.Fatal(err) + } + defer silentDNS.Close() + + ctl, err := (&net.Dialer{Timeout: egressTestDialTimeout}).Dial("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(srv.Port()))) + if err != nil { + t.Fatal(err) + } + defer ctl.Close() + + socksAuth(t, ctl) + if _, err := ctl.Write([]byte{0x05, 0x03, 0x00, 0x01, 0, 0, 0, 0, 0, 0}); err != nil { + t.Fatal(err) + } + reply := make([]byte, 10) + if _, err := io.ReadFull(ctl, reply); err != nil || reply[1] != 0x00 { + t.Fatalf("associate failed: %v %v", err, reply) + } + bindPort := binary.BigEndian.Uint16(reply[8:10]) + + udp, err := net.DialUDP("udp", nil, &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: int(bindPort)}) + if err != nil { + t.Fatal(err) + } + defer udp.Close() + + name := "never-answers.internal" + dgram := make([]byte, 0, 5+len(name)+2+1) + dgram = append(dgram, 0x00, 0x00, 0x00, 0x03, byte(len(name))) + dgram = append(dgram, name...) + dgram = append(dgram, 0x27, 0x0f, 'x') + + const flood = 2000 + before := runtime.NumGoroutine() + for i := 0; i < flood; i++ { + if _, err := udp.Write(dgram); err != nil { + t.Fatal(err) + } + if i%50 == 49 { + time.Sleep(time.Millisecond) + } + } + time.Sleep(100 * time.Millisecond) + if grown := runtime.NumGoroutine() - before; grown > maxPendingUDPResolves+8 { + t.Fatalf("%d datagrams left %d goroutines behind, want at most %d pending lookups", flood, grown, maxPendingUDPResolves) + } + + // A cached name takes no lookup slot, so it still gets through. + in, err := gonet.DialUDP(tun.server.Stack, &tcpip.FullAddress{NIC: 1, Port: 9999}, nil, ipv4.ProtocolNumber) + if err != nil { + t.Fatal(err) + } + defer in.Close() + cached := "cached.internal" + tunnelDNSCache.mu.Lock() + tunnelDNSCache.m[dnsCacheKey("awg-dom-test", srv.currentDNSServer(), cached)] = tunnelDNSCacheEntry{addr: tun.serverIP, exp: time.Now().Add(time.Hour)} + tunnelDNSCache.mu.Unlock() + dgram = append([]byte{0x00, 0x00, 0x00, 0x03, byte(len(cached))}, cached...) + dgram = append(dgram, 0x27, 0x0f, 'p', 'i', 'n', 'g') + rcv := make([]byte, 64) + var nr int + var rerr error + for attempt := 0; attempt < 3; attempt++ { + if _, err := udp.Write(dgram); err != nil { + t.Fatal(err) + } + in.SetReadDeadline(time.Now().Add(3 * time.Second)) + nr, _, rerr = in.ReadFrom(rcv) + if rerr == nil { + break + } + } + if rerr != nil || string(rcv[:nr]) != "ping" { + t.Fatalf("cached-name datagram behind the flood: payload = %q, err = %v", rcv[:nr], rerr) + } +} + +// TestResolveTunnelViaWaitsForLookupSlot ensures a cache miss opens no tunnel +// endpoint (the nil Stack would panic) while every lookup slot is taken. +func TestResolveTunnelViaWaitsForLookupSlot(t *testing.T) { + resetTunnelDNSCacheForTest() + for i := 0; i < cap(tunnelDNSSlots); i++ { + tunnelDNSSlots <- struct{}{} + } + t.Cleanup(func() { + for i := 0; i < cap(tunnelDNSSlots); i++ { + <-tunnelDNSSlots + } + }) + + dev := &Device{localAddrs: []netip.Addr{netip.MustParseAddr(tunnelTestClientAddr)}} + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + if _, err := resolveTunnelVia(ctx, dev, "awg-dom-test", "", "slots.internal"); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("err = %v, want context.DeadlineExceeded", err) + } +} + func TestDefaultDNSFor(t *testing.T) { v4 := netip.MustParseAddr("10.8.0.2") v6 := netip.MustParseAddr("2001:db8::2") From c0bd5720853827d1dbf4d2a7ee71634d5ad8333d Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 00:21:03 +0100 Subject: [PATCH 057/125] fix(sub): look up a socket child's fallback front on the child's own host projectThroughFallbackMaster found the front of a unix-socket child by scanning every inbound's settings.fallbacks for that socket. A node's inbound settings are mirrored from the node, so a node could name any socket and have its own port, security and external proxies substituted into the subscription links of another host's child. A socket exists only on the host that binds it, so the lookup, and the same scan the inbounds page uses for share links, now considers only inbounds hosted where the child runs. --- frontend/src/pages/inbounds/InboundsPage.tsx | 1 + internal/sub/host_sub_test.go | 51 ++++++++++++++++++++ internal/sub/service.go | 12 ++++- 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/inbounds/InboundsPage.tsx b/frontend/src/pages/inbounds/InboundsPage.tsx index ff1d54691..46b031237 100644 --- a/frontend/src/pages/inbounds/InboundsPage.tsx +++ b/frontend/src/pages/inbounds/InboundsPage.tsx @@ -294,6 +294,7 @@ export default function InboundsPage() { if (!dbInbound?.listen?.startsWith?.('@')) return dbInbound; for (const candidate of dbInbounds) { if (candidate.id === dbInbound.id) continue; + if ((candidate.nodeId ?? 0) !== (dbInbound.nodeId ?? 0)) continue; if (!['trojan', 'vless'].includes(candidate.protocol)) continue; const candStream = coerceInboundJsonField(candidate.streamSettings) as { network?: string }; if (candStream.network !== 'tcp') continue; diff --git a/internal/sub/host_sub_test.go b/internal/sub/host_sub_test.go index e7781e83e..3d674bbc6 100644 --- a/internal/sub/host_sub_test.go +++ b/internal/sub/host_sub_test.go @@ -233,6 +233,57 @@ func TestSub_HostOverFallback(t *testing.T) { } } +// The legacy "@socket" lookup stays on the child's own host: an older inbound +// mirrored from another node that names the same socket is never the master. +func TestSub_LegacyFallbackMasterStaysOnChildHost(t *testing.T) { + foreign, own := 7, 8 + for _, tc := range []struct { + name string + nodeID *int + }{ + {"local child", nil}, + {"node child", &own}, + } { + t.Run(tc.name, func(t *testing.T) { + seedSubDB(t) + db := database.GetDB() + for _, master := range []*model.Inbound{ + { + UserId: 1, Tag: "foreign", Enable: true, Port: 44301, Protocol: model.VLESS, NodeID: &foreign, + Settings: `{"clients":[],"decryption":"none","fallbacks":[{"dest":"@vless-ws"}]}`, + StreamSettings: `{"network":"tcp","security":"none","externalProxy":[{"forceTls":"none","dest":"evil.example","port":8080}]}`, + }, + { + UserId: 1, Tag: "front", Enable: true, Port: 9443, Protocol: model.VLESS, NodeID: tc.nodeID, + Settings: `{"clients":[],"decryption":"none","fallbacks":[{"dest":"@vless-ws"}]}`, + StreamSettings: `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"front.sni"}}`, + }, + } { + if err := db.Create(master).Error; err != nil { + t.Fatalf("seed %s: %v", master.Tag, err) + } + } + child := seedSubInbound(t, "s1", "child", 4441, 1, `{"network":"ws","security":"none"}`) + if err := db.Model(&model.Inbound{}).Where("id = ?", child.Id). + Updates(map[string]any{"listen": "@vless-ws", "node_id": tc.nodeID}).Error; err != nil { + t.Fatalf("place child: %v", err) + } + + links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com") + if err != nil { + t.Fatalf("GetSubs: %v", err) + } + joined := strings.Join(links, "\n") + if strings.Contains(joined, "evil.example") { + t.Fatalf("another node's inbound was used as fallback master: %s", joined) + } + if !strings.Contains(joined, ":9443") || !strings.Contains(joined, "sni=front.sni") { + t.Fatalf("the front on the child's host must be projected: %s", joined) + } + }) + } +} + // #8 — a client only gets hosts for inbounds it is actually on (the // clients ⋈ client_inbounds ⋈ inbounds join), never arbitrary inbounds. func TestSub_HostsResolveViaClientInbounds(t *testing.T) { diff --git a/internal/sub/service.go b/internal/sub/service.go index 89cc785f0..4d6f95e5f 100644 --- a/internal/sub/service.go +++ b/internal/sub/service.go @@ -827,7 +827,7 @@ func (s *SubService) indexStatsBySubId(subId string) { // when applicable. Covers both fallback mechanisms: // - panel-tracked: an inbound_fallbacks row where child_id = inbound.Id // - legacy unix-socket: inbound.Listen begins with "@" and some VLESS/ -// Trojan inbound's settings.fallbacks references that listen address +// Trojan inbound on the same host names that socket in settings.fallbacks // // Returns true when a projection happened; sub services call this before // generating links so a child VLESS-WS bound to 127.0.0.1 emits the @@ -861,7 +861,15 @@ func (s *SubService) projectThroughFallbackMaster(inbound *model.Inbound) bool { if master == nil && len(inbound.Listen) > 0 && inbound.Listen[0] == '@' { var m model.Inbound - if err := db.Model(model.Inbound{}). + // A unix socket exists only on the host that binds it, so only an inbound + // hosted where the child runs (this panel, or the same node) can front it. + q := db.Model(model.Inbound{}) + if inbound.NodeID != nil { + q = q.Where("node_id = ?", *inbound.NodeID) + } else { + q = q.Where("node_id IS NULL") + } + if err := q. Where("JSON_TYPE(settings, '$.fallbacks') = 'array'"). Where("EXISTS (SELECT * FROM json_each(settings, '$.fallbacks') WHERE json_extract(value, '$.dest') = ?)", inbound.Listen). First(&m).Error; err == nil { From 07bbf4788a9e221f5cd6d8b0db745e2faa5e1598 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 00:24:29 +0100 Subject: [PATCH 058/125] fix(node): remove a client a pushed inbound disables from the running core UpdateInbound applies a master's push to the node's running core as the difference between the stored and the pushed user sets, but both sets dropped clients whose traffic row is off, and the same transaction had already stored the pushed flags on those rows. A client the push disabled therefore vanished from both sets, no RemoveUser was sent and the node's Xray kept serving it. The running set is now taken from the stored row's own enable flags, so the client the push switches off is removed from every handler, including a second inbound sharing the same traffic row. --- internal/web/service/inbound.go | 20 +++++++-- .../service/inbound_update_hot_users_test.go | 41 +++++++++++++++++++ 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go index 746747285..8181d62a1 100644 --- a/internal/web/service/inbound.go +++ b/internal/web/service/inbound.go @@ -2281,7 +2281,7 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, } if runtimeInbound != nil && running.Enable { // Unbuildable means unknown running state: the rebuild below covers it. - runningInbound, _ = s.buildInboundForLocalRuntime(tx, &running) + runningInbound, _ = s.buildRunningInbound(tx, &running) } postCommitApply = func() { if runningInbound != nil && applyInboundUserOps(rt, runningInbound, runtimeInbound) { @@ -2438,6 +2438,16 @@ func (s *InboundService) buildInboundForNodePush(tx *gorm.DB, inbound *model.Inb // Strips disabled clients on top of the node payload. Safe only because the // target here is an in-memory Xray/mtg config, not another panel's database. func (s *InboundService) buildInboundForLocalRuntime(tx *gorm.DB, inbound *model.Inbound) (*model.Inbound, error) { + return s.stripDisabledClients(tx, inbound, true) +} + +// The stored row as its handler holds it: its own enable flags only. A stats row +// is shared by every inbound of the client and already holds what this push stored. +func (s *InboundService) buildRunningInbound(tx *gorm.DB, stored *model.Inbound) (*model.Inbound, error) { + return s.stripDisabledClients(tx, stored, false) +} + +func (s *InboundService) stripDisabledClients(tx *gorm.DB, inbound *model.Inbound, byStatsRow bool) (*model.Inbound, error) { built, err := s.buildInboundForNodePush(tx, inbound) if err != nil { return nil, err @@ -2459,9 +2469,11 @@ func (s *InboundService) buildInboundForLocalRuntime(tx *gorm.DB, inbound *model emails = append(emails, email) } } - disabled, err := trafficDisabledEmails(tx, emails) - if err != nil { - return nil, err + var disabled map[string]struct{} + if byStatsRow { + if disabled, err = trafficDisabledEmails(tx, emails); err != nil { + return nil, err + } } trafficIDs := make(map[string]int) diff --git a/internal/web/service/inbound_update_hot_users_test.go b/internal/web/service/inbound_update_hot_users_test.go index 98141213a..329f81480 100644 --- a/internal/web/service/inbound_update_hot_users_test.go +++ b/internal/web/service/inbound_update_hot_users_test.go @@ -133,6 +133,10 @@ func TestNodeInboundPushChangingOnlyClientsKeepsTheHandler(t *testing.T) { if err := (&ClientService{}).SyncInbound(nil, stored.Id, tc.stored); err != nil { t.Fatalf("SyncInbound: %v", err) } + // Every client the API creates has a stats row, and the push rewrites its flag. + for _, c := range tc.stored { + mkTraffic(t, stored.Id, c.Email, 0, 0, 0, 0, c.Enable) + } if tc.enabling { if err := database.GetDB().Model(stored).Update("enable", false).Error; err != nil { t.Fatalf("disable stored inbound: %v", err) @@ -157,6 +161,43 @@ func TestNodeInboundPushChangingOnlyClientsKeepsTheHandler(t *testing.T) { } } +// A client's stats row is shared by all its inbounds, so the push for the first one +// has switched it off by the time the second arrives: that handler holds the user still. +func TestNodeInboundPushDisablingASharedClientRemovesItFromEveryHandler(t *testing.T) { + const ( + idA = "5f2eb9d6-3a2f-4a55-9812-6ea1e2f7a301" + idB = "5f2eb9d6-3a2f-4a55-9812-6ea1e2f7a302" + ) + active := []model.Client{hotUsersClient("a", idA, true), hotUsersClient("b", idB, true)} + bDisabled := []model.Client{hotUsersClient("a", idA, true), hotUsersClient("b", idB, false)} + + setupConflictDB(t) + probe := &inboundOpsProbe{} + useTestRuntimeManager(t).SetLocalRuntimeOverride(probe) + + tags := []string{"hot-users-1", "hot-users-2"} + for i, tag := range tags { + seedInboundConflict(t, tag, "0.0.0.0", 50081+i, model.VLESS, `{"network":"tcp"}`, clientsSettings(t, active)) + if err := (&ClientService{}).SyncInbound(nil, loadInboundByTag(t, tag).Id, active); err != nil { + t.Fatalf("SyncInbound %s: %v", tag, err) + } + } + for _, c := range active { + mkTraffic(t, loadInboundByTag(t, tags[0]).Id, c.Email, 0, 0, 0, 0, true) + } + + for _, tag := range tags { + pushed := *loadInboundByTag(t, tag) + pushed.Settings = clientsSettings(t, bDisabled) + if _, _, err := (&InboundService{FromNodeSync: true}).UpdateInbound(&pushed); err != nil { + t.Fatalf("UpdateInbound %s: %v", tag, err) + } + } + if got, want := probe.recorded(), []string{"remove-user b", "remove-user b"}; !slices.Equal(got, want) { + t.Fatalf("core operations = %q, want %q", got, want) + } +} + // AmneziaWG has its own in-place reconfigure; deleting and re-adding the inbound // rebuilt the device and dropped every peer's session on each pushed client change. func TestNodeAmneziaWGPushReconfiguresTheDeviceInPlace(t *testing.T) { From c3a32c8343b2a4180489b5ffa7f3e01dd21102d1 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 00:30:14 +0100 Subject: [PATCH 059/125] fix(shadowsocks): keep a 2022 inbound with no enabled client locked When every client of a Shadowsocks-2022 inbound was disabled the panel handed the core an empty client list, from which xray-core builds a single-user server keyed by the inbound's server key alone. That key is the first half of every link the panel ever issued, so a client cut off for quota, expiry or by the operator connected again with it. An inbound left without clients now gets a placeholder user whose key is random and held only in memory, in the config and in the running core alike. The core stays multi-user, and disabling the last real client takes the same remove-and-restart path as any other client, so its live sessions end as well. --- internal/web/service/inbound.go | 3 ++ internal/web/service/xray.go | 3 ++ .../web/service/xray_config_clients_test.go | 46 +++++++++++++++++++ internal/xray/api_shadowsocks_test.go | 44 ++++++++++++++++++ internal/xray/shadowsocks2022.go | 41 ++++++++++++++++- 5 files changed, 136 insertions(+), 1 deletion(-) diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go index 8181d62a1..408fa0cd2 100644 --- a/internal/web/service/inbound.go +++ b/internal/web/service/inbound.go @@ -2508,6 +2508,9 @@ func (s *InboundService) stripDisabledClients(tx *gorm.DB, inbound *model.Inboun finalClients = append(finalClients, c) } settings["clients"] = finalClients + if inbound.Protocol == model.Shadowsocks { + xray.LockShadowsocks2022(settings) + } modifiedSettings, err := json.MarshalIndent(settings, "", " ") if err != nil { diff --git a/internal/web/service/xray.go b/internal/web/service/xray.go index 596986c33..462a880a0 100644 --- a/internal/web/service/xray.go +++ b/internal/web/service/xray.go @@ -329,6 +329,9 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) { if mutated { settings["clients"] = finalClients } + if inbound.Protocol == model.Shadowsocks && xray.LockShadowsocks2022(settings) { + mutated = true + } } if inboundCanHostFallbacks(inbound) { diff --git a/internal/web/service/xray_config_clients_test.go b/internal/web/service/xray_config_clients_test.go index 2d3e9437d..585d60b25 100644 --- a/internal/web/service/xray_config_clients_test.go +++ b/internal/web/service/xray_config_clients_test.go @@ -6,6 +6,7 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/xray" ) func seedVlessInbound(t *testing.T, tag string, port int, clients []model.Client) { @@ -179,3 +180,48 @@ func TestGetXrayConfig_KeepsVisionFlowWhereTheCoreRunsIt(t *testing.T) { }) } } + +// With an empty client list xray-core runs a Shadowsocks-2022 inbound as a single-user +// server, and the server key it then accepts alone is the first half of every client's password. +func TestShadowsocks2022WithoutEnabledClientsStaysLocked(t *testing.T) { + setupSettingTestDB(t) + key := "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=" + in := &model.Inbound{ + Tag: "ss-2022", Enable: true, Port: 43110, Protocol: model.Shadowsocks, + Settings: `{"method":"2022-blake3-aes-256-gcm","password":"` + key + `","network":"tcp,udp","clients":[]}`, + } + if err := database.GetDB().Create(in).Error; err != nil { + t.Fatalf("create shadowsocks inbound: %v", err) + } + if err := (&ClientService{}).SyncInbound(nil, in.Id, []model.Client{{Email: "only@x", Password: key, Enable: true}}); err != nil { + t.Fatalf("SyncInbound: %v", err) + } + disableClients(t, "only@x") + + onlyLocked := func(where string, clients any) { + t.Helper() + list, _ := clients.([]any) + if len(list) != 1 { + t.Fatalf("%s: clients = %v, want only the locked placeholder", where, clients) + } + if email, _ := list[0].(map[string]any)["email"].(string); email != xray.LockedShadowsocks2022Email { + t.Fatalf("%s: client %v served, want only the locked placeholder", where, list[0]) + } + } + emitted, _ := emittedClients(t, in.Tag) + onlyLocked("config", emitted) + + var stored model.Inbound + if err := database.GetDB().First(&stored, in.Id).Error; err != nil { + t.Fatalf("read inbound: %v", err) + } + runtimeInbound, err := (&InboundService{}).buildInboundForLocalRuntime(database.GetDB(), &stored) + if err != nil { + t.Fatalf("buildInboundForLocalRuntime: %v", err) + } + var settings map[string]any + if err := json.Unmarshal([]byte(runtimeInbound.Settings), &settings); err != nil { + t.Fatalf("unmarshal runtime settings: %v", err) + } + onlyLocked("local runtime", settings["clients"]) +} diff --git a/internal/xray/api_shadowsocks_test.go b/internal/xray/api_shadowsocks_test.go index 69186a56a..098242d57 100644 --- a/internal/xray/api_shadowsocks_test.go +++ b/internal/xray/api_shadowsocks_test.go @@ -1,9 +1,11 @@ package xray import ( + "encoding/json" "strings" "testing" + "github.com/xtls/xray-core/infra/conf" "github.com/xtls/xray-core/proxy/shadowsocks" "github.com/xtls/xray-core/proxy/shadowsocks_2022" "google.golang.org/protobuf/proto" @@ -147,3 +149,45 @@ func TestBuildUserAccountShadowsocksMissingPassword(t *testing.T) { t.Fatal("expected an error for a shadowsocks user without a password") } } + +// With no client the core builds a single-user Shadowsocks-2022 server keyed by the server +// key alone, which is the first half of every link handed out; the lock must prevent that. +func TestLockShadowsocks2022KeepsTheCoreMultiUser(t *testing.T) { + const key = "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=" + const user = `[{"email":"a@x","password":"` + key + `"}]` + for name, tc := range map[string]struct { + settings string + locks bool + }{ + "empty clients": {`{"method":"2022-blake3-aes-256-gcm","password":"` + key + `","clients":[]}`, true}, + "no clients key": {`{"method":"2022-blake3-aes-256-gcm","password":"` + key + `"}`, true}, + "upper-case method": {`{"method":"2022-BLAKE3-AES-256-GCM","password":"` + key + `","clients":[]}`, true}, + "aes-128": {`{"method":"2022-blake3-aes-128-gcm","password":"MDEyMzQ1Njc4OWFiY2RlZg==","clients":[]}`, true}, + "empty clients hide users": {`{"method":"2022-blake3-aes-256-gcm","password":"` + key + `","clients":[],"users":` + user + `}`, true}, + "one client": {`{"method":"2022-blake3-aes-256-gcm","password":"` + key + `","clients":` + user + `}`, false}, + "users only": {`{"method":"2022-blake3-aes-256-gcm","password":"` + key + `","users":` + user + `}`, false}, + "legacy method": {`{"method":"aes-256-gcm","password":"x","clients":[]}`, false}, + } { + t.Run(name, func(t *testing.T) { + var settings map[string]any + if err := json.Unmarshal([]byte(tc.settings), &settings); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got := LockShadowsocks2022(settings); got != tc.locks { + t.Fatalf("LockShadowsocks2022 = %v, want %v", got, tc.locks) + } + raw, _ := json.Marshal(settings) + var parsed conf.ShadowsocksServerConfig + if err := json.Unmarshal(raw, &parsed); err != nil { + t.Fatalf("unmarshal locked settings: %v", err) + } + built, err := parsed.Build() + if err != nil { + t.Fatalf("core refuses the locked settings: %v", err) + } + if _, singleUser := built.(*shadowsocks_2022.ServerConfig); singleUser { + t.Fatalf("core serves %s to the server key alone", name) + } + }) + } +} diff --git a/internal/xray/shadowsocks2022.go b/internal/xray/shadowsocks2022.go index f710ae454..93aa2db22 100644 --- a/internal/xray/shadowsocks2022.go +++ b/internal/xray/shadowsocks2022.go @@ -1,6 +1,12 @@ package xray -import "github.com/xtls/xray-core/proxy/shadowsocks_2022" +import ( + "crypto/rand" + "encoding/base64" + "sync" + + "github.com/xtls/xray-core/proxy/shadowsocks_2022" +) // Shadowsocks2022KeyBytes is the key size a Shadowsocks-2022 method takes, or 0 // for any other method, whose password is free-form. @@ -25,3 +31,36 @@ func Shadowsocks2022NoMultiUser(method string) bool { cipher, err := shadowsocks_2022.GetCipherMethod(method) return err == nil && cipher.IsChaCha } + +// LockedShadowsocks2022Email names the placeholder user; the panel refuses "/" in a +// client email, so it never collides with a real one. +const LockedShadowsocks2022Email = "/locked" + +var lockedShadowsocks2022Key = sync.OnceValue(func() []byte { + key := make([]byte, 32) + _, _ = rand.Read(key) + return key +}) + +// LockShadowsocks2022 gives a Shadowsocks-2022 inbound left with no client a user whose key +// no link carries: with none, the core serves the inbound to its server key alone. +func LockShadowsocks2022(settings map[string]any) bool { + method, _ := settings["method"].(string) + n := Shadowsocks2022KeyBytes(method) + if n == 0 { + return false + } + // The core reads clients over users, so users count only when clients is absent. + users, hasClients := settings["clients"] + if !hasClients { + users = settings["users"] + } + if list, _ := users.([]any); len(list) > 0 { + return false + } + settings["clients"] = []any{map[string]any{ + "email": LockedShadowsocks2022Email, + "password": base64.StdEncoding.EncodeToString(lockedShadowsocks2022Key()[:n]), + }} + return true +} From 776ca554bdfa53a5803fba805a1056869b11d827 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 00:31:59 +0100 Subject: [PATCH 060/125] fix(server): keep only the requested tail while reading the access log GetXrayLogs held one entry for every line of Xray's access log and cut the list to the requested count only at EOF, so the panel's memory grew with the file. Xray writes a line for every refused request, which puts the file's size in the hands of anyone who can reach an inbound port, and the periodic prune does not bound it between runs. A negative count also panicked on the final slice. Entries beyond the newest count are dropped while scanning, and count is clamped to 1..10000 with 100 as the fallback, as GetAmneziaWGLogs already does. --- internal/web/service/access_log_parse_test.go | 107 +++++++++++++++++- internal/web/service/server.go | 14 ++- 2 files changed, 115 insertions(+), 6 deletions(-) diff --git a/internal/web/service/access_log_parse_test.go b/internal/web/service/access_log_parse_test.go index 2d57da48e..15830d75a 100644 --- a/internal/web/service/access_log_parse_test.go +++ b/internal/web/service/access_log_parse_test.go @@ -1,6 +1,16 @@ package service -import "testing" +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "runtime" + "runtime/debug" + "strings" + "testing" + "time" +) func TestParseAccessLogFields(t *testing.T) { malformed := []string{ @@ -43,3 +53,98 @@ func TestParseAccessLogFields(t *testing.T) { t.Error("DateTime was not parsed from a well-formed line") } } + +// writeAccessLog points Xray's config at a fresh access log holding the lines +// that line(i) yields for i in [0, n). +func writeAccessLog(t *testing.T, n int, line func(i int) string) { + t.Helper() + dir := t.TempDir() + t.Setenv("XUI_BIN_FOLDER", dir) + accessLog := filepath.Join(dir, "access.log") + if err := os.WriteFile(filepath.Join(dir, "config.json"), fmt.Appendf(nil, `{"log":{"access":%q}}`, accessLog), 0o600); err != nil { + t.Fatal(err) + } + file, err := os.Create(accessLog) + if err != nil { + t.Fatal(err) + } + defer file.Close() + writer := bufio.NewWriter(file) + for i := range n { + _, _ = writer.WriteString(line(i) + "\n") + } + if err := writer.Flush(); err != nil { + t.Fatal(err) + } +} + +func readAccessLog(count string) []LogEntry { + return (&ServerService{}).GetXrayLogs(count, "", "true", "true", "true", []string{"direct"}, []string{"blocked"}) +} + +func TestGetXrayLogsReturnsTheNewestEntriesInFileOrder(t *testing.T) { + writeAccessLog(t, 250, func(i int) string { + return fmt.Sprintf("2024/01/02 15:04:05.000000 from 1.2.3.4:%d accepted tcp:example.com:443 [in-1 >> direct]", i) + }) + + tests := []struct { + count string + want int + }{ + {"1", 1}, + {"20", 20}, + {"250", 250}, + {"1000", 250}, + {"0", 100}, + {"-1", 100}, + {"10001", 100}, + {"not-a-number", 100}, + } + for _, tt := range tests { + entries := readAccessLog(tt.count) + if len(entries) != tt.want { + t.Errorf("count %q: got %d entries, want %d", tt.count, len(entries), tt.want) + continue + } + for i, entry := range entries { + if want := fmt.Sprintf("1.2.3.4:%d", 250-tt.want+i); entry.FromAddress != want { + t.Errorf("count %q: entry %d is from %q, want %q", tt.count, i, entry.FromAddress, want) + break + } + } + } +} + +// The file grows with every request Xray refuses, so what a read of it holds in +// memory must follow the requested count, not the size of the file. +func TestGetXrayLogsMemoryDoesNotGrowWithTheFile(t *testing.T) { + line := "2024/01/02 15:04:05.000000 from 1.2.3.4:1 accepted http://example.com/?" + strings.Repeat("a", 16<<10) + " [in-1 >> direct]" + writeAccessLog(t, (64<<20)/len(line), func(int) string { return line }) + + // A low GC target keeps garbage from the scan itself out of the measurement. + defer debug.SetGCPercent(debug.SetGCPercent(10)) + heap := func() uint64 { + var stats runtime.MemStats + runtime.ReadMemStats(&stats) + return stats.HeapAlloc + } + runtime.GC() + before, peak := heap(), uint64(0) + + done := make(chan []LogEntry) + go func() { done <- readAccessLog("20") }() + for { + select { + case entries := <-done: + if len(entries) != 20 { + t.Fatalf("got %d entries, want 20", len(entries)) + } + if grown := int64(peak) - int64(before); grown > 32<<20 { + t.Fatalf("heap grew by %d MiB while 20 lines of a 64 MiB log were read", grown>>20) + } + return + case <-time.After(time.Millisecond): + peak = max(peak, heap()) + } + } +} diff --git a/internal/web/service/server.go b/internal/web/service/server.go index cab19c6df..57287aed6 100644 --- a/internal/web/service/server.go +++ b/internal/web/service/server.go @@ -1482,7 +1482,10 @@ func (s *ServerService) GetXrayLogs( Proxied ) - countInt, _ := strconv.Atoi(count) + countInt, err := strconv.Atoi(count) + if err != nil || countInt < 1 || countInt > 10000 { + countInt = 100 + } var entries []LogEntry pathToAccessLog, err := xray.GetAccessLogPath() @@ -1530,17 +1533,18 @@ func (s *ServerService) GetXrayLogs( entry.Event = Proxied } + // Anyone reaching an inbound port adds lines here, so only the newest + // countInt entries are kept while reading, not one per line of the file. entries = append(entries, entry) + if len(entries) > countInt { + entries = entries[1:] + } } if err := scanner.Err(); err != nil { return nil } - if len(entries) > countInt { - entries = entries[len(entries)-countInt:] - } - return entries } From 152c84efa2daada5907d1ae8d3af7e2f215bbf0f Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 00:36:45 +0100 Subject: [PATCH 061/125] fix(inbounds): delete an inbound's dependent rows on every delete path The node sync's sweep of an inbound the node stopped reporting deleted the inbound but left its host rows, and the depleted-clients delete left its sub-balancer references. SQLite hands a freed inbound id out again once the table empties, so a node's host overrides could bind to a new inbound it never hosted and point that inbound's subscribers at an address, port and TLS settings the node chose. delInbound's cleanup moves into deleteInboundRow (detach clients, delete the inbound, its host rows and its sub-balancer references), which the sweep and the depleted-clients delete now call as well. --- internal/web/service/inbound.go | 55 +++++++++++-------- internal/web/service/inbound_node.go | 6 +- internal/web/service/inbound_traffic.go | 8 +-- internal/web/service/node_host_adopt_test.go | 49 +++++++++++++++++ .../web/service/traffic_runtime_apply_test.go | 10 ++++ 5 files changed, 93 insertions(+), 35 deletions(-) diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go index 408fa0cd2..940fedbab 100644 --- a/internal/web/service/inbound.go +++ b/internal/web/service/inbound.go @@ -1689,31 +1689,9 @@ func (s *InboundService) delInbound(id int) (bool, func(), error) { } if err := runSerializedTx(func(tx *gorm.DB) error { - if err := s.clientService.DetachInbound(tx, id); err != nil { + if err := s.deleteInboundRow(tx, id); err != nil { return err } - if err := tx.Delete(model.Inbound{}, id).Error; err != nil { - return err - } - if _, err := database.DeleteHosts(tx, "inbound_id = ?", id); err != nil { - return err - } - // Drop the deleted inbound from any sub-balancer that selects it; a - // dangling id would emit a member no subscriber can resolve (#5648). - var balancers []model.SubBalancer - if err := tx.Find(&balancers).Error; err != nil { - return err - } - for i := range balancers { - before := balancers[i].InboundIds - balancers[i].InboundIds = slices.DeleteFunc(before, func(b int) bool { return b == id }) - if len(balancers[i].InboundIds) == len(before) { - continue - } - if err := tx.Save(&balancers[i]).Error; err != nil { - return err - } - } if loadErr == nil && ib.NodeID != nil { return (&NodeService{}).MarkNodeDirtyTx(tx, *ib.NodeID) } @@ -1749,6 +1727,37 @@ func (s *InboundService) delInbound(id int) (bool, func(), error) { return needRestart, nodePush, nil } +// deleteInboundRow removes an inbound and every row keyed by its id; SQLite +// hands a freed id out again, so anything left behind binds to the next inbound. +func (s *InboundService) deleteInboundRow(tx *gorm.DB, id int) error { + if err := s.clientService.DetachInbound(tx, id); err != nil { + return err + } + if err := tx.Delete(model.Inbound{}, id).Error; err != nil { + return err + } + if _, err := database.DeleteHosts(tx, "inbound_id = ?", id); err != nil { + return err + } + // Drop the deleted inbound from any sub-balancer that selects it; a + // dangling id would emit a member no subscriber can resolve (#5648). + var balancers []model.SubBalancer + if err := tx.Find(&balancers).Error; err != nil { + return err + } + for i := range balancers { + before := balancers[i].InboundIds + balancers[i].InboundIds = slices.DeleteFunc(before, func(b int) bool { return b == id }) + if len(balancers[i].InboundIds) == len(before) { + continue + } + if err := tx.Save(&balancers[i]).Error; err != nil { + return err + } + } + return nil +} + type BulkDelInboundResult struct { Deleted int `json:"deleted"` Skipped []BulkDelInboundReport `json:"skipped,omitempty"` diff --git a/internal/web/service/inbound_node.go b/internal/web/service/inbound_node.go index a17787e21..76c352389 100644 --- a/internal/web/service/inbound_node.go +++ b/internal/web/service/inbound_node.go @@ -1010,11 +1010,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi } } } - if err := s.clientService.DetachInbound(tx, c.Id); err != nil { - return false, err - } - if err := tx.Where("id = ?", c.Id). - Delete(&model.Inbound{}).Error; err != nil { + if err := s.deleteInboundRow(tx, c.Id); err != nil { return false, err } delete(tagToCentral, c.Tag) diff --git a/internal/web/service/inbound_traffic.go b/internal/web/service/inbound_traffic.go index 6456aa1a9..6722b0e20 100644 --- a/internal/web/service/inbound_traffic.go +++ b/internal/web/service/inbound_traffic.go @@ -961,13 +961,7 @@ func (s *InboundService) DelDepletedClients(id int) (err error) { } if len(newClients) == 0 { deletedInbounds = append(deletedInbounds, *inbound) - if err := s.clientService.DetachInbound(tx, inbound.Id); err != nil { - return err - } - if _, err := database.DeleteHosts(tx, "inbound_id = ?", inbound.Id); err != nil { - return err - } - if err := tx.Delete(model.Inbound{}, inbound.Id).Error; err != nil { + if err := s.deleteInboundRow(tx, inbound.Id); err != nil { return err } if inbound.NodeID != nil { diff --git a/internal/web/service/node_host_adopt_test.go b/internal/web/service/node_host_adopt_test.go index 7ab57fc17..53906744b 100644 --- a/internal/web/service/node_host_adopt_test.go +++ b/internal/web/service/node_host_adopt_test.go @@ -75,3 +75,52 @@ func TestSetRemoteTraffic_AdoptsNodeHostRows(t *testing.T) { t.Fatalf("total host rows = %d, want 1 (group member for un-adopted node inbound 99 must not materialize)", total) } } + +func TestSetRemoteTraffic_SweptInboundTakesItsDependentsAlong(t *testing.T) { + setupConflictDB(t) + db := database.GetDB() + + const nodeID = 6 + if err := db.Create(&model.Node{Id: nodeID, Name: "host-node", Address: "10.0.0.6", Port: 2053, ApiToken: "t", Guid: "host-node-guid"}).Error; err != nil { + t.Fatalf("create node: %v", err) + } + inbound := func(id, port int, tag string) *model.Inbound { + return &model.Inbound{Id: id, Tag: tag, Enable: true, Port: port, Protocol: model.VLESS, Settings: `{"clients":[]}`} + } + group := func(groupId string, inboundId int) *entity.HostGroup { + return &entity.HostGroup{GroupId: groupId, InboundIds: []int{inboundId}, Hosts: []string{"cdn.example.com:8443"}, Remark: "cdn"} + } + + svc := InboundService{} + if _, err := svc.setRemoteTrafficLocked(nodeID, &runtime.TrafficSnapshot{ + Inbounds: []*model.Inbound{inbound(1, 443, "swept-443"), inbound(2, 444, "kept-444")}, + HostGroups: []*entity.HostGroup{group("g-swept", 1), group("g-kept", 2)}, + }, false, false); err != nil { + t.Fatalf("adopting merge: %v", err) + } + swept, kept := loadInboundByTag(t, "swept-443"), loadInboundByTag(t, "kept-444") + balancer := &model.SubBalancer{Remark: "both", InboundIds: []int{swept.Id, kept.Id}, Enabled: true} + if err := db.Create(balancer).Error; err != nil { + t.Fatalf("create balancer: %v", err) + } + + if _, err := svc.setRemoteTrafficLocked(nodeID, &runtime.TrafficSnapshot{ + Inbounds: []*model.Inbound{inbound(2, 444, "kept-444")}, + }, false, false); err != nil { + t.Fatalf("sweeping merge: %v", err) + } + + var groups []string + if err := db.Model(&model.Host{}).Order("group_id").Pluck("group_id", &groups).Error; err != nil { + t.Fatalf("load hosts: %v", err) + } + if len(groups) != 1 || groups[0] != "g-kept" { + t.Fatalf("host groups after the sweep = %v, want [g-kept]", groups) + } + if err := db.First(balancer, balancer.Id).Error; err != nil { + t.Fatalf("reload balancer: %v", err) + } + if len(balancer.InboundIds) != 1 || balancer.InboundIds[0] != kept.Id { + t.Fatalf("balancer members after the sweep = %v, want [%d]", balancer.InboundIds, kept.Id) + } +} diff --git a/internal/web/service/traffic_runtime_apply_test.go b/internal/web/service/traffic_runtime_apply_test.go index d8afc1620..77ac7165c 100644 --- a/internal/web/service/traffic_runtime_apply_test.go +++ b/internal/web/service/traffic_runtime_apply_test.go @@ -76,6 +76,10 @@ func TestDelDepletedClientsCleansRuntimeAfterCommit(t *testing.T) { Updates(map[string]any{"up": 100, "total": 100, "reset": 0}).Error; err != nil { t.Fatalf("deplete traffic: %v", err) } + balancer := &model.SubBalancer{Remark: "depleted", InboundIds: []int{ib.Id}, Enabled: true} + if err := database.GetDB().Create(balancer).Error; err != nil { + t.Fatalf("create balancer: %v", err) + } if err := (&InboundService{}).DelDepletedClients(-1); err != nil { t.Fatalf("DelDepletedClients: %v", err) @@ -83,4 +87,10 @@ func TestDelDepletedClientsCleansRuntimeAfterCommit(t *testing.T) { if got := fake.delInbound.Load(); got != 1 { t.Fatalf("runtime DelInbound calls = %d, want 1 after commit", got) } + if err := database.GetDB().First(balancer, balancer.Id).Error; err != nil { + t.Fatalf("reload balancer: %v", err) + } + if len(balancer.InboundIds) != 0 { + t.Fatalf("balancer members after the inbound went = %v, want none", balancer.InboundIds) + } } From 68e06a174bab41ffc36398c68863e1e329ba7901 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 00:39:26 +0100 Subject: [PATCH 062/125] fix(job): withhold fail2ban bans for addresses a datagram alone reports The IP-limit job treated every address on the core's online list as one its sender proved to own and wrote the oldest over-limit ones for the 3x-ipl jail. For Shadowsocks over UDP and for mKCP the core lists a datagram's source field with no exchange behind it, so a client with an IP limit could forge sources and get any address firewalled. A client attached to an enabled inbound that accepts such datagrams, on the master or on a node, is still counted and disconnected, but its old addresses are no longer written for fail2ban; a warning says why. --- .../job/check_client_ip_ban_commit_test.go | 99 +++++++++++++++++++ internal/web/job/check_client_ip_job.go | 65 +++++++++++- 2 files changed, 162 insertions(+), 2 deletions(-) diff --git a/internal/web/job/check_client_ip_ban_commit_test.go b/internal/web/job/check_client_ip_ban_commit_test.go index 164287928..10d7e065a 100644 --- a/internal/web/job/check_client_ip_ban_commit_test.go +++ b/internal/web/job/check_client_ip_ban_commit_test.go @@ -211,3 +211,102 @@ func TestProcessObserved_PublishesBanForCommittedScan(t *testing.T) { t.Fatalf("a published address must be recorded so the next scan does not repeat it") } } + +const ( + rawStream = `{"network":"tcp","security":"none"}` + shadowsocksUDP = `{"method":"2022-blake3-aes-256-gcm","network":"tcp,udp","clients":[{"email":"mallory","limitIp":1,"enable":true}]}` +) + +// One datagram with a forged source puts any address on the core's online list, +// so an address seen for such a client must not reach the file fail2ban follows. +func TestPublishBans_WithholdsAddressesOnlyADatagramVouchesFor(t *testing.T) { + const email, forged = "mallory", "192.0.2.77" + node := 1 + for _, tc := range []struct { + name string + second *model.Inbound + wantInLog bool + }{ + {"only a TCP inbound", nil, true}, + {"also on Shadowsocks with UDP", &model.Inbound{Enable: true}, false}, + {"Shadowsocks with UDP is disabled", &model.Inbound{Enable: false}, true}, + // Node syncs merge the node's observations into the same row (#6587). + {"Shadowsocks with UDP runs on a node", &model.Inbound{Enable: true, NodeID: &node}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + setupIntegrationDB(t) + db := database.GetDB() + // The scan resolves a client to its lowest inbound id, which is this one. + inbound := seedLinkedInboundWithClient(t, "inbound-vless", email, 1) + if tc.second != nil { + tc.second.Tag, tc.second.Port, tc.second.Protocol = "inbound-ss", 4322, model.Shadowsocks + tc.second.Settings, tc.second.StreamSettings = shadowsocksUDP, rawStream + if err := db.Create(tc.second).Error; err != nil { + t.Fatalf("seed second inbound: %v", err) + } + var client model.ClientRecord + if err := db.Where("email = ?", email).First(&client).Error; err != nil { + t.Fatalf("read client: %v", err) + } + if err := db.Create(&model.ClientInbound{ClientId: client.Id, InboundId: tc.second.Id}).Error; err != nil { + t.Fatalf("link second inbound: %v", err) + } + } + + now := time.Now().Unix() + row := seedClientIps(t, email, []model.ClientIpEntry{{IP: forged, Timestamp: now - 5}}) + live := []model.ClientIpEntry{{IP: forged, Timestamp: now - 5}, {IP: "203.0.113.4", Timestamp: now}} + overLimit, _ := NewCheckClientIpJob().enforceIpLimitForTest(t, row, inbound, email, 1, live, true) + + if !overLimit { + t.Error("the client is over its limit and must still be disconnected") + } + body, _ := os.ReadFile(readIpLimitLogPath()) + if got := contains(string(body), forged); got != tc.wantInLog { + t.Errorf("older address in the fail2ban log = %v, want %v:\n%s", got, tc.wantInLog, body) + } + }) + } +} + +// getInboundByEmail's settings scan is all that ties such a client to an inbound. +func TestPublishBans_ClientOutsideTheRelationIsJudgedByItsResolvedInbound(t *testing.T) { + setupIntegrationDB(t) + const email, forged = "mallory", "192.0.2.77" + inbound := seedInboundOnlyWithClient(t, "inbound-ss", email, 1) + inbound.Protocol, inbound.Settings, inbound.StreamSettings = model.Shadowsocks, shadowsocksUDP, rawStream + + now := time.Now().Unix() + row := seedClientIps(t, email, []model.ClientIpEntry{{IP: forged, Timestamp: now - 5}}) + live := []model.ClientIpEntry{{IP: forged, Timestamp: now - 5}, {IP: "203.0.113.4", Timestamp: now}} + NewCheckClientIpJob().enforceIpLimitForTest(t, row, inbound, email, 1, live, true) + + if body, _ := os.ReadFile(readIpLimitLogPath()); contains(string(body), forged) { + t.Errorf("an address only a datagram vouches for was written for fail2ban:\n%s", body) + } +} + +func TestAcceptsUnverifiedSources(t *testing.T) { + for _, tc := range []struct { + name string + protocol model.Protocol + settings string + stream string + want bool + }{ + {"shadowsocks tcp,udp", model.Shadowsocks, shadowsocksUDP, rawStream, true}, + {"shadowsocks network list", model.Shadowsocks, `{"network":["tcp","UDP"]}`, rawStream, true}, + {"shadowsocks tcp", model.Shadowsocks, `{"network":"tcp"}`, rawStream, false}, + {"shadowsocks core default", model.Shadowsocks, `{}`, rawStream, false}, + {"vless raw", model.VLESS, `{"clients":[]}`, rawStream, false}, + {"vless ignores settings.network", model.VLESS, `{"network":"udp"}`, rawStream, false}, + {"vless kcp", model.VLESS, `{"clients":[]}`, `{"network":"kcp"}`, true}, + {"vless mkcp by its newer key", model.VLESS, `{"clients":[]}`, `{"network":"tcp","method":"mKCP"}`, true}, + {"hysteria proves the address in its QUIC handshake", model.Hysteria, `{"clients":[]}`, `{"network":"hysteria"}`, false}, + } { + inbound := &model.Inbound{Protocol: tc.protocol, Settings: tc.settings, StreamSettings: tc.stream} + if got := acceptsUnverifiedSources(inbound); got != tc.want { + t.Errorf("%s: acceptsUnverifiedSources = %v, want %v", tc.name, got, tc.want) + } + } +} diff --git a/internal/web/job/check_client_ip_job.go b/internal/web/job/check_client_ip_job.go index eec1278ef..4773515f5 100644 --- a/internal/web/job/check_client_ip_job.go +++ b/internal/web/job/check_client_ip_job.go @@ -20,6 +20,8 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/web/service" "github.com/mhsanaei/3x-ui/v3/internal/xray" + xnet "github.com/xtls/xray-core/common/net" + "github.com/xtls/xray-core/infra/conf" "gorm.io/gorm" "gorm.io/gorm/clause" ) @@ -599,8 +601,8 @@ type pendingBan struct { keptLive int } -// publishBans returns the clients whose lines reached the log. bannedSeen -// advances only for those, so a failed write leaves the address retryable. +// publishBans returns the clients whose lines reached the log or were withheld as +// unproven. bannedSeen advances only for those, so a failed write stays retryable. func (j *CheckClientIpJob) publishBans(bans []pendingBan) []pendingBan { published := make([]pendingBan, 0, len(bans)) var logIpFile *os.File @@ -618,6 +620,14 @@ func (j *CheckClientIpJob) publishBans(bans []pendingBan) []pendingBan { j.recordBannedSeen(b.email, b.candidates, nil) continue } + // The caller still disconnects the client; only a firewall ban needs an + // address the sender has proven to own. + if j.sourceCanBeForged(b.email, b.inbound) { + j.recordBannedSeen(b.email, b.candidates, actionable) + logger.Warningf("[LIMIT_IP] Client %s: %d old IPs not queued for fail2ban, an inbound of the client accepts datagrams whose source can be forged", b.email, len(actionable)) + published = append(published, b) + continue + } if logIpFile == nil { f, err := os.OpenFile(xray.GetIPLimitLogPath(), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) if err != nil { @@ -637,6 +647,57 @@ func (j *CheckClientIpJob) publishBans(bans []pendingBan) []pendingBan { return published } +// sourceCanBeForged checks every enabled inbound the client is attached to: the +// online list is per email, and node syncs merge node observations into the row. +func (j *CheckClientIpJob) sourceCanBeForged(email string, resolved *model.Inbound) bool { + var inbounds []*model.Inbound + err := database.GetDB().Model(&model.Inbound{}). + Joins("JOIN client_inbounds ON client_inbounds.inbound_id = inbounds.id"). + Joins("JOIN clients ON clients.id = client_inbounds.client_id"). + Where("clients.email = ? AND inbounds.enable = ?", email, true). + Find(&inbounds).Error + if err != nil { + j.checkError(err) + return true + } + // A client not yet in the relation was resolved by getInboundByEmail's fallback. + if len(inbounds) == 0 && resolved != nil { + inbounds = append(inbounds, resolved) + } + for _, inbound := range inbounds { + if acceptsUnverifiedSources(inbound) { + return true + } + } + return false +} + +// acceptsUnverifiedSources: Shadowsocks authenticates each UDP datagram on its own +// and mKCP takes the request with the first ones, so the core never answers first. +func acceptsUnverifiedSources(inbound *model.Inbound) bool { + var stream struct { + Method string `json:"method"` + Network string `json:"network"` + } + _ = json.Unmarshal([]byte(inbound.StreamSettings), &stream) + // The core reads method as the newer name of network and prefers it. + if stream.Method != "" { + stream.Network = stream.Method + } + switch strings.ToLower(stream.Network) { + case "kcp", "mkcp": + return true + } + if inbound.Protocol != model.Shadowsocks { + return false + } + var settings struct { + Network *conf.NetworkList `json:"network"` + } + _ = json.Unmarshal([]byte(inbound.Settings), &settings) + return xnet.HasNetwork(settings.Network.Build(), xnet.Network_UDP) +} + // writeBanLines emits one line per address; the wording is load-bearing, since // x-ui.sh create_iplimit_jails builds filter.d/3x-ipl.conf failregex from it. func writeBanLines(w io.Writer, clientEmail string, actionable []model.ClientIpEntry) error { From c92c7718bf34a0880b7cd89f63de001a93a1bc15 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 00:41:37 +0100 Subject: [PATCH 063/125] fix(xray): refuse xhttp downloadSettings the core's dialer cannot use ValidateOutboundConfig, the save-time gate shared by template saves, API saves and outbound subscription refreshes, relied on conf.Build, which accepts a downloadSettings block with no address or on another transport. The core's XHTTP dialer dereferences that address and asserts that transport on every dial, so an imported link or a subscription carrying such a block panicked Xray, and every user's connections with it, whenever traffic was routed to that outbound. The built sender settings are now checked for both, and the outbound is refused with a message that names the missing part. --- .../web/service/outbound_subscription_test.go | 16 ++++++++ internal/xray/api.go | 37 +++++++++++++++++++ internal/xray/outbound_validation_test.go | 36 ++++++++++++++++++ 3 files changed, 89 insertions(+) diff --git a/internal/web/service/outbound_subscription_test.go b/internal/web/service/outbound_subscription_test.go index 62b4383db..b4f340d06 100644 --- a/internal/web/service/outbound_subscription_test.go +++ b/internal/web/service/outbound_subscription_test.go @@ -717,3 +717,19 @@ func TestOutboundSubscriptionRefreshCapsXMCMasks(t *testing.T) { t.Fatalf("kept %d outbounds, tags by address = %v", len(tags), byAddress) } } + +// The core's XHTTP dialer panics on a downloadSettings block without an address or on another transport. +func TestOutboundSubscriptionRefreshDropsXhttpDownloadSettingsTheCorePanicsOn(t *testing.T) { + setupSettingTestDB(t) + const server = "vless://00000000-0000-4000-8000-000000000000@%s:443?security=tls&type=xhttp&extra=%s#%s\n" + subID := serveOutboundSubscription(t, "p-", func(int) string { + return fmt.Sprintf(server, "1.1.1.1", url.QueryEscape(`{"downloadSettings":{}}`), "Empty") + + fmt.Sprintf(server, "8.8.8.8", url.QueryEscape(`{"downloadSettings":{"address":"8.8.4.4","port":443,"network":"tcp"}}`), "Tcp") + + fmt.Sprintf(server, "9.9.9.9", url.QueryEscape(`{"downloadSettings":{"address":"9.9.9.10","port":443,"network":"xhttp"}}`), "Complete") + }) + + want := map[string]string{"9.9.9.9": "p-complete"} + if _, got := refreshOutboundTags(t, subID); !maps.Equal(got, want) { + t.Fatalf("tags by address = %v, want %v", got, want) + } +} diff --git a/internal/xray/api.go b/internal/xray/api.go index a8d00abc5..418fdabea 100644 --- a/internal/xray/api.go +++ b/internal/xray/api.go @@ -23,6 +23,7 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/util/common" wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard" + "github.com/xtls/xray-core/app/proxyman" "github.com/xtls/xray-core/app/proxyman/command" "github.com/xtls/xray-core/app/router" routerService "github.com/xtls/xray-core/app/router/command" @@ -42,6 +43,7 @@ import ( vlessOutbound "github.com/xtls/xray-core/proxy/vless/outbound" "github.com/xtls/xray-core/proxy/vmess" wireguard "github.com/xtls/xray-core/proxy/wireguard" + "github.com/xtls/xray-core/transport/internet/splithttp" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/credentials/insecure" @@ -221,9 +223,44 @@ func ValidateOutboundConfig(outbound []byte) (err error) { if err != nil { return err } + if err := validateXhttpDownloadSettings(built.SenderSettings); err != nil { + return err + } return validateHandlerSettings(built.ProxySettings) } +// validateXhttpDownloadSettings refuses what conf.Build() lets through but the core's XHTTP +// dialer panics on at every dial: a downloadSettings block without an address or not on xhttp. +func validateXhttpDownloadSettings(settings *serial.TypedMessage) error { + if settings == nil { + return nil + } + instance, err := settings.GetInstance() + if err != nil { + return nil + } + sender, ok := instance.(*proxyman.SenderConfig) + if !ok { + return nil + } + transport, err := sender.StreamSettings.GetEffectiveTransportSettings() + if err != nil { + return nil + } + xhttp, ok := transport.(*splithttp.Config) + if !ok || xhttp.DownloadSettings == nil { + return nil + } + if xhttp.DownloadSettings.Address == nil { + return common.NewError("xhttp downloadSettings has no address") + } + transport, _ = xhttp.DownloadSettings.GetEffectiveTransportSettings() + if _, ok := transport.(*splithttp.Config); !ok { + return common.NewError("xhttp downloadSettings network is not xhttp") + } + return nil +} + // validateHandlerSettings refuses what conf.Build() lets through but the core parses only // in the handler constructor, where one failure aborts the whole core start. func validateHandlerSettings(settings *serial.TypedMessage) error { diff --git a/internal/xray/outbound_validation_test.go b/internal/xray/outbound_validation_test.go index d882355c8..3660ab1e7 100644 --- a/internal/xray/outbound_validation_test.go +++ b/internal/xray/outbound_validation_test.go @@ -71,6 +71,42 @@ func TestValidateOutboundConfig_RejectsWireguardRemoteDNSThatIsNotAnIP(t *testin } } +// The core's XHTTP dialer dereferences downloadSettings' address and casts its transport to +// XHTTP on every dial, so a block lacking either panics the whole core; conf.Build() lets it through. +func TestValidateOutboundConfig_RejectsXhttpDownloadSettingsTheDialerPanicsOn(t *testing.T) { + outbound := func(network, xhttpSettings string) []byte { + return []byte(`{ + "protocol": "vless", + "settings": {"address": "1.2.3.4", "port": 443, "id": "b831381d-6324-4d53-ad4f-8cda48b30811", "encryption": "none"}, + "streamSettings": {"network": "` + network + `", "security": "tls", "xhttpSettings": ` + xhttpSettings + `} + }`) + } + for _, rejected := range []string{ + `{"downloadSettings": {}}`, + `{"downloadSettings": {"port": 443, "network": "xhttp"}}`, + `{"downloadSettings": {"address": null, "port": 443, "network": "xhttp"}}`, + `{"downloadSettings": {"address": "5.6.7.8", "port": 443}}`, + `{"downloadSettings": {"address": "5.6.7.8", "port": 443, "network": "tcp"}}`, + `{"downloadSettings": {"address": "5.6.7.8", "port": 443, "method": "ws"}}`, + `{"DOWNLOADSETTINGS": {}}`, + } { + err := ValidateOutboundConfig(outbound("xhttp", rejected)) + if err == nil || !strings.Contains(err.Error(), "downloadSettings") { + t.Errorf("xhttpSettings %s: want a downloadSettings refusal, got %v", rejected, err) + } + } + for _, accepted := range [][]byte{ + outbound("xhttp", `{"path": "/up"}`), + outbound("xhttp", `{"downloadSettings": {"address": "5.6.7.8", "port": 443, "network": "xhttp"}}`), + outbound("xhttp", `{"downloadSettings": {"address": "down.example", "port": 443, "network": "splithttp", "security": "tls", "xhttpSettings": {"path": "/down"}}}`), + outbound("tcp", `{"downloadSettings": {}}`), + } { + if err := ValidateOutboundConfig(accepted); err != nil { + t.Errorf("outbound %s must stay valid, got: %v", accepted, err) + } + } +} + // xray-core 26.10.17 compiles a noise mask's Lua script inside Build (#7124), where the panel would // look beside its own binary; the xray child finds a relative script in its bin folder. func TestValidateOutboundConfig_FindsANoiseScriptBesideTheXrayBinary(t *testing.T) { From bebe1b911d460a22922457a1d2e701de61f53adc Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 00:44:05 +0100 Subject: [PATCH 064/125] fix(ldap): keep expired and over-quota clients off on directory sync The LDAP sync enabled every disabled client whose directory flag was true, without asking why it was off. A client the traffic job had just disabled for a passed expiry or a spent quota therefore came back on every sync and connected again for the seconds until the next traffic pass, for as long as the directory listed it. The sync's enable step now drops the clients the panel's own depletion rule keeps off, and enables nobody in a run whose lookup fails. A client the operator renews is enabled at the next sync. --- internal/web/job/ldap_sync_job.go | 21 ++++++++ internal/web/job/ldap_sync_job_test.go | 66 +++++++++++++++++++++++++ internal/web/service/inbound_disable.go | 21 ++++++++ 3 files changed, 108 insertions(+) diff --git a/internal/web/job/ldap_sync_job.go b/internal/web/job/ldap_sync_job.go index 65e47e645..56f93cc3c 100644 --- a/internal/web/job/ldap_sync_job.go +++ b/internal/web/job/ldap_sync_job.go @@ -1,6 +1,7 @@ package job import ( + "slices" "strings" "sync/atomic" "time" @@ -253,9 +254,29 @@ func (j *LdapSyncJob) createClients(newClients []model.Client, inboundIds []int, logger.Infof("LDAP auto-create: %d clients for %s", created, tagList) } +// withoutDepleted drops the clients the panel switched off itself: the directory +// flag grants an account, it does not waive a spent quota or a passed expiry. +func (j *LdapSyncJob) withoutDepleted(emails []string) []string { + if len(emails) == 0 { + return nil + } + depleted, err := j.inboundService.DepletedClientEmails(emails) + if err != nil { + logger.Warning("LDAP enable skipped, failed to look up depleted clients:", err) + return nil + } + return slices.DeleteFunc(emails, func(email string) bool { + _, hit := depleted[email] + return hit + }) +} + // batchSetEnable takes the bulk path: per-user calls held each inbound's lock through // its node push, so users sharing a hung node inbound queued one push timeout apiece. func (j *LdapSyncJob) batchSetEnable(emails []string, enable bool) { + if enable { + emails = j.withoutDepleted(emails) + } if len(emails) == 0 { return } diff --git a/internal/web/job/ldap_sync_job_test.go b/internal/web/job/ldap_sync_job_test.go index bdc5a77b1..ab20c3097 100644 --- a/internal/web/job/ldap_sync_job_test.go +++ b/internal/web/job/ldap_sync_job_test.go @@ -1,13 +1,17 @@ package job import ( + "encoding/json" + "fmt" "path/filepath" "testing" + "time" "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/dbtest" "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/web/service" + "github.com/mhsanaei/3x-ui/v3/internal/xray" ) func initLdapJobDB(t *testing.T) { @@ -128,3 +132,65 @@ func TestLdapCreateClients_FlagsRestartWhenEveryClientPartlyApplies(t *testing.T t.Fatal("a partly-applied LDAP batch left Xray unflagged for restart") } } + +// ldapDisabledClientsInbound seeds one local inbound holding the given clients +// switched off everywhere, each with its own client_traffics row. +func ldapDisabledClientsInbound(t *testing.T, rows []xray.ClientTraffic) *model.Inbound { + t.Helper() + initLdapJobDB(t) + db := database.GetDB() + clients := make([]model.Client, 0, len(rows)) + for i, row := range rows { + clients = append(clients, model.Client{ + Email: row.Email, ID: fmt.Sprintf("00000000-0000-4000-8000-%012d", i), + Enable: false, TotalGB: row.Total, ExpiryTime: row.ExpiryTime, + }) + } + settings, _ := json.Marshal(map[string]any{"clients": clients, "decryption": "none"}) + ib := &model.Inbound{ + UserId: 1, Enable: true, Port: 47300, Protocol: model.VLESS, + Tag: "ldap-local-in", Settings: string(settings), StreamSettings: `{"network":"tcp"}`, + } + if err := db.Create(ib).Error; err != nil { + t.Fatalf("create inbound: %v", err) + } + for i, c := range clients { + rec := model.ClientRecord{Email: c.Email, UUID: c.ID, Enable: false, TotalGB: c.TotalGB, ExpiryTime: c.ExpiryTime} + if err := db.Create(&rec).Error; err != nil { + t.Fatalf("create client record: %v", err) + } + // gorm default:true drops an explicit false on Create (#6478). + if err := db.Model(&rec).Update("enable", false).Error; err != nil { + t.Fatalf("disable client record: %v", err) + } + if err := db.Create(&model.ClientInbound{ClientId: rec.Id, InboundId: ib.Id}).Error; err != nil { + t.Fatalf("link client: %v", err) + } + traffic := rows[i] + traffic.InboundId = ib.Id + if err := db.Create(&traffic).Error; err != nil { + t.Fatalf("create client traffic: %v", err) + } + } + return ib +} + +// The directory flag stays true after an account expires or spends its quota, so +// enabling on the flag alone undid the traffic job's disable on every sync. +func TestLdapEnableLeavesDepletedClientsOff(t *testing.T) { + ib := ldapDisabledClientsInbound(t, []xray.ClientTraffic{ + {Email: "expired@ldap", ExpiryTime: time.Now().Add(-time.Minute).UnixMilli()}, + {Email: "spent@ldap", Total: 100, Up: 60, Down: 40}, + {Email: "paused@ldap", Total: 100, Up: 1, ExpiryTime: time.Now().Add(time.Hour).UnixMilli()}, + {Email: "unlimited@ldap", Up: 1 << 40}, + }) + + NewLdapSyncJob().batchSetEnable([]string{"expired@ldap", "spent@ldap", "paused@ldap", "unlimited@ldap"}, true) + + want := map[string]bool{"expired@ldap": false, "spent@ldap": false, "paused@ldap": true, "unlimited@ldap": true} + for email, enabled := range inboundClientEnables(t, ib.Id) { + if enabled != want[email] { + t.Errorf("client %s enabled = %v after the LDAP enable, want %v", email, enabled, want[email]) + } + } +} diff --git a/internal/web/service/inbound_disable.go b/internal/web/service/inbound_disable.go index 45e2632f1..c9cbd4330 100644 --- a/internal/web/service/inbound_disable.go +++ b/internal/web/service/inbound_disable.go @@ -6,6 +6,7 @@ import ( "strconv" "time" + "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/logger" "github.com/mhsanaei/3x-ui/v3/internal/xray" @@ -267,3 +268,23 @@ func trafficDisabledEmails(db *gorm.DB, emails []string) (map[string]struct{}, e } return disabled, nil } + +// DepletedClientEmails reports which emails disableInvalidClients keeps off (quota +// spent or expiry passed), so an unattended enable does not hand them back. +func (s *InboundService) DepletedClientEmails(emails []string) (map[string]struct{}, error) { + db := database.GetDB() + cond, condArgs := depletedCond(db) + depleted := make(map[string]struct{}) + for _, batch := range chunkStrings(uniqueNonEmptyStrings(emails), sqlInChunk) { + var page []string + if err := db.Model(xray.ClientTraffic{}). + Where(cond+" AND email IN ?", append(append([]any{}, condArgs...), batch)...). + Pluck("email", &page).Error; err != nil { + return nil, err + } + for _, e := range page { + depleted[e] = struct{}{} + } + } + return depleted, nil +} From c2250dbe57c1a59e500084b2fe744edfbd8e7f88 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 00:45:21 +0100 Subject: [PATCH 065/125] fix(sub): keep the salamander warning state one entry per inbound genHysteriaLink remembered which salamander settings it had warned about in a set keyed by the setting names themselves, which a node chooses with no bound on length or number and the set never forgot, and it logged the names verbatim into the in-memory log buffer. A node renaming a setting on every poll grew the master's memory by twice the name's size per subscription fetch until the process ran out. The set is keyed by inbound id and holds a digest of the names, the warning repeats only when that set changes, and it logs at most eight names cut to 32 characters. --- internal/sub/salamander_uri_test.go | 40 +++++++++++++++++++++++++++++ internal/sub/service.go | 9 ++++--- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/internal/sub/salamander_uri_test.go b/internal/sub/salamander_uri_test.go index c7add43a6..869110528 100644 --- a/internal/sub/salamander_uri_test.go +++ b/internal/sub/salamander_uri_test.go @@ -1,6 +1,7 @@ package sub import ( + "fmt" "reflect" "strconv" "strings" @@ -86,3 +87,42 @@ func TestGenHysteriaLinkGeckoStillWarnsOnExtraKeys(t *testing.T) { t.Fatal("leftover salamander key did not warn alongside the gecko export") } } + +// The names may be a node's: 50 renames of one oversized setting must leave +// one fixed-size entry for the inbound and warning lines of bounded length. +func TestGenHysteriaLinkSalamanderWarningStateIsBounded(t *testing.T) { + const id = 910004 + for i := range 50 { + name := strconv.Itoa(i) + strings.Repeat("k", 4096) + in := &model.Inbound{ + Id: id, Listen: "203.0.113.1", Port: 443, Protocol: model.Hysteria, + Settings: `{"version":2,"clients":[{"auth":"secret","email":"user"}]}`, + StreamSettings: `{"security":"tls","finalmask":{"udp":[{"type":"salamander","settings":{"password":"pw","` + name + `":1}}]}}`, + } + (&SubService{}).genHysteriaLink(in, "user") + } + entries := 0 + salamanderWarningSeen.Range(func(key, _ any) bool { + if strings.HasPrefix(fmt.Sprint(key), strconv.Itoa(id)) { + entries++ + } + return true + }) + if entries != 1 { + t.Fatalf("salamanderWarningSeen holds %d entries for one inbound, want 1", entries) + } + needle := "inbound " + strconv.Itoa(id) + ": salamander settings" + found := 0 + for _, line := range logger.GetLogs(100, "warning") { + if !strings.Contains(line, needle) { + continue + } + found++ + if len(line) > 512 { + t.Fatalf("warning line is %d bytes long, want at most 512", len(line)) + } + } + if found == 0 { + t.Fatal("renamed salamander settings did not warn at all") + } +} diff --git a/internal/sub/service.go b/internal/sub/service.go index 4d6f95e5f..c1ed7bb93 100644 --- a/internal/sub/service.go +++ b/internal/sub/service.go @@ -31,6 +31,8 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/xray" ) +// salamanderWarningSeen maps an inbound id to a digest of the setting names +// last warned about: a node picks the names, so the entry must stay fixed-size. var salamanderWarningSeen sync.Map // SubService provides business logic for generating subscription links and managing subscription data. @@ -1610,9 +1612,10 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin // packetSize rides its own URI fields; anything else still // breaks standard clients and must warn even when gecko fires. if extra := extraSalamanderKeys(settings, gecko != ""); len(extra) > 0 { - warningKey := fmt.Sprintf("%d:%v", inbound.Id, extra) - if _, loaded := salamanderWarningSeen.LoadOrStore(warningKey, struct{}{}); !loaded { - logger.Warningf("SubService - inbound %d: salamander settings %v cannot be expressed in a hysteria2 URI; standard clients will fail the handshake", inbound.Id, extra) + // Warn again when the set changes; log a bounded sample of the names. + digest := sha256.Sum256([]byte(strings.Join(extra, "\x00"))) + if prev, _ := salamanderWarningSeen.Swap(inbound.Id, digest); prev != digest { + logger.Warningf("SubService - inbound %d: salamander settings %.32q cannot be expressed in a hysteria2 URI; standard clients will fail the handshake", inbound.Id, extra[:min(len(extra), 8)]) } } if params["obfs"] == "" { From 6a0c983c8969bcb0af5b56883c0b322dcb1e8bdc Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 00:47:46 +0100 Subject: [PATCH 066/125] fix(node): keep only the latest refused claims for the log-once state The set that kept a refused node claim's warning to one line was keyed by the reported email and never forgot one. Some refused emails, such as a padded one or a new client holding a foreign subId, match no row on the master, so a node rotating them grew the set without bound; a snapshot made only of padded entries also skipped the warning. Each node now keeps just the emails its latest snapshot was refused, both return paths log, and the logged email is cut to 64 characters. --- internal/web/service/inbound_node_claims.go | 24 +++++++++++++------ .../web/service/node_foreign_client_test.go | 17 +++++++++++++ 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/internal/web/service/inbound_node_claims.go b/internal/web/service/inbound_node_claims.go index b54ff91c5..29c21dc93 100644 --- a/internal/web/service/inbound_node_claims.go +++ b/internal/web/service/inbound_node_claims.go @@ -2,7 +2,6 @@ package service import ( "encoding/json" - "fmt" "reflect" "strings" "sync" @@ -14,7 +13,9 @@ import ( "gorm.io/gorm" ) -var reportedRefusedClientClaim sync.Map +// reportedRefusedClaims holds, per node, the emails its last snapshot was refused: a +// node chooses them, so only the latest set is kept and a claim logs once while it lasts. +var reportedRefusedClaims sync.Map // nodeClientClaims is what one node snapshot may do to the master's client records: a node // changes only records linked solely to its own inbounds, plus the new ones it creates. @@ -66,6 +67,7 @@ func loadNodeClientClaims(tx *gorm.DB, nodeID int, snap *runtime.TrafficSnapshot } } if len(subIDByEmail) == 0 { + logRefusedClaims(nodeID, claims.refused) return claims, nil } @@ -149,13 +151,21 @@ func loadNodeClientClaims(tx *gorm.DB, nodeID int, snap *runtime.TrafficSnapshot claims.storedSubID[email] = rec.SubID } } - for email, reason := range claims.refused { - key := fmt.Sprintf("%d:%s", nodeID, email) - if _, seen := reportedRefusedClientClaim.LoadOrStore(key, struct{}{}); !seen { - logger.Warningf("setRemoteTraffic: node %d reported client %q, not adopting: %s", nodeID, email, reason) + logRefusedClaims(nodeID, claims.refused) + return claims, nil +} + +func logRefusedClaims(nodeID int, refused map[string]string) { + prev, _ := reportedRefusedClaims.Load(nodeID) + logged, _ := prev.(map[string]struct{}) + current := make(map[string]struct{}, len(refused)) + for email, reason := range refused { + current[email] = struct{}{} + if _, seen := logged[email]; !seen { + logger.Warningf("setRemoteTraffic: node %d reported client %.64q, not adopting: %s", nodeID, email, reason) } } - return claims, nil + reportedRefusedClaims.Store(nodeID, current) } // clientLinkScopes reports, per client id, whether it is linked to an inbound of this node diff --git a/internal/web/service/node_foreign_client_test.go b/internal/web/service/node_foreign_client_test.go index 6f9d71c5e..097260625 100644 --- a/internal/web/service/node_foreign_client_test.go +++ b/internal/web/service/node_foreign_client_test.go @@ -366,3 +366,20 @@ func TestNodeSharedClientInAgreementIsNotRewritten(t *testing.T) { t.Fatal("a node in agreement with the master was marked dirty for a re-push") } } + +// The refused emails are the node's choice, so the state that keeps their warning to one +// line must not keep every name a node ever sent. +func TestNodeRefusedClaimLogStateIsBounded(t *testing.T) { + db := initTrafficTestDB(t) + svc := &InboundService{} + seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true}) + createNodeInbound(t, db, 1, "n1-in", 41001) + + for i := range 50 { + syncNodeWithSettings(t, svc, 1, "n1-in", fmt.Sprintf(`{"clients":[{"email":"rotating-%d@x ","enable":true}]}`, i)) + } + held, _ := reportedRefusedClaims.Load(1) + if got := len(held.(map[string]struct{})); got != 1 { + t.Fatalf("refused-claim log state holds %d emails for one node, want 1", got) + } +} From 2b9adaa6ffb9b23151eb0a479f903dc8a472712b Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 00:51:31 +0100 Subject: [PATCH 067/125] fix(clients): set a client's Telegram user on every inbound it is on SetClientTelegramUserID patched tgId in the settings of only the inbound the client's traffic row points at. Sibling inbounds kept the old value, and the next sync of any of them wrote it back into the client record, so removing a Telegram user from a multi-inbound client silently re-bound that user to it. The write now goes through applyClientFieldByEmail, as the other by-email setters do, so every attached inbound, on the master or a node, receives the new tgId. --- .../web/service/client_apply_field_test.go | 73 +++++++++++++++++++ internal/web/service/client_inbound_apply.go | 47 ++---------- 2 files changed, 78 insertions(+), 42 deletions(-) diff --git a/internal/web/service/client_apply_field_test.go b/internal/web/service/client_apply_field_test.go index fd68e71a6..e4857115e 100644 --- a/internal/web/service/client_apply_field_test.go +++ b/internal/web/service/client_apply_field_test.go @@ -144,3 +144,76 @@ func TestSetClientEnableByEmail_MultiInbound(t *testing.T) { } } } + +// Regression test: removing a client's Telegram user cleared tgId on one inbound +// only, so the next sync of the sibling bound the removed account again. +func TestSetClientTelegramUserID_MultiInbound(t *testing.T) { + dbDir := t.TempDir() + t.Setenv("XUI_DB_FOLDER", dbDir) + dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db")) + + db := database.GetDB() + + const email = "multitg@example.com" + const uid = "ce8d33df-3a64-4f10-8f9b-91c3a8e0c333" + clientJSON := `{"clients":[{"email":"` + email + `","id":"` + uid + `","enable":true,"subId":"sub-tg-1","tgId":4242}]}` + + first := &model.Inbound{ + Tag: "vless-tg-a", Enable: true, Port: 50021, Protocol: model.VLESS, + StreamSettings: `{"network":"tcp","security":"reality"}`, Settings: clientJSON, + } + second := &model.Inbound{ + Tag: "vless-tg-b", Enable: true, Port: 50022, Protocol: model.VLESS, + StreamSettings: `{"network":"ws","security":"tls"}`, Settings: clientJSON, + } + for _, ib := range []*model.Inbound{first, second} { + if err := db.Create(ib).Error; err != nil { + t.Fatalf("create inbound %s: %v", ib.Tag, err) + } + } + + clientSvc := ClientService{} + inboundSvc := InboundService{} + for _, ib := range []*model.Inbound{first, second} { + clients, err := inboundSvc.GetClients(ib) + if err != nil { + t.Fatalf("GetClients(%s): %v", ib.Tag, err) + } + if err := clientSvc.SyncInbound(nil, ib.Id, clients); err != nil { + t.Fatalf("SyncInbound(%s): %v", ib.Tag, err) + } + } + traffic := &xray.ClientTraffic{InboundId: first.Id, Email: email, Enable: true} + if err := db.Create(traffic).Error; err != nil { + t.Fatalf("seed traffic: %v", err) + } + + if _, err := clientSvc.SetClientTelegramUserID(&inboundSvc, traffic.Id, 0); err != nil { + t.Fatalf("SetClientTelegramUserID: %v", err) + } + + for _, ib := range []*model.Inbound{first, second} { + fresh, err := inboundSvc.GetInbound(ib.Id) + if err != nil { + t.Fatalf("GetInbound(%s): %v", ib.Tag, err) + } + clients, err := inboundSvc.GetClients(fresh) + if err != nil { + t.Fatalf("GetClients(%s): %v", ib.Tag, err) + } + if len(clients) != 1 || clients[0].TgID != 0 { + t.Errorf("inbound %s still binds the removed Telegram user: %+v", ib.Tag, clients) + } + if err := clientSvc.SyncInbound(nil, ib.Id, clients); err != nil { + t.Fatalf("SyncInbound(%s): %v", ib.Tag, err) + } + } + + rec, err := clientSvc.GetRecordByEmail(nil, email) + if err != nil { + t.Fatalf("GetRecordByEmail: %v", err) + } + if rec.TgID != 0 { + t.Errorf("client record tgId = %d after the siblings were synced, want 0", rec.TgID) + } +} diff --git a/internal/web/service/client_inbound_apply.go b/internal/web/service/client_inbound_apply.go index 9da44d104..cc563cdf2 100644 --- a/internal/web/service/client_inbound_apply.go +++ b/internal/web/service/client_inbound_apply.go @@ -1339,48 +1339,11 @@ func (s *ClientService) SetClientTelegramUserID(inboundSvc *InboundService, traf return false, common.NewError("Inbound Not Found For Traffic ID:", trafficId) } - clientEmail := traffic.Email - - oldClients, err := inboundSvc.GetClients(inbound) - if err != nil { - return false, err - } - - found := false - for _, oldClient := range oldClients { - if oldClient.Email == clientEmail { - found = true - break - } - } - - if !found { - return false, common.NewError("Client Not Found For Email:", clientEmail) - } - - var settings map[string]any - err = json.Unmarshal([]byte(inbound.Settings), &settings) - if err != nil { - return false, err - } - clients := settings["clients"].([]any) - var newClients []any - for client_index := range clients { - c := clients[client_index].(map[string]any) - if c["email"] == clientEmail { - c["tgId"] = tgId - c["updated_at"] = time.Now().Unix() * 1000 - newClients = append(newClients, any(c)) - } - } - settings["clients"] = newClients - modifiedSettings, err := json.MarshalIndent(settings, "", " ") - if err != nil { - return false, err - } - inbound.Settings = string(modifiedSettings) - needRestart, err := s.UpdateInboundClient(inboundSvc, inbound, clientEmail) - return needRestart, err + // On every attached inbound: a sibling left holding the old tgId hands it + // back to the client record on its next SyncInbound (#5039). + return s.applyClientFieldByEmail(inboundSvc, traffic.Email, func(c map[string]any) { + c["tgId"] = tgId + }) } func (s *ClientService) CheckIsEnabledByEmail(inboundSvc *InboundService, clientEmail string) (bool, error) { From 9a95153f0e80bd4afb34cd76ff6908b4d87b6a14 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 00:54:59 +0100 Subject: [PATCH 068/125] fix(hwid): track devices only for subscriptions a client owns The subscription routes run the HWID gate before they look up the subId, and the gate read "no device limit" and "no such subscription" alike, so a request naming any path segment with an X-HWID header was answered 404 yet stored that segment, up to about 1 MiB, in client_hwids for good. Anyone reaching the subscription port could fill the disk until the panel could no longer save anything. An unlimited subscription is tracked only while an enabled client owns its subId, and a one-time seeder drops the rows already stored for subIds no client owns. --- internal/database/db.go | 20 +++++++++- internal/database/db_seed_test.go | 35 +++++++++++++++++ internal/sub/hwid_controller_test.go | 21 +++++++++++ internal/web/service/client_hwid.go | 29 ++++++++++---- internal/web/service/client_hwid_test.go | 48 ++++++++++++++++++++++++ 5 files changed, 144 insertions(+), 9 deletions(-) diff --git a/internal/database/db.go b/internal/database/db.go index 66d29d4e5..0e92b626a 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -1530,7 +1530,7 @@ func runSeeders(isUsersEmpty bool) error { } if empty && isUsersEmpty { - seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix2", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "FreedomFinalRulesPrivateEgressBlock", "UppercaseFreedomFinalRulesFix", "InboundRealityFinalmaskTcpStrip", "ApiTokensHash", "LegacyProxySettingsCleanup", "SessionSecretRotation", "OutboundRemovedKeysFix", "FreedomDomainStrategyFix", "DNSOutboundLegacyKeysFix", "DNSOutboundQTypeZeroFix", "WireguardDomainStrategyFix", "XdnsFinalmaskNamesAddrsFix", "UDPHopClientMaskFix", "HostVerifyPeerCertByNameBoolFix", "HostMihomoX25519Reset", "WireguardPeersToClients", "MtprotoSecretsToClients", "NodeInboundsAdopted", "NodeDownNotified", "ResetIpLimitNoFail2ban", "ClientGroupNameTrim"} + seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix2", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "FreedomFinalRulesPrivateEgressBlock", "UppercaseFreedomFinalRulesFix", "InboundRealityFinalmaskTcpStrip", "ApiTokensHash", "LegacyProxySettingsCleanup", "SessionSecretRotation", "OutboundRemovedKeysFix", "FreedomDomainStrategyFix", "DNSOutboundLegacyKeysFix", "DNSOutboundQTypeZeroFix", "WireguardDomainStrategyFix", "XdnsFinalmaskNamesAddrsFix", "UDPHopClientMaskFix", "HostVerifyPeerCertByNameBoolFix", "HostMihomoX25519Reset", "WireguardPeersToClients", "MtprotoSecretsToClients", "NodeInboundsAdopted", "NodeDownNotified", "ResetIpLimitNoFail2ban", "ClientGroupNameTrim", "OwnerlessClientHwidPurge"} for _, name := range seeders { if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil { return err @@ -1719,6 +1719,12 @@ func runSeeders(isUsersEmpty bool) error { } } + if !slices.Contains(seedersHistory, "OwnerlessClientHwidPurge") { + if err := purgeOwnerlessClientHwids(); err != nil { + return err + } + } + if err := seedHostsFromExternalProxy(); err != nil { return err } @@ -1914,6 +1920,18 @@ func clearLegacyProxySettings() error { }) } +// The HWID gate stored a row for any subId a request named, owned or not; those rows +// belong to no subscription and only grow the database. +func purgeOwnerlessClientHwids() error { + return db.Transaction(func(tx *gorm.DB) error { + if err := tx.Where("sub_id NOT IN (SELECT sub_id FROM clients WHERE sub_id IS NOT NULL)"). + Delete(&model.ClientHwid{}).Error; err != nil { + return err + } + return tx.Create(&model.HistoryOfSeeders{SeederName: "OwnerlessClientHwidPurge"}).Error + }) +} + // The groups page addresses a group by its trimmed name, so a padded stored name listed as // its own group while every action on it hit the trimmed one. func trimClientGroupNames() error { diff --git a/internal/database/db_seed_test.go b/internal/database/db_seed_test.go index 3f928879c..8ce6d2c16 100644 --- a/internal/database/db_seed_test.go +++ b/internal/database/db_seed_test.go @@ -2,6 +2,7 @@ package database import ( "encoding/json" + "fmt" "path/filepath" "regexp" "testing" @@ -376,3 +377,37 @@ func TestClientGroupNamesTrimmedOnUpgrade(t *testing.T) { t.Fatalf("groups after upgrade = %q, want [vip]", groups) } } + +// Requests naming a subId no client owns stored HWID rows that nothing ever reads; the +// upgrade drops them and keeps the rows of real subscriptions. +func TestOwnerlessClientHwidsPurgedOnUpgrade(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "x-ui.db") + if err := InitDB(dbPath); err != nil { + t.Fatalf("InitDB: %v", err) + } + t.Cleanup(func() { _ = CloseDB() }) + if err := db.Create(&model.ClientRecord{Email: "a@x", SubID: "owned", Enable: true}).Error; err != nil { + t.Fatalf("seed client: %v", err) + } + for i, sub := range []string{"owned", "junk-1", "junk-2"} { + if err := db.Create(&model.ClientHwid{SubID: sub, HwidHash: fmt.Sprintf("h%d", i), FirstSeen: 1, LastSeen: 1}).Error; err != nil { + t.Fatalf("seed hwid %s: %v", sub, err) + } + } + if err := db.Where("seeder_name = ?", "OwnerlessClientHwidPurge").Delete(&model.HistoryOfSeeders{}).Error; err != nil { + t.Fatalf("clear seeder history: %v", err) + } + if err := CloseDB(); err != nil { + t.Fatalf("CloseDB: %v", err) + } + if err := InitDB(dbPath); err != nil { + t.Fatalf("reopen InitDB: %v", err) + } + var subs []string + if err := db.Model(&model.ClientHwid{}).Pluck("sub_id", &subs).Error; err != nil { + t.Fatalf("read hwids: %v", err) + } + if len(subs) != 1 || subs[0] != "owned" { + t.Fatalf("hwid rows after upgrade = %q, want [owned]", subs) + } +} diff --git a/internal/sub/hwid_controller_test.go b/internal/sub/hwid_controller_test.go index 76ab4bafd..934b347b7 100644 --- a/internal/sub/hwid_controller_test.go +++ b/internal/sub/hwid_controller_test.go @@ -218,6 +218,27 @@ func TestSubscriptionHwidStatusDoesNotRegisterDevice(t *testing.T) { } } +// A 404 for a subId nobody was issued must not persist anything: the gate runs +// before the lookup, and an attacker can send any number of distinct subIds. +func TestSubscriptionHwidGateIgnoresUnknownSubID(t *testing.T) { + router, _ := initHwidSubRouter(t, 0) + + for _, path := range []string{"/sub/never-issued", "/json/never-issued", "/clash/never-issued"} { + rec := requestSub(t, router, http.MethodGet, path, "device-ghost", "") + if rec.Code != http.StatusNotFound { + t.Fatalf("%s unknown subId status = %d, want 404", path, rec.Code) + } + } + + var count int64 + if err := database.GetDB().Model(&model.ClientHwid{}).Where("sub_id = ?", "never-issued").Count(&count).Error; err != nil { + t.Fatalf("count hwids: %v", err) + } + if count != 0 { + t.Fatalf("client_hwids rows for an unknown subId = %d, want 0", count) + } +} + func TestSubscriptionHwidStatusWithoutLimit(t *testing.T) { router, subID := initHwidSubRouter(t, 0) diff --git a/internal/web/service/client_hwid.go b/internal/web/service/client_hwid.go index 606ea6ac3..6d7b01b97 100644 --- a/internal/web/service/client_hwid.go +++ b/internal/web/service/client_hwid.go @@ -101,6 +101,16 @@ func effectiveHwidLimitForSubID(tx *gorm.DB, subID string) (int, error) { return limit, err } +// subIDHasEnabledClient separates "no device limit" from "no such subscription": +// effectiveHwidLimitForSubID yields 0 for both. +func subIDHasEnabledClient(tx *gorm.DB, subID string) (bool, error) { + var enabled int64 + err := tx.Model(&model.ClientRecord{}). + Where("sub_id = ? AND enable = ?", subID, true). + Count(&enabled).Error + return enabled > 0, err +} + func (s *ClientService) EnforceHwidForSubID(subID string, req HwidRequest) (HwidGateResult, error) { var res HwidGateResult subID = strings.TrimSpace(subID) @@ -117,7 +127,15 @@ func (s *ClientService) EnforceHwidForSubID(subID string, req HwidRequest) (Hwid req = normalizeHwidRequest(req) if limit <= 0 { res.Allowed = true - if len(req.Hwid) >= minHwidLength { + if len(req.Hwid) < minHwidLength { + return res, nil + } + // The gate runs before the sub lookup; a subId no client owns gets no row. + known, err := subIDHasEnabledClient(db, subID) + if err != nil { + return res, err + } + if known { trackUnlimitedHwid(db, subID, req) } return res, nil @@ -206,15 +224,10 @@ func (s *ClientService) HwidSlotStatusForSubID(subID string) (status HwidSlotSta } db := database.GetDB() - var enabled int64 - if err := db.Model(&model.ClientRecord{}). - Where("sub_id = ? AND enable = ?", subID, true). - Count(&enabled).Error; err != nil { + known, err := subIDHasEnabledClient(db, subID) + if err != nil || !known { return status, false, err } - if enabled == 0 { - return status, false, nil - } limit, err := effectiveHwidLimitForSubID(db, subID) if err != nil { diff --git a/internal/web/service/client_hwid_test.go b/internal/web/service/client_hwid_test.go index 2d742d5e8..2d960322a 100644 --- a/internal/web/service/client_hwid_test.go +++ b/internal/web/service/client_hwid_test.go @@ -62,6 +62,54 @@ func TestClientHwidGate(t *testing.T) { } } +// The gate runs before the subscription lookup, so a subId no enabled client +// owns must pass through without leaving a client_hwids row behind. +func TestClientHwidGateDoesNotTrackUnknownSubID(t *testing.T) { + initClientHwidTestDB(t) + svc := &ClientService{} + db := database.GetDB() + rec := seedHwidClient(t, 0) + + countRows := func(subID string) int64 { + t.Helper() + var rows int64 + if err := db.Model(&model.ClientHwid{}).Where("sub_id = ?", subID).Count(&rows).Error; err != nil { + t.Fatalf("count rows for %q: %v", subID, err) + } + return rows + } + + res, err := svc.EnforceHwidForSubID("no-such-sub", HwidRequest{Hwid: "device-ghost", UserAgent: "Happ/1.0"}) + if err != nil || res != (HwidGateResult{Allowed: true}) { + t.Fatalf("unknown subId gate: err=%v res=%+v", err, res) + } + if rows := countRows("no-such-sub"); rows != 0 { + t.Fatalf("client_hwids rows for an unknown subId = %d, want 0", rows) + } + + if err := db.Model(&model.ClientRecord{}).Where("id = ?", rec.Id).UpdateColumn("enable", false).Error; err != nil { + t.Fatalf("disable client: %v", err) + } + res, err = svc.EnforceHwidForSubID(rec.SubID, HwidRequest{Hwid: "device-ghost"}) + if err != nil || res != (HwidGateResult{Allowed: true}) { + t.Fatalf("disabled subId gate: err=%v res=%+v", err, res) + } + if rows := countRows(rec.SubID); rows != 0 { + t.Fatalf("client_hwids rows for a disabled subId = %d, want 0", rows) + } + + if err := db.Model(&model.ClientRecord{}).Where("id = ?", rec.Id).UpdateColumn("enable", true).Error; err != nil { + t.Fatalf("enable client: %v", err) + } + res, err = svc.EnforceHwidForSubID(rec.SubID, HwidRequest{Hwid: "device-ghost"}) + if err != nil || res != (HwidGateResult{Allowed: true}) { + t.Fatalf("known subId gate: err=%v res=%+v", err, res) + } + if rows := countRows(rec.SubID); rows != 1 { + t.Fatalf("client_hwids rows for a known unlimited subId = %d, want 1", rows) + } +} + func TestClientHwidGateRegistersAndBlocks(t *testing.T) { initClientHwidTestDB(t) svc := &ClientService{} From 1c748adadfd05aca2febfdbaadb3730bff249ac9 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 00:57:32 +0100 Subject: [PATCH 069/125] fix(sub): resolve a blank subId to no subscription A subId is the only selector of a subscription's inbounds and external links, so a blank one matched every client stored without a subId, as clients embedded through inbounds/add or inbounds/import are. The Telegram bot authorised a client by email and then looked its links up by that blank subId, handing a bound user the working links of every other subId-less client. A blank subId now resolves to nothing in the subscription service, and the bot builds a subId-less client's own links by email, as the panel's client page does. --- internal/sub/external_config.go | 3 ++ internal/sub/inactive_external_sub_test.go | 22 ++++++++++++ internal/sub/service.go | 6 +++- internal/web/service/tgbot/tgbot_client.go | 5 +++ .../tgbot/tgbot_individual_links_test.go | 35 +++++++++++++++++++ 5 files changed, 70 insertions(+), 1 deletion(-) diff --git a/internal/sub/external_config.go b/internal/sub/external_config.go index 165d5a083..05131747b 100644 --- a/internal/sub/external_config.go +++ b/internal/sub/external_config.go @@ -36,6 +36,9 @@ type expandedLink struct { // getClientExternalLinksBySubId returns active rows with owner state attached. // Consumers keep inactive owners as metadata but omit their link values. func (s *SubService) getClientExternalLinksBySubId(subId string) ([]externalLinkEntry, error) { + if strings.TrimSpace(subId) == "" { + return nil, nil + } db := database.GetDB() var recs []model.ClientRecord if err := db.Where("sub_id = ?", subId).Find(&recs).Error; err != nil { diff --git a/internal/sub/inactive_external_sub_test.go b/internal/sub/inactive_external_sub_test.go index c6afb1040..072f04348 100644 --- a/internal/sub/inactive_external_sub_test.go +++ b/internal/sub/inactive_external_sub_test.go @@ -113,3 +113,25 @@ func TestInactiveExternalOnlySubRemainsKnownWithoutExposingLinks(t *testing.T) { }) } } + +// Clients stored without a subId share no subscription; a blank key must not +// select all of them. The routes never pass one, in-process callers can. +func TestBlankSubIdResolvesToNothing(t *testing.T) { + for _, subId := range []string{"", " "} { + seedSubDB(t) + seedSubInbound(t, subId, "one", 4431, 1, `{"network":"tcp","security":"none"}`) + seedSubInbound(t, subId, "two", 4432, 2, `{"network":"tcp","security":"none"}`) + seedInactiveExternalOnlySub(t, subId, "ext@e", true, time.Now().Add(time.Hour).UnixMilli()) + + links, emails, _, _, err := NewSubService("").GetSubs(subId, "req.example.com") + if err != nil || len(links) != 0 || len(emails) != 0 { + t.Errorf("GetSubs(%q) = %v, %v, %v; want nothing", subId, links, emails, err) + } + if out, _, err := NewSubJsonService("", "", "", "", NewSubService("")).GetJson(subId, "req.example.com", false); err != nil || out != "" { + t.Errorf("GetJson(%q) = %q, %v; want nothing", subId, out, err) + } + if out, _, err := NewSubClashService(false, "", false, NewSubService("")).GetClash(subId, "req.example.com"); err != nil || out != "" { + t.Errorf("GetClash(%q) = %q, %v; want nothing", subId, out, err) + } + } +} diff --git a/internal/sub/service.go b/internal/sub/service.go index c1ed7bb93..5f9c8cd09 100644 --- a/internal/sub/service.go +++ b/internal/sub/service.go @@ -737,8 +737,12 @@ func subscriptionExpiryFromClient(nowMs, expiryTime int64) int64 { return 0 } -// getInboundsBySubId loads the subscriber's inbounds; hosts are read per request, never cached. +// getInboundsBySubId loads the subscriber's inbounds, hosts read per request; a blank subId +// would match every client stored without one, so it names no subscription. func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error) { + if strings.TrimSpace(subId) == "" { + return nil, nil + } inbounds, err := s.loadInboundsBySubId(subId) if err != nil { return nil, err diff --git a/internal/web/service/tgbot/tgbot_client.go b/internal/web/service/tgbot/tgbot_client.go index 2edb1c874..5e9868781 100644 --- a/internal/web/service/tgbot/tgbot_client.go +++ b/internal/web/service/tgbot/tgbot_client.go @@ -232,6 +232,11 @@ func (t *Tgbot) clientSubLinks(email, subURL string) ([]string, error) { if err != nil || client == nil { return nil, errors.New("client not found") } + // A client stored without a subId has no subscription to look up; build + // its own links by email, as the panel's client page does. + if strings.TrimSpace(client.SubID) == "" { + return t.inboundService.GetAllClientLinks(u.Hostname(), email) + } return t.inboundService.GetSubLinks(u.Hostname(), client.SubID) } diff --git a/internal/web/service/tgbot/tgbot_individual_links_test.go b/internal/web/service/tgbot/tgbot_individual_links_test.go index b0246a3b2..c8acdbbb9 100644 --- a/internal/web/service/tgbot/tgbot_individual_links_test.go +++ b/internal/web/service/tgbot/tgbot_individual_links_test.go @@ -87,3 +87,38 @@ func TestIndividualLinksDoNotNeedAResolvableHost(t *testing.T) { t.Fatalf("bot sent %q, want the client's vless link", got) } } + +// A client stored without a subId has no subscription; the blank value must not +// be looked up as one, or it selects every other client stored without a subId. +func TestIndividualLinksOfBlankSubIdClientExposeNoOtherClient(t *testing.T) { + sent := recordingBotServer(t) + dbtest.InitDB(t, filepath.Join(t.TempDir(), "x-ui.db")) + service.RegisterSubLinkProvider(sub.NewLinkProvider()) + + const ownerUUID = "11111111-2222-4333-8444-555555555555" + const otherUUID = "99999999-2222-4333-8444-555555555555" + inboundSvc := service.InboundService{} + _, _, err := inboundSvc.AddInbound(&model.Inbound{ + UserId: 1, Tag: "in-443", Enable: true, Listen: "203.0.113.5", Port: 443, + Protocol: model.VLESS, Remark: "in", StreamSettings: `{"network":"tcp","security":"none"}`, + Settings: `{"clients":[{"id":"` + ownerUUID + `","email":"` + ownerMail + `","tgId":4242,"enable":true},` + + `{"id":"` + otherUUID + `","email":"other@x","enable":true}],"decryption":"none"}`, + }) + if err != nil { + t.Fatalf("AddInbound: %v", err) + } + + origRunning := isRunning + t.Cleanup(func() { isRunning = origRunning }) + isRunning = true + + tapClientLinks(t, &Tgbot{}, ownerTgID, "client_individual_links "+ownerMail) + + got := strings.Join(sent(), "\n") + if !strings.Contains(got, "vless://"+ownerUUID+"@203.0.113.5:443") { + t.Errorf("bot sent %q, want the client's own vless link", got) + } + if strings.Contains(got, otherUUID) { + t.Errorf("bot sent another client's credentials to %s: %q", ownerMail, got) + } +} From 3526a65f5dd58c2ad09f711366cc30dd9ebaf2ed Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 01:01:38 +0100 Subject: [PATCH 070/125] fix(web): bound the panel's open connections and share asset gzip The engine-wide gzip middleware gave every in-flight request its own compressor of about 1 MiB, and the public /assets bundles let an anonymous client keep a handler blocked in Write until WriteTimeout, so a thousand stalled downloads held a gigabyte. The listener itself took any number of connections, each holding buffers and a goroutine before any login. The bundles are now served from a gzip copy made once per file, with the assets prefix left out of the per-request middleware, and the panel listener closes a new connection once 2048 are open or its source (an IPv4 address or IPv6 /64) holds 256; a loopback reverse proxy counts only toward the total. --- internal/web/assets_test.go | 89 +++++++++++++++++++ internal/web/middleware/gzip_static.go | 64 ++++++++++++++ internal/web/middleware/gzip_static_test.go | 88 ++++++++++++++++++ internal/web/network/limit_listener.go | 98 +++++++++++++++++++++ internal/web/network/serve_test.go | 77 ++++++++++++++++ internal/web/routes_contract_test.go | 10 ++- internal/web/web.go | 21 ++--- 7 files changed, 436 insertions(+), 11 deletions(-) create mode 100644 internal/web/assets_test.go create mode 100644 internal/web/middleware/gzip_static.go create mode 100644 internal/web/middleware/gzip_static_test.go create mode 100644 internal/web/network/limit_listener.go diff --git a/internal/web/assets_test.go b/internal/web/assets_test.go new file mode 100644 index 000000000..6258e656b --- /dev/null +++ b/internal/web/assets_test.go @@ -0,0 +1,89 @@ +package web + +import ( + "bytes" + "compress/gzip" + "io" + "io/fs" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" +) + +// Any per-request compressor here is pinned for as long as an anonymous client +// stalls its download, so the bundles must come out as one shared gzip copy. +func TestAssetsServedFromSharedGzipCopy(t *testing.T) { + entries, err := fs.ReadDir(distFS, "dist/assets") + if err != nil || len(entries) == 0 { + t.Skip("no built frontend bundles embedded (dist stub)") + } + name := "" + for _, e := range entries { + if strings.HasSuffix(e.Name(), ".js") { + name = e.Name() + break + } + } + if name == "" { + t.Skip("no .js bundle embedded") + } + raw, err := fs.ReadFile(distFS, "dist/assets/"+name) + if err != nil { + t.Fatal(err) + } + engine := newTestEngine(t) + + get := func(acceptEncoding string) *http.Response { + t.Helper() + req := httptest.NewRequest(http.MethodGet, "/assets/"+name, nil) + req.Header.Set("Accept-Encoding", acceptEncoding) + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + return w.Result() + } + + for range 2 { + res := get("gzip") + body, _ := io.ReadAll(res.Body) + if res.StatusCode != http.StatusOK || res.Header.Get("Content-Encoding") != "gzip" { + t.Fatalf("gzip download: got %d, Content-Encoding %q", res.StatusCode, res.Header.Get("Content-Encoding")) + } + // A precompressed copy is sent with its length; a streamed compressor can't know it. + if got := res.Header.Get("Content-Length"); got != strconv.Itoa(len(body)) { + t.Errorf("Content-Length = %q, want %d: bundle was compressed per request", got, len(body)) + } + if res.Header.Get("Last-Modified") == "" { + t.Error("Last-Modified missing: assets FS no longer stamps startTime") + } + zr, err := gzip.NewReader(bytes.NewReader(body)) + if err != nil { + t.Fatal(err) + } + if plain, _ := io.ReadAll(zr); !bytes.Equal(plain, raw) { + t.Fatal("gzip download decompresses to something other than the bundle") + } + } + + res := get("") + body, _ := io.ReadAll(res.Body) + if res.StatusCode != http.StatusOK || res.Header.Get("Content-Encoding") != "" || !bytes.Equal(body, raw) { + t.Fatalf("plain download: got %d, Content-Encoding %q, %d bytes", res.StatusCode, res.Header.Get("Content-Encoding"), len(body)) + } +} + +// The assets route never listed its directory; serving the bundles from a shared copy +// must not turn that 404 into a 500. +func TestAssetsDirectoryIsNotFound(t *testing.T) { + engine := newTestEngine(t) + for _, encoding := range []string{"", "gzip"} { + req := httptest.NewRequest(http.MethodGet, "/assets/", nil) + req.Header.Set("Accept-Encoding", encoding) + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + if w.Code != http.StatusNotFound { + t.Errorf("GET /assets/ with Accept-Encoding %q = %d, want 404", encoding, w.Code) + } + } +} diff --git a/internal/web/middleware/gzip_static.go b/internal/web/middleware/gzip_static.go new file mode 100644 index 000000000..d09ff7631 --- /dev/null +++ b/internal/web/middleware/gzip_static.go @@ -0,0 +1,64 @@ +package middleware + +import ( + "bytes" + "compress/gzip" + "io/fs" + "net/http" + "path" + "strconv" + "strings" + "sync" + + "github.com/gin-gonic/gin" +) + +// GzipStatic serves a static route's "*filepath" from a gzip copy made once per +// file, so a stalled download holds shared bytes instead of its own compressor. +func GzipStatic(files fs.FS) gin.HandlerFunc { + var compressed sync.Map // name -> func() []byte + return func(c *gin.Context) { + if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead { + return + } + c.Writer.Header().Add("Vary", "Accept-Encoding") + if !strings.Contains(c.GetHeader("Accept-Encoding"), "gzip") { + return + } + name := strings.TrimPrefix(path.Clean("/"+c.Param("filepath")), "/") + // Only names that exist get an entry, so requests cannot grow the map. + info, err := fs.Stat(files, name) + if err != nil || info.IsDir() { + return + } + once, _ := compressed.LoadOrStore(name, sync.OnceValue(func() []byte { + return gzipFile(files, name) + })) + body := once.(func() []byte)() + if body == nil { + return + } + c.Header("Content-Encoding", "gzip") + // ServeContent leaves Content-Length unset once Content-Encoding is present. + c.Header("Content-Length", strconv.Itoa(len(body))) + http.ServeContent(c.Writer, c.Request, name, info.ModTime(), bytes.NewReader(body)) + c.Abort() + } +} + +// gzipFile returns the file compressed, or nil to leave it to the static route. +func gzipFile(files fs.FS, name string) []byte { + raw, err := fs.ReadFile(files, name) + if err != nil { + return nil + } + var buf bytes.Buffer + zw, _ := gzip.NewWriterLevel(&buf, gzip.BestCompression) + if _, err := zw.Write(raw); err != nil { + return nil + } + if err := zw.Close(); err != nil { + return nil + } + return buf.Bytes() +} diff --git a/internal/web/middleware/gzip_static_test.go b/internal/web/middleware/gzip_static_test.go new file mode 100644 index 000000000..acecc1aed --- /dev/null +++ b/internal/web/middleware/gzip_static_test.go @@ -0,0 +1,88 @@ +package middleware + +import ( + "bytes" + "compress/gzip" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "testing/fstest" + + "github.com/gin-gonic/gin" +) + +// readCountingFS counts ReadFile calls, i.e. how often a file gets compressed. +type readCountingFS struct { + fstest.MapFS + reads atomic.Int32 +} + +func (f *readCountingFS) ReadFile(name string) ([]byte, error) { + f.reads.Add(1) + return f.MapFS.ReadFile(name) +} + +func TestGzipStatic(t *testing.T) { + gin.SetMode(gin.TestMode) + raw := strings.Repeat("console.log('bundle');", 4096) + files := &readCountingFS{MapFS: fstest.MapFS{ + "app.js": {Data: []byte(raw)}, + }} + r := gin.New() + r.Group("/assets", GzipStatic(files)).StaticFS("/", http.FS(files)) + + do := func(method, target, acceptEncoding string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(method, target, nil) + req.Header.Set("Accept-Encoding", acceptEncoding) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + return w + } + + t.Run("gzip downloads share one compression", func(t *testing.T) { + for range 3 { + w := do(http.MethodGet, "/assets/app.js", "gzip, deflate") + if w.Code != http.StatusOK || w.Header().Get("Content-Encoding") != "gzip" || w.Header().Get("Vary") != "Accept-Encoding" { + t.Fatalf("got %d, headers %v", w.Code, w.Header()) + } + if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "javascript") { + t.Errorf("Content-Type = %q, want the bundle's own type", ct) + } + zr, err := gzip.NewReader(bytes.NewReader(w.Body.Bytes())) + if err != nil { + t.Fatal(err) + } + if plain, _ := io.ReadAll(zr); string(plain) != raw { + t.Fatal("decompressed body differs from the bundle") + } + } + if n := files.reads.Load(); n != 1 { + t.Errorf("bundle compressed %d times, want once", n) + } + }) + + t.Run("HEAD sends headers only", func(t *testing.T) { + w := do(http.MethodHead, "/assets/app.js", "gzip") + if w.Code != http.StatusOK || w.Body.Len() != 0 || w.Header().Get("Content-Encoding") != "gzip" { + t.Errorf("got %d, %d body bytes, headers %v", w.Code, w.Body.Len(), w.Header()) + } + }) + + t.Run("without Accept-Encoding the static route serves the plain file", func(t *testing.T) { + w := do(http.MethodGet, "/assets/app.js", "") + if w.Code != http.StatusOK || w.Header().Get("Content-Encoding") != "" || w.Body.String() != raw { + t.Errorf("got %d, Content-Encoding %q, %d body bytes", w.Code, w.Header().Get("Content-Encoding"), w.Body.Len()) + } + }) + + t.Run("unknown name is left to the static route", func(t *testing.T) { + w := do(http.MethodGet, "/assets/none.js", "gzip") + if w.Code != http.StatusNotFound || w.Header().Get("Content-Encoding") != "" { + t.Errorf("got %d, Content-Encoding %q, want 404 plain", w.Code, w.Header().Get("Content-Encoding")) + } + }) +} diff --git a/internal/web/network/limit_listener.go b/internal/web/network/limit_listener.go new file mode 100644 index 000000000..3c3b17398 --- /dev/null +++ b/internal/web/network/limit_listener.go @@ -0,0 +1,98 @@ +package network + +import ( + "net" + "net/netip" + "sync" +) + +// Every open panel connection holds buffers and a goroutine before any login, so their +// number is bounded overall and per source; a loopback reverse proxy counts only overall. +const ( + MaxPanelConns = 2048 + MaxPanelConnsPerSource = 256 +) + +type limitListener struct { + net.Listener + total, perSource int + + mu sync.Mutex + open int + bySource map[netip.Prefix]int +} + +// NewLimitListener closes an accepted connection at once when the total or its source +// (an IPv4 address or IPv6 /64) is at its limit, rather than leaving it in the backlog. +func NewLimitListener(l net.Listener, total, perSource int) net.Listener { + return &limitListener{Listener: l, total: total, perSource: perSource, bySource: map[netip.Prefix]int{}} +} + +func (l *limitListener) Accept() (net.Conn, error) { + for { + c, err := l.Listener.Accept() + if err != nil { + return nil, err + } + source, counted := connSource(c.RemoteAddr()) + if !l.acquire(source, counted) { + _ = c.Close() + continue + } + return &limitConn{Conn: c, release: func() { l.release(source, counted) }}, nil + } +} + +func (l *limitListener) acquire(source netip.Prefix, counted bool) bool { + l.mu.Lock() + defer l.mu.Unlock() + if l.open >= l.total || (counted && l.bySource[source] >= l.perSource) { + return false + } + l.open++ + if counted { + l.bySource[source]++ + } + return true +} + +func (l *limitListener) release(source netip.Prefix, counted bool) { + l.mu.Lock() + defer l.mu.Unlock() + l.open-- + if counted { + if l.bySource[source]--; l.bySource[source] <= 0 { + delete(l.bySource, source) + } + } +} + +// connSource is the per-source bucket of addr; loopback and unparseable sources have none. +func connSource(addr net.Addr) (netip.Prefix, bool) { + ap, err := netip.ParseAddrPort(addr.String()) + if err != nil { + return netip.Prefix{}, false + } + ip := ap.Addr().Unmap() + if ip.IsLoopback() { + return netip.Prefix{}, false + } + bits := 32 + if ip.Is6() { + bits = 64 + } + prefix, err := ip.Prefix(bits) + return prefix, err == nil +} + +type limitConn struct { + net.Conn + once sync.Once + release func() +} + +func (c *limitConn) Close() error { + err := c.Conn.Close() + c.once.Do(c.release) + return err +} diff --git a/internal/web/network/serve_test.go b/internal/web/network/serve_test.go index 8a0c08657..115a38be7 100644 --- a/internal/web/network/serve_test.go +++ b/internal/web/network/serve_test.go @@ -109,3 +109,80 @@ func TestProductionHTTPServersUseServeHTTPWrapper(t *testing.T) { t.Fatalf("scan production Go files: %v", err) } } + +// queueListener hands out conns from a channel, each claiming the given remote address. +type queueListener struct{ conns chan net.Conn } + +func (l queueListener) Accept() (net.Conn, error) { + c, ok := <-l.conns + if !ok { + return nil, net.ErrClosed + } + return c, nil +} +func (queueListener) Close() error { return nil } +func (queueListener) Addr() net.Addr { return testAddr("queue") } + +type remoteConn struct { + net.Conn + remote net.Addr + closed chan struct{} +} + +func (c *remoteConn) RemoteAddr() net.Addr { return c.remote } +func (c *remoteConn) Close() error { close(c.closed); return nil } + +func newRemoteConn(addr string) *remoteConn { + return &remoteConn{remote: &net.TCPAddr{IP: net.ParseIP(addr), Port: 40000}, closed: make(chan struct{})} +} + +// Every panel connection holds buffers before any login, so one source opening +// thousands of them used to take the whole panel's memory. +func TestLimitListenerCapsConnectionsPerSourceAndInTotal(t *testing.T) { + queue := queueListener{conns: make(chan net.Conn, 16)} + l := NewLimitListener(queue, 4, 2) + accept := func(c *remoteConn) net.Conn { + t.Helper() + queue.conns <- c + accepted, err := l.Accept() + if err != nil { + t.Fatalf("Accept: %v", err) + } + return accepted + } + + first := accept(newRemoteConn("203.0.113.1")) + accept(newRemoteConn("203.0.113.1")) + refused := newRemoteConn("203.0.113.1") + queue.conns <- refused + other := accept(newRemoteConn("2001:db8::1")) + select { + case <-refused.closed: + default: + t.Fatal("a source past its share was accepted") + } + if other.RemoteAddr().String() != "[2001:db8::1]:40000" { + t.Fatalf("accepted %s, want the other source", other.RemoteAddr()) + } + + // Loopback is exempt from the per-source share but counts toward the total. + accept(newRemoteConn("127.0.0.1")) + overTotal := newRemoteConn("127.0.0.1") + queue.conns <- overTotal + next := make(chan net.Conn, 1) + go func() { + c, _ := l.Accept() + next <- c + }() + select { + case <-overTotal.closed: + case c := <-next: + t.Fatalf("a connection past the total was accepted: %s", c.RemoteAddr()) + } + _ = first.Close() + queue.conns <- newRemoteConn("203.0.113.1") + again := <-next + if again.RemoteAddr().String() != "203.0.113.1:40000" { + t.Fatalf("a closed connection did not free its slot: accepted %s", again.RemoteAddr()) + } +} diff --git a/internal/web/routes_contract_test.go b/internal/web/routes_contract_test.go index dccdcb42e..7e6af1bf0 100644 --- a/internal/web/routes_contract_test.go +++ b/internal/web/routes_contract_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + "github.com/gin-gonic/gin" "github.com/robfig/cron/v3" "github.com/mhsanaei/3x-ui/v3/internal/database/dbtest" @@ -42,7 +43,8 @@ func inContractScope(method, path string) bool { return strings.HasPrefix(path, "/panel/api/") || contractExtraRoutes[method+" "+path] } -func registeredContractRoutes(t *testing.T) map[string]bool { +// newTestEngine builds the real panel router on a throwaway database. +func newTestEngine(t *testing.T) *gin.Engine { t.Helper() dbtest.InitDB(t, filepath.Join(t.TempDir(), "x-ui.db")) @@ -59,6 +61,12 @@ func registeredContractRoutes(t *testing.T) map[string]bool { if err != nil { t.Fatalf("init router: %v", err) } + return engine +} + +func registeredContractRoutes(t *testing.T) map[string]bool { + t.Helper() + engine := newTestEngine(t) routes := make(map[string]bool) for _, r := range engine.Routes() { routes[r.Method+" "+r.Path] = true diff --git a/internal/web/web.go b/internal/web/web.go index aaee5dbc6..81790cb8c 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -66,16 +66,14 @@ type cronPanicLogger struct{} func (cronPanicLogger) Printf(format string, args ...any) { logger.Errorf(format, args...) } -// wrapDistFS adapts the embedded `dist/` directory so it can be mounted -// as the panel's `/assets/` static route. Vite emits its bundled JS/CSS -// under `dist/assets/`; serving the FS rooted at `dist/assets` makes -// `/assets/.js` URLs resolve directly. +// wrapDistFS serves the embedded `dist/assets` tree (Vite's bundled JS/CSS) as the panel's +// `/assets/` route; a named field keeps embed.FS's ReadFile, rooted at `dist`, unpromoted. type wrapDistFS struct { - embed.FS + dist embed.FS } func (f *wrapDistFS) Open(name string) (fs.File, error) { - file, err := f.FS.Open("dist/assets/" + name) + file, err := f.dist.Open("dist/assets/" + name) if err != nil { return nil, err } @@ -205,8 +203,10 @@ func (s *Server) initRouter() (*gin.Engine, error) { if err != nil { return nil, err } - engine.Use(gzip.Gzip(gzip.DefaultCompression)) assetsBasePath := basePath + "assets/" + // The bundles are served below from a gzip copy made once per file: compressing them + // per request lets every stalled download pin its own compressor until WriteTimeout. + engine.Use(gzip.Gzip(gzip.DefaultCompression, gzip.WithExcludedPaths([]string{assetsBasePath}))) store := cookie.NewStore(secret) // Configure default session cookie options, including expiration (MaxAge) @@ -244,11 +244,11 @@ func (s *Server) initRouter() (*gin.Engine, error) { // so the Vite watcher's incremental rebuilds show up without // restarting the binary; in prod we serve the embedded dist FS // rooted at `dist/assets/`. + var assetsFS fs.FS = &wrapDistFS{dist: distFS} if config.IsDebug() { - engine.StaticFS(basePath+"assets", http.FS(os.DirFS("internal/web/dist/assets"))) - } else { - engine.StaticFS(basePath+"assets", http.FS(&wrapDistFS{FS: distFS})) + assetsFS = os.DirFS("internal/web/dist/assets") } + engine.Group(basePath+"assets", middleware.GzipStatic(assetsFS)).StaticFS("/", http.FS(assetsFS)) // Hand the embedded `dist/` filesystem to the controller package // before any HTML-serving controller is constructed. Phase 8 @@ -606,6 +606,7 @@ func (s *Server) start(restartXray bool, startTgBot bool) (err error) { if err != nil { return err } + listener = network.NewLimitListener(listener, network.MaxPanelConns, network.MaxPanelConnsPerSource) if certFile != "" || keyFile != "" { cert, err := tls.LoadX509KeyPair(certFile, keyFile) if err == nil { From f9f21fd8a43cb6816cd13166882501e27cce3bd2 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 01:05:48 +0100 Subject: [PATCH 071/125] fix(node): bound the host groups a node's inbounds may adopt A node-reported host group is stored once per address and per adopted inbound, so the rows and bytes a merge wrote were the product of three values the node chooses, unbounded by the response size cap: a small hosts/list answer could fill the master's disk. A group whose serialized form exceeds 16 KiB is no longer adopted, and a node's inbounds hold at most 2000 host rows, counted over what they already hold, so repeated merges do not add more; the merge logs how many groups it left out. --- internal/web/service/host.go | 39 ++++++++++- internal/web/service/inbound_node.go | 23 ++++++- internal/web/service/node_host_adopt_test.go | 70 ++++++++++++++++++++ 3 files changed, 128 insertions(+), 4 deletions(-) diff --git a/internal/web/service/host.go b/internal/web/service/host.go index 4da687ebf..4343e9387 100644 --- a/internal/web/service/host.go +++ b/internal/web/service/host.go @@ -1,6 +1,7 @@ package service import ( + "encoding/json" "slices" "sort" "strconv" @@ -182,19 +183,51 @@ func buildHostRows(groupId string, req *entity.HostGroup) []*model.Host { return rows } +// A node's group is stored once per address and per adopted inbound, so the +// response cap does not bound the write: the group's size and a per-node row budget do. +const ( + maxAdoptedHostGroupBytes = 16 << 10 + maxAdoptedHostRows = 2000 +) + +// adoptableHostGroups drops the node-reported groups too large to adopt. +func adoptableHostGroups(groups []*entity.HostGroup) []*entity.HostGroup { + return slices.DeleteFunc(slices.Clone(groups), func(g *entity.HostGroup) bool { + if g == nil { + return true + } + wire, err := json.Marshal(g) + return err != nil || len(wire) > maxAdoptedHostGroupBytes + }) +} + +// adoptedHostRowBudget is how many more host rows a node's inbounds may adopt. +func adoptedHostRowBudget(db *gorm.DB, nodeID int) (int, error) { + var held int64 + err := db.Model(&model.Host{}). + Where("inbound_id IN (?)", db.Model(&model.Inbound{}).Select("id").Where("node_id = ?", nodeID)). + Count(&held).Error + return max(maxAdoptedHostRows-int(held), 0), err +} + // adoptedHostRows projects a node's host groups onto a freshly adopted central -// inbound so TLS/SNI/fingerprint overrides survive the node-to-master import. -func adoptedHostRows(groups []*entity.HostGroup, nodeInboundId, centralInboundId int) []*model.Host { +// inbound, building at most budget rows; it also returns how many groups that left out. +func adoptedHostRows(groups []*entity.HostGroup, nodeInboundId, centralInboundId, budget int) ([]*model.Host, int) { var rows []*model.Host + skipped := 0 for _, g := range groups { if g == nil || !slices.Contains(g.InboundIds, nodeInboundId) { continue } + if len(rows)+max(len(g.Hosts), 1) > budget { + skipped++ + continue + } scoped := *g scoped.InboundIds = []int{centralInboundId} rows = append(rows, buildHostRows(g.GroupId, &scoped)...) } - return rows + return rows, skipped } func validateInboundsExist(tx *gorm.DB, inboundIds []int) error { diff --git a/internal/web/service/inbound_node.go b/internal/web/service/inbound_node.go index 76c352389..3c543b68c 100644 --- a/internal/web/service/inbound_node.go +++ b/internal/web/service/inbound_node.go @@ -654,6 +654,16 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi } } + // The host rows the node's inbounds already hold count against this merge's adoption. + hostGroups := adoptableHostGroups(snap.HostGroups) + hostGroupsSkipped := len(snap.HostGroups) - len(hostGroups) + hostRowBudget := 0 + if len(hostGroups) > 0 { + if hostRowBudget, err = adoptedHostRowBudget(db, nodeID); err != nil { + return false, err + } + } + tx := db.Begin() committed := false defer func() { @@ -821,9 +831,13 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi if newIb.Tag != snapIb.Tag { tagToCentral[newIb.Tag] = &newIb } - if rows := adoptedHostRows(snap.HostGroups, snapIb.Id, newIb.Id); len(rows) > 0 { + rows, skipped := adoptedHostRows(hostGroups, snapIb.Id, newIb.Id, hostRowBudget) + hostGroupsSkipped += skipped + if len(rows) > 0 { if err := tx.Create(&rows).Error; err != nil { logger.Warningf("setRemoteTraffic: adopt host rows for tag %q failed: %v", newIb.Tag, err) + } else { + hostRowBudget -= len(rows) } } newInboundIDs[newIb.Id] = struct{}{} @@ -901,6 +915,13 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi } } + if hostGroupsSkipped > 0 { + logger.Warningf( + "setRemoteTraffic: node %d: left out %d host group adoption(s) — a group is limited to %d bytes and the node's inbounds to %d host rows", + nodeID, hostGroupsSkipped, maxAdoptedHostGroupBytes, maxAdoptedHostRows, + ) + } + // The inbound each reported email now lives under, so a swept inbound's // accumulator rows follow the email instead of being dropped and re-seeded at 0. snapEmailHome := make(map[string]int, len(snapEmailsAll)) diff --git a/internal/web/service/node_host_adopt_test.go b/internal/web/service/node_host_adopt_test.go index 53906744b..bac184472 100644 --- a/internal/web/service/node_host_adopt_test.go +++ b/internal/web/service/node_host_adopt_test.go @@ -1,6 +1,8 @@ package service import ( + "fmt" + "strings" "testing" "github.com/mhsanaei/3x-ui/v3/internal/database" @@ -124,3 +126,71 @@ func TestSetRemoteTraffic_SweptInboundTakesItsDependentsAlong(t *testing.T) { t.Fatalf("balancer members after the sweep = %v, want [%d]", balancer.InboundIds, kept.Id) } } + +// floodSnapshot reports inboundCount new inbounds (ids and ports from firstPort) +// and makes every group a member of all of them. +func floodSnapshot(tagPrefix string, firstPort, inboundCount int, groups ...*entity.HostGroup) *runtime.TrafficSnapshot { + snap := &runtime.TrafficSnapshot{HostGroups: groups} + ids := make([]int, inboundCount) + for i := range ids { + ids[i] = firstPort + i + snap.Inbounds = append(snap.Inbounds, &model.Inbound{ + Id: firstPort + i, Tag: fmt.Sprintf("%s-%d", tagPrefix, i), Enable: true, Port: firstPort + i, Protocol: model.VLESS, Settings: `{"clients":[]}`, + }) + } + for _, g := range groups { + g.InboundIds = ids + } + return snap +} + +func countHostRows(t *testing.T, query string, args ...any) int64 { + t.Helper() + var n int64 + if err := database.GetDB().Model(&model.Host{}).Where(query, args...).Count(&n).Error; err != nil { + t.Fatalf("count hosts: %v", err) + } + return n +} + +// A group is stored once per address and per inbound, so a small hosts/list answer +// must not be able to write an unbounded number of rows or bytes, in one merge or many. +func TestSetRemoteTraffic_BoundsAdoptedHostRows(t *testing.T) { + setupConflictDB(t) + + const nodeID = 7 + if err := database.GetDB().Create(&model.Node{ + Id: nodeID, Name: "flood-node", Address: "10.0.0.7", Port: 2053, ApiToken: "t", Guid: "flood-node-guid", + }).Error; err != nil { + t.Fatalf("create node: %v", err) + } + + addresses := make([]string, 50) + for i := range addresses { + addresses[i] = fmt.Sprintf("h%d.example", i) + } + snap := floodSnapshot("flood", 30000, 100, + &entity.HostGroup{GroupId: "g-big", Hosts: []string{"cdn.example"}, Remark: "big", Sni: strings.Repeat("s", 100_000)}, + &entity.HostGroup{GroupId: "g-many", Hosts: addresses, Remark: "many", Sni: "sni.example"}, + ) + + svc := InboundService{} + if _, err := svc.setRemoteTrafficLocked(nodeID, snap, false, false); err != nil { + t.Fatalf("setRemoteTrafficLocked: %v", err) + } + if big := countHostRows(t, "group_id = ?", "g-big"); big != 0 { + t.Fatalf("a 100000-byte group was stored %d times", big) + } + if total := countHostRows(t, "1 = 1"); total != maxAdoptedHostRows { + t.Fatalf("one merge adopted %d host rows, want %d", total, maxAdoptedHostRows) + } + + more := floodSnapshot("more", 31000, 10, &entity.HostGroup{GroupId: "g-more", Hosts: addresses, Remark: "more"}) + more.Inbounds = append(more.Inbounds, snap.Inbounds...) + if _, err := svc.setRemoteTrafficLocked(nodeID, more, false, false); err != nil { + t.Fatalf("second setRemoteTrafficLocked: %v", err) + } + if total := countHostRows(t, "1 = 1"); total != maxAdoptedHostRows { + t.Fatalf("a second merge raised the node's host rows to %d, want %d", total, maxAdoptedHostRows) + } +} From fc8efc07456281f50c08c55b1e40ee71d6a0ab30 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 01:11:00 +0100 Subject: [PATCH 072/125] fix(node): bound what one node inbound adds to a subscription A subscription renders an inbound's stream and remark once per client and per external proxy, and every client holding its subId, all values a node chooses: an inbound with a megabyte stream and thousands of external proxies, or many clients sharing one subId, made a single unauthenticated request cost gigabytes of memory and minutes of CPU. A reported inbound whose streamSettings exceeds 64 KiB, sniffing 16 KiB or remark 1 KiB, or that lists more than 64 external proxies, is no longer taken in (an existing one keeps its stored values), and a node can no longer give a client a subId another client already holds, including another one in the same snapshot. --- internal/web/service/inbound_node.go | 7 ++ internal/web/service/inbound_node_claims.go | 69 ++++++++++++++++--- .../web/service/node_foreign_client_test.go | 56 +++++++++++++++ 3 files changed, 123 insertions(+), 9 deletions(-) diff --git a/internal/web/service/inbound_node.go b/internal/web/service/inbound_node.go index 3c543b68c..986e16e2f 100644 --- a/internal/web/service/inbound_node.go +++ b/internal/web/service/inbound_node.go @@ -708,6 +708,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi mergedCentral := make(map[int]struct{}, len(central)) snapTags := make(map[string]struct{}, len(snap.Inbounds)) + oversizeTags := map[string]string{} for snapIdx, snapIb := range snap.Inbounds { if snapIb == nil { continue @@ -759,6 +760,11 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi } mergedCentral[c.Id] = struct{}{} } + if reason := oversizeNodeInbound(snapIb); reason != "" { + oversizeTags[snapIb.Tag] = reason + snap.Inbounds[snapIdx] = nil + continue + } if !ok { if dirty { continue @@ -915,6 +921,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi } } + logOversizeNodeInbounds(nodeID, oversizeTags) if hostGroupsSkipped > 0 { logger.Warningf( "setRemoteTraffic: node %d: left out %d host group adoption(s) — a group is limited to %d bytes and the node's inbounds to %d host rows", diff --git a/internal/web/service/inbound_node_claims.go b/internal/web/service/inbound_node_claims.go index 29c21dc93..1f2b5736b 100644 --- a/internal/web/service/inbound_node_claims.go +++ b/internal/web/service/inbound_node_claims.go @@ -2,6 +2,7 @@ package service import ( "encoding/json" + "fmt" "reflect" "strings" "sync" @@ -42,6 +43,9 @@ func loadNodeClientClaims(tx *gorm.DB, nodeID int, snap *runtime.TrafficSnapshot storedSubID: map[string]string{}, } subIDByEmail := map[string]string{} + // A subId selects a whole subscription, so within one snapshot it may name one client only. + firstEmailBySub := map[string]string{} + sharesSubID := map[string]struct{}{} for _, snapIb := range snap.Inbounds { if snapIb == nil { continue @@ -63,6 +67,11 @@ func loadNodeClientClaims(tx *gorm.DB, nodeID int, snap *runtime.TrafficSnapshot var subID string _ = json.Unmarshal(c.SubID, &subID) subIDByEmail[c.Email] = subID + if first, taken := firstEmailBySub[subID]; subID != "" && taken && !strings.EqualFold(first, c.Email) { + sharesSubID[c.Email] = struct{}{} + } else if subID != "" && !taken { + firstEmailBySub[subID] = c.Email + } } } } @@ -105,13 +114,10 @@ func loadNodeClientClaims(tx *gorm.DB, nodeID int, snap *runtime.TrafficSnapshot holders = append(holders, rows...) } - ids := make([]int, 0, len(records)+len(holders)) + ids := make([]int, 0, len(records)) for i := range records { ids = append(ids, records[i].Id) } - for i := range holders { - ids = append(ids, holders[i].Id) - } scopes, err := clientLinkScopes(tx, nodeID, ids) if err != nil { return nil, err @@ -120,9 +126,12 @@ func loadNodeClientClaims(tx *gorm.DB, nodeID int, snap *runtime.TrafficSnapshot for _, h := range holders { holdersBySub[h.SubID] = append(holdersBySub[h.SubID], h) } - subHeldElsewhere := func(email, subID string) bool { + subIDTaken := func(email, subID string) bool { + if _, shared := sharesSubID[email]; shared { + return true + } for _, h := range holdersBySub[subID] { - if !strings.EqualFold(h.Email, email) && scopes[h.Id] != (clientLinkScope{here: true}) { + if !strings.EqualFold(h.Email, email) { return true } } @@ -134,8 +143,8 @@ func loadNodeClientClaims(tx *gorm.DB, nodeID int, snap *runtime.TrafficSnapshot if !exact { if _, variant := byLower[strings.ToLower(email)]; variant { claims.refused[email] = "another client's email differs from it only in case" - } else if subID != "" && subHeldElsewhere(email, subID) { - claims.refused[email] = "its subId belongs to a client this node does not host" + } else if subID != "" && subIDTaken(email, subID) { + claims.refused[email] = "its subId belongs to another client" } continue } @@ -147,7 +156,7 @@ func loadNodeClientClaims(tx *gorm.DB, nodeID int, snap *runtime.TrafficSnapshot claims.refused[email] = "it is attached only to inbounds this node does not host" case !scope.here && (rec.SyncOrphanedAt == 0 || rec.SyncOrphanedBy != nodeID): claims.refused[email] = "it is detached from every inbound on the master" - case subID != rec.SubID && subID != "" && subHeldElsewhere(email, subID): + case subID != rec.SubID && subID != "" && subIDTaken(email, subID): claims.storedSubID[email] = rec.SubID } } @@ -283,3 +292,45 @@ func sharedRecordWouldChange(rec *model.ClientRecord, reported *model.Client) bo keepLinkOwnedFields(&merged, rec, false) return !reflect.DeepEqual(merged, *rec) } + +// A node's stream, sniffing and remark are rendered into every subscription link of the +// inbound, once per client and external proxy, so their size bounds a request's cost. +const ( + maxNodeStreamSettingsBytes = 64 << 10 + maxNodeSniffingBytes = 16 << 10 + maxNodeRemarkBytes = 1 << 10 + maxNodeExternalProxies = 64 +) + +// reportedOversizeInbounds holds, per node, the tags its last snapshot left out as oversized. +var reportedOversizeInbounds sync.Map + +// oversizeNodeInbound names the field that keeps a reported inbound from being taken in. +func oversizeNodeInbound(ib *model.Inbound) string { + switch { + case len(ib.StreamSettings) > maxNodeStreamSettingsBytes: + return fmt.Sprintf("streamSettings exceeds %d bytes", maxNodeStreamSettingsBytes) + case len(ib.Sniffing) > maxNodeSniffingBytes: + return fmt.Sprintf("sniffing exceeds %d bytes", maxNodeSniffingBytes) + case len(ib.Remark) > maxNodeRemarkBytes: + return fmt.Sprintf("remark exceeds %d bytes", maxNodeRemarkBytes) + } + var stream struct { + ExternalProxy []json.RawMessage `json:"externalProxy"` + } + if json.Unmarshal([]byte(ib.StreamSettings), &stream) == nil && len(stream.ExternalProxy) > maxNodeExternalProxies { + return fmt.Sprintf("more than %d external proxies", maxNodeExternalProxies) + } + return "" +} + +func logOversizeNodeInbounds(nodeID int, tags map[string]string) { + prev, _ := reportedOversizeInbounds.Load(nodeID) + logged, _ := prev.(map[string]string) + for tag, reason := range tags { + if _, seen := logged[tag]; !seen { + logger.Warningf("setRemoteTraffic: node %d reported inbound %.64q, not taking it in: %s", nodeID, tag, reason) + } + } + reportedOversizeInbounds.Store(nodeID, tags) +} diff --git a/internal/web/service/node_foreign_client_test.go b/internal/web/service/node_foreign_client_test.go index 097260625..0d36eebb0 100644 --- a/internal/web/service/node_foreign_client_test.go +++ b/internal/web/service/node_foreign_client_test.go @@ -383,3 +383,59 @@ func TestNodeRefusedClaimLogStateIsBounded(t *testing.T) { t.Fatalf("refused-claim log state holds %d emails for one node, want 1", got) } } + +// A subscription renders every client holding its subId, so a node creating many clients +// under one subId multiplied the cost of a single request without bound. +func TestNodeClientsCannotShareASubID(t *testing.T) { + db := initTrafficTestDB(t) + svc := &InboundService{} + seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true}) + createNodeInbound(t, db, 1, "n1-in", 41001) + + syncNodeWithSettings(t, svc, 1, "n1-in", + `{"clients":[{"email":"a@x","subId":"one-sub","enable":true},{"email":"b@x","subId":"one-sub","enable":true},{"email":"c@x","subId":"c-sub","enable":true}]}`) + syncNodeWithSettings(t, svc, 1, "n1-in", + `{"clients":[{"email":"a@x","subId":"one-sub","enable":true},{"email":"c@x","subId":"c-sub","enable":true},{"email":"d@x","subId":"one-sub","enable":true}]}`) + + if n := countClientsWhere(t, db, "sub_id = ?", "one-sub"); n != 1 { + t.Fatalf("%d records hold one subId, want 1", n) + } + if !nodeHostsClient(t, db, "n1-in", "c@x") { + t.Fatal("a client with its own subId was not adopted") + } +} + +// Every reported stream, sniffing and remark is rendered once per client and external +// proxy of a subscription, so an oversized one let a node make one request cost gigabytes. +func TestNodeOversizeInboundIsNotTakenIn(t *testing.T) { + db := initTrafficTestDB(t) + svc := &InboundService{} + seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true}) + createNodeInbound(t, db, 1, "n1-in", 41001) + + proxies := make([]string, maxNodeExternalProxies+1) + for i := range proxies { + proxies[i] = fmt.Sprintf(`{"dest":"h%d.example","port":443,"forceTls":"same"}`, i) + } + stream := `{"network":"tcp","externalProxy":[` + strings.Join(proxies, ",") + `]}` + snap := &runtime.TrafficSnapshot{Inbounds: []*model.Inbound{ + {Tag: "n1-in", Enable: true, Port: 41001, Protocol: model.VLESS, Settings: `{"clients":[]}`, StreamSettings: stream}, + {Tag: "n1-new", Enable: true, Port: 41002, Protocol: model.VLESS, Settings: `{"clients":[]}`, Remark: strings.Repeat("r", maxNodeRemarkBytes+1)}, + }} + if _, err := svc.setRemoteTrafficLocked(1, snap, false, false); err != nil { + t.Fatalf("sync: %v", err) + } + + var stored model.Inbound + if err := db.Where("tag = ?", "n1-in").First(&stored).Error; err != nil { + t.Fatalf("the oversized report removed the existing inbound: %v", err) + } + if strings.Contains(stored.StreamSettings, "externalProxy") { + t.Fatal("an inbound with too many external proxies was taken in") + } + var created int64 + db.Model(&model.Inbound{}).Where("tag = ?", "n1-new").Count(&created) + if created != 0 { + t.Fatal("an inbound with an oversized remark was adopted") + } +} From 2467b9d542ac9a3ab370d59d71e4439a3d9904b7 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 01:15:28 +0100 Subject: [PATCH 073/125] fix(sub): bound what one subscription response renders A subscription's size is the product of its clients and inbounds with each one's remark and endpoints, and nothing bounded that product: data adopted from a node could make one unauthenticated request render gigabytes and run the panel out of memory. Each request now carries an 8 MiB budget that the raw, JSON and Clash outputs charge as they materialize a client's links, configs or proxies. Rendering stops once it is spent, with one warning, and anything below the budget is unchanged. --- internal/sub/clash_service.go | 6 ++- internal/sub/json_service.go | 4 +- internal/sub/render_budget.go | 70 +++++++++++++++++++++++++++++ internal/sub/render_budget_test.go | 71 ++++++++++++++++++++++++++++++ internal/sub/service.go | 16 ++++++- 5 files changed, 161 insertions(+), 6 deletions(-) create mode 100644 internal/sub/render_budget.go create mode 100644 internal/sub/render_budget_test.go diff --git a/internal/sub/clash_service.go b/internal/sub/clash_service.go index 670d2eb59..1463e3eae 100644 --- a/internal/sub/clash_service.go +++ b/internal/sub/clash_service.go @@ -90,7 +90,9 @@ func (s *SubClashService) getClash(subId string, host string, legacy bool) (stri variants, closed := subReq.clientHostVariants(inbound, client, "clash") hasBoundClosed = hasBoundClosed || closed for _, variant := range variants { - render := func() []map[string]any { return s.getProxies(subReq, variant.inbound(subReq, inbound), client, host) } + render := func() []map[string]any { + return s.budgetedProxies(subReq, variant.inbound(subReq, inbound), client, host) + } ordered = append(ordered, orderedSubEntry[func() []map[string]any]{variant.order, render}) } continue @@ -106,7 +108,7 @@ func (s *SubClashService) getClash(subId string, host string, legacy bool) (stri injectExternalProxy(&clone, effectiveEps) effective = &clone } - proxies = append(proxies, s.getProxies(subReq, effective, client, host)...) + proxies = append(proxies, s.budgetedProxies(subReq, effective, client, host)...) } } if subReq.hostsFirst { diff --git a/internal/sub/json_service.go b/internal/sub/json_service.go index c17edb2fa..3d75cdfdb 100644 --- a/internal/sub/json_service.go +++ b/internal/sub/json_service.go @@ -173,7 +173,7 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo hasBoundClosed = hasBoundClosed || closed for _, variant := range variants { render := func() subConfigEntry { - configs := s.getConfig(subReq, variant.inbound(subReq, inbound), client, host) + configs := s.budgetedConfig(subReq, variant.inbound(subReq, inbound), client, host) return subConfigEntry{sortIndex: inbound.SubSortIndex, id: inbound.Id, configs: configs, hostOrder: variant.order} } ordered = append(ordered, orderedSubEntry[func() subConfigEntry]{variant.order, render}) @@ -191,7 +191,7 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo injectExternalProxy(&clone, effectiveEps) effective = &clone } - inboundConfigs = append(inboundConfigs, s.getConfig(subReq, effective, client, host)...) + inboundConfigs = append(inboundConfigs, s.budgetedConfig(subReq, effective, client, host)...) } if len(inboundConfigs) > 0 { entries = append(entries, subConfigEntry{ diff --git a/internal/sub/render_budget.go b/internal/sub/render_budget.go new file mode 100644 index 000000000..8f59c9ed6 --- /dev/null +++ b/internal/sub/render_budget.go @@ -0,0 +1,70 @@ +package sub + +import ( + "encoding/json" + + "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/logger" + "github.com/mhsanaei/3x-ui/v3/internal/util/json_util" +) + +// maxRenderedBytes bounds what one subscription response renders: every client and +// inbound of the subId repeats the remark and endpoints, so the size is a product. +const maxRenderedBytes = 8 << 20 + +// renderBudget counts rendered bytes against maxRenderedBytes. The zero value +// is a fresh budget. +type renderBudget int + +// take books n more bytes and reports whether they still fit. The caller drops +// what does not fit and stops rendering: a spent budget admits nothing else. +func (b *renderBudget) take(n int) bool { + if b.spent() { + return false + } + *b += renderBudget(n) + if b.spent() { + logger.Warningf("sub: rendering stopped at %d bytes, the remaining links are dropped", maxRenderedBytes) + return false + } + return true +} + +func (b *renderBudget) spent() bool { + return *b > maxRenderedBytes +} + +// keepLink books a rendered raw link, and its line break, against the request's budget. +func (s *SubService) keepLink(link string) bool { + return s.rendered.take(len(link) + 1) +} + +// budgetedConfig renders a client's JSON configs, keeping those the request's budget admits. +func (s *SubJsonService) budgetedConfig(subReq *SubService, inbound *model.Inbound, client model.Client, host string) []json_util.RawMessage { + if subReq.rendered.spent() { + return nil + } + configs := s.getConfig(subReq, inbound, client, host) + for i, config := range configs { + if !subReq.rendered.take(len(config)) { + return configs[:i] + } + } + return configs +} + +// budgetedProxies renders a client's Clash proxies, keeping those the request's budget admits; +// the YAML is written only at the end, and its JSON form is about as long. +func (s *SubClashService) budgetedProxies(subReq *SubService, inbound *model.Inbound, client model.Client, host string) []map[string]any { + if subReq.rendered.spent() { + return nil + } + proxies := s.getProxies(subReq, inbound, client, host) + for i, proxy := range proxies { + text, _ := json.Marshal(proxy) + if !subReq.rendered.take(len(text)) { + return proxies[:i] + } + } + return proxies +} diff --git a/internal/sub/render_budget_test.go b/internal/sub/render_budget_test.go new file mode 100644 index 000000000..bc55fe7c3 --- /dev/null +++ b/internal/sub/render_budget_test.go @@ -0,0 +1,71 @@ +package sub + +import ( + "fmt" + "strings" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) + +// budgetRepeats copies of budgetRemark are well past maxRenderedBytes. +var budgetRemark = strings.Repeat("r", 100_000) + +const budgetRepeats = 200 + +func renderedSubscriptionSizes(t *testing.T, subId string) map[string]int { + t.Helper() + base := NewSubService("") + links, _, _, _, err := base.GetSubs(subId, "req.example.com") + if err != nil { + t.Fatalf("GetSubs: %v", err) + } + jsonBody, _, err := NewSubJsonService("", "", "", "", base).GetJson(subId, "req.example.com", false) + if err != nil { + t.Fatalf("GetJson: %v", err) + } + clashBody, _, err := NewSubClashService(false, "", false, base).GetClash(subId, "req.example.com") + if err != nil { + t.Fatalf("GetClash: %v", err) + } + return map[string]int{"raw": len(strings.Join(links, "\n")), "json": len(jsonBody), "clash": len(clashBody)} +} + +// The final documents indent the booked JSON configs again and list every Clash +// proxy name a second time in the PROXY group, hence the factor of two. +func assertBoundedSubscription(t *testing.T, subId string) { + t.Helper() + for format, size := range renderedSubscriptionSizes(t, subId) { + if size < len(budgetRemark) || size > 2*maxRenderedBytes { + t.Errorf("%s subscription is %d bytes, want %d..%d", format, size, len(budgetRemark), 2*maxRenderedBytes) + } + } +} + +// Clients sharing a subId repeat the remark just like endpoints do, each in a +// fan-out of its own, so the response as a whole needs the bound too. +func TestSubscriptionBoundsRenderedSizeAcrossClients(t *testing.T) { + seedSubDB(t) + db := database.GetDB() + ib := seedSubInbound(t, "s1", "many", 4431, 1, `{"network":"tcp","security":"none"}`) + var clients strings.Builder + for i := range budgetRepeats { + uuid, email := fmt.Sprintf("11111111-2222-4333-8444-%012d", i), fmt.Sprintf("c%d@e", i) + fmt.Fprintf(&clients, `{"id":%q,"email":%q,"subId":"s1","enable":true},`, uuid, email) + record := &model.ClientRecord{Email: email, SubID: "s1", UUID: uuid, Enable: true} + if err := db.Create(record).Error; err != nil { + t.Fatalf("seed client %s: %v", email, err) + } + if err := db.Create(&model.ClientInbound{ClientId: record.Id, InboundId: ib.Id}).Error; err != nil { + t.Fatalf("seed client_inbound %s: %v", email, err) + } + } + if err := db.Model(ib).Updates(map[string]any{ + "remark": budgetRemark, + "settings": `{"clients":[` + strings.TrimSuffix(clients.String(), ",") + `],"decryption":"none"}`, + }).Error; err != nil { + t.Fatalf("update inbound: %v", err) + } + assertBoundedSubscription(t, "s1") +} diff --git a/internal/sub/service.go b/internal/sub/service.go index 5f9c8cd09..0a0d3cf64 100644 --- a/internal/sub/service.go +++ b/internal/sub/service.go @@ -79,6 +79,8 @@ type SubService struct { // with the clients array left out; generators read only inbound-level // fields (encryption, method, version, …) from it. settingsByInbound map[int]map[string]any + // rendered is what this request has put into its response so far. + rendered renderBudget // settingsCache is shared by this service's requests (ForRequest copies the pointer); only // subscription bodies read through it, never panel link rendering. settingsCache *inboundSettingsCache @@ -131,6 +133,7 @@ func (s *SubService) PrepareForRequest(host string) { s.hostsByInbound = nil s.hostGroupOrders = nil s.sharedGroupSni = nil + s.rendered = 0 mode, _ := s.settingService.GetSubHostSortMode() s.hostsFirst = mode == "hosts-first" s.loadNodes() @@ -525,6 +528,10 @@ func (s *SubService) getSubs(subId string) ([]string, []string, int64, xray.Clie if client.Enable { hasEnabledClient = true } + if s.rendered.spent() { + seenEmails[client.Email] = struct{}{} + continue + } var link string if s.hostsFirst { variants, closed := s.clientHostVariants(inbound, client, "raw") @@ -552,7 +559,7 @@ func (s *SubService) getSubs(subId string) ([]string, []string, int64, xray.Clie } else { link = s.GetLink(inbound, client.Email) } - if !s.hostsFirst { + if !s.hostsFirst && s.keepLink(link) { result = append(result, link) } emails = append(emails, client.Email) @@ -561,7 +568,12 @@ func (s *SubService) getSubs(subId string) ([]string, []string, int64, xray.Clie } if s.hostsFirst { for _, render := range orderedSubValues(ordered) { - result = append(result, render()) + if s.rendered.spent() { + break + } + if link := render(); s.keepLink(link) { + result = append(result, link) + } } } for _, ext := range externalLinks { From 6dbd219a147a461a5921cac8b0ff767bf5cdeee8 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 01:16:56 +0100 Subject: [PATCH 074/125] fix(database): keep disabled clients disabled in the ClientsTable seeder The one-time ClientsTable migration builds client records from each inbound's settings with a plain Create, and gorm's default:true on ClientRecord.Enable drops an explicit false from that insert. Every client an operator had disabled before v3.1.0 was therefore stored enabled on an in-place upgrade or a restored backup, and Xray, which now reads that column, let the customer back in. The seeder restates enable=false after Create, as the service paths do since #6478, and a legacy client whose settings carry no switch stays enabled, as releases before 3.1.0 served it. --- internal/database/db.go | 13 ++++++++++ internal/database/db_seed_test.go | 41 +++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/internal/database/db.go b/internal/database/db.go index 0e92b626a..80b680365 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -3197,6 +3197,11 @@ func seedClientsFromInboundJSON() error { continue } model.NormalizeLegacyClientFields(obj) + // Releases before 3.1.0 served a client whose JSON carries no + // switch; only an explicit false means disabled. + if obj["enable"] == nil { + obj["enable"] = true + } blob, err := json.Marshal(obj) if err != nil { continue @@ -3218,6 +3223,14 @@ func seedClientsFromInboundJSON() error { if err := tx.Create(incoming).Error; err != nil { return err } + // gorm default:true drops enable=false on Create — restate (#6478). + if !c.Enable { + if err := tx.Model(&model.ClientRecord{}).Where("id = ?", incoming.Id). + UpdateColumn("enable", false).Error; err != nil { + return err + } + incoming.Enable = false + } byEmail[email] = incoming row = incoming } else { diff --git a/internal/database/db_seed_test.go b/internal/database/db_seed_test.go index 8ce6d2c16..8bf177254 100644 --- a/internal/database/db_seed_test.go +++ b/internal/database/db_seed_test.go @@ -220,6 +220,47 @@ func TestSeedClientsFromInboundJSON_IsIdempotentAgainstExistingClients(t *testin } } +func TestSeedClientsFromInboundJSON_KeepsTheManualSwitch(t *testing.T) { + dbDir := t.TempDir() + t.Setenv("XUI_DB_FOLDER", dbDir) + if err := InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil { + t.Fatalf("InitDB failed: %v", err) + } + t.Cleanup(func() { _ = CloseDB() }) + + inbound := model.Inbound{ + UserId: 1, + Port: 12345, + Protocol: model.VLESS, + Settings: `{"clients": [ + {"id": "ce8d33df-3a64-4f10-8f9b-91c3a8e0c001", "email": "paying", "enable": true}, + {"id": "ce8d33df-3a64-4f10-8f9b-91c3a8e0c002", "email": "banned", "enable": false}, + {"id": "ce8d33df-3a64-4f10-8f9b-91c3a8e0c003", "email": "no-switch"} + ]}`, + Tag: "test-inbound", + } + if err := db.Create(&inbound).Error; err != nil { + t.Fatalf("seed inbound: %v", err) + } + if err := db.Where("seeder_name = ?", "ClientsTable").Delete(&model.HistoryOfSeeders{}).Error; err != nil { + t.Fatalf("clear ClientsTable history: %v", err) + } + + if err := seedClientsFromInboundJSON(); err != nil { + t.Fatalf("seedClientsFromInboundJSON: %v", err) + } + + for email, want := range map[string]bool{"paying": true, "banned": false, "no-switch": true} { + var row model.ClientRecord + if err := db.Where("email = ?", email).First(&row).Error; err != nil { + t.Fatalf("read client %s: %v", email, err) + } + if row.Enable != want { + t.Errorf("client %s migrated with enable = %v, want %v", email, row.Enable, want) + } + } +} + func TestNormalizeInboundClientSubId_FillsMissingAndPreservesExisting(t *testing.T) { dbDir := t.TempDir() t.Setenv("XUI_DB_FOLDER", dbDir) From 30f17ef5a334fb501c81953710654b93982a3e05 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 01:22:51 +0100 Subject: [PATCH 075/125] fix(amneziawg): keep port forwards per inbound and off future relay ports Attach and BulkAttach copied a client record's forwardedPorts onto the target inbound. A client a node reports keeps the forwards the node chose, so attaching it to one of the master's AmneziaWG inbounds opened listeners of the node's choosing on the master, among them the loopback relay port a later AmneziaWG or TUIC inbound derives from its id; that inbound's traffic then went to the forward and Xray failed to bind. A forward is set per inbound and is no longer carried by an attach, and creating an AmneziaWG or TUIC inbound is refused while a peer forward already holds its relay port. --- internal/web/service/client_attach_test.go | 60 +++++++++++++++++++ internal/web/service/client_bulk.go | 2 + internal/web/service/client_crud.go | 3 + internal/web/service/port_conflict.go | 16 ++++- .../service/port_conflict_forwarded_test.go | 55 +++++++++++++++++ 5 files changed, 134 insertions(+), 2 deletions(-) diff --git a/internal/web/service/client_attach_test.go b/internal/web/service/client_attach_test.go index d1ef9b12e..65000a403 100644 --- a/internal/web/service/client_attach_test.go +++ b/internal/web/service/client_attach_test.go @@ -157,3 +157,63 @@ func TestAttachRefusesDefaultRouteNextToHeldAddress(t *testing.T) { } } } + +// A forward is a listener on the inbound's host; carried over from a node-reported +// client, it let the node pick ports on the master, a future relay port among them. +func TestAttachDoesNotCarryForwardedPorts(t *testing.T) { + attaches := map[string]func(*ClientService, *InboundService, string, int) error{ + "Attach": func(s *ClientService, inboundSvc *InboundService, email string, ibId int) error { + _, err := s.AttachByEmail(inboundSvc, email, []int{ibId}) + return err + }, + "BulkAttach": func(s *ClientService, inboundSvc *InboundService, email string, ibId int) error { + result, _, err := s.BulkAttach(inboundSvc, []string{email}, []int{ibId}) + if err == nil && len(result.Errors) > 0 { + err = errors.New(strings.Join(result.Errors, "; ")) + } + return err + }, + } + for name, attach := range attaches { + t.Run(name, func(t *testing.T) { + setupConflictDB(t) + node := 1 + seedInboundConflict(t, "own", "0.0.0.0", 51830, model.AmneziaWG, ``, awgRelayWindowSettings(t, "own")) + seedInboundConflictNode(t, "node", "0.0.0.0", 44301, model.AmneziaWG, ``, `{"clients":[`+ + `{"email":"forwarder","subId":"s-fwd","enable":true,"publicKey":"QkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkI=","allowedIPs":["10.9.1.2/32"],"forwardedPorts":"8022"}]}`, &node) + + s := &ClientService{} + inboundSvc := &InboundService{} + var own model.Inbound + for _, tag := range []string{"node", "own"} { + own = model.Inbound{} + if err := database.GetDB().Where("tag = ?", tag).First(&own).Error; err != nil { + t.Fatalf("read seeded %s row: %v", tag, err) + } + clients, err := inboundSvc.GetClients(&own) + if err != nil { + t.Fatalf("GetClients(%s): %v", tag, err) + } + if err := s.SyncInbound(nil, own.Id, clients); err != nil { + t.Fatalf("SyncInbound(%s): %v", tag, err) + } + } + + if err := attach(s, inboundSvc, "forwarder", own.Id); err != nil { + t.Fatalf("attach: %v", err) + } + if err := database.GetDB().First(&own, own.Id).Error; err != nil { + t.Fatalf("reread own inbound: %v", err) + } + clients, err := inboundSvc.GetClients(&own) + if err != nil { + t.Fatalf("GetClients(own): %v", err) + } + for _, c := range clients { + if c.Email == "forwarder" && c.ForwardedPorts != "" { + t.Fatalf("attach carried forwardedPorts %q onto the master's inbound", c.ForwardedPorts) + } + } + }) + } +} diff --git a/internal/web/service/client_bulk.go b/internal/web/service/client_bulk.go index 1f500c8ac..89b241dc8 100644 --- a/internal/web/service/client_bulk.go +++ b/internal/web/service/client_bulk.go @@ -111,6 +111,8 @@ func (s *ClientService) BulkAttach(inboundSvc *InboundService, emails []string, continue } client := *rec.ToClient() + // Forwards are set per inbound; see Attach. + client.ForwardedPorts = "" if flow, ok := flowsByEmail[rec.Email]; ok && flow != "" { client.Flow = flow } diff --git a/internal/web/service/client_crud.go b/internal/web/service/client_crud.go index 5429167cd..2b336d9b9 100644 --- a/internal/web/service/client_crud.go +++ b/internal/web/service/client_crud.go @@ -1172,6 +1172,9 @@ func (s *ClientService) Attach(inboundSvc *InboundService, id int, inboundIds [] return false, fmt.Errorf("inbound %d: %w", ibId, getErr) } copyClient := *clientWire + // A forward is a listener on the target inbound's host, set per inbound: carried over, it + // would let a node-reported client choose ports on this host. + copyClient.ForwardedPorts = "" if !addressesFitAmneziaWGInbound(copyClient.AllowedIPs, inbound) { copyClient.AllowedIPs = nil } diff --git a/internal/web/service/port_conflict.go b/internal/web/service/port_conflict.go index 07282f1d1..59a1eb635 100644 --- a/internal/web/service/port_conflict.go +++ b/internal/web/service/port_conflict.go @@ -553,7 +553,19 @@ func checkAmneziawgnetSocksReverseConflict(db *gorm.DB, id int) (*portConflictDe Transports: transportTCP, }, nil } - return nil, nil + return relayPortForwardOwner(db, relayPort, id) +} + +// relayPortForwardOwner asks the same of the forward listeners, which no port column +// shows: bound on every interface, one would be dialled in Xray's place by id's relay. +func relayPortForwardOwner(db *gorm.DB, relayPort, id int) (*portConflictDetail, error) { + owner, err := amneziawgForwardedPortOwner(db, &model.Inbound{Port: relayPort}, id) + if err != nil || owner == nil { + return nil, err + } + owner.Relay = true + owner.Transports = transportTCP + return owner, nil } func checkTuicSocksConflict(db *gorm.DB, inbound *model.Inbound, ignoreId int, newBits transportBits) (*portConflictDetail, error) { @@ -643,7 +655,7 @@ func checkTuicSocksReverseConflict(db *gorm.DB, id int) (*portConflictDetail, er Transports: transportTCP, }, nil } - return nil, nil + return relayPortForwardOwner(db, relayPort, id) } func sameNode(a, b *int) bool { diff --git a/internal/web/service/port_conflict_forwarded_test.go b/internal/web/service/port_conflict_forwarded_test.go index e8e0b676e..c1b2a76ce 100644 --- a/internal/web/service/port_conflict_forwarded_test.go +++ b/internal/web/service/port_conflict_forwarded_test.go @@ -1,11 +1,14 @@ package service import ( + "fmt" "strings" "testing" + "github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet" "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/tuic" ) // checkForwardedPortsConflict only ran from the AmneziaWG save path, so an @@ -133,3 +136,55 @@ func TestAddInboundAllowsANodeRowOnALocallyForwardedPort(t *testing.T) { t.Fatalf("a node row does not bind here; the create must be allowed: %v", err) } } + +// A forward bound on every interface holds a relay port no port column shows, so the +// inbound whose id derives that relay port must not be created onto it. +func TestAddInboundRefusesARelayPortAnAmneziaWGPeerForwards(t *testing.T) { + cases := []struct { + name string + relayPort func(id int) int + inbound func(t *testing.T) *model.Inbound + }{ + {"amneziawg", amneziawgnet.SOCKSPortForInbound, func(t *testing.T) *model.Inbound { + return &model.Inbound{ + Tag: "awg-next", Enable: true, Listen: "0.0.0.0", Port: 51821, + Protocol: model.AmneziaWG, Settings: awgRelayWindowSettings(t, "awg-next"), + } + }}, + {"tuic", tuic.SOCKSPortForInbound, func(t *testing.T) *model.Inbound { + return &model.Inbound{ + Tag: "tuic-next", Enable: true, Listen: "0.0.0.0", Port: 8443, + Protocol: model.TUIC, Settings: `{"clients":[]}`, + } + }}, + } + + for _, tc := range cases { + for _, held := range []bool{true, false} { + t.Run(fmt.Sprintf("%s held=%v", tc.name, held), func(t *testing.T) { + setupConflictDB(t) + // The holder takes id 1, so the create below derives its port from id 2. + forwarded := tc.relayPort(2) + if !held { + forwarded = tc.relayPort(3) + } + seedInboundConflict(t, "awg-forward", "0.0.0.0", 51820, model.AmneziaWG, ``, + awgRelayWindowSettingsWithForward(t, "awg-forward", fmt.Sprint(forwarded))) + + _, _, err := (&InboundService{}).AddInbound(tc.inbound(t)) + if !held { + if err != nil { + t.Fatalf("relay port %d is free; the create must be allowed: %v", tc.relayPort(2), err) + } + return + } + if err == nil { + t.Fatalf("relay port %d is forwarded by a peer of another inbound; the create must be refused", forwarded) + } + if !strings.Contains(err.Error(), "relay port") || !strings.Contains(err.Error(), "awg-forward@relay-window") { + t.Fatalf("the refusal must name the relay port and the peer holding it, got %v", err) + } + }) + } + } +} From 67820dc20da32402486b9a4cae3a0ba9cc151039 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 08:23:48 +0100 Subject: [PATCH 076/125] fix(clients): keep a hand-disabled client off through auto-renew autoRenewClients re-enabled every renewing client whose traffic row was off, as if only the panel's quota and expiry check cleared that flag. The operator's disable routes (the client editor and bulk disable) clear it too, so a client disabled by hand came back on with a fresh period and zeroed counters as soon as its Auto Renew period ended. A renewal now restores only a client the lifecycle job switched off: its quota is spent, counting cross-panel usage, or its record was stamped at or after its deadline, which the job does and a disable of a live client cannot. --- .../web/service/inbound_autorenew_test.go | 90 +++++++++++++++++++ internal/web/service/inbound_disable.go | 29 ++++++ internal/web/service/inbound_traffic.go | 17 ++-- 3 files changed, 130 insertions(+), 6 deletions(-) diff --git a/internal/web/service/inbound_autorenew_test.go b/internal/web/service/inbound_autorenew_test.go index 54a7b6840..4961cab45 100644 --- a/internal/web/service/inbound_autorenew_test.go +++ b/internal/web/service/inbound_autorenew_test.go @@ -102,3 +102,93 @@ func TestAutoRenewClients_MultiInbound(t *testing.T) { } } } + +// Every state is reached through the calls that produce it on a live panel: both +// operator paths clear client_traffics.enable as well, which a hand-seeded row hides. +func TestAutoRenewClients_RestoresOnlyWhatTheLifecycleJobDisabled(t *testing.T) { + setupBulkDB(t) + inboundSvc := &InboundService{} + clientSvc := &ClientService{} + + ib, _, err := inboundSvc.AddInbound(&model.Inbound{ + UserId: 1, Enable: true, Port: 30301, Protocol: model.VLESS, Tag: "in-30301", + Settings: `{"clients":[],"decryption":"none"}`, + StreamSettings: `{"network":"tcp","security":"none"}`, + }) + if err != nil { + t.Fatalf("AddInbound: %v", err) + } + // A whole second, as autoRenewClients compares against those, and far enough + // away for the operator's calls below to land before it. + deadline := (time.Now().Unix() + 3) * 1000 + wantEnable := map[string]bool{ + "bulk-banned@x": false, + "edit-banned@x": false, + "quota-spent@x": true, + "time-up@x": true, + } + for email := range wantEnable { + if _, err := clientSvc.Create(inboundSvc, &ClientCreatePayload{ + Client: model.Client{Email: email, Enable: true, Reset: 30, ExpiryTime: deadline, TotalGB: 100}, + InboundIds: []int{ib.Id}, + }); err != nil { + t.Fatalf("Create %s: %v", email, err) + } + } + record := func(email string) *model.ClientRecord { + t.Helper() + rec, err := clientSvc.GetRecordByEmail(nil, email) + if err != nil { + t.Fatalf("GetRecordByEmail %s: %v", email, err) + } + return rec + } + + if _, _, err := clientSvc.BulkSetEnable(inboundSvc, []string{"bulk-banned@x"}, false); err != nil { + t.Fatalf("BulkSetEnable: %v", err) + } + rec := record("edit-banned@x") + edited := rec.ToClient() + edited.Enable = false + if _, err := clientSvc.Update(inboundSvc, rec.Id, *edited, rec.LimitHwid); err != nil { + t.Fatalf("Update: %v", err) + } + if _, _, err := inboundSvc.AddTraffic(nil, []*xray.ClientTraffic{{Email: "quota-spent@x", Up: 60, Down: 60}}); err != nil { + t.Fatalf("AddTraffic: %v", err) + } + if time.Now().UnixMilli() >= deadline { + t.Fatal("the operator's calls landed after the deadline; the setup is too slow") + } + + time.Sleep(time.Until(time.UnixMilli(deadline))) + // A tick this close to the deadline disables what the next one renews. + if _, _, _, err := inboundSvc.disableInvalidClients(database.GetDB(), newTrafficMutationBatch()); err != nil { + t.Fatalf("disableInvalidClients: %v", err) + } + for email := range wantEnable { + if record(email).Enable { + t.Fatalf("%s is still enabled before the renewal", email) + } + } + + time.Sleep(time.Until(time.UnixMilli(deadline + 1000))) + if _, _, err := inboundSvc.AddTraffic(nil, nil); err != nil { + t.Fatalf("AddTraffic: %v", err) + } + for email, want := range wantEnable { + rec := record(email) + if rec.ExpiryTime <= deadline { + t.Errorf("%s was not renewed: expiry = %d", email, rec.ExpiryTime) + } + if rec.Enable != want { + t.Errorf("%s enable after the renewal = %v, want %v", email, rec.Enable, want) + } + var traffic xray.ClientTraffic + if err := database.GetDB().Where("email = ?", email).First(&traffic).Error; err != nil { + t.Fatalf("read client_traffics %s: %v", email, err) + } + if traffic.Enable != want { + t.Errorf("%s client_traffics.enable after the renewal = %v, want %v", email, traffic.Enable, want) + } + } +} diff --git a/internal/web/service/inbound_disable.go b/internal/web/service/inbound_disable.go index c9cbd4330..46cdbba51 100644 --- a/internal/web/service/inbound_disable.go +++ b/internal/web/service/inbound_disable.go @@ -288,3 +288,32 @@ func (s *InboundService) DepletedClientEmails(emails []string) (map[string]struc } return depleted, nil } + +// lifecycleDisabledEmails picks, out of switched-off stats rows, the ones that +// disableInvalidClients cleared; an operator's disable clears the flag as well. +func lifecycleDisabledEmails(tx *gorm.DB, rows []xray.ClientTraffic) (map[string]struct{}, error) { + emails := make([]string, 0, len(rows)) + for i := range rows { + emails = append(emails, rows[i].Email) + } + records, err := clientRecordsByEmail(tx, emails) + if err != nil { + return nil, err + } + // Cross-panel usage depletes too (depletedClientsCond); the overlay raises + // the counters in place, so callers hand it copies. + overlayGlobalTrafficValues(tx, rows) + disabled := make(map[string]struct{}, len(rows)) + for i := range rows { + r := &rows[i] + depleted := r.Total > 0 && r.Up+r.Down >= r.Total + // The job stamps the record once the deadline has passed (whole seconds + // is all a stamp pushed to a node keeps); an operator's disable came earlier. + record := records[r.Email] + expired := record != nil && r.ExpiryTime > 0 && record.UpdatedAt >= r.ExpiryTime/1000*1000 + if depleted || expired { + disabled[r.Email] = struct{}{} + } + } + return disabled, nil +} diff --git a/internal/web/service/inbound_traffic.go b/internal/web/service/inbound_traffic.go index 6722b0e20..595c58e43 100644 --- a/internal/web/service/inbound_traffic.go +++ b/internal/web/service/inbound_traffic.go @@ -462,13 +462,18 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB, mutationBatch *trafficMut // instead of a linear scan of every expired row (O(clients × expired) per // inbound, quadratic at scale). Pointers keep the in-place mutation below. trafficByEmail := make(map[string]*xray.ClientTraffic, len(traffics)) - // Keep the pre-renewal quota state: the shared pointer becomes enabled while - // processing the first inbound, while an already-enabled row paired with - // disabled settings represents an operator-disabled client we must preserve. - trafficWasEnabled := make(map[string]bool, len(traffics)) + // Judged on the pre-renewal rows, as the shared pointer is enabled while processing + // the first inbound: only what the lifecycle job switched off comes back. + var switchedOff []xray.ClientTraffic for i := range traffics { trafficByEmail[traffics[i].Email] = traffics[i] - trafficWasEnabled[traffics[i].Email] = traffics[i].Enable + if !traffics[i].Enable { + switchedOff = append(switchedOff, *traffics[i]) + } + } + lifecycleDisabled, err := lifecycleDisabledEmails(tx, switchedOff) + if err != nil { + return false, 0, err } renewedEmails := make([]string, 0, len(traffics)) for inbound_index := range inbounds { @@ -508,7 +513,7 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB, mutationBatch *trafficMut traffic.Up = 0 renewedEmails = append(renewedEmails, email) } - if !trafficWasEnabled[email] { + if _, restore := lifecycleDisabled[email]; restore { traffic.Enable = true c["enable"] = true key := inboundClientKey{inboundID: inbounds[inbound_index].Id, email: email} From 7f64a67ba53f44778cf1517de764c9acec3ebdad Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 08:31:27 +0100 Subject: [PATCH 077/125] fix(clients): re-enable only quota-disabled clients on a traffic reset Every traffic reset re-enabled its clients unconditionally, in the client record and in the traffic row a master treats as the client's state. A master forwards an inbound's scheduled reset and "reset all" to its nodes through the per-client reset calls, so a client disabled by hand on a node inbound came back on at the next scheduled reset. Each reset now decides, before it zeroes the counters, which of its clients the quota switched off, counting cross-panel usage, and re-enables only those; a client disabled below its quota keeps its record and traffic row off. The reset API docs say so. --- .../content/docs/en/reference/api/clients.mdx | 36 +++--- docs/public/openapi.json | 4 +- frontend/public/openapi.json | 4 +- frontend/src/pages/api-docs/endpoints.ts | 4 +- internal/web/service/bulk_traffic_test.go | 104 ++++++++++++++- internal/web/service/client_crud.go | 9 ++ .../web/service/client_reverse_readd_test.go | 4 +- internal/web/service/client_traffic.go | 119 ++++++++++++++---- .../web/service/client_update_enable_test.go | 5 +- internal/web/service/inbound_traffic.go | 20 ++- 10 files changed, 249 insertions(+), 60 deletions(-) diff --git a/docs/content/docs/en/reference/api/clients.mdx b/docs/content/docs/en/reference/api/clients.mdx index 5255194c7..ab790f938 100644 --- a/docs/content/docs/en/reference/api/clients.mdx +++ b/docs/content/docs/en/reference/api/clients.mdx @@ -182,10 +182,11 @@ _openapi: url: '#mirror-of-bulkattach-detach-many-existing-clients-from-many-inbounds-in-one-call-for-each-email-intersects-the-clients-current-inbounds-with-the-requested-set-and-detaches-from-those-only-email-inbound-pairs-where-the-client-is-not-currently-attached-are-silently-no-ops-emails-not-attached-to-any-of-the-requested-inbounds-are-reported-under-skipped-client-records-are-kept-even-if-they-become-orphaned--use-bulkdel-for-full-removal-returns-per-email-detachedskippederrors-lists-and-triggers-a-single-xray-restart-if-any-target-inbound-was-running' - depth: 2 title: Zero up/down counters for many clients in one call. Loops the - single-reset path so each client is re-enabled across its attached - inbounds and pushed to Xray/remote nodes. Returns the count of - successfully reset clients. - url: '#zero-updown-counters-for-many-clients-in-one-call-loops-the-single-reset-path-so-each-client-is-re-enabled-across-its-attached-inbounds-and-pushed-to-xrayremote-nodes-returns-the-count-of-successfully-reset-clients' + single-reset path so each client its quota had disabled is re-enabled + across its attached inbounds and pushed to Xray/remote nodes; a client + disabled by hand stays disabled. Returns the count of successfully reset + clients. + url: '#zero-updown-counters-for-many-clients-in-one-call-loops-the-single-reset-path-so-each-client-its-quota-had-disabled-is-re-enabled-across-its-attached-inbounds-and-pushed-to-xrayremote-nodes-a-client-disabled-by-hand-stays-disabled-returns-the-count-of-successfully-reset-clients' - depth: 2 title: List all client groups with their member counts. Merges persisted groups (rows in client_groups, including empty placeholders) with the distinct @@ -223,10 +224,11 @@ _openapi: client_groups row if the group exists only as a derived label. url: '#reset-only-the-group-level-traffic-counter-shown-on-the-groups-page-snapshots-the-current-updown-sum-of-the-groups-members-as-a-baseline-so-the-group-total-reads-zero-while-leaving-each-clients-own-counters-and-their-quotas-untouched-no-xray-restart-is-triggered-creates-the-client_groups-row-if-the-group-exists-only-as-a-derived-label' - depth: 2 - title: Zero out a single client’s up/down counters. Re-enables the client across - every attached inbound and pushes the change to Xray (or the remote - node) so depleted users can connect again immediately. - url: '#zero-out-a-single-clients-updown-counters-re-enables-the-client-across-every-attached-inbound-and-pushes-the-change-to-xray-or-the-remote-node-so-depleted-users-can-connect-again-immediately' + title: Zero out a single client’s up/down counters. Re-enables a client its + quota had disabled across every attached inbound and pushes the change + to Xray (or the remote node) so depleted users can connect again + immediately; a client disabled by hand stays disabled. + url: '#zero-out-a-single-clients-updown-counters-re-enables-a-client-its-quota-had-disabled-across-every-attached-inbound-and-pushes-the-change-to-xray-or-the-remote-node-so-depleted-users-can-connect-again-immediately-a-client-disabled-by-hand-stays-disabled' - depth: 2 title: Manually adjust a client’s upload + download counters. Useful for migrations from external accounting systems. @@ -457,10 +459,11 @@ _openapi: any target inbound was running." id: mirror-of-bulkattach-detach-many-existing-clients-from-many-inbounds-in-one-call-for-each-email-intersects-the-clients-current-inbounds-with-the-requested-set-and-detaches-from-those-only-email-inbound-pairs-where-the-client-is-not-currently-attached-are-silently-no-ops-emails-not-attached-to-any-of-the-requested-inbounds-are-reported-under-skipped-client-records-are-kept-even-if-they-become-orphaned--use-bulkdel-for-full-removal-returns-per-email-detachedskippederrors-lists-and-triggers-a-single-xray-restart-if-any-target-inbound-was-running - content: Zero up/down counters for many clients in one call. Loops the - single-reset path so each client is re-enabled across its attached - inbounds and pushed to Xray/remote nodes. Returns the count of - successfully reset clients. - id: zero-updown-counters-for-many-clients-in-one-call-loops-the-single-reset-path-so-each-client-is-re-enabled-across-its-attached-inbounds-and-pushed-to-xrayremote-nodes-returns-the-count-of-successfully-reset-clients + single-reset path so each client its quota had disabled is re-enabled + across its attached inbounds and pushed to Xray/remote nodes; a client + disabled by hand stays disabled. Returns the count of successfully + reset clients. + id: zero-updown-counters-for-many-clients-in-one-call-loops-the-single-reset-path-so-each-client-its-quota-had-disabled-is-re-enabled-across-its-attached-inbounds-and-pushed-to-xrayremote-nodes-a-client-disabled-by-hand-stays-disabled-returns-the-count-of-successfully-reset-clients - content: List all client groups with their member counts. Merges persisted groups (rows in client_groups, including empty placeholders) with the distinct group_name values currently set on clients. Sorted @@ -492,10 +495,11 @@ _openapi: Creates the client_groups row if the group exists only as a derived label. id: reset-only-the-group-level-traffic-counter-shown-on-the-groups-page-snapshots-the-current-updown-sum-of-the-groups-members-as-a-baseline-so-the-group-total-reads-zero-while-leaving-each-clients-own-counters-and-their-quotas-untouched-no-xray-restart-is-triggered-creates-the-client_groups-row-if-the-group-exists-only-as-a-derived-label - - content: Zero out a single client’s up/down counters. Re-enables the client - across every attached inbound and pushes the change to Xray (or the - remote node) so depleted users can connect again immediately. - id: zero-out-a-single-clients-updown-counters-re-enables-the-client-across-every-attached-inbound-and-pushes-the-change-to-xray-or-the-remote-node-so-depleted-users-can-connect-again-immediately + - content: Zero out a single client’s up/down counters. Re-enables a client its + quota had disabled across every attached inbound and pushes the change + to Xray (or the remote node) so depleted users can connect again + immediately; a client disabled by hand stays disabled. + id: zero-out-a-single-clients-updown-counters-re-enables-a-client-its-quota-had-disabled-across-every-attached-inbound-and-pushes-the-change-to-xray-or-the-remote-node-so-depleted-users-can-connect-again-immediately-a-client-disabled-by-hand-stays-disabled - content: Manually adjust a client’s upload + download counters. Useful for migrations from external accounting systems. id: manually-adjust-a-clients-upload--download-counters-useful-for-migrations-from-external-accounting-systems diff --git a/docs/public/openapi.json b/docs/public/openapi.json index 270316e56..9b90660cc 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -10163,7 +10163,7 @@ "tags": [ "Clients" ], - "summary": "Zero up/down counters for many clients in one call. Loops the single-reset path so each client is re-enabled across its attached inbounds and pushed to Xray/remote nodes. Returns the count of successfully reset clients.", + "summary": "Zero up/down counters for many clients in one call. Loops the single-reset path so each client its quota had disabled is re-enabled across its attached inbounds and pushed to Xray/remote nodes; a client disabled by hand stays disabled. Returns the count of successfully reset clients.", "operationId": "post_panel_api_clients_bulkResetTraffic", "requestBody": { "required": true, @@ -10504,7 +10504,7 @@ "tags": [ "Clients" ], - "summary": "Zero out a single client’s up/down counters. Re-enables the client across every attached inbound and pushes the change to Xray (or the remote node) so depleted users can connect again immediately.", + "summary": "Zero out a single client’s up/down counters. Re-enables a client its quota had disabled across every attached inbound and pushes the change to Xray (or the remote node) so depleted users can connect again immediately; a client disabled by hand stays disabled.", "operationId": "post_panel_api_clients_resetTraffic_email", "parameters": [ { diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index 270316e56..9b90660cc 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -10163,7 +10163,7 @@ "tags": [ "Clients" ], - "summary": "Zero up/down counters for many clients in one call. Loops the single-reset path so each client is re-enabled across its attached inbounds and pushed to Xray/remote nodes. Returns the count of successfully reset clients.", + "summary": "Zero up/down counters for many clients in one call. Loops the single-reset path so each client its quota had disabled is re-enabled across its attached inbounds and pushed to Xray/remote nodes; a client disabled by hand stays disabled. Returns the count of successfully reset clients.", "operationId": "post_panel_api_clients_bulkResetTraffic", "requestBody": { "required": true, @@ -10504,7 +10504,7 @@ "tags": [ "Clients" ], - "summary": "Zero out a single client’s up/down counters. Re-enables the client across every attached inbound and pushes the change to Xray (or the remote node) so depleted users can connect again immediately.", + "summary": "Zero out a single client’s up/down counters. Re-enables a client its quota had disabled across every attached inbound and pushes the change to Xray (or the remote node) so depleted users can connect again immediately; a client disabled by hand stays disabled.", "operationId": "post_panel_api_clients_resetTraffic_email", "parameters": [ { diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index 1d5cf3bc5..7e2fb34af 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/frontend/src/pages/api-docs/endpoints.ts @@ -1497,7 +1497,7 @@ export const sections: readonly Section[] = [ method: 'POST', path: '/panel/api/clients/bulkResetTraffic', summary: - 'Zero up/down counters for many clients in one call. Loops the single-reset path so each client is re-enabled across its attached inbounds and pushed to Xray/remote nodes. Returns the count of successfully reset clients.', + 'Zero up/down counters for many clients in one call. Loops the single-reset path so each client its quota had disabled is re-enabled across its attached inbounds and pushed to Xray/remote nodes; a client disabled by hand stays disabled. Returns the count of successfully reset clients.', body: '{\n "emails": ["alice", "bob"]\n}', response: '{\n "success": true,\n "obj": {\n "affected": 2\n }\n}', }, @@ -1553,7 +1553,7 @@ export const sections: readonly Section[] = [ method: 'POST', path: '/panel/api/clients/resetTraffic/:email', summary: - 'Zero out a single client’s up/down counters. Re-enables the client across every attached inbound and pushes the change to Xray (or the remote node) so depleted users can connect again immediately.', + 'Zero out a single client’s up/down counters. Re-enables a client its quota had disabled across every attached inbound and pushes the change to Xray (or the remote node) so depleted users can connect again immediately; a client disabled by hand stays disabled.', params: [{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' }], }, { diff --git a/internal/web/service/bulk_traffic_test.go b/internal/web/service/bulk_traffic_test.go index b3fc9fdfa..a49651f7c 100644 --- a/internal/web/service/bulk_traffic_test.go +++ b/internal/web/service/bulk_traffic_test.go @@ -1,6 +1,7 @@ package service import ( + "fmt" "testing" "time" @@ -48,7 +49,7 @@ func TestBulkResetTrafficZeroesUsageAndReenables(t *testing.T) { if err := svc.SyncInbound(nil, ib.Id, source); err != nil { t.Fatalf("seed linkage: %v", err) } - mkTraffic(t, ib.Id, "alice@x", 10, 20, 0, 0, false) + mkTraffic(t, ib.Id, "alice@x", 10, 20, 30, 0, false) mkTraffic(t, ib.Id, "bob@x", 5, 5, 0, 0, true) mkTraffic(t, ib.Id, "carol@x", 7, 0, 0, 0, true) @@ -231,3 +232,104 @@ func TestDelDepletedIgnoresPaddedUsageRow(t *testing.T) { t.Fatalf("alice was deleted through a look-alike usage row: %v", err) } } + +// trafficResets is every way to reset a client's traffic; a master forwards its +// scheduled and "reset all" resets to a node through the first two. +var trafficResets = []struct { + name string + run func(ib *model.Inbound, email string) error +}{ + {"ResetTrafficByEmail", func(_ *model.Inbound, email string) error { + _, err := (&ClientService{}).ResetTrafficByEmail(&InboundService{}, email) + return err + }}, + {"BulkResetTraffic", func(_ *model.Inbound, email string) error { + _, err := (&ClientService{}).BulkResetTraffic(&InboundService{}, []string{email}) + return err + }}, + {"ResetAllClientTraffics", func(ib *model.Inbound, _ string) error { + _, err := (&ClientService{}).ResetAllClientTraffics(&InboundService{}, ib.Id) + return err + }}, + {"ResetAllTraffics", func(*model.Inbound, string) error { + _, err := (&ClientService{}).ResetAllTraffics() + return err + }}, + {"ResetClientTraffic", func(ib *model.Inbound, email string) error { + _, err := (&InboundService{}).ResetClientTraffic(ib.Id, email) + return err + }}, + {"ResetClientTrafficByEmail", func(_ *model.Inbound, email string) error { + return (&InboundService{}).ResetClientTrafficByEmail(email) + }}, +} + +func TestTrafficReset_LeavesOperatorDisabledClientOff(t *testing.T) { + const email = "banned@x" + for _, total := range []int64{0, 1000} { + for _, reset := range trafficResets { + t.Run(fmt.Sprintf("%s/quota=%d", reset.name, total), func(t *testing.T) { + setupBulkDB(t) + svc := &ClientService{} + // Disabled with usage below quota: nothing but a human did that. + ib := seedLocalDisabledClient(t, svc, 52020, "", email, total, 0, 10, 10) + + if err := reset.run(ib, email); err != nil { + t.Fatalf("%s: %v", reset.name, err) + } + + assertEnableEverywhere(t, svc, &InboundService{}, ib.Id, email, false) + if tr := trafficOf(t, email); tr.Up != 0 || tr.Down != 0 { + t.Fatalf("traffic after reset up=%d down=%d, want 0/0", tr.Up, tr.Down) + } + }) + } + } +} + +func TestTrafficReset_RestoresQuotaDisabledClient(t *testing.T) { + const email = "depleted@x" + for _, reset := range trafficResets { + t.Run(reset.name, func(t *testing.T) { + setupBulkDB(t) + ib := seedLocalDisabledClient(t, &ClientService{}, 52021, "", email, 1000, 0, 600, 500) + + if err := reset.run(ib, email); err != nil { + t.Fatalf("%s: %v", reset.name, err) + } + + if tr := trafficOf(t, email); !tr.Enable || tr.Up != 0 || tr.Down != 0 { + t.Fatalf("traffic after reset enable=%v up=%d down=%d, want true 0/0", tr.Enable, tr.Up, tr.Down) + } + }) + } +} + +// A node cuts a client on the usage its master pushed while the node's own +// share is still below the quota; a reset must still count that as depleted. +func TestTrafficReset_RestoresClientDisabledByGlobalUsage(t *testing.T) { + const email = "shared@x" + for i, reset := range trafficResets { + t.Run(reset.name, func(t *testing.T) { + setupBulkDB(t) + svc := &ClientService{} + inboundSvc := &InboundService{} + ib := seedLocalDisabledClient(t, svc, 52022, "", email, 1000, 0, 100, 100) + if err := inboundSvc.AcceptGlobalTraffic("master-a", []*xray.ClientTraffic{{Email: email, Up: 800, Down: 700}}); err != nil { + t.Fatalf("AcceptGlobalTraffic: %v", err) + } + + if err := reset.run(ib, email); err != nil { + t.Fatalf("%s: %v", reset.name, err) + } + + if !trafficOf(t, email).Enable { + t.Fatal("client_traffics.enable = false: the pushed usage was not counted as depleted") + } + // Only the ClientService per-client resets restore the record and settings. + if i < 2 { + assertEnableEverywhere(t, svc, inboundSvc, ib.Id, email, true) + } + }) + } +} diff --git a/internal/web/service/client_crud.go b/internal/web/service/client_crud.go index 2b336d9b9..eb1135b9a 100644 --- a/internal/web/service/client_crud.go +++ b/internal/web/service/client_crud.go @@ -160,6 +160,15 @@ func quotaDisabledEmails(tx *gorm.DB, within []string) ([]string, error) { if err := clientResetCycles(tx).Where("c.enable = ?", false).Scan(&cycles).Error; err != nil { return nil, err } + // Cross-panel usage depletes too: a client cut off on pushed usage was the quota's doing. + rows := make([]xray.ClientTraffic, len(cycles)) + for i, c := range cycles { + rows[i] = xray.ClientTraffic{Email: c.Email, Up: c.Up, Down: c.Down, Total: c.Total} + } + overlayGlobalTrafficValues(tx, rows) + for i := range cycles { + cycles[i].Up, cycles[i].Down = rows[i].Up, rows[i].Down + } var in map[string]bool if within != nil { in = make(map[string]bool, len(within)) diff --git a/internal/web/service/client_reverse_readd_test.go b/internal/web/service/client_reverse_readd_test.go index 19b8694fb..498074658 100644 --- a/internal/web/service/client_reverse_readd_test.go +++ b/internal/web/service/client_reverse_readd_test.go @@ -137,12 +137,12 @@ func depleteClientTraffic(t *testing.T, inboundId int, email string) { t.Helper() db := database.GetDB() res := db.Model(&xray.ClientTraffic{}).Where("email = ?", email). - Updates(map[string]any{"enable": false, "up": 1, "down": 1}) + Updates(map[string]any{"enable": false, "up": 1, "down": 1, "total": 2}) if res.Error != nil { t.Fatalf("deplete traffic: %v", res.Error) } if res.RowsAffected == 0 { - if err := db.Create(&xray.ClientTraffic{InboundId: inboundId, Email: email, Enable: false, Up: 1, Down: 1}).Error; err != nil { + if err := db.Create(&xray.ClientTraffic{InboundId: inboundId, Email: email, Enable: false, Up: 1, Down: 1, Total: 2}).Error; err != nil { t.Fatalf("create depleted traffic: %v", err) } } diff --git a/internal/web/service/client_traffic.go b/internal/web/service/client_traffic.go index fb05aa5f4..73d663d2e 100644 --- a/internal/web/service/client_traffic.go +++ b/internal/web/service/client_traffic.go @@ -1,6 +1,7 @@ package service import ( + "slices" "time" "github.com/mhsanaei/3x-ui/v3/internal/database" @@ -24,6 +25,11 @@ func (s *ClientService) ResetTrafficByEmail(inboundSvc *InboundService, email st if err != nil { return false, err } + // Decided before the resets below zero the counters the rule reads. + quotaDisabled, err := quotaDisabledEmails(database.GetDB(), []string{email}) + if err != nil { + return false, err + } needRestart := false if len(inboundIds) == 0 { @@ -44,9 +50,9 @@ func (s *ClientService) ResetTrafficByEmail(inboundSvc *InboundService, email st needRestart = nr } - // Enable only once the counters are zero: a still-depleted client enabled - // first is switched off again by the next traffic tick. - if !rec.Enable { + // Enable only a client the quota switched off, once the counters are zero: a + // hand-disabled one stays off, and a still-depleted one would be switched off again. + if !rec.Enable && len(quotaDisabled) > 0 { updated := rec.ToClient() updated.Enable = true nr, uErr := s.Update(inboundSvc, rec.Id, *updated, rec.LimitHwid) @@ -73,22 +79,25 @@ func (s *ClientService) BulkResetTraffic(inboundSvc *InboundService, emails []st if err != nil { return 0, err } - affected := 0 + var affected int64 + var restore []string var resetNodes []int err = submitTrafficWrite(func() error { db := database.GetDB() return db.Transaction(func(tx *gorm.DB) error { + var err error + if restore, err = quotaDisabledEmails(tx, cleanEmails); err != nil { + return err + } + keepOff, err := handDisabledEmails(tx, cleanEmails) + if err != nil { + return err + } if err := adjustGroupBaselinesForRemovedTraffic(tx, cleanEmails); err != nil { return err } - for _, batch := range chunkStrings(cleanEmails, sqlInChunk) { - res := tx.Model(xray.ClientTraffic{}). - Where("email IN ?", batch). - Updates(map[string]any{"enable": true, "up": 0, "down": 0}) - if res.Error != nil { - return res.Error - } - affected += int(res.RowsAffected) + if affected, err = zeroClientTraffics(tx, cleanEmails, keepOff); err != nil { + return err } if err := clearGlobalTraffic(tx, cleanEmails...); err != nil { return err @@ -110,7 +119,7 @@ func (s *ClientService) BulkResetTraffic(inboundSvc *InboundService, emails []st inboundSvc.deliverNodeResetsNow(resetNodes) // After the zeroing, as in ResetTrafficByEmail: enabling a still-depleted // client first lets the next traffic tick switch it off again. - for _, e := range cleanEmails { + for _, e := range restore { rec := recordsByEmail[e] if rec == nil || rec.Enable { continue @@ -121,7 +130,7 @@ func (s *ClientService) BulkResetTraffic(inboundSvc *InboundService, emails []st logger.Warning("Failed to auto-enable client during bulk traffic reset:", uErr) } } - return affected, nil + return int(affected), nil } func (s *ClientService) ResetAllClientTraffics(inboundSvc *InboundService, id int) (bool, error) { @@ -180,17 +189,17 @@ func (s *ClientService) resetAllClientTrafficsLocked(id int) ([]string, []string if restore, qErr = quotaDisabledEmails(tx, resetEmails); qErr != nil { return qErr } + keepOff, qErr := handDisabledEmails(tx, resetEmails) + if qErr != nil { + return qErr + } if err := adjustGroupBaselinesForRemovedTraffic(tx, resetEmails); err != nil { return err } - result := tx.Model(xray.ClientTraffic{}). - Where("email IN ?", resetEmails). - Updates(map[string]any{"enable": true, "up": 0, "down": 0}) - - if result.Error != nil { - return result.Error + if _, err := zeroClientTraffics(tx, resetEmails, keepOff); err != nil { + return err } if err := clearGlobalTraffic(tx, resetEmails...); err != nil { @@ -213,7 +222,7 @@ func (s *ClientService) resetAllClientTrafficsLocked(id int) ([]string, []string inboundWhereText += " = ?" } - result = tx.Model(model.Inbound{}). + result := tx.Model(model.Inbound{}). Where(inboundWhereText, id). Update("last_traffic_reset_time", now) @@ -234,13 +243,13 @@ func (s *ClientService) ResetAllTraffics() (bool, error) { if restore, qErr = quotaDisabledEmails(tx, nil); qErr != nil { return qErr } - res := tx.Model(&xray.ClientTraffic{}). - Where("1 = 1"). - Updates(map[string]any{"enable": true, "up": 0, "down": 0}) - if res.Error != nil { - return res.Error + keepOff, qErr := handDisabledEmails(tx, nil) + if qErr != nil { + return qErr + } + if affected, qErr = zeroClientTraffics(tx, nil, keepOff); qErr != nil { + return qErr } - affected = res.RowsAffected if err := tx.Where("1 = 1").Delete(&model.ClientGlobalTraffic{}).Error; err != nil { return err } @@ -266,3 +275,59 @@ func (s *ClientService) ResetAllTraffics() (bool, error) { } return affected > 0 || needRestart, nil } + +// handDisabledEmails lists, among emails (every client when nil), the clients switched off by +// hand: a disabled record whose quota is not spent. Decide it before a reset zeroes the counters. +func handDisabledEmails(tx *gorm.DB, emails []string) ([]string, error) { + var disabled []string + q := func() *gorm.DB { return tx.Model(&model.ClientRecord{}).Where("enable = ?", false) } + if emails == nil { + if err := q().Pluck("email", &disabled).Error; err != nil { + return nil, err + } + } else { + for _, batch := range chunkStrings(emails, sqlInChunk) { + var page []string + if err := q().Where("email IN ?", batch).Pluck("email", &page).Error; err != nil { + return nil, err + } + disabled = append(disabled, page...) + } + } + if len(disabled) == 0 { + return nil, nil + } + quota, err := quotaDisabledEmails(tx, disabled) + if err != nil { + return nil, err + } + return slices.DeleteFunc(disabled, func(e string) bool { return slices.Contains(quota, e) }), nil +} + +// zeroClientTraffics zeroes the counters of emails (every row when nil) and lifts their traffic +// gate, except for keepOff: a master pushes that flag to its nodes as the client's state. +func zeroClientTraffics(tx *gorm.DB, emails, keepOff []string) (int64, error) { + var affected int64 + reset := map[string]any{"enable": true, "up": 0, "down": 0} + if emails == nil { + res := tx.Model(&xray.ClientTraffic{}).Where("1 = 1").Updates(reset) + if res.Error != nil { + return 0, res.Error + } + affected = res.RowsAffected + } else { + for _, batch := range chunkStrings(emails, sqlInChunk) { + res := tx.Model(&xray.ClientTraffic{}).Where("email IN ?", batch).Updates(reset) + if res.Error != nil { + return 0, res.Error + } + affected += res.RowsAffected + } + } + for _, batch := range chunkStrings(keepOff, sqlInChunk) { + if err := tx.Model(&xray.ClientTraffic{}).Where("email IN ?", batch).Update("enable", false).Error; err != nil { + return 0, err + } + } + return affected, nil +} diff --git a/internal/web/service/client_update_enable_test.go b/internal/web/service/client_update_enable_test.go index 191c73533..599797a81 100644 --- a/internal/web/service/client_update_enable_test.go +++ b/internal/web/service/client_update_enable_test.go @@ -103,12 +103,12 @@ func TestResetTrafficByEmail_LeavesRecordEnableTrue(t *testing.T) { inboundSvc := &InboundService{} email := "r-attached@x" - c := model.Client{Email: email, ID: "11111111-1111-1111-1111-111111111111", SubID: email, Enable: false} + c := model.Client{Email: email, ID: "11111111-1111-1111-1111-111111111111", SubID: email, Enable: false, TotalGB: 30} ib := mkInbound(t, 53003, model.VLESS, clientsSettings(t, []model.Client{c})) if err := svc.SyncInbound(nil, ib.Id, []model.Client{c}); err != nil { t.Fatalf("seed linkage: %v", err) } - mkTraffic(t, ib.Id, email, 10, 20, 0, 0, false) + mkTraffic(t, ib.Id, email, 10, 20, 30, 0, false) if _, err := svc.ResetTrafficByEmail(inboundSvc, email); err != nil { t.Fatalf("ResetTrafficByEmail: %v", err) @@ -142,6 +142,7 @@ func TestResetTrafficByEmail_NoInbound_LeavesRecordEnableTrue(t *testing.T) { t.Fatalf("create record: %v", err) } forceRecordDisabled(t, svc, email) + mkTraffic(t, 0, email, 10, 20, 30, 0, false) if _, err := svc.ResetTrafficByEmail(inboundSvc, email); err != nil { t.Fatalf("ResetTrafficByEmail: %v", err) diff --git a/internal/web/service/inbound_traffic.go b/internal/web/service/inbound_traffic.go index 595c58e43..ef6e96564 100644 --- a/internal/web/service/inbound_traffic.go +++ b/internal/web/service/inbound_traffic.go @@ -680,12 +680,14 @@ func (s *InboundService) ResetClientTrafficByEmail(clientEmail string) error { if err := adjustGroupBaselinesForRemovedTraffic(tx, []string{clientEmail}); err != nil { return err } - if err := clearGlobalTraffic(tx, clientEmail); err != nil { + keepOff, err := handDisabledEmails(tx, []string{clientEmail}) + if err != nil { return err } - if err := tx.Model(xray.ClientTraffic{}). - Where("email = ?", clientEmail). - Updates(map[string]any{"enable": true, "up": 0, "down": 0}).Error; err != nil { + if _, err := zeroClientTraffics(tx, []string{clientEmail}, keepOff); err != nil { + return err + } + if err := clearGlobalTraffic(tx, clientEmail); err != nil { return err } return tx.Where("email = ?", clientEmail).Delete(&model.NodeClientTraffic{}).Error @@ -723,8 +725,14 @@ func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (b if err != nil { return false, nil, err } + // A reset lifts the quota gate only: a client switched off by hand stays off. + keepOff, err := handDisabledEmails(database.GetDB(), []string{clientEmail}) + if err != nil { + return false, nil, err + } + handDisabled := len(keepOff) > 0 - if !traffic.Enable { + if !traffic.Enable && !handDisabled { inbound, err := s.GetInbound(id) if err != nil { return false, nil, err @@ -766,7 +774,7 @@ func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (b traffic.Up = 0 traffic.Down = 0 - traffic.Enable = true + traffic.Enable = !handDisabled db := database.GetDB() now := time.Now().UnixMilli() From 2024d349287e5c32ad6c5da6482168517a459dc6 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 08:33:19 +0100 Subject: [PATCH 078/125] fix(geodata): index a split category's attributes in one pass scanIndex merged each further record of an already-seen category with mergeAttributes, which rebuilt a map and re-sorted every attribute the category had so far. A category with A attributes split over N records cost A*N, about an hour and a half for a 5 MB file, while the scan lock was held and every geodata route waited on it. Each category now collects its attributes in one set during the walk, and the sorted lists are built once at the end. --- internal/xray/geodata/geodata_test.go | 32 ++++++++++++++++++++++ internal/xray/geodata/reader.go | 38 ++++++++++----------------- 2 files changed, 46 insertions(+), 24 deletions(-) diff --git a/internal/xray/geodata/geodata_test.go b/internal/xray/geodata/geodata_test.go index 00686a1b2..35ac99ac9 100644 --- a/internal/xray/geodata/geodata_test.go +++ b/internal/xray/geodata/geodata_test.go @@ -6,6 +6,7 @@ import ( "net/netip" "os" "path/filepath" + "slices" "strconv" "strings" "sync" @@ -407,6 +408,37 @@ func TestFileAboveSizeLimitIsRejected(t *testing.T) { } } +// Rebuilding the merged attribute list for every further record of a category +// made this sub-megabyte database take minutes to index instead of milliseconds. +func TestCategorySplitOverManyRecordsIsIndexedInOnePass(t *testing.T) { + dir := t.TempDir() + tagged := make([]*xraygeodata.Domain, 0, 20000) + for i := range 20000 { + tagged = append(tagged, domain(xraygeodata.Domain_Full, "a.example", strconv.Itoa(i))) + } + sites := []*xraygeodata.GeoSite{site("x", tagged...)} + for range 20000 { + sites = append(sites, site("x", domain(xraygeodata.Domain_Full, "a.example", "extra"))) + } + writeSiteDB(t, dir, "split.dat", sites...) + + started := time.Now() + page, err := NewStore(dir).Categories("split.dat", "", 0, 1) + if err != nil { + t.Fatalf("Categories() error = %v", err) + } + if elapsed := time.Since(started); elapsed > 10*time.Second { + t.Errorf("indexing took %s", elapsed) + } + got := page.Items[0] + if got.Entries != 40000 || len(got.Attributes) != 20001 { + t.Fatalf("category x: %d entries and %d attributes, want 40000 and 20001", got.Entries, len(got.Attributes)) + } + if !slices.IsSorted(got.Attributes) || !slices.Contains(got.Attributes, "extra") { + t.Errorf("attributes are not the sorted union of every record: %q ... %q", got.Attributes[0], got.Attributes[20000]) + } +} + func TestIndexCacheInvalidatedWhenFileChanges(t *testing.T) { dir := t.TempDir() sampleSiteDB(t, dir) diff --git a/internal/xray/geodata/reader.go b/internal/xray/geodata/reader.go index 88500303f..eaaa1bf4a 100644 --- a/internal/xray/geodata/reader.go +++ b/internal/xray/geodata/reader.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "io" + "maps" "net/netip" "os" "sort" @@ -53,6 +54,9 @@ type byteSpan struct { func scanIndex(data []byte, kind GeoKind) (*categoryScan, error) { scan := &categoryScan{kind: kind, spans: make(map[string][]byteSpan)} byCode := make(map[string]int) + // Parallel to scan.categories: a category split over many records adds each + // record's own attributes to its set and is sorted once, at the end. + var attributeSets []map[string]struct{} err := eachListEntry(data, func(entry []byte, span byteSpan) error { count := 0 @@ -88,20 +92,20 @@ func scanIndex(data []byte, kind GeoKind) (*categoryScan, error) { scan.spans[code] = append(scan.spans[code], span) if position, seen := byCode[code]; seen { scan.categories[position].Entries += count - scan.categories[position].Attributes = mergeAttributes(scan.categories[position].Attributes, attributes) + maps.Copy(attributeSets[position], attributes) return nil } byCode[code] = len(scan.categories) - scan.categories = append(scan.categories, GeoCategory{ - Code: code, - Entries: count, - Attributes: mergeAttributes(nil, attributes), - }) + scan.categories = append(scan.categories, GeoCategory{Code: code, Entries: count}) + attributeSets = append(attributeSets, attributes) return nil }) if err != nil { return nil, err } + for position, attributes := range attributeSets { + scan.categories[position].Attributes = sortedAttributes(attributes) + } sort.Slice(scan.categories, func(i, j int) bool { return scan.categories[i].Code < scan.categories[j].Code }) return scan, nil } @@ -461,25 +465,11 @@ func cidrBytes(payload []byte) ([]byte, bool, error) { return []byte(netip.PrefixFrom(addr, int(prefix)).String()), true, nil } -// mergeAttributes always returns a non-nil slice: the JSON contract declares -// attributes as an array, and a nil slice would marshal to null and break -// clients validating against it. -func mergeAttributes(existing []string, attributes map[string]struct{}) []string { - if len(attributes) == 0 { - if existing == nil { - return []string{} - } - return existing - } - merged := make(map[string]struct{}, len(existing)+len(attributes)) - for _, attribute := range existing { - merged[attribute] = struct{}{} - } +// sortedAttributes always returns a non-nil slice: the JSON contract declares +// attributes as an array, and a nil slice would marshal to null. +func sortedAttributes(attributes map[string]struct{}) []string { + out := make([]string, 0, len(attributes)) for attribute := range attributes { - merged[attribute] = struct{}{} - } - out := make([]string, 0, len(merged)) - for attribute := range merged { out = append(out, attribute) } sort.Strings(out) From bd360533568dd23b98705c694dd66f9234584fae Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 08:37:01 +0100 Subject: [PATCH 079/125] fix(clients): match non-ASCII capitals in the shared-email checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The checks that decide whether a client_traffics row is still used by another inbound, whether an email is already taken under another subId, and which depleted rows a scoped delete may drop compared a Go-lowered email with LOWER(clients.email), which SQLite folds for ASCII only. An email such as Иван was never seen as attached elsewhere, so the node sweep deleted the usage row of a client the master's own inbound still served and the client outlived its expiry and quota. Each check now matches the lowered or the stored spelling and keys the result in Go, so ASCII behaviour is unchanged. --- .../client_identity_normalized_test.go | 155 ++++++++++++++++++ internal/web/service/inbound.go | 19 +-- internal/web/service/inbound_clients.go | 56 ++++--- internal/web/service/inbound_traffic.go | 37 +++-- 4 files changed, 216 insertions(+), 51 deletions(-) diff --git a/internal/web/service/client_identity_normalized_test.go b/internal/web/service/client_identity_normalized_test.go index 5b1906715..57b17e9d7 100644 --- a/internal/web/service/client_identity_normalized_test.go +++ b/internal/web/service/client_identity_normalized_test.go @@ -4,8 +4,11 @@ import ( "strings" "testing" + "gorm.io/gorm" + "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/web/runtime" "github.com/mhsanaei/3x-ui/v3/internal/xray" ) @@ -243,3 +246,155 @@ func settingsSubID(t *testing.T, inboundId int, email string) string { t.Fatalf("%q not found in settings", email) return "" } + +func attachEmailToInbound(t *testing.T, db *gorm.DB, tag, email string) { + t.Helper() + id := nodeInboundID(t, db, tag) + clients, err := (&ClientService{}).ListForInbound(db, id) + if err != nil { + t.Fatalf("list clients of %q: %v", tag, err) + } + clients = append(clients, model.Client{Email: email, Enable: true}) + if err := (&ClientService{}).SyncInbound(db, id, clients); err != nil { + t.Fatalf("attach %q to %q: %v", email, tag, err) + } +} + +func countTrafficRows(t *testing.T, db *gorm.DB, email string) int64 { + t.Helper() + var rows int64 + if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).Count(&rows).Error; err != nil { + t.Fatalf("count rows of %q: %v", email, err) + } + return rows +} + +// SQLite's LOWER() folds ASCII only, so an email with a non-ASCII capital must +// still be recognised as attached to a sibling inbound. +func TestEmailUsedByOtherInbounds_NonASCIICapital(t *testing.T) { + db := initTrafficTestDB(t) + createNodeInbound(t, db, 1, "n1-a", 41001) + createNodeInbound(t, db, 1, "n1-b", 41002) + svc := &InboundService{} + + emails := []string{"Ivan", "Иван", "Ömer", "ÉMILE"} + for _, email := range emails { + attachEmailToInbound(t, db, "n1-a", email) + attachEmailToInbound(t, db, "n1-b", email) + } + except := nodeInboundID(t, db, "n1-a") + + shared, err := svc.emailsUsedByOtherInbounds(db, emails, except) + if err != nil { + t.Fatalf("emailsUsedByOtherInbounds: %v", err) + } + for _, email := range emails { + used, err := svc.emailUsedByOtherInbounds(db, email, except) + if err != nil { + t.Fatalf("emailUsedByOtherInbounds(%q): %v", email, err) + } + if !used { + t.Errorf("emailUsedByOtherInbounds(%q) = false, the client is attached to n1-b", email) + } + if !shared[strings.ToLower(email)] { + t.Errorf("emailsUsedByOtherInbounds misses %q, the client is attached to n1-b", email) + } + } +} + +// The traffic row holds usage, quota and expiry for every inbound of the client: +// a node dropping its inbound must not take it from the panel's own inbound. +func TestNodeInboundSweep_KeepsSharedRowOfNonASCIICapitalEmail(t *testing.T) { + db := initTrafficTestDB(t) + own := &model.Inbound{UserId: 1, Tag: "own-in", Enable: true, Port: 41000, Protocol: model.VLESS} + if err := db.Create(own).Error; err != nil { + t.Fatalf("create own inbound: %v", err) + } + createNodeInbound(t, db, 1, "n1-a", 41001) + createNodeInbound(t, db, 1, "n1-b", 41002) + svc := &InboundService{} + + for _, email := range []string{"Ivan", "Иван"} { + client := model.Client{Email: email, Enable: true, TotalGB: 1 << 30} + attachEmailToInbound(t, db, "own-in", email) + attachEmailToInbound(t, db, "n1-a", email) + if err := svc.AddClientStat(db, nodeInboundID(t, db, "n1-a"), &client); err != nil { + t.Fatalf("AddClientStat(%q): %v", email, err) + } + } + + snap := &runtime.TrafficSnapshot{Inbounds: []*model.Inbound{{Tag: "n1-b"}}} + if _, err := svc.setRemoteTrafficLocked(1, snap, false, false); err != nil { + t.Fatalf("setRemoteTrafficLocked: %v", err) + } + + for _, email := range []string{"Ivan", "Иван"} { + if rows := countTrafficRows(t, db, email); rows != 1 { + t.Errorf("%q is still attached to own-in but has %d client_traffics rows, want 1", email, rows) + } + } +} + +// The duplicate-email check folds in Go as well: neither the stored spelling nor +// a case variant of it may be claimed under another subId. +func TestAddInboundClientRejectsTakenNonASCIICapitalEmail(t *testing.T) { + for _, email := range []string{"Иван", "иван", "ИВАН"} { + t.Run(email, func(t *testing.T) { + setupBulkDB(t) + cs := &ClientService{} + is := &InboundService{} + + seed := []model.Client{{ID: "id-ivan", Email: "Иван", Enable: true, SubID: "s-ivan"}} + owner := mkInbound(t, 21161, model.VLESS, clientsSettings(t, seed)) + if err := cs.SyncInbound(nil, owner.Id, seed); err != nil { + t.Fatalf("seed SyncInbound: %v", err) + } + target := mkInbound(t, 21162, model.VLESS, `{"clients": []}`) + + add := []model.Client{{ID: "id-other", Email: email, Enable: true, SubID: "s-other"}} + _, err := cs.AddInboundClient(is, &model.Inbound{Id: target.Id, Settings: clientsSettings(t, add)}) + if err == nil { + t.Fatalf("%q was accepted although Иван is taken", email) + } + if !strings.Contains(err.Error(), "Duplicate email") { + t.Errorf("error = %q, want it to mention Duplicate email", err) + } + }) + } +} + +// Scoped to one inbound, a depleted row goes only with the email's last inbound; +// a row that is not shared goes even when LOWER() cannot fold its email. +func TestDelDepletedClients_NonASCIICapitalEmail(t *testing.T) { + setupBulkDB(t) + cs := &ClientService{} + is := &InboundService{} + db := database.GetDB() + + shared := model.Client{ID: "id-ivan", Email: "Иван", Enable: true, SubID: "s-ivan"} + single := model.Client{ID: "id-omer", Email: "Ömer", Enable: true, SubID: "s-omer"} + keep := model.Client{ID: "id-keep", Email: "keep@x", Enable: true, SubID: "s-keep"} + ibA := mkInbound(t, 21171, model.VLESS, clientsSettings(t, []model.Client{shared, single, keep})) + ibB := mkInbound(t, 21172, model.VLESS, clientsSettings(t, []model.Client{shared})) + if err := cs.SyncInbound(nil, ibA.Id, []model.Client{shared, single, keep}); err != nil { + t.Fatalf("seed A: %v", err) + } + if err := cs.SyncInbound(nil, ibB.Id, []model.Client{shared}); err != nil { + t.Fatalf("seed B: %v", err) + } + for _, email := range []string{"Иван", "Ömer"} { + if err := db.Create(&xray.ClientTraffic{InboundId: ibA.Id, Email: email, Enable: true, Up: 100, Total: 100}).Error; err != nil { + t.Fatalf("seed traffic of %q: %v", email, err) + } + } + + if err := is.DelDepletedClients(ibA.Id); err != nil { + t.Fatalf("DelDepletedClients: %v", err) + } + + for email, want := range map[string]int64{"Иван": 1, "Ömer": 0} { + if rows := countTrafficRows(t, db, email); rows != want { + t.Errorf("%q has %d client_traffics rows, want %d", email, rows, want) + } + } +} diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go index 940fedbab..9a6d4e1ab 100644 --- a/internal/web/service/inbound.go +++ b/internal/web/service/inbound.go @@ -616,29 +616,20 @@ func (s *InboundService) GetAllEmails() ([]string, error) { // checked. One clients row owns an email's identity, so the answer no longer // needs a scan of every inbound's settings JSON (#6252). func (s *InboundService) emailSubIDsForClients(clients []model.Client) (map[string]string, error) { - want := make(map[string]struct{}, len(clients)) + emails := make([]string, 0, len(clients)) for i := range clients { - if email := strings.ToLower(strings.TrimSpace(clients[i].Email)); email != "" { - want[email] = struct{}{} - } - } - result := make(map[string]string, len(want)) - if len(want) == 0 { - return result, nil - } - lowered := make([]string, 0, len(want)) - for email := range want { - lowered = append(lowered, email) + emails = append(emails, clients[i].Email) } + result := make(map[string]string, len(emails)) db := database.GetDB() - for _, batch := range chunkStrings(lowered, sqlInChunk) { + for _, batch := range chunkStrings(emailSpellings(emails), sqlInChunk) { var rows []struct { Email string SubID string `gorm:"column:sub_id"` } err := db.Model(&model.ClientRecord{}). Select("email, sub_id"). - Where("LOWER(email) IN ?", batch). + Where("LOWER(email) IN ? OR email IN ?", batch, batch). Scan(&rows).Error if err != nil { return nil, err diff --git a/internal/web/service/inbound_clients.go b/internal/web/service/inbound_clients.go index 010440de4..c8b26f60d 100644 --- a/internal/web/service/inbound_clients.go +++ b/internal/web/service/inbound_clients.go @@ -6,6 +6,7 @@ import ( "fmt" "strings" "time" + "unicode" "github.com/google/uuid" @@ -123,17 +124,42 @@ func (s *InboundService) backfillClientStats(db *gorm.DB, inbounds []*model.Inbo return clientsByInbound } +// emailSpellings returns the arguments a "LOWER(col) IN ? OR col IN ?" match +// needs: SQLite's LOWER() folds ASCII only, so non-ASCII capitals get every case. +func emailSpellings(emails []string) []string { + seen := make(map[string]struct{}, len(emails)) + out := make([]string, 0, len(emails)) + for _, e := range emails { + e = strings.TrimSpace(e) + if e == "" { + continue + } + variants := []string{strings.ToLower(e)} + if strings.ContainsFunc(e, func(r rune) bool { return r > unicode.MaxASCII }) { + variants = caseVariants(e) + } + for _, v := range variants { + if _, dup := seen[v]; !dup { + seen[v] = struct{}{} + out = append(out, v) + } + } + } + return out +} + // emailUsedByOtherInbounds reports whether email lives in any inbound other than // exceptInboundId. Inside a transaction pass tx: a pool query there starves the pool. func (s *InboundService) emailUsedByOtherInbounds(db *gorm.DB, email string, exceptInboundId int) (bool, error) { - if email == "" { + spellings := emailSpellings([]string{email}) + if len(spellings) == 0 { return false, nil } var count int64 err := db.Table("client_inbounds"). Joins("JOIN clients ON clients.id = client_inbounds.client_id"). - Where("client_inbounds.inbound_id != ? AND LOWER(clients.email) = ?", - exceptInboundId, strings.ToLower(strings.TrimSpace(email))). + Where("client_inbounds.inbound_id != ? AND (LOWER(clients.email) IN ? OR clients.email IN ?)", + exceptInboundId, spellings, spellings). Count(&count).Error if err != nil { return false, err @@ -141,34 +167,22 @@ func (s *InboundService) emailUsedByOtherInbounds(db *gorm.DB, email string, exc return count > 0, nil } +// emailsUsedByOtherInbounds is emailUsedByOtherInbounds for a batch; the result +// is keyed by the lower-cased email. func (s *InboundService) emailsUsedByOtherInbounds(db *gorm.DB, emails []string, exceptInboundId int) (map[string]bool, error) { shared := make(map[string]bool, len(emails)) - want := make(map[string]struct{}, len(emails)) - for _, e := range emails { - e = strings.ToLower(strings.TrimSpace(e)) - if e != "" { - want[e] = struct{}{} - } - } - if len(want) == 0 { - return shared, nil - } - lowered := make([]string, 0, len(want)) - for e := range want { - lowered = append(lowered, e) - } - for _, batch := range chunkStrings(lowered, sqlInChunk) { + for _, batch := range chunkStrings(emailSpellings(emails), sqlInChunk) { var rows []struct{ Email string } err := db.Table("client_inbounds"). Joins("JOIN clients ON clients.id = client_inbounds.client_id"). - Select("DISTINCT LOWER(clients.email) AS email"). - Where("client_inbounds.inbound_id != ? AND LOWER(clients.email) IN ?", exceptInboundId, batch). + Select("DISTINCT clients.email AS email"). + Where("client_inbounds.inbound_id != ? AND (LOWER(clients.email) IN ? OR clients.email IN ?)", exceptInboundId, batch, batch). Scan(&rows).Error if err != nil { return nil, err } for _, r := range rows { - shared[r.Email] = true + shared[strings.ToLower(r.Email)] = true } } return shared, nil diff --git a/internal/web/service/inbound_traffic.go b/internal/web/service/inbound_traffic.go index ef6e96564..9fd889c4e 100644 --- a/internal/web/service/inbound_traffic.go +++ b/internal/web/service/inbound_traffic.go @@ -1012,33 +1012,38 @@ func (s *InboundService) DelDepletedClients(id int) (err error) { if id < 0 { return tx.Where(depletedClause, now).Delete(xray.ClientTraffic{}).Error } - emails := make([]string, 0, len(depletedEmails)) - for e := range depletedEmails { - emails = append(emails, e) + rowEmails := make([]string, 0, len(depletedRows)) + for _, r := range depletedRows { + if r.Email != "" { + rowEmails = append(rowEmails, r.Email) + } } - var stillReferenced []string emailExpr := database.JSONFieldText("client.value", "email") stillQuery := fmt.Sprintf( - "SELECT DISTINCT LOWER(%s) %s WHERE LOWER(%s) IN ?", + "SELECT DISTINCT %s %s WHERE LOWER(%s) IN ? OR %s IN ?", emailExpr, database.JSONClientsFromInbound(), emailExpr, + emailExpr, ) - if err := tx.Raw(stillQuery, emails).Scan(&stillReferenced).Error; err != nil { - return err + stillSet := make(map[string]struct{}, len(rowEmails)) + for _, batch := range chunkStrings(emailSpellings(rowEmails), sqlInChunk) { + var stillReferenced []string + if err := tx.Raw(stillQuery, batch, batch).Scan(&stillReferenced).Error; err != nil { + return err + } + for _, e := range stillReferenced { + stillSet[strings.ToLower(e)] = struct{}{} + } } - stillSet := make(map[string]struct{}, len(stillReferenced)) - for _, e := range stillReferenced { - stillSet[e] = struct{}{} - } - toDelete := make([]string, 0, len(emails)) - for _, e := range emails { - if _, kept := stillSet[e]; !kept { + toDelete := make([]string, 0, len(rowEmails)) + for _, e := range rowEmails { + if _, kept := stillSet[strings.ToLower(e)]; !kept { toDelete = append(toDelete, e) } } - if len(toDelete) > 0 { - if err := tx.Where("LOWER(email) IN ?", toDelete).Delete(xray.ClientTraffic{}).Error; err != nil { + for _, batch := range chunkStrings(emailSpellings(toDelete), sqlInChunk) { + if err := tx.Where("LOWER(email) IN ? OR email IN ?", batch, batch).Delete(xray.ClientTraffic{}).Error; err != nil { return err } } From f8ceede46f8f0177c4eae2b5989ca90e49743fdd Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 08:39:50 +0100 Subject: [PATCH 080/125] fix(hwid): keep a subscription's device limit while its client is disabled effectiveHwidLimitForSubID took the limit over enabled clients only. A client switched off for expiry, quota or by hand therefore had no limit at all, while the subscription body is still served for disabled clients, so any number of devices registered and stayed past the renewal. The limit now follows the subId whatever the enable flag, and the gate, the slot status and the trim keep sharing that one query. --- internal/web/service/client_hwid.go | 6 ++-- internal/web/service/client_hwid_test.go | 42 ++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/internal/web/service/client_hwid.go b/internal/web/service/client_hwid.go index 6d7b01b97..813dbd605 100644 --- a/internal/web/service/client_hwid.go +++ b/internal/web/service/client_hwid.go @@ -92,10 +92,12 @@ func normalizeHwidRequest(req HwidRequest) HwidRequest { } } +// The subscription body is built for disabled clients too, so an expired, +// depleted or suspended client must keep holding the limit of its subId. func effectiveHwidLimitForSubID(tx *gorm.DB, subID string) (int, error) { var limit int err := tx.Model(&model.ClientRecord{}). - Where("sub_id = ? AND enable = ?", subID, true). + Where("sub_id = ?", subID). Select("COALESCE(MAX(limit_hwid), 0)"). Scan(&limit).Error return limit, err @@ -216,7 +218,7 @@ func trackUnlimitedHwid(db *gorm.DB, subID string, req HwidRequest) { } // HwidSlotStatusForSubID is SELECT-only: it must never write client_hwids or -// last_seen. Enabled-clients scope mirrors the gate, so limit == limit enforced. +// last_seen. It shares the gate's limit query, so limit == limit enforced. func (s *ClientService) HwidSlotStatusForSubID(subID string) (status HwidSlotStatus, found bool, err error) { subID = strings.TrimSpace(subID) if subID == "" { diff --git a/internal/web/service/client_hwid_test.go b/internal/web/service/client_hwid_test.go index 2d960322a..11a3e13df 100644 --- a/internal/web/service/client_hwid_test.go +++ b/internal/web/service/client_hwid_test.go @@ -288,6 +288,48 @@ func TestClientHwidGateSharedSubIdUsesMaxLimit(t *testing.T) { } } +func TestClientHwidGateHoldsWhileClientDisabled(t *testing.T) { + initClientHwidTestDB(t) + svc := &ClientService{} + db := database.GetDB() + rec := seedHwidClient(t, 1) + + if res, err := svc.EnforceHwidForSubID(rec.SubID, HwidRequest{Hwid: "device-one"}); err != nil || !res.Allowed { + t.Fatalf("register first device = %+v, %v", res, err) + } + if err := db.Model(&model.ClientRecord{}).Where("id = ?", rec.Id).UpdateColumn("enable", false).Error; err != nil { + t.Fatalf("disable client: %v", err) + } + + assertStillLimited := func(stage string) { + t.Helper() + res, err := svc.EnforceHwidForSubID(rec.SubID, HwidRequest{Hwid: "device-two"}) + if err != nil { + t.Fatalf("%s: second device: %v", stage, err) + } + if res.Allowed || !res.MaxDevicesReached || res.Limit != 1 { + t.Fatalf("%s: disabled client must keep its device limit: %+v", stage, res) + } + if res, err = svc.EnforceHwidForSubID(rec.SubID, HwidRequest{Hwid: "device-one"}); err != nil || !res.Allowed { + t.Fatalf("%s: registered device = %+v, %v", stage, res, err) + } + var count int64 + if err := db.Model(&model.ClientHwid{}).Where("sub_id = ?", rec.SubID).Count(&count).Error; err != nil { + t.Fatalf("%s: count HWIDs: %v", stage, err) + } + if count != 1 { + t.Fatalf("%s: stored HWIDs = %d, want 1", stage, count) + } + } + assertStillLimited("only client disabled") + + // An enabled sibling without a limit must not lift it either. + if err := db.Create(&model.ClientRecord{Email: "second@example.com", SubID: rec.SubID, UUID: "22222222-2222-4333-8444-555555555555", Enable: true, LimitHwid: 0}).Error; err != nil { + t.Fatalf("seed second client: %v", err) + } + assertStillLimited("unlimited sibling enabled") +} + func TestClientHwidSlotStatus(t *testing.T) { initClientHwidTestDB(t) svc := &ClientService{} From 0972ac31eb12ed9d36d1ea988823612ce2600cc4 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 08:44:16 +0100 Subject: [PATCH 081/125] fix(clients): collapse repeated inbound ids before fanning a client out Create, BulkCreate, Attach and BulkAttach built one marshalled copy of the client per entry of the request's inboundIds and held all of them until the fan-out returned, with repeated ids never collapsed. An id costs two bytes of request body, so a large client named on the same inbound a few thousand times needed gigabytes and the panel died. Every fan-out entry point now collapses the id list first, keeping the order of first occurrence so credentials are still minted on the first inbound. --- internal/web/service/client_bulk.go | 3 +- .../web/service/client_create_fanout_test.go | 53 +++++++++++++++++++ internal/web/service/client_crud.go | 3 ++ internal/web/service/inbound_node.go | 2 +- 4 files changed, 59 insertions(+), 2 deletions(-) diff --git a/internal/web/service/client_bulk.go b/internal/web/service/client_bulk.go index 89b241dc8..f6fe94586 100644 --- a/internal/web/service/client_bulk.go +++ b/internal/web/service/client_bulk.go @@ -30,6 +30,7 @@ type BulkAttachResult struct { // all clients to a target in a single AddInboundClient call, and reports clients already // present on a target as skipped. func (s *ClientService) BulkAttach(inboundSvc *InboundService, emails []string, inboundIds []int) (*BulkAttachResult, bool, error) { + inboundIds = uniqueInts(inboundIds) result := &BulkAttachResult{} if len(emails) == 0 || len(inboundIds) == 0 { return result, false, nil @@ -1357,7 +1358,7 @@ func (s *ClientService) bulkCreate(inboundSvc *InboundService, payloads []Client seenEmail[le] = struct{}{} seenSubID[client.SubID] = le - prep = append(prep, prepared{client: client, inboundIds: payloads[i].InboundIds, limitHwid: payloads[i].LimitHwid, payloadIdx: i}) + prep = append(prep, prepared{client: client, inboundIds: uniqueInts(payloads[i].InboundIds), limitHwid: payloads[i].LimitHwid, payloadIdx: i}) emails = append(emails, email) subIDs = append(subIDs, client.SubID) } diff --git a/internal/web/service/client_create_fanout_test.go b/internal/web/service/client_create_fanout_test.go index 1d3b63d52..1d7dc57bc 100644 --- a/internal/web/service/client_create_fanout_test.go +++ b/internal/web/service/client_create_fanout_test.go @@ -3,6 +3,8 @@ package service import ( "context" "fmt" + goruntime "runtime" + "slices" "strings" "sync/atomic" "testing" @@ -338,3 +340,54 @@ func TestFanoutReportsEveryFailingInbound(t *testing.T) { } }) } + +// An inbound id costs two bytes of request body, so a repeated one must not cost +// the panel another copy of the client: 2000 ids x 8 MB of comment was 16 GB. +func TestRepeatedInboundIdCostsOneCopyOfTheClient(t *testing.T) { + const commentBytes = 1 << 20 + client := func(email string) model.Client { + return model.Client{Email: email, Comment: strings.Repeat("x", commentBytes), Enable: true} + } + + cases := map[string]func(svc *ClientService, inboundSvc *InboundService, spare int, ids []int) error{ + "Create": func(svc *ClientService, inboundSvc *InboundService, _ int, ids []int) error { + _, err := svc.Create(inboundSvc, &ClientCreatePayload{Client: client("repeated@x"), InboundIds: ids}) + return err + }, + "BulkCreate": func(svc *ClientService, inboundSvc *InboundService, _ int, ids []int) error { + res, _, err := svc.BulkCreate(inboundSvc, []ClientCreatePayload{{Client: client("repeated@x"), InboundIds: ids}}) + if err == nil && res.Created != 1 { + t.Fatalf("bulk create: created %d, skipped %+v", res.Created, res.Skipped) + } + return err + }, + "Attach": func(svc *ClientService, inboundSvc *InboundService, spare int, ids []int) error { + if _, err := svc.CreateOne(inboundSvc, spare, client("repeated@x")); err != nil { + return err + } + _, err := svc.AttachByEmail(inboundSvc, "repeated@x", ids) + return err + }, + } + for name, run := range cases { + t.Run(name, func(t *testing.T) { + setupBulkDB(t) + ib := mkInbound(t, 21011, model.VLESS, `{"clients":[]}`) + spare := mkInbound(t, 21012, model.VLESS, `{"clients":[]}`) + + var before, after goruntime.MemStats + goruntime.ReadMemStats(&before) + if err := run(&ClientService{}, &InboundService{}, spare.Id, slices.Repeat([]int{ib.Id}, 300)); err != nil { + t.Fatal(err) + } + goruntime.ReadMemStats(&after) + + if allocated := after.TotalAlloc - before.TotalAlloc; allocated > 100*commentBytes { + t.Fatalf("allocated %d MiB for a 1 MiB client named on one inbound", allocated>>20) + } + if emails := settingsClientEmails(t, ib.Id); len(emails) != 1 { + t.Fatalf("clients on the inbound = %v, want the one", emails) + } + }) + } +} diff --git a/internal/web/service/client_crud.go b/internal/web/service/client_crud.go index eb1135b9a..904f93037 100644 --- a/internal/web/service/client_crud.go +++ b/internal/web/service/client_crud.go @@ -191,6 +191,8 @@ func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreate if payload == nil { return false, common.NewError("empty payload") } + // A repeated id adds nothing but another copy of the client held through the fan-out. + payload.InboundIds = uniqueInts(payload.InboundIds) client := payload.Client if strings.TrimSpace(client.Email) == "" { return false, common.NewError("client email is required") @@ -1138,6 +1140,7 @@ func addressesFitAmneziaWGInbound(addrs []string, ib *model.Inbound) bool { // Attach applies the client to every requested inbound: one failing inbound no // longer aborts the others, so the error can name several and needRestart holds. func (s *ClientService) Attach(inboundSvc *InboundService, id int, inboundIds []int) (bool, error) { + inboundIds = uniqueInts(inboundIds) existing, err := s.GetByID(id) if err != nil { return false, err diff --git a/internal/web/service/inbound_node.go b/internal/web/service/inbound_node.go index 986e16e2f..ba846a053 100644 --- a/internal/web/service/inbound_node.go +++ b/internal/web/service/inbound_node.go @@ -84,7 +84,7 @@ func (s *InboundService) AnyNodePending(inboundIds []int) bool { return false } nodeSvc := NodeService{} - for _, id := range inboundIds { + for _, id := range uniqueInts(inboundIds) { ib, err := s.GetInbound(id) if err != nil || ib.NodeID == nil { continue From a9191cb468f3248ea9b1d77521f6d8216a204319 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 08:55:38 +0100 Subject: [PATCH 082/125] fix(amneziawg): let an emptied forwardedPorts withdraw a client's forwards UpdateInboundClient carried the stored forwardedPorts forward whenever the incoming value was empty, and Client.ForwardedPorts was a plain omitempty string, so clearing the field in the client editor, as its hint says to, reported success while the settings kept the old ports and the host kept relaying them to the client's device. The field is now a pointer, as KeepAlive is: nil (not sent) keeps the stored spec and "" withdraws it. The client record follows the live spec on every update, and a one-time seeder fills records that still hold their creation value from the inbound settings, so the editor no longer posts an empty field that would drop forwards added later. --- docs/public/openapi.json | 3 +- frontend/public/openapi.json | 3 +- frontend/src/generated/examples.ts | 2 +- frontend/src/generated/schemas.ts | 3 +- frontend/src/generated/types.ts | 2 +- frontend/src/generated/zod.ts | 2 +- internal/amneziawg/instance.go | 2 +- internal/database/db.go | 42 +++++- internal/database/db_seed_test.go | 40 ++++++ internal/database/model/model.go | 27 +++- .../database/model/model_wireguard_test.go | 12 ++ internal/web/service/client_attach_test.go | 4 +- internal/web/service/client_bulk.go | 2 +- internal/web/service/client_crud.go | 16 ++- internal/web/service/client_inbound_apply.go | 22 +-- internal/web/service/client_link.go | 5 + internal/web/service/inbound_amneziawg.go | 4 +- .../web/service/inbound_amneziawg_test.go | 134 ++++++++++++++++++ .../web/service/kernel_client_guard_test.go | 10 +- 19 files changed, 304 insertions(+), 31 deletions(-) diff --git a/docs/public/openapi.json b/docs/public/openapi.json index 9b90660cc..5b2aa2488 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -1785,7 +1785,8 @@ "type": "string" }, "forwardedPorts": { - "description": "AmneziaWG per-client port-forwarding spec, e.g. \"80,443,8000-8100\"", + "description": "AmneziaWG port-forwarding spec, e.g. \"80,443,8000-8100\"; \"\" withdraws the forwards, omit to keep them", + "nullable": true, "type": "string" }, "group": { diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index 9b90660cc..5b2aa2488 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -1785,7 +1785,8 @@ "type": "string" }, "forwardedPorts": { - "description": "AmneziaWG per-client port-forwarding spec, e.g. \"80,443,8000-8100\"", + "description": "AmneziaWG port-forwarding spec, e.g. \"80,443,8000-8100\"; \"\" withdraws the forwards, omit to keep them", + "nullable": true, "type": "string" }, "group": { diff --git a/frontend/src/generated/examples.ts b/frontend/src/generated/examples.ts index 6c8c5aca5..384de907c 100644 --- a/frontend/src/generated/examples.ts +++ b/frontend/src/generated/examples.ts @@ -431,7 +431,7 @@ export const EXAMPLES: Record = { "enable": false, "expiryTime": 0, "flow": "", - "forwardedPorts": "", + "forwardedPorts": null, "group": "", "id": "", "keepAlive": 0, diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts index 8165b101b..479ec9fdb 100644 --- a/frontend/src/generated/schemas.ts +++ b/frontend/src/generated/schemas.ts @@ -1759,7 +1759,8 @@ export const SCHEMAS: Record = { "type": "string" }, "forwardedPorts": { - "description": "AmneziaWG per-client port-forwarding spec, e.g. \"80,443,8000-8100\"", + "description": "AmneziaWG port-forwarding spec, e.g. \"80,443,8000-8100\"; \"\" withdraws the forwards, omit to keep them", + "nullable": true, "type": "string" }, "group": { diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts index a7e158e36..c9d50cfc6 100644 --- a/frontend/src/generated/types.ts +++ b/frontend/src/generated/types.ts @@ -426,7 +426,7 @@ export interface Client { enable: boolean; expiryTime: number; flow?: string; - forwardedPorts?: string; + forwardedPorts?: string | null; group?: string; id?: string; keepAlive?: number | string; diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index 692a2f677..d86fa6391 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -450,7 +450,7 @@ export const ClientSchema = z.object({ enable: z.boolean(), expiryTime: z.number().int(), flow: z.string().optional(), - forwardedPorts: z.string().optional(), + forwardedPorts: z.string().nullable().optional(), group: z.string().optional(), id: z.string().optional(), keepAlive: z.union([z.number().int(), z.string()]).optional(), diff --git a/internal/amneziawg/instance.go b/internal/amneziawg/instance.go index 8c75af6ed..81a58823d 100644 --- a/internal/amneziawg/instance.go +++ b/internal/amneziawg/instance.go @@ -34,7 +34,7 @@ func InstanceFromInbound(ib *model.Inbound) (Instance, bool) { PublicKey: c.PublicKey, PresharedKey: c.PreSharedKey, AllowedIPs: c.AllowedIPs, - ForwardedPorts: c.ForwardedPorts, + ForwardedPorts: c.ForwardedPortsSpec(), }) } if len(peers) == 0 { diff --git a/internal/database/db.go b/internal/database/db.go index 80b680365..f3cbb7c47 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -1530,7 +1530,7 @@ func runSeeders(isUsersEmpty bool) error { } if empty && isUsersEmpty { - seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix2", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "FreedomFinalRulesPrivateEgressBlock", "UppercaseFreedomFinalRulesFix", "InboundRealityFinalmaskTcpStrip", "ApiTokensHash", "LegacyProxySettingsCleanup", "SessionSecretRotation", "OutboundRemovedKeysFix", "FreedomDomainStrategyFix", "DNSOutboundLegacyKeysFix", "DNSOutboundQTypeZeroFix", "WireguardDomainStrategyFix", "XdnsFinalmaskNamesAddrsFix", "UDPHopClientMaskFix", "HostVerifyPeerCertByNameBoolFix", "HostMihomoX25519Reset", "WireguardPeersToClients", "MtprotoSecretsToClients", "NodeInboundsAdopted", "NodeDownNotified", "ResetIpLimitNoFail2ban", "ClientGroupNameTrim", "OwnerlessClientHwidPurge"} + seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix2", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "FreedomFinalRulesPrivateEgressBlock", "UppercaseFreedomFinalRulesFix", "InboundRealityFinalmaskTcpStrip", "ApiTokensHash", "LegacyProxySettingsCleanup", "SessionSecretRotation", "OutboundRemovedKeysFix", "FreedomDomainStrategyFix", "DNSOutboundLegacyKeysFix", "DNSOutboundQTypeZeroFix", "WireguardDomainStrategyFix", "XdnsFinalmaskNamesAddrsFix", "UDPHopClientMaskFix", "HostVerifyPeerCertByNameBoolFix", "HostMihomoX25519Reset", "WireguardPeersToClients", "MtprotoSecretsToClients", "NodeInboundsAdopted", "NodeDownNotified", "ResetIpLimitNoFail2ban", "ClientGroupNameTrim", "OwnerlessClientHwidPurge", "ClientForwardedPortsBackfill"} for _, name := range seeders { if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil { return err @@ -1725,6 +1725,12 @@ func runSeeders(isUsersEmpty bool) error { } } + if !slices.Contains(seedersHistory, "ClientForwardedPortsBackfill") { + if err := backfillClientForwardedPorts(); err != nil { + return err + } + } + if err := seedHostsFromExternalProxy(); err != nil { return err } @@ -1920,6 +1926,40 @@ func clearLegacyProxySettings() error { }) } +// The client editor fills forwardedPorts from the record, which kept the value it was created +// with; an emptied field now withdraws the forwards, so the record must hold the live spec. +func backfillClientForwardedPorts() error { + return db.Transaction(func(tx *gorm.DB) error { + var inbounds []model.Inbound + if err := tx.Select("id", "settings").Where("protocol = ?", model.AmneziaWG).Find(&inbounds).Error; err != nil { + return err + } + for _, ib := range inbounds { + var parsed struct { + Clients []struct { + Email string `json:"email"` + ForwardedPorts string `json:"forwardedPorts"` + } `json:"clients"` + } + if json.Unmarshal([]byte(ib.Settings), &parsed) != nil { + continue + } + for _, c := range parsed.Clients { + spec := strings.TrimSpace(c.ForwardedPorts) + if c.Email == "" || spec == "" { + continue + } + if err := tx.Model(&model.ClientRecord{}). + Where("email = ? AND (wg_forwarded_ports IS NULL OR wg_forwarded_ports = '')", c.Email). + UpdateColumn("wg_forwarded_ports", spec).Error; err != nil { + return err + } + } + } + return tx.Create(&model.HistoryOfSeeders{SeederName: "ClientForwardedPortsBackfill"}).Error + }) +} + // The HWID gate stored a row for any subId a request named, owned or not; those rows // belong to no subscription and only grow the database. func purgeOwnerlessClientHwids() error { diff --git a/internal/database/db_seed_test.go b/internal/database/db_seed_test.go index 8bf177254..e42be24be 100644 --- a/internal/database/db_seed_test.go +++ b/internal/database/db_seed_test.go @@ -452,3 +452,43 @@ func TestOwnerlessClientHwidsPurgedOnUpgrade(t *testing.T) { t.Fatalf("hwid rows after upgrade = %q, want [owned]", subs) } } + +// The editor fills forwardedPorts from the record, and an emptied field now withdraws the +// forwards: a record left at its creation value would drop live forwards on the next save. +func TestClientForwardedPortsBackfilledOnUpgrade(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "x-ui.db") + if err := InitDB(dbPath); err != nil { + t.Fatalf("InitDB: %v", err) + } + t.Cleanup(func() { _ = CloseDB() }) + if err := db.Create(&model.ClientRecord{Email: "fwd@x", Enable: true}).Error; err != nil { + t.Fatalf("seed client: %v", err) + } + if err := db.Create(&model.ClientRecord{Email: "kept@x", Enable: true, ForwardedPorts: "9000"}).Error; err != nil { + t.Fatalf("seed client: %v", err) + } + if err := db.Create(&model.Inbound{ + Tag: "awg", Port: 51820, Protocol: model.AmneziaWG, Enable: true, + Settings: `{"clients":[{"email":"fwd@x","forwardedPorts":"8022"},{"email":"kept@x","forwardedPorts":"9001"}]}`, + }).Error; err != nil { + t.Fatalf("seed inbound: %v", err) + } + if err := db.Where("seeder_name = ?", "ClientForwardedPortsBackfill").Delete(&model.HistoryOfSeeders{}).Error; err != nil { + t.Fatalf("clear seeder history: %v", err) + } + if err := CloseDB(); err != nil { + t.Fatalf("CloseDB: %v", err) + } + if err := InitDB(dbPath); err != nil { + t.Fatalf("reopen InitDB: %v", err) + } + for email, want := range map[string]string{"fwd@x": "8022", "kept@x": "9000"} { + var rec model.ClientRecord + if err := db.Where("email = ?", email).First(&rec).Error; err != nil { + t.Fatalf("read %s: %v", email, err) + } + if rec.ForwardedPorts != want { + t.Errorf("%s forwardedPorts = %q after upgrade, want %q", email, rec.ForwardedPorts, want) + } + } +} diff --git a/internal/database/model/model.go b/internal/database/model/model.go index cf93ba7fe..06120d9f9 100644 --- a/internal/database/model/model.go +++ b/internal/database/model/model.go @@ -955,7 +955,7 @@ type Client struct { AllowedIPsByInbound map[int][]string `json:"allowedIPsByInbound,omitempty"` PreSharedKey string `json:"preSharedKey,omitempty"` KeepAlive *KeepAliveValue `json:"keepAlive,omitempty"` // Numeric values or AmneziaWG ranges; 0 disables, omission preserves. - ForwardedPorts string `json:"forwardedPorts,omitempty"` // AmneziaWG per-client port-forwarding spec, e.g. "80,443,8000-8100" + ForwardedPorts *string `json:"forwardedPorts,omitempty"` // AmneziaWG port-forwarding spec, e.g. "80,443,8000-8100"; "" withdraws the forwards, omit to keep them Secret string `json:"secret,omitempty" example:"ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d"` AdTag string `json:"adTag,omitempty" example:"0123456789abcdef0123456789abcdef"` Email string `json:"email"` // Client email identifier @@ -1273,6 +1273,27 @@ func keepAliveValue(value *KeepAliveValue) KeepAliveValue { return *value } +// ForwardedPortsSpec is the client's port-forwarding spec, "" when unset. +func (c Client) ForwardedPortsSpec() string { + if c.ForwardedPorts == nil { + return "" + } + return strings.TrimSpace(*c.ForwardedPorts) +} + +// ForwardedPortsPtr wraps an explicit spec, "" included: distinct from a nil +// ForwardedPorts, which means the field was never sent. +func ForwardedPortsPtr(spec string) *string { return &spec } + +// nonEmptyForwardedPorts keeps a stored "" nil: a record re-saved as a client must not +// read as a request to withdraw the forwards. +func nonEmptyForwardedPorts(spec string) *string { + if spec == "" { + return nil + } + return ForwardedPortsPtr(spec) +} + func (c *Client) ToRecord() *ClientRecord { rec := &ClientRecord{ Email: c.Email, @@ -1304,7 +1325,7 @@ func (c *Client) ToRecord() *ClientRecord { AllowedIPs: strings.Join(c.AllowedIPs, ","), PreSharedKey: c.PreSharedKey, KeepAlive: keepAliveValue(c.KeepAlive), - ForwardedPorts: c.ForwardedPorts, + ForwardedPorts: c.ForwardedPortsSpec(), Secret: c.Secret, AdTag: c.AdTag, } @@ -1364,7 +1385,7 @@ func (r *ClientRecord) ToClient() *Client { AllowedIPs: splitWireguardAllowedIPs(r.AllowedIPs), PreSharedKey: r.PreSharedKey, KeepAlive: keepAlivePtr(r.KeepAlive), - ForwardedPorts: r.ForwardedPorts, + ForwardedPorts: nonEmptyForwardedPorts(r.ForwardedPorts), Secret: r.Secret, AdTag: r.AdTag, } diff --git a/internal/database/model/model_wireguard_test.go b/internal/database/model/model_wireguard_test.go index f2d7b8898..10c0c7ee9 100644 --- a/internal/database/model/model_wireguard_test.go +++ b/internal/database/model/model_wireguard_test.go @@ -16,6 +16,8 @@ func TestClientToRecordRoundTripWireGuard(t *testing.T) { AllowedIPs: []string{"10.0.0.2/32", "fd00::2/128"}, PreSharedKey: "cHNrLWJhc2U2NC0zMmJ5dGVzLXBsYWNlaG9sZGVyISE=", KeepAlive: KeepAlivePtr(25), + + ForwardedPorts: ForwardedPortsPtr("80,8000-8100"), } rec := c.ToRecord() @@ -32,6 +34,7 @@ func TestClientToRecordRoundTripWireGuard(t *testing.T) { {"PublicKey", c.PublicKey, got.PublicKey}, {"PreSharedKey", c.PreSharedKey, got.PreSharedKey}, {"KeepAlive", c.KeepAliveSeconds(), got.KeepAliveSeconds()}, + {"ForwardedPorts", c.ForwardedPortsSpec(), got.ForwardedPortsSpec()}, } { if f.a != f.b { t.Errorf("%s round-trip = %v, want %v", f.name, f.b, f.a) @@ -59,6 +62,15 @@ func TestClientToClientOmitsUnsetKeepAlive(t *testing.T) { } } +// An explicit "" withdraws the forwards, which a re-saved record must never ask for. +func TestClientToClientOmitsUnsetForwardedPorts(t *testing.T) { + rec := &ClientRecord{Email: "awg@example.test", Enable: true} + + if got := rec.ToClient().ForwardedPorts; got != nil { + t.Fatalf("ForwardedPorts for a record that never set any = %q, want nil", *got) + } +} + func TestClientRecordEmptyAllowedIPs(t *testing.T) { rec := &ClientRecord{Email: "bob@example.test", AllowedIPs: ""} if got := rec.ToClient().AllowedIPs; got != nil { diff --git a/internal/web/service/client_attach_test.go b/internal/web/service/client_attach_test.go index 65000a403..45c123c2b 100644 --- a/internal/web/service/client_attach_test.go +++ b/internal/web/service/client_attach_test.go @@ -210,8 +210,8 @@ func TestAttachDoesNotCarryForwardedPorts(t *testing.T) { t.Fatalf("GetClients(own): %v", err) } for _, c := range clients { - if c.Email == "forwarder" && c.ForwardedPorts != "" { - t.Fatalf("attach carried forwardedPorts %q onto the master's inbound", c.ForwardedPorts) + if c.Email == "forwarder" && c.ForwardedPortsSpec() != "" { + t.Fatalf("attach carried forwardedPorts %q onto the master's inbound", c.ForwardedPortsSpec()) } } }) diff --git a/internal/web/service/client_bulk.go b/internal/web/service/client_bulk.go index f6fe94586..80dba5f38 100644 --- a/internal/web/service/client_bulk.go +++ b/internal/web/service/client_bulk.go @@ -113,7 +113,7 @@ func (s *ClientService) BulkAttach(inboundSvc *InboundService, emails []string, } client := *rec.ToClient() // Forwards are set per inbound; see Attach. - client.ForwardedPorts = "" + client.ForwardedPorts = nil if flow, ok := flowsByEmail[rec.Email]; ok && flow != "" { client.Flow = flow } diff --git a/internal/web/service/client_crud.go b/internal/web/service/client_crud.go index 904f93037..e2878159e 100644 --- a/internal/web/service/client_crud.go +++ b/internal/web/service/client_crud.go @@ -213,6 +213,10 @@ func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreate return false, err } normalizeClientTrafficReset(&client) + // Nothing to withdraw on a new client, so a blank spec is no spec. + if client.ForwardedPortsSpec() == "" { + client.ForwardedPorts = nil + } client.ClientHostRuleIds = model.NormalizeClientHostRuleIds(client.ClientHostRuleIds) if len(payload.InboundIds) == 0 { return false, common.NewError("at least one inbound is required") @@ -938,6 +942,16 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model return needRestart, err } + // The AmneziaWG forwards too: the record merge keeps a stored spec when the settings + // carry none, so a withdrawal is written here; nil (not sent) leaves it alone. + if updated.ForwardedPorts != nil { + if err := database.GetDB().Model(&model.ClientRecord{}). + Where("id = ?", id). + UpdateColumn("wg_forwarded_ports", updated.ForwardedPortsSpec()).Error; err != nil { + return needRestart, err + } + } + // Same shape as the group write above: SyncInbound keeps a stored ad-tag // when the incoming settings carry none, so clearing the override must be // applied here, where the editor always round-trips the field. @@ -1186,7 +1200,7 @@ func (s *ClientService) Attach(inboundSvc *InboundService, id int, inboundIds [] copyClient := *clientWire // A forward is a listener on the target inbound's host, set per inbound: carried over, it // would let a node-reported client choose ports on this host. - copyClient.ForwardedPorts = "" + copyClient.ForwardedPorts = nil if !addressesFitAmneziaWGInbound(copyClient.AllowedIPs, inbound) { copyClient.AllowedIPs = nil } diff --git a/internal/web/service/client_inbound_apply.go b/internal/web/service/client_inbound_apply.go index cc563cdf2..f1142d87f 100644 --- a/internal/web/service/client_inbound_apply.go +++ b/internal/web/service/client_inbound_apply.go @@ -61,7 +61,7 @@ func validateAmneziaWGClientWrite(inbound *model.Inbound, clients []model.Client inst := amneziawg.Instance{Listen: inbound.Listen, IPv6Enabled: settings.Server.IPv6Enabled} for _, client := range clients { inst.Peers = append(inst.Peers, amneziawg.Peer{ - AllowedIPs: client.AllowedIPs, ForwardedPorts: client.ForwardedPorts, + AllowedIPs: client.AllowedIPs, ForwardedPorts: client.ForwardedPortsSpec(), }) } if err := amneziawgkern.ValidateInstanceFeatures(inst, settings.Server.RouteThroughXray); err != nil { @@ -537,7 +537,7 @@ func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model } } if oldInbound.Protocol == model.AmneziaWG { - if hit := inboundSvc.checkForwardedPortsConflict(portCtx, client.ForwardedPorts); hit != "" { + if hit := inboundSvc.checkForwardedPortsConflict(portCtx, client.ForwardedPortsSpec()); hit != "" { return false, common.NewError("amneziawg: forwardedPorts collides with", hit) } } @@ -607,7 +607,7 @@ func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model return pErr } for i := range clients { - if hit := inboundSvc.checkForwardedPortsConflict(txPortCtx, clients[i].ForwardedPorts); hit != "" { + if hit := inboundSvc.checkForwardedPortsConflict(txPortCtx, clients[i].ForwardedPortsSpec()); hit != "" { return common.NewError("amneziawg: forwardedPorts collides with", hit) } } @@ -845,8 +845,8 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo if clients[0].KeepAlive == nil { clients[0].KeepAlive = old.KeepAlive } - // AWG partial edits preserve forwarding specs; kernel mode rejects legacy specs. - if oldInbound.Protocol == model.AmneziaWG && clients[0].ForwardedPorts == "" { + // AWG partial edits (nil: the field was not sent) keep the stored spec; "" withdraws it. + if oldInbound.Protocol == model.AmneziaWG && clients[0].ForwardedPorts == nil { clients[0].ForwardedPorts = old.ForwardedPorts } } @@ -858,7 +858,7 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo if err != nil { return false, err } - if hit := inboundSvc.checkForwardedPortsConflict(portCtx, clients[0].ForwardedPorts); hit != "" { + if hit := inboundSvc.checkForwardedPortsConflict(portCtx, clients[0].ForwardedPortsSpec()); hit != "" { return false, common.NewError("amneziawg: forwardedPorts collides with", hit) } } @@ -911,10 +911,14 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo if clients[0].KeepAlive != nil { newMap["keepAlive"] = clients[0].KeepAlive } - if oldInbound.Protocol == model.AmneziaWG && clients[0].ForwardedPorts != "" { - newMap["forwardedPorts"] = clients[0].ForwardedPorts + if oldInbound.Protocol == model.AmneziaWG && clients[0].ForwardedPortsSpec() != "" { + newMap["forwardedPorts"] = clients[0].ForwardedPortsSpec() } } + // Withdrawn forwards are stored as no spec at all, whatever the protocol. + if clients[0].ForwardedPortsSpec() == "" { + delete(newMap, "forwardedPorts") + } if oldClientMap != nil && sameClientConfigExceptUpdatedAt(oldClientMap, newMap) { if v, ok2 := oldClientMap["updated_at"]; ok2 { newMap["updated_at"] = v @@ -995,7 +999,7 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo if pErr != nil { return pErr } - if hit := inboundSvc.checkForwardedPortsConflict(txPortCtx, clients[0].ForwardedPorts); hit != "" { + if hit := inboundSvc.checkForwardedPortsConflict(txPortCtx, clients[0].ForwardedPortsSpec()); hit != "" { return common.NewError("amneziawg: forwardedPorts collides with", hit) } } diff --git a/internal/web/service/client_link.go b/internal/web/service/client_link.go index 912408bb2..fe69c523b 100644 --- a/internal/web/service/client_link.go +++ b/internal/web/service/client_link.go @@ -63,6 +63,11 @@ func applyClientRecordMerge(row *model.ClientRecord, incoming *model.ClientRecor if incoming.KeepAlive != "" { row.KeepAlive = incoming.KeepAlive } + // Guarded like Group: only an AmneziaWG entry is sure to carry the spec, and the record + // is shared with every other inbound of the client. + if incoming.ForwardedPorts != "" { + row.ForwardedPorts = incoming.ForwardedPorts + } row.SubID = incoming.SubID row.LimitIP = incoming.LimitIP row.TotalGB = incoming.TotalGB diff --git a/internal/web/service/inbound_amneziawg.go b/internal/web/service/inbound_amneziawg.go index e4e099ea2..c32064198 100644 --- a/internal/web/service/inbound_amneziawg.go +++ b/internal/web/service/inbound_amneziawg.go @@ -324,7 +324,7 @@ func (s *InboundService) normalizeAmneziaWGSettings(inbound *model.Inbound, old if client.Enable { inst.Peers = append(inst.Peers, amneziawg.Peer{ AllowedIPs: client.AllowedIPs, - ForwardedPorts: client.ForwardedPorts, + ForwardedPorts: client.ForwardedPortsSpec(), }) } } @@ -372,7 +372,7 @@ func (s *InboundService) loadPortConflictContext(db *gorm.DB, nodeID *int) (port // amneziaWGForwardedPortsConflict renders one client's ForwardedPorts collision, // or nil: the single copy both the pre-Save pass and the post-Save re-run use. func (s *InboundService) amneziaWGForwardedPortsConflict(ctx portConflictContext, c *model.Client) error { - hit := s.checkForwardedPortsConflict(ctx, c.ForwardedPorts) + hit := s.checkForwardedPortsConflict(ctx, c.ForwardedPortsSpec()) if hit == "" { return nil } diff --git a/internal/web/service/inbound_amneziawg_test.go b/internal/web/service/inbound_amneziawg_test.go index 94c057d52..2a2d48df3 100644 --- a/internal/web/service/inbound_amneziawg_test.go +++ b/internal/web/service/inbound_amneziawg_test.go @@ -580,3 +580,137 @@ func TestNormalizeAmneziaWGSettingsGatesHostReadinessOnlyWhenEnablingKernelMode( }) } } + +func inboundForwardedPorts(t *testing.T, inboundSvc *InboundService, ibId int, email string) string { + t.Helper() + ib, err := inboundSvc.GetInbound(ibId) + if err != nil { + t.Fatalf("GetInbound %d: %v", ibId, err) + } + clients, err := inboundSvc.GetClients(ib) + if err != nil { + t.Fatalf("GetClients %d: %v", ibId, err) + } + for i := range clients { + if clients[i].Email == email { + return clients[i].ForwardedPortsSpec() + } + } + t.Fatalf("email %q not found on inbound %d", email, ibId) + return "" +} + +// seedForwardedPortsClient attaches one AmneziaWG client already holding a +// port forward, and returns its inbound and client-record id. +func seedForwardedPortsClient(t *testing.T, email, forwardedPorts string) (*model.Inbound, int) { + t.Helper() + svc := &ClientService{} + + seeded := model.Client{ + Email: email, + SubID: "sub-" + email, + Enable: true, + PublicKey: "QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUE=", + AllowedIPs: []string{"10.8.1.2/32"}, + ForwardedPorts: model.ForwardedPortsPtr(forwardedPorts), + } + ib := mkInbound(t, 51830, model.AmneziaWG, clientsSettings(t, []model.Client{seeded})) + if err := svc.SyncInbound(nil, ib.Id, []model.Client{seeded}); err != nil { + t.Fatalf("seed linkage: %v", err) + } + return ib, lookupClientRecord(t, email).Id +} + +// The client form sends "" once the field was emptied. Carried forward like an +// omitted field, the save was reported as done and the port stayed open. +func TestUpdateCanWithdrawForwardedPorts(t *testing.T) { + setupBulkDB(t) + inboundSvc := &InboundService{} + svc := &ClientService{} + + ib, recId := seedForwardedPortsClient(t, "fp@x", "8022") + if got := inboundForwardedPorts(t, inboundSvc, ib.Id, "fp@x"); got != "8022" { + t.Fatalf("seeded forwardedPorts = %q, want 8022", got) + } + + updated := model.Client{ + Email: "fp@x", + Enable: true, + AllowedIPs: []string{"10.8.1.2/32"}, + ForwardedPorts: model.ForwardedPortsPtr(""), + } + if _, err := svc.Update(inboundSvc, recId, updated, 0); err != nil { + t.Fatalf("Update: %v", err) + } + + if got := inboundForwardedPorts(t, inboundSvc, ib.Id, "fp@x"); got != "" { + t.Fatalf("inbound forwardedPorts after an explicit \"\" = %q, want none", got) + } + stored, err := inboundSvc.GetInbound(ib.Id) + if err != nil { + t.Fatalf("GetInbound: %v", err) + } + if strings.Contains(stored.Settings, "forwardedPorts") { + t.Fatalf("withdrawn forwards must leave no key behind: %s", stored.Settings) + } + if got := lookupClientRecord(t, "fp@x").ForwardedPorts; got != "" { + t.Fatalf("stored wg_forwarded_ports after an explicit \"\" = %q, want none", got) + } +} + +// The other half of the contract: a payload that never mentions forwardedPorts +// (a metadata-only edit from the bot or the API) leaves the stored value alone. +func TestUpdateWithoutForwardedPortsPreservesTheStoredValue(t *testing.T) { + setupBulkDB(t) + inboundSvc := &InboundService{} + svc := &ClientService{} + + ib, recId := seedForwardedPortsClient(t, "fp@x", "8022") + + updated := model.Client{ + Email: "fp@x", + Enable: true, + AllowedIPs: []string{"10.8.1.2/32"}, + } + if _, err := svc.Update(inboundSvc, recId, updated, 0); err != nil { + t.Fatalf("Update: %v", err) + } + + if got := inboundForwardedPorts(t, inboundSvc, ib.Id, "fp@x"); got != "8022" { + t.Fatalf("inbound forwardedPorts after an edit that omitted it = %q, want 8022", got) + } + if got := lookupClientRecord(t, "fp@x").ForwardedPorts; got != "8022" { + t.Fatalf("stored wg_forwarded_ports after an edit that omitted it = %q, want 8022", got) + } +} + +// The form is filled from the record, and the traffic resets re-save it: a record +// left at the creation-time spec moves the forward back on the next save. +func TestUpdateKeepsTheRecordsForwardedPortsCurrent(t *testing.T) { + setupBulkDB(t) + inboundSvc := &InboundService{} + svc := &ClientService{} + + ib, recId := seedForwardedPortsClient(t, "fp@x", "8021") + + updated := model.Client{ + Email: "fp@x", + Enable: true, + AllowedIPs: []string{"10.8.1.2/32"}, + ForwardedPorts: model.ForwardedPortsPtr("8022"), + } + if _, err := svc.Update(inboundSvc, recId, updated, 0); err != nil { + t.Fatalf("Update: %v", err) + } + rec := lookupClientRecord(t, "fp@x") + if rec.ForwardedPorts != "8022" { + t.Fatalf("stored wg_forwarded_ports after 8021 -> 8022 = %q, want 8022", rec.ForwardedPorts) + } + + if _, err := svc.Update(inboundSvc, recId, *rec.ToClient(), 0); err != nil { + t.Fatalf("re-save of the record: %v", err) + } + if got := inboundForwardedPorts(t, inboundSvc, ib.Id, "fp@x"); got != "8022" { + t.Fatalf("inbound forwardedPorts after a re-save of the record = %q, want 8022", got) + } +} diff --git a/internal/web/service/kernel_client_guard_test.go b/internal/web/service/kernel_client_guard_test.go index 7e6d15547..f6c2a5e2b 100644 --- a/internal/web/service/kernel_client_guard_test.go +++ b/internal/web/service/kernel_client_guard_test.go @@ -23,7 +23,7 @@ func TestKernelAWGClientWritesRejectUnsafeValuesAndUnsupportedFeatures(t *testin client model.Client }{ {"public key hook injection", "control characters", model.Client{Email: "bad-key@awg", Enable: true, PublicKey: "peer-key\n[Interface]\nPostDown = touch /tmp/pwned", AllowedIPs: []string{"10.8.1.2/32"}}}, - {"forwarded ports", "port forwarding is not supported", model.Client{Email: "bad-forward@awg", Enable: true, PublicKey: "peer-key", AllowedIPs: []string{"10.8.1.2/32"}, ForwardedPorts: "54321"}}, + {"forwarded ports", "port forwarding is not supported", model.Client{Email: "bad-forward@awg", Enable: true, PublicKey: "peer-key", AllowedIPs: []string{"10.8.1.2/32"}, ForwardedPorts: model.ForwardedPortsPtr("54321")}}, {"IPv6 allowed IP", "per-peer IPv6 egress is not supported", model.Client{Email: "bad-v6@awg", Enable: true, PublicKey: "peer-key", AllowedIPs: []string{"2001:db8::2/128"}}}, } for _, tc := range cases { @@ -70,7 +70,7 @@ func TestKernelAWGClientUpdateRejectsUnsafeValuesAndUnsupportedFeatures(t *testi update model.Client }{ {"preshared key hook injection", "control characters", model.Client{Email: "kept@awg", PreSharedKey: "psk\n[Interface]\nPreDown = touch /tmp/pwned"}}, - {"forwarded ports", "port forwarding is not supported", model.Client{Email: "kept@awg", ForwardedPorts: "54321"}}, + {"forwarded ports", "port forwarding is not supported", model.Client{Email: "kept@awg", ForwardedPorts: model.ForwardedPortsPtr("54321")}}, {"IPv6 allowed IP", "per-peer IPv6 egress is not supported", model.Client{Email: "kept@awg", AllowedIPs: []string{"2001:db8::2/128"}}}, } for _, tc := range cases { @@ -123,7 +123,7 @@ func TestKernelAWGClientFeatureChecksPreserveSupportedPaths(t *testing.T) { t.Fatalf("valid IPv4 kernel peer was rejected: %v", err) } embedded := mkInbound(t, 52003, model.AmneziaWG, amneziawgClientTestSettings) - if _, err := svc.CreateOne(inboundSvc, embedded.Id, model.Client{Email: "forward@embedded", Enable: true, PublicKey: "valid-key", AllowedIPs: []string{"10.8.1.3/32"}, ForwardedPorts: "54321"}); err != nil { + if _, err := svc.CreateOne(inboundSvc, embedded.Id, model.Client{Email: "forward@embedded", Enable: true, PublicKey: "valid-key", AllowedIPs: []string{"10.8.1.3/32"}, ForwardedPorts: model.ForwardedPortsPtr("54321")}); err != nil { t.Fatalf("embedded AWG port forwarding was rejected: %v", err) } } @@ -136,7 +136,7 @@ func TestKernelAWGSharedCreateAndUpdateValidateAllTargetsBeforeWrites(t *testing kernel := mkInbound(t, 52005, model.AmneziaWG, kernelAWGSettings(nil)) embedded := mkInbound(t, 52006, model.AmneziaWG, amneziawgClientTestSettings) _, err := svc.Create(inboundSvc, &ClientCreatePayload{ - Client: model.Client{Email: "shared-create@awg", Enable: true, PublicKey: "valid-key", AllowedIPs: []string{"10.8.1.2/32"}, ForwardedPorts: "54321"}, + Client: model.Client{Email: "shared-create@awg", Enable: true, PublicKey: "valid-key", AllowedIPs: []string{"10.8.1.2/32"}, ForwardedPorts: model.ForwardedPortsPtr("54321")}, InboundIds: []int{embedded.Id, kernel.Id}, }) if err == nil || !strings.Contains(err.Error(), "port forwarding is not supported") { @@ -173,7 +173,7 @@ func TestKernelAWGSharedCreateAndUpdateValidateAllTargetsBeforeWrites(t *testing beforeKernel, _ := inboundSvc.GetInbound(kernel.Id) beforeEmbedded, _ := inboundSvc.GetInbound(embedded.Id) rec := lookupClientRecord(t, seed.Email) - _, err := svc.Update(inboundSvc, rec.Id, model.Client{Email: seed.Email, ForwardedPorts: "54321"}, 0) + _, err := svc.Update(inboundSvc, rec.Id, model.Client{Email: seed.Email, ForwardedPorts: model.ForwardedPortsPtr("54321")}, 0) if err == nil || !strings.Contains(err.Error(), "port forwarding is not supported") { t.Fatalf("Update error = %v, want kernel feature rejection", err) } From fac90093982047443f34d8f4e181a91a9cca7f5e Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 08:58:43 +0100 Subject: [PATCH 083/125] fix(tgbot): escape template parameters once in I18nBot Every bot message is sent HTML-parsed while the intended markup lives in the translation templates, yet the values filled into them were escaped only at some call sites. Remarks and e-mails, which a node chooses for the inbounds it reports, reached the scheduled report and the client info unescaped and turned into markup, and the reports that escaped their whole text instead showed the templates' own tags. I18nBot now escapes every parameter value, the call sites drop their own escaping and the whole-report escapes, the two values concatenated outside a template are escaped where they are written, and callback answers, which are plain text, are unescaped again. --- .../web/service/tgbot/screen_client_views.go | 8 +- internal/web/service/tgbot/screen_views.go | 2 +- internal/web/service/tgbot/tgbot.go | 15 +- internal/web/service/tgbot/tgbot_client.go | 2 +- internal/web/service/tgbot/tgbot_invite.go | 4 +- internal/web/service/tgbot/tgbot_report.go | 3 +- .../tgbot/tgbot_report_batching_test.go | 174 ++---------------- internal/web/service/tgbot/tgbot_router.go | 12 +- internal/web/service/tgbot/tgbot_send.go | 4 +- internal/web/service/tgbot/tgbot_send_test.go | 36 ++++ 10 files changed, 88 insertions(+), 172 deletions(-) diff --git a/internal/web/service/tgbot/screen_client_views.go b/internal/web/service/tgbot/screen_client_views.go index 3e82b1a01..9a2136a2d 100644 --- a/internal/web/service/tgbot/screen_client_views.go +++ b/internal/web/service/tgbot/screen_client_views.go @@ -43,7 +43,7 @@ func (t *Tgbot) screenClientIps(chatID int64, email string) { if err != nil || len(ips) == 0 { body += t.I18nBot("tgbot.noIpRecord") } else { - body += t.I18nBot("tgbot.messages.ips", "IPs=="+html.EscapeString(formatIPLog(ips))) + body += t.I18nBot("tgbot.messages.ips", "IPs=="+formatIPLog(ips)) } body += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05")) rows := [][]telego.InlineKeyboardButton{ @@ -158,9 +158,9 @@ func (t *Tgbot) screenClientLinks(chatID int64, email string) { return } body := t.clientScreenBody(email, "") - body += t.I18nBot("tgbot.messages.subURL", "URL=="+html.EscapeString(subURL)) + body += t.I18nBot("tgbot.messages.subURL", "URL=="+subURL) if subJSON != "" { - body += t.I18nBot("tgbot.messages.jsonURL", "URL=="+html.EscapeString(subJSON)) + body += t.I18nBot("tgbot.messages.jsonURL", "URL=="+subJSON) } body += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05")) rows := [][]telego.InlineKeyboardButton{ @@ -213,7 +213,7 @@ func (t *Tgbot) screenClientQR(chatID int64, email string) { return } body := t.clientScreenBody(email, t.I18nBot("tgbot.answers.qrCodeForClient", "Email=="+email)) - body += t.I18nBot("tgbot.messages.subURL", "URL=="+html.EscapeString(subURL)) + body += t.I18nBot("tgbot.messages.subURL", "URL=="+subURL) rows := [][]telego.InlineKeyboardButton{ tu.InlineKeyboardRow(t.btn("tgbot.buttons.individualLinks", t.encodeQuery("client_individual_links "+email))), t.backRow(), diff --git a/internal/web/service/tgbot/screen_views.go b/internal/web/service/tgbot/screen_views.go index 3303ffa11..d21871b3a 100644 --- a/internal/web/service/tgbot/screen_views.go +++ b/internal/web/service/tgbot/screen_views.go @@ -235,7 +235,7 @@ func (t *Tgbot) screenReport(chatID int64) { tu.InlineKeyboardRow(t.btn("tgbot.buttons.refresh", cbReport)), t.backRow(), } - t.renderScreen(chatID, t.newScreen("reports", html.EscapeString(report.String()), rows...)) + t.renderScreen(chatID, t.newScreen("reports", report.String(), rows...)) } // screenCommands explains the classic commands; kept because muscle memory is diff --git a/internal/web/service/tgbot/tgbot.go b/internal/web/service/tgbot/tgbot.go index 374cf5034..e3065395e 100644 --- a/internal/web/service/tgbot/tgbot.go +++ b/internal/web/service/tgbot/tgbot.go @@ -5,6 +5,7 @@ import ( "crypto/rand" "embed" "fmt" + "html" "math/big" "net" "net/url" @@ -249,9 +250,19 @@ func (t *Tgbot) NewTgbot() *Tgbot { return new(Tgbot) } -// I18nBot retrieves a localized message for the bot interface. +// I18nBot retrieves a localized message for the bot interface. Every message is sent +// HTML-parsed, so param values (remarks, e-mails, errors) are escaped here. func (t *Tgbot) I18nBot(name string, params ...string) string { - return locale.I18n(locale.Bot, name, params...) + escaped := make([]string, len(params)) + for i, param := range params { + key, value, found := strings.Cut(param, "==") + if !found { + escaped[i] = param + continue + } + escaped[i] = key + "==" + html.EscapeString(value) + } + return locale.I18n(locale.Bot, name, escaped...) } // GetHashStorage returns the hash storage instance for callback queries. diff --git a/internal/web/service/tgbot/tgbot_client.go b/internal/web/service/tgbot/tgbot_client.go index 5e9868781..e11e2ce77 100644 --- a/internal/web/service/tgbot/tgbot_client.go +++ b/internal/web/service/tgbot/tgbot_client.go @@ -321,7 +321,7 @@ func (t *Tgbot) clientInfoMsg( output := "" output += t.I18nBot("tgbot.messages.email", "Email=="+traffic.Email) if attachIds, err := t.clientService.GetInboundIdsForEmail(nil, traffic.Email); err == nil && len(attachIds) > 0 { - output += fmt.Sprintf("🔗 Inbounds: %s\r\n", t.describeAttachedInbounds(attachIds)) + output += fmt.Sprintf("🔗 Inbounds: %s\r\n", html.EscapeString(t.describeAttachedInbounds(attachIds))) } if printEnabled { output += t.I18nBot("tgbot.messages.enabled", "Enable=="+enabled) diff --git a/internal/web/service/tgbot/tgbot_invite.go b/internal/web/service/tgbot/tgbot_invite.go index 35f1bf3f4..ac1ff8474 100644 --- a/internal/web/service/tgbot/tgbot_invite.go +++ b/internal/web/service/tgbot/tgbot_invite.go @@ -11,6 +11,7 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/logger" "github.com/mhsanaei/3x-ui/v3/internal/util/common" + "github.com/mhsanaei/3x-ui/v3/internal/web/locale" "github.com/mymmrac/telego" ) @@ -197,7 +198,8 @@ func (t *Tgbot) allowInviteAttempt(from *telego.User) bool { inviteAttemptsMu.Unlock() if count == inviteAttemptLimit+1 { - t.SendMsgToTgbotAdmins(t.I18nBot("tgbot.messages.inviteRateLimitedAdmin", + // locale.I18n directly: User is a ready-made mention that I18nBot would escape. + t.SendMsgToTgbotAdmins(locale.I18n(locale.Bot, "tgbot.messages.inviteRateLimitedAdmin", "User=="+tgUserMention(from), "ID=="+strconv.FormatInt(from.ID, 10), "Limit=="+strconv.Itoa(inviteAttemptLimit))) diff --git a/internal/web/service/tgbot/tgbot_report.go b/internal/web/service/tgbot/tgbot_report.go index c1da45bbc..dd7a65c3f 100644 --- a/internal/web/service/tgbot/tgbot_report.go +++ b/internal/web/service/tgbot/tgbot_report.go @@ -2,6 +2,7 @@ package tgbot import ( "fmt" + "html" "net" "os" "strconv" @@ -292,7 +293,7 @@ func (t *Tgbot) notifyExhausted() { output.WriteString(":\r\n") for _, traffic := range disabledClients { output.WriteString(" ") - output.WriteString(traffic.Email) + output.WriteString(html.EscapeString(traffic.Email)) } output.WriteString("\r\n") } diff --git a/internal/web/service/tgbot/tgbot_report_batching_test.go b/internal/web/service/tgbot/tgbot_report_batching_test.go index ba0166ba9..96bb1f03d 100644 --- a/internal/web/service/tgbot/tgbot_report_batching_test.go +++ b/internal/web/service/tgbot/tgbot_report_batching_test.go @@ -2,7 +2,6 @@ package tgbot import ( "encoding/json" - "errors" "fmt" "io" "net/http" @@ -11,18 +10,13 @@ import ( "strings" "sync" "testing" - "time" "github.com/mymmrac/telego" "github.com/nicksnyder/go-i18n/v2/i18n" - "golang.org/x/text/language" - "gorm.io/gorm" "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/dbtest" "github.com/mhsanaei/3x-ui/v3/internal/database/model" - "github.com/mhsanaei/3x-ui/v3/internal/web/locale" - "github.com/mhsanaei/3x-ui/v3/internal/web/service" "github.com/mhsanaei/3x-ui/v3/internal/xray" ) @@ -173,157 +167,29 @@ func TestResetAllTrafficsAnswersWithNoClients(t *testing.T) { } } -// Regression test: the report leaves as one HTML-parsed message, so a remark -// holding "<" must reach Telegram escaped instead of dropping the whole page. -func TestTrafficUsageReportEscapesHtml(t *testing.T) { - mock, sent := captureReportServer(t) - swapTestBot(t, mock.URL) - defer mock.Close() - +// Every bot message is HTML-parsed, and a node chooses the remark and e-mails the master +// stores for its inbound: filled into a template unescaped, they became markup. +func TestClientInfoEscapesNodeValues(t *testing.T) { tb := initReportDB(t) - seedReportClients(t, "DE ", []string{"a@x"}) - - tb.answerCallback(&telego.CallbackQuery{ - ID: "q1", - From: telego.User{ID: 1}, - Data: "get_sorted_traffic_usage_report", - Message: &telego.Message{Chat: telego.Chat{ID: 1}}, - }, true) // admin - - got := sent() - if len(got) != 1 { - t.Fatalf("messages = %d, want 1", len(got)) + draftLocalizer(t, &i18n.Message{ID: "tgbot.messages.email", Other: "📧 Email: {{ .Email }}\r\n"}) + remark := `Panel session expired` + email := "zz" + seedReportClients(t, remark, []string{email}) + var traffic xray.ClientTraffic + if err := database.GetDB().Where("email = ?", email).First(&traffic).Error; err != nil { + t.Fatalf("read traffic: %v", err) } - if strings.Contains(got[0].body(), "") { - t.Errorf("report text = %q, want the remark escaped", got[0].body()) - } -} -func TestNotifyExhaustedLooksUpEachIDOnce(t *testing.T) { - for _, outcome := range []string{"empty", "failure", "exhausted", "expiry", "admin", "zero", "disabled-inbound"} { - t.Run(outcome, func(t *testing.T) { - tb := initReportDB(t) - clientInfoLocalizer(t) - mock, sent := captureReportServer(t) - swapTestBot(t, mock.URL) - t.Cleanup(mock.Close) - clients := []model.Client{ - {Email: "notify-a", ID: "uuid-a", TgID: 6886, Enable: true}, - {Email: "notify-b", ID: "uuid-b", TgID: 6886, Enable: true}, - } - if outcome == "zero" { - for i := range clients { - clients[i].TgID = 0 - } - } - if outcome == "admin" { - tgBotMutex.Lock() - previous := adminIds - adminIds = append(append([]int64(nil), adminIds...), 6886) - tgBotMutex.Unlock() - t.Cleanup(func() { - tgBotMutex.Lock() - adminIds = previous - tgBotMutex.Unlock() - }) - } - db := database.GetDB() - for i := range 2 { - inbound := &model.Inbound{UserId: 1, Tag: fmt.Sprintf("notify-%d", i), Port: 41890 + i, Enable: true, Protocol: model.VLESS, Settings: `{"clients":[{"email":"notify-a","id":"uuid-a","enable":true,"tgId":6886},{"email":"notify-b","id":"uuid-b","enable":true,"tgId":6886}]}`} - if outcome == "disabled-inbound" { - inbound.Enable = false - } - if err := db.Create(inbound).Error; err != nil { - t.Fatal(err) - } - if err := (&service.ClientService{}).SyncInbound(nil, inbound.Id, clients); err != nil { - t.Fatal(err) - } - // Keep the inbound eligible even when the Telegram account has no traffic. - traffic := &xray.ClientTraffic{Email: fmt.Sprintf("unrelated-%d", i), InboundId: inbound.Id} - if outcome == "exhausted" || outcome == "expiry" { - traffic.Email = clients[i].Email - traffic.Enable = true - traffic.Total = 1 - traffic.Up = 2 - if outcome == "expiry" { - traffic.Total = 0 - traffic.ExpiryTime = time.Now().Add(-time.Hour).UnixMilli() - } - } - if err := db.Create(traffic).Error; err != nil { - t.Fatal(err) - } - } - lookups := 0 - cb := "test:notify_lookup" - if err := db.Callback().Query().Before("gorm:query").Register(cb, func(tx *gorm.DB) { - if tx.Statement.Table == "clients" && strings.Contains(fmt.Sprint(tx.Statement.Clauses["WHERE"]), "tg_id") { - lookups++ - if outcome == "failure" { - tx.AddError(errors.New("injected Telegram lookup failure")) - } - } - }); err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = db.Callback().Query().Remove(cb) }) - tb.notifyExhausted() - wantLookups := 1 - if outcome == "admin" || outcome == "zero" || outcome == "disabled-inbound" { - wantLookups = 0 - } - if lookups != wantLookups { - t.Fatalf("lookups = %d, want %d after %s", lookups, wantLookups, outcome) - } - got := sent() - if outcome == "exhausted" || outcome == "expiry" { - if len(got) != 1 || !strings.Contains(got[0].Text, "notify-a") || !strings.Contains(got[0].Text, "notify-b") { - t.Fatalf("want one notification containing both clients, got %+v", got) - } - } else if len(got) != 0 { - t.Fatalf("unexpected notifications: %+v", got) - } - }) - } -} + out := tb.clientInfoMsg(&traffic, true, false, false, true, true, false) -func TestServerUsageInfoShowsUnavailableUptime(t *testing.T) { - bundle := i18n.NewBundle(language.English) - _ = bundle.AddMessages(language.English, &i18n.Message{ID: "tgbot.messages.serverUpTime", Other: "Uptime: {{.UpTime}} {{.Unit}}\n"}, &i18n.Message{ID: "tgbot.days", Other: "days"}) - oldLocale := locale.SwapBotLocalizer(i18n.NewLocalizer(bundle, "en")) - t.Cleanup(func() { locale.SwapBotLocalizer(oldLocale) }) - statusCache.mutex.Lock() - oldStatus, oldTime := statusCache.data, statusCache.timestamp - statusCache.mutex.Unlock() - serverStatsCache.mutex.Lock() - oldStats, oldStatsTime := serverStatsCache.data, serverStatsCache.timestamp - serverStatsCache.mutex.Unlock() - t.Cleanup(func() { - statusCache.mutex.Lock() - statusCache.data, statusCache.timestamp = oldStatus, oldTime - statusCache.mutex.Unlock() - serverStatsCache.mutex.Lock() - serverStatsCache.data, serverStatsCache.timestamp = oldStats, oldStatsTime - serverStatsCache.mutex.Unlock() - }) - for _, unavailable := range []bool{true, false} { - t.Run(map[bool]string{true: "unavailable", false: "valid zero"}[unavailable], func(t *testing.T) { - bot := &Tgbot{} - status := &service.Status{Loads: []float64{0, 0, 0}} - if unavailable { - status.UnavailableMetrics = []string{"uptime"} - } - bot.setCachedStatus(status) - bot.setCachedServerStats("") - info := bot.prepareServerUsageInfo() - want := "Uptime: 0 days" - if unavailable { - want = "Uptime: —" - } - if !strings.Contains(info, want) { - t.Fatalf("report did not contain %q: %s", want, info) - } - }) + for _, raw := range []string{"zz"} { + if strings.Contains(out, raw) { + t.Errorf("client info = %q, want %q escaped", out, raw) + } + } + for _, want := range []string{"📧 Email: <b>zz</b>", "<a href="} { + if !strings.Contains(out, want) { + t.Errorf("client info = %q, want it to contain %q", out, want) + } } } diff --git a/internal/web/service/tgbot/tgbot_router.go b/internal/web/service/tgbot/tgbot_router.go index 107fdb896..e883401bb 100644 --- a/internal/web/service/tgbot/tgbot_router.go +++ b/internal/web/service/tgbot/tgbot_router.go @@ -2,7 +2,6 @@ package tgbot import ( "context" - "html" "slices" "strconv" "strings" @@ -1030,17 +1029,17 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool t.openOwnClientScreen(chatId, callbackQuery.From.ID, callbackQuery.Data) case "add_client_ch_default_email": userStateMgr.set(actor, "awaiting_email") - t.wizardPrompt(chatId, draft, t.I18nBot("tgbot.messages.email_prompt", "ClientEmail=="+html.EscapeString(draft.email))) + t.wizardPrompt(chatId, draft, t.I18nBot("tgbot.messages.email_prompt", "ClientEmail=="+draft.email)) case "add_client_ch_default_comment": userStateMgr.set(actor, "awaiting_comment") - t.wizardPrompt(chatId, draft, t.I18nBot("tgbot.messages.comment_prompt", "ClientComment=="+html.EscapeString(draft.comment))) + t.wizardPrompt(chatId, draft, t.I18nBot("tgbot.messages.comment_prompt", "ClientComment=="+draft.comment)) case "add_client_ch_default_tg_id": userStateMgr.set(actor, "awaiting_tg_id") current := draft.tgID if current == "" { current = "—" } - t.wizardPrompt(chatId, draft, t.I18nBot("tgbot.messages.tgid_prompt", "ClientTGID=="+html.EscapeString(current))) + t.wizardPrompt(chatId, draft, t.I18nBot("tgbot.messages.tgid_prompt", "ClientTGID=="+current)) case "add_client_ch_default_traffic": inlineKeyboard := tu.InlineKeyboard( tu.InlineKeyboardRow( @@ -1192,9 +1191,8 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool } report.WriteString(t.I18nBot("tgbot.messages.FinishProcess")) - // Escaped whole (a stray "<" makes Telegram reject a page) and paged: - // past ~80 clients the report exceeds one message. - t.sendPagedNotice(chatId, html.EscapeString(report.String())) + // Paged: past ~80 clients the report exceeds one message. + t.sendPagedNotice(chatId, report.String()) // The confirmation has served its purpose: leaving it on screen left the // only button being the one just pressed. t.screenHome(chatId, callbackQuery.From.ID) diff --git a/internal/web/service/tgbot/tgbot_send.go b/internal/web/service/tgbot/tgbot_send.go index e134f1c18..8db98c51d 100644 --- a/internal/web/service/tgbot/tgbot_send.go +++ b/internal/web/service/tgbot/tgbot_send.go @@ -3,6 +3,7 @@ package tgbot import ( "context" "fmt" + "html" "strings" "time" @@ -149,9 +150,10 @@ func (t *Tgbot) SendMsgToTgbotAdmins(msg string, replyMarkup ...telego.ReplyMark // sendCallbackAnswerTgBot answers a callback query with a message. func (t *Tgbot) sendCallbackAnswerTgBot(id string, message string) { + // Callback answers are plain text, so the HTML entities I18nBot adds are undone. params := telego.AnswerCallbackQueryParams{ CallbackQueryID: id, - Text: message, + Text: html.UnescapeString(message), } if err := liveBot().AnswerCallbackQuery(context.Background(), ¶ms); err != nil { logger.Warning(err) diff --git a/internal/web/service/tgbot/tgbot_send_test.go b/internal/web/service/tgbot/tgbot_send_test.go index 958ffccf6..b86391725 100644 --- a/internal/web/service/tgbot/tgbot_send_test.go +++ b/internal/web/service/tgbot/tgbot_send_test.go @@ -6,12 +6,14 @@ import ( "net/http" "net/http/httptest" "strings" + "sync" "testing" "github.com/mhsanaei/3x-ui/v3/internal/logger" "github.com/mymmrac/telego" tu "github.com/mymmrac/telego/telegoutil" + "github.com/nicksnyder/go-i18n/v2/i18n" ) func TestIsTelegramNotModifiedError(t *testing.T) { @@ -136,3 +138,37 @@ func TestPageMessageSplitsLinkListWithoutBlankLines(t *testing.T) { t.Errorf("pages contain %d links, want 50", links) } } + +// Regression test: callback answers are shown as plain text, so the entities +// I18nBot escapes for HTML-parsed messages must not surface in them. +func TestCallbackAnswerIsPlainText(t *testing.T) { + var mu sync.Mutex + var texts []string + mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/bot"+testBotToken+"/answerCallbackQuery" { + w.WriteHeader(http.StatusNotFound) + return + } + var body struct { + Text string `json:"text"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + mu.Lock() + texts = append(texts, body.Text) + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": true}) + })) + defer mock.Close() + swapTestBot(t, mock.URL) + draftLocalizer(t, &i18n.Message{ID: "tgbot.answers.canceled", Other: "❌ {{ .Email }}: Operation canceled."}) + + tb := &Tgbot{} + tb.sendCallbackAnswerTgBot("q1", tb.I18nBot("tgbot.answers.canceled", "Email==a&b")) + + mu.Lock() + defer mu.Unlock() + if len(texts) != 1 || texts[0] != "❌ a&b: Operation canceled." { + t.Errorf("answerCallbackQuery texts = %q, want the e-mail shown verbatim", texts) + } +} From 1507fd4edf4cadea4b3500baa8a813ac6cc9ee2a Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 09:02:15 +0100 Subject: [PATCH 084/125] fix(hwid): re-check the device limit where an unlimited fetch registers EnforceHwidForSubID read the subscription's device limit and, finding none, recorded the device without looking again. A fetch racing an operator who had just set a limit could therefore register its device right after the limit was written and trimmed, leaving more devices registered than the limit and serving all of them. The tracking now re-reads the limit and writes the row in one transaction, which serializes with the operator's, and a limit that has appeared sends the request through the limited gate instead. --- internal/web/service/client_hwid.go | 33 ++++++++++----- internal/web/service/client_hwid_tx_test.go | 47 +++++++++++++++++++++ 2 files changed, 70 insertions(+), 10 deletions(-) diff --git a/internal/web/service/client_hwid.go b/internal/web/service/client_hwid.go index 813dbd605..109589106 100644 --- a/internal/web/service/client_hwid.go +++ b/internal/web/service/client_hwid.go @@ -128,19 +128,18 @@ func (s *ClientService) EnforceHwidForSubID(subID string, req HwidRequest) (Hwid } req = normalizeHwidRequest(req) if limit <= 0 { - res.Allowed = true if len(req.Hwid) < minHwidLength { - return res, nil + return HwidGateResult{Allowed: true}, nil } // The gate runs before the sub lookup; a subId no client owns gets no row. known, err := subIDHasEnabledClient(db, subID) if err != nil { return res, err } - if known { - trackUnlimitedHwid(db, subID, req) + // A limit set since the read above sends the request through the gate below. + if !known || trackUnlimitedHwid(db, subID, req) { + return HwidGateResult{Allowed: true}, nil } - return res, nil } res.Active = true @@ -206,15 +205,29 @@ func (s *ClientService) EnforceHwidForSubID(subID string, req HwidRequest) (Hwid // trackUnlimitedHwid lists devices of a sub with no HWID limit in the panel. It is // best-effort: a failed write must not deny a subscription nothing restricts. -func trackUnlimitedHwid(db *gorm.DB, subID string, req HwidRequest) { +// False means the sub has a limit by now and no row was written. +func trackUnlimitedHwid(db *gorm.DB, subID string, req HwidRequest) bool { + limited := false now := time.Now().UnixMilli() - err := db.Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "sub_id"}, {Name: "hwid_hash"}}, - DoUpdates: clause.AssignmentColumns([]string{"last_seen", "user_agent", "device_os", "os_version", "device_model"}), - }).Create(&model.ClientHwid{SubID: subID, HwidHash: hashHwid(req.Hwid), FirstSeen: now, LastSeen: now, UserAgent: req.UserAgent, DeviceOS: req.DeviceOS, OsVersion: req.OsVersion, DeviceModel: req.DeviceModel}).Error + err := db.Transaction(func(tx *gorm.DB) error { + // A row landing behind setClientLimitHwidByEmailTx's trim would stay registered + // above the limit, so the caller's read does not count here. + limit, err := effectiveHwidLimitForSubID(tx, subID) + if err != nil { + return err + } + if limited = limit > 0; limited { + return nil + } + return tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "sub_id"}, {Name: "hwid_hash"}}, + DoUpdates: clause.AssignmentColumns([]string{"last_seen", "user_agent", "device_os", "os_version", "device_model"}), + }).Create(&model.ClientHwid{SubID: subID, HwidHash: hashHwid(req.Hwid), FirstSeen: now, LastSeen: now, UserAgent: req.UserAgent, DeviceOS: req.DeviceOS, OsVersion: req.OsVersion, DeviceModel: req.DeviceModel}).Error + }) if err != nil { logger.Warning("track HWID for unlimited subscription failed:", err) } + return !limited } // HwidSlotStatusForSubID is SELECT-only: it must never write client_hwids or diff --git a/internal/web/service/client_hwid_tx_test.go b/internal/web/service/client_hwid_tx_test.go index 0f83822f4..5372baa52 100644 --- a/internal/web/service/client_hwid_tx_test.go +++ b/internal/web/service/client_hwid_tx_test.go @@ -96,6 +96,53 @@ func TestClientHwidTxRejectsUnserializedHandle(t *testing.T) { assertHwidState(t, db, rec.Email, 1, 0) } +func TestUnlimitedHwidTrackingCannotLandBehindNewLimit(t *testing.T) { + initClientHwidTestDB(t) + db := database.GetDB() + rec := seedHwidClient(t, 0) + svc := &ClientService{} + if _, err := svc.EnforceHwidForSubID(rec.SubID, HwidRequest{Hwid: "device-one"}); err != nil { + t.Fatalf("track first device: %v", err) + } + + // The limit write and its trim stay uncommitted while the gate reads "no limit". + written := make(chan struct{}) + release := make(chan struct{}) + limitDone := make(chan error, 1) + go func() { + limitDone <- runSerializedTx(func(tx *gorm.DB) error { + err := svc.setClientLimitHwidByEmailTx(tx, rec.Email, 1) + close(written) + <-release + return err + }) + }() + <-written + + type gate struct { + res HwidGateResult + err error + } + gateDone := make(chan gate, 1) + go func() { + res, err := svc.EnforceHwidForSubID(rec.SubID, HwidRequest{Hwid: "device-two"}) + gateDone <- gate{res, err} + }() + time.Sleep(100 * time.Millisecond) + close(release) + if err := <-limitDone; err != nil { + t.Fatalf("set limit: %v", err) + } + g := <-gateDone + if g.err != nil { + t.Fatalf("gate: %v", g.err) + } + if g.res.Allowed || !g.res.MaxDevicesReached || g.res.Limit != 1 { + t.Fatalf("second device must meet the new limit: %+v", g.res) + } + assertHwidState(t, db, rec.Email, 1, 1) +} + func TestBulkAdjustHwidRollsBackFailedTrim(t *testing.T) { setupBulkDB(t) db := database.GetDB() From 878d37c2fc01c15c1f844d82ff0524a6cee08b2d Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 09:03:42 +0100 Subject: [PATCH 085/125] fix(panel): bound the sponsor logo cache and evict logos that left the list The logo cache was keyed by whatever names the sponsors list named and never pruned, so a list naming thousands of logos made the public logo route pin hundreds of megabytes, and every hourly refresh with new names added more. Only the first 32 distinct logo names of the active list are kept, surplus sponsors render with their initial as a bad logo name already does, and each store drops cached logos no longer on the list. --- internal/web/service/panel/sponsor.go | 25 ++++++++++---- internal/web/service/panel/sponsor_test.go | 39 ++++++++++++++++++++++ 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/internal/web/service/panel/sponsor.go b/internal/web/service/panel/sponsor.go index 0df1d46db..2e0356ddf 100644 --- a/internal/web/service/panel/sponsor.go +++ b/internal/web/service/panel/sponsor.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io" + "maps" "net/http" "os" "path/filepath" @@ -45,6 +46,7 @@ const ( sponsorsErrTTL = 10 * time.Minute maxSponsorsBytes = 256 << 10 maxLogoBytes = 256 << 10 + maxSponsorLogos = 32 localSponsorsDir = "../sponsors/3X" sponsorLogoPath = "/sponsors/logo/" ) @@ -118,7 +120,7 @@ func (s *PanelService) GetSponsorLogo(name string) ([]byte, string, error) { if err != nil { return nil, "", err } - if !slices.ContainsFunc(sponsors.Sponsors, func(sp Sponsor) bool { return sp.Logo == sponsorLogoPath+name }) { + if !sponsors.hasLogo(name) { return nil, "", ErrSponsorLogoUnknown } logosMu.Lock() @@ -138,10 +140,16 @@ func (s *PanelService) GetSponsorLogo(name string) ([]byte, string, error) { default: l = sponsorLogo{err: err, retryAt: now.Add(sponsorsErrTTL)} } + // Logos that left the active list go with every store, so the cache never outgrows maxSponsorLogos. + maps.DeleteFunc(logos, func(cached string, _ sponsorLogo) bool { return !sponsors.hasLogo(cached) }) logos[name] = l return l.data, l.contentType, l.err } +func (list *SponsorList) hasLogo(name string) bool { + return slices.ContainsFunc(list.Sponsors, func(sp Sponsor) bool { return sp.Logo == sponsorLogoPath+name }) +} + func fetchSponsorLogo(name string) ([]byte, string, error) { data, err := readSponsorSource(sponsorLogoBase+name, filepath.Join(localSponsorsDir, "logos", name), maxLogoBytes) if err != nil { @@ -216,6 +224,7 @@ func activeSponsors(raw *SponsorList, now time.Time) *SponsorList { if strings.HasPrefix(raw.Contact, "https://") { out.Contact = raw.Contact } + logoNames := map[string]bool{} for _, sp := range raw.Sponsors { // A missing enable counts as on, so a forgotten field never hides a paid slot. disabled := sp.Enable != nil && !*sp.Enable @@ -223,12 +232,6 @@ func activeSponsors(raw *SponsorList, now time.Time) *SponsorList { if disabled || notStarted || sp.ID == "" || !now.Before(sp.Until) || !strings.HasPrefix(sp.Link, "https://") { continue } - // A bad logo name drops only the logo; the paid slot still renders with its initial. - if sponsorLogoRe.MatchString(sp.Logo) { - sp.Logo = sponsorLogoPath + sp.Logo - } else { - sp.Logo = "" - } slots := make([]string, 0, len(sp.Slots)) for _, slot := range sp.Slots { if sponsorSlots[slot] { @@ -238,6 +241,14 @@ func activeSponsors(raw *SponsorList, now time.Time) *SponsorList { if len(slots) == 0 { continue } + // A bad or surplus logo name drops only the logo; the paid slot renders with its initial. + // Every name kept here becomes one cached image, so the list must not decide how many. + if sponsorLogoRe.MatchString(sp.Logo) && (logoNames[sp.Logo] || len(logoNames) < maxSponsorLogos) { + logoNames[sp.Logo] = true + sp.Logo = sponsorLogoPath + sp.Logo + } else { + sp.Logo = "" + } sp.Slots = slots out.Sponsors = append(out.Sponsors, sp) } diff --git a/internal/web/service/panel/sponsor_test.go b/internal/web/service/panel/sponsor_test.go index c6bfe0c6b..212ae5e21 100644 --- a/internal/web/service/panel/sponsor_test.go +++ b/internal/web/service/panel/sponsor_test.go @@ -2,6 +2,7 @@ package panel import ( "errors" + "fmt" "net/http" "net/http/httptest" "os" @@ -114,6 +115,10 @@ func setupSponsorServer(t *testing.T, body string) *atomic.Int32 { w.WriteHeader(http.StatusBadGateway) return } + if strings.HasPrefix(r.URL.Path, "/media/many") { + _, _ = w.Write(pngMagic) + return + } switch r.URL.Path { case "/media/acme.png": _, _ = w.Write(pngMagic) @@ -234,6 +239,40 @@ func TestGetSponsorLogo(t *testing.T) { } } +// The feed decides which logo names exist, so it must not decide how many the panel holds in memory. +func TestSponsorLogoCacheStaysBounded(t *testing.T) { + var list []string + for i := range 3 * maxSponsorLogos { + until := "2099-01-01T00:00:00Z" + if i < maxSponsorLogos { + until = sponsorTestNow.Add(time.Minute).Format(time.RFC3339) + } + list = append(list, fmt.Sprintf(`{"id":"s%d","slots":["page"],"until":%q, + "link":"https://s.example/","logo":"many%d.png"}`, i, until, i)) + } + setupSponsorServer(t, `{"sponsors":[`+strings.Join(list, ",")+`]}`) + now := sponsorTestNow + sponsorNow = func() time.Time { return now } + svc := &PanelService{} + + got, err := svc.GetSponsors() + if err != nil || len(got.Sponsors) != 3*maxSponsorLogos { + t.Fatalf("GetSponsors() = %d sponsors, %v; want all %d kept (only surplus logos drop)", len(got.Sponsors), err, 3*maxSponsorLogos) + } + for _, step := range []string{"full list", "first sponsors expired"} { + served := 0 + for i := range 3 * maxSponsorLogos { + if _, _, err := svc.GetSponsorLogo(fmt.Sprintf("many%d.png", i)); err == nil { + served++ + } + } + if served != maxSponsorLogos || len(logos) != maxSponsorLogos { + t.Errorf("%s: served %d logos, cache holds %d, want %d for both", step, served, len(logos), maxSponsorLogos) + } + now = now.Add(2 * time.Minute) + } +} + func TestGetSponsorsCachesAndExpiresWhileCached(t *testing.T) { hits := setupSponsorServer(t, `{"sponsors":[{"id":"acme","slots":["page"], "until":"2026-10-15T00:30:00Z","link":"https://acme.example/"}]}`) From dda44c1e72d43d2554d7f7a68080a3834b980f02 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 09:06:32 +0100 Subject: [PATCH 086/125] perf(sub): resolve legacy socket fallback fronts once per request projectThroughFallbackMaster found the front of each unix-socket child by scanning every inbound's settings JSON, once per child, so a subscription cost the number of socket inbounds times the size of all settings in the table. A request now collects every host's socket fronts in one statement, the lowest-id front naming a socket on that host still wins, and an inbound with unreadable settings no longer fails the lookup. --- internal/sub/host_sub_test.go | 66 ++++++++++++++++++++++++++++++++++ internal/sub/service.go | 68 ++++++++++++++++++++++++++++------- 2 files changed, 121 insertions(+), 13 deletions(-) diff --git a/internal/sub/host_sub_test.go b/internal/sub/host_sub_test.go index 3d674bbc6..e01ff30ea 100644 --- a/internal/sub/host_sub_test.go +++ b/internal/sub/host_sub_test.go @@ -17,6 +17,8 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/web/entity" "github.com/mhsanaei/3x-ui/v3/internal/web/service" "github.com/mhsanaei/3x-ui/v3/internal/xray" + + "gorm.io/gorm" ) func seedSubDB(t *testing.T) { @@ -2011,3 +2013,67 @@ func TestSub_HostMultiSNI_Overrides(t *testing.T) { t.Errorf("expected blank sni: %s", parts[1]) } } + +// The "@socket" front lookup parses every inbound's settings, so a request runs it +// once, not per child; the lower-id front still wins and unreadable settings are skipped. +func TestSub_LegacyFallbackMasterLookedUpOncePerRequest(t *testing.T) { + seedSubDB(t) + db := database.GetDB() + fronts := []*model.Inbound{ + { + UserId: 1, Tag: "front", Enable: true, Port: 9443, Protocol: model.VLESS, + Settings: `{"clients":[],"decryption":"none","fallbacks":[{"dest":80},{"dest":"@child-1"},"not-an-object"]}`, + StreamSettings: `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"front.sni"}}`, + }, + { + UserId: 1, Tag: "newer-front", Enable: true, Port: 9444, Protocol: model.VLESS, + Settings: `{"clients":[],"decryption":"none","fallbacks":[{"dest":"@child-1"},{"dest":"@child-2"}]}`, + StreamSettings: `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"newer.sni"}}`, + }, + {UserId: 1, Tag: "broken", Enable: true, Port: 9445, Protocol: model.VLESS, Settings: `{not json`, StreamSettings: `{}`}, + } + for _, ib := range fronts { + if err := db.Create(ib).Error; err != nil { + t.Fatalf("seed %s: %v", ib.Tag, err) + } + } + for i := 1; i <= 3; i++ { + child := seedSubInbound(t, "s1", fmt.Sprintf("child-%d", i), 4450+i, i, `{"network":"ws","security":"none"}`) + child.Listen = fmt.Sprintf("@child-%d", i) + if err := db.Model(&model.Inbound{}).Where("id = ?", child.Id).Update("listen", child.Listen).Error; err != nil { + t.Fatalf("set child listen: %v", err) + } + } + + scans := 0 + count := func(tx *gorm.DB) { + if strings.Contains(tx.Statement.SQL.String(), "$.fallbacks") { + scans++ + } + } + _ = db.Callback().Query().After("gorm:query").Register("test:count_front_scans", count) + _ = db.Callback().Row().After("gorm:row").Register("test:count_front_scans", count) + t.Cleanup(func() { + _ = db.Callback().Query().Remove("test:count_front_scans") + _ = db.Callback().Row().Remove("test:count_front_scans") + }) + + links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com") + if err != nil { + t.Fatalf("GetSubs: %v", err) + } + if scans != 1 { + t.Fatalf("inbound settings were scanned %d times in one request, want 1", scans) + } + if len(links) != 3 { + t.Fatalf("links = %d, want 3: %v", len(links), links) + } + for i, want := range []string{"req.example.com:9443?", "req.example.com:9444?", "req.example.com:4453?"} { + if !strings.Contains(links[i], want) { + t.Fatalf("child-%d link should advertise %s: %s", i+1, want, links[i]) + } + } + if !strings.Contains(links[0], "sni=front.sni") { + t.Fatalf("child-1 must carry its front's TLS state: %s", links[0]) + } +} diff --git a/internal/sub/service.go b/internal/sub/service.go index 0a0d3cf64..0f6ea464d 100644 --- a/internal/sub/service.go +++ b/internal/sub/service.go @@ -79,6 +79,9 @@ type SubService struct { // with the clients array left out; generators read only inbound-level // fields (encryption, method, version, …) from it. settingsByInbound map[int]map[string]any + // socketFronts maps a host and a unix-socket dest named by a settings.fallbacks to the + // lowest-id inbound of that host naming it. Filled by socketFront; reset per request. + socketFronts map[socketFrontKey]*model.Inbound // rendered is what this request has put into its response so far. rendered renderBudget // settingsCache is shared by this service's requests (ForRequest copies the pointer); only @@ -130,6 +133,7 @@ func (s *SubService) PrepareForRequest(host string) { s.clientsByInbound = map[int]map[string]model.Client{} s.fullyPrimedInbounds = map[int]bool{} s.settingsByInbound = map[int]map[string]any{} + s.socketFronts = nil s.hostsByInbound = nil s.hostGroupOrders = nil s.sharedGroupSni = nil @@ -878,21 +882,9 @@ func (s *SubService) projectThroughFallbackMaster(inbound *model.Inbound) bool { } if master == nil && len(inbound.Listen) > 0 && inbound.Listen[0] == '@' { - var m model.Inbound // A unix socket exists only on the host that binds it, so only an inbound // hosted where the child runs (this panel, or the same node) can front it. - q := db.Model(model.Inbound{}) - if inbound.NodeID != nil { - q = q.Where("node_id = ?", *inbound.NodeID) - } else { - q = q.Where("node_id IS NULL") - } - if err := q. - Where("JSON_TYPE(settings, '$.fallbacks') = 'array'"). - Where("EXISTS (SELECT * FROM json_each(settings, '$.fallbacks') WHERE json_extract(value, '$.dest') = ?)", inbound.Listen). - First(&m).Error; err == nil { - master = &m - } + master = s.socketFront(socketFrontKey{node: nodeKeyOf(inbound.NodeID), dest: inbound.Listen}) } if master == nil { @@ -3521,3 +3513,53 @@ func extraSalamanderKeys(settings map[string]any, expressedPacketSize bool) []st sort.Strings(extra) return extra } + +// socketFrontKey names a unix socket on one host: 0 is this panel, else the node id. +type socketFrontKey struct { + node int + dest string +} + +func nodeKeyOf(nodeID *int) int { + if nodeID == nil { + return 0 + } + return *nodeID +} + +// socketFront resolves a legacy unix-socket child to its front. That means parsing the +// settings JSON of every inbound, so the scan runs once per request, not once per child. +func (s *SubService) socketFront(key socketFrontKey) *model.Inbound { + if s.socketFronts == nil { + s.socketFronts = map[socketFrontKey]*model.Inbound{} + var fronts []struct { + NodeID *int + Listen string + Port int + StreamSettings string + Fallbacks string + } + // json_valid keeps one inbound with unreadable settings from failing the + // whole statement and hiding every front. + if err := database.GetDB().Model(model.Inbound{}). + Select("node_id, listen, port, stream_settings, json_extract(settings, '$.fallbacks') AS fallbacks"). + Where("CASE WHEN json_valid(settings) THEN JSON_TYPE(settings, '$.fallbacks') END = 'array'"). + Order("id ASC").Scan(&fronts).Error; err != nil { + return nil + } + for _, front := range fronts { + var fallbacks []any + _ = json.Unmarshal([]byte(front.Fallbacks), &fallbacks) + for _, fallback := range fallbacks { + rule, _ := fallback.(map[string]any) + dest, _ := rule["dest"].(string) + k := socketFrontKey{node: nodeKeyOf(front.NodeID), dest: dest} + if _, taken := s.socketFronts[k]; taken || !strings.HasPrefix(dest, "@") { + continue + } + s.socketFronts[k] = &model.Inbound{Listen: front.Listen, Port: front.Port, StreamSettings: front.StreamSettings} + } + } + } + return s.socketFronts[key] +} From 7163c6e521b17ff371aee05368a09c80ded8bc52 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 09:09:28 +0100 Subject: [PATCH 087/125] perf(sub): decode TUIC and AmneziaWG settings once per request genTuicLink and genAmneziaWGLink, and their Clash and JSON siblings, parsed the inbound's whole settings, every client included, for each link they built, bypassing the per-request caches the other generators use. Building the links of n clients of one inbound, as the export of all links does, cost n full parses of an n-client document. Typed per-request caches next to settingsByInbound now hold the decoded TUIC instance, indexed by email, and the AmneziaWG server block, and every generator reads through them. --- internal/sub/clash_service.go | 42 +++----- internal/sub/host_sub_test.go | 153 ++++++++++++++++++++++++++++++ internal/sub/json_service.go | 16 ++-- internal/sub/json_service_test.go | 4 +- internal/sub/service.go | 79 ++++++++++++--- 5 files changed, 244 insertions(+), 50 deletions(-) diff --git a/internal/sub/clash_service.go b/internal/sub/clash_service.go index 1463e3eae..123ecb92f 100644 --- a/internal/sub/clash_service.go +++ b/internal/sub/clash_service.go @@ -15,7 +15,6 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/amneziawg" "github.com/mhsanaei/3x-ui/v3/internal/database/model" - "github.com/mhsanaei/3x-ui/v3/internal/tuic" wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard" ) @@ -670,8 +669,7 @@ func (s *SubClashService) buildWireguardProxy(subReq *SubService, inbound *model return nil } - var inboundSettings map[string]any - _ = json.Unmarshal([]byte(inbound.Settings), &inboundSettings) + inboundSettings := subReq.linkSettings(inbound) secretKey, _ := inboundSettings["secretKey"].(string) proxy := map[string]any{ @@ -723,21 +721,18 @@ func (s *SubClashService) buildWireguardProxy(subReq *SubService, inbound *model } func (s *SubClashService) buildTuicProxy(subReq *SubService, inbound *model.Inbound, client model.Client, ep map[string]any) map[string]any { - inst, ok := tuic.InstanceFromInbound(inbound) - if !ok { + inst := subReq.tuicInstanceForLink(inbound) + if inst == nil { return nil } uuid := client.ID password := client.Password - for _, c := range inst.Clients { - if c.Email == client.Email { - if uuid == "" { - uuid = c.UUID - } - if password == "" { - password = c.Password - } - break + if c := inst.byEmail[client.Email]; c != nil { + if uuid == "" { + uuid = c.UUID + } + if password == "" { + password = c.Password } } if uuid == "" || password == "" { @@ -779,15 +774,9 @@ func (s *SubClashService) buildTuicProxy(subReq *SubService, inbound *model.Inbo // amneziaWGClientAddresses prefers this inbound's own settings entry over the // shared clients.wg_allowed_ips column, which for an identity attached to both // a wireguard and an amneziawg inbound holds the other one's address. -func amneziaWGClientAddresses(settingsClients []model.Client, client model.Client) []string { - for i := range settingsClients { - if !strings.EqualFold(settingsClients[i].Email, client.Email) { - continue - } - if len(settingsClients[i].AllowedIPs) > 0 { - return settingsClients[i].AllowedIPs - } - break +func amneziaWGClientAddresses(subReq *SubService, inbound *model.Inbound, client model.Client) []string { + if own, ok := subReq.clientForLink(inbound, client.Email); ok && len(own.AllowedIPs) > 0 { + return own.AllowedIPs } return client.AllowedIPs } @@ -811,11 +800,10 @@ func (s *SubClashService) buildAmneziaWGProxy(subReq *SubService, inbound *model return nil } - var parsed amneziawg.InboundSettings - if err := json.Unmarshal([]byte(inbound.Settings), &parsed); err != nil || parsed.Server == nil { + server := subReq.amneziaWGServerForLink(inbound) + if server == nil { return nil } - server := parsed.Server proxy := map[string]any{ "name": subReq.endpointRemark(inbound, client.Email, ep, ""), @@ -836,7 +824,7 @@ func (s *SubClashService) buildAmneziaWGProxy(subReq *SubService, inbound *model proxy["persistent-keepalive"] = ka } - for _, addr := range amneziaWGClientAddresses(parsed.Clients, client) { + for _, addr := range amneziaWGClientAddresses(subReq, inbound, client) { ip := stripCIDR(addr) if ip == "" { continue diff --git a/internal/sub/host_sub_test.go b/internal/sub/host_sub_test.go index e01ff30ea..5bf9cbb24 100644 --- a/internal/sub/host_sub_test.go +++ b/internal/sub/host_sub_test.go @@ -6,6 +6,7 @@ import ( "net" "net/url" "path/filepath" + "runtime" "strings" "testing" @@ -2077,3 +2078,155 @@ func TestSub_LegacyFallbackMasterLookedUpOncePerRequest(t *testing.T) { t.Fatalf("child-1 must carry its front's TLS state: %s", links[0]) } } + +// seedSharedSubIdInbound stores one inbound whose n clients all carry subId, the +// shape a node merge produces (the admin API keeps a subId unique per client). +func seedSharedSubIdInbound(t *testing.T, protocol model.Protocol, subId string, n int, serverJSON string, clientJSON func(i int) string, record func(i int) model.ClientRecord) *model.Inbound { + t.Helper() + entries := make([]string, n) + records := make([]model.ClientRecord, n) + for i := range entries { + entries[i] = clientJSON(i) + records[i] = record(i) + records[i].SubID = subId + records[i].Enable = true + } + db := database.GetDB() + ib := &model.Inbound{ + UserId: 1, Tag: string(protocol), Enable: true, Listen: "203.0.113.5", Port: 443, Protocol: protocol, + Remark: string(protocol), + Settings: fmt.Sprintf(`{%s,"clients":[%s]}`, serverJSON, strings.Join(entries, ",")), + } + if err := db.Create(ib).Error; err != nil { + t.Fatalf("create %s inbound: %v", protocol, err) + } + if err := db.CreateInBatches(&records, 100).Error; err != nil { + t.Fatalf("create clients: %v", err) + } + links := make([]model.ClientInbound, n) + for i := range records { + links[i] = model.ClientInbound{ClientId: records[i].Id, InboundId: ib.Id} + } + if err := db.CreateInBatches(&links, 500).Error; err != nil { + t.Fatalf("link clients: %v", err) + } + return ib +} + +// A subscription of n clients of one inbound must decode that inbound's settings +// once, not once per link: with the clients array inside, n decodes grow with n². +func TestSubscriptionDecodesInboundSettingsOncePerRequest(t *testing.T) { + const n = 1000 + const subId = "shared-sub-id" + const maxAllocated = 128 << 20 + serverPriv, serverPub := mustWireguardKeypair(t) + clientPriv, _ := mustWireguardKeypair(t) + + email := func(i int) string { return fmt.Sprintf("user-%04d@sub", i) } + uuid := func(i int) string { return fmt.Sprintf("11111111-2222-4333-8444-%012d", i) } + seed := map[model.Protocol]func(t *testing.T) *model.Inbound{ + model.TUIC: func(t *testing.T) *model.Inbound { + return seedSharedSubIdInbound(t, model.TUIC, subId, n, `"server":{"certificate":"/c","private_key":"/k"}`, + func(i int) string { + return fmt.Sprintf(`{"uuid":%q,"password":"pw","email":%q,"enable":true}`, uuid(i), email(i)) + }, + func(i int) model.ClientRecord { + return model.ClientRecord{Email: email(i), UUID: uuid(i), Password: "pw"} + }) + }, + model.AmneziaWG: func(t *testing.T) *model.Inbound { + return seedSharedSubIdInbound(t, model.AmneziaWG, subId, n, fmt.Sprintf(`"server":{"privateKey":%q,"publicKey":%q,"mtu":1420}`, serverPriv, serverPub), + func(i int) string { + return fmt.Sprintf(`{"email":%q,"privateKey":%q,"allowedIPs":["10.8.%d.%d/32"],"enable":true}`, email(i), clientPriv, i/256, i%256) + }, + func(i int) model.ClientRecord { + return model.ClientRecord{Email: email(i), PrivateKey: clientPriv, AllowedIPs: fmt.Sprintf("10.8.%d.%d/32", i/256, i%256)} + }) + }, + model.WireGuard: func(t *testing.T) *model.Inbound { + return seedSharedSubIdInbound(t, model.WireGuard, subId, n, fmt.Sprintf(`"secretKey":%q,"mtu":1420`, serverPriv), + func(i int) string { + return fmt.Sprintf(`{"email":%q,"privateKey":%q,"allowedIPs":["10.8.%d.%d/32"],"enable":true}`, email(i), clientPriv, i/256, i%256) + }, + func(i int) model.ClientRecord { + return model.ClientRecord{Email: email(i), PrivateKey: clientPriv, AllowedIPs: fmt.Sprintf("10.8.%d.%d/32", i/256, i%256)} + }) + }, + model.Hysteria: func(t *testing.T) *model.Inbound { + return seedSharedSubIdInbound(t, model.Hysteria, subId, n, `"version":2`, + func(i int) string { + return fmt.Sprintf(`{"email":%q,"auth":"auth","enable":true}`, email(i)) + }, + func(i int) model.ClientRecord { return model.ClientRecord{Email: email(i), Auth: "auth"} }) + }, + } + + raw := func(t *testing.T) int { + links, _, _, _, err := NewSubService("").GetSubs(subId, "sub.example.com") + if err != nil { + t.Fatalf("GetSubs: %v", err) + } + for _, link := range links { + if link == "" { + t.Fatal("GetSubs returned an empty link") + } + } + return len(links) + } + clash := func(t *testing.T) int { + out, _, err := NewSubClashService(false, "", false, NewSubService("")).GetClash(subId, "sub.example.com") + if err != nil { + t.Fatalf("GetClash: %v", err) + } + var doc struct { + Proxies []map[string]any `yaml:"proxies"` + } + if err := yaml.Unmarshal([]byte(out), &doc); err != nil { + t.Fatalf("parse clash yaml: %v", err) + } + return len(doc.Proxies) + } + jsonSub := func(t *testing.T) int { + out, _, err := NewSubJsonService("", "", "", "", NewSubService("")).GetJson(subId, "sub.example.com", true) + if err != nil { + t.Fatalf("GetJson: %v", err) + } + var configs []json.RawMessage + if err := json.Unmarshal([]byte(out), &configs); err != nil { + t.Fatalf("parse json subscription: %v", err) + } + return len(configs) + } + + cases := []struct { + name string + protocol model.Protocol + fetch func(t *testing.T) int + }{ + {"raw tuic", model.TUIC, raw}, + {"raw amneziawg", model.AmneziaWG, raw}, + {"clash tuic", model.TUIC, clash}, + {"clash amneziawg", model.AmneziaWG, clash}, + {"clash wireguard", model.WireGuard, clash}, + {"json wireguard", model.WireGuard, jsonSub}, + {"json hysteria", model.Hysteria, jsonSub}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + initSubDB(t) + seed[tc.protocol](t) + + var before, after runtime.MemStats + runtime.ReadMemStats(&before) + got := tc.fetch(t) + runtime.ReadMemStats(&after) + + if got != n { + t.Fatalf("entries = %d, want %d", got, n) + } + if allocated := after.TotalAlloc - before.TotalAlloc; allocated > maxAllocated { + t.Fatalf("%d entries allocated %d MiB, want at most %d MiB", n, allocated>>20, maxAllocated>>20) + } + }) + } +} diff --git a/internal/sub/json_service.go b/internal/sub/json_service.go index 3d75cdfdb..c608b928d 100644 --- a/internal/sub/json_service.go +++ b/internal/sub/json_service.go @@ -731,11 +731,11 @@ func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, c case "trojan", "shadowsocks": newOutbounds = append(newOutbounds, s.genServer(subReq, inbound, streamSettings, client, jsonMux(mux, hostMux))) case "hysteria": - newOutbounds = append(newOutbounds, s.genHy(inbound, newStream, client, jsonMux(mux, hostMux))) + newOutbounds = append(newOutbounds, s.genHy(subReq, inbound, newStream, client, jsonMux(mux, hostMux))) case "masque": newOutbounds = append(newOutbounds, s.genMasque(inbound, newStream, client)) case "wireguard": - wgOutbound := s.genWireguard(inbound, client) + wgOutbound := s.genWireguard(subReq, inbound, client) if wgOutbound == nil { continue } @@ -1037,7 +1037,7 @@ func (s *SubJsonService) genServer(subReq *SubService, inbound *model.Inbound, s return result } -func (s *SubJsonService) genHy(inbound *model.Inbound, newStream map[string]any, client model.Client, mux string) json_util.RawMessage { +func (s *SubJsonService) genHy(subReq *SubService, inbound *model.Inbound, newStream map[string]any, client model.Client, mux string) json_util.RawMessage { outbound := Outbound{ Protocol: string(inbound.Protocol), Tag: "proxy", @@ -1047,9 +1047,8 @@ func (s *SubJsonService) genHy(inbound *model.Inbound, newStream map[string]any, outbound.Mux = json_util.RawMessage(mux) } - var settings, stream map[string]any - _ = json.Unmarshal([]byte(inbound.Settings), &settings) - version, _ := settings["version"].(float64) + var stream map[string]any + version, _ := subReq.linkSettings(inbound)["version"].(float64) outbound.Settings = map[string]any{ "version": int(version), "address": inbound.Listen, @@ -1115,13 +1114,12 @@ func (s *SubJsonService) genMasque(inbound *model.Inbound, newStream map[string] // mirroring genWireguardLink: the peer public key is derived from the inbound // secretKey, the client owns the private key / tunnel address / pre-shared key, // and the peer routes the full tunnel. Returns nil when the client has no key. -func (s *SubJsonService) genWireguard(inbound *model.Inbound, client model.Client) json_util.RawMessage { +func (s *SubJsonService) genWireguard(subReq *SubService, inbound *model.Inbound, client model.Client) json_util.RawMessage { if client.PrivateKey == "" { return nil } - var inboundSettings map[string]any - _ = json.Unmarshal([]byte(inbound.Settings), &inboundSettings) + inboundSettings := subReq.linkSettings(inbound) secretKey, _ := inboundSettings["secretKey"].(string) peer := map[string]any{ diff --git a/internal/sub/json_service_test.go b/internal/sub/json_service_test.go index dac398a25..4a03af723 100644 --- a/internal/sub/json_service_test.go +++ b/internal/sub/json_service_test.go @@ -520,7 +520,7 @@ func TestSubJsonServiceWireguard(t *testing.T) { AllowedIPs: []string{"10.0.0.2/32", "fd00::2/128"}, } - raw := NewSubJsonService("", "", "", "", nil).genWireguard(inbound, client) + raw := NewSubJsonService("", "", "", "", nil).genWireguard(&SubService{}, inbound, client) if raw == nil { t.Fatal("genWireguard returned nil for a valid wireguard client") } @@ -564,7 +564,7 @@ func TestSubJsonServiceWireguardNoKey(t *testing.T) { inbound := &model.Inbound{Listen: "203.0.113.9", Port: 51820, Protocol: model.WireGuard, Settings: `{}`} client := model.Client{Email: "user"} - if raw := NewSubJsonService("", "", "", "", nil).genWireguard(inbound, client); raw != nil { + if raw := NewSubJsonService("", "", "", "", nil).genWireguard(&SubService{}, inbound, client); raw != nil { t.Fatalf("genWireguard = %s, want nil for a keyless wireguard client", raw) } } diff --git a/internal/sub/service.go b/internal/sub/service.go index 0f6ea464d..23523b926 100644 --- a/internal/sub/service.go +++ b/internal/sub/service.go @@ -91,6 +91,17 @@ type SubService struct { hostGroupOrders map[string]hostEntryOrder hostsFirst bool sharedGroupSni map[string]string + // tuicByInbound and amneziaWGServerByInbound are the typed equivalents of + // settingsByInbound: one decode per request, not one per link of a subId. + tuicByInbound map[int]*tuicLinkInstance + amneziaWGServerByInbound map[int]*amneziawg.ServerSettings +} + +// tuicLinkInstance indexes a decoded TUIC inbound's clients by email +// (first occurrence wins, matching the settings-JSON scan it replaces). +type tuicLinkInstance struct { + tuic.Instance + byEmail map[string]*tuic.TuicClientSettings } // NewSubService creates a new subscription service with the given configuration. @@ -134,6 +145,8 @@ func (s *SubService) PrepareForRequest(host string) { s.fullyPrimedInbounds = map[int]bool{} s.settingsByInbound = map[int]map[string]any{} s.socketFronts = nil + s.tuicByInbound = map[int]*tuicLinkInstance{} + s.amneziaWGServerByInbound = map[int]*amneziawg.ServerSettings{} s.hostsByInbound = nil s.hostGroupOrders = nil s.sharedGroupSni = nil @@ -257,6 +270,55 @@ func (s *SubService) linkSettings(inbound *model.Inbound) map[string]any { return out } +// tuicInstanceForLink returns the inbound's tuic.Instance decoded once per +// request, or nil when the settings do not decode. +func (s *SubService) tuicInstanceForLink(inbound *model.Inbound) *tuicLinkInstance { + if inbound.Id > 0 { + if cached, ok := s.tuicByInbound[inbound.Id]; ok { + return cached + } + } + var out *tuicLinkInstance + if inst, ok := tuic.InstanceFromInbound(inbound); ok { + out = &tuicLinkInstance{Instance: inst, byEmail: make(map[string]*tuic.TuicClientSettings, len(inst.Clients))} + for i := range out.Clients { + if _, exists := out.byEmail[out.Clients[i].Email]; !exists { + out.byEmail[out.Clients[i].Email] = &out.Clients[i] + } + } + } + if inbound.Id > 0 { + if s.tuicByInbound == nil { + s.tuicByInbound = map[int]*tuicLinkInstance{} + } + s.tuicByInbound[inbound.Id] = out + } + return out +} + +// amneziaWGServerForLink returns the inbound's AmneziaWG server block decoded once +// per request without its clients (those resolve via clientForLink), or nil. +func (s *SubService) amneziaWGServerForLink(inbound *model.Inbound) *amneziawg.ServerSettings { + if inbound.Id > 0 { + if cached, ok := s.amneziaWGServerByInbound[inbound.Id]; ok { + return cached + } + } + var parsed struct { + Server *amneziawg.ServerSettings `json:"server"` + } + if err := json.Unmarshal([]byte(inbound.Settings), &parsed); err != nil { + parsed.Server = nil + } + if inbound.Id > 0 { + if s.amneziaWGServerByInbound == nil { + s.amneziaWGServerByInbound = map[int]*amneziawg.ServerSettings{} + } + s.amneziaWGServerByInbound[inbound.Id] = parsed.Server + } + return parsed.Server +} + // loadRemarkSettings populates the per-request remark formatting state so // every subscription format — raw, JSON, Clash — renders remarks the same way // (the date formatter reads datepicker). Loading it only in getSubs left @@ -965,17 +1027,11 @@ func (s *SubService) genTuicLink(inbound *model.Inbound, email string) string { if inbound.Protocol != model.TUIC { return "" } - inst, ok := tuic.InstanceFromInbound(inbound) - if !ok { + inst := s.tuicInstanceForLink(inbound) + if inst == nil { return "" } - var client *tuic.TuicClientSettings - for _, c := range inst.Clients { - if c.Email == email { - client = &c - break - } - } + client := inst.byEmail[email] if client == nil && len(inst.Clients) > 0 && email == "" { client = &inst.Clients[0] } @@ -1197,11 +1253,10 @@ func (s *SubService) genAmneziaWGLink(inbound *model.Inbound, email string) stri if inbound.Protocol != model.AmneziaWG { return "" } - var parsed amneziawg.InboundSettings - if err := json.Unmarshal([]byte(inbound.Settings), &parsed); err != nil || parsed.Server == nil { + server := s.amneziaWGServerForLink(inbound) + if server == nil { return "" } - server := parsed.Server resolved, ok := s.clientForLink(inbound, email) if !ok || resolved.PrivateKey == "" { From efaa73db3a5417844fbfe1d5997d0a5c0222a15b Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 09:14:43 +0100 Subject: [PATCH 088/125] perf(tgbot): stop loading the whole inbound per client in usage listings Listing a Telegram account's clients ran CheckIsEnabledByEmail and describeAttachedInbounds for every client, and both loaded and parsed the client's entire inbound, so one message cost the number of clients times the size of their inbound's settings. CheckIsEnabledByEmail now reads the client's own record first, keeping the inbound parse only for legacy clients never normalized into the clients table, and describeAttachedInbounds takes every remark in one query without loading any settings. --- internal/web/service/client_inbound_apply.go | 6 ++ internal/web/service/inbound.go | 17 +++++ internal/web/service/tgbot/tgbot_client.go | 8 +-- .../service/tgbot/tgbot_review_fixes_test.go | 62 +++++++++++++++++++ 4 files changed, 87 insertions(+), 6 deletions(-) diff --git a/internal/web/service/client_inbound_apply.go b/internal/web/service/client_inbound_apply.go index f1142d87f..67b6faebe 100644 --- a/internal/web/service/client_inbound_apply.go +++ b/internal/web/service/client_inbound_apply.go @@ -1351,6 +1351,12 @@ func (s *ClientService) SetClientTelegramUserID(inboundSvc *InboundService, traf } func (s *ClientService) CheckIsEnabledByEmail(inboundSvc *InboundService, clientEmail string) (bool, error) { + // The client's own row answers without loading and parsing its whole + // inbound; legacy clients never normalized into `clients` fall through. + if rec, rErr := s.GetRecordByEmail(nil, clientEmail); rErr == nil && rec != nil { + return rec.Enable, nil + } + _, inbound, err := inboundSvc.GetClientInboundByEmail(clientEmail) if err != nil { return false, err diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go index 9a6d4e1ab..a0296cb8f 100644 --- a/internal/web/service/inbound.go +++ b/internal/web/service/inbound.go @@ -1802,6 +1802,23 @@ func (s *InboundService) GetInbound(id int) (*model.Inbound, error) { return inbound, nil } +// GetInboundRemarks returns the remarks of the given inbounds keyed by id +// without loading their settings; ids that do not exist are absent. +func (s *InboundService) GetInboundRemarks(ids []int) (map[int]string, error) { + db := database.GetDB() + remarks := make(map[int]string, len(ids)) + for _, batch := range chunkInts(ids, sqlInChunk) { + var rows []model.Inbound + if err := db.Select("id", "remark").Where("id IN ?", batch).Find(&rows).Error; err != nil { + return nil, err + } + for i := range rows { + remarks[rows[i].Id] = rows[i].Remark + } + } + return remarks, nil +} + func (s *InboundService) GetInboundDetail(id int) (*model.Inbound, error) { db := database.GetDB() inbound := &model.Inbound{} diff --git a/internal/web/service/tgbot/tgbot_client.go b/internal/web/service/tgbot/tgbot_client.go index e11e2ce77..fb8f367ea 100644 --- a/internal/web/service/tgbot/tgbot_client.go +++ b/internal/web/service/tgbot/tgbot_client.go @@ -86,14 +86,10 @@ func (t *Tgbot) describeAttachedInbounds(ids []int) string { if len(ids) == 0 { return "" } + remarks, _ := t.inboundService.GetInboundRemarks(ids) parts := make([]string, 0, len(ids)) for _, id := range ids { - ib, err := t.inboundService.GetInbound(id) - if err != nil || ib == nil { - parts = append(parts, fmt.Sprintf("#%d", id)) - continue - } - label := ib.Remark + label := remarks[id] if label == "" { label = fmt.Sprintf("#%d", id) } diff --git a/internal/web/service/tgbot/tgbot_review_fixes_test.go b/internal/web/service/tgbot/tgbot_review_fixes_test.go index f3c1c1ef3..75dfe3fce 100644 --- a/internal/web/service/tgbot/tgbot_review_fixes_test.go +++ b/internal/web/service/tgbot/tgbot_review_fixes_test.go @@ -2,12 +2,20 @@ package tgbot import ( "encoding/json" + "path/filepath" + "strconv" "strings" "testing" + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/dbtest" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/eventbus" + "github.com/mhsanaei/3x-ui/v3/internal/web/service" + "github.com/mhsanaei/3x-ui/v3/internal/xray" "github.com/mymmrac/telego" + "gorm.io/gorm" ) // A login success must not fold into the failure card of the same IP: the @@ -272,3 +280,57 @@ func TestUsageEmailArgumentDoesNotLeakAnotherClient(t *testing.T) { t.Fatalf("a client's /usage leaked another client:\n%s\n%s", body, markup) } } + +// Listing an account's clients used to load their whole inbound, settings and +// all, several times per client, so the list cost its length times the inbound's. +func TestClientUsageDoesNotLoadTheInboundPerClient(t *testing.T) { + sent := recordingBotServer(t) + dbtest.InitDB(t, filepath.Join(t.TempDir(), "x-ui.db")) + db := database.GetDB() + + const owned = 40 + clients := make([]model.Client, 0, owned) + for i := range owned { + n := strconv.Itoa(i) + clients = append(clients, model.Client{ID: "uuid-" + n, Email: "owned-" + n, SubID: "sub-" + n, TgID: ownerTgID, Enable: true}) + } + settings, err := json.Marshal(map[string]any{"clients": clients}) + if err != nil { + t.Fatalf("marshal settings: %v", err) + } + inbound := &model.Inbound{UserId: 1, Tag: "in-443", Remark: "shared-inbound", Enable: true, Port: 443, Protocol: model.VLESS, Settings: string(settings)} + if err := db.Create(inbound).Error; err != nil { + t.Fatalf("seed inbound: %v", err) + } + if err := (&service.ClientService{}).SyncInbound(nil, inbound.Id, clients); err != nil { + t.Fatalf("SyncInbound: %v", err) + } + for i := range clients { + if err := db.Create(&xray.ClientTraffic{InboundId: inbound.Id, Email: clients[i].Email, Enable: true}).Error; err != nil { + t.Fatalf("seed traffic: %v", err) + } + } + + origRunning := isRunning + t.Cleanup(func() { isRunning = origRunning }) + isRunning = true + + wholeRows := 0 + if err := db.Callback().Query().After("gorm:query").Register("test:whole_inbound_rows", func(tx *gorm.DB) { + if tx.Statement.Table == "inbounds" && len(tx.Statement.Selects) == 0 { + wholeRows++ + } + }); err != nil { + t.Fatalf("register query counter: %v", err) + } + t.Cleanup(func() { _ = db.Callback().Query().Remove("test:whole_inbound_rows") }) + + (&Tgbot{}).getClientUsage(ownerTgID, ownerTgID) + + if got := strings.Join(sent(), "\n"); !strings.Contains(got, "shared-inbound") { + t.Fatalf("no client listed with its inbound: %q", got) + } + if wholeRows >= owned { + t.Fatalf("the inbound row was loaded %d times for %d clients", wholeRows, owned) + } +} From ecd817658cba3dc35cb31c70816d92f96d93bb87 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 10:02:11 +0100 Subject: [PATCH 089/125] fix(amneziawg): attribute an equal-prefix source to the peer the device picks PeerIndex names the peer a source address came from, but for two peers holding the same prefix, which only a default route can be after the overlap check, it kept the first while amneziawg-go's allowed-IPs table hands the prefix to the peer configured last. Traffic was then billed to a different client than the one the device accepted it from. An equal-length match now goes to the later peer, as in the device. --- internal/amneziawgnet/identity.go | 4 +- internal/amneziawgnet/identity_test.go | 72 ++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 internal/amneziawgnet/identity_test.go diff --git a/internal/amneziawgnet/identity.go b/internal/amneziawgnet/identity.go index 087774f00..62e21369d 100644 --- a/internal/amneziawgnet/identity.go +++ b/internal/amneziawgnet/identity.go @@ -49,7 +49,9 @@ func (idx *PeerIndex) Lookup(addr netip.Addr) (amneziawg.Peer, bool) { bestBits := -1 var bestPeer amneziawg.Peer for _, e := range idx.entries { - if e.prefix.Bits() <= bestBits || !e.prefix.Contains(addr) { + // Equal length: the later peer wins, as in the device's allowed-IPs + // table, which buildUAPIConfig fills from the same peers in this order. + if e.prefix.Bits() < bestBits || !e.prefix.Contains(addr) { continue } bestBits = e.prefix.Bits() diff --git a/internal/amneziawgnet/identity_test.go b/internal/amneziawgnet/identity_test.go new file mode 100644 index 000000000..4a9fdb460 --- /dev/null +++ b/internal/amneziawgnet/identity_test.go @@ -0,0 +1,72 @@ +package amneziawgnet + +import ( + "net/netip" + "slices" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/amneziawg" + "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard" +) + +// TestPeerIndexLookupMatchesDeviceAllowedIPs catches Lookup attributing a +// prefix two peers both carry to a peer the device accepts no such source from. +func TestPeerIndexLookupMatchesDeviceAllowedIPs(t *testing.T) { + serverPriv, serverPub, err := wireguard.GenerateWireguardKeypair() + if err != nil { + t.Fatalf("generate server keypair: %v", err) + } + var peers []amneziawg.Peer + for _, p := range []amneziawg.Peer{ + {Email: "first@example.com", AllowedIPs: []string{"10.202.0.2/32", "fd00:202::2/128", "0.0.0.0/0", "::/0"}}, + {Email: "last@example.com", AllowedIPs: []string{"10.202.0.3/32", "fd00:202::3/128", "0.0.0.0/0", "::/0"}}, + } { + if _, p.PublicKey, err = wireguard.GenerateWireguardKeypair(); err != nil { + t.Fatalf("generate peer keypair: %v", err) + } + peers = append(peers, p) + } + + dev, err := NewDevice(amneziawg.Instance{ + Id: 1, + PrivateKey: serverPriv, + PublicKey: serverPub, + Address: []string{"10.202.0.1/24"}, + MTU: 1420, + Peers: peers, + }, DeviceOptions{}) + if err != nil { + t.Fatalf("NewDevice: %v", err) + } + defer dev.Close() + + dump, err := dev.IpcGet() + if err != nil { + t.Fatalf("IpcGet: %v", err) + } + _, states := parseUAPIDump(dump) + + idx := NewPeerIndex(peers) + for _, tc := range []struct{ addr, prefix string }{ + {"10.99.0.1", "0.0.0.0/0"}, + {"fd00:99::1", "::/0"}, + {"10.202.0.2", "10.202.0.2/32"}, + {"10.202.0.3", "10.202.0.3/32"}, + {"fd00:202::2", "fd00:202::2/128"}, + {"fd00:202::3", "fd00:202::3/128"}, + } { + t.Run(tc.addr, func(t *testing.T) { + got, ok := idx.Lookup(netip.MustParseAddr(tc.addr)) + if !ok { + t.Fatalf("Lookup(%s) found no peer", tc.addr) + } + gotHex, err := wireguard.KeyToHex(got.PublicKey) + if err != nil { + t.Fatalf("KeyToHex: %v", err) + } + if !slices.Contains(states[gotHex].allowedIPs, tc.prefix) { + t.Errorf("Lookup(%s) = %s, but the device holds %s for another peer", tc.addr, got.Email, tc.prefix) + } + }) + } +} From 082d7ac091a22cd8df99235a5232ccf7a3a76698 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 10:03:17 +0100 Subject: [PATCH 090/125] fix(xray): block private-range egress in per-peer AmneziaWG IPv6 outbounds The per-peer IPv6 freedom outbounds the panel injects for AmneziaWG clients were built with empty settings and their routing rule sits ahead of the template's private-range block, so a peer's IPv6 traffic skipped both layers and could reach fc00::/7 hosts on the panel's network. Each per-peer outbound now carries the same private-range finalRules block every other freedom outbound gets. --- internal/web/service/xray.go | 13 +++++++- .../web/service/xray_config_inject_test.go | 32 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/internal/web/service/xray.go b/internal/web/service/xray.go index 462a880a0..7712e06c3 100644 --- a/internal/web/service/xray.go +++ b/internal/web/service/xray.go @@ -962,6 +962,17 @@ func amneziawgV6EgressTag(inboundID int, email string) string { return fmt.Sprintf("amneziawg-v6-%d-%s", inboundID, email) } +// amneziawgV6EgressSettings carries the template's private-range egress block: +// the per-peer rule is matched ahead of the routing-level geoip:private rule. +func amneziawgV6EgressSettings() map[string]any { + return map[string]any{ + "finalRules": []any{ + map[string]any{"action": "block", "ip": []any{"geoip:private"}}, + map[string]any{"action": "allow"}, + }, + } +} + // injectAmneziawgV6Egress gives every enabled, non-node-hosted AmneziaWG // peer with an IPv6 AllowedIPs entry its own single-purpose freedom // outbound, bound via sendThrough to that exact address, plus a routing @@ -1061,7 +1072,7 @@ func injectAmneziawgV6Egress(cfg *xray.Config, inbounds []*model.Inbound) { "tag": tag, "protocol": "freedom", "sendThrough": v6, - "settings": map[string]any{}, + "settings": amneziawgV6EgressSettings(), }) newRules = append(newRules, map[string]any{ "type": "field", diff --git a/internal/web/service/xray_config_inject_test.go b/internal/web/service/xray_config_inject_test.go index 71f4dd760..f5433d101 100644 --- a/internal/web/service/xray_config_inject_test.go +++ b/internal/web/service/xray_config_inject_test.go @@ -1053,3 +1053,35 @@ func TestInjectAmneziawgV6Egress_RulesPrependedBeforeExistingRules(t *testing.T) t.Fatalf("the pre-existing rule must survive, got %+v", routing.Rules[1]) } } + +// The per-peer rule precedes the template's geoip:private routing rule, so the +// outbound itself must block private-range egress, as "direct" does. +func TestInjectAmneziawgV6Egress_OutboundBlocksPrivateEgress(t *testing.T) { + cfg := egressTestConfig() + inbound := amneziawgV6Inbound(1, "awg-1", "eth0", []model.Client{ + {Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"fd86:ea04:1115::2/128"}}, + }) + injectAmneziawgSocksThenV6(cfg, []*model.Inbound{inbound}) + + var outbounds []struct { + Tag string `json:"tag"` + Settings struct { + FinalRules []any `json:"finalRules"` + } `json:"settings"` + } + if err := json.Unmarshal(cfg.OutboundConfigs, &outbounds); err != nil { + t.Fatal(err) + } + want := `[{"action":"block","ip":["geoip:private"]},{"action":"allow"}]` + for _, o := range outbounds { + if o.Tag != amneziawgV6EgressTag(1, "a@x") { + continue + } + got, _ := json.Marshal(o.Settings.FinalRules) + if string(got) != want { + t.Fatalf("per-peer outbound finalRules = %s, want %s", got, want) + } + return + } + t.Fatalf("no outbound tagged %q in %s", amneziawgV6EgressTag(1, "a@x"), cfg.OutboundConfigs) +} From 5c6cbb5680b8f393e6fe9aee7e7026cf0963dd64 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 10:04:45 +0100 Subject: [PATCH 091/125] chore(deps): update follow-redirects to 1.16.2 A high-severity advisory published against follow-redirects 1.16.0 to 1.16.1, pulled in through swagger-ui-react's client, fails the frontend's production audit. npm audit fix moves it to 1.16.2, along with the swagger-ui-react 5.34.1 patch release that allows it. --- frontend/package-lock.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 014fd6f90..a30cf5b01 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -7575,9 +7575,9 @@ "license": "ISC" }, "node_modules/follow-redirects": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz", - "integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.2.tgz", + "integrity": "sha512-/kVP2U8z596Ia0gwFrwyIeo74ta5aVmSeU1FfAUMAmcEP5EP7zydmag3SLiN64NaRKef3lxsq7i/g7ltJQDGow==", "funding": [ { "type": "individual", @@ -10459,9 +10459,9 @@ } }, "node_modules/swagger-ui-react": { - "version": "5.34.0", - "resolved": "https://registry.npmjs.org/swagger-ui-react/-/swagger-ui-react-5.34.0.tgz", - "integrity": "sha512-pwWmbBfDoHHVS/Fx4L4KpgqZQPCx5leWk9Wi98tHw1eYmTrxcFmyOUXvjaj3gOO55Stjtw6fqlL9W6WT28YJfA==", + "version": "5.34.1", + "resolved": "https://registry.npmjs.org/swagger-ui-react/-/swagger-ui-react-5.34.1.tgz", + "integrity": "sha512-22/QWHoSwlLHGyB2BMHEzLoFU80Zp1286BZmTSNPWnI9SomWlrtmO+W4p5X9Rt0jH2tl4D+VU8aYsg+ufhMpIQ==", "license": "Apache-2.0", "dependencies": { "@babel/runtime-corejs3": "^7.27.1", @@ -10472,7 +10472,7 @@ "classnames": "^2.5.1", "css.escape": "1.5.1", "deep-extend": "0.6.0", - "dompurify": "^3.4.16", + "dompurify": "^3.4.17", "ieee754": "^1.2.1", "immutable": "^5.1.9", "js-file-download": "^0.4.12", From 9c0bf6938c742e05a0bf3ff39f8bc705cbd2c681 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 10:10:01 +0100 Subject: [PATCH 092/125] test(sub): leave the decode-once allocation bound room for the race detector TestSubscriptionDecodesInboundSettingsOncePerRequest capped a request at 128 MiB, which the race job exceeds on correct code (up to 170 MiB) because the detector inflates allocations. One decode per link costs 489 to 750 MiB without it, so a 256 MiB bound still tells the two apart. --- internal/sub/host_sub_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/sub/host_sub_test.go b/internal/sub/host_sub_test.go index 5bf9cbb24..3f8b75a4d 100644 --- a/internal/sub/host_sub_test.go +++ b/internal/sub/host_sub_test.go @@ -2118,7 +2118,8 @@ func seedSharedSubIdInbound(t *testing.T, protocol model.Protocol, subId string, func TestSubscriptionDecodesInboundSettingsOncePerRequest(t *testing.T) { const n = 1000 const subId = "shared-sub-id" - const maxAllocated = 128 << 20 + // The race detector roughly doubles allocations; one decode per link costs several times this. + const maxAllocated = 256 << 20 serverPriv, serverPub := mustWireguardKeypair(t) clientPriv, _ := mustWireguardKeypair(t) From 0394996a3039407f3bfc6a1b0710aff141431f46 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 10:25:14 +0100 Subject: [PATCH 093/125] fix(node): keep summing usage across panels for a client the node shares by email MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client gate refused a node's entry for an email the master serves only on its own or another node's inbounds, and so also dropped the node's usage for it; on top of that, a refused or rewritten entry made the master mark the node dirty and push its own settings back, which deleted the node's local client of that email. A master → node → node chain whose panels each serve the same client therefore stopped adding the nodes' usage to the master's totals. Such an email is now usage-only: the node's usage still adds to the master's row, while its enable, expiry and record values do not. A claim rewrite no longer forces a re-push; the stored, filtered settings are what the reconcile fingerprint tracks. --- internal/web/service/inbound_node.go | 43 ++++++++----------- internal/web/service/inbound_node_claims.go | 26 +++++++++++ .../web/service/node_foreign_client_test.go | 30 ++++++------- 3 files changed, 59 insertions(+), 40 deletions(-) diff --git a/internal/web/service/inbound_node.go b/internal/web/service/inbound_node.go index ba846a053..378295410 100644 --- a/internal/web/service/inbound_node.go +++ b/internal/web/service/inbound_node.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "maps" "sort" "strings" "sync" @@ -676,8 +677,8 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi if err != nil { return false, err } - // The raw blob stays for the reconcile fingerprint, so a rewrite leaves a mismatch to - // re-push the master's values against. + // The raw blob stays for the usage-only lookup: those entries are refused from the stored + // settings, yet the node's usage under them still counts. rawSettings := make(map[*model.Inbound]string, len(snap.Inbounds)) for _, snapIb := range snap.Inbounds { if snapIb != nil { @@ -688,7 +689,6 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi structuralChange := false lifecycleLifted := false - claimsRewritten := false var sweptTags []string var adoptedInbounds []*model.Inbound @@ -696,7 +696,6 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi central *model.Inbound snapIb *model.Inbound wireSettings string - fingerprint string } var pendingAdopts []pendingAdopt degradedInbounds := map[int]string{} @@ -864,11 +863,6 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi if deduped, changed := dedupeSettingsClients(adoptedSettings); changed { adoptedSettings = deduped } - fingerprint := adoptedSettings - if raw := rawSettings[snapIb]; raw != snapIb.Settings { - fingerprint, _ = stripTombstonedClients(raw) - fingerprint, _ = dedupeSettingsClients(fingerprint) - } updates := map[string]any{} if !dirty && snapshotDropsEveryHubClient(tx, c.Id, adoptedSettings) { degradedInbounds[c.Id] = c.Tag @@ -876,7 +870,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi // Defer lifecycle lift until after client_traffics absorbs this tick's // deltas so quota stale-disable matches SQL (#6228). pendingAdopts = append(pendingAdopts, pendingAdopt{ - central: c, snapIb: snapIb, wireSettings: adoptedSettings, fingerprint: fingerprint, + central: c, snapIb: snapIb, wireSettings: adoptedSettings, }) updates["enable"] = snapIb.Enable updates["remark"] = snapIb.Remark @@ -1058,6 +1052,10 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi // Parsed once per inbound on the first renewal candidate, not per client. var snapExpiries map[string]int64 attachedOnNode, membershipKnown := snapshotAttachedEmails(snapIb.Settings) + usageOnly := claims.usageOnlyIn(rawSettings[snapIb]) + if membershipKnown { + maps.Copy(attachedOnNode, usageOnly) + } for _, cs := range snapIb.ClientStats { // A node detach keeps the stat row, so its quota and verdict are stale (#6724). if _, attached := attachedOnNode[strings.ToLower(cs.Email)]; membershipKnown && !attached { @@ -1070,7 +1068,8 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi // Until the node applies a reset it owes, its verdict rests on the // pre-reset counters: only usage may move for this client. _, owed := owedResets[cs.Email] - clientFrozen := lifecycleFrozen || owed + _, onlyUsage := usageOnly[strings.ToLower(cs.Email)] + clientFrozen := lifecycleFrozen || owed || onlyUsage base, seen := nodeBaselines[cs.Email] var deltaUp, deltaDown int64 @@ -1290,12 +1289,10 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi // so the adopted rows are served this tick, not after the TTL. InvalidateSubData() } - rewritten := p.fingerprint != p.wireSettings - claimsRewritten = claimsRewritten || rewritten - // The fingerprint stamps the node's own blob on purpose: a lift or a refused claim - // must leave reconcile a mismatch to re-push against. - if liftChanged || rewritten || adoptedWireChanged(p.central, p.snapIb, p.fingerprint) { - adoptedInbounds = append(adoptedInbounds, adoptedWireInbound(p.central, p.snapIb, p.fingerprint)) + // The fingerprint stamps the un-lifted wire blob on purpose: a lift must leave reconcile + // a mismatch to re-push against. A refused claim must not: the push would delete it on the node. + if liftChanged || adoptedWireChanged(p.central, p.snapIb, p.wireSettings) { + adoptedInbounds = append(adoptedInbounds, adoptedWireInbound(p.central, p.snapIb, p.wireSettings)) } } for _, snapIb := range snap.Inbounds { @@ -1454,18 +1451,14 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi } } - if (lifecycleLifted || claimsRewritten) && !dirty { - reason := "lifecycle lift" - if !lifecycleLifted { - reason = "client claim rewrite" - } + if lifecycleLifted && !dirty { var already model.Node if err := database.GetDB().Select("config_dirty").Where("id = ?", nodeID).First(&already).Error; err == nil && already.ConfigDirty { - logger.Debugf("setRemoteTraffic: node %d %s; already dirty", nodeID, reason) + logger.Debugf("setRemoteTraffic: node %d lifecycle lift; already dirty", nodeID) } else { - logger.Infof("setRemoteTraffic: node %d %s; marking dirty for re-push", nodeID, reason) + logger.Infof("setRemoteTraffic: node %d lifecycle lift; marking dirty for re-push", nodeID) if err := (&NodeService{}).MarkNodeDirty(nodeID); err != nil { - logger.Warningf("setRemoteTraffic: mark node %d dirty after %s failed: %v", nodeID, reason, err) + logger.Warningf("setRemoteTraffic: mark node %d dirty after lifecycle lift failed: %v", nodeID, err) } } } diff --git a/internal/web/service/inbound_node_claims.go b/internal/web/service/inbound_node_claims.go index 1f2b5736b..2ade4c32b 100644 --- a/internal/web/service/inbound_node_claims.go +++ b/internal/web/service/inbound_node_claims.go @@ -28,6 +28,9 @@ type nodeClientClaims struct { // storedSubID keeps an own record's subId when the node reports one held by a // client it does not host. storedSubID map[string]string + // usageOnly records live only outside this node but share the email: the node's usage + // still adds to them (cross-panel totals), its lifecycle and record values do not. + usageOnly map[string]struct{} } // nodeKeptSharedKeys stay as the node reported them: per-link values, and the lifecycle @@ -41,6 +44,7 @@ func loadNodeClientClaims(tx *gorm.DB, nodeID int, snap *runtime.TrafficSnapshot refused: map[string]string{}, shared: map[string]*model.ClientRecord{}, storedSubID: map[string]string{}, + usageOnly: map[string]struct{}{}, } subIDByEmail := map[string]string{} // A subId selects a whole subscription, so within one snapshot it may name one client only. @@ -154,6 +158,7 @@ func loadNodeClientClaims(tx *gorm.DB, nodeID int, snap *runtime.TrafficSnapshot claims.shared[email] = rec case scope.elsewhere: claims.refused[email] = "it is attached only to inbounds this node does not host" + claims.usageOnly[email] = struct{}{} case !scope.here && (rec.SyncOrphanedAt == 0 || rec.SyncOrphanedBy != nodeID): claims.refused[email] = "it is detached from every inbound on the master" case subID != rec.SubID && subID != "" && subIDTaken(email, subID): @@ -334,3 +339,24 @@ func logOversizeNodeInbounds(nodeID int, tags map[string]string) { } reportedOversizeInbounds.Store(nodeID, tags) } + +// usageOnlyIn lists, lowercased as the stats merge keys them, the usage-only emails a +// snapshot inbound's own settings carry. +func (c *nodeClientClaims) usageOnlyIn(settings string) map[string]struct{} { + out := map[string]struct{}{} + if len(c.usageOnly) == 0 { + return out + } + _, entries, ok := splitSettingsClients(settings) + if !ok { + return out + } + for _, entry := range entries { + if sc, ok := readSettingsClient(entry); ok { + if _, only := c.usageOnly[sc.Email]; only { + out[strings.ToLower(sc.Email)] = struct{}{} + } + } + } + return out +} diff --git a/internal/web/service/node_foreign_client_test.go b/internal/web/service/node_foreign_client_test.go index 0d36eebb0..43605617f 100644 --- a/internal/web/service/node_foreign_client_test.go +++ b/internal/web/service/node_foreign_client_test.go @@ -191,8 +191,8 @@ func TestNodeCannotRewriteClientSharedWithMaster(t *testing.T) { } } -// A refused claim must leave no trace: the stored settings are re-synced later by -// auto-renew, and its stats would land on the victim's traffic row. +// A refused claim must not be stored (auto-renew re-syncs the settings later) nor move the +// client's lifecycle; usage under an email the master also serves still adds up across panels. func TestNodeRefusedClaimIsNotStoredOrCounted(t *testing.T) { db := initTrafficTestDB(t) svc := &InboundService{} @@ -206,7 +206,8 @@ func TestNodeRefusedClaimIsNotStoredOrCounted(t *testing.T) { hostile := `{"clients":[{"email":"victim@x","id":"","subId":"picked-by-node","enable":true},{"email":"victim@x ","subId":"padded","enable":true},{"email":"VICTIM@X","subId":"cased","enable":true},{"email":"own@x","enable":true}]}` for _, up := range []int64{100, 600} { syncNodeWithSettings(t, svc, 1, "n1-in", hostile, - xray.ClientTraffic{Email: "victim@x", Up: up, Enable: true}, + xray.ClientTraffic{Email: "victim@x", Up: up, Enable: false, ExpiryTime: 1}, + xray.ClientTraffic{Email: "victim@x ", Up: up, Enable: true}, xray.ClientTraffic{Email: "own@x", Enable: true}) } @@ -219,10 +220,13 @@ func TestNodeRefusedClaimIsNotStoredOrCounted(t *testing.T) { if n := countClientsWhere(t, db, "LOWER(email) LIKE ?", "victim%"); n != 1 { t.Fatalf("a padded or case-variant claim created a look-alike record: %d victim rows", n) } - var counted int64 - db.Model(xray.ClientTraffic{}).Where("LOWER(email) LIKE ? AND up > 0", "victim%").Count(&counted) - if counted != 0 { - t.Fatalf("refused claim's stats reached a victim traffic row: %d rows", counted) + if got := readTraffic(t, db, "victim@x"); got.Up != 500 || !got.Enable || got.ExpiryTime != 0 { + t.Fatalf("victim traffic up=%d enable=%v expiry=%d, want the node's usage (500) and the master's lifecycle (true, 0)", got.Up, got.Enable, got.ExpiryTime) + } + var lookalike int64 + db.Model(xray.ClientTraffic{}).Where("email <> ? AND LOWER(email) LIKE ?", "victim@x", "victim%").Count(&lookalike) + if lookalike != 0 { + t.Fatalf("a padded claim's stats created %d look-alike traffic rows", lookalike) } if !nodeHostsClient(t, db, "n1-in", "own@x") { t.Fatal("the node's own client was not adopted") @@ -332,8 +336,8 @@ func TestNodeTunnelDoesNotOwnSharedPeerKeys(t *testing.T) { } } -// An honest node's copy of a shared client differs only in timestamps and comment; treating -// that as a refused claim would mark the node dirty and re-push it on every tick. +// An honest node's copy of a shared client differs only in timestamps and comment; rewriting +// it to the record's JSON would churn the stored settings on every tick. func TestNodeSharedClientInAgreementIsNotRewritten(t *testing.T) { db := initTrafficTestDB(t) svc := &InboundService{} @@ -358,12 +362,8 @@ func TestNodeSharedClientInAgreementIsNotRewritten(t *testing.T) { `{"clients":[{"email":"shared@x","id":"11111111-1111-4111-8111-111111111111","subId":"shared-sub","enable":true,"created_at":1,"updated_at":2}]}`, xray.ClientTraffic{Email: "shared@x", Enable: true}) - var node model.Node - if err := db.Select("config_dirty").Where("id = ?", 1).First(&node).Error; err != nil { - t.Fatalf("read node: %v", err) - } - if node.ConfigDirty { - t.Fatal("a node in agreement with the master was marked dirty for a re-push") + if stored := storedInboundSettings(t, db, "n1-in"); !strings.Contains(stored, `"updated_at":2`) { + t.Fatalf("a node in agreement with the master had its entry rewritten: %s", stored) } } From 255c658468e48660d8c421c76bf141d0cf2b9d16 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 10:26:54 +0100 Subject: [PATCH 094/125] fix(sub): escape a VLESS id the way the panel's own links do Subscription links escaped the VLESS id with url.QueryEscape, while the panel builds its own link and QR through the browser's URL parser, which escapes userinfo with the WHATWG userinfo set. A non-UUID id such as team+1 was therefore served as team%2B1 by the subscription and as team+1 in the QR, and a client that does not percent-decode userinfo authenticated with a different id depending on where it came from. The id is now escaped with exactly that set, which still keeps a line break or an "@" from splitting the link. --- internal/sub/endpoint_test.go | 23 +++++++++++++++++++++++ internal/sub/service.go | 19 +++++++++++++++++-- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/internal/sub/endpoint_test.go b/internal/sub/endpoint_test.go index ebfa71da7..7d5772fa0 100644 --- a/internal/sub/endpoint_test.go +++ b/internal/sub/endpoint_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "net/url" + "strconv" "strings" "testing" @@ -231,3 +232,25 @@ func TestGenVlessLinkEscapesID(t *testing.T) { t.Fatalf("vless id not escaped: %q", got) } } + +// The panel builds its own link and QR through the browser's URL parser, which escapes +// userinfo with the WHATWG userinfo set; a subscription escaping more serves another id. +func TestGenVlessLinkEscapesIDLikeThePanel(t *testing.T) { + for id, want := range map[string]string{ + "team+1": "team+1", + "a=b": "a%3Db", + "x:y;z": "x%3Ay%3Bz", + "ok!$,'()*~": "ok!$,'()*~", + "é": "%C3%A9", + } { + in := &model.Inbound{ + Listen: "203.0.113.5", Port: 443, Protocol: model.VLESS, Remark: "n", + Settings: `{"clients":[{"id":` + strconv.Quote(id) + `,"email":"user"}],"decryption":"none"}`, + StreamSettings: `{"network":"tcp","security":"none"}`, + } + got := NewSubService("").genVlessLink(in, "user") + if !strings.HasPrefix(got, "vless://"+want+"@203.0.113.5:443?") { + t.Errorf("id %q: link %q, want userinfo %q", id, got, want) + } + } +} diff --git a/internal/sub/service.go b/internal/sub/service.go index 23523b926..2c4c005f7 100644 --- a/internal/sub/service.go +++ b/internal/sub/service.go @@ -1432,7 +1432,7 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string { params, security, func(ep map[string]any, dest string, port int) string { - return fmt.Sprintf("vless://%s@%s", encodeUserinfo(applyVlessRoute(uuid, hostVlessRoute(ep))), joinHostPort(dest, port)) + return fmt.Sprintf("vless://%s@%s", encodeLinkUserinfo(applyVlessRoute(uuid, hostVlessRoute(ep))), joinHostPort(dest, port)) }, func(ep map[string]any) string { return s.endpointRemark(inbound, email, ep, streamNetwork) @@ -1440,7 +1440,7 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string { ) } - link := fmt.Sprintf("vless://%s@%s", encodeUserinfo(uuid), joinHostPort(address, port)) + link := fmt.Sprintf("vless://%s@%s", encodeLinkUserinfo(uuid), joinHostPort(address, port)) return buildLinkWithParams(link, params, s.genRemark(inbound, email, "", streamNetwork)) } @@ -1508,6 +1508,21 @@ func encodeUserinfo(s string) string { return strings.ReplaceAll(url.QueryEscape(s), "+", "%20") } +// encodeLinkUserinfo escapes with the WHATWG userinfo set, as the panel's own links do through +// the browser's URL parser, so a non-UUID id reads the same in a subscription and a QR. +func encodeLinkUserinfo(s string) string { + const escaped = " \"#<>?`{}/:;=@[\\]^|" + var sb strings.Builder + for _, b := range []byte(s) { + if b < 0x20 || b > 0x7e || strings.IndexByte(escaped, b) >= 0 { + fmt.Fprintf(&sb, "%%%02X", b) + } else { + sb.WriteByte(b) + } + } + return sb.String() +} + // joinHostPort wraps an IPv6 host in square brackets the way RFC 3986 // requires for URI authorities, while leaving IPv4 addresses and hostnames // untouched. It also strips any brackets already present on the input so From 4cddcff12b483a229fc07eb7b4735349c76d9613 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 10:30:26 +0100 Subject: [PATCH 095/125] fix(sub): fetch operator-chosen private and proxied external subscriptions again Guarding the external subscription fetch against a provider's redirects also refused the URL the operator configured when it names a LAN or loopback address, and the guarded transport dropped the host's proxy setting, so a panel merging another panel on its own network, or one reaching the Internet only through a proxy, silently lost those links. A provider listing more than 1000 links was refused whole as well. A URL that names a private address itself is now fetched, while a hostname still is not, since the provider controls what its DNS answers; public providers stay held to public addresses through every redirect. A fetch the host's proxy setting covers goes through that proxy, and a long list is cut to its first 1000 links instead of refused. --- internal/sub/external_subscription.go | 40 ++++++++++++++++-- internal/sub/external_subscription_test.go | 48 +++++++++++++++++++--- 2 files changed, 80 insertions(+), 8 deletions(-) diff --git a/internal/sub/external_subscription.go b/internal/sub/external_subscription.go index adf7f4a21..52e0c2087 100644 --- a/internal/sub/external_subscription.go +++ b/internal/sub/external_subscription.go @@ -4,7 +4,9 @@ import ( "context" "encoding/base64" "io" + "net" "net/http" + "net/url" "slices" "strings" "sync" @@ -40,6 +42,35 @@ var subscriptionHTTPClient = &http.Client{ }, } +// subscriptionProxy is the host's proxy setting: the proxy is the operator's and does the +// dialling, so a proxied fetch goes through it as the default transport did. +var subscriptionProxy = http.ProxyFromEnvironment + +var proxiedSubscriptionHTTPClient = &http.Client{ + Timeout: 6 * time.Second, + Transport: &http.Transport{ + Proxy: func(r *http.Request) (*url.URL, error) { return subscriptionProxy(r) }, + TLSHandshakeTimeout: 5 * time.Second, + MaxResponseHeaderBytes: 64 << 10, + IdleConnTimeout: 90 * time.Second, + }, +} + +// subscriptionClientFor picks the proxied client when the host's proxy setting covers req. +func subscriptionClientFor(req *http.Request) *http.Client { + if proxyURL, err := subscriptionProxy(req); err == nil && proxyURL != nil { + return proxiedSubscriptionHTTPClient + } + return subscriptionHTTPClient +} + +// operatorChosePrivateHost reports a URL naming a private address itself: the operator picked +// it. A hostname does not count, since the provider controls what its DNS answers. +func operatorChosePrivateHost(u *url.URL) bool { + ip := net.ParseIP(u.Hostname()) + return ip != nil && netsafe.IsBlockedIP(ip) +} + type subscriptionCacheEntry struct { links []string fetchedAt time.Time @@ -161,13 +192,16 @@ func doFetchSubscriptionLinks(rawURL string) ([]string, error) { if err != nil { return nil, service.WithoutRequestURL(err) } + // A provider on the operator's own network is fetched; the hops a public provider picks by + // redirecting stay held to public addresses. + req = req.WithContext(netsafe.ContextWithAllowPrivate(req.Context(), operatorChosePrivateHost(req.URL))) // Some providers gate the link body on a known client User-Agent. req.Header.Set("User-Agent", externalSubUserAgent()) // A 3x-ui donor with an HWID limit answers 404 when the header is empty (#6559). if hwid := service.ExternalSubscriptionHwid(); hwid != "" { req.Header.Set("X-HWID", hwid) } - resp, err := subscriptionHTTPClient.Do(req) + resp, err := subscriptionClientFor(req).Do(req) if err != nil { return nil, service.WithoutRequestURL(err) } @@ -184,7 +218,8 @@ func doFetchSubscriptionLinks(rawURL string) ([]string, error) { } links := decodeSubscriptionBody(body) if len(links) > subscriptionMaxLinks { - return nil, errSubscriptionTooManyLinks + logger.Warningf("sub: an external subscription lists %d share links; serving the first %d", len(links), subscriptionMaxLinks) + links = links[:subscriptionMaxLinks] } return links, nil } @@ -192,7 +227,6 @@ func doFetchSubscriptionLinks(rawURL string) ([]string, error) { var ( errBadStatus = &subError{"non-2xx subscription response"} errSubscriptionBodyTooLarge = &subError{"subscription response body exceeds size limit"} - errSubscriptionTooManyLinks = &subError{"subscription lists more than 1000 share links"} ) // externalSubUserAgent returns the panel setting for external subscription diff --git a/internal/sub/external_subscription_test.go b/internal/sub/external_subscription_test.go index e1565a37d..04f9c5ef9 100644 --- a/internal/sub/external_subscription_test.go +++ b/internal/sub/external_subscription_test.go @@ -5,6 +5,7 @@ import ( "net" "net/http" "net/http/httptest" + "net/url" "path/filepath" "strconv" "strings" @@ -371,6 +372,7 @@ func TestFetchFallsBackToDefaultExternalSubUserAgent(t *testing.T) { // The provider chooses where the fetch goes, every redirect included, so the dial itself // must refuse this host and its network. +// A hostname is the provider's to resolve, so one answering with a private address is refused. func TestDoFetchSubscriptionLinksRefusesPrivateTargets(t *testing.T) { var hits atomic.Int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -378,7 +380,8 @@ func TestDoFetchSubscriptionLinksRefusesPrivateTargets(t *testing.T) { _, _ = w.Write([]byte("vless://uuid@example.com:443#internal")) })) defer srv.Close() - if links, err := doFetchSubscriptionLinks(srv.URL + "/admin"); err == nil || links != nil { + viaName := strings.Replace(srv.URL, "127.0.0.1", "localhost", 1) + if links, err := doFetchSubscriptionLinks(viaName + "/admin"); err == nil || links != nil { t.Fatalf("fetch of a loopback target = %v, %v; want it refused", links, err) } if hits.Load() != 0 { @@ -416,13 +419,48 @@ func TestExternalSubscriptionFetchErrorIsBoundedAndKeepsTheURLSecret(t *testing. } } -func TestDoFetchSubscriptionLinksRefusesTooManyLinks(t *testing.T) { - allowLoopbackProvider(t) +// A provider listing past the cap is cut to it, not refused: refusing dropped every link +// the subscription served before the cap existed. +func TestDoFetchSubscriptionLinksCapsTooManyLinks(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(strings.Repeat("trojan://p@a:1\n", subscriptionMaxLinks+1))) })) defer srv.Close() - if links, err := doFetchSubscriptionLinks(srv.URL); !errors.Is(err, errSubscriptionTooManyLinks) || links != nil { - t.Fatalf("fetch = %d links, %v; want errSubscriptionTooManyLinks", len(links), err) + if links, err := doFetchSubscriptionLinks(srv.URL); err != nil || len(links) != subscriptionMaxLinks { + t.Fatalf("fetch = %d links, %v; want the first %d", len(links), err, subscriptionMaxLinks) + } +} + +// An operator merging a panel on their own network names its address in the URL; refusing +// it lost those links from every subscriber's response after the SSRF guard went in. +func TestDoFetchSubscriptionLinksFetchesOperatorChosenPrivateAddress(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("vless://uuid@example.com:443#lan")) + })) + defer srv.Close() + if links, err := doFetchSubscriptionLinks(srv.URL + "/sub"); err != nil || len(links) != 1 { + t.Fatalf("fetch of an operator-chosen loopback URL = %v, %v; want its link", links, err) + } +} + +// A panel that reaches the Internet only through the host's proxy fetched through it before +// the guarded transport, which had no proxy at all. +func TestDoFetchSubscriptionLinksUsesTheHostProxy(t *testing.T) { + var proxied atomic.Int32 + proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + proxied.Add(1) + _, _ = w.Write([]byte("vless://uuid@example.com:443#via-proxy")) + })) + defer proxy.Close() + proxyURL, _ := url.Parse(proxy.URL) + previous := subscriptionProxy + subscriptionProxy = http.ProxyURL(proxyURL) + t.Cleanup(func() { subscriptionProxy = previous }) + + if links, err := doFetchSubscriptionLinks("http://provider.example/sub"); err != nil || len(links) != 1 { + t.Fatalf("proxied fetch = %v, %v; want the provider's link", links, err) + } + if proxied.Load() != 1 { + t.Fatalf("proxy saw %d requests, want 1", proxied.Load()) } } From 828855cdb5aadad36574a13c0b1666f3dc8cff97 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 13:12:15 +0100 Subject: [PATCH 096/125] fix(node): apply the client gate to emails with non-ASCII capitals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The node client gate loaded the records it judges with LOWER(email) IN the lowered reported emails, but SQLite's LOWER folds ASCII only. For a stored email such as Иван no record was found, so the node's claim was treated as a new client and passed untouched, rewriting a client that lives only on the master's inbounds. The lookup matches the lowered or the stored spellings, as the shared email checks already do. --- internal/web/service/inbound_node_claims.go | 9 +++++---- .../web/service/node_foreign_client_test.go | 20 +++++++++++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/internal/web/service/inbound_node_claims.go b/internal/web/service/inbound_node_claims.go index 2ade4c32b..82d7064f4 100644 --- a/internal/web/service/inbound_node_claims.go +++ b/internal/web/service/inbound_node_claims.go @@ -84,14 +84,15 @@ func loadNodeClientClaims(tx *gorm.DB, nodeID int, snap *runtime.TrafficSnapshot return claims, nil } - lowered := make([]string, 0, len(subIDByEmail)) + reported := make([]string, 0, len(subIDByEmail)) for email := range subIDByEmail { - lowered = append(lowered, strings.ToLower(email)) + reported = append(reported, email) } + // SQLite's LOWER folds ASCII only, so a non-ASCII capital is matched by its spellings too. var records []model.ClientRecord - for _, batch := range chunkStrings(uniqueNonEmptyStrings(lowered), sqlInChunk) { + for _, batch := range chunkStrings(emailSpellings(reported), sqlInChunk) { var rows []model.ClientRecord - if err := tx.Where("LOWER(email) IN ?", batch).Find(&rows).Error; err != nil { + if err := tx.Where("LOWER(email) IN ? OR email IN ?", batch, batch).Find(&rows).Error; err != nil { return nil, err } records = append(records, rows...) diff --git a/internal/web/service/node_foreign_client_test.go b/internal/web/service/node_foreign_client_test.go index 43605617f..d581969b2 100644 --- a/internal/web/service/node_foreign_client_test.go +++ b/internal/web/service/node_foreign_client_test.go @@ -439,3 +439,23 @@ func TestNodeOversizeInboundIsNotTakenIn(t *testing.T) { t.Fatal("an inbound with an oversized remark was adopted") } } + +// SQLite's LOWER folds ASCII only, so a lookup by lowered email missed a stored non-ASCII +// capital and the node's claim on that master-only client went through untouched. +func TestNodeCannotClaimNonASCIICapitalEmail(t *testing.T) { + db := initTrafficTestDB(t) + svc := &InboundService{} + seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true}) + seedMasterInbound(t, db, "master-in", 40001, `{"email":"Иван@x","id":"11111111-1111-4111-8111-111111111111","subId":"ivan-sub","enable":true}`) + createNodeInbound(t, db, 1, "n1-in", 41001) + + syncNodeWithSettings(t, svc, 1, "n1-in", + `{"clients":[{"email":"Иван@x","subId":"picked-by-node","enable":true},{"email":"иван@x","subId":"lower","enable":true}]}`) + + if row := readClientRecord(t, db, "Иван@x"); row.SubID != "ivan-sub" { + t.Fatalf("node rewrote a master-only non-ASCII client: subId=%q", row.SubID) + } + if n := countClientsWhere(t, db, "email = ?", "иван@x"); n != 0 { + t.Fatal("a case variant of a non-ASCII email created a look-alike record") + } +} From 1d373cf67eeeca253cfc30be8ea0ef7781eaf5c5 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 13:13:10 +0100 Subject: [PATCH 097/125] fix(node): let a node take back a client orphaned before the upgrade The migration backfills sync_orphaned_by = 0 for every client a node merge had already soft-orphaned, and the gate refused a detached record unless its mark named the reporting node. A client inside its orphan grace window at upgrade time was therefore refused when its node reported it again, and ReapSyncOrphans then deleted it on the master while the node kept serving it. A mark that names no node predates the column and may be taken back by any node, as before. --- internal/web/service/inbound_node_claims.go | 3 ++- internal/web/service/node_foreign_client_test.go | 11 ++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/internal/web/service/inbound_node_claims.go b/internal/web/service/inbound_node_claims.go index 82d7064f4..df0acc534 100644 --- a/internal/web/service/inbound_node_claims.go +++ b/internal/web/service/inbound_node_claims.go @@ -160,7 +160,8 @@ func loadNodeClientClaims(tx *gorm.DB, nodeID int, snap *runtime.TrafficSnapshot case scope.elsewhere: claims.refused[email] = "it is attached only to inbounds this node does not host" claims.usageOnly[email] = struct{}{} - case !scope.here && (rec.SyncOrphanedAt == 0 || rec.SyncOrphanedBy != nodeID): + // A mark from before sync_orphaned_by existed names no node, so any node may take it back. + case !scope.here && (rec.SyncOrphanedAt == 0 || (rec.SyncOrphanedBy != 0 && rec.SyncOrphanedBy != nodeID)): claims.refused[email] = "it is detached from every inbound on the master" case subID != rec.SubID && subID != "" && subIDTaken(email, subID): claims.storedSubID[email] = rec.SubID diff --git a/internal/web/service/node_foreign_client_test.go b/internal/web/service/node_foreign_client_test.go index d581969b2..21b9f78f3 100644 --- a/internal/web/service/node_foreign_client_test.go +++ b/internal/web/service/node_foreign_client_test.go @@ -243,11 +243,13 @@ func TestNodeCannotAdoptDetachedClient(t *testing.T) { master := seedMasterInbound(t, db, "master-in", 40001, `{"email":"detached@x","id":"11111111-1111-4111-8111-111111111111","subId":"d-sub","enable":true},`+ `{"email":"mine@x","id":"22222222-2222-4222-8222-222222222222","enable":true},`+ - `{"email":"theirs@x","id":"33333333-3333-4333-8333-333333333333","enable":true}`) + `{"email":"theirs@x","id":"33333333-3333-4333-8333-333333333333","enable":true},`+ + `{"email":"legacy@x","id":"44444444-4444-4444-8444-444444444444","enable":true}`) if err := db.Exec("DELETE FROM client_inbounds WHERE inbound_id = ?", master.Id).Error; err != nil { t.Fatalf("detach: %v", err) } - for email, node := range map[string]int{"mine@x": 1, "theirs@x": 2} { + // legacy@x was marked before sync_orphaned_by existed, so its mark names no node. + for email, node := range map[string]int{"mine@x": 1, "theirs@x": 2, "legacy@x": 0} { if err := db.Model(&model.ClientRecord{}).Where("email = ?", email). Updates(map[string]any{"sync_orphaned_at": 1, "sync_orphaned_by": node}).Error; err != nil { t.Fatalf("mark %s orphaned: %v", email, err) @@ -256,7 +258,7 @@ func TestNodeCannotAdoptDetachedClient(t *testing.T) { createNodeInbound(t, db, 1, "n1-in", 41001) syncNodeWithSettings(t, svc, 1, "n1-in", - `{"clients":[{"email":"detached@x","subId":"picked","enable":true},{"email":"mine@x","enable":true},{"email":"theirs@x","subId":"picked2","enable":true}]}`) + `{"clients":[{"email":"detached@x","subId":"picked","enable":true},{"email":"mine@x","enable":true},{"email":"theirs@x","subId":"picked2","enable":true},{"email":"legacy@x","enable":true}]}`) if row := readClientRecord(t, db, "detached@x"); row.SubID != "d-sub" || nodeHostsClient(t, db, "n1-in", "detached@x") { t.Fatalf("node adopted a detached client: subId=%q", row.SubID) @@ -267,6 +269,9 @@ func TestNodeCannotAdoptDetachedClient(t *testing.T) { if !nodeHostsClient(t, db, "n1-in", "mine@x") { t.Fatal("node could not take back a client its own merge orphaned") } + if !nodeHostsClient(t, db, "n1-in", "legacy@x") { + t.Fatal("a client orphaned before the upgrade could not be taken back and would be reaped") + } } // The subscription server selects by subId alone, so a node's client must not join From 93d1e63554ca79e0801b9b8a5a19c309e9ee6716 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 13:14:46 +0100 Subject: [PATCH 098/125] fix(node): count only values against the node-answer value cap decodeCappedValues is documented to count JSON values, but it also counted object keys and closing brackets, so a node's GUID-keyed online and IP trees reached the cap at roughly half the stated number of values, and the master then refused the whole report rather than the excess. Keys and closing brackets no longer count; every value, nested ones included, still does. --- internal/web/runtime/remote_decode.go | 24 ++++++++++++++++++++---- internal/web/runtime/remote_test.go | 14 ++++++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/internal/web/runtime/remote_decode.go b/internal/web/runtime/remote_decode.go index ec4c92c78..d4d9e599a 100644 --- a/internal/web/runtime/remote_decode.go +++ b/internal/web/runtime/remote_decode.go @@ -23,20 +23,36 @@ const ( var errRemoteListTooLong = json_util.ErrListTooLong // decodeCappedValues unmarshals raw into v after checking it holds at most maxValues -// JSON values in all, nested ones included. +// JSON values in all, nested ones included; object keys and closing brackets are not values. func decodeCappedValues(raw json.RawMessage, v any, maxValues int) error { dec := json.NewDecoder(bytes.NewReader(raw)) - for count := 0; ; count++ { + // One frame per open container; an object's strings alternate between key and value. + type frame struct{ object, wantKey bool } + var open []frame + count := 0 + for { tok, err := dec.Token() if err != nil { break } - if delim, ok := tok.(json.Delim); ok && (delim == ']' || delim == '}') { + delim, isDelim := tok.(json.Delim) + if isDelim && (delim == ']' || delim == '}') { + open = open[:len(open)-1] continue } - if count > maxValues { + if top := len(open) - 1; top >= 0 && open[top].object { + if open[top].wantKey { + open[top].wantKey = false + continue + } + open[top].wantKey = true + } + if count++; count > maxValues { return fmt.Errorf("%w (%d values)", errRemoteListTooLong, maxValues) } + if isDelim { + open = append(open, frame{object: delim == '{', wantKey: delim == '{'}) + } } return json.Unmarshal(raw, v) } diff --git a/internal/web/runtime/remote_test.go b/internal/web/runtime/remote_test.go index 1720514a1..5fee05759 100644 --- a/internal/web/runtime/remote_test.go +++ b/internal/web/runtime/remote_test.go @@ -554,3 +554,17 @@ func TestRemoteForgetInboundDropsEveryTrace(t *testing.T) { t.Fatalf("left behind: pushedFP=%v ids=%v aliases=%v", r.pushedFP, r.remoteIDByTag, r.adoptedAliases) } } + +// The cap is documented in JSON values; counting keys and closing brackets as well refused a +// node's whole online report at well under the stated number of values. +func TestDecodeCappedValuesCountsValuesOnly(t *testing.T) { + var got map[string][]string + // One object, two arrays and three strings: six values, though fourteen tokens. + raw := json.RawMessage(`{"guid-a":["x","y"],"guid-b":["z"]}`) + if err := decodeCappedValues(raw, &got, 6); err != nil { + t.Fatalf("six values under a cap of six: %v", err) + } + if err := decodeCappedValues(raw, &got, 5); !errors.Is(err, errRemoteListTooLong) { + t.Fatalf("six values under a cap of five: err = %v, want errRemoteListTooLong", err) + } +} From f99c6c664d9083b614695ff3dc4510603a2dec7b Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 13:16:14 +0100 Subject: [PATCH 099/125] fix(sub): resolve legacy socket fallback fronts on PostgreSQL too The socket-front lookup filtered and extracted settings.fallbacks with SQLite's json_valid, JSON_TYPE and json_extract, which PostgreSQL does not have, so on PostgreSQL the statement failed and a legacy @socket child never got its front's address and security in its links. The lookup now selects the inbounds whose settings mention fallbacks and parses them in Go, which works on both databases and still skips an inbound whose settings do not decode. --- internal/sub/host_sub_test.go | 6 ++++-- internal/sub/service.go | 18 ++++++++++-------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/internal/sub/host_sub_test.go b/internal/sub/host_sub_test.go index 3f8b75a4d..cb3dacc91 100644 --- a/internal/sub/host_sub_test.go +++ b/internal/sub/host_sub_test.go @@ -2048,8 +2048,10 @@ func TestSub_LegacyFallbackMasterLookedUpOncePerRequest(t *testing.T) { scans := 0 count := func(tx *gorm.DB) { - if strings.Contains(tx.Statement.SQL.String(), "$.fallbacks") { - scans++ + for _, v := range tx.Statement.Vars { + if v == "%fallbacks%" { + scans++ + } } } _ = db.Callback().Query().After("gorm:query").Register("test:count_front_scans", count) diff --git a/internal/sub/service.go b/internal/sub/service.go index 2c4c005f7..4b0b23ad1 100644 --- a/internal/sub/service.go +++ b/internal/sub/service.go @@ -3607,20 +3607,22 @@ func (s *SubService) socketFront(key socketFrontKey) *model.Inbound { Listen string Port int StreamSettings string - Fallbacks string + Settings string } - // json_valid keeps one inbound with unreadable settings from failing the - // whole statement and hiding every front. + // Parsed here, not with SQL JSON functions, so the lookup works on PostgreSQL too and one + // inbound with unreadable settings cannot hide every front. if err := database.GetDB().Model(model.Inbound{}). - Select("node_id, listen, port, stream_settings, json_extract(settings, '$.fallbacks') AS fallbacks"). - Where("CASE WHEN json_valid(settings) THEN JSON_TYPE(settings, '$.fallbacks') END = 'array'"). + Select("node_id, listen, port, stream_settings, settings"). + Where("settings LIKE ?", "%fallbacks%"). Order("id ASC").Scan(&fronts).Error; err != nil { return nil } for _, front := range fronts { - var fallbacks []any - _ = json.Unmarshal([]byte(front.Fallbacks), &fallbacks) - for _, fallback := range fallbacks { + var parsed struct { + Fallbacks []any `json:"fallbacks"` + } + _ = json.Unmarshal([]byte(front.Settings), &parsed) + for _, fallback := range parsed.Fallbacks { rule, _ := fallback.(map[string]any) dest, _ := rule["dest"].(string) k := socketFrontKey{node: nodeKeyOf(front.NodeID), dest: dest} From 06cb1742a11f7bdb6314229ea1a56f1575251805 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 13:18:01 +0100 Subject: [PATCH 100/125] fix(server): bound the public-IP lookup and accept only an address getPublicIP read a third-party IP echo service's body whole and cached whatever came back as the panel's address, so one misbehaving service could make the panel inflate an endless response at startup, and an error page became the address shown in every status sample. The read is capped at 64 bytes and only a parsable IP is accepted; anything else counts as unavailable. The GitHub release lookups are capped as well. --- internal/web/service/panel/panel.go | 3 ++- internal/web/service/server.go | 22 ++++++++++----- .../service/server_public_ip_async_test.go | 27 +++++++++++++++++++ 3 files changed, 45 insertions(+), 7 deletions(-) diff --git a/internal/web/service/panel/panel.go b/internal/web/service/panel/panel.go index 62ce7d10a..c4db7017c 100644 --- a/internal/web/service/panel/panel.go +++ b/internal/web/service/panel/panel.go @@ -43,6 +43,7 @@ type PanelUpdateInfo struct { const ( panelUpdaterURL = "https://raw.githubusercontent.com/MHSanaei/3x-ui/main/update.sh" maxPanelUpdaterBytes = 2 << 20 + maxPanelReleaseBytes = 2 << 20 // devReleaseTag is the fixed-tag rolling pre-release the CI force-moves to the // newest main commit; the dev update channel installs from it. devReleaseTag = "dev-latest" @@ -450,7 +451,7 @@ func fetchPanelRelease(tag string) (*service.Release, error) { } var release service.Release - if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + if err := json.NewDecoder(io.LimitReader(resp.Body, maxPanelReleaseBytes)).Decode(&release); err != nil { return nil, err } return &release, nil diff --git a/internal/web/service/server.go b/internal/web/service/server.go index 57287aed6..8fa7db3ca 100644 --- a/internal/web/service/server.go +++ b/internal/web/service/server.go @@ -19,6 +19,7 @@ import ( "mime/multipart" stdnet "net" "net/http" + "net/netip" "net/url" "os" "os/exec" @@ -402,6 +403,10 @@ type MLKEM768Response struct { Client string `json:"client" example:"mlkem768-client"` } +// maxPublicIPBytes caps an IP echo service's answer; a textual IPv6 address +// with its line ending fits well within it. +const maxPublicIPBytes = 64 + func getPublicIP(url string) string { client := &http.Client{ Timeout: 3 * time.Second, @@ -425,17 +430,19 @@ func getPublicIP(url string) string { return "N/A" } - ip, err := io.ReadAll(resp.Body) + body, err := io.ReadAll(io.LimitReader(resp.Body, maxPublicIPBytes)) if err != nil { return "N/A" } - ipString := strings.TrimSpace(string(ip)) - if ipString == "" { + // The answer is cached and sent with every status sample, so only a bare + // address is accepted; an over-long one is cut above and fails to parse. + ip, err := netip.ParseAddr(strings.TrimSpace(string(body))) + if err != nil { return "N/A" } - return ipString + return ip.WithZone("").String() } var publicIPv4Services = []string{ @@ -921,6 +928,9 @@ const ( // maxXrayDigestBytes caps the .dgst checksum sidecar read; it is a few // hundred bytes in practice. maxXrayDigestBytes = 64 << 10 + // maxXrayReleasesBytes caps the GitHub release list read; a page of + // releases with all their assets is a few MiB. + maxXrayReleasesBytes = 32 << 20 ) func (s *ServerService) GetXrayVersions() ([]string, error) { @@ -941,7 +951,7 @@ func (s *ServerService) GetXrayVersions() ([]string, error) { // Check HTTP status code - GitHub API returns object instead of array on error if resp.StatusCode != http.StatusOK { - bodyBytes, _ := io.ReadAll(resp.Body) + bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, maxXrayReleasesBytes)) var errorResponse struct { Message string `json:"message"` } @@ -953,7 +963,7 @@ func (s *ServerService) GetXrayVersions() ([]string, error) { buffer := bytes.NewBuffer(make([]byte, bufferSize)) buffer.Reset() - if _, err := buffer.ReadFrom(resp.Body); err != nil { + if _, err := buffer.ReadFrom(io.LimitReader(resp.Body, maxXrayReleasesBytes)); err != nil { return nil, err } diff --git a/internal/web/service/server_public_ip_async_test.go b/internal/web/service/server_public_ip_async_test.go index f01e2f1e0..1f56717d7 100644 --- a/internal/web/service/server_public_ip_async_test.go +++ b/internal/web/service/server_public_ip_async_test.go @@ -564,3 +564,30 @@ func TestPublicIPv6BackoffPreservesIPv4RefreshAndRetry(t *testing.T) { } }) } + +// The IP echo services are third parties; their answer is cached for the life +// of the process and copied into every status sample. +func TestGetPublicIPAcceptsOnlyAnAddress(t *testing.T) { + tests := []struct { + name string + body string + want string + }{ + {name: "ipv4 with line ending", body: "203.0.113.7\n", want: "203.0.113.7"}, + {name: "ipv6", body: "2001:db8::7", want: "2001:db8::7"}, + {name: "not an address", body: "blocked", want: "N/A"}, + {name: "address followed by megabytes", body: "203.0.113.7\n" + strings.Repeat("1", 8<<20), want: "N/A"}, + {name: "empty", body: "", want: "N/A"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(tt.body)) + })) + defer server.Close() + if got := getPublicIP(server.URL); got != tt.want { + t.Fatalf("getPublicIP = %.40q (%d bytes), want %q", got, len(got), tt.want) + } + }) + } +} From 8bbfda051002b65615c91fbbb451127c4cb8cbfe Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 13:18:54 +0100 Subject: [PATCH 101/125] fix(email): cap what one SMTP session reads from the server net/smtp buffers a server reply of any length, so a mail server, or anyone on the path before STARTTLS, could send a greeting or EHLO reply that never ends and grow the panel's heap until the 30 s deadline, on every notification. Each session now reads at most 1 MiB from the server, below TLS, and fails with a clear error past it. --- internal/web/service/email/email.go | 72 +++++++++++++------ .../web/service/email/email_deadline_test.go | 49 +++++++++++++ 2 files changed, 101 insertions(+), 20 deletions(-) diff --git a/internal/web/service/email/email.go b/internal/web/service/email/email.go index 7031183fa..f6f2a0c95 100644 --- a/internal/web/service/email/email.go +++ b/internal/web/service/email/email.go @@ -5,6 +5,7 @@ import ( "crypto/rand" "crypto/tls" "encoding/hex" + "errors" "fmt" "mime" "net" @@ -42,6 +43,54 @@ const smtpConnectTimeout = 10 * time.Second var smtpDeadline = 30 * time.Second +// smtpMaxServerBytes caps what one session reads from the server, TLS handshake +// included: net/smtp buffers a reply of any length, so the deadline alone is no bound. +const smtpMaxServerBytes = 1 << 20 + +var errSMTPReplyTooLarge = errors.New("smtp server sent more data than a session may read") + +// limitedConn fails every Read once the peer has sent remaining bytes. +type limitedConn struct { + net.Conn + remaining int64 +} + +func (c *limitedConn) Read(p []byte) (int, error) { + if c.remaining <= 0 { + return 0, errSMTPReplyTooLarge + } + if int64(len(p)) > c.remaining { + p = p[:c.remaining] + } + n, err := c.Conn.Read(p) + c.remaining -= int64(n) + return n, err +} + +// dialSMTP opens a connection capped at smtpMaxServerBytes; timeout covers the +// dial and, with implicitTLS, the handshake. +func dialSMTP(addr, host string, timeout time.Duration, implicitTLS bool) (net.Conn, error) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + raw, err := (&net.Dialer{}).DialContext(ctx, "tcp", addr) + if err != nil { + return nil, err + } + // The cap sits below TLS: net/smtp allows PLAIN auth only on a *tls.Conn, + // and this keeps it in force across a later STARTTLS upgrade. + limited := &limitedConn{Conn: raw, remaining: smtpMaxServerBytes} + if !implicitTLS { + return limited, nil + } + conn := tls.Client(limited, &tls.Config{ServerName: host}) + if err = conn.HandshakeContext(ctx); err != nil { + raw.Close() + return nil, err + } + return conn, nil +} + // Send sends an HTML email to all configured recipients. func (s *EmailService) Send(subject, body string) error { host, err := s.settingService.GetSmtpHost() @@ -139,19 +188,7 @@ func (s *EmailService) TestConnection() SMTPTestResult { addr := net.JoinHostPort(host, fmt.Sprintf("%d", port)) // Stage 1: Connect - var conn net.Conn - dialer := &net.Dialer{Timeout: 5 * time.Second} - - switch encryptionType { - case "tls": - conn, err = (&tls.Dialer{NetDialer: dialer, Config: &tls.Config{ - ServerName: host, - InsecureSkipVerify: false, - }}).DialContext(context.Background(), "tcp", addr) - default: - conn, err = dialer.Dial("tcp", addr) - } - + conn, err := dialSMTP(addr, host, 5*time.Second, encryptionType == "tls") if err != nil { return SMTPTestResult{false, "connect", classifySMTPError(err)} } @@ -216,12 +253,7 @@ func (s *EmailService) TestConnection() SMTPTestResult { } func (s *EmailService) sendWithTLS(addr string, auth smtp.Auth, from string, to []string, msg []byte, host string) error { - // Dial with explicit timeout - dialer := &net.Dialer{Timeout: smtpConnectTimeout} - conn, err := (&tls.Dialer{NetDialer: dialer, Config: &tls.Config{ - ServerName: host, - InsecureSkipVerify: false, - }}).DialContext(context.Background(), "tcp", addr) + conn, err := dialSMTP(addr, host, smtpConnectTimeout, true) if err != nil { return err } @@ -266,7 +298,7 @@ func (s *EmailService) sendWithTLS(addr string, auth smtp.Auth, from string, to // timeout and arms a connection deadline, so a server that never speaks or // stalls mid-protocol cannot block the sender goroutine past smtpDeadline. func (s *EmailService) sendPlain(addr string, auth smtp.Auth, from string, to []string, msg []byte, host string) error { - conn, err := (&net.Dialer{Timeout: smtpConnectTimeout}).Dial("tcp", addr) + conn, err := dialSMTP(addr, host, smtpConnectTimeout, false) if err != nil { return err } diff --git a/internal/web/service/email/email_deadline_test.go b/internal/web/service/email/email_deadline_test.go index 17d36c423..dc926f2aa 100644 --- a/internal/web/service/email/email_deadline_test.go +++ b/internal/web/service/email/email_deadline_test.go @@ -1,6 +1,9 @@ package email import ( + "bufio" + "bytes" + "errors" "net" "testing" "time" @@ -44,3 +47,49 @@ func TestSendPlainReturnsOnStalledServer(t *testing.T) { t.Fatal("sendPlain did not return on a stalled server within the deadline") } } + +// The flood is capped well above smtpMaxServerBytes: without the cap the client +// buffers it all and then fails on the closed socket, not errSMTPReplyTooLarge. +func TestSendPlainStopsReadingOversizedReply(t *testing.T) { + tests := []struct { + name string + greeting string + flood []byte + }{ + {"greeting line that never ends", "220 ", bytes.Repeat([]byte("a"), 64<<10)}, + {"EHLO reply that never ends", "220 ready\r\n", bytes.Repeat([]byte("250-X\r\n"), 8<<10)}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + defer ln.Close() + + go func() { + conn, err := ln.Accept() + if err != nil { + return + } + defer conn.Close() + _, _ = conn.Write([]byte(tt.greeting)) + if tt.greeting[len(tt.greeting)-1] == '\n' { + _, _ = bufio.NewReader(conn).ReadString('\n') + } + for sent := 0; sent < 16<<20; sent += len(tt.flood) { + if _, err := conn.Write(tt.flood); err != nil { + return + } + } + }() + + s := &EmailService{} + err = s.sendPlain(ln.Addr().String(), nil, "from@example.com", + []string{"to@example.com"}, []byte("body"), "example.com") + if !errors.Is(err, errSMTPReplyTooLarge) { + t.Fatalf("sendPlain error = %v, want %v", err, errSMTPReplyTooLarge) + } + }) + } +} From 4d8e2b48ffb510c69f262dde39dc19b6e3fb8626 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 13:20:27 +0100 Subject: [PATCH 102/125] perf(sub): make balancer member retagging linear MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildBalancerConfig restarted the free tag-suffix search at 2 for every member, so member k formatted and looked up k tags and n members of one protocol cost n²/2 lookups; a balancer with many same-protocol members held a subscription request for minutes. The search now resumes per base tag where the previous member stopped, producing the same tags in linear time. --- internal/sub/json_service.go | 7 ++++- internal/sub/sub_balancer_test.go | 45 +++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/internal/sub/json_service.go b/internal/sub/json_service.go index c608b928d..caa9cc1e9 100644 --- a/internal/sub/json_service.go +++ b/internal/sub/json_service.go @@ -531,6 +531,9 @@ func leastLoadCosts(balancer *model.SubBalancer, members []balMember) []any { func (s *SubJsonService) buildBalancerConfig(balancer *model.SubBalancer, entries []subConfigEntry, entryProxies [][]map[string]any) json_util.RawMessage { prefix := fmt.Sprintf("bal-%d-", balancer.Id) usedTags := make(map[string]bool) + // Next suffix to try per base: tags are only ever added, so every lower one + // is taken and resuming there keeps a node-sized member list linear. + nextSuffix := make(map[string]int) var proxies []json_util.RawMessage // Members in emission order with their owning inbound, so costs[] can // reference the exact retagged tags assigned here. @@ -546,9 +549,11 @@ func (s *SubJsonService) buildBalancerConfig(balancer *model.SubBalancer, entrie protocol, _ := outbound["protocol"].(string) base := prefix + balancerMemberSuffix(protocol) tag := base - for suffix := 2; usedTags[tag]; suffix++ { + suffix := max(nextSuffix[base], 2) + for ; usedTags[tag]; suffix++ { tag = fmt.Sprintf("%s-%d", base, suffix) } + nextSuffix[base] = suffix usedTags[tag] = true member := maps.Clone(outbound) member["tag"] = tag diff --git a/internal/sub/sub_balancer_test.go b/internal/sub/sub_balancer_test.go index dd827b1e4..80386e62d 100644 --- a/internal/sub/sub_balancer_test.go +++ b/internal/sub/sub_balancer_test.go @@ -2,11 +2,15 @@ package sub import ( "encoding/json" + "fmt" + "slices" "strings" "testing" + "time" "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/util/json_util" ) func seedSubBalancer(t *testing.T, b *model.SubBalancer) *model.SubBalancer { @@ -508,3 +512,44 @@ func TestSubJson_BalancerCostsSkippedForNonLeastLoadStrategy(t *testing.T) { t.Fatalf("random balancer must never emit costs despite stored weights: %v", strategy) } } + +// A node authors the endpoint list of its inbounds, so the member count is +// not the admin's to bound; probing every taken suffix per member went quadratic. +func TestSubJson_BalancerManySameProtocolMembers(t *testing.T) { + members := make([]map[string]any, 100_000) + for i := range members { + members[i] = map[string]any{"protocol": "vless", "tag": "proxy"} + } + // A protocol whose base collides with a suffixed tag must still be skipped. + members[0]["protocol"] = "vless-3" + members[4]["protocol"] = "trojan" + + js := NewSubJsonService("", "", "", "", NewSubService("")) + done := make(chan json_util.RawMessage, 1) + go func() { + done <- js.buildBalancerConfig( + &model.SubBalancer{Id: 1, Remark: "many", Strategy: "random", InboundIds: []int{7}}, + []subConfigEntry{{id: 7}}, + [][]map[string]any{members}, + ) + }() + var raw json_util.RawMessage + select { + case raw = <-done: + case <-time.After(30 * time.Second): + t.Fatalf("retagging %d same-protocol members did not finish in 30s", len(members)) + } + + var doc map[string]any + if err := json.Unmarshal(raw, &doc); err != nil { + t.Fatalf("balancer doc is not JSON: %v", err) + } + tags := docOutboundTags(doc) + want := []string{"bal-1-vless-3", "bal-1-vless", "bal-1-vless-2", "bal-1-vless-4", "bal-1-trojan", "bal-1-vless-5"} + if len(tags) < len(members) || !slices.Equal(tags[:len(want)], want) { + t.Fatalf("first member tags = %v, want %v", tags[:min(len(tags), len(want))], want) + } + if got, last := tags[len(members)-1], fmt.Sprintf("bal-1-vless-%d", len(members)-1); got != last { + t.Fatalf("last member tag = %q, want %q", got, last) + } +} From cc3aff24340925a31bfe13de3e616307436af009 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 13:21:08 +0100 Subject: [PATCH 103/125] fix(discord): escape Markdown in alert text the panel did not write Every Discord alert field went through cleanField, which only trimmed and truncated, so a login username, a node's error text, a remark or an email reached Discord as live Markdown: masked links, bold, headings, mentions. A failed login with a crafted username could post a working phishing link in the admins' channel. Foreign text is now Markdown-escaped in every field and command reply, and values shown in code spans can no longer close their span. --- internal/web/service/discord/gateway.go | 10 +++-- .../discord/gateway_inbounds_limits_test.go | 37 +++++++++++++++++ internal/web/service/discord/subscriber.go | 20 +++++++++ .../web/service/discord/subscriber_test.go | 41 +++++++++++++++++++ 4 files changed, 104 insertions(+), 4 deletions(-) diff --git a/internal/web/service/discord/gateway.go b/internal/web/service/discord/gateway.go index 6199ab15f..0559cb92c 100644 --- a/internal/web/service/discord/gateway.go +++ b/internal/web/service/discord/gateway.go @@ -610,8 +610,8 @@ func (g *GatewayClient) sendUsage(ctx context.Context, email string) { } embed := Embed{ - Title: tr("discord.commands.usageTitle", "Email=="+client.Email), - Description: tr("discord.commands.usageDescription", "Remark=="+in.Remark, "Port=="+strconv.Itoa(in.Port)), + Title: tr("discord.commands.usageTitle", "Email=="+escapeMarkdown(client.Email)), + Description: tr("discord.commands.usageDescription", "Remark=="+escapeMarkdown(in.Remark), "Port=="+strconv.Itoa(in.Port)), Color: color, Timestamp: time.Now().UTC().Format(time.RFC3339), Fields: []EmbedField{ @@ -659,15 +659,17 @@ func (g *GatewayClient) sendInbounds(ctx context.Context) { if !in.Enable { state = tr("disabled") } + // The template puts the protocol in a code span, where a backslash escapes + // nothing and only a backtick gets out. val := tr("discord.values.inbound", - "Protocol=="+string(in.Protocol), + "Protocol=="+strings.ReplaceAll(string(in.Protocol), "`", "'"), "Port=="+strconv.Itoa(in.Port), "Clients=="+strconv.Itoa(len(in.ClientStats)), "Up=="+common.FormatTraffic(in.Up), "Down=="+common.FormatTraffic(in.Down), "State=="+state, ) - fields = append(fields, cleanField(fmt.Sprintf("📍 %s", in.Remark), val, false)) + fields = append(fields, markupField(fmt.Sprintf("📍 %s", escapeMarkdown(in.Remark)), val, false)) } title := tr("discord.commands.inboundsTitle") diff --git a/internal/web/service/discord/gateway_inbounds_limits_test.go b/internal/web/service/discord/gateway_inbounds_limits_test.go index d40692c17..43e34e849 100644 --- a/internal/web/service/discord/gateway_inbounds_limits_test.go +++ b/internal/web/service/discord/gateway_inbounds_limits_test.go @@ -216,3 +216,40 @@ func TestInboundsCommandRetriesARateLimitedPage(t *testing.T) { t.Errorf("posted %d times for %d messages, want one retry of the limited page", posts, len(msgs)) } } + +func TestCommandRepliesEscapeNodeReportedText(t *testing.T) { + settingService := setupTestDB(t) + _ = settingService.SetDiscordBotToken("test-bot-token") + _ = settingService.SetDiscordChannelId("ch-1") + _ = settingService.SetDiscordAdminIds("u1") + + inbounds := inboundFixtures(1) + inbounds[0].Remark = "**x** [reset](https://attacker.example/)" + inbounds[0].Protocol = "vless` [reset](https://attacker.example/) `" + inbounds[0].ClientStats[0].Email = "[reset](https://attacker.example/)" + const remark = `\*\*x\*\* \[reset\](https\://attacker.example/)` + + t.Run("inbounds", func(t *testing.T) { + msgs := runInboundsCommand(t, settingService, inbounds) + if len(msgs) != 1 || len(msgs[0].Embeds) != 1 || len(msgs[0].Embeds[0].Fields) != 1 { + t.Fatalf("expected one message with one field, got %+v", msgs) + } + field := msgs[0].Embeds[0].Fields[0] + if want := "📍 " + remark; field.Name != want { + t.Errorf("field name = %q, want %q", field.Name, want) + } + if want := "Protocol: `vless' [reset](https://attacker.example/) '` | Port: `10000`"; !strings.HasPrefix(field.Value, want) { + t.Errorf("field value = %q, want the protocol kept inside its code span: %q", field.Value, want) + } + }) + + t.Run("usage", func(t *testing.T) { + embed := runUsageCommand(t, settingService, inbounds, inbounds[0].ClientStats[0].Email).Embeds[0] + if want := `\[reset\](https\://attacker.example/)`; !strings.HasSuffix(embed.Title, ": "+want) { + t.Errorf("title = %q, want it to end in %q", embed.Title, want) + } + if !strings.Contains(embed.Description, "**"+remark+"**") { + t.Errorf("description = %q, want it to carry %q", embed.Description, remark) + } + }) +} diff --git a/internal/web/service/discord/subscriber.go b/internal/web/service/discord/subscriber.go index cad635632..1c493f606 100644 --- a/internal/web/service/discord/subscriber.go +++ b/internal/web/service/discord/subscriber.go @@ -98,7 +98,27 @@ func truncateUnits(s string, maxUnits int) string { return b.String() + suffix } +// markdownEscaper covers what Discord renders as markup in an embed field: +// formatting, headings, quotes, masked links, mentions and bare-URL auto-links. +var markdownEscaper = strings.NewReplacer( + `\`, `\\`, "`", "\\`", `*`, `\*`, `_`, `\_`, `~`, `\~`, `|`, `\|`, `#`, `\#`, + `[`, `\[`, `]`, `\]`, `<`, `\<`, `>`, `\>`, `://`, `\://`, +) + +// escapeMarkdown makes text the panel did not write itself (login usernames, +// node errors, remarks, emails) show up literally instead of as Markdown. +func escapeMarkdown(s string) string { + return markdownEscaper.Replace(s) +} + +// cleanField builds a field from plain text: Discord shows both strings literally. func cleanField(name, value string, inline bool) EmbedField { + return markupField(escapeMarkdown(name), escapeMarkdown(value), inline) +} + +// markupField keeps the caller's own Markdown, so whatever foreign text the +// caller puts into it has to be escaped beforehand. +func markupField(name, value string, inline bool) EmbedField { name = strings.TrimSpace(name) if name == "" { name = "-" diff --git a/internal/web/service/discord/subscriber_test.go b/internal/web/service/discord/subscriber_test.go index 0daba256b..e5f7fffca 100644 --- a/internal/web/service/discord/subscriber_test.go +++ b/internal/web/service/discord/subscriber_test.go @@ -252,6 +252,47 @@ func TestCleanField_Protection(t *testing.T) { } } +func TestFormatEmbed_ForeignTextIsNotMarkdown(t *testing.T) { + sub := NewSubscriber(setupTestDB(t), nil) + const text = "a_b\n**x** [reset](https://attacker.example/) <@1> `c`" + const want = "a\\_b\n\\*\\*x\\*\\* \\[reset\\](https\\://attacker.example/) \\<@1\\> \\`c\\`" + + for name, tc := range map[string]struct { + event eventbus.Event + field int + }{ + "failed login username": {eventbus.Event{ + Type: eventbus.EventLoginAttempt, + Data: &eventbus.LoginEventData{Username: text, IP: "2001:db8::1", Status: "fail", Time: "2026-10-15 02:52:22"}, + }, 0}, + "successful login username": {eventbus.Event{ + Type: eventbus.EventLoginAttempt, + Data: &eventbus.LoginEventData{Username: text, IP: "2001:db8::1", Status: "success", Time: "2026-10-15 02:52:22"}, + }, 0}, + "node-reported xray error": {eventbus.Event{ + Type: eventbus.EventNodeDown, + Source: "node-1", + Data: &eventbus.NodeHealthData{XrayError: text}, + }, 1}, + } { + t.Run(name, func(t *testing.T) { + embed, ok := sub.FormatEmbed(tc.event) + if !ok || len(embed.Fields) <= tc.field { + t.Fatalf("expected an alert with field %d, got ok=%v fields=%d", tc.field, ok, len(embed.Fields)) + } + if got := embed.Fields[tc.field].Value; got != want { + t.Errorf("field value = %q, want %q", got, want) + } + // Only markup is escaped: the IP and time fields go out as written. + for _, f := range embed.Fields[tc.field+1:] { + if strings.Contains(f.Value, `\`) { + t.Errorf("field %q = %q, want it untouched", f.Name, f.Value) + } + } + }) + } +} + func TestHandleEvent_EndToEndWithServer(t *testing.T) { settingService := setupTestDB(t) _ = settingService.SetDiscordBotEnable(true) From 2e41f908db9462c9111d2d2cefc7a252ee3f7884 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 13:22:26 +0100 Subject: [PATCH 104/125] fix(limit-ip): do not re-add a client the database stopped serving disconnectClientTemporarily removed an over-limit client from the core and re-added the snapshot the scan had loaded 100 ms later, so a disable or detach committed in between was undone: the traffic job's own RemoveUser had already found nothing, and the disabled account stayed live in Xray until the next restart. The re-add now rereads the client and its inbound and skips one that is disabled, expired, over quota or no longer attached. --- internal/web/job/check_client_ip_job.go | 32 ++++++ internal/web/job/limit_ip_disconnect_test.go | 114 +++++++++++++++++++ 2 files changed, 146 insertions(+) diff --git a/internal/web/job/check_client_ip_job.go b/internal/web/job/check_client_ip_job.go index 4773515f5..f9c39315a 100644 --- a/internal/web/job/check_client_ip_job.go +++ b/internal/web/job/check_client_ip_job.go @@ -820,11 +820,43 @@ func (j *CheckClientIpJob) disconnectClientTemporarily(inbound *model.Inbound, c // only widens the window in which new handshakes fail. time.Sleep(100 * time.Millisecond) + // clientConfig is the scan's snapshot: a disable or delete committed since then + // found no user to remove (or restarted the core) and will not come back for it. + if !j.clientStillServed(inbound.Id, clientEmail) { + logger.Infof("[LIMIT_IP] Not restoring %s: disabled or removed during its temporary disconnect", clientEmail) + return + } + // Re-add user to allow new connections err = xrayAPI.AddUser(protocol, inbound.Tag, clientConfig) if err != nil { logger.Warningf("[LIMIT_IP] Failed to re-add user %s: %v", clientEmail, err) + return } + + // Asked again: a commit can land between the check and the re-add. Writers touch + // the core only after they commit, so one this check misses removes the user itself. + if !j.clientStillServed(inbound.Id, clientEmail) { + logger.Infof("[LIMIT_IP] Removing %s again: disabled or removed during its temporary disconnect", clientEmail) + if err := xrayAPI.RemoveUser(inbound.Tag, clientEmail); err != nil { + logger.Warningf("[LIMIT_IP] Failed to remove user %s again: %v", clientEmail, err) + } + } +} + +// clientStillServed is GetXrayConfig's rule for handing a client to the core: linked to +// the enabled inbound, enabled, not depleted per client_traffics. A failed read is "no". +func (j *CheckClientIpJob) clientStillServed(inboundId int, clientEmail string) bool { + var served int64 + err := database.GetDB().Table("client_inbounds"). + Joins("JOIN clients ON clients.id = client_inbounds.client_id"). + Joins("JOIN inbounds ON inbounds.id = client_inbounds.inbound_id"). + Where("client_inbounds.inbound_id = ? AND inbounds.enable = ?", inboundId, true). + Where("clients.email = ? AND clients.enable = ?", clientEmail, true). + Where("NOT EXISTS (SELECT 1 FROM client_traffics WHERE client_traffics.email = clients.email AND client_traffics.enable = ?)", false). + Count(&served).Error + j.checkError(err) + return err == nil && served > 0 } // resolveXrayAPIPort returns the API inbound port from running config, then template config, then default. diff --git a/internal/web/job/limit_ip_disconnect_test.go b/internal/web/job/limit_ip_disconnect_test.go index f305fd652..e08243edc 100644 --- a/internal/web/job/limit_ip_disconnect_test.go +++ b/internal/web/job/limit_ip_disconnect_test.go @@ -1,11 +1,24 @@ package job import ( + "context" + "fmt" + "net" + "os" + "path/filepath" + "slices" "strings" + "sync" "testing" + "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/logger" + "github.com/mhsanaei/3x-ui/v3/internal/xray" + + "github.com/xtls/xray-core/app/proxyman/command" + "google.golang.org/grpc" + "gorm.io/gorm" ) // A reverse client has to be left alone: the cycle drops its reverse outbound @@ -73,3 +86,104 @@ func TestDisconnectClientTemporarilyAllowsHysteria(t *testing.T) { t.Fatal("expected a remove attempt against the Xray API for hysteria") } } + +// fakeCoreAPI stands in for the core's HandlerService: it records the user +// operations it is asked for and runs onOp inside each, where another job would act. +type fakeCoreAPI struct { + command.UnimplementedHandlerServiceServer + mu sync.Mutex + ops []string + onOp func(op string) +} + +func (f *fakeCoreAPI) AlterInbound(_ context.Context, req *command.AlterInboundRequest) (*command.AlterInboundResponse, error) { + operation, err := req.Operation.GetInstance() + if err != nil { + return nil, err + } + op := "add" + if _, ok := operation.(*command.RemoveUserOperation); ok { + op = "remove" + } + f.mu.Lock() + defer f.mu.Unlock() + f.ops = append(f.ops, op) + f.onOp(op) + return &command.AlterInboundResponse{}, nil +} + +func startFakeCoreAPI(t *testing.T, core *fakeCoreAPI) { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + server := grpc.NewServer() + command.RegisterHandlerServiceServer(server, core) + go func() { _ = server.Serve(listener) }() + t.Cleanup(server.Stop) + + binDir := t.TempDir() + t.Setenv("XUI_BIN_FOLDER", binDir) + config := fmt.Sprintf(`{"inbounds":[{"tag":"api","port":%d}]}`, listener.Addr().(*net.TCPAddr).Port) + if err := os.WriteFile(filepath.Join(binDir, "config.json"), []byte(config), 0o600); err != nil { + t.Fatal(err) + } +} + +// The re-add hands the core the scan's snapshot. Whatever took the client out of +// service since then has already been to the core, so the account must not stay. +func TestDisconnectClientTemporarilyDoesNotRestoreDroppedClient(t *testing.T) { + const email = "readd-probe" + trafficJobDisables := func(db *gorm.DB, _ *model.Inbound) error { + return db.Model(&xray.ClientTraffic{}).Where("email = ?", email).Update("enable", false).Error + } + tests := []struct { + name string + during string + drop func(db *gorm.DB, inbound *model.Inbound) error + wantOps []string + }{ + {name: "still served", wantOps: []string{"remove", "add"}}, + {name: "expired while removed", during: "remove", drop: trafficJobDisables, wantOps: []string{"remove"}}, + {name: "expired while being re-added", during: "add", drop: trafficJobDisables, wantOps: []string{"remove", "add", "remove"}}, + {name: "disabled by the operator", during: "remove", wantOps: []string{"remove"}, drop: func(db *gorm.DB, _ *model.Inbound) error { + return db.Model(&model.ClientRecord{}).Where("email = ?", email).Update("enable", false).Error + }}, + {name: "detached from the inbound", during: "remove", wantOps: []string{"remove"}, drop: func(db *gorm.DB, inbound *model.Inbound) error { + return db.Where("inbound_id = ?", inbound.Id).Delete(&model.ClientInbound{}).Error + }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + setupIntegrationDB(t) + db := database.GetDB() + inbound := seedLinkedInboundWithClient(t, "readd-probe-tag", email, 1) + if err := db.Model(&model.ClientRecord{}).Where("email = ?", email).Update("enable", true).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&xray.ClientTraffic{InboundId: inbound.Id, Email: email, Enable: true}).Error; err != nil { + t.Fatal(err) + } + + core := &fakeCoreAPI{} + core.onOp = func(op string) { + if op == tt.during { + if err := tt.drop(db, inbound); err != nil { + t.Error(err) + } + } + } + startFakeCoreAPI(t, core) + + clients := []model.Client{{Email: email, ID: "11111111-1111-1111-1111-111111111111", Enable: true}} + (&CheckClientIpJob{}).disconnectClientTemporarily(inbound, email, clients) + + core.mu.Lock() + defer core.mu.Unlock() + if !slices.Equal(core.ops, tt.wantOps) { + t.Fatalf("core saw %v, want %v", core.ops, tt.wantOps) + } + }) + } +} From 5a6d40e3ff76c83a006d39ef7a02373e66f9547a Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 13:26:10 +0100 Subject: [PATCH 105/125] fix(inbounds): keep disabled clients out of the core when adding an inbound The running core must only see clients the panel has enabled, and UpdateInbound and SetInboundEnable already build their payload through buildInboundForLocalRuntime, which strips disabled ones. AddInbound used it only for MTProto and TUIC and hot-added the stored row for every other protocol, so an imported or pushed inbound re-armed its disabled clients until the next restart. AddInbound now builds the runtime payload the same way for every protocol. --- internal/web/service/inbound.go | 17 +++--- .../service/inbound_update_hot_users_test.go | 53 +++++++++++++++++++ 2 files changed, 60 insertions(+), 10 deletions(-) diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go index a0296cb8f..50a875721 100644 --- a/internal/web/service/inbound.go +++ b/internal/web/service/inbound.go @@ -1574,17 +1574,14 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo return perr } if push { - payload := inbound - pushable := true - if inbound.Protocol == model.MTProto || inbound.Protocol == model.TUIC { - if built, bErr := s.buildInboundForLocalRuntime(tx, inbound); bErr == nil { - payload = built - } else { - logger.Debug("Unable to prepare runtime inbound config:", bErr) - pushable = false + // An imported or master-pushed row lists its disabled and depleted clients too. + payload, bErr := s.buildInboundForLocalRuntime(tx, inbound) + if bErr != nil { + logger.Debug("Unable to prepare runtime inbound config:", bErr) + if inbound.Protocol != model.MTProto && inbound.Protocol != model.TUIC { + needRestart = true } - } - if pushable { + } else { postCommitApply = func() { if err1 := rt.AddInbound(context.Background(), payload); err1 == nil { logger.Debug("New inbound added on", rt.Name(), ":", inbound.Tag) diff --git a/internal/web/service/inbound_update_hot_users_test.go b/internal/web/service/inbound_update_hot_users_test.go index 329f81480..ec26c284b 100644 --- a/internal/web/service/inbound_update_hot_users_test.go +++ b/internal/web/service/inbound_update_hot_users_test.go @@ -11,6 +11,7 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/xray" ) // inboundOpsProbe records every handler and user operation the panel sends the @@ -322,3 +323,55 @@ func TestNodeShadowsocksPushChangingOnlyClientsKeepsTheHandler(t *testing.T) { } } } + +// addInboundRecorder keeps the payload handed to the local runtime, so a test can +// check what the running core would register rather than what the DB stores. +type addInboundRecorder struct { + fakeNodeRuntime + added *model.Inbound +} + +func (r *addInboundRecorder) AddInbound(_ context.Context, ib *model.Inbound) error { + r.added = ib + return nil +} + +// An imported or master-pushed inbound arrives with its clients, switched-off +// ones included; only UpdateInbound/SetInboundEnable used to strip them. +func TestAddInboundKeepsDisabledClientsOutOfTheCore(t *testing.T) { + setupConflictDB(t) + mgr := useTestRuntimeManager(t) + local := &addInboundRecorder{} + mgr.SetLocalRuntimeOverride(local) + + svc := &InboundService{} + saved, needRestart, err := svc.AddInbound(&model.Inbound{ + UserId: 1, Enable: true, Port: 44490, Protocol: model.VLESS, + StreamSettings: `{"network":"tcp","security":"none"}`, + Settings: `{"decryption":"none","clients":[ + {"email":"paying","id":"5f2eb9d6-3a2f-4a55-9812-6ea1e2f7a001","enable":true}, + {"email":"banned","id":"5f2eb9d6-3a2f-4a55-9812-6ea1e2f7a002","enable":false}, + {"email":"depleted","id":"5f2eb9d6-3a2f-4a55-9812-6ea1e2f7a003","enable":true}]}`, + ClientStats: []xray.ClientTraffic{{Email: "depleted", Enable: false, Up: 10, Total: 10}}, + }) + if err != nil { + t.Fatalf("AddInbound: %v", err) + } + if needRestart || local.added == nil { + t.Fatalf("the inbound must be hot-added to the running core; needRestart=%v added=%v", needRestart, local.added) + } + served, err := svc.GetClients(local.added) + if err != nil { + t.Fatalf("GetClients(runtime payload): %v", err) + } + if len(served) != 1 || served[0].Email != "paying" { + t.Fatalf("the core must get only the enabled client, got %s", local.added.Settings) + } + stored, err := svc.GetClients(saved) + if err != nil { + t.Fatalf("GetClients(stored row): %v", err) + } + if len(stored) != 3 { + t.Fatalf("the stored row must keep every client, got %s", saved.Settings) + } +} From 3471cecbdaa41c8e68119cbfbf99c5e15c8fcce0 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 13:27:02 +0100 Subject: [PATCH 106/125] fix(mtproto): leave clients mtg cannot parse out of the generated config One mtg-multi process serves every client of an MTProto inbound from a single TOML file and refuses the whole file over one value it cannot parse. InstanceFromInbound already guarded the ad tag for that reason but copied secret, quota and expiry as stored, so a malformed secret, a year-10000 expiry or a near-MaxInt64 quota on one client took the sidecar down for everyone on the inbound. A client whose secret mtg cannot parse is left out with a warning, and quota and expiry are capped at what mtg accepts. --- internal/database/model/model.go | 11 ++++++ internal/database/model/model_mtproto_test.go | 14 +++++++ internal/mtproto/manager.go | 18 ++++++--- internal/mtproto/manager_test.go | 38 ++++++++++++++++++- 4 files changed, 75 insertions(+), 6 deletions(-) diff --git a/internal/database/model/model.go b/internal/database/model/model.go index 06120d9f9..302b0ef6a 100644 --- a/internal/database/model/model.go +++ b/internal/database/model/model.go @@ -4,6 +4,7 @@ package model import ( "bytes" "crypto/rand" + "encoding/base64" "encoding/hex" "encoding/json" "fmt" @@ -707,6 +708,16 @@ func ValidMtprotoAdTag(tag string) bool { return err == nil } +// ValidMtprotoSecret mirrors mtg's secret parser: hex, else unpadded URL-safe +// base64, of the 0xee FakeTLS marker, a 16-byte key and a non-empty host name. +func ValidMtprotoSecret(secret string) bool { + decoded, err := hex.DecodeString(secret) + if err != nil { + decoded, err = base64.RawURLEncoding.DecodeString(secret) + } + return err == nil && len(decoded) > 17 && decoded[0] == 0xee +} + // StripMtprotoInboundSecret removes the vestigial inbound-level `secret` from an // mtproto inbound's settings JSON. MTProto is multi-client: every secret lives on // a client, and mtg's [secrets] config plus every share link read only the diff --git a/internal/database/model/model_mtproto_test.go b/internal/database/model/model_mtproto_test.go index 674ee8373..b4031f683 100644 --- a/internal/database/model/model_mtproto_test.go +++ b/internal/database/model/model_mtproto_test.go @@ -25,6 +25,20 @@ func TestGenerateFakeTLSSecret(t *testing.T) { } } +func TestValidMtprotoSecret(t *testing.T) { + const good = "ee0123456789abcdef0123456789abcdef6578616d706c652e636f6d" + for _, s := range []string{good, strings.ToUpper(good), "ee0123456789abcdef0123456789abcdef61", "7gEjRWeJq83vASNFZ4mrze9leGFtcGxlLmNvbQ"} { + if !ValidMtprotoSecret(s) { + t.Fatalf("mtg parses %q, so it must be valid", s) + } + } + for _, s := range []string{"", "zz", "ee00", "dd0123456789abcdef0123456789abcdef6578616d706c652e636f6d", "ee0123456789abcdef0123456789abcdef", "7gEjRWeJq83vASNFZ4mrze9leGFtcGxlLmNvbQ==", "not a secret"} { + if ValidMtprotoSecret(s) { + t.Fatalf("mtg refuses %q, so it must be invalid", s) + } + } +} + func TestStripMtprotoInboundAdTag(t *testing.T) { in := `{"adTag":"0123456789abcdef0123456789abcdef","clients":[{"email":"a","adTag":"fedcba9876543210fedcba9876543210"}]}` out, changed := StripMtprotoInboundAdTag(in) diff --git a/internal/mtproto/manager.go b/internal/mtproto/manager.go index 44ace5622..fdd19127d 100644 --- a/internal/mtproto/manager.go +++ b/internal/mtproto/manager.go @@ -169,8 +169,9 @@ func GetManager() *Manager { // InstanceFromInbound derives a desired Instance from an mtproto inbound, // building one named secret per active client. Secrets are healed on save (see // normalizeMtprotoSecret) and by the migration, so they are read as-is here to -// keep the fingerprint stable across reconciles. Returns false when the inbound -// is not a usable mtproto inbound or has no active client secret to serve. +// keep the fingerprint stable across reconciles; a client whose stored values +// mtg could not parse is left out (see usableAdTag). Returns false when the +// inbound is not a usable mtproto inbound or has no active client secret to serve. func InstanceFromInbound(ib *model.Inbound) (Instance, bool) { if ib == nil || ib.Protocol != model.MTProto { return Instance{}, false @@ -205,15 +206,15 @@ func InstanceFromInbound(ib *model.Inbound) (Instance, bool) { } secrets := make([]SecretEntry, 0, len(parsed.Clients)) for _, c := range parsed.Clients { - if !c.Enable || c.Secret == "" || c.Email == "" { + if !c.Enable || c.Email == "" || !model.ValidMtprotoSecret(c.Secret) { continue } entry := SecretEntry{Name: c.Email, Secret: c.Secret, AdTag: usableAdTag(c.AdTag)} if c.TotalGB > 0 { - entry.QuotaBytes = c.TotalGB + entry.QuotaBytes = min(c.TotalGB, maxQuotaBytes) } if c.ExpiryTime > 0 { - entry.ExpiresUnix = c.ExpiryTime / 1000 + entry.ExpiresUnix = min(c.ExpiryTime/1000, maxExpiresUnix) } secrets = append(secrets, entry) } @@ -253,6 +254,13 @@ func usableAdTag(tag string) string { return tag } +// The largest limits mtg parses: its byte parser overflows just short of +// MaxInt64 and RFC 3339 ends with the year 9999. Larger stored values are capped. +const ( + maxQuotaBytes = int64(9_000_000_000_000_000_000) + maxExpiresUnix = int64(253402300799) +) + // Ensure starts the mtg process for an instance, or restarts it when its // configuration changed. A no-op when the desired process is already running. func (m *Manager) Ensure(inst Instance) error { diff --git a/internal/mtproto/manager_test.go b/internal/mtproto/manager_test.go index 079840507..412975aac 100644 --- a/internal/mtproto/manager_test.go +++ b/internal/mtproto/manager_test.go @@ -76,7 +76,7 @@ func TestInstanceFromInbound(t *testing.T) { t.Fatal("an inbound with no active secret should not produce an instance") } - badTags := &model.Inbound{Protocol: model.MTProto, Settings: `{"clients":[{"email":"x","secret":"ee00","adTag":"deadbeef","enable":true}]}`} + badTags := &model.Inbound{Protocol: model.MTProto, Settings: `{"clients":[{"email":"x","secret":"` + aliceSecret + `","adTag":"deadbeef","enable":true}]}`} badInst, ok := InstanceFromInbound(badTags) if !ok { t.Fatal("expected a usable instance despite a malformed ad tag") @@ -86,6 +86,42 @@ func TestInstanceFromInbound(t *testing.T) { } } +// mtg refuses its whole config over one secret, quota or expiry it cannot +// parse, which would take every client of the inbound down with the one holding it. +func TestInstanceFromInboundLeavesOutWhatMtgCannotParse(t *testing.T) { + const good = "ee0123456789abcdef0123456789abcdef6578616d706c652e636f6d" + inst, ok := InstanceFromInbound(&model.Inbound{Id: 1, Protocol: model.MTProto, Port: 443, Settings: `{"clients":[` + + `{"email":"hex","enable":true,"secret":"` + good + `","totalGB":1000,"expiryTime":4102444800000},` + + `{"email":"base64","enable":true,"secret":"7gEjRWeJq83vASNFZ4mrze9leGFtcGxlLmNvbQ"},` + + `{"email":"truncated","enable":true,"secret":"zz"},` + + `{"email":"not-faketls","enable":true,"secret":"dd0123456789abcdef0123456789abcdef6578616d706c652e636f6d"},` + + `{"email":"no-host","enable":true,"secret":"ee0123456789abcdef0123456789abcdef"},` + + `{"email":"year-10000","enable":true,"secret":"` + good + `","expiryTime":253402300800000},` + + `{"email":"huge-quota","enable":true,"secret":"` + good + `","totalGB":9223372036854775806}]}`}) + if !ok { + t.Fatal("expected a usable instance") + } + got := map[string]SecretEntry{} + for _, e := range inst.Secrets { + got[e.Name] = e + } + if len(got) != 4 { + t.Fatalf("only the clients mtg can parse should be served, got %+v", inst.Secrets) + } + if e := got["hex"]; e.Secret != good || e.QuotaBytes != 1000 || e.ExpiresUnix != 4102444800 { + t.Fatalf("parsable values must be kept as stored, got %+v", e) + } + if _, served := got["base64"]; !served { + t.Fatal("mtg parses a base64 secret, so its client must be served") + } + if e := got["year-10000"]; expiresString(e.ExpiresUnix) != "9999-12-31T23:59:59Z" { + t.Fatalf("an expiry past RFC 3339 must be capped, got %q", expiresString(e.ExpiresUnix)) + } + if e := got["huge-quota"]; e.QuotaBytes != 9_000_000_000_000_000_000 { + t.Fatalf("a quota mtg's parser overflows on must be capped, got %d", e.QuotaBytes) + } +} + func TestRenderConfig(t *testing.T) { // A bare instance emits only the required keys, api-bind-to, and the // [secrets] section, with no optional keys and no [domain-fronting]. From 8b525b5be621dc7773e55feee3e6afc7e52d8d5f Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 13:30:53 +0100 Subject: [PATCH 107/125] fix(node): clear a client's orphan mark when it is attached again A client with an inbound link is not an orphan, but the node sweep's mark was cleared only at the end of a node merge. An admin attach left a stale mark, and ReapSyncOrphans deleted the client at its next detach, traffic and IPs included, without the grace window. Inserting a link now clears the mark of the clients it links, and the reaper rereads its candidates inside its writer, so an attach that commits after the list was read is not reaped. --- internal/web/service/client_link.go | 12 +++++ internal/web/service/client_sync_orphan.go | 30 ++++++++---- .../web/service/client_sync_orphan_test.go | 46 +++++++++++++++++++ 3 files changed, 80 insertions(+), 8 deletions(-) diff --git a/internal/web/service/client_link.go b/internal/web/service/client_link.go index fe69c523b..141e84c3c 100644 --- a/internal/web/service/client_link.go +++ b/internal/web/service/client_link.go @@ -387,6 +387,18 @@ func (s *ClientService) reconcileInboundLinks(tx *gorm.DB, inboundId int, wanted }).CreateInBatches(toInsert, 200).Error; err != nil { return nil, err } + // Not left to the next node merge: there may never be one, and a mark that outlives the + // attach hands the client to the reaper on its next detach. + linked := make([]int, 0, len(toInsert)) + for _, link := range toInsert { + linked = append(linked, link.ClientId) + } + for _, batch := range chunkInts(linked, sqlInChunk) { + if err := tx.Model(&model.ClientRecord{}).Where("id IN ? AND sync_orphaned_at > 0", batch). + Updates(map[string]any{"sync_orphaned_at": 0, "sync_orphaned_by": 0}).Error; err != nil { + return nil, err + } + } } relinked := append([]int(nil), toDelete...) diff --git a/internal/web/service/client_sync_orphan.go b/internal/web/service/client_sync_orphan.go index 4aa8d4275..b1a9a17aa 100644 --- a/internal/web/service/client_sync_orphan.go +++ b/internal/web/service/client_sync_orphan.go @@ -12,7 +12,7 @@ import ( ) // How long a client stays recoverable after a node merge concluded it is gone. -// Any merge that sees it attached again inside this window clears the mark. +// Attaching it again inside this window, by a merge or an admin, clears the mark. const syncOrphanReapGrace = 15 * time.Minute // A traffic row with no clients row behind it is drift the orphan sweep will @@ -42,17 +42,20 @@ func clearSyncOrphanMarks(tx *gorm.DB) error { Updates(map[string]any{"sync_orphaned_at": 0, "sync_orphaned_by": 0}).Error } -// ReapSyncOrphans deletes the clients the node-snapshot sweep marked and no -// later merge reclaimed. It is the only path that hard-deletes for that sweep. +func reapableSyncOrphans(tx *gorm.DB, cutoff int64) *gorm.DB { + return tx.Model(&model.ClientRecord{}). + Where("sync_orphaned_at > 0 AND sync_orphaned_at <= ?", cutoff). + Where("NOT EXISTS (SELECT 1 FROM client_inbounds WHERE client_inbounds.client_id = clients.id)") +} + +// ReapSyncOrphans deletes the clients the node-snapshot sweep marked and nothing +// attached again since. It is the only path that hard-deletes for that sweep. func (s *ClientService) ReapSyncOrphans() (int, error) { db := database.GetDB() cutoff := time.Now().Add(-syncOrphanReapGrace).UnixMilli() var emails []string - if err := db.Model(&model.ClientRecord{}). - Where("sync_orphaned_at > 0 AND sync_orphaned_at <= ?", cutoff). - Where("NOT EXISTS (SELECT 1 FROM client_inbounds WHERE client_inbounds.client_id = clients.id)"). - Pluck("email", &emails).Error; err != nil { + if err := reapableSyncOrphans(db, cutoff).Pluck("email", &emails).Error; err != nil { return 0, err } if len(emails) == 0 { @@ -60,8 +63,16 @@ func (s *ClientService) ReapSyncOrphans() (int, error) { } reaped := 0 - for _, batch := range chunkStrings(emails, sqlInChunk) { + for _, listed := range chunkStrings(emails, sqlInChunk) { + var batch []string if err := runSerializedTx(func(tx *gorm.DB) error { + // The list was read outside the writer; an attach may have committed since. + if err := reapableSyncOrphans(tx, cutoff).Where("email IN ?", listed).Pluck("email", &batch).Error; err != nil { + return err + } + if len(batch) == 0 { + return nil + } if err := adjustGroupBaselinesForRemovedTraffic(tx, batch); err != nil { return err } @@ -78,6 +89,9 @@ func (s *ClientService) ReapSyncOrphans() (int, error) { }); err != nil { return reaped, err } + if len(batch) == 0 { + continue + } reaped += len(batch) logger.Infof("reaped %d client(s) confirmed removed on their node", len(batch)) } diff --git a/internal/web/service/client_sync_orphan_test.go b/internal/web/service/client_sync_orphan_test.go index 1a0a1b8ff..4d1eb3488 100644 --- a/internal/web/service/client_sync_orphan_test.go +++ b/internal/web/service/client_sync_orphan_test.go @@ -155,3 +155,49 @@ func TestReapSyncOrphansIgnoresUnmarkedOrphans(t *testing.T) { t.Fatalf("unmarked orphan was reaped: %d rows survive, want 1", surviving) } } + +// With its only node silent no merge runs, so an admin's attach has to clear the mark +// itself, or the client's next detach is reaped instead of kept for manual cleanup. +func TestAdminAttachClearsSyncOrphanMark(t *testing.T) { + db := initTrafficTestDB(t) + svc := &InboundService{} + clientSvc := &ClientService{} + + seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true}) + + const email = "rescued@x" + const keep = "keep@x" + createNodeInboundWithClient(t, db, 1, "n1-in", 41001, keep) + bothSettings := fmt.Sprintf(`{"clients":[{"email":%q,"enable":true},{"email":%q,"enable":true}]}`, keep, email) + syncNodeWithSettings(t, svc, 1, "n1-in", bothSettings, + xray.ClientTraffic{Email: keep, Enable: true}, + xray.ClientTraffic{Email: email, Enable: true}) + syncNodeWithSettings(t, svc, 1, "n1-in", fmt.Sprintf(`{"clients":[{"email":%q,"enable":true}]}`, keep), + xray.ClientTraffic{Email: keep, Enable: true}) + if readOrphanMark(t, db, email) <= 0 { + t.Fatal("setup: expected the merge to mark the client") + } + backdateOrphanMark(t, db, email) + + master := &model.Inbound{UserId: 1, Tag: "master-in", Enable: true, Port: 40001, Protocol: model.VLESS, Settings: `{"clients":[]}`} + if err := db.Create(master).Error; err != nil { + t.Fatalf("create master inbound: %v", err) + } + if err := clientSvc.ApplyInboundClientDelta(db, master.Id, []model.Client{{Email: email, Enable: true}}, nil); err != nil { + t.Fatalf("attach: %v", err) + } + if at := readOrphanMark(t, db, email); at != 0 { + t.Fatalf("attached client kept its orphan mark: sync_orphaned_at=%d", at) + } + + if err := clientSvc.ApplyInboundClientDelta(db, master.Id, nil, []string{email}); err != nil { + t.Fatalf("detach: %v", err) + } + reaped, err := clientSvc.ReapSyncOrphans() + if err != nil { + t.Fatalf("reap: %v", err) + } + if rec, _ := countClientRows(t, db, email); reaped != 0 || rec != 1 { + t.Fatalf("admin-detached client was reaped: reaped=%d clients=%d, want 0/1", reaped, rec) + } +} From 6c74fccbf797a6df850ed146b6ca199a415c44bc Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 13:32:21 +0100 Subject: [PATCH 108/125] fix(amneziawg): only delete IPv6 aliases this panel added itself Teardown and the per-peer diff built the list of IPv6 aliases to delete from configuration alone, while adding an alias treated "already exists" as success. An address the host already owned, ::1 on lo or its own public /128, was therefore deleted when an inbound or peer that named it went away. The manager records the aliases whose add actually created them and deletes only those; an address that was already there is left in place. --- internal/amneziawgnet/manager.go | 15 +++--- internal/amneziawgnet/v6alias.go | 33 +++++++++---- internal/amneziawgnet/v6alias_test.go | 68 +++++++++++++++++++++++++-- 3 files changed, 95 insertions(+), 21 deletions(-) diff --git a/internal/amneziawgnet/manager.go b/internal/amneziawgnet/manager.go index 30187bd88..eeef3cf21 100644 --- a/internal/amneziawgnet/manager.go +++ b/internal/amneziawgnet/manager.go @@ -116,6 +116,9 @@ func (m *managed) close() { type Manager struct { mu sync.Mutex ifaces map[int]*managed + // v6Owned: IPv6 aliases this Manager put on the host, the only ones it + // may delete again (see applyV6Aliases). + v6Owned map[v6Alias]struct{} } var ( @@ -189,7 +192,7 @@ func (m *Manager) ensureLocked(d Desired) error { if conf == cur.uapiConfig { cur.peers.Store(NewPeerIndex(inst.Peers)) cur.inst = inst - applyV6Aliases(diffV6Aliases(oldInst, inst)) + m.applyV6Aliases(diffV6Aliases(oldInst, inst)) // buildUAPIConfig never reads ForwardedPorts (it's a panel-level // concept, not a WireGuard UAPI field), so a ForwardedPorts-only // edit renders byte-identical here and takes this exact no-op @@ -206,7 +209,7 @@ func (m *Manager) ensureLocked(d Desired) error { oldPeers := cur.peers.Swap(NewPeerIndex(inst.Peers)) cur.inst = inst cur.uapiConfig = conf - applyV6Aliases(diffV6Aliases(oldInst, inst)) + m.applyV6Aliases(diffV6Aliases(oldInst, inst)) cur.portForwards.Reconcile(inst) cur.dropReassignedFlows(oldPeers) return nil @@ -262,7 +265,7 @@ func (m *Manager) ensureLocked(d Desired) error { conf, _ := buildUAPIConfig(inst, opts) next.uapiConfig = conf m.ifaces[inst.Id] = next - applyV6Aliases(diffV6Aliases(oldInst, inst)) + m.applyV6Aliases(diffV6Aliases(oldInst, inst)) portForwards.Reconcile(inst) logger.Infof("amneziawgnet: started embedded interface %s for inbound %d", inst.InterfaceName, inst.Id) return nil @@ -302,7 +305,7 @@ func (m *Manager) Reconcile(desired []Desired) { if _, ok := want[id]; ok { continue } - applyV6Aliases(diffV6Aliases(cur.inst, amneziawg.Instance{})) + m.applyV6Aliases(diffV6Aliases(cur.inst, amneziawg.Instance{})) cur.close() delete(m.ifaces, id) logger.Infof("amneziawgnet: stopped embedded interface for removed inbound %d", id) @@ -325,7 +328,7 @@ func (m *Manager) Remove(id int) { if !exists { return } - applyV6Aliases(diffV6Aliases(cur.inst, amneziawg.Instance{})) + m.applyV6Aliases(diffV6Aliases(cur.inst, amneziawg.Instance{})) cur.close() delete(m.ifaces, id) logger.Infof("amneziawgnet: stopped embedded interface for removed inbound %d", id) @@ -336,7 +339,7 @@ func (m *Manager) StopAll() { m.mu.Lock() defer m.mu.Unlock() for id, cur := range m.ifaces { - applyV6Aliases(diffV6Aliases(cur.inst, amneziawg.Instance{})) + m.applyV6Aliases(diffV6Aliases(cur.inst, amneziawg.Instance{})) cur.close() delete(m.ifaces, id) } diff --git a/internal/amneziawgnet/v6alias.go b/internal/amneziawgnet/v6alias.go index b48f89d20..7c01faa8c 100644 --- a/internal/amneziawgnet/v6alias.go +++ b/internal/amneziawgnet/v6alias.go @@ -117,16 +117,30 @@ const ipCommandTimeout = 3 * time.Second // aliased at once is harmless). Never surfaces an error — an alias failing // only narrows that one peer's own outbound-source-identity feature, never // a reason to fail the tunnel or its SOCKS5 relay. -func applyV6Aliases(add, remove []v6Alias) { - for _, a := range add { - addV6Alias(a) +func (m *Manager) applyV6Aliases(add, remove []v6Alias) { + if m.v6Owned == nil { + m.v6Owned = map[v6Alias]struct{}{} } + for _, a := range add { + if addV6Alias(a) { + m.v6Owned[a] = struct{}{} + } + } + // A peer may name an address the host already owns (::1 on lo, its own + // public /128): only what this Manager itself added is ever deleted. for _, a := range remove { + if _, ours := m.v6Owned[a]; !ours { + logger.Debugf("amneziawgnet: leaving IPv6 address %s on %s, not aliased by this process", a.Addr, a.Iface) + continue + } + delete(m.v6Owned, a) removeV6Alias(a) } } -func addV6Alias(a v6Alias) { +// addV6Alias reports whether the alias is on the host because of this call; +// an address that was already there is the host's own, not ours to remove. +func addV6Alias(a v6Alias) bool { ctx, cancel := context.WithTimeout(context.Background(), ipCommandTimeout) defer cancel() // nodad: this address is a specific peer's own admin-assigned identity, @@ -136,16 +150,15 @@ func addV6Alias(a v6Alias) { stderr, err := runIP(ctx, "-6", "addr", "add", a.Addr+"/128", "dev", a.Iface, "nodad") if err == nil { logger.Infof("amneziawgnet: aliased IPv6 address %s onto %s", a.Addr, a.Iface) - return + return true } if strings.Contains(stderr, "File exists") { - // Already the desired end state -- most commonly hit once, harmlessly, - // right after an ungraceful panel restart (the OS-level alias from - // before the crash outlives the process; the in-memory managed map - // doesn't). - return + // Already there: the host's own address, or an alias an ungraceful + // restart left behind -- indistinguishable, so neither is ours. + return false } logger.Warningf("amneziawgnet: alias IPv6 address %s onto %s: %v (%s)", a.Addr, a.Iface, err, strings.TrimSpace(stderr)) + return false } func removeV6Alias(a v6Alias) { diff --git a/internal/amneziawgnet/v6alias_test.go b/internal/amneziawgnet/v6alias_test.go index 8f6ea17a0..7b4dce784 100644 --- a/internal/amneziawgnet/v6alias_test.go +++ b/internal/amneziawgnet/v6alias_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/mhsanaei/3x-ui/v3/internal/amneziawg" + "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard" ) func peerWithIPs(email string, ips ...string) amneziawg.Peer { @@ -264,14 +265,71 @@ func TestApplyV6AliasesAddsBeforeRemoves(t *testing.T) { } return "", nil }) - applyV6Aliases( + m := &Manager{ifaces: map[int]*managed{}} + m.applyV6Aliases([]v6Alias{{Addr: "fd86::2", Iface: "eth0"}}, nil) + m.applyV6Aliases( []v6Alias{{Addr: "fd86::99", Iface: "eth0"}}, []v6Alias{{Addr: "fd86::2", Iface: "eth0"}}, ) - if len(*calls) != 2 { - t.Fatalf("expected exactly 2 calls, got %d", len(*calls)) + if len(*calls) != 3 { + t.Fatalf("expected exactly 3 calls, got %d", len(*calls)) } - if order[0] != "add" || order[1] != "del" { - t.Fatalf("expected add before del, got order=%v", order) + if order[1] != "add" || order[2] != "del" { + t.Fatalf("expected add before del, got order=%v", order[1:]) + } +} + +// An address the host already held (::1 on lo, its own /128) answers the add +// with EEXIST; tearing the inbound down must leave it in place. +func TestManagerRemoveLeavesHostOwnedAddressInPlace(t *testing.T) { + calls := withFakeRunIP(t, func(ctx context.Context, args ...string) (string, error) { + if args[2] == "add" && args[3] == "::1/128" { + return "RTNETLINK answers: File exists", errors.New("exit status 2") + } + return "", nil + }) + priv, pub, err := wireguard.GenerateWireguardKeypair() + if err != nil { + t.Fatalf("generate keypair: %v", err) + } + _, foreignPub, err := wireguard.GenerateWireguardKeypair() + if err != nil { + t.Fatalf("generate peer keypair: %v", err) + } + _, hostOwnPub, err := wireguard.GenerateWireguardKeypair() + if err != nil { + t.Fatalf("generate peer keypair: %v", err) + } + m := &Manager{ifaces: map[int]*managed{}} + defer m.StopAll() + inst := amneziawg.Instance{ + Id: 7, + InterfaceName: "awgtest7", + ListenPort: 58717, + PrivateKey: priv, + PublicKey: pub, + Address: []string{"10.207.0.1/24", "fd86:ea04:1115::1/64"}, + MTU: 1420, + Obfuscation: amneziawg.Obfuscation31{Jc: 4, Jmin: 40, Jmax: 70, S1: 20, S2: 30, S3: 20, S4: 20}, + IPv6Enabled: true, + IPv6ExternalInterface: "lo", + Peers: []amneziawg.Peer{ + {Email: "foreign", PublicKey: foreignPub, AllowedIPs: []string{"10.207.0.2/32", "2001:db8:beef::1/128"}}, + {Email: "host-own", PublicKey: hostOwnPub, AllowedIPs: []string{"10.207.0.3/32", "::1/128"}}, + }, + } + if err := m.Ensure(Desired{Instance: inst}); err != nil { + t.Fatalf("Ensure: %v", err) + } + m.Remove(inst.Id) + + var deleted []string + for _, c := range *calls { + if c[2] == "del" { + deleted = append(deleted, c[3]) + } + } + if len(deleted) != 1 || deleted[0] != "2001:db8:beef::1/128" { + t.Fatalf("deleted %v, want only the alias this process added (2001:db8:beef::1/128)", deleted) } } From 72fb4d93582bb3cac222c4a21819822a47b04ca3 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 13:34:21 +0100 Subject: [PATCH 109/125] fix(frontend): keep node-reported text out of i18next's nesting parser The SPA initialised i18next with nesting on, though no locale uses $t(...), so its nesting regex, exponential on an unclosed "$t(", ran over every missing key and interpolated value, including the status and name a node reports for its sub-nodes. One crafted status froze the Nodes page. Nesting is switched off for every i18next instance the project creates, an unknown status renders as Unknown instead of being used as a key, and a sub-node is never named as the parent it is reached through. --- frontend/.storybook/preview.tsx | 3 +- frontend/src/i18n/react.ts | 13 +++- frontend/src/pages/nodes/NodeList.tsx | 15 +++- .../test/node-list-untrusted-i18n.test.tsx | 77 +++++++++++++++++++ frontend/src/test/setup.components.ts | 3 +- 5 files changed, 104 insertions(+), 7 deletions(-) create mode 100644 frontend/src/test/node-list-untrusted-i18n.test.tsx diff --git a/frontend/.storybook/preview.tsx b/frontend/.storybook/preview.tsx index 93b3846a6..3ee3f563a 100644 --- a/frontend/.storybook/preview.tsx +++ b/frontend/.storybook/preview.tsx @@ -5,6 +5,7 @@ import i18next from 'i18next'; import { initReactI18next } from 'react-i18next'; import { buildAntdThemeConfig } from '@/hooks/useTheme'; +import { INTERPOLATION } from '@/i18n/react'; import enUS from '../../internal/web/translation/en-US.json'; if (!i18next.isInitialized) { @@ -12,7 +13,7 @@ if (!i18next.isInitialized) { lng: 'en-US', fallbackLng: 'en-US', resources: { 'en-US': { translation: enUS } }, - interpolation: { escapeValue: false, prefix: '{', suffix: '}' }, + interpolation: INTERPOLATION, returnNull: false, }); } diff --git a/frontend/src/i18n/react.ts b/frontend/src/i18n/react.ts index 4a6f7a7e7..7ca9d78d0 100644 --- a/frontend/src/i18n/react.ts +++ b/frontend/src/i18n/react.ts @@ -1,4 +1,5 @@ import i18next from 'i18next'; +import type { InterpolationOptions } from 'i18next'; import { initReactI18next } from 'react-i18next'; import { LanguageManager } from '@/utils'; @@ -8,6 +9,16 @@ import enUS from '../../../internal/web/translation/en-US.json'; const FALLBACK = 'en-US'; +// No locale uses $t(...) nesting, and its regex backtracks exponentially on an unclosed +// "$t(" in untrusted text (node-reported names, remarks); no off switch, so never match. +export const INTERPOLATION: InterpolationOptions = { + escapeValue: false, + prefix: '{', + suffix: '}', + nestingPrefix: '', + nestingPrefixEscaped: '(?!)', +}; + const lazyModules = import.meta.glob([ '../../../internal/web/translation/*.json', '!../../../internal/web/translation/en-US.json', @@ -30,7 +41,7 @@ export async function readyI18n(scope: LanguageScope = 'panel') { lng: active, fallbackLng: FALLBACK, resources: { [FALLBACK]: { translation: enUS } }, - interpolation: { escapeValue: false, prefix: '{', suffix: '}' }, + interpolation: INTERPOLATION, returnNull: false, }); if (active !== FALLBACK) { diff --git a/frontend/src/pages/nodes/NodeList.tsx b/frontend/src/pages/nodes/NodeList.tsx index b8cf089a3..467dc704c 100644 --- a/frontend/src/pages/nodes/NodeList.tsx +++ b/frontend/src/pages/nodes/NodeList.tsx @@ -127,7 +127,14 @@ function StatusLabel({ status, xrayState }: HealthProps) { ); } - return {t(`pages.nodes.statusValues.${status || 'unknown'}`)}; + // A sub-node's status is free text from its parent node: show it as unknown, not as a key. + return ( + + {t(`pages.nodes.statusValues.${status || 'unknown'}`, { + defaultValue: t('pages.nodes.statusValues.unknown'), + })} + + ); } function formatPct(p?: number, available = true): string { @@ -188,11 +195,11 @@ export default function NodeList({ const [statsNode, setStatsNode] = useState(null); const [expandedIds, setExpandedIds] = useState>(new Set()); - // Map a node GUID to its display name so a transitive sub-node can show which - // parent it is reached through (#4983). + // Map a direct node's GUID to its display name so a transitive sub-node can show + // which parent it is reached through (#4983); the master walks one level only. const nameByGuid = useMemo(() => { const m = new Map(); - for (const n of nodes) if (n.guid) m.set(n.guid, n.name || n.guid); + for (const n of nodes) if (n.guid && !n.transitive) m.set(n.guid, n.name || n.guid); return m; }, [nodes]); diff --git a/frontend/src/test/node-list-untrusted-i18n.test.tsx b/frontend/src/test/node-list-untrusted-i18n.test.tsx new file mode 100644 index 000000000..ff98835e1 --- /dev/null +++ b/frontend/src/test/node-list-untrusted-i18n.test.tsx @@ -0,0 +1,77 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { screen } from '@testing-library/react'; +import i18next from 'i18next'; + +import NodeList from '@/pages/nodes/NodeList'; +import type { NodeRecord } from '@/schemas/node'; + +import { renderWithProviders } from './test-utils'; + +const noop = () => {}; + +// A closed reference: an unclosed "$t(" would hang a regressed run instead of failing it. +const reported = '$t(pages.nodes.subNode)'; + +function renderSubNode(fields: Partial) { + const nodes: NodeRecord[] = [ + { id: 1, name: 'parent', guid: 'p1', enable: true, status: 'online' }, + // parentGuid is its own GUID: the sub-node tries to put its name into the parent tooltip. + { id: 0, name: 'child', guid: 'c1', parentGuid: 'c1', transitive: true, ...fields }, + ]; + renderWithProviders( + , + ); +} + +describe('NodeList sub-node rows', () => { + afterEach(() => vi.restoreAllMocks()); + + it('shows a node-reported status it does not know as Unknown, not as an i18next key', () => { + renderSubNode({ status: reported }); + + const row = screen.getByText('child').closest('tr') as HTMLElement; + expect(row.textContent).toContain('Unknown'); + expect(row.textContent).not.toContain('statusValues'); + }); + + it('never names a sub-node as the parent it is reached through', () => { + const translate = vi.spyOn(i18next, 't'); + renderSubNode({ name: reported, status: 'online' }); + + expect(translate).toHaveBeenCalledWith( + 'pages.nodes.subNodeTip', + expect.objectContaining({ parent: '-' }), + ); + expect(JSON.stringify(translate.mock.calls)).not.toContain(reported); + }); +}); + +describe('i18next nesting', () => { + afterEach(() => vi.restoreAllMocks()); + + // Closed references only: an unclosed "$t(" would hang a regressed run instead of failing it. + it('is off, so "$t(...)" in a missing key or an interpolated value stays plain text', async () => { + const utils = await import('@/utils'); + vi.spyOn(utils.LanguageManager, 'getLanguage').mockReturnValue('en-US'); + const { readyI18n } = await import('@/i18n/react'); + const i18n = await readyI18n(); + + expect(i18n.t('$t(pages.nodes.subNode)')).toBe('$t(pages.nodes.subNode)'); + expect(i18n.t('pages.nodes.subNodeTip', { parent: '$t(pages.nodes.subNode)' })).toBe( + "Read-only: a downstream node reached through $t(pages.nodes.subNode). Manage it from $t(pages.nodes.subNode)'s own panel.", + ); + }); +}); diff --git a/frontend/src/test/setup.components.ts b/frontend/src/test/setup.components.ts index 931901984..acb7cdf15 100644 --- a/frontend/src/test/setup.components.ts +++ b/frontend/src/test/setup.components.ts @@ -3,6 +3,7 @@ import { cleanup } from '@testing-library/react'; import i18next from 'i18next'; import { initReactI18next } from 'react-i18next'; +import { INTERPOLATION } from '@/i18n/react'; import enUS from '../../../internal/web/translation/en-US.json'; // RTL sets this from a global beforeAll, which never runs with `globals: false`. @@ -75,7 +76,7 @@ if (!i18next.isInitialized) { lng: 'en-US', fallbackLng: 'en-US', resources: { 'en-US': { translation: enUS } }, - interpolation: { escapeValue: false, prefix: '{', suffix: '}' }, + interpolation: INTERPOLATION, returnNull: false, }); } From d63583caafbe00cb1b64db376821df4251b15373 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 13:36:37 +0100 Subject: [PATCH 110/125] fix(tgbot): keep a login alert's username on one line The Telegram login alert is a list of "label: value" lines, and the username, whatever the visitor typed on the login form, was inserted with its line breaks intact. Text after a break read as the panel's own IP and time lines, letting a failed login forge the alert. Control characters and Unicode line separators in the username become spaces, in the failed and the successful alert alike. --- internal/web/service/tgbot/tgbot_event.go | 16 ++++++++-- ...es_test.go => tgbot_reports_usage_test.go} | 31 +++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) rename internal/web/service/tgbot/{tgbot_review_fixes_test.go => tgbot_reports_usage_test.go} (87%) diff --git a/internal/web/service/tgbot/tgbot_event.go b/internal/web/service/tgbot/tgbot_event.go index f3acc6c28..9046645df 100644 --- a/internal/web/service/tgbot/tgbot_event.go +++ b/internal/web/service/tgbot/tgbot_event.go @@ -5,6 +5,7 @@ import ( "os" "strconv" "strings" + "unicode" "github.com/mhsanaei/3x-ui/v3/internal/eventbus" ) @@ -64,6 +65,17 @@ func (t *Tgbot) isEventEnabled(eventType eventbus.EventType) bool { return false } +// oneLine keeps a value the panel did not write itself on its own line of an +// alert: control characters and Unicode line separators become spaces. +func oneLine(s string) string { + return strings.Map(func(r rune) rune { + if unicode.IsControl(r) || r == '
' || r == '
' { + return ' ' + } + return r + }, s) +} + func (t *Tgbot) formatEventMessage(e eventbus.Event) string { host := getHostname() header := fmt.Sprintf("📡 %s\n", host) @@ -147,7 +159,7 @@ func (t *Tgbot) formatEventMessage(e eventbus.Event) string { if data.Status == "success" { msg := t.I18nBot("tgbot.messages.loginSuccess") msg += t.I18nBot("tgbot.messages.hostname", "Hostname=="+host) - msg += t.I18nBot("tgbot.messages.username", "Username=="+data.Username) + msg += t.I18nBot("tgbot.messages.username", "Username=="+oneLine(data.Username)) msg += t.I18nBot("tgbot.messages.ip", "IP=="+data.IP) msg += t.I18nBot("tgbot.messages.time", "Time=="+data.Time) return msg @@ -157,7 +169,7 @@ func (t *Tgbot) formatEventMessage(e eventbus.Event) string { if data.Reason != "" { msg += t.I18nBot("tgbot.messages.reason", "Reason=="+data.Reason) } - msg += t.I18nBot("tgbot.messages.username", "Username=="+data.Username) + msg += t.I18nBot("tgbot.messages.username", "Username=="+oneLine(data.Username)) msg += t.I18nBot("tgbot.messages.ip", "IP=="+data.IP) msg += t.I18nBot("tgbot.messages.time", "Time=="+data.Time) return msg diff --git a/internal/web/service/tgbot/tgbot_review_fixes_test.go b/internal/web/service/tgbot/tgbot_reports_usage_test.go similarity index 87% rename from internal/web/service/tgbot/tgbot_review_fixes_test.go rename to internal/web/service/tgbot/tgbot_reports_usage_test.go index 75dfe3fce..e30cae594 100644 --- a/internal/web/service/tgbot/tgbot_review_fixes_test.go +++ b/internal/web/service/tgbot/tgbot_reports_usage_test.go @@ -15,6 +15,7 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/xray" "github.com/mymmrac/telego" + "github.com/nicksnyder/go-i18n/v2/i18n" "gorm.io/gorm" ) @@ -334,3 +335,33 @@ func TestClientUsageDoesNotLoadTheInboundPerClient(t *testing.T) { t.Fatalf("the inbound row was loaded %d times for %d clients", wholeRows, owned) } } + +// The login alert is one field per line and the username is +// whatever the visitor typed, so a line break in it must not start a new line. +func TestLoginAlertKeepsUsernameOnOneLine(t *testing.T) { + draftLocalizer(t, + &i18n.Message{ID: "tgbot.messages.loginSuccess", Other: "✅ Logged in to the panel successfully.\r\n"}, + &i18n.Message{ID: "tgbot.messages.loginFailed", Other: "❗️Login attempt to the panel failed.\r\n"}, + &i18n.Message{ID: "tgbot.messages.hostname", Other: "💻 Host: {{ .Hostname }}\r\n"}, + &i18n.Message{ID: "tgbot.messages.reason", Other: "❗️ Reason: {{ .Reason }}\r\n"}, + &i18n.Message{ID: "tgbot.messages.username", Other: "👤 Username: {{ .Username }}\r\n"}, + &i18n.Message{ID: "tgbot.messages.ip", Other: "🌐 IP: {{ .IP }}\r\n"}, + &i18n.Message{ID: "tgbot.messages.time", Other: "⏰ Time: {{ .Time }}\r\n"}, + ) + + for _, lineBreak := range []string{"\r\n", "\n", "\r", "\v", "\f", "\u0085", "\u2028", "\u2029", "\r\n\r\n"} { + username := "mallory" + lineBreak + "🌐 IP: 203.0.113.77" + for _, status := range []string{"fail", "success"} { + got := (&Tgbot{}).formatEventMessage(eventbus.Event{ + Type: eventbus.EventLoginAttempt, + Data: &eventbus.LoginEventData{ + Username: username, IP: "192.0.2.1", Time: "now", Status: status, Reason: "invalid credentials", + }, + }) + want := "👤 Username: mallory" + strings.Repeat(" ", len([]rune(lineBreak))) + "🌐 IP: 203.0.113.77\r\n🌐 IP: 192.0.2.1\r\n" + if !strings.Contains(got, want) { + t.Errorf("login %s, line break %q: message = %q, want it to contain %q", status, lineBreak, got, want) + } + } + } +} From e02f51320a5432de9521127d322ae31016784b4e Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 13:38:58 +0100 Subject: [PATCH 111/125] fix(tgbot): release the invite claim lock even when a claim panics claimInvite took the package-level inviteClaimMu and released it without defer, inside a handler whose panics the bot recovers. A panic while resolving or binding a token left the lock held for good, and every later /start claim parked on a worker slot until the bot answered nobody. The lock is now held in its own function with a deferred unlock. --- internal/web/service/tgbot/tgbot_invite.go | 20 ++++++++++++------- .../web/service/tgbot/tgbot_invite_test.go | 15 ++++++++++++++ 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/internal/web/service/tgbot/tgbot_invite.go b/internal/web/service/tgbot/tgbot_invite.go index ac1ff8474..29966f6f0 100644 --- a/internal/web/service/tgbot/tgbot_invite.go +++ b/internal/web/service/tgbot/tgbot_invite.go @@ -71,13 +71,7 @@ func (t *Tgbot) claimInvite(chatId int64, fromID int64, payload string) inviteOu return inviteInvalid } - inviteClaimMu.Lock() - outcome, records := t.resolveInviteToken(token, fromID) - var bindErr error - if outcome == inviteBindable { - bindErr = t.bindRecordsToUser(records, fromID) - } - inviteClaimMu.Unlock() + outcome, records, bindErr := t.resolveAndBindInvite(token, fromID) switch outcome { case inviteAlreadyOwned: @@ -97,6 +91,18 @@ func (t *Tgbot) claimInvite(chatId int64, fromID int64, payload string) inviteOu return outcome } +// resolveAndBindInvite holds inviteClaimMu with a deferred unlock: the handler recovers panics, +// and a lock a panic left held would park every later /start claim for good. +func (t *Tgbot) resolveAndBindInvite(token string, fromID int64) (inviteOutcome, []*model.ClientRecord, error) { + inviteClaimMu.Lock() + defer inviteClaimMu.Unlock() + outcome, records := t.resolveInviteToken(token, fromID) + if outcome != inviteBindable { + return outcome, records, nil + } + return outcome, records, t.bindRecordsToUser(records, fromID) +} + func recordEmails(records []*model.ClientRecord) string { emails := make([]string, 0, len(records)) for _, record := range records { diff --git a/internal/web/service/tgbot/tgbot_invite_test.go b/internal/web/service/tgbot/tgbot_invite_test.go index 1207243e3..936e91c65 100644 --- a/internal/web/service/tgbot/tgbot_invite_test.go +++ b/internal/web/service/tgbot/tgbot_invite_test.go @@ -1,6 +1,7 @@ package tgbot import ( + "encoding/base64" "strings" "sync" "testing" @@ -222,3 +223,17 @@ func TestTgUserMentionEscapesName(t *testing.T) { t.Errorf("tgUserMention = %q, want %q", got, want) } } + +// The bot's handlers recover panics, so a claim that panicked with inviteClaimMu held left +// it locked for good and every later /start claim parked on a worker slot. +func TestInviteClaimReleasesTheLockWhenItPanics(t *testing.T) { + func() { + defer func() { _ = recover() }() + // No database is open here, so resolving the token panics inside the lock. + (&Tgbot{}).claimInvite(1, 1, base64.RawURLEncoding.EncodeToString([]byte("some-sub-id"))) + }() + if !inviteClaimMu.TryLock() { + t.Fatal("inviteClaimMu is still held after a claim panicked") + } + inviteClaimMu.Unlock() +} From 54339661a62d9c77acaa1558e5abb3ae03992045 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 13:42:12 +0100 Subject: [PATCH 112/125] fix(server): keep the access log view when one line is over 64 KiB GetXrayLogs read access.log with a default bufio.Scanner, whose token limit is 64 KiB, and returned nothing at all when it stopped with ErrTooLong. Xray logs an HTTP request's URL whole, so one proxied request with a long URL blanked the Access Logs view for every client. Lines are now read with a bounded reader: a line that fits is kept whole, and a longer one keeps its first 64 KiB and its last 1 KiB, where the route and email are, so it still shows as a correctly attributed row. --- internal/web/service/access_log_parse_test.go | 60 +++++++++++++++++++ internal/web/service/server.go | 48 ++++++++++++--- 2 files changed, 101 insertions(+), 7 deletions(-) diff --git a/internal/web/service/access_log_parse_test.go b/internal/web/service/access_log_parse_test.go index 15830d75a..623393db1 100644 --- a/internal/web/service/access_log_parse_test.go +++ b/internal/web/service/access_log_parse_test.go @@ -2,7 +2,9 @@ package service import ( "bufio" + "errors" "fmt" + "io" "os" "path/filepath" "runtime" @@ -148,3 +150,61 @@ func TestGetXrayLogsMemoryDoesNotGrowWithTheFile(t *testing.T) { } } } + +// One proxied request with a URL past 64 KiB stopped the scanner and blanked the whole view. +func TestGetXrayLogsSurvivesOverlongLine(t *testing.T) { + dir := t.TempDir() + t.Setenv("XUI_BIN_FOLDER", dir) + accessLog := filepath.Join(dir, "access.log") + if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(`{"log":{"access":"`+filepath.ToSlash(accessLog)+`"}}`), 0o600); err != nil { + t.Fatal(err) + } + lines := []string{ + "2024/01/02 15:04:05.000000 from 1.2.3.4:1 accepted tcp:one.example:443 [in-1 >> direct] email: alice", + "2024/01/02 15:04:06.000000 from 1.2.3.4:2 accepted http://two.example/?" + strings.Repeat("a", 300000) + " [in-2 >> blocked] email: mallory", + "2024/01/02 15:04:07.000000 from 1.2.3.4:3 accepted tcp:three.example:443 [in-1 >> direct] email: alice", + } + if err := os.WriteFile(accessLog, []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil { + t.Fatal(err) + } + + get := func(filter string) []LogEntry { + return (&ServerService{}).GetXrayLogs("100", filter, "true", "true", "true", []string{"direct"}, []string{"blocked"}) + } + entries := get("") + if len(entries) != len(lines) { + t.Fatalf("got %d entries for %d log lines", len(entries), len(lines)) + } + if entries[0].ToAddress != "tcp:one.example:443" || entries[2].ToAddress != "tcp:three.example:443" { + t.Errorf("neighbours of the long line were not parsed: %q, %q", entries[0].ToAddress, entries[2].ToAddress) + } + + long := entries[1] + if !strings.HasPrefix(long.ToAddress, "http://two.example/?a") || len(long.ToAddress) > maxAccessLogLineBytes+accessLogLineEndBytes { + t.Errorf("long ToAddress: %d bytes starting %.30q", len(long.ToAddress), long.ToAddress) + } + if long.FromAddress != "1.2.3.4:2" || long.Inbound != "in-2" || long.Outbound != "blocked" || long.Email != "mallory" || long.Event != 1 { + t.Errorf("long line lost its attribution: from %q, [%q >> %q], email %q, event %d", long.FromAddress, long.Inbound, long.Outbound, long.Email, long.Event) + } + if got := get("mallory"); len(got) != 1 { + t.Errorf("filter by the long line's email matched %d entries, want 1", len(got)) + } +} + +func TestReadAccessLogLine(t *testing.T) { + const size, end = 64, accessLogLineEndBytes + fits := strings.Repeat("f", size-1) + head, middle, tail := strings.Repeat("h", size), strings.Repeat("m", 3*end), strings.Repeat("t", end) + + // the last line fills the buffer exactly and ends the input without a newline + r := bufio.NewReaderSize(strings.NewReader(fits+"\n"+head+"x\r\n\n"+head+middle+tail+"\n"+head), size) + for i, want := range []string{fits, head + "x", "", head + tail, head} { + got, err := readAccessLogLine(r) + if err != nil || got != want { + t.Errorf("line %d: got %d bytes %.20q (err %v), want %d bytes %.20q", i, len(got), got, err, len(want), want) + } + } + if _, err := readAccessLogLine(r); !errors.Is(err, io.EOF) { + t.Errorf("after the last line: err = %v, want io.EOF", err) + } +} diff --git a/internal/web/service/server.go b/internal/web/service/server.go index 8fa7db3ca..294625def 100644 --- a/internal/web/service/server.go +++ b/internal/web/service/server.go @@ -1477,6 +1477,37 @@ func (s *ServerService) GetAmneziaWGLogs(count string, filter string) *AmneziaWG return logs } +// Xray's HTTP inbound logs the requested URL whole, so an access-log line has +// no length bound; GetXrayLogs keeps this much of a line's start and of its end. +const ( + maxAccessLogLineBytes = 64 << 10 + accessLogLineEndBytes = 1 << 10 +) + +// readAccessLogLine returns the next line of r. A line longer than r's buffer +// loses its middle and keeps its end (route, email) instead of failing the read. +func readAccessLogLine(r *bufio.Reader) (string, error) { + chunk, more, err := r.ReadLine() + if err != nil { + return "", err + } + line := string(chunk) + + var end []byte + for more { + chunk, more, err = r.ReadLine() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return "", err + } + end = append(end, chunk...) + end = end[max(0, len(end)-accessLogLineEndBytes):] + } + return line + string(end), nil +} + func (s *ServerService) GetXrayLogs( count string, filter string, @@ -1509,10 +1540,17 @@ func (s *ServerService) GetXrayLogs( } defer file.Close() - scanner := bufio.NewScanner(file) + reader := bufio.NewReaderSize(file, maxAccessLogLineBytes) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) + for { + line, err := readAccessLogLine(reader) + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return nil + } + line = strings.TrimSpace(line) if line == "" || strings.Contains(line, "api -> api") { // skipping empty lines and api calls @@ -1551,10 +1589,6 @@ func (s *ServerService) GetXrayLogs( } } - if err := scanner.Err(); err != nil { - return nil - } - return entries } From 846dfe3d74c1900e3972f9fa081cc88d6d1aecd2 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 13:44:32 +0100 Subject: [PATCH 113/125] fix(notifications): throttle failed-login alerts where they are published Every failed login was published on the event bus, and the e-mail, Telegram and Discord notifiers exempt login events from their cooldown so that successful logins are never swallowed. An unauthenticated login flood therefore filled each notifier's queue and crowded out every other alert, a successful login, an Xray crash or a node going down. UserLoginNotify now passes a failed attempt only for the first failure of its source address in a minute, at most ten addresses per minute; successful logins are always published. --- internal/web/service/tgbot/tgbot_report.go | 37 +++++++++- .../service/tgbot/tgbot_reports_usage_test.go | 72 +++++++++++++++++++ 2 files changed, 108 insertions(+), 1 deletion(-) diff --git a/internal/web/service/tgbot/tgbot_report.go b/internal/web/service/tgbot/tgbot_report.go index dd7a65c3f..1b377896e 100644 --- a/internal/web/service/tgbot/tgbot_report.go +++ b/internal/web/service/tgbot/tgbot_report.go @@ -208,7 +208,40 @@ func (t *Tgbot) prepareServerUsageInfo() string { return info } -// UserLoginNotify publishes a login event to the event bus. +const ( + loginFailAlertWindow = 1 * time.Minute + // Distinct source addresses alerted per window; the address is the caller's + // to vary, so this ceiling keeps a broad flood below the notifier queue size. + loginFailAlertMaxSources = 10 +) + +var loginFailAlerts = &loginFailAlertGate{} + +// loginFailAlertGate throttles failed-login alerts before they reach the bus: the +// notifiers exempt login events from their cooldown, so a flood crowds out every other alert. +type loginFailAlertGate struct { + mu sync.Mutex + windowStart time.Time + sources map[string]struct{} +} + +// allow passes the first failure of each source address in a window. +func (g *loginFailAlertGate) allow(ip string, now time.Time) bool { + g.mu.Lock() + defer g.mu.Unlock() + if now.Sub(g.windowStart) >= loginFailAlertWindow { + g.windowStart = now + g.sources = make(map[string]struct{}, loginFailAlertMaxSources) + } + if _, seen := g.sources[ip]; seen || len(g.sources) >= loginFailAlertMaxSources { + return false + } + g.sources[ip] = struct{}{} + return true +} + +// UserLoginNotify publishes a login event to the event bus. Successful logins +// are always published; failed ones are throttled by loginFailAlerts. func (t *Tgbot) UserLoginNotify(attempt LoginAttempt) { if attempt.Username == "" || attempt.IP == "" || attempt.Time == "" { logger.Warning("UserLoginNotify failed, invalid info!") @@ -222,6 +255,8 @@ func (t *Tgbot) UserLoginNotify(attempt LoginAttempt) { status := "fail" if attempt.Status == LoginSuccess { status = "success" + } else if !loginFailAlerts.allow(attempt.IP, time.Now()) { + return } EventBus.Publish(eventbus.Event{ diff --git a/internal/web/service/tgbot/tgbot_reports_usage_test.go b/internal/web/service/tgbot/tgbot_reports_usage_test.go index e30cae594..bcd3a4b2b 100644 --- a/internal/web/service/tgbot/tgbot_reports_usage_test.go +++ b/internal/web/service/tgbot/tgbot_reports_usage_test.go @@ -2,10 +2,13 @@ package tgbot import ( "encoding/json" + "fmt" "path/filepath" "strconv" "strings" + "sync/atomic" "testing" + "time" "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/dbtest" @@ -365,3 +368,72 @@ func TestLoginAlertKeepsUsernameOnOneLine(t *testing.T) { } } } + +// The notifiers exempt login events from their cooldown, so failed logins are throttled where +// they are published, or an unauthenticated flood crowds every other alert out of the queues. +func TestLoginFailAlertGate(t *testing.T) { + g := &loginFailAlertGate{} + now := time.Now() + + if !g.allow("192.0.2.1", now) { + t.Fatal("first failure of a source must be alerted") + } + if g.allow("192.0.2.1", now.Add(loginFailAlertWindow-time.Second)) { + t.Fatal("a source must be alerted once per window") + } + + passed := 1 + for i := range 1000 { + if g.allow(fmt.Sprintf("2001:db8::%x", i), now.Add(time.Second)) { + passed++ + } + } + if passed != loginFailAlertMaxSources { + t.Fatalf("%d sources alerted in one window, want %d", passed, loginFailAlertMaxSources) + } + if len(g.sources) > loginFailAlertMaxSources { + t.Fatalf("gate tracks %d sources, want at most %d", len(g.sources), loginFailAlertMaxSources) + } + + if !g.allow("192.0.2.1", now.Add(loginFailAlertWindow)) { + t.Fatal("a new window must alert again") + } +} + +func TestUserLoginNotifyThrottlesOnlyFailures(t *testing.T) { + bus := eventbus.New(0) + defer bus.Stop() + prevBus, prevGate := EventBus, loginFailAlerts + EventBus, loginFailAlerts = bus, &loginFailAlertGate{} + defer func() { EventBus, loginFailAlerts = prevBus, prevGate }() + + var failures, successes atomic.Int32 + bus.Subscribe("counter", func(e eventbus.Event) { + if e.Data.(*eventbus.LoginEventData).Status == "success" { + successes.Add(1) + } else { + failures.Add(1) + } + }) + + bot := &Tgbot{} + for range 50 { + bot.UserLoginNotify(LoginAttempt{Username: "admin", IP: "192.0.2.1", Time: "now", Status: LoginFail, Reason: "too many failed attempts"}) + } + bot.UserLoginNotify(LoginAttempt{Username: "admin", IP: "192.0.2.2", Time: "now", Status: LoginFail, Reason: "invalid credentials"}) + for range 3 { + bot.UserLoginNotify(LoginAttempt{Username: "admin", IP: "192.0.2.1", Time: "now", Status: LoginSuccess}) + } + + // The bus delivers in publication order, so the last success arrives after every failure. + deadline := time.Now().Add(5 * time.Second) + for successes.Load() < 3 && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + if got := successes.Load(); got != 3 { + t.Fatalf("successful logins reported %d times, want 3", got) + } + if got := failures.Load(); got != 2 { + t.Fatalf("failed logins from two sources reported %d times, want 2", got) + } +} From 8f3dd70db42b81dc94dd1649bd83503b68080db0 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 13:46:18 +0100 Subject: [PATCH 114/125] fix(geodata): refuse a database whose category index is too large to browse The geofile browser indexes every category record and attribute key a database holds, which cost the panel far more memory than the file's bytes: a crafted file of a few megabytes full of empty categories or attribute keys ran the panel out of memory. The file size cap bounded nothing here. The scan now counts records and attribute keys as it keeps them and refuses a database past 65536 with "too large to browse", which the cache remembers; the other layout is not retried for the same file. --- internal/xray/geodata/geodata.go | 9 +++- internal/xray/geodata/geodata_test.go | 60 +++++++++++++++++++++++++++ internal/xray/geodata/reader.go | 16 +++++++ 3 files changed, 84 insertions(+), 1 deletion(-) diff --git a/internal/xray/geodata/geodata.go b/internal/xray/geodata/geodata.go index c6ef6ae3b..caf802fe2 100644 --- a/internal/xray/geodata/geodata.go +++ b/internal/xray/geodata/geodata.go @@ -30,9 +30,15 @@ const MaxFileSize int64 = 256 << 20 // the caller asks for. const MaxPageSize = 500 +// MaxIndexSize caps the category records plus attribute keys one database may +// keep in the index: each costs hundreds of bytes in memory for ten on disk. +const MaxIndexSize = 1 << 16 + var ( // ErrFileTooLarge reports a database above MaxFileSize. ErrFileTooLarge = errors.New("geodata file is too large to browse") + // ErrIndexTooLarge reports a database above MaxIndexSize. + ErrIndexTooLarge = errors.New("geodata category index is too large to browse") // ErrInvalidName reports a file name that does not resolve to a .dat file // directly inside the asset directory. ErrInvalidName = errors.New("invalid geodata file name") @@ -226,7 +232,8 @@ func (s *Store) index(name string) (*index, error) { // isPermanent reports whether an error will repeat for the same bytes, and is // therefore worth caching instead of re-deriving on every request. func isPermanent(err error) bool { - return errors.Is(err, ErrUnrecognized) || errors.Is(err, ErrInvalidName) || errors.Is(err, ErrFileTooLarge) + return errors.Is(err, ErrUnrecognized) || errors.Is(err, ErrInvalidName) || errors.Is(err, ErrFileTooLarge) || + errors.Is(err, ErrIndexTooLarge) } func (s *Store) cachedIndex(key fileKey) (*index, bool) { diff --git a/internal/xray/geodata/geodata_test.go b/internal/xray/geodata/geodata_test.go index 35ac99ac9..5582b722f 100644 --- a/internal/xray/geodata/geodata_test.go +++ b/internal/xray/geodata/geodata_test.go @@ -439,6 +439,66 @@ func TestCategorySplitOverManyRecordsIsIndexedInOnePass(t *testing.T) { } } +func TestFileAboveIndexLimitIsRejected(t *testing.T) { + rule := domain(xraygeodata.Domain_Full, "a.b") + categories := make([]*xraygeodata.GeoSite, MaxIndexSize+1) + records := make([]*xraygeodata.GeoSite, MaxIndexSize+1) + keys := make([]string, MaxIndexSize) + tagged := make([]*xraygeodata.Domain, MaxIndexSize) + for i := range categories { + categories[i] = site(strconv.Itoa(i)) + records[i] = site("z", rule) + } + categories[0].Domain = append(categories[0].Domain, rule) + for i := range keys { + keys[i] = strconv.Itoa(i) + tagged[i] = domain(xraygeodata.Domain_Full, "a.b", keys[i]) + } + + tests := []struct { + name string + sites []*xraygeodata.GeoSite + want error + }{ + {name: "categories at the limit", sites: categories[:MaxIndexSize]}, + {name: "categories", sites: categories, want: ErrIndexTooLarge}, + {name: "records of one category", sites: records, want: ErrIndexTooLarge}, + {name: "attributes at the limit", sites: []*xraygeodata.GeoSite{site("z", tagged[1:]...)}}, + {name: "attributes across rules", sites: []*xraygeodata.GeoSite{site("z", tagged...)}, want: ErrIndexTooLarge}, + { + name: "attributes of one rule", + sites: []*xraygeodata.GeoSite{site("z", domain(xraygeodata.Domain_Full, "a.b", keys...))}, + want: ErrIndexTooLarge, + }, + { + name: "one attribute repeated on one rule", + sites: []*xraygeodata.GeoSite{site("z", domain(xraygeodata.Domain_Full, "a.b", strings.Fields(strings.Repeat("k ", MaxIndexSize+1))...))}, + want: ErrIndexTooLarge, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + writeSiteDB(t, dir, "geosite.dat", tt.sites...) + store := NewStore(dir) + + if _, err := store.Categories("geosite.dat", "", 0, 10); !errors.Is(err, tt.want) { + t.Fatalf("error = %v, want %v", err, tt.want) + } + if len(store.indexes) != 1 { + t.Errorf("cached indexes = %d, want the outcome cached once", len(store.indexes)) + } + files, err := store.ListFiles() + if err != nil { + t.Fatalf("ListFiles() error = %v", err) + } + if tt.want != nil && (len(files) != 1 || files[0].Error != tt.want.Error()) { + t.Errorf("ListFiles() = %+v, want the file listed with %q", files, tt.want) + } + }) + } +} + func TestIndexCacheInvalidatedWhenFileChanges(t *testing.T) { dir := t.TempDir() sampleSiteDB(t, dir) diff --git a/internal/xray/geodata/reader.go b/internal/xray/geodata/reader.go index eaaa1bf4a..eed655f15 100644 --- a/internal/xray/geodata/reader.go +++ b/internal/xray/geodata/reader.go @@ -57,6 +57,9 @@ func scanIndex(data []byte, kind GeoKind) (*categoryScan, error) { // Parallel to scan.categories: a category split over many records adds each // record's own attributes to its set and is sorted once, at the end. var attributeSets []map[string]struct{} + // A record or attribute key costs the index far more than its bytes on disk, so + // MaxFileSize bounds nothing here: size counts what the scan has kept. + size := 0 err := eachListEntry(data, func(entry []byte, span byteSpan) error { count := 0 @@ -73,6 +76,9 @@ func scanIndex(data []byte, kind GeoKind) (*categoryScan, error) { for _, attr := range attrs { attributes[attr] = struct{}{} } + if len(attributes) > MaxIndexSize { + return ErrIndexTooLarge + } } else { _, ok, err := cidrBytes(payload) if err != nil { @@ -88,6 +94,9 @@ func scanIndex(data []byte, kind GeoKind) (*categoryScan, error) { if err != nil || code == "" { return err } + if size += 1 + len(attributes); size > MaxIndexSize { + return ErrIndexTooLarge + } scan.usable += count scan.spans[code] = append(scan.spans[code], span) if position, seen := byCode[code]; seen { @@ -172,6 +181,10 @@ func detectKind(data []byte, name string) (GeoKind, *categoryScan, error) { var firstErr error for _, kind := range [...]GeoKind{first, second} { scan, err := scanIndex(data, kind) + if errors.Is(err, ErrIndexTooLarge) { + // Retrying as the other layout would only spend the same memory again. + return "", nil, err + } if err != nil { if firstErr == nil { firstErr = err @@ -350,6 +363,9 @@ func domainValue(payload []byte) ([]byte, []string, error) { return nil, nil, protowire.ParseError(size) } if key := attributeKey(raw); key != "" { + if len(attributes) == MaxIndexSize { + return nil, nil, ErrIndexTooLarge + } attributes = append(attributes, key) } payload = payload[size:] From 99f386820d9dbd37ef4f2402dc27443ef749e00a Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 13:47:40 +0100 Subject: [PATCH 115/125] fix(tgbot): treat messages sent on behalf of a chat as strangers Telegram fills a message's From with one of three fixed placeholder users whenever it is posted on behalf of a chat, by an anonymous group admin or a channel. The level gate and the invite claim took that placeholder for the account acting, so it got a throttle window of its own, and a claim bound the client to an id every anonymous admin shares. Such a message is rated as a stranger whatever the placeholder id is bound to, and its invite claim is refused before it is counted or bound. --- .../web/service/tgbot/tgbot_invite_test.go | 39 +++++++++++++++++++ internal/web/service/tgbot/tgbot_level.go | 11 +++++- .../web/service/tgbot/tgbot_level_test.go | 18 +++++++++ internal/web/service/tgbot/tgbot_router.go | 8 +++- 4 files changed, 74 insertions(+), 2 deletions(-) diff --git a/internal/web/service/tgbot/tgbot_invite_test.go b/internal/web/service/tgbot/tgbot_invite_test.go index 936e91c65..b69d99b25 100644 --- a/internal/web/service/tgbot/tgbot_invite_test.go +++ b/internal/web/service/tgbot/tgbot_invite_test.go @@ -216,6 +216,45 @@ func TestInviteAttemptLimit(t *testing.T) { } } +// Regression test: anonymous admins, channels and linked-channel posts all arrive +// under three fixed user ids. A claim under one opened a throttle window of its +// own and bound the client to everybody who can post that way. +func TestClaimSentForChatIsRefused(t *testing.T) { + tb, calls := newInviteTgbot(t, "invitee@x") + + origBy := inviteAttemptsBy + inviteAttemptsBy = map[int64]*inviteAttempts{} + t.Cleanup(func() { inviteAttemptsBy = origBy }) + + placeholders := []telego.User{ + {ID: 1087968824, IsBot: true, Username: "GroupAnonymousBot"}, + {ID: 136817688, IsBot: true, Username: "Channel_Bot"}, + {ID: 777000, FirstName: "Telegram"}, + } + for _, from := range placeholders { + message := commandFrom(from.ID, "/start "+mustInvitePayload(t, "sub-invite")) + message.From = &from + message.Chat = telego.Chat{ID: -1001234500001, Type: telego.ChatTypeSupergroup} + message.SenderChat = &message.Chat + + if isAdmin, ok := tb.gateCommand(message); ok { + tb.answerCommand(message, message.Chat.ID, isAdmin) + } + if got := tb.levelOf(from.ID); got != levelStranger { + t.Errorf("placeholder user %d claimed the invite: level %d", from.ID, got) + } + } + if len(inviteAttemptsBy) != 0 { + t.Errorf("throttle windows = %d, want none for placeholder users", len(inviteAttemptsBy)) + } + if n := calls("sendMessage"); n != len(placeholders) { + t.Errorf("sendMessage calls = %d, want %d refusals", n, len(placeholders)) + } + if outcome, _ := tb.resolveInviteToken("sub-invite", 8080); outcome != inviteBindable { + t.Errorf("owner's outcome afterwards = %d, want bindable", outcome) + } +} + func TestTgUserMentionEscapesName(t *testing.T) { got := tgUserMention(&telego.User{ID: 42, FirstName: "Eve", Username: "eve"}) want := `<b>Eve</b> @eve` diff --git a/internal/web/service/tgbot/tgbot_level.go b/internal/web/service/tgbot/tgbot_level.go index 0918594ea..79371a740 100644 --- a/internal/web/service/tgbot/tgbot_level.go +++ b/internal/web/service/tgbot/tgbot_level.go @@ -46,10 +46,19 @@ func commandAllowed(level userLevel, command string) bool { return commandsByLevel[level][command] } +// sentForChat reports that no account stands behind message.From: a post by an +// anonymous admin or a channel carries a placeholder user shared by everybody. +func sentForChat(message *telego.Message) bool { + return message.SenderChat != nil || message.From.IsBot +} + // gateCommand reports whether a command reaches answerCommand, and as whom. A // stranger's refused command gets no reply, so the bot reveals nothing to probe. func (t *Tgbot) gateCommand(message *telego.Message) (isAdmin bool, ok bool) { - level := t.levelOf(message.From.ID) + level := levelStranger + if !sentForChat(message) { + level = t.levelOf(message.From.ID) + } command, _, _ := tu.ParseCommand(message.Text) if commandAllowed(level, command) { return level == levelAdmin, true diff --git a/internal/web/service/tgbot/tgbot_level_test.go b/internal/web/service/tgbot/tgbot_level_test.go index 70c7590c1..47d0ad66c 100644 --- a/internal/web/service/tgbot/tgbot_level_test.go +++ b/internal/web/service/tgbot/tgbot_level_test.go @@ -79,6 +79,24 @@ func TestGateCommand(t *testing.T) { } } +// Regression test: a message sent on behalf of a chat carries a placeholder user +// shared by everybody, so whatever that id is bound to, its sender is a stranger. +func TestGateCommandIgnoresPlaceholderSender(t *testing.T) { + tb, calls := newLevelTgbot(t) + + for _, from := range []int64{ownerTgID, 1} { + message := commandFrom(from, "/usage "+ownerMail) + message.Chat = telego.Chat{ID: -100200, Type: telego.ChatTypeSupergroup} + message.SenderChat = &message.Chat + if isAdmin, ok := tb.gateCommand(message); ok || isAdmin { + t.Errorf("gateCommand(%d for a chat) = (%v, %v), want (false, false)", from, isAdmin, ok) + } + } + if n := calls("sendMessage"); n != 0 { + t.Errorf("sendMessage calls = %d, want 0", n) + } +} + // Regression test: a stranger's forged callback must be answered, so the button // stops spinning, and must never reach answerCallback. func TestGateCallbackStopsStrangers(t *testing.T) { diff --git a/internal/web/service/tgbot/tgbot_router.go b/internal/web/service/tgbot/tgbot_router.go index e883401bb..206ca5c39 100644 --- a/internal/web/service/tgbot/tgbot_router.go +++ b/internal/web/service/tgbot/tgbot_router.go @@ -258,6 +258,12 @@ func (t *Tgbot) answerCommand(message *telego.Message, chatId int64, isAdmin boo msg += t.I18nBot("tgbot.commands.pleaseChoose") case "start": if len(commandArgs) > 0 { + // A placeholder user can be neither throttled nor bound, so its claim is + // refused before it counts: the link works from the account's own chat. + if sentForChat(message) { + t.sendNotice(chatId, t.I18nBot("tgbot.messages.inviteInvalid")) + return + } if !isAdmin && !t.allowInviteAttempt(message.From) { t.sendNotice(chatId, t.I18nBot("tgbot.messages.inviteRateLimited")) return @@ -265,7 +271,7 @@ func (t *Tgbot) answerCommand(message *telego.Message, chatId int64, isAdmin boo t.claimInvite(chatId, message.From.ID, commandArgs[0]) } // A stranger learns only its ChatID, which is what an admin needs to bind it. - if !isAdmin && t.levelOf(message.From.ID) == levelStranger { + if !isAdmin && (sentForChat(message) || t.levelOf(message.From.ID) == levelStranger) { t.sendNotice(chatId, t.I18nBot("tgbot.answers.askToAddUserId", "TgUserID=="+strconv.FormatInt(message.From.ID, 10))) return } From 4dfd008edb1893dd72965ab8f15b6b8fcb83403f Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 14:00:29 +0100 Subject: [PATCH 116/125] test(sub): assert linear allocation growth instead of an absolute budget TestSubscriptionDecodesInboundSettingsOncePerRequest capped one request at a fixed number of bytes, which the race detector, a Go upgrade or a richer link format can move: the race job already broke 128 MiB on correct code, and raising the cap only waits for the next drift. The test now builds each subscription at n and 3n clients and asserts the allocation grows by at most 5x. One decode per request grows about 3x (2.94 to 3.40 measured, under -race too); one decode per link grows 6.9 to 9.3x, so the bound tells the two apart whatever the absolute sizes are. --- internal/sub/host_sub_test.go | 42 ++++++++++++++++++----------------- 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/internal/sub/host_sub_test.go b/internal/sub/host_sub_test.go index cb3dacc91..a884bfad1 100644 --- a/internal/sub/host_sub_test.go +++ b/internal/sub/host_sub_test.go @@ -2118,17 +2118,17 @@ func seedSharedSubIdInbound(t *testing.T, protocol model.Protocol, subId string, // A subscription of n clients of one inbound must decode that inbound's settings // once, not once per link: with the clients array inside, n decodes grow with n². func TestSubscriptionDecodesInboundSettingsOncePerRequest(t *testing.T) { - const n = 1000 const subId = "shared-sub-id" - // The race detector roughly doubles allocations; one decode per link costs several times this. - const maxAllocated = 256 << 20 + // Growth, not an absolute byte count, so the race detector or a Go upgrade cannot move + // it: tripling n triples linear work and multiplies a per-link decode by nine. + const small, scale, maxGrowth = 300, 3, 5.0 serverPriv, serverPub := mustWireguardKeypair(t) clientPriv, _ := mustWireguardKeypair(t) email := func(i int) string { return fmt.Sprintf("user-%04d@sub", i) } uuid := func(i int) string { return fmt.Sprintf("11111111-2222-4333-8444-%012d", i) } - seed := map[model.Protocol]func(t *testing.T) *model.Inbound{ - model.TUIC: func(t *testing.T) *model.Inbound { + seed := map[model.Protocol]func(t *testing.T, n int) *model.Inbound{ + model.TUIC: func(t *testing.T, n int) *model.Inbound { return seedSharedSubIdInbound(t, model.TUIC, subId, n, `"server":{"certificate":"/c","private_key":"/k"}`, func(i int) string { return fmt.Sprintf(`{"uuid":%q,"password":"pw","email":%q,"enable":true}`, uuid(i), email(i)) @@ -2137,7 +2137,7 @@ func TestSubscriptionDecodesInboundSettingsOncePerRequest(t *testing.T) { return model.ClientRecord{Email: email(i), UUID: uuid(i), Password: "pw"} }) }, - model.AmneziaWG: func(t *testing.T) *model.Inbound { + model.AmneziaWG: func(t *testing.T, n int) *model.Inbound { return seedSharedSubIdInbound(t, model.AmneziaWG, subId, n, fmt.Sprintf(`"server":{"privateKey":%q,"publicKey":%q,"mtu":1420}`, serverPriv, serverPub), func(i int) string { return fmt.Sprintf(`{"email":%q,"privateKey":%q,"allowedIPs":["10.8.%d.%d/32"],"enable":true}`, email(i), clientPriv, i/256, i%256) @@ -2146,7 +2146,7 @@ func TestSubscriptionDecodesInboundSettingsOncePerRequest(t *testing.T) { return model.ClientRecord{Email: email(i), PrivateKey: clientPriv, AllowedIPs: fmt.Sprintf("10.8.%d.%d/32", i/256, i%256)} }) }, - model.WireGuard: func(t *testing.T) *model.Inbound { + model.WireGuard: func(t *testing.T, n int) *model.Inbound { return seedSharedSubIdInbound(t, model.WireGuard, subId, n, fmt.Sprintf(`"secretKey":%q,"mtu":1420`, serverPriv), func(i int) string { return fmt.Sprintf(`{"email":%q,"privateKey":%q,"allowedIPs":["10.8.%d.%d/32"],"enable":true}`, email(i), clientPriv, i/256, i%256) @@ -2155,7 +2155,7 @@ func TestSubscriptionDecodesInboundSettingsOncePerRequest(t *testing.T) { return model.ClientRecord{Email: email(i), PrivateKey: clientPriv, AllowedIPs: fmt.Sprintf("10.8.%d.%d/32", i/256, i%256)} }) }, - model.Hysteria: func(t *testing.T) *model.Inbound { + model.Hysteria: func(t *testing.T, n int) *model.Inbound { return seedSharedSubIdInbound(t, model.Hysteria, subId, n, `"version":2`, func(i int) string { return fmt.Sprintf(`{"email":%q,"auth":"auth","enable":true}`, email(i)) @@ -2216,19 +2216,21 @@ func TestSubscriptionDecodesInboundSettingsOncePerRequest(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - initSubDB(t) - seed[tc.protocol](t) - - var before, after runtime.MemStats - runtime.ReadMemStats(&before) - got := tc.fetch(t) - runtime.ReadMemStats(&after) - - if got != n { - t.Fatalf("entries = %d, want %d", got, n) + allocated := func(n int) float64 { + initSubDB(t) + seed[tc.protocol](t, n) + var before, after runtime.MemStats + runtime.ReadMemStats(&before) + got := tc.fetch(t) + runtime.ReadMemStats(&after) + if got != n { + t.Fatalf("entries = %d, want %d", got, n) + } + return float64(after.TotalAlloc - before.TotalAlloc) } - if allocated := after.TotalAlloc - before.TotalAlloc; allocated > maxAllocated { - t.Fatalf("%d entries allocated %d MiB, want at most %d MiB", n, allocated>>20, maxAllocated>>20) + base, grown := allocated(small), allocated(small*scale) + if growth := grown / base; growth > maxGrowth { + t.Fatalf("%dx the entries allocated %.1fx the memory (%d MiB), want linear growth", scale, growth, int64(grown)>>20) } }) } From 9938d0b7a6adbc694e1ffc95a1255db58b754d80 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 14:55:10 +0100 Subject: [PATCH 117/125] chore(deps): update frontend dependencies Minor and patch updates across the frontend toolchain and runtime (antd, TanStack Query, react-router, react-hook-form, i18next, Storybook, oxfmt/oxlint), plus @hookform/resolvers 7. Typecheck, lint, the full vitest suite and the production build pass. --- frontend/package-lock.json | 1135 ++++++++++++++++++------------------ frontend/package.json | 34 +- 2 files changed, 589 insertions(+), 580 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a30cf5b01..bb717d589 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,30 +11,30 @@ "@ant-design/icons": "^6.3.4", "@codemirror/lang-json": "^6.0.2", "@codemirror/theme-one-dark": "^6.1.3", - "@hookform/resolvers": "^6.0.0", + "@hookform/resolvers": "^7.0.1", "@noble/hashes": "^2.4.0", - "@tanstack/react-query": "^5.104.5", - "@tanstack/react-query-devtools": "^5.104.5", - "antd": "^6.6.7", + "@tanstack/react-query": "^5.105.1", + "@tanstack/react-query-devtools": "^5.105.1", + "antd": "^6.6.8", "codemirror": "^6.0.2", "dayjs": "^1.11.23", - "i18next": "^26.5.6", + "i18next": "^26.6.0", "otpauth": "^9.5.2", "persian-calendar-suite": "^1.5.6", "react": "^19.3.1", "react-dom": "^19.3.1", - "react-hook-form": "^7.90.0", - "react-i18next": "^17.1.3", - "react-router": "^8.5.0", - "swagger-ui-react": "^5.34.0", + "react-hook-form": "^7.91.0", + "react-i18next": "^17.1.10", + "react-router": "^8.5.1", + "swagger-ui-react": "^5.34.1", "uplot": "^1.6.32", "zod": "^4.6.7" }, "devDependencies": { - "@storybook/addon-a11y": "^10.6.3", - "@storybook/addon-docs": "^10.6.3", - "@storybook/addon-vitest": "^10.6.3", - "@storybook/react-vite": "^10.6.3", + "@storybook/addon-a11y": "^10.6.5", + "@storybook/addon-docs": "^10.6.5", + "@storybook/addon-vitest": "^10.6.5", + "@storybook/react-vite": "^10.6.5", "@testing-library/dom": "^10.4.2", "@testing-library/react": "^16.3.3", "@types/react": "^19.3.0", @@ -47,11 +47,11 @@ "jsdom": "^30.1.3", "lint-staged": "^17.6.0", "msw": "^3.0.3", - "oxfmt": "0.74.0", - "oxlint": "1.89.0", - "oxlint-tsgolint": "^7.0.2004", + "oxfmt": "0.75.0", + "oxlint": "1.90.0", + "oxlint-tsgolint": "^7.0.2005", "playwright": "^1.64.1", - "storybook": "^10.6.3", + "storybook": "^10.6.5", "typescript": "7.0.2", "vite": "8.3.5", "vitest": "^5.0.5" @@ -162,9 +162,9 @@ } }, "node_modules/@asamuzakjp/css-color": { - "version": "7.1.7", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.1.7.tgz", - "integrity": "sha512-Q7ioeear07HwixdIitIn9KS1LZi97CEDFCSjPNm9v+y8KNbsu9q4Lrd4zUQW4faED+E7qxvfeVnbNRW46wFKuw==", + "version": "7.1.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.1.9.tgz", + "integrity": "sha512-vZ6JQVq8Ivgz6b/IBlAf90wZviXdluY13cQOPO+nJHbmXwGrPl2GcpqUY7a55/8ZVdfONXpCFMpRijOBCHNlcQ==", "dev": true, "license": "MIT", "dependencies": { @@ -179,9 +179,9 @@ } }, "node_modules/@asamuzakjp/dom-selector": { - "version": "10.0.4", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-10.0.4.tgz", - "integrity": "sha512-A6f1nKy99I6tyoK82U6kBHvQQZmdHmTmPzJYPY/0eaOW0r8/qPmEOwjGHs+hL/vCgOwQTupu4LT1XtXNUuDruA==", + "version": "10.0.5", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-10.0.5.tgz", + "integrity": "sha512-EgmJ7oEaGQECTn3H3wmnQpEy4hKY66VvM7VQndwPIQob3p877BgbyOUE3EX0LgXQCciF2H8KMdR+RznqjMNOLw==", "dev": true, "license": "MIT", "dependencies": { @@ -251,14 +251,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", - "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "version": "7.29.12", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.12.tgz", + "integrity": "sha512-2W76mhjrpQFO1lK5e8IvGYB2D0YZ7S2R1Z2BjO2ZUYuW+amcpK/z60U6ZZmzskwoa7sJ6XohP/Wg8H5KxXI/IA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.8", - "@babel/types": "^7.29.8", + "@babel/parser": "^7.29.12", + "@babel/types": "^7.29.11", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -337,9 +337,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", - "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.11.tgz", + "integrity": "sha512-snZbO7MEDOof+hvNa97EjEY7qD7h2kwvQr5AJC3ZhQudKW5dnej6k3kP9NQV88hJwaS8vDqRLFo9rivLEhle2g==", "dev": true, "license": "MIT", "engines": { @@ -381,13 +381,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.9", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", - "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "version": "7.29.12", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.12.tgz", + "integrity": "sha512-WJ7JBp/Q4+z/ImydtI7jS6NVco5++632ExBemSX+Ysb+G1lCwQ5BVo3SY+jGGOcZXI4ZccbZSUSJLNBW6eEpJg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.8" + "@babel/types": "^7.29.11" }, "bin": { "parser": "bin/babel-parser.js" @@ -397,9 +397,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", - "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz", + "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -433,18 +433,18 @@ } }, "node_modules/@babel/traverse": { - "version": "7.29.10", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.10.tgz", - "integrity": "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==", + "version": "7.29.12", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.12.tgz", + "integrity": "sha512-MscXe/NAZcOkYEsRajHZFt3ynsb22PUi6CTrp5U7+RYycR9By84mn9mFN54YgfITiqK4hFx3Ju3tZSyzfpS5ew==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.8", + "@babel/generator": "^7.29.12", "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.9", + "@babel/parser": "^7.29.12", "@babel/template": "^7.29.7", - "@babel/types": "^7.29.8", + "@babel/types": "^7.29.11", "debug": "^4.3.1" }, "engines": { @@ -452,13 +452,13 @@ } }, "node_modules/@babel/types": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", - "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.11.tgz", + "integrity": "sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-string-parser": "^7.29.11", "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { @@ -496,9 +496,9 @@ } }, "node_modules/@codemirror/autocomplete": { - "version": "6.20.3", - "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz", - "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==", + "version": "6.20.4", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.4.tgz", + "integrity": "sha512-yF8RhOf2SXlg00+qtupzLhrglcas61boqaONiNcbPIebHCpJocpqTBA6dyrtUrQO47hqyTdeob/3XnYilT7TaQ==", "license": "MIT", "dependencies": { "@codemirror/language": "^6.0.0", @@ -530,12 +530,13 @@ } }, "node_modules/@codemirror/language": { - "version": "6.12.4", - "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz", - "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==", + "version": "6.13.1", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.13.1.tgz", + "integrity": "sha512-zfvU2r3TyylQLMj/te+5CnQ0rSqZi8B1oTCBbQkagcLW4Xc6imab6qN+pEJhsuNOLaJqiRyBXp9UhWA1DhVvDw==", "license": "MIT", "dependencies": { "@codemirror/state": "^6.0.0", + "@codemirror/streamparser": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0", @@ -574,6 +575,12 @@ "@marijn/find-cluster-break": "^1.0.0" } }, + "node_modules/@codemirror/streamparser": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@codemirror/streamparser/-/streamparser-6.0.0.tgz", + "integrity": "sha512-JTFPF6xrfjt0mnfGPgzcLiMwYmvIyNaoizp8BYzv8FE8KrT6GBGDZ190rtt+O7Wke3gG/MFHyiY9YGNeimof4w==", + "license": "MIT" + }, "node_modules/@codemirror/theme-one-dark": { "version": "6.1.3", "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz", @@ -587,9 +594,9 @@ } }, "node_modules/@codemirror/view": { - "version": "6.43.13", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.13.tgz", - "integrity": "sha512-sihaFrUzAsYBQsL9J2t69y8nfMQGwcYmggAZsk+kjPbjYZMyuf2hU8tUNTZ+P+isb6XRr8JE22TZlJxBoVdH1A==", + "version": "6.43.15", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.15.tgz", + "integrity": "sha512-HHsfpqFeHyXLs983pSRe38Xajs0vFWZQ2BuX0m7Ki8w3jZ5iD8cuR3lBz2/5/X53Dhf32SBHKh6rtSTB6tKeGw==", "license": "MIT", "dependencies": { "@codemirror/state": "^6.7.0", @@ -655,9 +662,9 @@ } }, "node_modules/@csstools/css-color-parser": { - "version": "4.2.6", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.6.tgz", - "integrity": "sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg==", + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.7.tgz", + "integrity": "sha512-zgm9yjQz5iJB4VL8RyVwd71TWB8Q89FSxt/NzXoWqPSkEBOVtn9L6vAPtSc9hpKfB0Awo/AL2VKFHjYetBLFHQ==", "dev": true, "funding": [ { @@ -706,9 +713,9 @@ } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.15.tgz", - "integrity": "sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.16.tgz", + "integrity": "sha512-LsDbJPP/B8cPasUJyTA5S99F7C4G93ngW8hUluXz8CFeOaaRnBPtBiIlvgcPYU0HmHNz8CXg+1vwFrIB4F6Dgw==", "dev": true, "funding": [ { @@ -1282,17 +1289,15 @@ "license": "MIT" }, "node_modules/@hookform/resolvers": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-6.0.0.tgz", - "integrity": "sha512-6iBzQnB+YwAveVc8HUi6QYLITn5X+xNKRMFIcNXHZlvp2aTI7yWbj4n/JodXxirMCcBc4VCjb2aYAJh4x07mVw==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-7.0.1.tgz", + "integrity": "sha512-F3aM0dDWsRGmFjnnXjogaGLwP7gY2HA6/c5qp4v4Im/UV7pTNkX+WAjcOlM1V0PE0zoMzuPqCyaYtMzUhoFToA==", "license": "MIT", "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { - "@sinclair/typebox": ">=0.25.24", "@standard-schema/spec": "^1.0.0", - "@typeschema/main": ">=0.13.7", "@vinejs/vine": "^2.0.0 || ^3.0.0 || ^4.0.0", "ajv": "^8.12.0", "ajv-errors": "^3.0.0", @@ -1311,21 +1316,16 @@ "react-hook-form": "^7.55.0", "superstruct": ">=0.12.0", "typanion": "^3.3.2", + "typebox": "^1.0.0", "valibot": ">=0.31.0 || ^1.0.0-beta.4 || ^1.0.0-rc", "vest": ">=6.0.0", "yup": "^1.0.0", "zod": "^3.25.0 || ^4.0.0" }, "peerDependenciesMeta": { - "@sinclair/typebox": { - "optional": true - }, "@standard-schema/spec": { "optional": true }, - "@typeschema/main": { - "optional": true - }, "@vinejs/vine": { "optional": true }, @@ -1377,6 +1377,9 @@ "typanion": { "optional": true }, + "typebox": { + "optional": true + }, "valibot": { "optional": true }, @@ -1392,9 +1395,9 @@ } }, "node_modules/@inquirer/ansi": { - "version": "2.0.9", - "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.9.tgz", - "integrity": "sha512-0Ywt3S8tqM6TWUcsRlBFQ1x+X3/o8kktX0ZiKAnByYb9GFDknnOhse1PVtfiD2BXPI8ar+BWx9PV9R0lQIcygw==", + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.10.tgz", + "integrity": "sha512-dwrc+1aqVZpHNkNDswXGtn1typ+Pxpo5FHZRyBw1le7onEJUkPUkYOY1FIFC1LvFMYUqqLuWBwElhAmencL7MQ==", "dev": true, "license": "MIT", "engines": { @@ -1402,14 +1405,14 @@ } }, "node_modules/@inquirer/confirm": { - "version": "6.3.3", - "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.3.3.tgz", - "integrity": "sha512-ImOBgpl/sJTFp8naeSkNu4S0Sf8F8pxv3/CUx+LoBAdQvCVqFz6rSB2A/oZYsJge3+yphbEDxEkfaRNYq1OU0A==", + "version": "6.3.5", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.3.5.tgz", + "integrity": "sha512-BJ59BOyURiZiAxdr9AuRu38/k+rV+DcTIM2fUF/c/BXrab6H8zMZTTOl3LgkSJH96Xob2vOJxDiBkHiz32Xx2w==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^12.0.4", - "@inquirer/type": "4.2.0" + "@inquirer/core": "^12.0.6", + "@inquirer/type": "4.2.1" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" @@ -1424,15 +1427,15 @@ } }, "node_modules/@inquirer/core": { - "version": "12.0.4", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.4.tgz", - "integrity": "sha512-M5fB67y7jPObI45vhD82RskWwCSm0scOvenV/I6K8tIi2gfr9DXqQqJ+iw+Wi6SgapikdOiJibYzz1gW47fsDg==", + "version": "12.0.6", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.6.tgz", + "integrity": "sha512-5yxn2LsJ8Tu4ei2a+yNkmFRhM/cb+JMEsEs/AW6CCOnBnpMy4IjYiPMFyHtYq8weOmlTyqqCx40y0DsD7OsEPw==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/ansi": "^2.0.9", - "@inquirer/figures": "^2.0.9", - "@inquirer/type": "4.2.0", + "@inquirer/ansi": "^2.0.10", + "@inquirer/figures": "^2.0.10", + "@inquirer/type": "4.2.1", "cli-width": "^4.1.0", "fast-wrap-ansi": "^0.2.0", "mute-stream": "^3.0.0", @@ -1451,9 +1454,9 @@ } }, "node_modules/@inquirer/figures": { - "version": "2.0.9", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.9.tgz", - "integrity": "sha512-EAWgUTGQ/Umgga51dE3B2PUHbufuXarDfg86uVgoSgNHNNQnyFKcOrQLWVqYMghuSyHh8+2HUH0Js9cTC1WAdg==", + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.10.tgz", + "integrity": "sha512-51elrUJV3jZpvD0ygz8+U10Kc6+Lzqln+/tYmnbhy6jo5oXpE6hMCcv+OTBC0enwJXsb3JIuT3VtEinRNCSK2A==", "dev": true, "license": "MIT", "engines": { @@ -1461,9 +1464,9 @@ } }, "node_modules/@inquirer/type": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.2.0.tgz", - "integrity": "sha512-2rnHX8RPo4glJYqnFc/v3nQeIhDcjyRRyaT7MGcfL/8xv/8K03SXYoA2tX06WVSKywWOE/ncbG3dtuzLjR5TCQ==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.2.1.tgz", + "integrity": "sha512-lDkPsnv5M5CcYB2T1qTc3dobNTPkVr4DU3Ic2Q6SW8/qVRHgvzdRdmVZ6YqG9Ag1aJTeMsLU0s0WPRh3CZWDQQ==", "dev": true, "license": "MIT", "engines": { @@ -1594,9 +1597,9 @@ } }, "node_modules/@lezer/common": { - "version": "1.5.3", - "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.3.tgz", - "integrity": "sha512-H0iErY4e43LpXbYDyBci5W4v/RwTgGV3YzYOlJPiGZ8RY8w51kVE+xLn1tg3Z6UmSfyETvtsUK3r7YsgDQEI7Q==", + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.5.tgz", + "integrity": "sha512-Pw7PVCndruF1pb3ebCxpSVpkMHSDjwRCLcTRw+RizUKco5QbWylwq2g4lnG/RYN7yTTQDXE9F77MwtkAyphrjg==", "license": "MIT" }, "node_modules/@lezer/highlight": { @@ -1620,9 +1623,9 @@ } }, "node_modules/@lezer/lr": { - "version": "1.4.10", - "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", - "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "version": "1.4.12", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.12.tgz", + "integrity": "sha512-On9HU4oMMiHPMkQDgoTnGahjgG5n/MkN38CgG77sMraKLL+3JBbeQNi1FcCXV9QRqwF9/BUXP3caHyJC1fBOTQ==", "license": "MIT", "dependencies": { "@lezer/common": "^1.0.0" @@ -1660,9 +1663,9 @@ "license": "MIT" }, "node_modules/@mswjs/interceptors": { - "version": "0.45.7", - "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.45.7.tgz", - "integrity": "sha512-PPZQBiojRuy1jJQoUqbrJhoac47GC1GwRazDrYwwbE70zfqMkOY/5a+Du4hp3vlPUXLt/um4TMcmRabmooAkxw==", + "version": "0.45.11", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.45.11.tgz", + "integrity": "sha512-+ISPERIRIHhSVnFQRGCP9jDJXxn0/z2+pWWbXUym6JUfpUCRW2Pvro4P9RBKGYUKTKAULszKurKMTQIsaJUIgQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1678,9 +1681,9 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.5.tgz", - "integrity": "sha512-HshSvXzmBxNzqejd3k/KemgQ3hdREUYFRRrgxOZ+gPljDIsO2SDZrVZMd4Z/HsxudQbE62OVyVFE8z9BCurfjA==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.6.tgz", + "integrity": "sha512-mRyDmEw9/fCDXCGfL1sbgIw/GUK0PVxRW4YwgPN61F3LQ+fSyoBFarJVzgGyhF6kRzEQZ0FejDqm4WLXSeqQmw==", "dev": true, "license": "MIT", "optional": true, @@ -1695,8 +1698,8 @@ "url": "https://github.com/sponsors/Brooooooklyn" }, "peerDependencies": { - "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", - "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" + "@emnapi/core": "^1.7.1 || 2.0.0-beta.2", + "@emnapi/runtime": "^1.7.1 || 2.0.0-beta.2" } }, "node_modules/@noble/hashes": { @@ -2427,9 +2430,9 @@ ] }, "node_modules/@oxfmt/binding-android-arm-eabi": { - "version": "0.74.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.74.0.tgz", - "integrity": "sha512-xxQwrf5EPI5zxvuCTW8r6tzCTjPQ+h6+HqJ0iRKRy2zS1yBllt/xLVjoIyUc+FnR7g0a5g1SZv2yyDYhTItLbA==", + "version": "0.75.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.75.0.tgz", + "integrity": "sha512-zHJ7LcTN46/5mtoLxsUu14pdF9ixFwDURtmxZ0CeNwqom5qB5NRVqU6o2GRZ7QPdNBW8G6j9UTJzQ+EDevMH0w==", "cpu": [ "arm" ], @@ -2444,9 +2447,9 @@ } }, "node_modules/@oxfmt/binding-android-arm64": { - "version": "0.74.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.74.0.tgz", - "integrity": "sha512-FJW3sATVvrWbV64al8xofCPZj4500Z/1W0ad7Tg1rcCUyh0EMbuSrsf3jsZS7CEbwQ5fQ0mRhmkc129GFgofSQ==", + "version": "0.75.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.75.0.tgz", + "integrity": "sha512-IdxsKZJvHZUvI7q6kLmzXdEFpFiBEDQw/WJtSp1QlzNXBw1JlLN3tY50WwWd4doPnq86XW4P+7BsQwu6meCW+w==", "cpu": [ "arm64" ], @@ -2461,9 +2464,9 @@ } }, "node_modules/@oxfmt/binding-darwin-arm64": { - "version": "0.74.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.74.0.tgz", - "integrity": "sha512-TfmNKBD30dGyMu9GCLiaw2rt/z7fms9LS+5cKcNOyWst0QgAs8oZs6wPxMbVaaDVE0/PCdCosgWM1vbu6f5NUg==", + "version": "0.75.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.75.0.tgz", + "integrity": "sha512-vyiqZilO5jal/nXZ0143NyKcP6TkzjaQdVEWU/kwgyWqugAgIRgAbrnO3MOF+e6LkQu3CrtrkPoc3mrsZZY6cA==", "cpu": [ "arm64" ], @@ -2478,9 +2481,9 @@ } }, "node_modules/@oxfmt/binding-darwin-x64": { - "version": "0.74.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.74.0.tgz", - "integrity": "sha512-5Usf8+DfC4Q3o/MD8FkN47GrI6y0KbhQ4KhZ2HGzMZcXjcW1AeldwbUn51aKih+1TVuJFF9qkNm6X1nzm6NYng==", + "version": "0.75.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.75.0.tgz", + "integrity": "sha512-oua7eVqVe769mDam6ORcavygxTzG3ovo5QRQ1kJCPaEbnWXPYhtAiYDj8pajVmFjO7j8Elf3gInnWpTX0LLYtg==", "cpu": [ "x64" ], @@ -2495,9 +2498,9 @@ } }, "node_modules/@oxfmt/binding-freebsd-x64": { - "version": "0.74.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.74.0.tgz", - "integrity": "sha512-eL+1O3gykw1XihbZh2MRAciQvU2988LByrnjhyo/dmNG74X4oZ7XzigRoWPRFLfJCp8hzRkp/LbEhwe7uuT2eA==", + "version": "0.75.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.75.0.tgz", + "integrity": "sha512-NWkbezhg7mx2Ep94g/TysR3c1bsISsU4lQ6t5xI4841PAk0Kqcn1+UEe+BoFH2/UK2YegPXq+OF+0bUcSvUalA==", "cpu": [ "x64" ], @@ -2512,9 +2515,9 @@ } }, "node_modules/@oxfmt/binding-linux-arm-gnueabihf": { - "version": "0.74.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.74.0.tgz", - "integrity": "sha512-Xt0QtO2hJ4stnul8IL4LJGVLyp0xnc6ccN9fZ30yuubv8FWSRYxWwd1Za62KsEqAfmPSazoS/cLSUUJpja6npA==", + "version": "0.75.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.75.0.tgz", + "integrity": "sha512-CAHX87kJplTq2E0OWP7opG8gEVlHc7CeLEi4nMJOyx3psuqBKf84HZ3UIkX2guWEJ5UljU2zJF0o46XylywVVA==", "cpu": [ "arm" ], @@ -2529,9 +2532,9 @@ } }, "node_modules/@oxfmt/binding-linux-arm-musleabihf": { - "version": "0.74.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.74.0.tgz", - "integrity": "sha512-/4wYQ0gZl4vUR0QoBTGaay73FmQ1GAjUa9z9101TR2xbGNrg4a+grazlR0yzEOe3/E+PJr5BGfj66MMzm/54vg==", + "version": "0.75.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.75.0.tgz", + "integrity": "sha512-jmxYOiQzx2fp+OwQb7717K1zI8AMKEykw04sfr95/mU8SIL/oKgQq8nXH42sEznEquBL2RU7nLY36eBnYFT+vg==", "cpu": [ "arm" ], @@ -2546,9 +2549,9 @@ } }, "node_modules/@oxfmt/binding-linux-arm64-gnu": { - "version": "0.74.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.74.0.tgz", - "integrity": "sha512-846bKzF+HJd+JkOFh3rxzmYvSq4Cm+GMT+p+mW7S13ZYo6GAAGv8bL8cAOi/XcdeMqxEJDCIMTVQLVBXHMCdig==", + "version": "0.75.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.75.0.tgz", + "integrity": "sha512-eimaiAhx08dbOg12Fj3PYhymXY8vRbaiJB505vDXMy6ohne3Y1qTS0e5KEwFMq3LmDjEO+16BX38c/oXFI/SAQ==", "cpu": [ "arm64" ], @@ -2566,9 +2569,9 @@ } }, "node_modules/@oxfmt/binding-linux-arm64-musl": { - "version": "0.74.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.74.0.tgz", - "integrity": "sha512-nI5FJwZ4Flo6CBdscpwxymZXxO3m3IErQJX55ZS6h+ObfGXu8FostL85L/RWPFaCLnIVE4/WfLw+2q8XvVEeUA==", + "version": "0.75.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.75.0.tgz", + "integrity": "sha512-bjSONIG1ba2xugr9oHQau0E6oPFYOMjPaa5hK/wpVC/Q1pcgf2BZMKwbuA8/cWzhWX5QV/UdPzD+rw1kQHpikQ==", "cpu": [ "arm64" ], @@ -2586,9 +2589,9 @@ } }, "node_modules/@oxfmt/binding-linux-ppc64-gnu": { - "version": "0.74.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.74.0.tgz", - "integrity": "sha512-BoQYKuqUdsKAFAkK6DLBvkdv4+t0hppSm79sB0wIcE2o/eOwNj+6Z3OlKY4q3Eta/tlssP65FaS9vHn8TeF7vQ==", + "version": "0.75.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.75.0.tgz", + "integrity": "sha512-wDksWQXC/KZucISyqlhBtvpW+pyHh8z0Cjm6Qxiv22XlllM2Bx5gJGDnPYjV+H0lL+o1iqLlEli8jmBuRCLcjw==", "cpu": [ "ppc64" ], @@ -2606,9 +2609,9 @@ } }, "node_modules/@oxfmt/binding-linux-riscv64-gnu": { - "version": "0.74.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.74.0.tgz", - "integrity": "sha512-FSroRrM12+jCdSQklF9aOxsKpI6wFs4ZCo/1cSYG80RWZS6z4LV+l9tXRMT8lDGu+yohIZBwi9M76FzGPFDE2A==", + "version": "0.75.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.75.0.tgz", + "integrity": "sha512-5JkNwViqaVuk4I+9+zgVrEYiHOAPXFxgDgP6iDnpdOBjm7XAHAOjSvolHZZAokWPx7CGOw8tKteXmn4WPIInaQ==", "cpu": [ "riscv64" ], @@ -2626,9 +2629,9 @@ } }, "node_modules/@oxfmt/binding-linux-riscv64-musl": { - "version": "0.74.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.74.0.tgz", - "integrity": "sha512-0VLQD0dhGBLY9Ik/w+otMvD81D0YXi4VDwql65macV+O9UzmSWmUr6CKf+dMtysDeC0PnG3y6kBkRO08BJx52g==", + "version": "0.75.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.75.0.tgz", + "integrity": "sha512-1EVqRIE0eZLB08j3ZCVN47Ors7LCXGmOI+3xJYgyoFUKUerD3Glxb+Q1Bp3lK8NeT5R3ZdS9uhX/wobNBYZ6jw==", "cpu": [ "riscv64" ], @@ -2646,9 +2649,9 @@ } }, "node_modules/@oxfmt/binding-linux-s390x-gnu": { - "version": "0.74.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.74.0.tgz", - "integrity": "sha512-6X7BbZNz34+OHM6Oqjf6TRcUyRn6aRcB4VtIDZAF/uQRyeVhg+G2hv6MlMvmnRekVSyTJ/8rG/u8W2dRFW6glg==", + "version": "0.75.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.75.0.tgz", + "integrity": "sha512-UJWRVNT6Yuw4ssLv2BwaBUWU1GWtByE+EnoHu8I+P1t8Kn+WozYU5WYOqb5aj8gbXJJ8gGZt+w40Hi/7L6iVLw==", "cpu": [ "s390x" ], @@ -2666,9 +2669,9 @@ } }, "node_modules/@oxfmt/binding-linux-x64-gnu": { - "version": "0.74.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.74.0.tgz", - "integrity": "sha512-4kYIQK53yJXrxwxSzx8H2bojlf6/KezLGYLRXzV9QCCkoiCsvBTCid9Bg83dUPTLPa3g9K0730f6MKDbck1+OA==", + "version": "0.75.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.75.0.tgz", + "integrity": "sha512-FHdvHhyz8ChBctBUvVGti49kSEwANd1PZQwlXUEoF9Ed4plxXxk/wWc0mfZmrze2lRgmME7ytDmVIOixYauQhg==", "cpu": [ "x64" ], @@ -2686,9 +2689,9 @@ } }, "node_modules/@oxfmt/binding-linux-x64-musl": { - "version": "0.74.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.74.0.tgz", - "integrity": "sha512-MfWz4wDREjp/JQDmuxtKieix4FHGa1z5rBvB3betdcPwZvf5fqeQrlCm1mmoyuUVQOh9Pt3M61HwN94kJCxw7Q==", + "version": "0.75.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.75.0.tgz", + "integrity": "sha512-6MjtsF96s9mSBV0GEwUe4pOFPP2Mz7ahcHPAit0DSKL5fM6oMM7RG1zvYxwB7+SBkIxsWBRhhxuqmy7pibNwsA==", "cpu": [ "x64" ], @@ -2706,9 +2709,9 @@ } }, "node_modules/@oxfmt/binding-openharmony-arm64": { - "version": "0.74.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.74.0.tgz", - "integrity": "sha512-6rmAApkq5s1kbDjvNI6Nl5A+JqQkLORb2sR941pf5HWsrd01WGKyagK/B4vX9+kh1FfvRkNgEu59FHvVk4H8cQ==", + "version": "0.75.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.75.0.tgz", + "integrity": "sha512-1D6T6aL1Pj+U5GLxqwV1fKjUJ5+FFm+bn7UrqNDMveZmWs0jo/3vc+dzzuV/jF/FdVo12gowODk3SnAO2YOwLg==", "cpu": [ "arm64" ], @@ -2723,9 +2726,9 @@ } }, "node_modules/@oxfmt/binding-win32-arm64-msvc": { - "version": "0.74.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.74.0.tgz", - "integrity": "sha512-+5SpRUnZNGeXTyumHnpHa791tQtt04qZs076WT8eTcGF2OJ631VLnf7S0kQSPuDG+PtJM0phadEB2WAr5n1w9g==", + "version": "0.75.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.75.0.tgz", + "integrity": "sha512-RX2eUmy7J/nM8+tnM3nzR8t1eqkwj1q8Kgo7beA+4DVgKYYHHyrEa3qEmthxw7Z+HqpCCEOWuoqa+jelo9/w2Q==", "cpu": [ "arm64" ], @@ -2740,9 +2743,9 @@ } }, "node_modules/@oxfmt/binding-win32-ia32-msvc": { - "version": "0.74.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.74.0.tgz", - "integrity": "sha512-1Fg+wYBO3Dsti1QX+/6EYHjeQzLC84tLOAqPu/r7ah3UJlUs9C/DB703Ay9Vcg+8VMzqZA2P5VGhzJraVUD4OQ==", + "version": "0.75.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.75.0.tgz", + "integrity": "sha512-UpKta0iSZmPLII0dYXYdbzdBTsGREUbSHLqgLdtKcqBvs40qjmUz+cgUcaU1smPx13DfbEG4XqnaSn5MoFN0og==", "cpu": [ "ia32" ], @@ -2757,9 +2760,9 @@ } }, "node_modules/@oxfmt/binding-win32-x64-msvc": { - "version": "0.74.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.74.0.tgz", - "integrity": "sha512-y9jXUgPBm2TZIWbQhMYRRYAQM57+r4fbykgc+j24ICIIZl+pcuoNhnq1CRGm3oFiHnEUnZO00qOtSPGQdJ2ziQ==", + "version": "0.75.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.75.0.tgz", + "integrity": "sha512-pjiLiCk0QMCWxs0n5XiNU85YLAKA+wJxPjxlDVygmr4bP4glcfTU2nOpso5nR4Rdi2Pxv5YwakD4UE+eh7M0BQ==", "cpu": [ "x64" ], @@ -2774,9 +2777,9 @@ } }, "node_modules/@oxlint-tsgolint/darwin-arm64": { - "version": "7.0.2004", - "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-arm64/-/darwin-arm64-7.0.2004.tgz", - "integrity": "sha512-uJk3ttju7Lz+Nv8WG4CsLyewoL1qf/Ny8S8OksJ7Bm/F14Tc9gMfW6arM1PCc57IzH/X/I79YsCWTgxX1FKibQ==", + "version": "7.0.2005", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-arm64/-/darwin-arm64-7.0.2005.tgz", + "integrity": "sha512-VqDZxcZVjlonNLuuvraVNgDQt+1ndwa4yJo8OqsK4HYh/61ba9IlMStCVfi/aaIqRoDhNbAKC8gdcTrXqnb+Ug==", "cpu": [ "arm64" ], @@ -2788,9 +2791,9 @@ ] }, "node_modules/@oxlint-tsgolint/darwin-x64": { - "version": "7.0.2004", - "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-x64/-/darwin-x64-7.0.2004.tgz", - "integrity": "sha512-0BFfHTzDxNDFPcu0UZAXT6CyCJHAXM0dZveyDb4KEHa2qF/DSnOKOrmvK/wFuFol9kvijbGM9JfuOsor+zWXRw==", + "version": "7.0.2005", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-x64/-/darwin-x64-7.0.2005.tgz", + "integrity": "sha512-4kUfLRNYoVpv0Tm3jPYrvxyfOc/LmdvFC3pKZ4tL6K7lA7K/VS81SAtmxRzbYXYSyb3SBWhwsuX+df4neqzxxA==", "cpu": [ "x64" ], @@ -2802,9 +2805,9 @@ ] }, "node_modules/@oxlint-tsgolint/linux-arm64": { - "version": "7.0.2004", - "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-arm64/-/linux-arm64-7.0.2004.tgz", - "integrity": "sha512-TAehQc+qdRXyDN3nl/r/3/oPrBVrbtrDeGMkFyG6mf+XphpoJ2lAMVFLclyvrVZCw02RbKROjyw8h72XwQQHcw==", + "version": "7.0.2005", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-arm64/-/linux-arm64-7.0.2005.tgz", + "integrity": "sha512-rhUNcPsBlKvhXtrbhbBUidtRBRdJ8fbEHN9H5Q5N59gby18ruGuDA7KUxl7oRnUzp797uykYQhkBDm+0KpfK/Q==", "cpu": [ "arm64" ], @@ -2816,9 +2819,9 @@ ] }, "node_modules/@oxlint-tsgolint/linux-x64": { - "version": "7.0.2004", - "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-x64/-/linux-x64-7.0.2004.tgz", - "integrity": "sha512-LPunZvm6ZbWPfHMAyfZf9ci3HHvNEzLejO8zdqO3GoVGoSEHqqufHUD7+OgeatVSaJP7uXDJ2HaNOHIoH7go4Q==", + "version": "7.0.2005", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-x64/-/linux-x64-7.0.2005.tgz", + "integrity": "sha512-tKs9yinDQZOHygM0e8EJnjTU3mzJtdfkOZP6Udu82iLMePUSHOhTJ9zzU/mSd1hQxURI4LTRRiFhfzC+PzwdNA==", "cpu": [ "x64" ], @@ -2830,9 +2833,9 @@ ] }, "node_modules/@oxlint-tsgolint/win32-arm64": { - "version": "7.0.2004", - "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-arm64/-/win32-arm64-7.0.2004.tgz", - "integrity": "sha512-FVWplzDImWhiuawnIZVETaQecv4GWom6Jv4Fx2xj7hhXXDZ5okEgiw1gTCImHTIQsi2ycC5oSxVwrn69bMh7iw==", + "version": "7.0.2005", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-arm64/-/win32-arm64-7.0.2005.tgz", + "integrity": "sha512-VYPnu1gjfbSv+ROhuyNfMs382D4zQU2ZRc8Uc3nuEZo1k2kf+1KfTrg4hrlWh73h9GI+dUWyo+DYCQzUBlPwcQ==", "cpu": [ "arm64" ], @@ -2844,9 +2847,9 @@ ] }, "node_modules/@oxlint-tsgolint/win32-x64": { - "version": "7.0.2004", - "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-x64/-/win32-x64-7.0.2004.tgz", - "integrity": "sha512-7PsxuUx11Ry9DYTWcljTzRbmZY9B6O9fFFfZV+aodQEMy6pfRT3fhoQRv9PtGN/89z1pPz3nFKrsFm6WqNtX7w==", + "version": "7.0.2005", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-x64/-/win32-x64-7.0.2005.tgz", + "integrity": "sha512-ekU89cTGy9fpQzuvxIsFHrnoP8XoFjGIRgPEW6eqgY2NYjGy9pwpjF79o04WXRMJFKRrcaVqkZXUUBdLsqpQDw==", "cpu": [ "x64" ], @@ -2858,9 +2861,9 @@ ] }, "node_modules/@oxlint/binding-android-arm-eabi": { - "version": "1.89.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.89.0.tgz", - "integrity": "sha512-Pflpba5F3gRAQ12sWglRxvWTHEdAusNDvmi4Y5N1T5SY1AGkM7AixOnET8qkrnLJ/F32qAlNokO6eCknT8tmnw==", + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.90.0.tgz", + "integrity": "sha512-sgfalb1HuxYjiAa4wESZ626z1FGusqNcXYHyB5oIOEprS2sbPg0UYEfo83uF1GNG70DIfZxHkXuE6KFrlSdn8Q==", "cpu": [ "arm" ], @@ -2875,9 +2878,9 @@ } }, "node_modules/@oxlint/binding-android-arm64": { - "version": "1.89.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.89.0.tgz", - "integrity": "sha512-dYmyD1x7LaPlb79vZWUcpTShk8dI9z1I+IubtqrBxWNJaDhQdPHwcsvg7q5StTU4xWyd9AwPfLaTYFZY8NZgtQ==", + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.90.0.tgz", + "integrity": "sha512-RcKhz1ETMq+fazE9TKS2+5NIRGwigm/nW8egqF0LI+A8Yw2iW5r0E/C3XAPRiwIm/vcPiutIRIa6IQCIKhZsTQ==", "cpu": [ "arm64" ], @@ -2892,9 +2895,9 @@ } }, "node_modules/@oxlint/binding-darwin-arm64": { - "version": "1.89.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.89.0.tgz", - "integrity": "sha512-Z/oxOPiIqiS7fItRdxaaOEC+6GD3OpGi0IxoR0dMefzQHolsKLzScw2DNcu/5gUPok3tv+G9mslvYB5bo3i0qg==", + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.90.0.tgz", + "integrity": "sha512-PSrxMvP6mx+p1karZcUxLdy9lpaP+JWQPti4al9FgTaPhxE+pT/BjhGExloVVSWgZgIfOlLOjVyyq8BXlodhLQ==", "cpu": [ "arm64" ], @@ -2909,9 +2912,9 @@ } }, "node_modules/@oxlint/binding-darwin-x64": { - "version": "1.89.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.89.0.tgz", - "integrity": "sha512-eAjTkHSjAxrdCPJxO6y5sPQL5pYLKb2kvxZz9UU7tXnOZk9I/HLtkvx9S7GHY883k+AmL/eC7mqFt+w7WCwk0w==", + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.90.0.tgz", + "integrity": "sha512-OLbU9qsrV2Q8QxT2OEzOPdO8zCCpyI8qB+hDI8yr6RsYELtkf0FAZhJdfjM/RnLHKzt44HWwAPnk92vBtKofvw==", "cpu": [ "x64" ], @@ -2926,9 +2929,9 @@ } }, "node_modules/@oxlint/binding-freebsd-x64": { - "version": "1.89.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.89.0.tgz", - "integrity": "sha512-0lNCA9Tl1n1FKpqy2/9ckp0kIuSrtJ/l9bMRgaEhOtM7TsOHshFuwOcvW8oA9teXLMOJT61LjEKWiaHDFGxF/w==", + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.90.0.tgz", + "integrity": "sha512-fBA4pWyVDARmNucQqpyFuPC49xYf1r+sX7dxPL5fNPQrSMLum39Sn56tZ9JPleWC7FzLPwSQ7plYYfirYBHF8A==", "cpu": [ "x64" ], @@ -2943,9 +2946,9 @@ } }, "node_modules/@oxlint/binding-linux-arm-gnueabihf": { - "version": "1.89.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.89.0.tgz", - "integrity": "sha512-OTFEvi9W+JczaZD1Caj17SGCoW8H7TE+fvx0D7ATEWa1Lohn91UMxFQ6r145SiGy/yCxeIwptMys/RAM7gSVSQ==", + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.90.0.tgz", + "integrity": "sha512-zhxH78eP2nR6/pGdhBc7MIfjhhcgATeu8fkmkFJbuzfk6Cj0FzBxyEhL85yDqrCybjx+G2YDzZGsxIO9kvO4Iw==", "cpu": [ "arm" ], @@ -2960,9 +2963,9 @@ } }, "node_modules/@oxlint/binding-linux-arm-musleabihf": { - "version": "1.89.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.89.0.tgz", - "integrity": "sha512-fRxgNHvBLswj6MrhW2uBq5UDhPRQlCbHeq/Nf8yuG9zOxhDsXqX/mi2PX1axgsQxMm88el7HLiJAzglAnflYxw==", + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.90.0.tgz", + "integrity": "sha512-MvYmpHyJqUJjEvkZbJHdwfdL45cGn8EVBkYmoXFiaYFW9suoD3dbJl5mBdpy0DVgdjlyddmldbRtk3cCgETadQ==", "cpu": [ "arm" ], @@ -2977,9 +2980,9 @@ } }, "node_modules/@oxlint/binding-linux-arm64-gnu": { - "version": "1.89.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.89.0.tgz", - "integrity": "sha512-v9ENzHvwrQoYSWTtviH1pOYc7R9UovDxVofnck50cNmPihRRTcrg5X0UlRbLOZ7IVtxPOlh2hb7kDO+FpE8SfQ==", + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.90.0.tgz", + "integrity": "sha512-L13Sy+Kg0nZhwTsToniqjUFPA2TuM6962qCZ5Wp5KeB517Tw7ApdLTTtlb+bF86fcZZa2EtvCVvAJxk4Dya/MQ==", "cpu": [ "arm64" ], @@ -2997,9 +3000,9 @@ } }, "node_modules/@oxlint/binding-linux-arm64-musl": { - "version": "1.89.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.89.0.tgz", - "integrity": "sha512-vfffe8mQ484AzVadlUOFpgL+JDwhvs9hrzxTgQP1FVfi4ZEUUYeffXlzC9dFV7Dgnu5s/KrNKvfmG6HxBd/ISQ==", + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.90.0.tgz", + "integrity": "sha512-wFDdZWGpzIPFdJFjSLWnKB0TGzFriTKUIrl04iMqMyM9BBIJTuerfCHYa0WpDSjr5x3lcW/CHLyacSt886WpGg==", "cpu": [ "arm64" ], @@ -3017,9 +3020,9 @@ } }, "node_modules/@oxlint/binding-linux-ppc64-gnu": { - "version": "1.89.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.89.0.tgz", - "integrity": "sha512-Buonlp4hVS7RGNP5M7bPpGNQrenyzA2C64JcO/oz9C1c6dGOXJ7VEAnaiAMY7voou0JYyCJdvDVlFQc5DZX3DQ==", + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.90.0.tgz", + "integrity": "sha512-FRywfn+MoKn8xpBSzgNwNVliSKRI2QXkxi/iF7o6FEQs8Bh8z2RpVTaFAcO3pchXjvB2eGNFIoLLQABIQ+u7VA==", "cpu": [ "ppc64" ], @@ -3037,9 +3040,9 @@ } }, "node_modules/@oxlint/binding-linux-riscv64-gnu": { - "version": "1.89.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.89.0.tgz", - "integrity": "sha512-1eVwJJrJ3lYIPT+cm7BcBBZZXY6IQZP/oyonaruTor/MrVyWOpvnwt1oD5CLdsHg5aVBIxXodQSvt+1gK6ck5w==", + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.90.0.tgz", + "integrity": "sha512-IS2PaUgpc/qeLADTyhUNmdx3x4ijs9pjJdPKBYHaT+mAE7k3FsjnHPTjhpsak1b++h6CIPhv6WX9Z+y7Pw0voQ==", "cpu": [ "riscv64" ], @@ -3057,9 +3060,9 @@ } }, "node_modules/@oxlint/binding-linux-riscv64-musl": { - "version": "1.89.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.89.0.tgz", - "integrity": "sha512-fRQQsKUGShdEL5Fl9o9uhj3grkZbeXKLv0icQ5tspUCNYSDy8wzzEcP1WC2bKdTQ0rSszbKlO3iiHORxV22DLg==", + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.90.0.tgz", + "integrity": "sha512-s7JFKRkGS8O7i2ZVODfuO++NC7sH6HDqpMn8lYxw91w0SXJOxnAVjQ8utvmDwLMMT0jaSkuOKuUYJDGDtu4sHQ==", "cpu": [ "riscv64" ], @@ -3077,9 +3080,9 @@ } }, "node_modules/@oxlint/binding-linux-s390x-gnu": { - "version": "1.89.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.89.0.tgz", - "integrity": "sha512-dEtYjIy6+fHcjKklsmtmMkZRhwHr2UbzjmpvuPtQYSeN9awQ2hx5JLms5Wvri+8P/NZtD8v5hfgHALUql+/Ycg==", + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.90.0.tgz", + "integrity": "sha512-b0GxrOQr5VONCkd7KLSqmP6b6nqZuTpuS8QPQHzltmqdXP5bY0Jl0CZ0QmlCTrS3ExaLYbioFYOARzNbYvcApQ==", "cpu": [ "s390x" ], @@ -3097,9 +3100,9 @@ } }, "node_modules/@oxlint/binding-linux-x64-gnu": { - "version": "1.89.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.89.0.tgz", - "integrity": "sha512-hqjwIj5KOYM8Wjef/p7IruNi1iByLhSzbMBk62y22P/E8q7Jj0Pp+DeUTKTRYyZYCDtU1wEGlnWOnttHbyNIww==", + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.90.0.tgz", + "integrity": "sha512-iaJeShJtgUp8rZbt9YUdTAGOpmX5KogCLba17Jp1tWdJ3F4M3D2g2TKUyI1FMTHxKH9NNHuQcuC2kZRg6IV7yQ==", "cpu": [ "x64" ], @@ -3117,9 +3120,9 @@ } }, "node_modules/@oxlint/binding-linux-x64-musl": { - "version": "1.89.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.89.0.tgz", - "integrity": "sha512-iAQN3e6o9UgUCcdGwqRx/jYcKbG3HK7ur3rH6lTqVcQeNGZ85AeWIg6cgn/edwFQ0ofEZZItMwkzyCVTahnvEA==", + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.90.0.tgz", + "integrity": "sha512-/aZoXnL5WBLCXuzfHqvEFXcm4iwYmr/cYByHDTXzLFzJdE5DC7bm6phpWqHv6BeqpVgY9IpBAxGDv2WEkl5JCg==", "cpu": [ "x64" ], @@ -3137,9 +3140,9 @@ } }, "node_modules/@oxlint/binding-openharmony-arm64": { - "version": "1.89.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.89.0.tgz", - "integrity": "sha512-MNxjEByz3N/7Q4qFQuPWvc14TpN9LoD3pECEDsJTZbP/wyB7U+hduUXEl7QAbbgVezFwsb9sWtNUO6XVBndOhg==", + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.90.0.tgz", + "integrity": "sha512-SMhk7ozwoxYDKfYqGZCQEfULOKDneF9uhaoIf2neoDLWvNtjY9yVeL7YHmE5L9WdKvMpWNwwZxhenxBu6AgfOg==", "cpu": [ "arm64" ], @@ -3154,9 +3157,9 @@ } }, "node_modules/@oxlint/binding-win32-arm64-msvc": { - "version": "1.89.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.89.0.tgz", - "integrity": "sha512-xxe+i9wfuFso/VnoL4mfj0UlzE+cfCFiUB06vkXCMyQdgVVmItF5FfAF4tATAktSU6uHruWR+FjqvAT9cHZZzg==", + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.90.0.tgz", + "integrity": "sha512-KBvR3A9fO7fCi2vCP8ifWvlia7tGEQV9NaYh+IGwa/7Groy48Mt/UaYqMK9/zOtAWl20BUCf6RPqzJ32fRw33Q==", "cpu": [ "arm64" ], @@ -3171,9 +3174,9 @@ } }, "node_modules/@oxlint/binding-win32-ia32-msvc": { - "version": "1.89.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.89.0.tgz", - "integrity": "sha512-saFy07cO94E664glwgLmKLI40HgTL1PY86y4T6+ud4niX4H0Kdqjcmqmjps7mQNdBytV5guGbXDdyQtRfeLbLA==", + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.90.0.tgz", + "integrity": "sha512-+x42rDHQGbTCuT3EzpWAzhP3NoVPE/cJsG4gnrakOSg0olV+GCdMpPU23Nvrl3zZMnXxFZdTtDv4D3SuaA5ZeQ==", "cpu": [ "ia32" ], @@ -3188,9 +3191,9 @@ } }, "node_modules/@oxlint/binding-win32-x64-msvc": { - "version": "1.89.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.89.0.tgz", - "integrity": "sha512-hLFJ/o0f/dTJh/EW0vmcbREYSQO9tYgfiAAlsOkiLFiJuRRWvYtFELUXN5o2ijpSo/tlO1n+YlWN58tZwPG18A==", + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.90.0.tgz", + "integrity": "sha512-30JiaFEBUB9peEOcvVM8ggo88SoN/IdSr9DW505JdBji/R6dM66gKEiG65hw6qcaF2f4mO0cEJ+RwRZprmun9g==", "cpu": [ "x64" ], @@ -3682,9 +3685,9 @@ } }, "node_modules/@rc-component/segmented/node_modules/@babel/runtime": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-8.0.5.tgz", - "integrity": "sha512-7NK+Lz3spQ52XsUGTxIEVU4jYN2/dIaX8sTxRFAUtYmttYZnVh3aehiihv+/Gb7+duMNHl2OrFcBQRyOHScxpg==", + "version": "8.0.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-8.0.10.tgz", + "integrity": "sha512-0QyWQOIhl2onOgg96BuOYV+oCgLINVDPC8RkVPCiQhLILVqpefDUmcY+aAsW6vp7fcuTZL6SXXlFrSDCTFIu1w==", "license": "MIT" }, "node_modules/@rc-component/select": { @@ -3914,9 +3917,9 @@ } }, "node_modules/@rc-component/virtual-list": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/@rc-component/virtual-list/-/virtual-list-1.5.2.tgz", - "integrity": "sha512-igfe7NP9cOmDAhLy5z+Juz8fhjOMzMbF7TRoR/yqXIsbU6XZm4TU7ogcm5O553P0rLF6n0j5F6J+n15Izb1tqg==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@rc-component/virtual-list/-/virtual-list-1.6.0.tgz", + "integrity": "sha512-aHPlTWVtfgDmzb4oJNrqBW1yeEdFeIS28EoJLQ/dGBgZPRlrQ84c2Rk/Il6JxDUV5P7FDtNFD0aXc7EB22kR3Q==", "license": "MIT", "dependencies": { "@babel/runtime": "^8.0.0", @@ -3933,9 +3936,9 @@ } }, "node_modules/@rc-component/virtual-list/node_modules/@babel/runtime": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-8.0.5.tgz", - "integrity": "sha512-7NK+Lz3spQ52XsUGTxIEVU4jYN2/dIaX8sTxRFAUtYmttYZnVh3aehiihv+/Gb7+duMNHl2OrFcBQRyOHScxpg==", + "version": "8.0.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-8.0.10.tgz", + "integrity": "sha512-0QyWQOIhl2onOgg96BuOYV+oCgLINVDPC8RkVPCiQhLILVqpefDUmcY+aAsW6vp7fcuTZL6SXXlFrSDCTFIu1w==", "license": "MIT" }, "node_modules/@remix-run/route-pattern": { @@ -3945,9 +3948,9 @@ "license": "MIT" }, "node_modules/@rolldown/binding-android-arm-eabi": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.15.tgz", - "integrity": "sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.16.tgz", + "integrity": "sha512-ia3DwAs0OsMGS7+IConptSiyk9pEXkOx/AbJ2n/lAGxH3QkWQus1t8d7gnAT38FCU2sQu8lUbeR6AiZFv4JxaA==", "cpu": [ "arm" ], @@ -3962,9 +3965,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.15.tgz", - "integrity": "sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.16.tgz", + "integrity": "sha512-kx5wBiMB6E7TEvpl2B9gjLU004BkvEm2Dr941BFjx/SontPwSzNuQUIoVKxHHoSjpFIfHMmcguyLOtyn0hObRg==", "cpu": [ "arm64" ], @@ -3979,9 +3982,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.15.tgz", - "integrity": "sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.16.tgz", + "integrity": "sha512-qslZrXnKJU4cUpjoAmtAo/KQM9e2IRMlmpig6Zpwj+8KWxW8v+nkAf9216Bsek9lOC1jvg2yCjygCFo2f83msQ==", "cpu": [ "arm64" ], @@ -3996,9 +3999,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.15.tgz", - "integrity": "sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.16.tgz", + "integrity": "sha512-FdF+y48ND13eAyM2mQn+85kvAqNlvRWROy2rhgA463X/0Gv4vGtazZtfaF1mRBLscYvtOlh4QnLjOWiFzX69OQ==", "cpu": [ "x64" ], @@ -4013,9 +4016,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.15.tgz", - "integrity": "sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.16.tgz", + "integrity": "sha512-ddjqFM5e95AgLbUPtKIS7V1KTVlA0RUgCVr5mn+/IEhsdy3pr+Vuj/HZ7nlMN2FyxIwUktOH1LEPIQXO1v9y1Q==", "cpu": [ "x64" ], @@ -4030,9 +4033,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.15.tgz", - "integrity": "sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.16.tgz", + "integrity": "sha512-tHdKWbFkFBI687iim3LIyIvyL7j66VvQYkDPss5Stn/qyE2tWkR1YD0dXmXPFKKqT/o5ZEJVsaRsWS2meQwGOA==", "cpu": [ "arm" ], @@ -4047,9 +4050,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.15.tgz", - "integrity": "sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.16.tgz", + "integrity": "sha512-FJiLGAblPBMlBEdbOqw6kW6QcXDWAf/oJu7gYs9ZJZwmg+K+/zjFLxPTIY9izJUzxzc24FHvmseyA0IvJSl17w==", "cpu": [ "arm64" ], @@ -4067,9 +4070,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.15.tgz", - "integrity": "sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.16.tgz", + "integrity": "sha512-hrJpblMsrQ58U9veWpbXzkhV5zStunds6HWLprqq1qauvgBr1E6P+24kLj6lSLI9vjRn/RYpjprhkHQPKGWpNg==", "cpu": [ "arm64" ], @@ -4087,9 +4090,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.15.tgz", - "integrity": "sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.16.tgz", + "integrity": "sha512-qJd/dC6j6CwLG9/s/RP2nb1RZ0GIgj7Ioj7x/SYa3RzjVrlzrmH0fhIOpfvqQDeIsdP0loi+kHaMhJzY1PBIaA==", "cpu": [ "ppc64" ], @@ -4107,9 +4110,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.15.tgz", - "integrity": "sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.16.tgz", + "integrity": "sha512-Vx7Xg5uUJuNcujJjI70OICaH0EYj80BtOLZTSN7uOYg8QOLlyKnaG0IW7Fn/J0IpJsaKzmDKMhOQv1JBM9m9vg==", "cpu": [ "s390x" ], @@ -4127,9 +4130,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.15.tgz", - "integrity": "sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.16.tgz", + "integrity": "sha512-TNKhgE3uJTsTzatUSSYt0Kgo+Oa2uSlSjz4r1aEIea7HX6ULBym4fAlYUhG1i6pjNf2njfCUAfKwr6C3E9Xg1w==", "cpu": [ "x64" ], @@ -4147,9 +4150,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.15.tgz", - "integrity": "sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.16.tgz", + "integrity": "sha512-7chXdpjXZ8qG0+mIxECN3cT9kvhZ3/oZhMX6Pp876P7CDrIy8nJswva6Mq357JR7eYXfuskwZxMeV1SzoZdHrA==", "cpu": [ "x64" ], @@ -4167,9 +4170,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.15.tgz", - "integrity": "sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.16.tgz", + "integrity": "sha512-Kwjt+7uNTJK5/MDkGWGVO2KQPnj8yAV75dMpfwyHZrJSXWHrniOB0aUEWlVYf3H1hOqFLIStLdM5QXq6bnKmOQ==", "cpu": [ "arm64" ], @@ -4184,9 +4187,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.15.tgz", - "integrity": "sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.16.tgz", + "integrity": "sha512-/LH6WfjDRXXJz/p5xl4TmU+jpcnwcxbKEQBW/GZE0JHfN0lGIyTtsUEGaQ/q/GikkGJ2yLKe6pwm7AnEWO8pdw==", "cpu": [ "arm64" ], @@ -4201,9 +4204,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.15.tgz", - "integrity": "sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.16.tgz", + "integrity": "sha512-1lv1KvX0Z871zfk7vw+dnXMMnHNyDwMzWG8Kv3HxDXMOhzCX4SHc7HK319fInP0i+0p9D8brkfzL7pJOaGl+tw==", "cpu": [ "x64" ], @@ -4225,9 +4228,9 @@ "license": "MIT" }, "node_modules/@rollup/pluginutils": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz", - "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==", + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.5.1.tgz", + "integrity": "sha512-P9ZvnfouRu4MSC+ilERaw6FmZs8zqKhpHxEs4aGDGWQAMzQGXnhvCSvoNwokfNvdt7t3Y5Y4p6gStQgezeIqug==", "dev": true, "license": "MIT", "dependencies": { @@ -4414,9 +4417,9 @@ "license": "MIT" }, "node_modules/@storybook/addon-a11y": { - "version": "10.6.3", - "resolved": "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-10.6.3.tgz", - "integrity": "sha512-nqvjpmu5g+PPtEKwgE0qAi4wfuQ9yngOb/1Gm24pBBZOuRsd3UMEBjKORSuAGrIAz9X3LCFfQi7sCcBFXvFbwg==", + "version": "10.6.5", + "resolved": "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-10.6.5.tgz", + "integrity": "sha512-7dEH3OHKdE+0zakqEy50KtfhPORIm1xnhWeehyROO1Ct7Rx9s7cYcx6edUZvh0tCuX5VhDnDmkvQV+dbrPDmCA==", "dev": true, "license": "MIT", "dependencies": { @@ -4428,19 +4431,19 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^10.6.3" + "storybook": "^10.6.5" } }, "node_modules/@storybook/addon-docs": { - "version": "10.6.3", - "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.6.3.tgz", - "integrity": "sha512-365f712au4w/xvFqEKY6TIvTeGGAY7FKRB8zP4CFa3VSv9oyxHaq/ublFg4YDbCeRagppwl1PZ6k4+PoUor3+A==", + "version": "10.6.5", + "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.6.5.tgz", + "integrity": "sha512-Kt0pGLA+nwdu2g80p8jbdoiiKXxwQbhrY6uzMzq/0G1lytxpJUzrOQAM+GDcEbqyvpKr0u5d1q05/LPem/uooQ==", "dev": true, "license": "MIT", "dependencies": { "@mdx-js/react": "^3.0.0", "@storybook/icons": "^2.0.2", - "@storybook/react-dom-shim": "10.6.3", + "@storybook/react-dom-shim": "10.6.5", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "ts-dedent": "^2.0.0", @@ -4452,7 +4455,7 @@ }, "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "^10.6.3" + "storybook": "^10.6.5" }, "peerDependenciesMeta": { "@types/react": { @@ -4461,9 +4464,9 @@ } }, "node_modules/@storybook/addon-vitest": { - "version": "10.6.3", - "resolved": "https://registry.npmjs.org/@storybook/addon-vitest/-/addon-vitest-10.6.3.tgz", - "integrity": "sha512-ANz0SHAi9OTtrZt+YmcQvP7tcUZrTSm8Gq/nI7p9ZRyHAUOTdGoCex2cPyLc3mEqZTCBnaYXR6ZhURlpGsh9dQ==", + "version": "10.6.5", + "resolved": "https://registry.npmjs.org/@storybook/addon-vitest/-/addon-vitest-10.6.5.tgz", + "integrity": "sha512-0N0E7TQgmdrPNCPqNiVdTWOEQ1Nes+YsUphYX/abzlp6ReOyYBoQJw6mZ0b7BN5ACgdXQsr9Tth161t72k+51Q==", "dev": true, "license": "MIT", "dependencies": { @@ -4477,7 +4480,7 @@ "peerDependencies": { "@vitest/browser": "^4.0.0 || ^5.0.0", "@vitest/browser-playwright": "^4.0.0 || ^5.0.0", - "storybook": "^10.6.3", + "storybook": "^10.6.5", "vitest": "^4.0.0 || ^5.0.0" }, "peerDependenciesMeta": { @@ -4493,9 +4496,9 @@ } }, "node_modules/@storybook/builder-vite": { - "version": "10.6.3", - "resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-10.6.3.tgz", - "integrity": "sha512-1+WCZZg/fPxxyHDB5sFKniFt9/4RFUIzoNqMujv+BLRROM+gQeyUHvdvoyZOZ51+P8ee+/PgnNKHjsTupV+nOA==", + "version": "10.6.5", + "resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-10.6.5.tgz", + "integrity": "sha512-udrGjg3yt38pz4FxplypXxw1creIGS7N6QS8xfvTFM8MQNtPyojT/gZdd4fhX0dFrQx5MMuJEYJA5gUBiPsLtg==", "dev": true, "license": "MIT", "dependencies": { @@ -4506,7 +4509,7 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^10.6.3", + "storybook": "^10.6.5", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, @@ -4518,9 +4521,9 @@ "license": "MIT" }, "node_modules/@storybook/icons": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@storybook/icons/-/icons-2.1.0.tgz", - "integrity": "sha512-Fxh9vYpX9bQqFeHRiY8h2ApeRGDzRSMLwJwNZ/AIRqnyOKHxRKL+yFe+ctEkVJmuptRE9u1Hrn8ZZNHyfDKKNg==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@storybook/icons/-/icons-2.2.0.tgz", + "integrity": "sha512-FpUB/xizIrV1ILhCIYFo+TtQNN9YEoLjfcnAW0JtOXL+BcM2aisSysI0B54JMWDnljriXySBHgN6ps4s9lb75A==", "dev": true, "license": "MIT", "peerDependencies": { @@ -4528,14 +4531,14 @@ } }, "node_modules/@storybook/react": { - "version": "10.6.3", - "resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.6.3.tgz", - "integrity": "sha512-3tfAkZNdUyx3I4IjM8dgbNWg4k0gj89Hi1MPiawfTybRRWhvnX2YqV9Ut4mrud7YzRv0v+U7+ZYEvj+maUPaow==", + "version": "10.6.5", + "resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.6.5.tgz", + "integrity": "sha512-vvRjRe/O8NRiQbGyA7Ne6h2jbD6GjwNZFkMdHl+0kZH8Z/S9ZWWmKmjCySvH/OmgighdCOQ98tcfND7m65WQBg==", "dev": true, "license": "MIT", "dependencies": { "@storybook/global": "^5.0.0", - "@storybook/react-dom-shim": "10.6.3", + "@storybook/react-dom-shim": "10.6.5", "react-docgen": "^8.0.2", "react-docgen-typescript": "^2.2.2" }, @@ -4548,7 +4551,7 @@ "@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "^10.6.3", + "storybook": "^10.6.5", "typescript": ">= 4.9.x" }, "peerDependenciesMeta": { @@ -4564,9 +4567,9 @@ } }, "node_modules/@storybook/react-dom-shim": { - "version": "10.6.3", - "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.6.3.tgz", - "integrity": "sha512-cJUuPrieiTl06kzlgaU89iG+CtLAgUTt0Xdvm8qFT9yR2qwafSaswhwYWlZCNX4nWuOu7eK7uzWTwzKqyDBOWQ==", + "version": "10.6.5", + "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.6.5.tgz", + "integrity": "sha512-Qbbkp+5Otg32w8Ddav6rH6MGPiYVfEzrgn0HqbqlqS8kY+DRXo+NYXJwZTVSDSwrYPrfH+Ok2tV4uE8Zo0c3cg==", "dev": true, "license": "MIT", "funding": { @@ -4578,7 +4581,7 @@ "@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "^10.6.3" + "storybook": "^10.6.5" }, "peerDependenciesMeta": { "@types/react": { @@ -4590,16 +4593,16 @@ } }, "node_modules/@storybook/react-vite": { - "version": "10.6.3", - "resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-10.6.3.tgz", - "integrity": "sha512-geWwLi9JnaRG2t9bYBfz+94skARzzXNT1Toaf0lk7Oog13rtNK7ay2pWNm9SP6/L2S7mEjoWu9+zHLpwxXvfIw==", + "version": "10.6.5", + "resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-10.6.5.tgz", + "integrity": "sha512-RsRF1drYtCku7+PMdRnuFt3EdG8Rn8I5G8ne0cBjn24xSn5Pbo9W+y9IoyD2llAeaeCpzEEdASY12wpKd3aCQA==", "dev": true, "license": "MIT", "dependencies": { "@joshwooding/vite-plugin-react-docgen-typescript": "^0.7.0", "@rollup/pluginutils": "^5.0.2", - "@storybook/builder-vite": "10.6.3", - "@storybook/react": "10.6.3", + "@storybook/builder-vite": "10.6.5", + "@storybook/react": "10.6.5", "empathic": "^2.0.0", "magic-string": "^1.1.0", "react-docgen": "^8.0.2", @@ -4613,7 +4616,7 @@ "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "^10.6.3", + "storybook": "^10.6.5", "typescript": ">= 4.9.x", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, @@ -5309,12 +5312,12 @@ } }, "node_modules/@swaggerexpert/cookie": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@swaggerexpert/cookie/-/cookie-2.0.2.tgz", - "integrity": "sha512-DPI8YJ0Vznk4CT+ekn3rcFNq1uQwvUHZhH6WvTSPD0YKBIlMS9ur2RYKghXuxxOiqOam/i4lHJH4xTIiTgs3Mg==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@swaggerexpert/cookie/-/cookie-2.1.1.tgz", + "integrity": "sha512-JakpJ28ieo/axwdRJ8Yb1Yae52YDPswvOUIsLT5cTMB1+7GXcC4nk+UjfpvlCVrRPHJc1vK+O1ylr3Al1KXBAA==", "license": "Apache-2.0", "dependencies": { - "apg-lite": "^1.0.3" + "apg-lite": "^1.0.7" }, "engines": { "node": ">=12.20.0" @@ -5358,9 +5361,9 @@ } }, "node_modules/@tanstack/query-core": { - "version": "5.104.5", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.104.5.tgz", - "integrity": "sha512-Q94gI8Opd+CbdVNOdj8ZCyV8WqLYitrDq1t96Xc8WZT6lY0r5iRnJLXYDxMex7EEf2v+mXvn4FcjaBKSxXZQ9A==", + "version": "5.105.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.105.1.tgz", + "integrity": "sha512-B/rNlZoZ54w1dlddiUt9oEe1SLM+37c00SKLJEg9YH+7as3Y54P81o9Fh+2ujZ36tXPnQJ5oVe7AcyEWkyFhPg==", "license": "MIT", "funding": { "type": "github", @@ -5368,9 +5371,9 @@ } }, "node_modules/@tanstack/query-devtools": { - "version": "5.104.5", - "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.104.5.tgz", - "integrity": "sha512-BLVEw4lFF7aIRsfqGvI50SSR9oN46/EmlygC+3h5TUdI+fL8GnMQialL3H92TbYi7J1b/hYlUGnckBT2uG5h8w==", + "version": "5.105.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.105.1.tgz", + "integrity": "sha512-3iHdlC/S1e1W+7bemP5WN/feZapc0WW6XHXtmfY3AmWVlA20tEK4WxVXcKhh7IngjPGHlY6eXi1MVkrHuaxDzQ==", "license": "MIT", "dependencies": { "@kobalte/core": "^0.13.14", @@ -5378,7 +5381,7 @@ "@solid-primitives/resize-observer": "^2.2.0", "@solid-primitives/storage": "^1.3.11", "@tanstack/match-sorter-utils": "^9.1.2", - "@tanstack/query-core": "5.104.5", + "@tanstack/query-core": "5.105.1", "clsx": "^2.1.1", "goober": "^2.1.19", "solid-js": "^1.9.15", @@ -5391,12 +5394,12 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.104.5", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.104.5.tgz", - "integrity": "sha512-RORgFe052U+93weaNuqg7qHq7vNVNzXN/VrXqD4bbAuQCWUqOl2lV5Ui9flWk5CVvMyL/Bh27QWhzwH0aaOx9g==", + "version": "5.105.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.105.1.tgz", + "integrity": "sha512-qcu+/+PtBOjW8qU4/0l47QVmNG7hPOWsCLzP0bTRhE1X7HYa757C4732wXTUH0QhCjTV1yedX8onsCiEwlNYrA==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.104.5" + "@tanstack/query-core": "5.105.1" }, "funding": { "type": "github", @@ -5413,19 +5416,19 @@ } }, "node_modules/@tanstack/react-query-devtools": { - "version": "5.104.5", - "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.104.5.tgz", - "integrity": "sha512-2SMhSSYnOGSaPLuSTKLOj5nPoP4sqasOfkpTn5BLe6LIZuHzrpcqrJx0Rm8uUSN5Ob4lPAbh/1oWClnnGaR7Bg==", + "version": "5.105.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.105.1.tgz", + "integrity": "sha512-VJwunCp9fPbGGkbXlKn4T8w20sNdlFZoMqTjOdzqdhfk0Ujthln4LSN7xkVLCTAq0OGaV/0srQ/5esNpaktRvw==", "license": "MIT", "dependencies": { - "@tanstack/query-devtools": "5.104.5" + "@tanstack/query-devtools": "5.105.1" }, "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" }, "peerDependencies": { - "@tanstack/react-query": "^5.104.5", + "@tanstack/react-query": "^5.105.1", "@types/react": "^18 || ^19", "react": "^18 || ^19" }, @@ -5538,9 +5541,9 @@ } }, "node_modules/@testing-library/user-event": { - "version": "14.6.7", - "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.7.tgz", - "integrity": "sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==", + "version": "14.6.8", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.8.tgz", + "integrity": "sha512-vgSSqnANMnkcYR/vUNDbwS7aM3OErRLNUG8vCVVlj4oc6cKffUrccX7UAS5AmJQQNZTSibi9z5GvvRZ0aYgUCQ==", "dev": true, "license": "MIT", "engines": { @@ -5650,9 +5653,9 @@ "license": "MIT" }, "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.10.tgz", + "integrity": "sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g==", "dev": true, "license": "MIT" }, @@ -5680,9 +5683,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "26.6.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.4.tgz", - "integrity": "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg==", + "version": "26.6.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.6.tgz", + "integrity": "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==", "dev": true, "license": "MIT", "dependencies": { @@ -6227,6 +6230,19 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@vitest/expect/node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@vitest/expect/node_modules/@vitest/utils": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", @@ -6303,16 +6319,6 @@ } } }, - "node_modules/@vitest/mocker/node_modules/@vitest/spy": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.5.tgz", - "integrity": "sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/@vitest/mocker/node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -6337,14 +6343,11 @@ } }, "node_modules/@vitest/spy": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", - "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.5.tgz", + "integrity": "sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==", "dev": true, "license": "MIT", - "dependencies": { - "tinyspy": "^4.0.3" - }, "funding": { "url": "https://opencollective.com/vitest" } @@ -6441,9 +6444,9 @@ } }, "node_modules/antd": { - "version": "6.6.7", - "resolved": "https://registry.npmjs.org/antd/-/antd-6.6.7.tgz", - "integrity": "sha512-6D9oSAot26LIHYmR0ZyquOZY4WIcgRurZ/9//1GEJoo/vWvKV3E1PJPmB3orGkRlmGxArX3vSJFxQfRKr5qHZA==", + "version": "6.6.8", + "resolved": "https://registry.npmjs.org/antd/-/antd-6.6.8.tgz", + "integrity": "sha512-e+hDj/0myqbLBn8UGS0dYhPROAENruNscWe4gzyTnwx7yTip7D72wJUNxtsTplPXwRM1Mo4tXNSgaSb3UmV3RQ==", "license": "MIT", "dependencies": { "@ant-design/colors": "^8.0.1", @@ -6505,9 +6508,9 @@ } }, "node_modules/apg-lite": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/apg-lite/-/apg-lite-1.0.5.tgz", - "integrity": "sha512-SlI+nLMQDzCZfS39ihzjGp3JNBQfJXyMi6cg9tkLOCPVErgFsUIAEdO9IezR7kbP5Xd0ozcPNQBkf9TO5cHgWw==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/apg-lite/-/apg-lite-1.0.7.tgz", + "integrity": "sha512-wehJttEme63zE8FsrFThE8Ozmx2u8brywefDNb9oVPUQJupDNBFOOyhbpQSI2P+k/yqKhdgx2DErLfy4Pswycg==", "license": "BSD-2-Clause" }, "node_modules/argparse": { @@ -6619,9 +6622,9 @@ } }, "node_modules/axios": { - "version": "1.20.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", - "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.21.0.tgz", + "integrity": "sha512-YCzXMB53Q9PZBsh7yN7wtapit4WepwcHS2fP8QhGsBxSz5YtgYobV7GGlWlAJi+RxfU77GQTQi2DSLUma42EYA==", "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", @@ -6660,9 +6663,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.11.31", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.31.tgz", - "integrity": "sha512-b0k7wXlWjpuL/Uxb/pYlpjY0ej5BjAmurtPUk3eRGuloI4yjK32m8TiKUQ81l1ptcLjKS/KH2+uqIolZti1x4Q==", + "version": "2.11.34", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.34.tgz", + "integrity": "sha512-Aqr8mA1kIvyJ3UY5YAY7ib8LSo1DmDMbMnVqGPtsKW1LxaFQFwn9Eb3yvq2fUKJsRvaXjnAV7R5osqETapzNiA==", "dev": true, "license": "Apache-2.0", "bin": { @@ -6695,9 +6698,9 @@ } }, "node_modules/browserslist": { - "version": "4.29.4", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.4.tgz", - "integrity": "sha512-2TcbbiwXEq+N0kD36tykpCfyyQigUXmuwNQbnPp4Xk4PX9JOqVIKjJlIDjpH7uY6tmRGoAd+apeT1p60T8wsdA==", + "version": "4.29.5", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.5.tgz", + "integrity": "sha512-khTmKu5NGYTotqcYo8Bew2k4RAE9PuXRSFRXG2lQBrCEQHyeQ/vX6gKMxfokwywBsLiXZMnHsmWMG1pnO8J7uQ==", "dev": true, "funding": [ { @@ -6715,10 +6718,10 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.11.29", - "caniuse-lite": "^1.0.30001816", - "electron-to-chromium": "^1.5.459", - "node-releases": "^2.0.59", + "baseline-browser-mapping": "^2.11.33", + "caniuse-lite": "^1.0.30001819", + "electron-to-chromium": "^1.5.464", + "node-releases": "^2.0.60", "update-browserslist-db": "^1.3.4" }, "bin": { @@ -6816,9 +6819,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001819", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001819.tgz", - "integrity": "sha512-LFUqIIAwpzsIuevnKnsR9qnStBcgzPShTX61lBwN9uFkkf8o8zLiebEjbW8AYtZiF+1YKDWfdt/pWDtMuG8Xwg==", + "version": "1.0.30001820", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001820.tgz", + "integrity": "sha512-gfWD6MxqVCqAOwmODfZf7DchWvlrA+SRdNX7ceLfg3xRqe5ssjjpF7EgslC/0tiLiNTajdaNN/q8c/5u/7Gz8w==", "dev": true, "funding": [ { @@ -6924,13 +6927,6 @@ "node": ">=20" } }, - "node_modules/cliui/node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "dev": true, - "license": "MIT" - }, "node_modules/cliui/node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", @@ -7322,12 +7318,19 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.463", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.463.tgz", - "integrity": "sha512-nVWNU6ltFjopvOdD44J4zoBkgO0M+WZiFKKFV7EjebOH9z0KNphk6bCEjplXtJofL21ARIuuwWOCsFkBVklm6w==", + "version": "1.5.472", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.472.tgz", + "integrity": "sha512-s4+nfT29b0FKpVKoaIxl2pWsoNcn7A/nMPSrxayw4T3BsmPeV2lF9Y06BsAQeMGhCTQ4nYJ5XST7jkqe4ZEqFw==", "dev": true, "license": "ISC" }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, "node_modules/empathic": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.1.0.tgz", @@ -7831,9 +7834,9 @@ } }, "node_modules/hastscript": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", - "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.3.tgz", + "integrity": "sha512-1ALZXSmCGmW3h8AMz4R4+QKSKFi7sNgXLVwmwB8FYVtl1CwQ3WDkSAEY/d5tp1g/vbQgq2s1WlHgfO3jbw1TsA==", "license": "MIT", "dependencies": { "@types/hast": "^3.0.0", @@ -7925,9 +7928,9 @@ } }, "node_modules/i18next": { - "version": "26.5.6", - "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.5.6.tgz", - "integrity": "sha512-GFWrDwGmYhOQS3bKvjA2Ir2ob9ScEdyY1UbeQJ10jyVACGQ6XxmDYqC/lxEj/IPEJlPQYNRnYffDET6TM8mnFg==", + "version": "26.6.0", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.6.0.tgz", + "integrity": "sha512-jpb1n5386s9mbBLNF6tSNIVhbBUUNiNeDyjWte+uxHP/Wr+CEy7WBM8q29TkfQ46VbwKeJFYdQJK3pSUhLp4CQ==", "funding": [ { "type": "individual", @@ -8646,9 +8649,9 @@ } }, "node_modules/magic-string": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", - "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.5.tgz", + "integrity": "sha512-oLLC2+YvQUfAa+vxoufE/yc1icb87M9iXIRqAoR/2EhE/8cb3SF/+Gi0c2By1B+5Vz2e2vQtUvHiu1+63Znh+w==", "dev": true, "license": "MIT", "dependencies": { @@ -8890,9 +8893,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.60", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.60.tgz", - "integrity": "sha512-OmO8+GM1JUZqXET720VrJCwkjUCXmCMQm0Z3UDDRbfNLHS4NEAvY60j5jiFv0tkKw2PNNlqOLhahVmwJIOLDIQ==", + "version": "2.0.63", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.63.tgz", + "integrity": "sha512-6li2QanLsAXpIH2R6j77MzRyvOyrvdTqlKtO4CgBdHGNvN99ZZfyxszHc5o5+IZBWD5u6Hzwg83JoGXmqZNG9Q==", "dev": true, "license": "MIT", "engines": { @@ -8942,12 +8945,12 @@ } }, "node_modules/openapi-path-templating": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/openapi-path-templating/-/openapi-path-templating-2.2.1.tgz", - "integrity": "sha512-eN14VrDvl/YyGxxrkGOHkVkWEoPyhyeydOUrbvjoz8K5eIGgELASwN1eqFOJ2CTQMGCy2EntOK1KdtJ8ZMekcg==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/openapi-path-templating/-/openapi-path-templating-2.3.0.tgz", + "integrity": "sha512-rjqLJDhInqH2ZqD76oTfc2Ufc3BoKY7+E0AB3POH39E6ltsaWS5zLSiwflQi9NtFQqEUYzwWrzUjX1BIYpNpBQ==", "license": "Apache-2.0", "dependencies": { - "apg-lite": "^1.0.4" + "apg-lite": "^1.0.5" }, "engines": { "node": ">=12.20.0" @@ -9054,9 +9057,9 @@ } }, "node_modules/oxfmt": { - "version": "0.74.0", - "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.74.0.tgz", - "integrity": "sha512-G4OP7FgwGYZR5ttRpCOOfJdyRsSyvtn34vSXnjDq6Oqed5Dufg5mB44mank+51nAdHOKuTJ0lj0mksY7L+N61A==", + "version": "0.75.0", + "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.75.0.tgz", + "integrity": "sha512-/ZKvY5asfsVi9hdkOmRIumqOPu6kxlTgiJSZf54uq29kKew+Gu1FFAnEkGePyvLVW+MAYu41D6XggnzoElQzPw==", "dev": true, "license": "MIT", "dependencies": { @@ -9072,28 +9075,28 @@ "url": "https://github.com/sponsors/oxc-project" }, "optionalDependencies": { - "@oxfmt/binding-android-arm-eabi": "0.74.0", - "@oxfmt/binding-android-arm64": "0.74.0", - "@oxfmt/binding-darwin-arm64": "0.74.0", - "@oxfmt/binding-darwin-x64": "0.74.0", - "@oxfmt/binding-freebsd-x64": "0.74.0", - "@oxfmt/binding-linux-arm-gnueabihf": "0.74.0", - "@oxfmt/binding-linux-arm-musleabihf": "0.74.0", - "@oxfmt/binding-linux-arm64-gnu": "0.74.0", - "@oxfmt/binding-linux-arm64-musl": "0.74.0", - "@oxfmt/binding-linux-ppc64-gnu": "0.74.0", - "@oxfmt/binding-linux-riscv64-gnu": "0.74.0", - "@oxfmt/binding-linux-riscv64-musl": "0.74.0", - "@oxfmt/binding-linux-s390x-gnu": "0.74.0", - "@oxfmt/binding-linux-x64-gnu": "0.74.0", - "@oxfmt/binding-linux-x64-musl": "0.74.0", - "@oxfmt/binding-openharmony-arm64": "0.74.0", - "@oxfmt/binding-win32-arm64-msvc": "0.74.0", - "@oxfmt/binding-win32-ia32-msvc": "0.74.0", - "@oxfmt/binding-win32-x64-msvc": "0.74.0" + "@oxfmt/binding-android-arm-eabi": "0.75.0", + "@oxfmt/binding-android-arm64": "0.75.0", + "@oxfmt/binding-darwin-arm64": "0.75.0", + "@oxfmt/binding-darwin-x64": "0.75.0", + "@oxfmt/binding-freebsd-x64": "0.75.0", + "@oxfmt/binding-linux-arm-gnueabihf": "0.75.0", + "@oxfmt/binding-linux-arm-musleabihf": "0.75.0", + "@oxfmt/binding-linux-arm64-gnu": "0.75.0", + "@oxfmt/binding-linux-arm64-musl": "0.75.0", + "@oxfmt/binding-linux-ppc64-gnu": "0.75.0", + "@oxfmt/binding-linux-riscv64-gnu": "0.75.0", + "@oxfmt/binding-linux-riscv64-musl": "0.75.0", + "@oxfmt/binding-linux-s390x-gnu": "0.75.0", + "@oxfmt/binding-linux-x64-gnu": "0.75.0", + "@oxfmt/binding-linux-x64-musl": "0.75.0", + "@oxfmt/binding-openharmony-arm64": "0.75.0", + "@oxfmt/binding-win32-arm64-msvc": "0.75.0", + "@oxfmt/binding-win32-ia32-msvc": "0.75.0", + "@oxfmt/binding-win32-x64-msvc": "0.75.0" }, "peerDependencies": { - "@astrojs/compiler-rs": "^0.4.0 || ^0.5.0", + "@astrojs/compiler-rs": "^0.5.0", "svelte": "^5.0.0", "vite-plus": "*" }, @@ -9110,9 +9113,9 @@ } }, "node_modules/oxlint": { - "version": "1.89.0", - "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.89.0.tgz", - "integrity": "sha512-a25hkfo61okGPvJz432fRIPoxlKtNnOVy9w4rzrmGgNm4iVTPwpSQlmdqNWnp6p9QyyPVfAZRgnf5XvSBZFk/A==", + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.90.0.tgz", + "integrity": "sha512-RkTN9EmgIUpLuhNoLhO3OliMGJlvb3Dk7XVjBZnfoMCHrVpuz5ESd97klfiyYeV7XfA2lNLnDCPMkUI+Qw5How==", "dev": true, "license": "MIT", "bin": { @@ -9125,28 +9128,28 @@ "url": "https://github.com/sponsors/oxc-project" }, "optionalDependencies": { - "@oxlint/binding-android-arm-eabi": "1.89.0", - "@oxlint/binding-android-arm64": "1.89.0", - "@oxlint/binding-darwin-arm64": "1.89.0", - "@oxlint/binding-darwin-x64": "1.89.0", - "@oxlint/binding-freebsd-x64": "1.89.0", - "@oxlint/binding-linux-arm-gnueabihf": "1.89.0", - "@oxlint/binding-linux-arm-musleabihf": "1.89.0", - "@oxlint/binding-linux-arm64-gnu": "1.89.0", - "@oxlint/binding-linux-arm64-musl": "1.89.0", - "@oxlint/binding-linux-ppc64-gnu": "1.89.0", - "@oxlint/binding-linux-riscv64-gnu": "1.89.0", - "@oxlint/binding-linux-riscv64-musl": "1.89.0", - "@oxlint/binding-linux-s390x-gnu": "1.89.0", - "@oxlint/binding-linux-x64-gnu": "1.89.0", - "@oxlint/binding-linux-x64-musl": "1.89.0", - "@oxlint/binding-openharmony-arm64": "1.89.0", - "@oxlint/binding-win32-arm64-msvc": "1.89.0", - "@oxlint/binding-win32-ia32-msvc": "1.89.0", - "@oxlint/binding-win32-x64-msvc": "1.89.0" + "@oxlint/binding-android-arm-eabi": "1.90.0", + "@oxlint/binding-android-arm64": "1.90.0", + "@oxlint/binding-darwin-arm64": "1.90.0", + "@oxlint/binding-darwin-x64": "1.90.0", + "@oxlint/binding-freebsd-x64": "1.90.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.90.0", + "@oxlint/binding-linux-arm-musleabihf": "1.90.0", + "@oxlint/binding-linux-arm64-gnu": "1.90.0", + "@oxlint/binding-linux-arm64-musl": "1.90.0", + "@oxlint/binding-linux-ppc64-gnu": "1.90.0", + "@oxlint/binding-linux-riscv64-gnu": "1.90.0", + "@oxlint/binding-linux-riscv64-musl": "1.90.0", + "@oxlint/binding-linux-s390x-gnu": "1.90.0", + "@oxlint/binding-linux-x64-gnu": "1.90.0", + "@oxlint/binding-linux-x64-musl": "1.90.0", + "@oxlint/binding-openharmony-arm64": "1.90.0", + "@oxlint/binding-win32-arm64-msvc": "1.90.0", + "@oxlint/binding-win32-ia32-msvc": "1.90.0", + "@oxlint/binding-win32-x64-msvc": "1.90.0" }, "peerDependencies": { - "oxlint-tsgolint": ">=7.0.2003", + "oxlint-tsgolint": ">=7.0.2005", "vite-plus": "*" }, "peerDependenciesMeta": { @@ -9159,21 +9162,21 @@ } }, "node_modules/oxlint-tsgolint": { - "version": "7.0.2004", - "resolved": "https://registry.npmjs.org/oxlint-tsgolint/-/oxlint-tsgolint-7.0.2004.tgz", - "integrity": "sha512-vQ9WDF3fiZJvRdSDosNOflerOCOpwol6vWzVDxW76RcU19cjTh7engvkHLvmv+tzedR8fAiJUVUGr6IVZdkzXQ==", + "version": "7.0.2005", + "resolved": "https://registry.npmjs.org/oxlint-tsgolint/-/oxlint-tsgolint-7.0.2005.tgz", + "integrity": "sha512-6ccPGu4u4J9bwYPHXBFPee3RZrX3BboRdU7k14zRDDB1lvN3O7J/vPRFdaEfa1+CWx4xnQQOp4ctoaF66oO0uw==", "dev": true, "license": "MIT", "bin": { "tsgolint": "bin/tsgolint.js" }, "optionalDependencies": { - "@oxlint-tsgolint/darwin-arm64": "7.0.2004", - "@oxlint-tsgolint/darwin-x64": "7.0.2004", - "@oxlint-tsgolint/linux-arm64": "7.0.2004", - "@oxlint-tsgolint/linux-x64": "7.0.2004", - "@oxlint-tsgolint/win32-arm64": "7.0.2004", - "@oxlint-tsgolint/win32-x64": "7.0.2004" + "@oxlint-tsgolint/darwin-arm64": "7.0.2005", + "@oxlint-tsgolint/darwin-x64": "7.0.2005", + "@oxlint-tsgolint/linux-arm64": "7.0.2005", + "@oxlint-tsgolint/linux-x64": "7.0.2005", + "@oxlint-tsgolint/win32-arm64": "7.0.2005", + "@oxlint-tsgolint/win32-x64": "7.0.2005" } }, "node_modules/parse-entities": { @@ -9333,9 +9336,9 @@ } }, "node_modules/postcss": { - "version": "8.5.31", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", - "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", + "version": "8.5.32", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz", + "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==", "dev": true, "funding": [ { @@ -9572,9 +9575,9 @@ } }, "node_modules/react-hook-form": { - "version": "7.90.0", - "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.90.0.tgz", - "integrity": "sha512-+NdG0FLzCPhFOp2NNxaUYLF9fyZhNjx9FTBBSYWF71/l6BBlmIc/j6xlp3DbaHWLJbIG7Zd8oznA19xJClU97w==", + "version": "7.91.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.91.0.tgz", + "integrity": "sha512-5BEBUnCBwa28AFXYTYmOaB/+CSpJ/+CWVASHEvIGUzFdHyUiPHeRhG7r5hOZ8nZi0yP7pbHdFNXO/p31EKTJFQ==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -9594,9 +9597,9 @@ } }, "node_modules/react-i18next": { - "version": "17.1.3", - "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.1.3.tgz", - "integrity": "sha512-XfaTqU09NPuBDuADQ8TEPbE0Goc2Gh6FHu0qvdFWfOWCdGQWRxRY2Uo5tDPtGBdzu4COXp0LQCkQIYa0IhJXlA==", + "version": "17.1.10", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.1.10.tgz", + "integrity": "sha512-XuUuA/rxukE4hXuR//PN308DuSQkWqKNNWBUj0dDTR087l3+6WLid3NC/w55ALrXA/dtWb7lychJ4rQLR3DCDg==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.29.7", @@ -9653,15 +9656,15 @@ } }, "node_modules/react-is": { - "version": "19.3.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.3.0.tgz", - "integrity": "sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA==", + "version": "19.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.3.1.tgz", + "integrity": "sha512-RRieF6IyxQBkcK6pP8xOHprLTPW6QjRkbGdImtqNme+/hYVwCZkLQLdlZGOhUqXwkS9IEynaGvPJtv6XB/cz0Q==", "license": "MIT" }, "node_modules/react-redux": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", - "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.1.tgz", + "integrity": "sha512-vxMLT0lalUjbZPkoTepWyKAZbjIIFssNx/CEL9lTfwQBU6XpkGSmCNdcCIrkOBUgB68uftZMnbXVb6DwGnhRKQ==", "license": "MIT", "dependencies": { "@types/use-sync-external-store": "^0.0.6", @@ -9682,9 +9685,9 @@ } }, "node_modules/react-router": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-8.5.0.tgz", - "integrity": "sha512-9nEd/deifcTJIIcUaNIyQGkyXD0D6QVB9XD2O5GgHpP3xu3U5RXm87YQ8++Wn8JQWgMBM/5+PjWPM/7n7O7WHA==", + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-8.5.1.tgz", + "integrity": "sha512-5u1eQ46QjuXjNfEnLwzrnou1Bm5dquO3xjVYkkMYCudtkicD2AQHtx+fvXzPATtPJx8TW6DR1x1TDRqsfHyZBw==", "license": "MIT", "dependencies": { "@remix-run/route-pattern": "^0.22.1", @@ -9712,9 +9715,9 @@ } }, "node_modules/react-syntax-highlighter": { - "version": "16.1.1", - "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.1.tgz", - "integrity": "sha512-PjVawBGy80C6YbC5DDZJeUjBmC7skaoEUdvfFQediQHgCL7aKyVHe57SaJGfQsloGDac+gCpTfRdtxzWWKmCXA==", + "version": "16.1.2", + "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.2.tgz", + "integrity": "sha512-4dM0+mYtfnhgwYWAvFx56fgyjIYk2yFopfiICFvMDhRfeSGRNd+ngFALdPD9q7llyGAmiE6pzOSOwI8Lf9ca1w==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.28.4", @@ -9889,13 +9892,13 @@ "license": "MIT" }, "node_modules/rolldown": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.15.tgz", - "integrity": "sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.16.tgz", + "integrity": "sha512-bWvlMpFKiTfDKfnK7FC2MPgPeWCf/Kpwsq49ApdW+FQaBZb6O6dZ4MAPbPUjp7hYQrw/RobqCzn64BRrQecGHw==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.155.0", + "@oxc-project/types": "=0.156.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -9905,27 +9908,27 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm-eabi": "1.2.15", - "@rolldown/binding-android-arm64": "1.2.15", - "@rolldown/binding-darwin-arm64": "1.2.15", - "@rolldown/binding-darwin-x64": "1.2.15", - "@rolldown/binding-freebsd-x64": "1.2.15", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.15", - "@rolldown/binding-linux-arm64-gnu": "1.2.15", - "@rolldown/binding-linux-arm64-musl": "1.2.15", - "@rolldown/binding-linux-ppc64-gnu": "1.2.15", - "@rolldown/binding-linux-s390x-gnu": "1.2.15", - "@rolldown/binding-linux-x64-gnu": "1.2.15", - "@rolldown/binding-linux-x64-musl": "1.2.15", - "@rolldown/binding-openharmony-arm64": "1.2.15", - "@rolldown/binding-win32-arm64-msvc": "1.2.15", - "@rolldown/binding-win32-x64-msvc": "1.2.15" + "@rolldown/binding-android-arm-eabi": "1.2.16", + "@rolldown/binding-android-arm64": "1.2.16", + "@rolldown/binding-darwin-arm64": "1.2.16", + "@rolldown/binding-darwin-x64": "1.2.16", + "@rolldown/binding-freebsd-x64": "1.2.16", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.16", + "@rolldown/binding-linux-arm64-gnu": "1.2.16", + "@rolldown/binding-linux-arm64-musl": "1.2.16", + "@rolldown/binding-linux-ppc64-gnu": "1.2.16", + "@rolldown/binding-linux-s390x-gnu": "1.2.16", + "@rolldown/binding-linux-x64-gnu": "1.2.16", + "@rolldown/binding-linux-x64-musl": "1.2.16", + "@rolldown/binding-openharmony-arm64": "1.2.16", + "@rolldown/binding-win32-arm64-msvc": "1.2.16", + "@rolldown/binding-win32-x64-msvc": "1.2.16" } }, "node_modules/rolldown/node_modules/@oxc-project/types": { - "version": "0.155.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz", - "integrity": "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==", + "version": "0.156.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.156.0.tgz", + "integrity": "sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw==", "dev": true, "license": "MIT", "funding": { @@ -10031,30 +10034,30 @@ } }, "node_modules/seroval": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.7.0.tgz", - "integrity": "sha512-FU+LUkmDzWNsJ6oubZFN3dGPKny53DRhPJG6nftEaXDL8MIn19+kEE1Gvc+63Go+qPOwVUlKIdKhsu2HN6tksw==", + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.7.6.tgz", + "integrity": "sha512-oKNe6A8I3lSA5J0xg9xsrWCWDpET6CFhCMVuIsCmqrjNpBXzQXDAAQI5UfuC4Ner4DIR+VAtSw7TC0AmRMqFFQ==", "license": "MIT", "engines": { "node": ">=20" } }, "node_modules/seroval-plugins": { - "version": "1.6.12", - "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.6.12.tgz", - "integrity": "sha512-+reiwp/lYxiyctSrdvX7OQ1YLrcbWYgBiZrhSFL+V43b0/DGRLNVSDyZINbQe+tWplPOI2fw2dPrdv6hcc09kQ==", + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.7.6.tgz", + "integrity": "sha512-/TPCAd6q5stAZnugD2fsvNKtM2UpgwrS8/fO1cXTgPC0EqCsGB+UfkEEsToFCfg35CDTMlWkppfr/zWnLJGpBA==", "license": "MIT", "engines": { - "node": ">=10" + "node": ">=20" }, "peerDependencies": { "seroval": "^1.0" } }, "node_modules/set-cookie-parser": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz", - "integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==", + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.2.2.tgz", + "integrity": "sha512-df017qMeYuXo4emL09nlyMpi2i2MtEIKS/6jtXH3vmjFgIRsOi852/5RmhJ4EkvN3ZF8jXadM/EkTPXgc8yPIw==", "dev": true, "license": "MIT" }, @@ -10134,13 +10137,13 @@ } }, "node_modules/solid-js": { - "version": "1.9.17", - "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.17.tgz", - "integrity": "sha512-4GAmLOWPfNV8H9Jfn2LSFTpNEG6jiOKPiwd5lnLGQtehHaX6l3TOLzJqQ7mqwB/3k1Yy3zyumFVopmc/tm34ow==", + "version": "1.9.18", + "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.18.tgz", + "integrity": "sha512-CHGO5YFk6Y9m/1YSxVtKnXfrW7JD3Tmof8HmR4z6TmCPsS8efo8LnzIR8CQh3cUvffqYm6DWyBFfk8dQw1CNQQ==", "license": "MIT", "dependencies": { "csstype": "^3.1.0", - "seroval": "~1.6.8", + "seroval": "~1.6.11", "seroval-plugins": "~1.6.8" } }, @@ -10223,9 +10226,9 @@ "license": "MIT" }, "node_modules/storybook": { - "version": "10.6.3", - "resolved": "https://registry.npmjs.org/storybook/-/storybook-10.6.3.tgz", - "integrity": "sha512-lNBvcZFG44C98KvQHYzfq1O46m+sl94zY1+na5/9E3ihtNMbvpOgYyMFMIcWYUvOMo3OM4q9jzvB73KU20bU8w==", + "version": "10.6.5", + "resolved": "https://registry.npmjs.org/storybook/-/storybook-10.6.5.tgz", + "integrity": "sha512-qurC38Nx4PDF9YIq6GO95dpUx8a3B9AZwlcHBo4cOI9At09ifjQRsqcwcdOf2YX0cpVhFKmHkI20SWCrxqYQrg==", "dev": true, "license": "MIT", "dependencies": { @@ -10271,6 +10274,19 @@ } } }, + "node_modules/storybook/node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/storybook/node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -10334,9 +10350,9 @@ } }, "node_modules/strip-ansi/node_modules/ansi-regex": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", - "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.5.0.tgz", + "integrity": "sha512-zhvbbbdt1kbw7EJ9OQoxmQcuaNR9P5kTj7e/YdmOSU10aHWbycofJxA6jKfoEy3vlLdctOF9ZHaxLtmSm998wQ==", "dev": true, "license": "MIT", "engines": { @@ -10601,22 +10617,22 @@ } }, "node_modules/tldts": { - "version": "7.4.16", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.16.tgz", - "integrity": "sha512-QwBER5KMR86IIjpIiO7H/Z3IMJPsZ1A6RKPAqzTTgOyUQUSt9FdnKcqhTaJmkY6HVrgouZHZR0ncK5QxvmnQeg==", + "version": "7.4.19", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.19.tgz", + "integrity": "sha512-48dH6aSpQnpbzDUc4vIQZ1VjB8tkK+e8c1yx05/SZK+rkssgjEOlxDd/yU8/a4vGYQ9vRgJr9dwMq9TJ0b3X2Q==", "dev": true, "license": "MIT", "dependencies": { - "tldts-core": "^7.4.16" + "tldts-core": "^7.4.19" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "7.4.16", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.16.tgz", - "integrity": "sha512-MDolfaSJtlSK5Y0A1xl3277ekubZwobpBjugknDizI9O5Rm60a1m8k4ICK+MRsCDzPygT81mp3BBf5RKDlFRfA==", + "version": "7.4.20", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.20.tgz", + "integrity": "sha512-C630TWXZCZmLq0F2bW4QqwnaaxCQ/svpAqvJwnC4JvgEM5ohhBnGsEzTLSOXcN+xjz+kEaHS5uoWIw2klnqd0A==", "dev": true, "license": "MIT" }, @@ -10746,9 +10762,9 @@ } }, "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.9.1.tgz", + "integrity": "sha512-a4Ux9VKuIMHsOS7ENbEq6hK2ZGYIT27Rtl0E4eD9KqwxhKiQeMZE/9xb3IdYwWaSOyIBQLExhQgXDTYoiCxnWA==", "license": "0BSD" }, "node_modules/type-fest": { @@ -11234,13 +11250,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/wrap-ansi/node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "dev": true, - "license": "MIT" - }, "node_modules/wrap-ansi/node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", @@ -11260,9 +11269,9 @@ } }, "node_modules/ws": { - "version": "8.22.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", - "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "version": "8.22.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.1.tgz", + "integrity": "sha512-oyL6OPYme/CmiFvNuGzeoxCJ4nOrZE2O1FwM9MWM1AsGri6nLXB2pg+lNA8aLeXMSav0J6eIVS1j6vUPzUtK9g==", "dev": true, "license": "MIT", "engines": { diff --git a/frontend/package.json b/frontend/package.json index c78106aba..5bb2cae3d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -38,30 +38,30 @@ "@ant-design/icons": "^6.3.4", "@codemirror/lang-json": "^6.0.2", "@codemirror/theme-one-dark": "^6.1.3", - "@hookform/resolvers": "^6.0.0", + "@hookform/resolvers": "^7.0.1", "@noble/hashes": "^2.4.0", - "@tanstack/react-query": "^5.104.5", - "@tanstack/react-query-devtools": "^5.104.5", - "antd": "^6.6.7", + "@tanstack/react-query": "^5.105.1", + "@tanstack/react-query-devtools": "^5.105.1", + "antd": "^6.6.8", "codemirror": "^6.0.2", "dayjs": "^1.11.23", - "i18next": "^26.5.6", + "i18next": "^26.6.0", "otpauth": "^9.5.2", "persian-calendar-suite": "^1.5.6", "react": "^19.3.1", "react-dom": "^19.3.1", - "react-hook-form": "^7.90.0", - "react-i18next": "^17.1.3", - "react-router": "^8.5.0", - "swagger-ui-react": "^5.34.0", + "react-hook-form": "^7.91.0", + "react-i18next": "^17.1.10", + "react-router": "^8.5.1", + "swagger-ui-react": "^5.34.1", "uplot": "^1.6.32", "zod": "^4.6.7" }, "devDependencies": { - "@storybook/addon-a11y": "^10.6.3", - "@storybook/addon-docs": "^10.6.3", - "@storybook/addon-vitest": "^10.6.3", - "@storybook/react-vite": "^10.6.3", + "@storybook/addon-a11y": "^10.6.5", + "@storybook/addon-docs": "^10.6.5", + "@storybook/addon-vitest": "^10.6.5", + "@storybook/react-vite": "^10.6.5", "@testing-library/dom": "^10.4.2", "@testing-library/react": "^16.3.3", "@types/react": "^19.3.0", @@ -74,11 +74,11 @@ "jsdom": "^30.1.3", "lint-staged": "^17.6.0", "msw": "^3.0.3", - "oxfmt": "0.74.0", - "oxlint": "1.89.0", - "oxlint-tsgolint": "^7.0.2004", + "oxfmt": "0.75.0", + "oxlint": "1.90.0", + "oxlint-tsgolint": "^7.0.2005", "playwright": "^1.64.1", - "storybook": "^10.6.3", + "storybook": "^10.6.5", "typescript": "7.0.2", "vite": "8.3.5", "vitest": "^5.0.5" From 72c9092d4ab95e7ba4abb41dc9d38cb1fbe05ec9 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 14:55:14 +0100 Subject: [PATCH 118/125] feat(xray): update xray-core to v26.10.31 Bump xtls/xray-core to a4a3dff5b20c (v26.10.31) and the three binary pins in DockerInit.sh and release.yml in lockstep, so the in-process conf.Build() validation and the child binary agree. The other Go modules move to their latest patch/minor releases in the same go.mod. 26.10.31 moved ResolveLuaFile from common/platform to common/platform/filesystem; childLuaScriptPath now calls it there. The lookup order is unchanged, the extension check is now case-insensitive. --- .github/workflows/release.yml | 4 +- DockerInit.sh | 2 +- go.mod | 38 +++++++++--------- go.sum | 76 +++++++++++++++++------------------ internal/xray/api.go | 4 +- 5 files changed, 62 insertions(+), 62 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6230b2eca..0c24efecf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -115,7 +115,7 @@ jobs: cd x-ui/bin # Download dependencies - Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.10.21/" + Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.10.31/" if [ "${{ matrix.platform }}" == "amd64" ]; then fetch ${Xray_URL}Xray-linux-64.zip unzip Xray-linux-64.zip @@ -283,7 +283,7 @@ jobs: cd x-ui\bin # Download Xray for Windows - $Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.10.21/" + $Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.10.31/" Invoke-WebRequest @retry -Uri "${Xray_URL}Xray-windows-64.zip" -OutFile "Xray-windows-64.zip" Expand-Archive -Path "Xray-windows-64.zip" -DestinationPath . Remove-Item "Xray-windows-64.zip" diff --git a/DockerInit.sh b/DockerInit.sh index ee8361f9b..e4e4ea879 100755 --- a/DockerInit.sh +++ b/DockerInit.sh @@ -52,7 +52,7 @@ if [ -z "$MTG_MULTI_VER" ]; then fi mkdir -p build/bin cd build/bin -curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.10.21/Xray-linux-${XRAY_ZIP}.zip" +curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.10.31/Xray-linux-${XRAY_ZIP}.zip" unzip "Xray-linux-${XRAY_ZIP}.zip" rm -f "Xray-linux-${XRAY_ZIP}.zip" geoip.dat geosite.dat mv xray "${XRAY_BIN}" diff --git a/go.mod b/go.mod index 77e23ff91..e447fee5c 100644 --- a/go.mod +++ b/go.mod @@ -5,33 +5,33 @@ go 1.27.3 require ( github.com/amnezia-vpn/amneziawg-go/v3 v3.1.20260828 github.com/apernet/quic-go v0.61.1-0.20260806010916-184d081eef3e - github.com/gin-contrib/gzip v1.2.8 - github.com/gin-contrib/sessions v1.1.2 + github.com/gin-contrib/gzip v1.2.9 + github.com/gin-contrib/sessions v1.1.3 github.com/gin-gonic/gin v1.12.0 github.com/go-ldap/ldap/v3 v3.4.15 github.com/go-playground/validator/v10 v10.30.5 - github.com/goccy/go-json v0.11.2 + github.com/goccy/go-json v0.12.0 github.com/goccy/go-yaml v1.19.2 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 - github.com/jackc/pgx/v5 v5.11.0 + github.com/jackc/pgx/v5 v5.12.0 github.com/joho/godotenv v1.5.1 github.com/klauspost/compress v1.20.3 github.com/mattn/go-sqlite3 v1.14.52 github.com/mymmrac/telego v1.12.1 - github.com/nicksnyder/go-i18n/v2 v2.6.1 + github.com/nicksnyder/go-i18n/v2 v2.7.0 github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 github.com/quic-go/quic-go v0.64.1 github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af github.com/robfig/cron/v3 v3.0.1 - github.com/shirou/gopsutil/v4 v4.26.9 + github.com/shirou/gopsutil/v4 v4.26.10 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e - github.com/valyala/fasthttp v1.76.0 + github.com/valyala/fasthttp v1.77.0 github.com/xlzd/gotp v0.1.0 - github.com/xtls/xray-core v1.260327.1-0.20261021115930-1b1647cd9ddf + github.com/xtls/xray-core v1.260327.1-0.20261031132019-a4a3dff5b20c go.uber.org/atomic v1.12.0 golang.org/x/crypto v0.58.0 - golang.org/x/net v0.61.0 + golang.org/x/net v0.62.0 golang.org/x/sync v0.24.0 golang.org/x/sys v0.49.0 golang.org/x/text v0.43.0 @@ -47,14 +47,14 @@ require ( require ( github.com/Azure/go-ntlmssp v0.1.1 // indirect - github.com/andybalholm/brotli v1.2.6 // indirect + github.com/andybalholm/brotli v1.2.7 // indirect github.com/bytedance/gopkg v0.1.4 // indirect - github.com/bytedance/sonic v1.15.4 // indirect + github.com/bytedance/sonic v1.15.5 // indirect github.com/bytedance/sonic/loader v0.5.3 // indirect github.com/cloudwego/base64x v0.1.7 // indirect github.com/ebitengine/purego v0.11.2 // indirect github.com/gabriel-vasile/mimetype v1.4.16 // indirect - github.com/gin-contrib/sse v1.1.2 // indirect + github.com/gin-contrib/sse v1.2.0 // indirect github.com/go-asn1-ber/asn1-ber v1.5.8 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-playground/locales v0.14.2 // indirect @@ -78,18 +78,18 @@ require ( github.com/leodido/go-urn v1.5.0 // indirect github.com/libp2p/go-nat v1.0.1-0.20250821073202-01afc089f138 // indirect github.com/libp2p/go-netroute v0.4.0 // indirect - github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 // indirect + github.com/lufia/plan9stats v0.0.0-20261101121723-be84dd9509a0 // indirect github.com/mattn/go-isatty v0.0.24 // indirect github.com/miekg/dns v1.1.73 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect - github.com/molecule-man/go-brrr v1.3.0 // indirect + github.com/molecule-man/go-brrr v1.3.1 // indirect github.com/pelletier/go-toml/v2 v2.4.3 // indirect github.com/pion/dtls/v3 v3.1.15 // indirect github.com/pion/logging v0.2.4 // indirect github.com/pion/stun/v3 v3.1.7 // indirect - github.com/pion/transport/v4 v4.1.1 // indirect - github.com/pion/transport/v5 v5.1.1 // indirect + github.com/pion/transport/v4 v4.1.3 // indirect + github.com/pion/transport/v5 v5.1.4 // indirect github.com/pires/go-proxyproto v0.16.0 // indirect github.com/power-devops/perfstat v0.0.0-20260916203055-22a1a467d9f0 // indirect github.com/quic-go/qpack v0.6.0 // indirect @@ -109,13 +109,13 @@ require ( go.mongodb.org/mongo-driver/v2 v2.9.2 // indirect go4.org/netipx v0.0.0-20260823151212-3075585bcbeb // indirect golang.org/x/arch v0.32.0 // indirect - golang.org/x/crypto/x509roots/fallback v0.0.0-20261020153306-9f2b28d4331a // indirect + golang.org/x/crypto/x509roots/fallback v0.0.0-20261030153154-30a33513ff6d // indirect golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e // indirect golang.org/x/time v0.16.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect - golang.zx2c4.com/wireguard v0.0.0-20261006164505-2631ce99a06f // indirect + golang.zx2c4.com/wireguard v0.0.0-20261024220952-b3bc704ef0ee // indirect golang.zx2c4.com/wireguard/windows v1.1.1 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20261019180522-ce908d29299b // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20261026165753-2ffab32bce7e // indirect layeh.com/gopher-luar v1.0.11 // indirect lukechampine.com/blake3 v1.4.1 // indirect ) diff --git a/go.sum b/go.sum index 741fc6831..5a3c43b35 100644 --- a/go.sum +++ b/go.sum @@ -6,14 +6,14 @@ github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktp github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= github.com/amnezia-vpn/amneziawg-go/v3 v3.1.20260828 h1:D8d8gGvwXcTxUIsE4z6F6vjy4/VZddu95vMNtOygh1c= github.com/amnezia-vpn/amneziawg-go/v3 v3.1.20260828/go.mod h1:YoPc6qcOZqD7TXZ1xpedD8Sx3aSKsxN05ZqEFmXDNHk= -github.com/andybalholm/brotli v1.2.6 h1:ftYnfj6usCp+UGV5kSJ3+chpMQgU+gJf/AxsUQ52REI= -github.com/andybalholm/brotli v1.2.6/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/andybalholm/brotli v1.2.7 h1:UgafRPGMam5ySHfRkhthMqOFA6IFoeF3D7mWIDLjG5A= +github.com/andybalholm/brotli v1.2.7/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/apernet/quic-go v0.61.1-0.20260806010916-184d081eef3e h1:5mgtR5gwIgBKMiGI1QdXldZZ+SNor06Nbu1wCBulQBg= github.com/apernet/quic-go v0.61.1-0.20260806010916-184d081eef3e/go.mod h1:x7qxEvX6MCVtDuBKHj3E+88+BtrbEMuAL5qGUKItjW8= github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM= github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4= -github.com/bytedance/sonic v1.15.4 h1:FgtV/4aBHpla9AxuMpuuzVUpa/Cf3izufkxNmnEzdI8= -github.com/bytedance/sonic v1.15.4/go.mod h1:8e51yTPdY8M6t+vvGL1c2Y1xL9i+frEeIAQAEl75NUc= +github.com/bytedance/sonic v1.15.5 h1:gKWTBfYCIQpQHZ7LpwGf9Gj3N88ZN0G/GaB3EQhiGXE= +github.com/bytedance/sonic v1.15.5/go.mod h1:nn7CoK5W+LKbl22XGJrXg6SwzaFAcVs89miqzkqDwOU= github.com/bytedance/sonic/loader v0.5.3 h1:ziVGAbXQtLoaot/M6YQxntGkekxz6B/DyuJVwnTbMAA= github.com/bytedance/sonic/loader v0.5.3/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= @@ -30,12 +30,12 @@ github.com/gabriel-vasile/mimetype v1.4.16 h1:5izNFuvA37st1dDRAcA7TtmngBmRgiIPDm github.com/gabriel-vasile/mimetype v1.4.16/go.mod h1:3oFFwxX8kTklZwzIl2aQEDZT0lKY8xH92ST88bAwba4= github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344 h1:Arcl6UOIS/kgO2nW3A65HN+7CMjSDP/gofXL4CZt1V4= github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344/go.mod h1:GIjDIg/heH5DOkXY3YJ/wNhfHsQHoXGjl8G8amsYQ1I= -github.com/gin-contrib/gzip v1.2.8 h1:wDb1thtVsSUe+126xHdgLiWcZqHdWXiBzdZj2yhdUM8= -github.com/gin-contrib/gzip v1.2.8/go.mod h1:OiNBR7FxAwHN3iwoDtjj2RlypuONCk48zmtagvASfrc= -github.com/gin-contrib/sessions v1.1.2 h1:utWRqOHT/kbqxxqF/mtutV9wfGLi1+uu5YuvWUhj/Ow= -github.com/gin-contrib/sessions v1.1.2/go.mod h1:qRxYwA/TYJw0wv28oFzs4gY/8G8z69id862SNgX9Gmw= -github.com/gin-contrib/sse v1.1.2 h1:MU2fgl1RrdYTMcgJLtz2kJF+vPg3xrqaaKfUUU18tCo= -github.com/gin-contrib/sse v1.1.2/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s= +github.com/gin-contrib/gzip v1.2.9 h1:MPyIubgLF/o5A4ni90weaQV0Oji7JTQto7QBVqWtmmY= +github.com/gin-contrib/gzip v1.2.9/go.mod h1:vLBq0o7y73JpE7m1pngAK/BPaVB7EU56R2gQP1yCgbo= +github.com/gin-contrib/sessions v1.1.3 h1:jTd6RietfxB/0brE36W6Q7XTl9die/gwD5orFZQZfh0= +github.com/gin-contrib/sessions v1.1.3/go.mod h1:4As481wI7g3rsRf6RnQYc0pS/IqBjp8vpz1Y4wudHek= +github.com/gin-contrib/sse v1.2.0 h1:AeYPE9hukQvAU1y7+eNvTC67PuBLK4BOduG08yv7L28= +github.com/gin-contrib/sse v1.2.0/go.mod h1:9ivB3pROCxcwnwHvAoYaHLwNQMGmzCjgriA04TuNIAc= github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= github.com/go-asn1-ber/asn1-ber v1.5.8 h1:H9AZkK22UOmfX8J84ubyaZxKJZ3FMHVwn8swoMML7iQ= @@ -53,8 +53,8 @@ github.com/go-playground/universal-translator v0.18.2 h1:LCsMLC9RzmbUMNUPVYD15dm github.com/go-playground/universal-translator v0.18.2/go.mod h1:67VZIMp5lQpDWlnStOct22q1bkdJGJqHghbOtmkawxk= github.com/go-playground/validator/v10 v10.30.5 h1:YyCXvVShZbs2Sm3Mb53eNOlhRXctSOzW5QJAouCTZL4= github.com/go-playground/validator/v10 v10.30.5/go.mod h1:wEqiaov48pXX1kjhc3Da8y0M0Dtg/BK7gurFBLgwFrQ= -github.com/goccy/go-json v0.11.2 h1:jdZv93Tt4ioR8yW1CoNsvSxrcZlCXAUU1aZXN7gpXUA= -github.com/goccy/go-json v0.11.2/go.mod h1:3NdmfEkZlB7YI5UFw/qdFKq8XN1aiWR0YyRPWZNQltY= +github.com/goccy/go-json v0.12.0 h1:pD+oWouAiz4yruwXIt/iT+lOMeyhr1aTtxzaMP/RgVI= +github.com/goccy/go-json v0.12.0/go.mod h1:3NdmfEkZlB7YI5UFw/qdFKq8XN1aiWR0YyRPWZNQltY= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/golang/mock v1.7.0-rc.1 h1:YojYx61/OLFsiv6Rw1Z96LpldJIy31o+UHmwAUMJ6/U= @@ -88,8 +88,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg= -github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/pgx/v5 v5.12.0 h1:hWwax8YiGT0YiN50U1oIDn5kgR7KC2tG3rmavwpPkxY= +github.com/jackc/pgx/v5 v5.12.0/go.mod h1:u2Fi6no03HCSBYE+ZVdLxbwdu3xyi2vU+K/YNJioSds= github.com/jackc/puddle/v2 v2.2.3 h1:f8HoREISb3l5eO9coPgK7U3hKLhWGokOFVcThl5jrao= github.com/jackc/puddle/v2 v2.2.3/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jackpal/go-nat-pmp v1.1.0 h1:UInMLPV1VQdP860ggNiz0YxGvJH/bWzxL099y+1EdCs= @@ -132,8 +132,8 @@ github.com/libp2p/go-nat v1.0.1-0.20250821073202-01afc089f138 h1:YohuNPT/1k3VcTh github.com/libp2p/go-nat v1.0.1-0.20250821073202-01afc089f138/go.mod h1:TXQg5tfSy+bUjnhT5728j5j/MBj7keIYqqZ1+8k/ui8= github.com/libp2p/go-netroute v0.4.0 h1:sZZx9hyANYUx9PZyqcgE/E1GUG3iEtTZHUEvdtXT7/Q= github.com/libp2p/go-netroute v0.4.0/go.mod h1:Nkd5ShYgSMS5MUKy/MU2T57xFoOKvvLR92Lic48LEyA= -github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 h1:eveIIGn4BGM3qknO74omf6HYr30/exH+eVUTuAgwjZ0= -github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +github.com/lufia/plan9stats v0.0.0-20261101121723-be84dd9509a0 h1:bGmNmV0W+HWMJyDOXmbY2InjV33LK59X9ClO7AU/8e8= +github.com/lufia/plan9stats v0.0.0-20261101121723-be84dd9509a0/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mattn/go-sqlite3 v1.14.52 h1:wVbm2Qnf4OXkqhBTSPuCRZDRnxfbVrrmiCEroVdog8U= @@ -145,12 +145,12 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/molecule-man/go-brrr v1.3.0 h1:KEGPkUk9PYFw5+Sv/evCOSU5OmMDXCGFqQ9YjRB7GeU= -github.com/molecule-man/go-brrr v1.3.0/go.mod h1:7ybW6/7gA3oKY45jOfVNjSJDtrr6ea4tzbsTkjmQDC4= +github.com/molecule-man/go-brrr v1.3.1 h1:EwaJUzRIlp/dFmIpzq4J17CA5Zi5OHo2vOGmu09hF1w= +github.com/molecule-man/go-brrr v1.3.1/go.mod h1:7ybW6/7gA3oKY45jOfVNjSJDtrr6ea4tzbsTkjmQDC4= github.com/mymmrac/telego v1.12.1 h1:yx1T5pPSNsU3BjLR7jnfY0D4dtL9caH58Y9e8uzjR88= github.com/mymmrac/telego v1.12.1/go.mod h1:K4z3Z3Qr6AA8yEjSry3JGScu506NlLl1O4Gqascmop4= -github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ= -github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA= +github.com/nicksnyder/go-i18n/v2 v2.7.0 h1:oiVQUmuCeNggvm/fUVb6/R0aP82BDVphZnmVHwut+J0= +github.com/nicksnyder/go-i18n/v2 v2.7.0/go.mod h1:ZMYiWg8ndQSGNk4zbzbyJjrrVjQjuQ8pERFrnBik+c8= github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 h1:lDH9UUVJtmYCjyT0CI4q8xvlXPxeZ0gYCVvWbmPlp88= github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk= github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= @@ -163,10 +163,10 @@ github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= github.com/pion/stun/v3 v3.1.7 h1:uRXMTlGLf89WgItGNyZ6aR5jMTX0NBbybXADpQCzn+E= github.com/pion/stun/v3 v3.1.7/go.mod h1:Nq77RW4aRrSNrltf2ksUJLjxWeipj4lnlgdsYIxC8g8= -github.com/pion/transport/v4 v4.1.1 h1:kEBtbp1sRad0tnkREDNJ3fmop/CR8p/YxXnpFbn9tqo= -github.com/pion/transport/v4 v4.1.1/go.mod h1:Oxo5oODQvjFF7NkHBBz3f4fZXLB7x03uLVuzfYeA5pE= -github.com/pion/transport/v5 v5.1.1 h1:Sauq8YJqRopgRfIgnpfKpdpKG14g0N4jWlRgpWsEfN8= -github.com/pion/transport/v5 v5.1.1/go.mod h1:Qxw6fCEjFWQkRDZOhS4Vf+neJBcihauvA3uyEa1J1F0= +github.com/pion/transport/v4 v4.1.3 h1:Qf6b/NJRn8J0yJK/Ac/qq9oY6XcKiWiLf9Kxsi0x05o= +github.com/pion/transport/v4 v4.1.3/go.mod h1:Oxo5oODQvjFF7NkHBBz3f4fZXLB7x03uLVuzfYeA5pE= +github.com/pion/transport/v5 v5.1.4 h1:j0E5QIPyv28YnWxa8yE9AyDmrxVnwhZ109c7Dpt48K4= +github.com/pion/transport/v5 v5.1.4/go.mod h1:Qxw6fCEjFWQkRDZOhS4Vf+neJBcihauvA3uyEa1J1F0= github.com/pires/go-proxyproto v0.16.0 h1:9KYT/fM3wpjT8bdMEj/7FBcoiUG1KyzEjRYnPu+vz7A= github.com/pires/go-proxyproto v0.16.0/go.mod h1:n7yhg8yIfNl3KgYDCwBzmeUHNpKTVnUhcWpc7rV4RW4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -184,8 +184,8 @@ github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc= github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= -github.com/shirou/gopsutil/v4 v4.26.9 h1:CaBo/hBFqvlJoLvAQEKdHdf5GD97/MN2ACfyv63+2Ig= -github.com/shirou/gopsutil/v4 v4.26.9/go.mod h1:nKH+8wX2zxr/mDbsR+AAy6Qd2z4ZOQ7wfQaEp+yrYmg= +github.com/shirou/gopsutil/v4 v4.26.10 h1:MJN6euesJx53MVEjscYeyRSRcrdIcy3sz5k9nosjldg= +github.com/shirou/gopsutil/v4 v4.26.10/go.mod h1:XpkIrcUMQhwte/dGxu57KtnMMFsbKelI7PP23zweKBk= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -210,8 +210,8 @@ github.com/ugorji/go/codec v1.3.2 h1:zkEASHHyEClGeURfgNT9PJZVfAbs9oEX9QXggwWNJbc github.com/ugorji/go/codec v1.3.2/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasthttp v1.76.0 h1:QitrT4foDQuqvUnr/hzmQUDbYzV12Zz4vkLOlipU4Cw= -github.com/valyala/fasthttp v1.76.0/go.mod h1:m8oTPC8riDDky07invpl+Gz3RixnAN5BbwyUuVB5H2o= +github.com/valyala/fasthttp v1.77.0 h1:6mxBJdHabRmU4Cmw79Uqhgvjzsp6pjOaQk2t8COflzo= +github.com/valyala/fasthttp v1.77.0/go.mod h1:m8oTPC8riDDky07invpl+Gz3RixnAN5BbwyUuVB5H2o= github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4= github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE= github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0= @@ -224,8 +224,8 @@ github.com/xlzd/gotp v0.1.0 h1:37blvlKCh38s+fkem+fFh7sMnceltoIEBYTVXyoa5Po= github.com/xlzd/gotp v0.1.0/go.mod h1:ndLJ3JKzi3xLmUProq4LLxCuECL93dG9WASNLpHz8qg= github.com/xtls/reality v0.0.0-20261014101318-83f621788ca8 h1:RjLnVvlzK4q6zWaSBPNRGsrgx2fbJDeUWffjw574uzs= github.com/xtls/reality v0.0.0-20261014101318-83f621788ca8/go.mod h1:ptlSkJ8k/7IDauNH0SA96JPOo+nYGLTv2Y3UMpwvAsY= -github.com/xtls/xray-core v1.260327.1-0.20261021115930-1b1647cd9ddf h1:Eqz6sj4Up2NtymyVY2UtXNpHZqJGrMwKsXnGOySyUNI= -github.com/xtls/xray-core v1.260327.1-0.20261021115930-1b1647cd9ddf/go.mod h1:rbK8dL0A1f4uYpNa3ljvB1U5qrcb4KQ4Pxb8yGdWZXo= +github.com/xtls/xray-core v1.260327.1-0.20261031132019-a4a3dff5b20c h1:XijTasBJ2fImHZo1GoAgSfHuDqloQGYHEADLy9CgrJ8= +github.com/xtls/xray-core v1.260327.1-0.20261031132019-a4a3dff5b20c/go.mod h1:/Umx7vpU+A78gOOuKl6ESU6eQJ5EBRhwr5cl7+9/drw= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yuin/gopher-lua v0.0.0-20190206043414-8bfc7677f583/go.mod h1:gqRgreBUhTSL0GeU64rtZ3Uq3wtjOa/TB2YfrtkCbVQ= @@ -247,12 +247,12 @@ golang.org/x/arch v0.32.0 h1:NjGf8/xGf5JgAJ4dw4NtME5R1L7xatagqAg95T3LgII= golang.org/x/arch v0.32.0/go.mod h1:KcJSod3cqT2dKcjBxqTyGfbumNikqU9p5tHJinPJnuY= golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= -golang.org/x/crypto/x509roots/fallback v0.0.0-20261020153306-9f2b28d4331a h1:V5oicZXS0oqZx30gY/apgSVD7YvNBfrIOdcMnlelJY4= -golang.org/x/crypto/x509roots/fallback v0.0.0-20261020153306-9f2b28d4331a/go.mod h1:HPze8vhfG6fO06AM+VSvxRm4E3+5Yk375mgrJ5M2z1E= +golang.org/x/crypto/x509roots/fallback v0.0.0-20261030153154-30a33513ff6d h1:5NBkmd8MXirajj6AAEpi4HFVJnVkBNPTL1ggYAKqaws= +golang.org/x/crypto/x509roots/fallback v0.0.0-20261030153154-30a33513ff6d/go.mod h1:HPze8vhfG6fO06AM+VSvxRm4E3+5Yk375mgrJ5M2z1E= golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e h1:HgI6W1OolKK6tcV74is+rl9SMT0njsHlp0TbQiGXI/A= golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e/go.mod h1:DBoxwWhjaW8DJJw/YeM4zpeOKMC1FMXSblsYy+1mFSw= -golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw= -golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= +golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY= +golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= @@ -272,14 +272,14 @@ golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs= golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= -golang.zx2c4.com/wireguard v0.0.0-20261006164505-2631ce99a06f h1:gJI6OVshWW0mvGyTVmW8mJoaw2OM69wVCeZkwpX+Jdc= -golang.zx2c4.com/wireguard v0.0.0-20261006164505-2631ce99a06f/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw= +golang.zx2c4.com/wireguard v0.0.0-20261024220952-b3bc704ef0ee h1:hByBPN/P/BVzJ591rJfvNhdoL94vmBtRbnVw77QJL20= +golang.zx2c4.com/wireguard v0.0.0-20261024220952-b3bc704ef0ee/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw= golang.zx2c4.com/wireguard/windows v1.1.1 h1:8/H97U1v1PNDNcBsMZgU3KFuND9MQdTsU2NOwmCXArE= golang.zx2c4.com/wireguard/windows v1.1.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20261019180522-ce908d29299b h1:6WNWHZk91dp4gZ25642OALkuG1UVNZbwENJD19DnB8k= -google.golang.org/genproto/googleapis/rpc v0.0.0-20261019180522-ce908d29299b/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20261026165753-2ffab32bce7e h1:w3fy/G8OrJHYexn+u5y7hFyc8YRRIr7DCevtIOwAcGY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20261026165753-2ffab32bce7e/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc= google.golang.org/grpc v1.86.0-dev h1:FLq7hcApuJeJ70J9+k0FvmXsNCwS8B2/MeAfYdy3GHY= google.golang.org/grpc v1.86.0-dev/go.mod h1:Ovl0ECo4xx5r4kn/6d4BPSNB7OIFuu6EAjOzjtVAKaM= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= diff --git a/internal/xray/api.go b/internal/xray/api.go index 418fdabea..0c5bdbba9 100644 --- a/internal/xray/api.go +++ b/internal/xray/api.go @@ -29,7 +29,7 @@ import ( routerService "github.com/xtls/xray-core/app/router/command" statsService "github.com/xtls/xray-core/app/stats/command" xnet "github.com/xtls/xray-core/common/net" - "github.com/xtls/xray-core/common/platform" + "github.com/xtls/xray-core/common/platform/filesystem" "github.com/xtls/xray-core/common/protocol" "github.com/xtls/xray-core/common/serial" "github.com/xtls/xray-core/infra/conf" @@ -656,7 +656,7 @@ func childLuaScriptPath(script string) string { if script == "" || filepath.IsAbs(script) { return script } - if _, err := platform.ResolveLuaFile(script); err == nil { + if _, err := filesystem.ResolveLuaFile(script); err == nil { return script } binDir, err := filepath.Abs(config.GetBinFolderPath()) From e789583b6352d5f281aa2facdf355f935cde53db Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 14:55:29 +0100 Subject: [PATCH 119/125] fix(xray): keep legacy-private unencrypted outbounds on cores before 26.10.29 xray-core 26.10.29 (#7249) narrowed the "private" addresses an unencrypted vless/trojan outbound may reach to RFC 1918, loopback and link-local, so 100.64.0.0/10 (CGNAT, Tailscale), 198.18.0.0/15 and the other ranges of the old geoip:private list are now refused. The embedded validator follows the new core, which made the template save and outbound subscriptions refuse such an outbound even when the panel still runs an older core that accepts it. The legacy skip now also lets through, on a core from 26.7.11 up to 26.10.28, an outbound whose server address (flat, vnext or servers) is an IP in that core's wider private set. Unknown versions stay strict. --- internal/web/service/outbound_subscription.go | 2 +- internal/web/service/xray_setting.go | 79 +++++++++++++++++-- internal/web/service/xray_setting_test.go | 35 +++++--- 3 files changed, 94 insertions(+), 22 deletions(-) diff --git a/internal/web/service/outbound_subscription.go b/internal/web/service/outbound_subscription.go index 759847370..bc9115639 100644 --- a/internal/web/service/outbound_subscription.go +++ b/internal/web/service/outbound_subscription.go @@ -41,7 +41,7 @@ func filterOutboundsRejectedByCore(label string, outbounds []any) ([]any, []stri for _, ob := range outbounds { raw, err := json.Marshal(ob) if err == nil { - if buildErr := xray.ValidateOutboundConfig(raw); buildErr != nil && !shouldSkipLegacyUnencryptedOutboundRejection(coreVersion, buildErr) { + if buildErr := xray.ValidateOutboundConfig(raw); buildErr != nil && !shouldSkipLegacyUnencryptedOutboundRejection(coreVersion, raw, buildErr) { tag := "" if m, ok := ob.(map[string]any); ok { tag, _ = m["tag"].(string) diff --git a/internal/web/service/xray_setting.go b/internal/web/service/xray_setting.go index 7d41f1a9d..79d49c081 100644 --- a/internal/web/service/xray_setting.go +++ b/internal/web/service/xray_setting.go @@ -4,6 +4,7 @@ import ( _ "embed" "encoding/base64" "encoding/json" + "net/netip" "slices" "strconv" "strings" @@ -23,8 +24,32 @@ type XraySettingService struct { const ( unencryptedOutboundProhibitedError = "without TLS or other encryption is prohibited unless the server address is a private IP or domain" unencryptedOutboundMinimumVersion = "26.7.11" + narrowedPrivateIPMinimumVersion = "26.10.29" ) +// legacyPrivateIPPrefixes is the "private" set xray-core 26.7.11 to 26.10.28 let an unencrypted +// vless/trojan outbound reach; 26.10.29 (#7249) narrowed it to RFC 1918, loopback and link-local. +var legacyPrivateIPPrefixes = []netip.Prefix{ + netip.MustParsePrefix("0.0.0.0/8"), + netip.MustParsePrefix("10.0.0.0/8"), + netip.MustParsePrefix("100.64.0.0/10"), + netip.MustParsePrefix("127.0.0.0/8"), + netip.MustParsePrefix("169.254.0.0/16"), + netip.MustParsePrefix("172.16.0.0/12"), + netip.MustParsePrefix("192.0.0.0/24"), + netip.MustParsePrefix("192.0.2.0/24"), + netip.MustParsePrefix("192.88.99.0/24"), + netip.MustParsePrefix("192.168.0.0/16"), + netip.MustParsePrefix("198.18.0.0/15"), + netip.MustParsePrefix("198.51.100.0/24"), + netip.MustParsePrefix("203.0.113.0/24"), + netip.MustParsePrefix("224.0.0.0/3"), + netip.MustParsePrefix("::/127"), + netip.MustParsePrefix("fc00::/7"), + netip.MustParsePrefix("fe80::/10"), + netip.MustParsePrefix("ff00::/8"), +} + func (s *XraySettingService) SaveXraySetting(newXraySettings string) error { // The frontend round-trips the whole getXraySetting response back // through the textarea, so if it has ever received a wrapped @@ -103,7 +128,7 @@ func (s *XraySettingService) CheckXrayConfig(XrayTemplateConfig string) error { return common.NewError("xray template outbound \""+probe.Tag+"\":", err) } if err := xray.ValidateOutboundConfig(outbound); err != nil { - if shouldSkipLegacyUnencryptedOutboundRejection(coreVersion, err) { + if shouldSkipLegacyUnencryptedOutboundRejection(coreVersion, outbound, err) { continue } tagged := struct { @@ -133,16 +158,54 @@ func checkTemplateLuaScripts(cfg *xray.Config) error { return nil } -// shouldSkipLegacyUnencryptedOutboundRejection lets an older running Xray -// core accept an outbound that the newer embedded validator rejects solely -// because it is unencrypted and targets a public address. Unknown or malformed -// versions preserve the embedded validator's strict behavior. -func shouldSkipLegacyUnencryptedOutboundRejection(coreVersion string, err error) bool { +// shouldSkipLegacyUnencryptedOutboundRejection keeps an unencrypted outbound the running core still +// allows: any address before 26.7.11, a legacy-private IP before 26.10.29; unknown versions stay strict. +func shouldSkipLegacyUnencryptedOutboundRejection(coreVersion string, outbound []byte, err error) bool { if err == nil || !strings.Contains(err.Error(), unencryptedOutboundProhibitedError) { return false } - comparison, ok := compareXrayCoreVersions(coreVersion, unencryptedOutboundMinimumVersion) - return ok && comparison < 0 + if comparison, ok := compareXrayCoreVersions(coreVersion, unencryptedOutboundMinimumVersion); !ok { + return false + } else if comparison < 0 { + return true + } + comparison, _ := compareXrayCoreVersions(coreVersion, narrowedPrivateIPMinimumVersion) + if comparison >= 0 { + return false + } + addr, parseErr := netip.ParseAddr(unencryptedOutboundServerAddress(outbound)) + if parseErr != nil { + return false + } + addr = addr.Unmap() + return slices.ContainsFunc(legacyPrivateIPPrefixes, func(p netip.Prefix) bool { return p.Contains(addr) }) +} + +// unencryptedOutboundServerAddress reads the server address of a vless or trojan outbound in +// any of the shapes the core loader accepts: flat, vless "vnext" or trojan "servers". +func unencryptedOutboundServerAddress(outbound []byte) string { + type server struct { + Address string `json:"address"` + } + var probe struct { + Settings struct { + Address string `json:"address"` + Vnext []server `json:"vnext"` + Servers []server `json:"servers"` + } `json:"settings"` + } + if json.Unmarshal(outbound, &probe) != nil { + return "" + } + switch { + case probe.Settings.Address != "": + return probe.Settings.Address + case len(probe.Settings.Vnext) > 0: + return probe.Settings.Vnext[0].Address + case len(probe.Settings.Servers) > 0: + return probe.Settings.Servers[0].Address + } + return "" } func compareXrayCoreVersions(a, b string) (int, bool) { diff --git a/internal/web/service/xray_setting_test.go b/internal/web/service/xray_setting_test.go index 0e55ad92b..20ba90fe9 100644 --- a/internal/web/service/xray_setting_test.go +++ b/internal/web/service/xray_setting_test.go @@ -11,26 +11,35 @@ import ( func TestShouldSkipLegacyUnencryptedOutboundRejection(t *testing.T) { prohibited := errors.New("vless without TLS or other encryption is prohibited unless the server address is a private IP or domain") + public := []byte(`{"protocol":"vless","settings":{"address":"8.8.8.8","port":443}}`) + cgnat := []byte(`{"protocol":"vless","settings":{"address":"100.64.1.2","port":443}}`) + cgnatVnext := []byte(`{"protocol":"vless","settings":{"vnext":[{"address":"100.100.1.1","port":443}]}}`) + benchmarkTrojan := []byte(`{"protocol":"trojan","settings":{"servers":[{"address":"198.18.0.9","port":443}]}}`) tests := []struct { - name string - version string - err error - want bool + name string + version string + outbound []byte + err error + want bool }{ - {name: "older core", version: "26.4.25", err: prohibited, want: true}, - {name: "boundary version", version: "26.7.11", err: prohibited, want: false}, - {name: "newer core", version: "26.10.0", err: prohibited, want: false}, - {name: "unknown version", version: "Unknown", err: prohibited, want: false}, - {name: "unparseable version", version: "26.7", err: prohibited, want: false}, - {name: "empty version", version: "", err: prohibited, want: false}, - {name: "unrelated validation error", version: "26.4.25", err: errors.New("invalid outbound"), want: false}, + {name: "older core", version: "26.4.25", outbound: public, err: prohibited, want: true}, + {name: "boundary version", version: "26.7.11", outbound: public, err: prohibited, want: false}, + {name: "newer core", version: "26.10.0", outbound: public, err: prohibited, want: false}, + {name: "unknown version", version: "Unknown", outbound: public, err: prohibited, want: false}, + {name: "unparseable version", version: "26.7", outbound: public, err: prohibited, want: false}, + {name: "empty version", version: "", outbound: public, err: prohibited, want: false}, + {name: "unrelated validation error", version: "26.4.25", outbound: public, err: errors.New("invalid outbound"), want: false}, + {name: "cgnat on a core that still counts it private", version: "26.10.21", outbound: cgnat, err: prohibited, want: true}, + {name: "cgnat vnext on a core that still counts it private", version: "26.10.21", outbound: cgnatVnext, err: prohibited, want: true}, + {name: "benchmark range trojan on a core that still counts it private", version: "26.10.21", outbound: benchmarkTrojan, err: prohibited, want: true}, + {name: "cgnat on the core that narrowed private", version: "26.10.29", outbound: cgnat, err: prohibited, want: false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := shouldSkipLegacyUnencryptedOutboundRejection(tt.version, tt.err); got != tt.want { - t.Fatalf("shouldSkipLegacyUnencryptedOutboundRejection(%q, %v) = %v, want %v", tt.version, tt.err, got, tt.want) + if got := shouldSkipLegacyUnencryptedOutboundRejection(tt.version, tt.outbound, tt.err); got != tt.want { + t.Fatalf("shouldSkipLegacyUnencryptedOutboundRejection(%q, %s, %v) = %v, want %v", tt.version, tt.outbound, tt.err, got, tt.want) } }) } From 7982794291661342eb58e7394be12fc84aed06fa Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 14:55:41 +0100 Subject: [PATCH 120/125] fix(xray): refuse geodata settings xray-core 26.10.29 refuses at startup xray-core 26.10.29 (#7267) checks a scheduled geodata section when the core starts: at most 100 assets, every file a .dat, a cron it can parse and an @every interval of at least a minute. A refusal there stops the core and every inbound with it, and the panel's own file-name pattern still let any extension through. The geodata editor now requires a .dat file name, and the template save path applies the same checks the core does. The invalid-file hint names the .dat requirement in all 14 locales. --- frontend/src/pages/index/GeodataSection.tsx | 4 +-- frontend/src/test/geodata-section.test.tsx | 29 +++++++++++++++ internal/web/service/xray_setting.go | 37 +++++++++++++++++++ internal/web/service/xray_setting_test.go | 39 +++++++++++++++++++++ internal/web/translation/ar-EG.json | 2 +- internal/web/translation/en-US.json | 2 +- internal/web/translation/es-ES.json | 2 +- internal/web/translation/fa-IR.json | 2 +- internal/web/translation/id-ID.json | 2 +- internal/web/translation/ja-JP.json | 2 +- internal/web/translation/pt-BR.json | 2 +- internal/web/translation/ru-RU.json | 2 +- internal/web/translation/sk-SK.json | 2 +- internal/web/translation/tr-TR.json | 2 +- internal/web/translation/uk-UA.json | 2 +- internal/web/translation/vi-VN.json | 2 +- internal/web/translation/zh-CN.json | 2 +- internal/web/translation/zh-TW.json | 2 +- 18 files changed, 121 insertions(+), 16 deletions(-) diff --git a/frontend/src/pages/index/GeodataSection.tsx b/frontend/src/pages/index/GeodataSection.tsx index 4af726925..94f94f531 100644 --- a/frontend/src/pages/index/GeodataSection.tsx +++ b/frontend/src/pages/index/GeodataSection.tsx @@ -19,8 +19,8 @@ interface GeodataSectionProps { } const DEFAULT_CRON = '0 4 * * *'; -// Xray resolves `file` inside its asset directory; plain file names only. -const FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+$/; +// Xray resolves `file` inside its asset directory; since 26.10.29 a scheduled one must be .dat. +const FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+\.dat$/i; function fileNameFromUrl(url: string): string { try { diff --git a/frontend/src/test/geodata-section.test.tsx b/frontend/src/test/geodata-section.test.tsx index 8c11ffb53..6b7e842f9 100644 --- a/frontend/src/test/geodata-section.test.tsx +++ b/frontend/src/test/geodata-section.test.tsx @@ -76,4 +76,33 @@ describe('GeodataSection', () => { } }); }); + + // xray-core 26.10.29 refuses a scheduled asset whose file is not .dat when it starts. + it('refuses to save a scheduled asset whose file is not .dat', async () => { + const post = vi.spyOn(HttpUtil, 'post').mockResolvedValue( + new Msg( + true, + '', + JSON.stringify({ + xraySetting: { + outbounds: [], + geodata: { cron: '0 4 * * *', assets: [CUSTOM_SOURCE] }, + }, + geodataSources: [], + }), + ), + ); + const user = userEvent.setup(); + + render(); + + const file = await screen.findByDisplayValue(CUSTOM_SOURCE.file); + await user.clear(file); + await user.type(file, 'geosite_custom.mmdb'); + await user.click(screen.getByRole('button', { name: 'Save & Restart Xray' })); + + expect(await screen.findByText(/File names must be/)).toBeTruthy(); + expect(screen.queryByText('Save geodata settings?')).toBeNull(); + expect(post).toHaveBeenCalledTimes(1); + }); }); diff --git a/internal/web/service/xray_setting.go b/internal/web/service/xray_setting.go index 79d49c081..048a75f3e 100644 --- a/internal/web/service/xray_setting.go +++ b/internal/web/service/xray_setting.go @@ -5,9 +5,13 @@ import ( "encoding/base64" "encoding/json" "net/netip" + "path/filepath" "slices" "strconv" "strings" + "time" + + "github.com/robfig/cron/v3" "github.com/mhsanaei/3x-ui/v3/internal/amneziawg" "github.com/mhsanaei/3x-ui/v3/internal/database" @@ -81,6 +85,9 @@ func (s *XraySettingService) CheckXrayConfig(XrayTemplateConfig string) error { if err := checkTemplateLuaScripts(xrayConfig); err != nil { return err } + if err := checkTemplateGeodata([]byte(xrayConfig.Geodata)); err != nil { + return err + } for _, inbound := range xrayConfig.InboundConfigs { if err := validateFinalMaskSalamander(string(inbound.StreamSettings)); err != nil { return common.NewError("xray template inbound \""+inbound.Tag+"\":", err) @@ -158,6 +165,36 @@ func checkTemplateLuaScripts(cfg *xray.Config) error { return nil } +// checkTemplateGeodata applies the checks xray-core 26.10.29 (#7267) runs on a scheduled geodata +// section only when the core starts, where a refusal stops every inbound. +func checkTemplateGeodata(raw []byte) error { + var geodata struct { + Cron string `json:"cron"` + Assets []struct { + File string `json:"file"` + } `json:"assets"` + } + if len(raw) == 0 || json.Unmarshal(raw, &geodata) != nil || geodata.Cron == "" { + return nil + } + if len(geodata.Assets) > 100 { + return common.NewError("xray template geodata: xray-core supports at most 100 assets.") + } + for _, asset := range geodata.Assets { + if !strings.EqualFold(filepath.Ext(asset.File), ".dat") { + return common.NewError(`xray template geodata: file extension must be .dat, not "` + asset.File + `".`) + } + } + schedule, err := cron.ParseStandard(geodata.Cron) + if err != nil { + return common.NewError("xray template geodata: invalid geodata cron:", err) + } + if interval, ok := schedule.(cron.ConstantDelaySchedule); ok && interval.Delay < time.Minute { + return common.NewError("xray template geodata: the cron interval must be at least 1 minute.") + } + return nil +} + // shouldSkipLegacyUnencryptedOutboundRejection keeps an unencrypted outbound the running core still // allows: any address before 26.7.11, a legacy-private IP before 26.10.29; unknown versions stay strict. func shouldSkipLegacyUnencryptedOutboundRejection(coreVersion string, outbound []byte, err error) bool { diff --git a/internal/web/service/xray_setting_test.go b/internal/web/service/xray_setting_test.go index 20ba90fe9..58b79897a 100644 --- a/internal/web/service/xray_setting_test.go +++ b/internal/web/service/xray_setting_test.go @@ -3,6 +3,7 @@ package service import ( "encoding/json" "errors" + "strconv" "strings" "testing" @@ -289,6 +290,44 @@ func TestCheckXrayConfig_RefusesLuaScriptsTheCoreRefuses(t *testing.T) { } } +// xray-core 26.10.29 (#7267) refuses these geodata settings when the core starts, stopping every inbound. +func TestCheckXrayConfig_RefusesGeodataTheCoreRefuses(t *testing.T) { + const outbounds = `"outbounds":[{"protocol":"freedom","tag":"direct"}]` + geodata := func(cron string, files ...string) string { + assets := make([]string, len(files)) + for i, f := range files { + assets[i] = `{"url":"https://example.com/` + f + `","file":"` + f + `"}` + } + return `{"geodata":{"cron":"` + cron + `","assets":[` + strings.Join(assets, ",") + `]},` + outbounds + `}` + } + tooMany := make([]string, 101) + for i := range tooMany { + tooMany[i] = "geo" + strconv.Itoa(i) + ".dat" + } + tests := []struct{ name, template, want string }{ + {"file that is not .dat", geodata("0 4 * * *", "custom.mmdb"), "file extension must be .dat"}, + {"more than 100 assets", geodata("0 4 * * *", tooMany...), "at most 100 assets"}, + {"cron the core cannot parse", geodata("every day", "custom.dat"), "invalid geodata cron"}, + {"interval under a minute", geodata("@every 30s", "custom.dat"), "at least 1 minute"}, + {"upper-case extension on a daily cron", geodata("0 4 * * *", "Custom.DAT"), ""}, + {"no cron leaves the assets unscheduled", geodata("", "custom.mmdb"), ""}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := (&XraySettingService{}).CheckXrayConfig(tc.template) + if tc.want == "" { + if err != nil { + t.Fatalf("CheckXrayConfig = %v, want the geodata the core accepts kept", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("CheckXrayConfig = %v, want one containing %q", err, tc.want) + } + }) + } +} + // A template saved under 26.10.10 can carry those scripts; the build drops them so the core still // starts, and the vendored loader accepts the routing and dns sections it then emits. func TestGetXrayConfig_DropsLuaScriptsTheCoreRefuses(t *testing.T) { diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index 58d2c5e4c..4aee49632 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -238,7 +238,7 @@ "geodataConfirmTitle": "حفظ إعدادات geodata؟", "geodataConfirmContent": "سيتم تحديث قالب إعدادات Xray وإعادة تشغيل Xray.", "geodataInvalidUrl": "كل ملف يحتاج إلى رابط HTTPS.", - "geodataInvalidFile": "يجب أن يكون اسم الملف اسماً بسيطاً مثل geosite_custom.dat (بدون مسارات).", + "geodataInvalidFile": "يجب أن يكون اسم الملف اسماً بسيطاً بامتداد ‎.dat مثل geosite_custom.dat (بدون مسارات).", "geodataInvalidCron": "يجب أن يتكون cron من 5 حقول، مثل 0 4 * * *", "geodataEmpty": "لا توجد ملفات مهيأة. في قواعد التوجيه يُشار إلى الملفات بالشكل ext:geosite_custom.dat:category.", "dontRefresh": "التثبيت شغال، متعملش Refresh للصفحة", diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index 38a3c675e..28320955c 100644 --- a/internal/web/translation/en-US.json +++ b/internal/web/translation/en-US.json @@ -238,7 +238,7 @@ "geodataConfirmTitle": "Save geodata settings?", "geodataConfirmContent": "This updates the Xray config template and restarts Xray.", "geodataInvalidUrl": "Each file needs an HTTPS URL.", - "geodataInvalidFile": "File names must be plain names like geosite_custom.dat (no paths).", + "geodataInvalidFile": "File names must be plain .dat names like geosite_custom.dat (no paths).", "geodataInvalidCron": "Cron must have 5 fields, e.g. 0 4 * * *", "geodataEmpty": "No files configured. Reference files in routing rules as ext:geosite_custom.dat:category.", "dontRefresh": "Installation is in progress, please do not refresh this page", diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index fbcc76191..f9cbf2208 100644 --- a/internal/web/translation/es-ES.json +++ b/internal/web/translation/es-ES.json @@ -238,7 +238,7 @@ "geodataConfirmTitle": "¿Guardar la configuración de geodata?", "geodataConfirmContent": "Se actualizará la plantilla de configuración de Xray y se reiniciará Xray.", "geodataInvalidUrl": "Cada archivo necesita una URL HTTPS.", - "geodataInvalidFile": "El nombre de archivo debe ser simple, p. ej. geosite_custom.dat (sin rutas).", + "geodataInvalidFile": "El nombre de archivo debe ser un nombre .dat simple, p. ej. geosite_custom.dat (sin rutas).", "geodataInvalidCron": "Cron debe tener 5 campos, p. ej. 0 4 * * *", "geodataEmpty": "No hay archivos configurados. En las reglas de enrutamiento se referencian como ext:geosite_custom.dat:category.", "dontRefresh": "La instalación está en progreso, por favor no actualices esta página.", diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index 6e790b04a..b07d71b8b 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -238,7 +238,7 @@ "geodataConfirmTitle": "تنظیمات geodata ذخیره شود؟", "geodataConfirmContent": "قالب پیکربندی Xray به‌روزرسانی و Xray ری‌استارت می‌شود.", "geodataInvalidUrl": "هر فایل به یک آدرس HTTPS نیاز دارد.", - "geodataInvalidFile": "نام فایل باید ساده باشد، مانند geosite_custom.dat (بدون مسیر).", + "geodataInvalidFile": "نام فایل باید یک نام ساده با پسوند ‎.dat باشد، مانند geosite_custom.dat (بدون مسیر).", "geodataInvalidCron": "Cron باید ۵ بخش داشته باشد، مثل 0 4 * * *", "geodataEmpty": "فایلی پیکربندی نشده است. در قوانین مسیریابی فایل‌ها به صورت ext:geosite_custom.dat:category استفاده می‌شوند.", "dontRefresh": "در حال نصب، لطفا صفحه را رفرش نکنید", diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index bc95dc0a7..2a2822290 100644 --- a/internal/web/translation/id-ID.json +++ b/internal/web/translation/id-ID.json @@ -238,7 +238,7 @@ "geodataConfirmTitle": "Simpan pengaturan geodata?", "geodataConfirmContent": "Templat konfigurasi Xray akan diperbarui dan Xray akan dimulai ulang.", "geodataInvalidUrl": "Setiap berkas memerlukan URL HTTPS.", - "geodataInvalidFile": "Nama berkas harus berupa nama sederhana, mis. geosite_custom.dat (tanpa path).", + "geodataInvalidFile": "Nama berkas harus berupa nama .dat sederhana, mis. geosite_custom.dat (tanpa path).", "geodataInvalidCron": "Cron harus terdiri dari 5 bagian, mis. 0 4 * * *", "geodataEmpty": "Belum ada berkas yang dikonfigurasi. Pada aturan routing, rujuk berkas sebagai ext:geosite_custom.dat:category.", "dontRefresh": "Instalasi sedang berlangsung, harap jangan menyegarkan halaman ini", diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index 7a7c8021d..12cd2af39 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -238,7 +238,7 @@ "geodataConfirmTitle": "geodata 設定を保存しますか?", "geodataConfirmContent": "Xray 設定テンプレートを更新し、Xray を再起動します。", "geodataInvalidUrl": "各ファイルには HTTPS URL が必要です。", - "geodataInvalidFile": "ファイル名はパスを含まない単純な名前にしてください(例: geosite_custom.dat)。", + "geodataInvalidFile": "ファイル名はパスを含まない単純な .dat 名にしてください(例: geosite_custom.dat)。", "geodataInvalidCron": "Cron は 5 フィールド必要です(例: 0 4 * * *)", "geodataEmpty": "ファイルが設定されていません。ルーティングルールでは ext:geosite_custom.dat:category の形式で参照します。", "dontRefresh": "インストール中、このページをリロードしないでください", diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index dba34991e..c24280532 100644 --- a/internal/web/translation/pt-BR.json +++ b/internal/web/translation/pt-BR.json @@ -238,7 +238,7 @@ "geodataConfirmTitle": "Salvar configurações de geodata?", "geodataConfirmContent": "O modelo de configuração do Xray será atualizado e o Xray será reiniciado.", "geodataInvalidUrl": "Cada arquivo precisa de uma URL HTTPS.", - "geodataInvalidFile": "O nome do arquivo deve ser simples, ex.: geosite_custom.dat (sem caminhos).", + "geodataInvalidFile": "O nome do arquivo deve ser um nome .dat simples, ex.: geosite_custom.dat (sem caminhos).", "geodataInvalidCron": "O cron deve ter 5 campos, ex.: 0 4 * * *", "geodataEmpty": "Nenhum arquivo configurado. Nas regras de roteamento, referencie como ext:geosite_custom.dat:category.", "dontRefresh": "Instalação em andamento, por favor não atualize a página", diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index 8ab6ca7f4..6b55abdb0 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -238,7 +238,7 @@ "geodataConfirmTitle": "Сохранить настройки geodata?", "geodataConfirmContent": "Шаблон конфигурации Xray будет обновлён, а Xray перезапущен.", "geodataInvalidUrl": "Для каждого файла нужен HTTPS URL.", - "geodataInvalidFile": "Имя файла должно быть простым, например geosite_custom.dat (без путей).", + "geodataInvalidFile": "Имя файла должно быть простым именем .dat, например geosite_custom.dat (без путей).", "geodataInvalidCron": "Cron должен содержать 5 полей, напр. 0 4 * * *", "geodataEmpty": "Файлы не настроены. В правилах маршрутизации файлы указываются как ext:geosite_custom.dat:category.", "dontRefresh": "Установка в процессе. Не обновляйте страницу", diff --git a/internal/web/translation/sk-SK.json b/internal/web/translation/sk-SK.json index 4d7a7cd73..3e11a3dc5 100644 --- a/internal/web/translation/sk-SK.json +++ b/internal/web/translation/sk-SK.json @@ -238,7 +238,7 @@ "geodataConfirmTitle": "Uložiť nastavenia geodát?", "geodataConfirmContent": "Zmení sa šablóna konfigurácie Xray a Xray sa reštartuje.", "geodataInvalidUrl": "Pre každý súbor zadajte odkaz HTTPS.", - "geodataInvalidFile": "Použite samotný názov súboru, napr. geosite_custom.dat, bez cesty.", + "geodataInvalidFile": "Použite samotný názov súboru .dat, napr. geosite_custom.dat, bez cesty.", "geodataInvalidCron": "Výraz cron musí mať 5 polí, napr. 0 4 * * *", "geodataEmpty": "Nie sú nastavené žiadne súbory. V pravidlách smerovania na ne odkazujte ako ext:geosite_custom.dat:category.", "dontRefresh": "Prebieha inštalácia. Neobnovujte stránku.", diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index 024822587..1b23d182b 100644 --- a/internal/web/translation/tr-TR.json +++ b/internal/web/translation/tr-TR.json @@ -238,7 +238,7 @@ "geodataConfirmTitle": "Geodata ayarları kaydedilsin mi?", "geodataConfirmContent": "Xray yapılandırma şablonu güncellenecek ve Xray yeniden başlatılacak.", "geodataInvalidUrl": "Her dosya için bir HTTPS URL gerekir.", - "geodataInvalidFile": "Dosya adı yalnızca dosya adı olmalıdır, örn. geosite_custom.dat (yol içermemeli).", + "geodataInvalidFile": "Dosya adı yalnızca .dat uzantılı bir dosya adı olmalıdır, örn. geosite_custom.dat (yol içermemeli).", "geodataInvalidCron": "Cron 5 alandan oluşmalıdır, örn. 0 4 * * *", "geodataEmpty": "Yapılandırılmış dosya yok. Yönlendirme kurallarında dosyalar ext:geosite_custom.dat:category olarak kullanılır.", "dontRefresh": "Kurulum devam ediyor, lütfen bu sayfayı yenilemeyin", diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index f21d413cd..4d69d7ae0 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -238,7 +238,7 @@ "geodataConfirmTitle": "Зберегти налаштування geodata?", "geodataConfirmContent": "Шаблон конфігурації Xray буде оновлено, а Xray перезапущено.", "geodataInvalidUrl": "Для кожного файлу потрібен HTTPS URL.", - "geodataInvalidFile": "Ім’я файлу має бути простим, напр. geosite_custom.dat (без шляхів).", + "geodataInvalidFile": "Ім’я файлу має бути простим ім’ям .dat, напр. geosite_custom.dat (без шляхів).", "geodataInvalidCron": "Cron має містити 5 полів, напр. 0 4 * * *", "geodataEmpty": "Файли не налаштовано. У правилах маршрутизації файли вказуються як ext:geosite_custom.dat:category.", "dontRefresh": "Інсталяція триває, будь ласка, не оновлюйте цю сторінку", diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index 57450713c..38f8c92ba 100644 --- a/internal/web/translation/vi-VN.json +++ b/internal/web/translation/vi-VN.json @@ -238,7 +238,7 @@ "geodataConfirmTitle": "Lưu cài đặt geodata?", "geodataConfirmContent": "Mẫu cấu hình Xray sẽ được cập nhật và Xray sẽ khởi động lại.", "geodataInvalidUrl": "Mỗi tệp cần một URL HTTPS.", - "geodataInvalidFile": "Tên tệp phải là tên đơn giản, ví dụ geosite_custom.dat (không chứa đường dẫn).", + "geodataInvalidFile": "Tên tệp phải là tên .dat đơn giản, ví dụ geosite_custom.dat (không chứa đường dẫn).", "geodataInvalidCron": "Cron phải có 5 trường, ví dụ 0 4 * * *", "geodataEmpty": "Chưa cấu hình tệp nào. Trong quy tắc định tuyến, tham chiếu tệp dạng ext:geosite_custom.dat:category.", "dontRefresh": "Đang tiến hành cài đặt, vui lòng không làm mới trang này.", diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index 0ef602822..66b40ca8f 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -238,7 +238,7 @@ "geodataConfirmTitle": "保存 geodata 设置?", "geodataConfirmContent": "将更新 Xray 配置模板并重启 Xray。", "geodataInvalidUrl": "每个文件都需要 HTTPS 地址。", - "geodataInvalidFile": "文件名必须是纯文件名,例如 geosite_custom.dat(不能包含路径)。", + "geodataInvalidFile": "文件名必须是纯 .dat 文件名,例如 geosite_custom.dat(不能包含路径)。", "geodataInvalidCron": "Cron 必须为 5 段,例如 0 4 * * *", "geodataEmpty": "尚未配置文件。路由规则中可通过 ext:geosite_custom.dat:category 引用文件。", "dontRefresh": "安装中,请勿刷新此页面", diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index e77b37ab0..348c0856a 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -238,7 +238,7 @@ "geodataConfirmTitle": "儲存 geodata 設定?", "geodataConfirmContent": "將更新 Xray 設定範本並重啟 Xray。", "geodataInvalidUrl": "每個檔案都需要 HTTPS 位址。", - "geodataInvalidFile": "檔案名稱必須是純檔名,例如 geosite_custom.dat(不能包含路徑)。", + "geodataInvalidFile": "檔案名稱必須是純 .dat 檔名,例如 geosite_custom.dat(不能包含路徑)。", "geodataInvalidCron": "Cron 必須為 5 段,例如 0 4 * * *", "geodataEmpty": "尚未設定檔案。路由規則中可透過 ext:geosite_custom.dat:category 引用檔案。", "dontRefresh": "安裝中,請勿重新整理此頁面", From ba27b74be88db54c8b3c99826259965cb4a06941 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 14:55:57 +0100 Subject: [PATCH 121/125] feat(xray): keep the XHTTP server's h2Params xray-core 26.10.31 (#7295) added xhttpSettings.h2Params, a Go net/http HTTP2Config the XHTTP server applies to its HTTP/2 listener. The inbound form parsed xhttpSettings through a Zod object that strips unknown keys, so a value set through the JSON editor vanished on the next save; it is now kept as an optional record. h2Params tunes the server only. Share links already carry an allowlist of XHTTP keys, but the JSON subscription copies the inbound's xhttpSettings minus a denylist, which now drops h2Params too. --- frontend/src/schemas/protocols/stream/xhttp.ts | 2 ++ frontend/src/test/inbound-form-adapter.test.ts | 15 +++++++++++++++ internal/sub/json_service.go | 1 + internal/sub/json_service_test.go | 13 +++++++++++++ 4 files changed, 31 insertions(+) diff --git a/frontend/src/schemas/protocols/stream/xhttp.ts b/frontend/src/schemas/protocols/stream/xhttp.ts index 1c558e72b..4b92f8a81 100644 --- a/frontend/src/schemas/protocols/stream/xhttp.ts +++ b/frontend/src/schemas/protocols/stream/xhttp.ts @@ -108,6 +108,8 @@ export const XHttpStreamSettingsSchema = z.preprocess( serverMaxHeaderBytes: z.number().int().min(0).default(0), uplinkHTTPMethod: z.string().default(''), headers: WsHeaderMapSchema.default({}), + // Server-only Go net/http HTTP2Config (xray-core 26.10.31); never put into share links. + h2Params: z.record(z.string(), z.unknown()).optional(), // Client-side fields stored on inbound for subscription propagation. // The server listener ignores them at runtime, but the panel embeds // them in share-link `extra` blobs so the same xhttp config can diff --git a/frontend/src/test/inbound-form-adapter.test.ts b/frontend/src/test/inbound-form-adapter.test.ts index c0180a65a..0f47e0aff 100644 --- a/frontend/src/test/inbound-form-adapter.test.ts +++ b/frontend/src/test/inbound-form-adapter.test.ts @@ -228,6 +228,21 @@ describe('formValuesToWirePayload', () => { expect(payload.sniffing).toBe(''); }); + // xray-core 26.10.31 (#7295): the XHTTP server reads h2Params; the form must not strip it. + it('keeps an XHTTP inbound h2Params set through JSON', () => { + const h2Params = { MaxConcurrentStreams: 512, MaxReadFrameSize: 1048576 }; + const values = rawInboundToFormValues({ + ...vlessRow, + streamSettings: { + network: 'xhttp', + security: 'none', + xhttpSettings: { path: '/x', mode: 'auto', h2Params }, + }, + }); + const payload = formValuesToWirePayload(values); + expect(JSON.parse(payload.streamSettings).xhttpSettings.h2Params).toEqual(h2Params); + }); + it('omits nodeId when null', () => { const values = rawInboundToFormValues({ ...vlessRow, nodeId: null }); const payload = formValuesToWirePayload(values); diff --git a/internal/sub/json_service.go b/internal/sub/json_service.go index caa9cc1e9..35468cb5f 100644 --- a/internal/sub/json_service.go +++ b/internal/sub/json_service.go @@ -809,6 +809,7 @@ func (s *SubJsonService) streamData(stream string, clientKey string) map[string] delete(xhttp, "scMaxBufferedPosts") delete(xhttp, "scStreamUpServerSecs") delete(xhttp, "serverMaxHeaderBytes") + delete(xhttp, "h2Params") // Values matching xray-core's own defaults stay off the wire: // old panels seeded them into every stored config and the // literal scMinPostsIntervalMs=30 is a DPI fingerprint (#5141). diff --git a/internal/sub/json_service_test.go b/internal/sub/json_service_test.go index 4a03af723..e89950af1 100644 --- a/internal/sub/json_service_test.go +++ b/internal/sub/json_service_test.go @@ -155,6 +155,19 @@ func TestSubJsonServiceInjectsGlobalFinalMask(t *testing.T) { } } +// h2Params (xray-core 26.10.31, #7295) tunes the XHTTP server's HTTP/2; clients must not get it. +func TestSubJsonServiceDropsXhttpServerOnlyH2Params(t *testing.T) { + svc := NewSubJsonService("", "", "", "", nil) + stream := svc.streamData(`{"network":"xhttp","security":"none","xhttpSettings":{"path":"/x","mode":"auto","h2Params":{"MaxConcurrentStreams":512}}}`, "") + xhttp, _ := stream["xhttpSettings"].(map[string]any) + if xhttp == nil || xhttp["path"] != "/x" { + t.Fatalf("xhttpSettings = %#v, want the inbound's path kept", stream["xhttpSettings"]) + } + if h2, ok := xhttp["h2Params"]; ok { + t.Fatalf("xhttpSettings.h2Params = %#v, want the server-only key dropped", h2) + } +} + func TestSubJsonServiceMergesWithExistingFinalMask(t *testing.T) { finalMask := `{"tcp":[{"type":"fragment","settings":{"packets":"tlshello"}}]}` svc := NewSubJsonService("", "", finalMask, "", nil) From 87749c1ee450965aeade3a7aa1a8328e042cf493 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 14:56:13 +0100 Subject: [PATCH 122/125] feat(xray): accept the TUN WFP leak ports of xray-core 26.10.31 xray-core 26.10.31 (#7288) widened a TUN inbound's autoSystemWfpBlockLeak: "dns" now also blocks DNS over TLS/QUIC (port 853), "windnsclient" covers only Windows' DNS Client service, and any "tcp/" or "udp/" keeps that port inside the TUN. Anything else, such as "tcp/0" or "icmp/1", stops the core at startup. The form offers windnsclient and takes typed port values, the hint explains them in all 14 locales, and the inbound and template save paths run the core's own ExpandWfpBlockLeak so a value it refuses is refused at save instead of taking every inbound down. --- .../src/pages/inbounds/form/protocols/tun.tsx | 6 ++-- frontend/src/schemas/protocols/inbound/tun.ts | 2 +- internal/web/service/inbound.go | 6 ++++ internal/web/service/inbound_protocol.go | 19 +++++++++++++ internal/web/service/inbound_protocol_test.go | 28 +++++++++++++++++++ internal/web/service/xray_setting.go | 3 ++ internal/web/translation/ar-EG.json | 2 +- internal/web/translation/en-US.json | 2 +- internal/web/translation/es-ES.json | 2 +- internal/web/translation/fa-IR.json | 2 +- internal/web/translation/id-ID.json | 2 +- internal/web/translation/ja-JP.json | 2 +- internal/web/translation/pt-BR.json | 2 +- internal/web/translation/ru-RU.json | 2 +- internal/web/translation/sk-SK.json | 2 +- internal/web/translation/tr-TR.json | 2 +- internal/web/translation/uk-UA.json | 2 +- internal/web/translation/vi-VN.json | 2 +- internal/web/translation/zh-CN.json | 2 +- internal/web/translation/zh-TW.json | 2 +- internal/xray/api.go | 8 ++++++ 21 files changed, 83 insertions(+), 17 deletions(-) diff --git a/frontend/src/pages/inbounds/form/protocols/tun.tsx b/frontend/src/pages/inbounds/form/protocols/tun.tsx index 062d74f4e..94229d85b 100644 --- a/frontend/src/pages/inbounds/form/protocols/tun.tsx +++ b/frontend/src/pages/inbounds/form/protocols/tun.tsx @@ -46,9 +46,10 @@ function StringList({ name, label, tooltip, placeholder }: StringListProps) { ); } -// xray-core 26.9.30 accepts exactly these autoSystemWfpBlockLeak values. +// Named autoSystemWfpBlockLeak values; xray-core 26.10.31 also takes typed "tcp/" or "udp/". const WFP_BLOCK_LEAK_OPTIONS = [ { value: 'dns', label: 'dns' }, + { value: 'windnsclient', label: 'windnsclient' }, { value: 'misconfigtun', label: 'misconfigtun' }, ]; @@ -110,7 +111,8 @@ export default function TunFields() { > + {service === 'google-drive' && GOOGLE_DRIVE_SECRETS.map((key, index) => ( @@ -103,6 +112,48 @@ export default function XDriveForm() { {key === 'clientId' ? : } ))} + {service === 's3' && ( + <> + {S3_SECRETS.map(([key, required], index) => ( + + {key === 'accessKeyId' ? : } + + ))} + + + + + + + + + + value !== false }} + > + + + + )} {service === 'template' && ( <> ; // xray-core 26.10.17 (#7182) renamed "Google Drive" and reads the old name only as a deprecated alias. @@ -22,10 +22,19 @@ export function liftLegacyXDriveService(stream: Record): void { const MillisSchema = z.number().int().min(0); +// S3-compatible storage (xray-core 26.10.31, #7231); an unset pathStyle means path-style addressing. +export const XDriveS3Schema = z.object({ + endpoint: z.string().optional(), + region: z.string().optional(), + bucket: z.string().optional(), + pathStyle: z.boolean().optional(), +}); + export const XDriveStreamSettingsSchema = z.object({ service: XDriveServiceInputSchema.default('google-drive'), remoteFolder: z.string().default(''), - // Google Drive takes exactly three, in order: ClientID, ClientSecret, RefreshToken. + // Google Drive takes exactly three, in order: ClientID, ClientSecret, RefreshToken; S3 takes + // AccessKeyID, SecretAccessKey and an optional SessionToken. secrets: z.array(z.string()).default([]), segmentBytes: z.number().int().min(0).optional(), flushIntervalMs: MillisSchema.optional(), @@ -36,6 +45,7 @@ export const XDriveStreamSettingsSchema = z.object({ eagerWindowMs: MillisSchema.optional(), holeTimeoutMs: MillisSchema.optional(), template: z.record(z.string(), z.unknown()).optional(), + s3: XDriveS3Schema.optional(), // Client XMUX (xray-core 26.10.21); left unset, the core sizes it per service, so no // defaults are filled in here. A JSON subscription hands an inbound's value to clients. xmux: z.record(z.string(), z.unknown()).optional(), diff --git a/frontend/src/test/outbound-form-modal.test.tsx b/frontend/src/test/outbound-form-modal.test.tsx index 29e19fed6..cb8d4bbd1 100644 --- a/frontend/src/test/outbound-form-modal.test.tsx +++ b/frontend/src/test/outbound-form-modal.test.tsx @@ -563,6 +563,69 @@ describe('OutboundFormModal XDRIVE service name', () => { }); }); +// xray-core 26.10.31 (#7231): every S3 key needs a registered field or the editor drops it on save. +describe('OutboundFormModal XDRIVE S3 storage', () => { + it('opens and saves a stored S3 outbound unchanged', async () => { + const onConfirm = vi.fn(); + const queryClient = makeTestQueryClient(); + const xdriveSettings = { + service: 's3', + remoteFolder: 'xdrive', + secrets: ['AKID', 'SECRET', 'TOKEN'], + s3: { + endpoint: 'https://storage.yandexcloud.net', + region: 'ru-central1', + bucket: 'example-bucket', + pathStyle: false, + }, + }; + const outbound = { + protocol: 'vless', + tag: 's3-out', + settings: { + address: 'example.com', + port: 443, + id: 'c9f0c2d0-0000-4000-8000-000000000000', + encryption: 'mlkem768x25519plus.native.0rtt.key', + }, + mux: { enabled: true, concurrency: 8 }, + streamSettings: { network: 'xdrive', security: 'none', xdriveSettings }, + }; + const tree = (open: boolean) => ( + + + {}} + onConfirm={onConfirm} + /> + + + ); + const { rerender } = render(tree(false)); + rerender(tree(true)); + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + expect((screen.getByLabelText('Bucket') as HTMLInputElement).value).toBe('example-bucket'); + expect((screen.getByLabelText('Access key ID') as HTMLInputElement).value).toBe('AKID'); + const ok = document.querySelector('.ant-modal-footer .ant-btn-primary') as HTMLElement; + await act(async () => { + fireEvent.click(ok); + }); + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + expect(onConfirm).toHaveBeenCalledTimes(1); + const payload = onConfirm.mock.calls[0][0] as { streamSettings: Record }; + expect(payload.streamSettings.xdriveSettings).toMatchObject(xdriveSettings); + }); +}); + // xray-core 26.10.10 dials the XDRIVE storage API with the outbound's tlsSettings (SNI, // uTLS fingerprint), so switching a TLS outbound to XDRIVE must keep them. describe('OutboundFormModal XDRIVE TLS', () => { diff --git a/frontend/src/test/stream.test.ts b/frontend/src/test/stream.test.ts index 5be9db518..c61b4efd0 100644 --- a/frontend/src/test/stream.test.ts +++ b/frontend/src/test/stream.test.ts @@ -56,6 +56,17 @@ describe('NetworkSettingsSchema xray-core 26.10.21 keys', () => { expect(parsed.xdriveSettings.service).toBe('google-drive'); }); + // xray-core 26.10.31 (#7231): the "s3" service reads its storage from an "s3" object. + it('keeps the XDRIVE s3 service and its storage', () => { + const s3 = { endpoint: 'https://s3.example', region: 'r', bucket: 'b', pathStyle: false }; + const parsed = NetworkSettingsSchema.parse({ + network: 'xdrive', + xdriveSettings: { service: 's3', remoteFolder: 'x', secrets: ['AKID', 'SECRET'], s3 }, + }) as { xdriveSettings: { service: string; s3?: unknown } }; + expect(parsed.xdriveSettings.service).toBe('s3'); + expect(parsed.xdriveSettings.s3).toEqual(s3); + }); + it('keeps the XDRIVE and MASQUE xmux', () => { const xmux = { maxConnections: 2, hMaxReusableSecs: '1800-3000' }; const xdrive = NetworkSettingsSchema.parse({ diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go index 447bee557..faf799701 100644 --- a/internal/web/service/inbound.go +++ b/internal/web/service/inbound.go @@ -1028,18 +1028,21 @@ const ( // listening (stopping every inbound) or an outbound dials. func validateXDriveStorage(streamSettings string) error { var stream struct { - Network string `json:"network"` - XDriveSettings struct { - Service string `json:"service"` - RemoteFolder string `json:"remoteFolder"` - Secrets []string `json:"secrets"` - Template json.RawMessage `json:"template"` - } `json:"xdriveSettings"` + Network string `json:"network"` + XDriveSettings json.RawMessage `json:"xdriveSettings"` } if json.Unmarshal([]byte(streamSettings), &stream) != nil || stream.Network != "xdrive" { return nil } - xd := stream.XDriveSettings + var xd struct { + Service string `json:"service"` + RemoteFolder string `json:"remoteFolder"` + Secrets []string `json:"secrets"` + Template json.RawMessage `json:"template"` + } + if len(stream.XDriveSettings) > 0 && json.Unmarshal(stream.XDriveSettings, &xd) != nil { + return common.NewError("XDRIVE settings are not a valid object.") + } hasFolder := strings.TrimSpace(xd.RemoteFolder) != "" switch xd.Service { case "local": @@ -1058,8 +1061,12 @@ func validateXDriveStorage(streamSettings string) error { return common.NewError(`XDRIVE service "template" needs a "template" object describing the storage API.`) } return validateXDriveTemplate(xd.Template) + case "s3": + if err := xray.ValidateXDriveS3(stream.XDriveSettings); err != nil { + return common.NewError("XDRIVE ", err) + } default: - return common.NewError(fmt.Sprintf("unsupported XDRIVE service %q: use local, google-drive or template.", xd.Service)) + return common.NewError(fmt.Sprintf("unsupported XDRIVE service %q: use local, google-drive, template or s3.", xd.Service)) } return nil } diff --git a/internal/web/service/inbound_push_guard.go b/internal/web/service/inbound_push_guard.go index 2e6f898f7..16dc185ab 100644 --- a/internal/web/service/inbound_push_guard.go +++ b/internal/web/service/inbound_push_guard.go @@ -150,7 +150,7 @@ func hostReaches(inbound *model.Inbound) ([]pushReach, error) { object("hysteria masquerade", "hysteriasettings", "masquerade") case leaf.endsWith("masterkeylog") && lower != "" && lower != "none": add("masterKeyLog", leaf) - case leaf.endsWith("xdrivesettings", "service") && (lower == "local" || lower == "template"): + case leaf.endsWith("xdrivesettings", "service") && (lower == "local" || lower == "template" || lower == "s3"): object("xdrive service", "xdrivesettings") case leaf.endsWith("echconfiglist") && strings.Contains(lower, "://"): add("echConfigList", leaf) diff --git a/internal/web/service/inbound_tls_test.go b/internal/web/service/inbound_tls_test.go index 978a73d34..6dc8f9300 100644 --- a/internal/web/service/inbound_tls_test.go +++ b/internal/web/service/inbound_tls_test.go @@ -252,6 +252,10 @@ func TestAddInboundRefusesPushedFieldsThatReachThisHost(t *testing.T) { t.Errorf("%s: refused: %v", name, err) } } + s3 := xdriveInbound(44404, `{"service":"s3","remoteFolder":"x","secrets":["AKID","SECRET"],"s3":{"endpoint":"http://127.0.0.1:2053","region":"r","bucket":"b"}}`) + if _, _, err := push.AddInbound(s3); err == nil || !strings.Contains(err.Error(), "may not set xdrive service") { + t.Fatalf("pushed S3 storage on this host's own port = %v, want refused by the push guard", err) + } if _, _, err := (&InboundService{}).AddInbound(&model.Inbound{Enable: true, Listen: "env:XUI_DB_DSN", Port: 44403, Protocol: model.VLESS, Settings: `{"clients":[]}`}); err == nil || !strings.Contains(err.Error(), "env:") { t.Fatalf("env: listen = %v, want refused for every caller", err) } diff --git a/internal/web/service/inbound_xdrive_test.go b/internal/web/service/inbound_xdrive_test.go index a580315ca..60f7116c8 100644 --- a/internal/web/service/inbound_xdrive_test.go +++ b/internal/web/service/inbound_xdrive_test.go @@ -140,6 +140,10 @@ func TestAddInboundRefusesXDriveStorageTheCoreCannotOpen(t *testing.T) { {"template without a delete op", `{"service":"template","template":{"put":{"url":"u"},"get":{"url":"u"},"list":{"url":"u","namesRegex":"(.+)"}}}`, "XDRIVE template needs put, get, list and delete operations"}, {"template without namesRegex", `{"service":"template","template":{"put":{"url":"u"},"get":{"url":"u"},"list":{"url":"u"},"delete":{"url":"u"}}}`, "XDRIVE template list needs a namesRegex"}, {"template namesRegex without a group", `{"service":"template","template":{"put":{"url":"u"},"get":{"url":"u"},"list":{"url":"u","namesRegex":"name"},"delete":{"url":"u"}}}`, "XDRIVE template namesRegex needs one capture group"}, + {"s3 without an s3 object", `{"service":"s3","remoteFolder":"f","secrets":["key","secret"]}`, `service "s3" needs an "s3" object`}, + {"s3 endpoint that is not http", `{"service":"s3","remoteFolder":"f","secrets":["key","secret"],"s3":{"endpoint":"ftp://s3.example","region":"r","bucket":"b"}}`, "S3 endpoint must be an http(s) URL"}, + {"s3 with one secret", `{"service":"s3","remoteFolder":"f","secrets":["key"],"s3":{"endpoint":"https://s3.example","region":"r","bucket":"b"}}`, "S3 needs AccessKeyID, SecretAccessKey"}, + {"s3 virtual-hosted on an IP endpoint", `{"service":"s3","remoteFolder":"f","secrets":["key","secret"],"s3":{"endpoint":"https://10.0.0.1","region":"r","bucket":"b","pathStyle":false}}`, "requires a DNS endpoint"}, {"unknown service", `{"service":"Dropbox","remoteFolder":"f"}`, `unsupported XDRIVE service "Dropbox"`}, } for i, tc := range tests { @@ -161,6 +165,14 @@ func TestAddInboundAcceptsACompleteXDriveTemplate(t *testing.T) { } } +func TestAddInboundAcceptsACompleteXDriveS3Storage(t *testing.T) { + setupBulkDB(t) + s3 := `{"service":"s3","remoteFolder":"xdrive","secrets":["AKID","SECRET"],"s3":{"endpoint":"https://storage.yandexcloud.net","region":"ru-central1","bucket":"example-bucket"}}` + if _, _, err := (&InboundService{}).AddInbound(xdriveInbound(18481, s3)); err != nil { + t.Fatalf("AddInbound of an S3 storage xray-core can open: %v", err) + } +} + func TestSavePathsRefuseXDriveWithoutStorage(t *testing.T) { const want = `XDRIVE needs a "remoteFolder"` const empty = `{"service":"local","remoteFolder":""}` diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index c89343738..47f90a788 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -737,6 +737,14 @@ "clientId": "Client ID", "clientSecret": "Client Secret", "refreshToken": "Refresh Token", + "accessKeyId": "معرّف مفتاح الوصول", + "secretAccessKey": "مفتاح الوصول السري", + "sessionToken": "رمز الجلسة", + "s3Endpoint": "نقطة النهاية", + "s3Region": "المنطقة", + "s3Bucket": "الحاوية (Bucket)", + "s3PathStyle": "عنونة بنمط المسار", + "s3PathStyleDesc": "مفعّل: اسم الحاوية بيتحط في مسار الرابط. مقفول: بيتحط في اسم المضيف، وده محتاج endpoint باسم DNS.", "secrets": "الأسرار", "secretsDesc": "قيم يقرؤها القالب باسم {secret0} و{secret1} …", "template": "قالب التخزين", diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index 6578a9292..27f14cf38 100644 --- a/internal/web/translation/en-US.json +++ b/internal/web/translation/en-US.json @@ -738,6 +738,14 @@ "clientId": "Client ID", "clientSecret": "Client secret", "refreshToken": "Refresh token", + "accessKeyId": "Access key ID", + "secretAccessKey": "Secret access key", + "sessionToken": "Session token", + "s3Endpoint": "Endpoint", + "s3Region": "Region", + "s3Bucket": "Bucket", + "s3PathStyle": "Path-style addressing", + "s3PathStyleDesc": "On: the bucket goes in the URL path. Off: in the host name, which needs a DNS endpoint.", "secrets": "Secrets", "secretsDesc": "Values the template reads as {secret0}, {secret1}, …", "template": "Storage template", diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index 6fb602d31..a933ce3ae 100644 --- a/internal/web/translation/es-ES.json +++ b/internal/web/translation/es-ES.json @@ -737,6 +737,14 @@ "clientId": "Client ID", "clientSecret": "Client Secret", "refreshToken": "Refresh Token", + "accessKeyId": "ID de clave de acceso", + "secretAccessKey": "Clave de acceso secreta", + "sessionToken": "Token de sesión", + "s3Endpoint": "Endpoint", + "s3Region": "Región", + "s3Bucket": "Bucket", + "s3PathStyle": "Direccionamiento por ruta", + "s3PathStyleDesc": "Activado: el bucket va en la ruta de la URL. Desactivado: en el nombre de host, lo que requiere un endpoint DNS.", "secrets": "Secretos", "secretsDesc": "Valores que la plantilla lee como {secret0}, {secret1}, …", "template": "Plantilla de almacenamiento", diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index f01c53540..a04d621d7 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -737,6 +737,14 @@ "clientId": "Client ID", "clientSecret": "Client Secret", "refreshToken": "Refresh Token", + "accessKeyId": "شناسه کلید دسترسی", + "secretAccessKey": "کلید دسترسی محرمانه", + "sessionToken": "توکن نشست", + "s3Endpoint": "نقطه پایانی (Endpoint)", + "s3Region": "منطقه (Region)", + "s3Bucket": "باکت (Bucket)", + "s3PathStyle": "آدرس‌دهی مسیرمحور", + "s3PathStyleDesc": "روشن: نام باکت در مسیر URL قرار می‌گیرد. خاموش: در نام میزبان، که به یک endpoint دامنه‌ای نیاز دارد.", "secrets": "مقادیر محرمانه", "secretsDesc": "مقادیری که قالب با {secret0}، {secret1}، … می‌خواند", "template": "قالب ذخیره‌سازی", diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index f0b8ac109..45d730e1d 100644 --- a/internal/web/translation/id-ID.json +++ b/internal/web/translation/id-ID.json @@ -737,6 +737,14 @@ "clientId": "Client ID", "clientSecret": "Client Secret", "refreshToken": "Refresh Token", + "accessKeyId": "ID kunci akses", + "secretAccessKey": "Kunci akses rahasia", + "sessionToken": "Token sesi", + "s3Endpoint": "Endpoint", + "s3Region": "Region", + "s3Bucket": "Bucket", + "s3PathStyle": "Pengalamatan gaya path", + "s3PathStyleDesc": "Aktif: bucket ditaruh di path URL. Nonaktif: di nama host, yang membutuhkan endpoint DNS.", "secrets": "Rahasia", "secretsDesc": "Nilai yang dibaca template sebagai {secret0}, {secret1}, …", "template": "Template penyimpanan", diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index 0bfeeb5be..28f018b7e 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -737,6 +737,14 @@ "clientId": "クライアント ID", "clientSecret": "クライアントシークレット", "refreshToken": "リフレッシュトークン", + "accessKeyId": "アクセスキー ID", + "secretAccessKey": "シークレットアクセスキー", + "sessionToken": "セッショントークン", + "s3Endpoint": "エンドポイント", + "s3Region": "リージョン", + "s3Bucket": "バケット", + "s3PathStyle": "パス形式のアドレス指定", + "s3PathStyleDesc": "オン:バケットを URL のパスに入れます。オフ:ホスト名に入れます(DNS 名のエンドポイントが必要)。", "secrets": "シークレット", "secretsDesc": "テンプレートで {secret0}、{secret1}、… として参照される値", "template": "ストレージテンプレート", diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index 01ff63735..58296ab40 100644 --- a/internal/web/translation/pt-BR.json +++ b/internal/web/translation/pt-BR.json @@ -737,6 +737,14 @@ "clientId": "Client ID", "clientSecret": "Client Secret", "refreshToken": "Refresh Token", + "accessKeyId": "ID da chave de acesso", + "secretAccessKey": "Chave de acesso secreta", + "sessionToken": "Token de sessão", + "s3Endpoint": "Endpoint", + "s3Region": "Região", + "s3Bucket": "Bucket", + "s3PathStyle": "Endereçamento por caminho", + "s3PathStyleDesc": "Ligado: o bucket vai no caminho da URL. Desligado: no nome do host, o que exige um endpoint DNS.", "secrets": "Segredos", "secretsDesc": "Valores que o template lê como {secret0}, {secret1}, …", "template": "Template de armazenamento", diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index eba1e20e4..c0ff714ca 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -738,6 +738,14 @@ "clientId": "Client ID", "clientSecret": "Client Secret", "refreshToken": "Refresh Token", + "accessKeyId": "ID ключа доступа", + "secretAccessKey": "Секретный ключ доступа", + "sessionToken": "Токен сессии", + "s3Endpoint": "Endpoint", + "s3Region": "Регион", + "s3Bucket": "Бакет", + "s3PathStyle": "Адресация через путь", + "s3PathStyleDesc": "Вкл.: бакет указывается в пути URL. Выкл.: в имени хоста, для чего нужен DNS-endpoint.", "secrets": "Секреты", "secretsDesc": "Значения, которые шаблон читает как {secret0}, {secret1}, …", "template": "Шаблон хранилища", diff --git a/internal/web/translation/sk-SK.json b/internal/web/translation/sk-SK.json index 95449f105..19b9a472a 100644 --- a/internal/web/translation/sk-SK.json +++ b/internal/web/translation/sk-SK.json @@ -738,6 +738,14 @@ "clientId": "ID klienta", "clientSecret": "Tajný kľúč klienta", "refreshToken": "Obnovovací token", + "accessKeyId": "ID prístupového kľúča", + "secretAccessKey": "Tajný prístupový kľúč", + "sessionToken": "Token relácie", + "s3Endpoint": "Endpoint", + "s3Region": "Región", + "s3Bucket": "Bucket", + "s3PathStyle": "Adresovanie cez cestu", + "s3PathStyleDesc": "Zapnuté: bucket je v ceste URL. Vypnuté: v názve hostiteľa, čo vyžaduje DNS endpoint.", "secrets": "Tajné hodnoty", "secretsDesc": "Hodnoty, ktoré šablóna číta ako {secret0}, {secret1}, …", "template": "Šablóna úložiska", diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index adc69e513..56500bc01 100644 --- a/internal/web/translation/tr-TR.json +++ b/internal/web/translation/tr-TR.json @@ -737,6 +737,14 @@ "clientId": "Client ID", "clientSecret": "Client Secret", "refreshToken": "Refresh Token", + "accessKeyId": "Erişim anahtarı kimliği", + "secretAccessKey": "Gizli erişim anahtarı", + "sessionToken": "Oturum belirteci", + "s3Endpoint": "Uç nokta", + "s3Region": "Bölge", + "s3Bucket": "Bucket", + "s3PathStyle": "Yol tarzı adresleme", + "s3PathStyleDesc": "Açık: bucket URL yoluna yazılır. Kapalı: ana bilgisayar adına yazılır, bu da DNS uç noktası gerektirir.", "secrets": "Gizli değerler", "secretsDesc": "Şablonun {secret0}, {secret1}, … olarak okuduğu değerler", "template": "Depolama şablonu", diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index cc824b81d..15c1e011b 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -737,6 +737,14 @@ "clientId": "Client ID", "clientSecret": "Client Secret", "refreshToken": "Refresh Token", + "accessKeyId": "ID ключа доступу", + "secretAccessKey": "Секретний ключ доступу", + "sessionToken": "Токен сесії", + "s3Endpoint": "Endpoint", + "s3Region": "Регіон", + "s3Bucket": "Бакет", + "s3PathStyle": "Адресація через шлях", + "s3PathStyleDesc": "Увімк.: бакет вказується в шляху URL. Вимк.: в імені хоста, для чого потрібен DNS-endpoint.", "secrets": "Секрети", "secretsDesc": "Значення, які шаблон читає як {secret0}, {secret1}, …", "template": "Шаблон сховища", diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index 25e17db14..18aa3db53 100644 --- a/internal/web/translation/vi-VN.json +++ b/internal/web/translation/vi-VN.json @@ -737,6 +737,14 @@ "clientId": "Client ID", "clientSecret": "Client Secret", "refreshToken": "Refresh Token", + "accessKeyId": "ID khóa truy cập", + "secretAccessKey": "Khóa truy cập bí mật", + "sessionToken": "Token phiên", + "s3Endpoint": "Endpoint", + "s3Region": "Vùng", + "s3Bucket": "Bucket", + "s3PathStyle": "Định địa chỉ kiểu đường dẫn", + "s3PathStyleDesc": "Bật: bucket nằm trong đường dẫn URL. Tắt: nằm trong tên máy chủ, cần endpoint dạng DNS.", "secrets": "Giá trị bí mật", "secretsDesc": "Các giá trị template đọc dưới dạng {secret0}, {secret1}, …", "template": "Template lưu trữ", diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index 2684cda56..9d760b324 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -737,6 +737,14 @@ "clientId": "客户端 ID", "clientSecret": "客户端密钥", "refreshToken": "刷新令牌", + "accessKeyId": "访问密钥 ID", + "secretAccessKey": "秘密访问密钥", + "sessionToken": "会话令牌", + "s3Endpoint": "端点", + "s3Region": "区域", + "s3Bucket": "存储桶", + "s3PathStyle": "路径风格寻址", + "s3PathStyleDesc": "开启:存储桶名放在 URL 路径中。关闭:放在主机名中,需要使用域名端点。", "secrets": "密钥", "secretsDesc": "模板中以 {secret0}、{secret1}… 引用的值", "template": "存储模板", diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index 0bcc9bea3..71e993a22 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -737,6 +737,14 @@ "clientId": "用戶端 ID", "clientSecret": "用戶端密鑰", "refreshToken": "重新整理權杖", + "accessKeyId": "存取金鑰 ID", + "secretAccessKey": "秘密存取金鑰", + "sessionToken": "工作階段權杖", + "s3Endpoint": "端點", + "s3Region": "區域", + "s3Bucket": "儲存貯體", + "s3PathStyle": "路徑樣式定址", + "s3PathStyleDesc": "開啟:儲存貯體名稱放在 URL 路徑中。關閉:放在主機名稱中,需要使用網域端點。", "secrets": "密鑰", "secretsDesc": "範本中以 {secret0}、{secret1}… 引用的值", "template": "儲存範本", diff --git a/internal/xray/api.go b/internal/xray/api.go index a03aac962..81555fdc7 100644 --- a/internal/xray/api.go +++ b/internal/xray/api.go @@ -237,6 +237,17 @@ func ValidateTunWfpBlockLeak(values []string) error { return err } +// ValidateXDriveS3 builds an "s3" xdriveSettings object through the vendored loader, which runs +// the endpoint, bucket and secret checks xray-core 26.10.31 (#7231) applies to the S3 backend. +func ValidateXDriveS3(xdriveSettings []byte) error { + c := new(conf.XDriveConfig) + if err := json.Unmarshal(xdriveSettings, c); err != nil { + return err + } + _, err := c.Build() + return err +} + // validateXhttpDownloadSettings refuses what conf.Build() lets through but the core's XHTTP // dialer panics on at every dial: a downloadSettings block without an address or not on xhttp. func validateXhttpDownloadSettings(settings *serial.TypedMessage) error { From 757444d50549d8cb1b85c235a52de639c35f7bdd Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 14:56:40 +0100 Subject: [PATCH 124/125] fix(tgbot): let a screen-art reset outlast a probe still in flight TestScreenArtReprobeWindow failed under -race -shuffle in CI: the probe slot is process-wide, and a probe an earlier test started against its own (now closing) server still held it when the next test asked for a re-probe, so that request was dropped and the stale avatar never re-probed. The leftover probe could also write its result over the state the next test had just reset. Probes now run in a WaitGroup that resetScreenArt waits on, so a reset between mock servers always starts from an idle probe slot. --- internal/web/service/tgbot/screens_art.go | 7 ++++-- internal/web/service/tgbot/screens_test.go | 28 ++++++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/internal/web/service/tgbot/screens_art.go b/internal/web/service/tgbot/screens_art.go index 44887a7c9..0ae4b5bdc 100644 --- a/internal/web/service/tgbot/screens_art.go +++ b/internal/web/service/tgbot/screens_art.go @@ -32,6 +32,8 @@ var ( artProbedAt time.Time // artProbeRunning keeps a burst of renders from starting a burst of probes. artProbeRunning atomic.Bool + // artProbes lets resetScreenArt outlast a probe still running against a previous bot. + artProbes sync.WaitGroup blackTileOnce sync.Once blackTileBytes []byte @@ -109,10 +111,10 @@ func (t *Tgbot) ensureScreenArt() { if !artProbeRunning.CompareAndSwap(false, true) { return } - go func() { + artProbes.Go(func() { defer artProbeRunning.Store(false) t.resolveScreenArt() - }() + }) } // artFileID returns the cached file_id for a screen kind. A fallback tile cached @@ -163,6 +165,7 @@ func cacheArtFileID(kind, fileID string) { // resetScreenArt drops the cached file_ids; tests use it between mock servers. func resetScreenArt() { + artProbes.Wait() screenArtMu.Lock() screenArtIDs = map[string]string{} artOverrides = map[string]string{} diff --git a/internal/web/service/tgbot/screens_test.go b/internal/web/service/tgbot/screens_test.go index 34ccb68ae..048c0de4d 100644 --- a/internal/web/service/tgbot/screens_test.go +++ b/internal/web/service/tgbot/screens_test.go @@ -508,6 +508,34 @@ func TestScreenArtReprobeWindow(t *testing.T) { } } +// A probe a previous test left in flight held the process-wide probe slot, so under -race -shuffle +// the next test's stale avatar was never re-probed (CI flake in TestScreenArtReprobeWindow). +func TestScreenArtResetWaitsForAProbeLeftInFlight(t *testing.T) { + slow := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(300 * time.Millisecond) + http.Error(w, "gone", http.StatusServiceUnavailable) + })) + defer slow.Close() + swapTestBot(t, slow.URL) + (&Tgbot{}).ensureScreenArt() + + srv, calls := recordingServer(t, false) + defer srv.Close() + swapTestBot(t, srv.URL) + screenArtMu.Lock() + artProbedAt = time.Now().Add(-2 * time.Minute) + screenArtMu.Unlock() + + (&Tgbot{}).ensureScreenArt() + for range 100 { + if countMethod(calls(), "getUserProfilePhotos") > 0 { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatal("the probe left in flight by the previous bot kept the new one from probing") +} + func TestScreenKeepsArtFromTheBotAvatar(t *testing.T) { tb, calls := newScreenTgbot(t, false) tb.resolveScreenArt() From 6f37433d43101e36f6f37ed68f804e12df17fa6c Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 15:14:49 +0100 Subject: [PATCH 125/125] fix(amneziawgnet): stop tests binding fixed ports in the ephemeral range The race job failed TestEnsureRebuildsWhenS4ChangesTheDerivedMTU with "bind 127.0.0.1:58720: address already in use". The package hardcoded WireGuard and port-forward ports 58712-58922, all inside Linux's ephemeral range (32768-60999), so any concurrent :0 socket could already hold one; several were also reused across tests (58713 x3, 58715, 58717, 58720). Tests that never dial the server now use ListenPort 0; tests that need the number take an OS-assigned one via freeUDPPort / freeTCPUDPPort (the latter for port forwards, which bind TCP and UDP on the same port). --- internal/amneziawgnet/bench_test.go | 2 +- internal/amneziawgnet/device_test.go | 10 +++--- internal/amneziawgnet/diagnostics_test.go | 2 +- internal/amneziawgnet/egress_domain_test.go | 14 ++------ internal/amneziawgnet/manager_test.go | 12 +++---- internal/amneziawgnet/portfwd_test.go | 14 ++++---- internal/amneziawgnet/relay_e2e_test.go | 37 +++++++++++++++++++-- internal/amneziawgnet/udp_test.go | 2 +- internal/amneziawgnet/v6alias_test.go | 2 +- 9 files changed, 58 insertions(+), 37 deletions(-) diff --git a/internal/amneziawgnet/bench_test.go b/internal/amneziawgnet/bench_test.go index 61bd8e50b..cd8aaf231 100644 --- a/internal/amneziawgnet/bench_test.go +++ b/internal/amneziawgnet/bench_test.go @@ -210,7 +210,7 @@ func (t *benchTunnel) dial(b *testing.B, dest netip.AddrPort) *gonet.TCPConn { func BenchmarkTunnelThroughput(b *testing.B) { const chunkSize = 64 << 10 const serverAddr = "10.203.0.1" - tun := newBenchTunnel(b, 58714, serverAddr, "10.203.0.2") + tun := newBenchTunnel(b, freeUDPPort(b), serverAddr, "10.203.0.2") defer tun.closeFn() b.Run("upload", func(b *testing.B) { diff --git a/internal/amneziawgnet/device_test.go b/internal/amneziawgnet/device_test.go index 821cfc095..06d939dd9 100644 --- a/internal/amneziawgnet/device_test.go +++ b/internal/amneziawgnet/device_test.go @@ -39,7 +39,7 @@ func TestNewDeviceHandshakeForwarderAndIdentity(t *testing.T) { t.Fatalf("generate client keypair: %v", err) } - const listenPort = 58712 // fixed loopback test port, matches the validated Phase 0 spike approach + listenPort := freeUDPPort(t) const wantEmail = "test-peer@example.com" inst := amneziawg.Instance{ @@ -243,7 +243,7 @@ func TestNewDeviceHeaderProtectionAndContentPaddingRoundTrip(t *testing.T) { t.Fatalf("generate header protection key: %v", err) } - const listenPort = 58713 // fixed loopback test port, distinct from the handshake test above + listenPort := freeUDPPort(t) const contentPaddingAddition = "20-40" inst := amneziawg.Instance{ @@ -429,7 +429,7 @@ func TestNewDeviceRandomTrailersAndDisableCookiesRoundTrip(t *testing.T) { t.Fatalf("generate client keypair: %v", err) } - const listenPort = 58721 // fixed loopback test port, distinct from every other test in this package + listenPort := freeUDPPort(t) inst := amneziawg.Instance{ Id: 3, @@ -591,7 +591,7 @@ func TestValidatedObfuscationAlwaysApplies(t *testing.T) { }}, } - for i, tc := range cases { + for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { o := base tc.mut(&o) @@ -599,7 +599,7 @@ func TestValidatedObfuscationAlwaysApplies(t *testing.T) { return // rejected before saving, which is the whole point } inst := amneziawg.Instance{ - Id: 88, InterfaceName: "awgcontract", ListenPort: 58900 + i, + Id: 88, InterfaceName: "awgcontract", ListenPort: 0, PrivateKey: priv, PublicKey: pub, Address: []string{"10.198.0.1/24"}, MTU: 1420, Obfuscation: o, diff --git a/internal/amneziawgnet/diagnostics_test.go b/internal/amneziawgnet/diagnostics_test.go index c87b79b77..5344aac04 100644 --- a/internal/amneziawgnet/diagnostics_test.go +++ b/internal/amneziawgnet/diagnostics_test.go @@ -45,7 +45,7 @@ func TestDiagnoseDeviceReportsListenPortAndPeerState(t *testing.T) { t.Fatalf("generate idle-peer keypair: %v", err) } - const listenPort = 58713 // distinct from device_test.go's fixed port + listenPort := freeUDPPort(t) const activeEmail = "active@example.com" const idleEmail = "idle@example.com" diff --git a/internal/amneziawgnet/egress_domain_test.go b/internal/amneziawgnet/egress_domain_test.go index 164cc598d..dea4f0977 100644 --- a/internal/amneziawgnet/egress_domain_test.go +++ b/internal/amneziawgnet/egress_domain_test.go @@ -57,12 +57,7 @@ func newPairedTunnelForTest(t *testing.T) *pairedTunnel { if err != nil { t.Fatal(err) } - pc, err := net.ListenPacket("udp", "127.0.0.1:0") - if err != nil { - t.Fatal(err) - } - listenPort := pc.LocalAddr().(*net.UDPAddr).Port - pc.Close() + listenPort := freeUDPPort(t) obf := amneziawg.Obfuscation31{Jc: 4, Jmin: 40, Jmax: 70, S1: 20, S2: 30, S3: 20, S4: 20} serverInst := amneziawg.Instance{ @@ -133,12 +128,7 @@ func newPairedTunnelV6ForTest(t *testing.T) *pairedTunnel { if err != nil { t.Fatal(err) } - pc, err := net.ListenPacket("udp", "127.0.0.1:0") - if err != nil { - t.Fatal(err) - } - listenPort := pc.LocalAddr().(*net.UDPAddr).Port - pc.Close() + listenPort := freeUDPPort(t) obf := amneziawg.Obfuscation31{Jc: 4, Jmin: 40, Jmax: 70, S1: 20, S2: 30, S3: 20, S4: 20} serverInst := amneziawg.Instance{ diff --git a/internal/amneziawgnet/manager_test.go b/internal/amneziawgnet/manager_test.go index 497b4360d..6cc8e8827 100644 --- a/internal/amneziawgnet/manager_test.go +++ b/internal/amneziawgnet/manager_test.go @@ -29,7 +29,7 @@ func TestManagerLifecycle(t *testing.T) { inst := amneziawg.Instance{ Id: 3, InterfaceName: "awgtest3", - ListenPort: 58714, + ListenPort: 0, PrivateKey: priv, PublicKey: pub, Address: []string{"10.203.0.1/24"}, @@ -117,7 +117,7 @@ func TestEnsureRebuildsWhenS4ChangesTheDerivedMTU(t *testing.T) { inst := amneziawg.Instance{ Id: 9 + i, InterfaceName: fmt.Sprintf("awgtest%d", 9+i), - ListenPort: 58719 + i, + ListenPort: 0, PrivateKey: priv, PublicKey: pub, Address: []string{"10.209.0.1/24"}, @@ -214,7 +214,7 @@ func TestEnsureUnchangedInstanceDoesNotResetLivePeers(t *testing.T) { inst := amneziawg.Instance{ Id: 4, InterfaceName: "awgtest4", - ListenPort: 58715, + ListenPort: 0, PrivateKey: priv, PublicKey: pub, Address: []string{"10.204.0.1/24"}, @@ -351,7 +351,7 @@ func TestEnsureDropsFlowsOfReassignedTunnelAddress(t *testing.T) { inst := amneziawg.Instance{ Id: 9, InterfaceName: "awgtest9", - ListenPort: 58720, + ListenPort: 0, PrivateKey: priv, PublicKey: pub, Address: []string{"10.206.0.1/24"}, @@ -465,7 +465,7 @@ func TestEnsureHeaderProtectionKeyChangeReconfiguresInPlace(t *testing.T) { inst := amneziawg.Instance{ Id: 7, InterfaceName: "awgtest7", - ListenPort: 58717, + ListenPort: 0, PrivateKey: priv, PublicKey: pub, Address: []string{"10.207.0.1/24"}, @@ -525,7 +525,7 @@ func TestEnsureRejectsHeaderProtectionKeyWithLowS1S4(t *testing.T) { inst := amneziawg.Instance{ Id: 8, InterfaceName: "awgtest8", - ListenPort: 58718, + ListenPort: 0, PrivateKey: priv, PublicKey: pub, Address: []string{"10.208.0.1/24"}, diff --git a/internal/amneziawgnet/portfwd_test.go b/internal/amneziawgnet/portfwd_test.go index 9a55c7f29..a61777f28 100644 --- a/internal/amneziawgnet/portfwd_test.go +++ b/internal/amneziawgnet/portfwd_test.go @@ -145,7 +145,7 @@ func TestPortForwardSetReconcileOpensAndClosesListeners(t *testing.T) { gs := newTestStack(t, "10.211.0.1") set := NewPortForwardSet(gs, 501) - const port = 58910 + port := freeTCPUDPPort(t) inst := amneziawg.Instance{Peers: []amneziawg.Peer{ peerWithPortsAndIPs("a@x", fmt.Sprintf("%d", port), "10.211.0.2/32"), }} @@ -190,8 +190,7 @@ func TestPortForwardSetReconcileSurvivesPreBoundPort(t *testing.T) { gs := newTestStack(t, "10.211.1.1") set := NewPortForwardSet(gs, 502) - const collidingPort = 58911 - const okPort = 58912 + collidingPort, okPort := freeTCPUDPPort(t), freeTCPUDPPort(t) blocker, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", collidingPort)) if err != nil { t.Fatalf("pre-bind test port: %v", err) @@ -280,9 +279,8 @@ func TestPortForwardRoundTripTCPAndUDP(t *testing.T) { t.Fatalf("generate client keypair: %v", err) } - const listenPort = 58920 // fixed loopback test port, matches this package's existing test convention - const tcpPort = 58921 - const udpPort = 58922 + listenPort := freeUDPPort(t) + tcpPort, udpPort := freeTCPUDPPort(t), freeTCPUDPPort(t) const clientAddr = "10.202.0.2" inst := amneziawg.Instance{ @@ -510,7 +508,7 @@ func waitPortForwardSlots(t *testing.T, slots chan struct{}, want int) { } func TestPortForwardUDPSessionsAreCapped(t *testing.T) { - const port = 58913 + port := freeUDPPort(t) const free, sources = 3, 10 held := leavePortForwardSlots(t, portForwardUDPSlots, free) @@ -551,7 +549,7 @@ func TestPortForwardUDPSessionsAreCapped(t *testing.T) { } func TestPortForwardTCPConnectionsAreCapped(t *testing.T) { - const port = 58914 + port := freePort(t) const free, clients = 3, 10 held := leavePortForwardSlots(t, portForwardTCPSlots, free) diff --git a/internal/amneziawgnet/relay_e2e_test.go b/internal/amneziawgnet/relay_e2e_test.go index b1a00a3d9..2d5a5756c 100644 --- a/internal/amneziawgnet/relay_e2e_test.go +++ b/internal/amneziawgnet/relay_e2e_test.go @@ -77,7 +77,7 @@ func TestSocksRelayAgainstRealXray(t *testing.T) { t.Fatalf("generate client keypair: %v", err) } - const listenPort = 58715 + listenPort := freeUDPPort(t) inst := amneziawg.Instance{ Id: 4, InterfaceName: "awgtest4", @@ -305,7 +305,7 @@ func TestManagerEnsureAutomaticallyWiresRelay(t *testing.T) { } const wantEmail = "manager-e2e-peer@example.com" - const listenPort = 58716 + listenPort := freeUDPPort(t) const inboundID = 5 tcpEcho, tcpEchoAddr := startTCPEcho(t, localIP) @@ -595,6 +595,39 @@ func freePort(t *testing.T) int { return l.Addr().(*net.TCPAddr).Port } +// freeUDPPort asks the OS for a free loopback UDP port: a fixed one sits in the +// Linux ephemeral range, where any concurrent :0 socket can already hold it. +func freeUDPPort(tb testing.TB) int { + tb.Helper() + pc, err := net.ListenPacket("udp", "127.0.0.1:0") + if err != nil { + tb.Fatal(err) + } + defer pc.Close() + return pc.LocalAddr().(*net.UDPAddr).Port +} + +// freeTCPUDPPort finds a loopback port free for both protocols, since a port +// forward binds a TCP and a UDP listener on the same number. +func freeTCPUDPPort(t *testing.T) int { + t.Helper() + for range 20 { + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + port := l.Addr().(*net.TCPAddr).Port + pc, err := net.ListenPacket("udp", fmt.Sprintf("127.0.0.1:%d", port)) + l.Close() + if err == nil { + pc.Close() + return port + } + } + t.Fatal("no loopback port free for both TCP and UDP") + return 0 +} + func waitForPort(t *testing.T, port int) { t.Helper() deadline := time.Now().Add(15 * time.Second) diff --git a/internal/amneziawgnet/udp_test.go b/internal/amneziawgnet/udp_test.go index c2d79307b..a2fc7006c 100644 --- a/internal/amneziawgnet/udp_test.go +++ b/internal/amneziawgnet/udp_test.go @@ -34,7 +34,7 @@ func TestNewDeviceUDPHandlerAndReply(t *testing.T) { t.Fatalf("generate client keypair: %v", err) } - const listenPort = 58713 // distinct from the TCP test's port + listenPort := freeUDPPort(t) const wantEmail = "udp-test-peer@example.com" const echoPayload = "hello-from-client" diff --git a/internal/amneziawgnet/v6alias_test.go b/internal/amneziawgnet/v6alias_test.go index 7b4dce784..e11e94404 100644 --- a/internal/amneziawgnet/v6alias_test.go +++ b/internal/amneziawgnet/v6alias_test.go @@ -305,7 +305,7 @@ func TestManagerRemoveLeavesHostOwnedAddressInPlace(t *testing.T) { inst := amneziawg.Instance{ Id: 7, InterfaceName: "awgtest7", - ListenPort: 58717, + ListenPort: 0, PrivateKey: priv, PublicKey: pub, Address: []string{"10.207.0.1/24", "fd86:ea04:1115::1/64"},