mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-02 19:25:05 +03:00
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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+67
-12
@@ -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 == "" {
|
||||
|
||||
Reference in New Issue
Block a user