10 Commits f8b72de995 ... 608ef4e401

Author SHA1 Message Date
  MHSanaei 608ef4e401 v3.10.2 9 hours ago
  MHSanaei 35b5e0989a fix(sponsor): fetch sponsor data from raw.githubusercontent.com 9 hours ago
  MHSanaei b177b98ce8 feat(mtproto): expose mtg-multi's per-IP pending-handshake limit 10 hours ago
  Jay Hemnani 6bd1b8676a fix(link): read the XHTTP fields a vmess link carries inline (#6808) 11 hours ago
  Jay Hemnani ff3f42ee2a fix(link): read fm and the mKCP fields from vmess links (#6809) 11 hours ago
  MHSanaei 760f428c41 fix(xray): capture whole core crash reports instead of the first pipe chunk 11 hours ago
  MHSanaei c45ffcdafa chore(windows): update OpenSSL installer 12 hours ago
  MHSanaei cb4f2ec92f fix(sub): export Hysteria2 Gecko obfs in Clash/Mihomo subscriptions 12 hours ago
  MHSanaei db8b01b8ed feat(release): ship the Windows build as 3x-ui.exe with version info and icon 12 hours ago
  MHSanaei a66b360c09 fix(xray): apply shadowsocks client changes in place instead of rebuilding 13 hours ago
38 changed files with 761 additions and 129 deletions
  1. 6 1
      .github/workflows/release.yml
  2. 1 0
      .gitignore
  3. 13 0
      frontend/src/lib/xray/outbound-link-parser.ts
  4. 7 0
      frontend/src/pages/inbounds/form/protocols/mtproto.tsx
  5. 2 0
      frontend/src/schemas/protocols/inbound/mtproto.ts
  6. 51 1
      frontend/src/test/outbound-link-parser.test.ts
  7. 1 1
      internal/config/version
  8. 34 24
      internal/mtproto/manager.go
  9. 27 18
      internal/mtproto/manager_test.go
  10. 10 2
      internal/sub/clash_service.go
  11. 40 0
      internal/sub/hysteria_geco_uri_test.go
  12. 33 2
      internal/util/link/outbound.go
  13. 77 0
      internal/util/link/outbound_helpers_test.go
  14. 74 0
      internal/web/service/inbound_update_hot_users_test.go
  15. 2 2
      internal/web/service/panel/sponsor.go
  16. 2 0
      internal/web/translation/ar-EG.json
  17. 2 0
      internal/web/translation/en-US.json
  18. 2 0
      internal/web/translation/es-ES.json
  19. 2 0
      internal/web/translation/fa-IR.json
  20. 2 0
      internal/web/translation/id-ID.json
  21. 2 0
      internal/web/translation/ja-JP.json
  22. 2 0
      internal/web/translation/pt-BR.json
  23. 2 0
      internal/web/translation/ru-RU.json
  24. 2 0
      internal/web/translation/tr-TR.json
  25. 2 0
      internal/web/translation/uk-UA.json
  26. 2 0
      internal/web/translation/vi-VN.json
  27. 2 0
      internal/web/translation/zh-CN.json
  28. 2 0
      internal/web/translation/zh-TW.json
  29. 29 3
      internal/xray/hot_diff.go
  30. 42 1
      internal/xray/hot_diff_test.go
  31. 107 68
      internal/xray/log_writer.go
  32. 78 1
      internal/xray/log_writer_race_test.go
  33. 31 0
      internal/xray/mutation_audit_test.go
  34. 16 5
      internal/xray/process.go
  35. BIN
      windows_files/SSL/Win64OpenSSL_Light-3_6_0.exe
  36. BIN
      windows_files/SSL/Win64OpenSSL_Light-4_0_3.msi
  37. BIN
      winres/icon.png
  38. 54 0
      winres/winres.json

+ 6 - 1
.github/workflows/release.yml

@@ -261,6 +261,11 @@ jobs:
           go version
           go version
           gcc --version
           gcc --version
 
 
+          # Version info + manifest resource: a bare stripped Go exe scores high on AV heuristics.
+          PANEL_VERSION=$(tr -d '[:space:]' < internal/config/version)
+          go run github.com/tc-hib/[email protected] make --arch amd64 \
+            --product-version "$PANEL_VERSION" --file-version "$PANEL_VERSION"
+
           # Stamp the commit into per-commit (dev channel) builds only.
           # Stamp the commit into per-commit (dev channel) builds only.
           LDFLAGS="-w -s"
           LDFLAGS="-w -s"
           if [[ "$GITHUB_REF" != refs/tags/* ]]; then
           if [[ "$GITHUB_REF" != refs/tags/* ]]; then
@@ -273,7 +278,7 @@ jobs:
         run: |
         run: |
           $retry = @{ MaximumRetryCount = 5; RetryIntervalSec = 10 }
           $retry = @{ MaximumRetryCount = 5; RetryIntervalSec = 10 }
           mkdir x-ui
           mkdir x-ui
-          Copy-Item xui-release.exe x-ui\x-ui.exe
+          Copy-Item xui-release.exe x-ui\3x-ui.exe
           mkdir x-ui\bin
           mkdir x-ui\bin
           cd x-ui\bin
           cd x-ui\bin
 
 

+ 1 - 0
.gitignore

@@ -32,6 +32,7 @@ Thumbs.db
 
 
 # Ignore Go build files
 # Ignore Go build files
 *.exe
 *.exe
+*.syso
 x-ui.db
 x-ui.db
 x-ui.db-shm
 x-ui.db-shm
 x-ui.db-wal
 x-ui.db-wal

+ 13 - 0
frontend/src/lib/xray/outbound-link-parser.ts

@@ -543,7 +543,20 @@ export function parseVmessLink(link: string): Raw | null {
       xhttp.path = json.path ?? '/';
       xhttp.path = json.path ?? '/';
       if (json.mode) xhttp.mode = json.mode;
       if (json.mode) xhttp.mode = json.mode;
       applyXhttpStringFromJson(xhttp, json);
       applyXhttpStringFromJson(xhttp, json);
+    } else if (network === 'kcp') {
+      applyTransportParams(
+        stream,
+        new URLSearchParams({ mtu: String(json.mtu ?? ''), tti: String(json.tti ?? '') }),
+      );
     }
     }
+    // The vmess object carries fm as-is and the mKCP header and seed as type and path.
+    const maskParams = new URLSearchParams();
+    if (typeof json.fm === 'string') maskParams.set('fm', json.fm);
+    if (network === 'kcp') {
+      if (typeof json.type === 'string') maskParams.set('headerType', json.type);
+      if (typeof json.path === 'string') maskParams.set('seed', json.path);
+    }
+    applyFinalMaskParam(stream, maskParams);
     if (security === 'tls') {
     if (security === 'tls') {
       const tls = stream.tlsSettings as Raw;
       const tls = stream.tlsSettings as Raw;
       tls.serverName = json.sni ?? '';
       tls.serverName = json.sni ?? '';

+ 7 - 0
frontend/src/pages/inbounds/form/protocols/mtproto.tsx

@@ -74,6 +74,13 @@ export default function MtprotoFields() {
       >
       >
         <InputNumber min={0} placeholder="0" style={{ width: '100%' }} />
         <InputNumber min={0} placeholder="0" style={{ width: '100%' }} />
       </FormField>
       </FormField>
+      <FormField
+        name={['settings', 'pendingHandshakesMaxPerIp']}
+        label={t('pages.inbounds.form.mtgPendingHandshakesMaxPerIp')}
+        tooltip={t('pages.inbounds.form.mtgPendingHandshakesMaxPerIpHint')}
+      >
+        <InputNumber min={0} placeholder="0" style={{ width: '100%' }} />
+      </FormField>
       <FormField
       <FormField
         name={['settings', 'routeThroughXray']}
         name={['settings', 'routeThroughXray']}
         label={t('pages.inbounds.form.mtgRouteThroughXray')}
         label={t('pages.inbounds.form.mtgRouteThroughXray')}

+ 2 - 0
frontend/src/schemas/protocols/inbound/mtproto.ts

@@ -53,6 +53,8 @@ export const MtprotoInboundSettingsSchema = z.object({
   // Caps concurrent connections across all users with a fair-share algorithm;
   // Caps concurrent connections across all users with a fair-share algorithm;
   // 0 or unset disables throttling.
   // 0 or unset disables throttling.
   throttleMaxConnections: z.number().int().min(0).optional(),
   throttleMaxConnections: z.number().int().min(0).optional(),
+  // mtg's [defense.pending-handshakes] max-per-ip; 0 or unset leaves it off.
+  pendingHandshakesMaxPerIp: z.number().int().min(0).optional(),
   // When set, the mtg sidecar dials Telegram through a loopback SOCKS bridge in
   // When set, the mtg sidecar dials Telegram through a loopback SOCKS bridge in
   // the Xray config so the egress obeys routing rules. `outboundTag` optionally
   // the Xray config so the egress obeys routing rules. `outboundTag` optionally
   // forces that traffic out a specific outbound/balancer. `routeXrayPort` is the
   // forces that traffic out a specific outbound/balancer. `routeXrayPort` is the

+ 51 - 1
frontend/src/test/outbound-link-parser.test.ts

@@ -305,8 +305,25 @@ describe('mKCP share params', () => {
   // The emitter flattens one mkcp-legacy mask per field into headerType/seed; a merged
   // The emitter flattens one mkcp-legacy mask per field into headerType/seed; a merged
   // mask drops the seed in xray-core (MkcpLegacy.Build), so import rebuilds them separately.
   // mask drops the seed in xray-core (MkcpLegacy.Build), so import rebuilds them separately.
   type Mask = { type: string; settings: { header: string; value: string } };
   type Mask = { type: string; settings: { header: string; value: string } };
+  const vmessKcp = (fields: Record<string, unknown>) =>
+    'vmess://' +
+    Base64.encode(
+      JSON.stringify({
+        v: '2',
+        add: 'h.com',
+        port: 443,
+        id: '11111111-2222-4333-8444-555555555555',
+        net: 'kcp',
+        tls: 'none',
+        ...fields,
+      }),
+    );
   const parse = (link: string) => {
   const parse = (link: string) => {
-    const out = link.startsWith('trojan://') ? parseTrojanLink(link) : parseVlessLink(link);
+    const out = link.startsWith('trojan://')
+      ? parseTrojanLink(link)
+      : link.startsWith('vmess://')
+        ? parseVmessLink(link)
+        : parseVlessLink(link);
     expect(out).not.toBeNull();
     expect(out).not.toBeNull();
     const stream = out!.streamSettings as Record<string, unknown>;
     const stream = out!.streamSettings as Record<string, unknown>;
     const kcp = stream.kcpSettings as { mtu: number; tti: number };
     const kcp = stream.kcpSettings as { mtu: number; tti: number };
@@ -357,9 +374,42 @@ describe('mKCP share params', () => {
       { mtu: 1350, tti: 20 },
       { mtu: 1350, tti: 20 },
       [],
       [],
     ],
     ],
+    [
+      'vmess header and seed from type and path',
+      vmessKcp({ type: 'wechat-video', path: 'secret-seed', mtu: 1400, tti: 50 }),
+      { mtu: 1400, tti: 50 },
+      [
+        ['mkcp-legacy', '', 'secret-seed'],
+        ['mkcp-legacy', 'wechat', ''],
+      ],
+    ],
+    [
+      'vmess fm mkcp-legacy mask wins over path',
+      vmessKcp({
+        type: 'none',
+        path: 's3cr3tSeed',
+        mtu: 1350,
+        tti: 20,
+        fm: JSON.stringify({
+          udp: [{ settings: { header: '', value: 's3cr3tSeed' }, type: 'mkcp-legacy' }],
+        }),
+      }),
+      { mtu: 1350, tti: 20 },
+      [['mkcp-legacy', '', 's3cr3tSeed']],
+    ],
   ])('%s', (_name, link, kcp, masks) => {
   ])('%s', (_name, link, kcp, masks) => {
     expect(parse(link)).toEqual({ kcp, masks });
     expect(parse(link)).toEqual({ kcp, masks });
   });
   });
+
+  it('keeps a vmess fm udp mask', () => {
+    const fm = JSON.stringify({
+      udp: [{ type: 'salamander', settings: { password: 'obfspw123' } }],
+    });
+    const out = parseVmessLink(vmessKcp({ type: 'none', fm }));
+    expect((out!.streamSettings as Record<string, unknown>).finalmask).toEqual({
+      udp: [{ type: 'salamander', settings: { password: 'obfspw123' } }],
+    });
+  });
 });
 });
 
 
 describe('parseTrojanLink', () => {
 describe('parseTrojanLink', () => {

+ 1 - 1
internal/config/version

@@ -1 +1 @@
-3.10.0
+3.10.2

+ 34 - 24
internal/mtproto/manager.go

@@ -58,6 +58,10 @@ type Instance struct {
 	// fair-share algorithm; zero disables throttling.
 	// fair-share algorithm; zero disables throttling.
 	ThrottleMaxConnections int
 	ThrottleMaxConnections int
 
 
+	// PendingHandshakesMaxPerIP caps unauthenticated handshakes held open by one
+	// client IP (mtg-multi >= 1.16.0); zero leaves the limit off.
+	PendingHandshakesMaxPerIP int
+
 	// PublicIPv4/PublicIPv6 pin the proxy's reachable address the Telegram
 	// PublicIPv4/PublicIPv6 pin the proxy's reachable address the Telegram
 	// middle proxy needs when clients carry advertising tags; they are omitted
 	// middle proxy needs when clients carry advertising tags; they are omitted
 	// when empty so mtg auto-detects, and a change forces a restart.
 	// when empty so mtg auto-detects, and a change forces a restart.
@@ -93,6 +97,7 @@ func (inst Instance) structuralFingerprint() string {
 		strconv.Itoa(inst.FrontingPort),
 		strconv.Itoa(inst.FrontingPort),
 		strconv.FormatBool(inst.FrontingProxyProtocol),
 		strconv.FormatBool(inst.FrontingProxyProtocol),
 		strconv.Itoa(inst.ThrottleMaxConnections),
 		strconv.Itoa(inst.ThrottleMaxConnections),
+		strconv.Itoa(inst.PendingHandshakesMaxPerIP),
 		strconv.FormatBool(inst.RouteThroughXray),
 		strconv.FormatBool(inst.RouteThroughXray),
 		strconv.Itoa(inst.XrayRoutePort),
 		strconv.Itoa(inst.XrayRoutePort),
 		inst.PublicIPv4,
 		inst.PublicIPv4,
@@ -179,13 +184,14 @@ func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
 			Port          int    `json:"port"`
 			Port          int    `json:"port"`
 			ProxyProtocol bool   `json:"proxyProtocol"`
 			ProxyProtocol bool   `json:"proxyProtocol"`
 		} `json:"domainFronting"`
 		} `json:"domainFronting"`
-		PreferIP               string `json:"preferIp"`
-		ThrottleMaxConnections int    `json:"throttleMaxConnections"`
-		RouteThroughXray       bool   `json:"routeThroughXray"`
-		RouteXrayPort          int    `json:"routeXrayPort"`
-		PublicIPv4             string `json:"publicIpv4"`
-		PublicIPv6             string `json:"publicIpv6"`
-		Clients                []struct {
+		PreferIP                  string `json:"preferIp"`
+		ThrottleMaxConnections    int    `json:"throttleMaxConnections"`
+		PendingHandshakesMaxPerIP int    `json:"pendingHandshakesMaxPerIp"`
+		RouteThroughXray          bool   `json:"routeThroughXray"`
+		RouteXrayPort             int    `json:"routeXrayPort"`
+		PublicIPv4                string `json:"publicIpv4"`
+		PublicIPv6                string `json:"publicIpv6"`
+		Clients                   []struct {
 			Email      string `json:"email"`
 			Email      string `json:"email"`
 			Secret     string `json:"secret"`
 			Secret     string `json:"secret"`
 			AdTag      string `json:"adTag"`
 			AdTag      string `json:"adTag"`
@@ -215,22 +221,23 @@ func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
 		return Instance{}, false
 		return Instance{}, false
 	}
 	}
 	return Instance{
 	return Instance{
-		Id:                     ib.Id,
-		Tag:                    ib.Tag,
-		Listen:                 ib.Listen,
-		Port:                   ib.Port,
-		Secrets:                secrets,
-		Debug:                  parsed.Debug,
-		ProxyProtocolListener:  parsed.ProxyProtocolListener,
-		PreferIP:               parsed.PreferIP,
-		FrontingIP:             parsed.DomainFronting.IP,
-		FrontingPort:           parsed.DomainFronting.Port,
-		FrontingProxyProtocol:  parsed.DomainFronting.ProxyProtocol,
-		ThrottleMaxConnections: parsed.ThrottleMaxConnections,
-		RouteThroughXray:       parsed.RouteThroughXray,
-		XrayRoutePort:          parsed.RouteXrayPort,
-		PublicIPv4:             strings.TrimSpace(parsed.PublicIPv4),
-		PublicIPv6:             strings.TrimSpace(parsed.PublicIPv6),
+		Id:                        ib.Id,
+		Tag:                       ib.Tag,
+		Listen:                    ib.Listen,
+		Port:                      ib.Port,
+		Secrets:                   secrets,
+		Debug:                     parsed.Debug,
+		ProxyProtocolListener:     parsed.ProxyProtocolListener,
+		PreferIP:                  parsed.PreferIP,
+		FrontingIP:                parsed.DomainFronting.IP,
+		FrontingPort:              parsed.DomainFronting.Port,
+		FrontingProxyProtocol:     parsed.DomainFronting.ProxyProtocol,
+		ThrottleMaxConnections:    parsed.ThrottleMaxConnections,
+		PendingHandshakesMaxPerIP: parsed.PendingHandshakesMaxPerIP,
+		RouteThroughXray:          parsed.RouteThroughXray,
+		XrayRoutePort:             parsed.RouteXrayPort,
+		PublicIPv4:                strings.TrimSpace(parsed.PublicIPv4),
+		PublicIPv6:                strings.TrimSpace(parsed.PublicIPv6),
 	}, true
 	}, true
 }
 }
 
 
@@ -523,7 +530,7 @@ func FreeLocalPort() (int, error) {
 // precede any [section] header in TOML, and [secrets] must be the final section
 // precede any [section] header in TOML, and [secrets] must be the final section
 // so trailing keys are not swallowed by another table. The layout is therefore:
 // so trailing keys are not swallowed by another table. The layout is therefore:
 // top-level scalars (incl. api-bind-to and api-token), then [domain-fronting],
 // top-level scalars (incl. api-bind-to and api-token), then [domain-fronting],
-// [network] and [throttle], then [secret-ad-tags] for clients overriding the
+// [network], [throttle] and [defense.pending-handshakes], then [secret-ad-tags] for clients overriding the
 // global advertising tag, and finally [secrets] with one named secret per
 // global advertising tag, and finally [secrets] with one named secret per
 // active client.
 // active client.
 func renderConfig(inst Instance, apiPort int, apiToken string) string {
 func renderConfig(inst Instance, apiPort int, apiToken string) string {
@@ -569,6 +576,9 @@ func renderConfig(inst Instance, apiPort int, apiToken string) string {
 	if inst.ThrottleMaxConnections > 0 {
 	if inst.ThrottleMaxConnections > 0 {
 		fmt.Fprintf(&b, "\n[throttle]\nmax-connections = %d\n", inst.ThrottleMaxConnections)
 		fmt.Fprintf(&b, "\n[throttle]\nmax-connections = %d\n", inst.ThrottleMaxConnections)
 	}
 	}
+	if inst.PendingHandshakesMaxPerIP > 0 {
+		fmt.Fprintf(&b, "\n[defense.pending-handshakes]\nmax-per-ip = %d\n", inst.PendingHandshakesMaxPerIP)
+	}
 	// Only clients present in [secrets] may appear here: mtg rejects a config
 	// Only clients present in [secrets] may appear here: mtg rejects a config
 	// whose [secret-ad-tags] names an unknown secret, so a disabled client's
 	// whose [secret-ad-tags] names an unknown secret, so a disabled client's
 	// override must vanish together with its secret.
 	// override must vanish together with its secret.

+ 27 - 18
internal/mtproto/manager_test.go

@@ -18,7 +18,7 @@ func TestInstanceFromInbound(t *testing.T) {
 		Settings: `{"fakeTlsDomain":"example.com",` +
 		Settings: `{"fakeTlsDomain":"example.com",` +
 			`"debug":true,"proxyProtocolListener":true,"preferIp":"prefer-ipv4",` +
 			`"debug":true,"proxyProtocolListener":true,"preferIp":"prefer-ipv4",` +
 			`"domainFronting":{"ip":"127.0.0.1","port":9443,"proxyProtocol":true},` +
 			`"domainFronting":{"ip":"127.0.0.1","port":9443,"proxyProtocol":true},` +
-			`"throttleMaxConnections":5000,` +
+			`"throttleMaxConnections":5000,"pendingHandshakesMaxPerIp":64,` +
 			`"routeThroughXray":true,"routeXrayPort":50000,` +
 			`"routeThroughXray":true,"routeXrayPort":50000,` +
 			`"adTag":" 0123456789abcdef0123456789abcdef ",` +
 			`"adTag":" 0123456789abcdef0123456789abcdef ",` +
 			`"clients":[` +
 			`"clients":[` +
@@ -60,6 +60,9 @@ func TestInstanceFromInbound(t *testing.T) {
 	if inst.ThrottleMaxConnections != 5000 {
 	if inst.ThrottleMaxConnections != 5000 {
 		t.Fatalf("throttle not parsed: %+v", inst)
 		t.Fatalf("throttle not parsed: %+v", inst)
 	}
 	}
+	if inst.PendingHandshakesMaxPerIP != 64 {
+		t.Fatalf("pending-handshake limit not parsed: %+v", inst)
+	}
 	if !inst.RouteThroughXray || inst.XrayRoutePort != 50000 {
 	if !inst.RouteThroughXray || inst.XrayRoutePort != 50000 {
 		t.Fatalf("xray routing not parsed: %+v", inst)
 		t.Fatalf("xray routing not parsed: %+v", inst)
 	}
 	}
@@ -90,7 +93,7 @@ func TestRenderConfig(t *testing.T) {
 		Secrets: []SecretEntry{{Name: "alice", Secret: "ee00"}},
 		Secrets: []SecretEntry{{Name: "alice", Secret: "ee00"}},
 		Listen:  "0.0.0.0", Port: 8443,
 		Listen:  "0.0.0.0", Port: 8443,
 	}, 5000, "")
 	}, 5000, "")
-	for _, unwanted := range []string{"debug", "proxy-protocol-listener", "prefer-ip", "[domain-fronting]", "[stats.prometheus]", "[throttle]", "[secret-ad-tags]", "api-token"} {
+	for _, unwanted := range []string{"debug", "proxy-protocol-listener", "prefer-ip", "[domain-fronting]", "[stats.prometheus]", "[throttle]", "[defense.pending-handshakes]", "[secret-ad-tags]", "api-token"} {
 		if strings.Contains(bare, unwanted) {
 		if strings.Contains(bare, unwanted) {
 			t.Fatalf("bare config should not contain %q:\n%s", unwanted, bare)
 			t.Fatalf("bare config should not contain %q:\n%s", unwanted, bare)
 		}
 		}
@@ -116,9 +119,10 @@ func TestRenderConfig(t *testing.T) {
 		Listen: "0.0.0.0", Port: 443,
 		Listen: "0.0.0.0", Port: 443,
 		Debug: true, ProxyProtocolListener: true, PreferIP: "only-ipv6",
 		Debug: true, ProxyProtocolListener: true, PreferIP: "only-ipv6",
 		FrontingIP: "127.0.0.1", FrontingPort: 9443, FrontingProxyProtocol: true,
 		FrontingIP: "127.0.0.1", FrontingPort: 9443, FrontingProxyProtocol: true,
-		ThrottleMaxConnections: 5000,
-		PublicIPv4:             "1.2.3.4",
-		PublicIPv6:             "2001:db8::1",
+		ThrottleMaxConnections:    5000,
+		PendingHandshakesMaxPerIP: 64,
+		PublicIPv4:                "1.2.3.4",
+		PublicIPv6:                "2001:db8::1",
 	}, 6000, "sesame")
 	}, 6000, "sesame")
 	for _, want := range []string{
 	for _, want := range []string{
 		"debug = true\n",
 		"debug = true\n",
@@ -133,6 +137,7 @@ func TestRenderConfig(t *testing.T) {
 		"proxy-protocol = true\n",
 		"proxy-protocol = true\n",
 		"[throttle]",
 		"[throttle]",
 		"max-connections = 5000",
 		"max-connections = 5000",
+		"[defense.pending-handshakes]\nmax-per-ip = 64\n",
 		"[secret-ad-tags]",
 		"[secret-ad-tags]",
 		`"bob" = "fedcba9876543210fedcba9876543210"`,
 		`"bob" = "fedcba9876543210fedcba9876543210"`,
 	} {
 	} {
@@ -160,6 +165,9 @@ func TestRenderConfig(t *testing.T) {
 	if strings.LastIndex(full, "[secrets]") < strings.Index(full, "[throttle]") {
 	if strings.LastIndex(full, "[secrets]") < strings.Index(full, "[throttle]") {
 		t.Fatalf("[throttle] must precede [secrets]:\n%s", full)
 		t.Fatalf("[throttle] must precede [secrets]:\n%s", full)
 	}
 	}
+	if strings.LastIndex(full, "[secrets]") < strings.Index(full, "[defense.pending-handshakes]") {
+		t.Fatalf("[defense.pending-handshakes] must precede [secrets]:\n%s", full)
+	}
 	if strings.LastIndex(full, "[secrets]") < strings.Index(full, "[secret-ad-tags]") {
 	if strings.LastIndex(full, "[secrets]") < strings.Index(full, "[secret-ad-tags]") {
 		t.Fatalf("[secret-ad-tags] must precede [secrets]:\n%s", full)
 		t.Fatalf("[secret-ad-tags] must precede [secrets]:\n%s", full)
 	}
 	}
@@ -244,19 +252,20 @@ func TestFingerprintSplit(t *testing.T) {
 	base := Instance{Secrets: []SecretEntry{{Name: "a", Secret: "ee"}}, Listen: "0.0.0.0", Port: 443}
 	base := Instance{Secrets: []SecretEntry{{Name: "a", Secret: "ee"}}, Listen: "0.0.0.0", Port: 443}
 
 
 	for name, mutate := range map[string]func(*Instance){
 	for name, mutate := range map[string]func(*Instance){
-		"debug":         func(i *Instance) { i.Debug = true },
-		"listener":      func(i *Instance) { i.ProxyProtocolListener = true },
-		"preferIp":      func(i *Instance) { i.PreferIP = "only-ipv4" },
-		"frontingIP":    func(i *Instance) { i.FrontingIP = "127.0.0.1" },
-		"frontingPort":  func(i *Instance) { i.FrontingPort = 9443 },
-		"frontingProxy": func(i *Instance) { i.FrontingProxyProtocol = true },
-		"throttle":      func(i *Instance) { i.ThrottleMaxConnections = 5000 },
-		"routeXray":     func(i *Instance) { i.RouteThroughXray = true },
-		"routeXrayPort": func(i *Instance) { i.XrayRoutePort = 50000 },
-		"port":          func(i *Instance) { i.Port = 8443 },
-		"listen":        func(i *Instance) { i.Listen = "127.0.0.1" },
-		"publicIpv4":    func(i *Instance) { i.PublicIPv4 = "1.2.3.4" },
-		"publicIpv6":    func(i *Instance) { i.PublicIPv6 = "2001:db8::1" },
+		"debug":             func(i *Instance) { i.Debug = true },
+		"listener":          func(i *Instance) { i.ProxyProtocolListener = true },
+		"preferIp":          func(i *Instance) { i.PreferIP = "only-ipv4" },
+		"frontingIP":        func(i *Instance) { i.FrontingIP = "127.0.0.1" },
+		"frontingPort":      func(i *Instance) { i.FrontingPort = 9443 },
+		"frontingProxy":     func(i *Instance) { i.FrontingProxyProtocol = true },
+		"throttle":          func(i *Instance) { i.ThrottleMaxConnections = 5000 },
+		"pendingHandshakes": func(i *Instance) { i.PendingHandshakesMaxPerIP = 64 },
+		"routeXray":         func(i *Instance) { i.RouteThroughXray = true },
+		"routeXrayPort":     func(i *Instance) { i.XrayRoutePort = 50000 },
+		"port":              func(i *Instance) { i.Port = 8443 },
+		"listen":            func(i *Instance) { i.Listen = "127.0.0.1" },
+		"publicIpv4":        func(i *Instance) { i.PublicIPv4 = "1.2.3.4" },
+		"publicIpv6":        func(i *Instance) { i.PublicIPv6 = "2001:db8::1" },
 	} {
 	} {
 		t.Run("structural/"+name, func(t *testing.T) {
 		t.Run("structural/"+name, func(t *testing.T) {
 			changed := base
 			changed := base

+ 10 - 2
internal/sub/clash_service.go

@@ -7,6 +7,7 @@ import (
 	"maps"
 	"maps"
 	"net/netip"
 	"net/netip"
 	"slices"
 	"slices"
+	"strconv"
 	"strings"
 	"strings"
 
 
 	"github.com/goccy/go-json"
 	"github.com/goccy/go-json"
@@ -540,8 +541,8 @@ func (s *SubClashService) buildHysteriaProxy(subReq *SubService, inbound *model.
 		proxy["fingerprint"] = certFingerprint
 		proxy["fingerprint"] = certFingerprint
 	}
 	}
 
 
-	// Salamander obfs (Hysteria2). Read the same finalmask.udp[salamander]
-	// block the subscription link generator uses.
+	// Salamander obfs (Hysteria2), read from the same finalmask.udp[salamander]
+	// block as the link generator; a packetSize range is mihomo's gecko obfs.
 	if finalmask, ok := rawStream["finalmask"].(map[string]any); ok {
 	if finalmask, ok := rawStream["finalmask"].(map[string]any); ok {
 		if udpMasks, ok := finalmask["udp"].([]any); ok {
 		if udpMasks, ok := finalmask["udp"].([]any); ok {
 			for _, m := range udpMasks {
 			for _, m := range udpMasks {
@@ -553,6 +554,13 @@ func (s *SubClashService) buildHysteriaProxy(subReq *SubService, inbound *model.
 				if pw, ok := settings["password"].(string); ok && pw != "" {
 				if pw, ok := settings["password"].(string); ok && pw != "" {
 					proxy["obfs"] = "salamander"
 					proxy["obfs"] = "salamander"
 					proxy["obfs-password"] = pw
 					proxy["obfs-password"] = pw
+					packetSize, _ := settings["packetSize"].(string)
+					if gecko := parseHysteriaPacketSize(packetSize); gecko != "" {
+						minStr, maxStr := splitHysteriaPacketSize(gecko)
+						proxy["obfs"] = "gecko"
+						proxy["obfs-min-packet-size"], _ = strconv.Atoi(minStr)
+						proxy["obfs-max-packet-size"], _ = strconv.Atoi(maxStr)
+					}
 					break
 					break
 				}
 				}
 			}
 			}

+ 40 - 0
internal/sub/hysteria_geco_uri_test.go

@@ -150,3 +150,43 @@ func TestParseHysteriaPacketSizeBounds(t *testing.T) {
 		t.Fatalf("valid range = %q", got)
 		t.Fatalf("valid range = %q", got)
 	}
 	}
 }
 }
+
+// Mihomo reads Gecko only from obfs: gecko plus integer packet-size keys; plain
+// salamander on a Gecko server never completes the handshake.
+func TestBuildHysteriaProxyExportsGeckoObfs(t *testing.T) {
+	cases := []struct {
+		name     string
+		settings string
+		want     map[string]any
+	}{
+		{
+			name:     "packetSize exports gecko",
+			settings: `{"password":"pw","packetSize":"512-1200"}`,
+			want: map[string]any{
+				"obfs": "gecko", "obfs-password": "pw",
+				"obfs-min-packet-size": 512, "obfs-max-packet-size": 1200,
+			},
+		},
+		{
+			name:     "password only stays salamander",
+			settings: `{"password":"pw"}`,
+			want:     map[string]any{"obfs": "salamander", "obfs-password": "pw"},
+		},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			in := &model.Inbound{
+				Listen: "203.0.113.1", Port: 443, Protocol: model.Hysteria,
+				Settings: `{"version":2}`,
+				StreamSettings: `{"security":"tls","finalmask":{"udp":[{"type":"salamander","settings":` +
+					tc.settings + `}]}}`,
+			}
+			proxy := (&SubClashService{}).buildHysteriaProxy(&SubService{}, in, model.Client{Email: "user", Auth: "secret"}, nil)
+			for _, key := range []string{"obfs", "obfs-password", "obfs-min-packet-size", "obfs-max-packet-size"} {
+				if got, want := proxy[key], tc.want[key]; got != want {
+					t.Errorf("%s = %#v, want %#v", key, got, want)
+				}
+			}
+		})
+	}
+}

+ 33 - 2
internal/util/link/outbound.go

@@ -185,12 +185,18 @@ func parseVmess(link string) (*ParseResult, error) {
 		if m := getString(j, "mode", ""); m != "" {
 		if m := getString(j, "mode", ""); m != "" {
 			xh["mode"] = m
 			xh["mode"] = m
 		}
 		}
-		// xhttp advanced keys are passed through if present in the json
-		for _, k := range []string{"xPaddingBytes", "scMaxEachPostBytes", "scMinPostsIntervalMs"} {
+		for _, k := range vmessXhttpKeys {
 			if v, ok := j[k]; ok {
 			if v, ok := j[k]; ok {
 				xh[k] = v
 				xh[k] = v
 			}
 			}
 		}
 		}
+		for legacy, renamed := range xhttpLegacyAliases {
+			if _, ok := xh[renamed]; !ok {
+				if v, ok := j[legacy].(string); ok && v != "" {
+					xh[renamed] = v
+				}
+			}
+		}
 	case "tcp":
 	case "tcp":
 		if getString(j, "type", "") == "http" {
 		if getString(j, "type", "") == "http" {
 			stream["tcpSettings"] = map[string]any{
 			stream["tcpSettings"] = map[string]any{
@@ -205,7 +211,17 @@ func parseVmess(link string) (*ParseResult, error) {
 				},
 				},
 			}
 			}
 		}
 		}
+	case "kcp":
+		applyTransport(stream, url.Values{"mtu": {strconv.Itoa(num(j["mtu"]))}, "tti": {strconv.Itoa(num(j["tti"]))}})
+	}
+
+	// The vmess object carries fm as-is and the mKCP header and seed as type and path.
+	maskParams := url.Values{"fm": {getString(j, "fm", "")}}
+	if network == "kcp" {
+		maskParams.Set("headerType", getString(j, "type", ""))
+		maskParams.Set("seed", getString(j, "path", ""))
 	}
 	}
+	applyFinalMask(stream, maskParams)
 
 
 	if security == "tls" {
 	if security == "tls" {
 		tls := stream["tlsSettings"].(map[string]any)
 		tls := stream["tlsSettings"].(map[string]any)
@@ -248,6 +264,21 @@ func parseVmess(link string) (*ParseResult, error) {
 	return &ParseResult{Outbound: ob, Identity: identity}, nil
 	return &ParseResult{Outbound: ob, Identity: identity}, nil
 }
 }
 
 
+// vmessXhttpKeys are the XHTTP fields a vmess link carries inline, where vless/trojan put them in extra.
+var vmessXhttpKeys = []string{
+	"xPaddingBytes", "xPaddingObfsMode", "xPaddingKey", "xPaddingHeader", "xPaddingPlacement", "xPaddingMethod",
+	"sessionIDPlacement", "sessionIDKey", "sessionIDTable", "sessionIDLength",
+	"seqPlacement", "seqKey", "uplinkDataPlacement", "uplinkDataKey", "uplinkHTTPMethod", "uplinkChunkSize",
+	"scMaxEachPostBytes", "scMinPostsIntervalMs", "scMaxBufferedPosts", "scStreamUpServerSecs",
+	"serverMaxHeaderBytes", "noSSEHeader", "noGRPCHeader", "xmux", "downloadSettings", "headers",
+}
+
+// xhttpLegacyAliases maps the pre-rename session keys older links carry onto the current ones.
+var xhttpLegacyAliases = map[string]string{
+	"sessionPlacement": "sessionIDPlacement",
+	"sessionKey":       "sessionIDKey",
+}
+
 func vmessIdentity(j map[string]any) string {
 func vmessIdentity(j map[string]any) string {
 	// Remove ps (remark) for identity
 	// Remove ps (remark) for identity
 	core := map[string]any{}
 	core := map[string]any{}

+ 77 - 0
internal/util/link/outbound_helpers_test.go

@@ -200,6 +200,52 @@ func TestParse_XhttpExtraAndSnakeCaseFields(t *testing.T) {
 	}
 	}
 }
 }
 
 
+func TestParse_VmessXhttpInlineFields(t *testing.T) {
+	// The vmess emitter writes the XHTTP extra fields at the top level of the link JSON.
+	cases := []struct {
+		name  string
+		extra string
+		want  map[string]any
+	}{
+		{
+			name:  "session placement and padding obfs",
+			extra: `"sessionIDPlacement":"header","sessionIDKey":"X-Sid","sessionPlacement":"header","sessionKey":"X-Sid","xPaddingObfsMode":true,"xPaddingKey":"pk","xPaddingHeader":"X-Pad","xPaddingPlacement":"header","xPaddingMethod":"tokenish"`,
+			want: map[string]any{
+				"sessionIDPlacement": "header", "sessionIDKey": "X-Sid", "xPaddingObfsMode": true,
+				"xPaddingKey": "pk", "xPaddingHeader": "X-Pad", "xPaddingPlacement": "header", "xPaddingMethod": "tokenish",
+			},
+		},
+		{
+			name:  "uplink and xmux",
+			extra: `"uplinkDataPlacement":"cookie","uplinkDataKey":"d","uplinkHTTPMethod":"PUT","uplinkChunkSize":4096,"xmux":{"maxConcurrency":"16-32"}`,
+			want: map[string]any{
+				"uplinkDataPlacement": "cookie", "uplinkDataKey": "d", "uplinkHTTPMethod": "PUT",
+				"uplinkChunkSize": float64(4096), "xmux": map[string]any{"maxConcurrency": "16-32"},
+			},
+		},
+		{
+			name:  "legacy session keys",
+			extra: `"sessionPlacement":"query","sessionKey":"sid"`,
+			want:  map[string]any{"sessionIDPlacement": "query", "sessionIDKey": "sid"},
+		},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			inner := `{"v":"2","add":"h","port":443,"id":"11111111-2222-4333-8444-555555555555","net":"xhttp","path":"/xh","mode":"packet-up","tls":"none",` + c.extra + `}`
+			res, err := ParseLink("vmess://" + base64.StdEncoding.EncodeToString([]byte(inner)))
+			if err != nil {
+				t.Fatalf("parse: %v", err)
+			}
+			xh := streamSub(t, res, "xhttpSettings")
+			for k, want := range c.want {
+				if !reflect.DeepEqual(xh[k], want) {
+					t.Errorf("xhttpSettings.%s = %#v, want %#v", k, xh[k], want)
+				}
+			}
+		})
+	}
+}
+
 func TestParse_VmessWSPathWithoutHostKey(t *testing.T) {
 func TestParse_VmessWSPathWithoutHostKey(t *testing.T) {
 	inner := `{"v":"2","add":"h","port":443,"id":"11111111-2222-4333-8444-555555555555","net":"ws","path":"/api","tls":"tls"}`
 	inner := `{"v":"2","add":"h","port":443,"id":"11111111-2222-4333-8444-555555555555","net":"ws","path":"/api","tls":"tls"}`
 	link := "vmess://" + base64.StdEncoding.EncodeToString([]byte(inner))
 	link := "vmess://" + base64.StdEncoding.EncodeToString([]byte(inner))
@@ -286,6 +332,11 @@ func mkcpLegacyMasks(t *testing.T, res *ParseResult) []mkcpMask {
 	return out
 	return out
 }
 }
 
 
+func vmessKcpLink(fields string) string {
+	inner := `{"v":"2","add":"h.com","port":443,"id":"11111111-2222-4333-8444-555555555555","net":"kcp","tls":"none",` + fields + `}`
+	return "vmess://" + base64.StdEncoding.EncodeToString([]byte(inner))
+}
+
 func TestParse_KcpShareParams(t *testing.T) {
 func TestParse_KcpShareParams(t *testing.T) {
 	// The emitter flattens one mkcp-legacy mask per field into headerType/seed; a merged
 	// The emitter flattens one mkcp-legacy mask per field into headerType/seed; a merged
 	// mask drops the seed in xray-core (MkcpLegacy.Build), so import rebuilds them separately.
 	// mask drops the seed in xray-core (MkcpLegacy.Build), so import rebuilds them separately.
@@ -323,6 +374,20 @@ func TestParse_KcpShareParams(t *testing.T) {
 			wantMTU: 1350,
 			wantMTU: 1350,
 			wantTTI: 20,
 			wantTTI: 20,
 		},
 		},
+		{
+			name:      "vmess header and seed from type and path",
+			link:      vmessKcpLink(`"type":"wechat-video","path":"secret-seed","mtu":1400,"tti":50`),
+			wantMTU:   1400,
+			wantTTI:   50,
+			wantMasks: []mkcpMask{{"", "secret-seed"}, {"wechat", ""}},
+		},
+		{
+			name:      "vmess fm mkcp-legacy mask wins over path",
+			link:      vmessKcpLink(`"type":"none","path":"s3cr3tSeed","mtu":1350,"tti":20,"fm":"{\"udp\":[{\"settings\":{\"header\":\"\",\"value\":\"s3cr3tSeed\"},\"type\":\"mkcp-legacy\"}]}"`),
+			wantMTU:   1350,
+			wantTTI:   20,
+			wantMasks: []mkcpMask{{"", "s3cr3tSeed"}},
+		},
 	}
 	}
 	for _, c := range cases {
 	for _, c := range cases {
 		t.Run(c.name, func(t *testing.T) {
 		t.Run(c.name, func(t *testing.T) {
@@ -340,3 +405,15 @@ func TestParse_KcpShareParams(t *testing.T) {
 		})
 		})
 	}
 	}
 }
 }
+
+func TestParse_VmessFinalMask(t *testing.T) {
+	res, err := ParseLink(vmessKcpLink(`"type":"none","fm":"{\"udp\":[{\"settings\":{\"password\":\"obfspw123\"},\"type\":\"salamander\"}]}"`))
+	if err != nil {
+		t.Fatalf("parse: %v", err)
+	}
+	udp := finalmaskUDP(t, res)
+	want := []any{map[string]any{"type": "salamander", "settings": map[string]any{"password": "obfspw123"}}}
+	if !reflect.DeepEqual(udp, want) {
+		t.Fatalf("finalmask.udp = %#v, want %#v", udp, want)
+	}
+}

+ 74 - 0
internal/web/service/inbound_update_hot_users_test.go

@@ -2,6 +2,7 @@ package service
 
 
 import (
 import (
 	"context"
 	"context"
+	"encoding/json"
 	"errors"
 	"errors"
 	"slices"
 	"slices"
 	"strings"
 	"strings"
@@ -207,3 +208,76 @@ func TestNodeInboundUnchangedPushLeavesTheCoreAlone(t *testing.T) {
 		t.Fatalf("core operations = %q, want none for an unchanged inbound", got)
 		t.Fatalf("core operations = %q, want none for an unchanged inbound", got)
 	}
 	}
 }
 }
+
+func ssHotSettings(t *testing.T, method, serverKey string, clients []model.Client) string {
+	t.Helper()
+	var settings map[string]any
+	if err := json.Unmarshal([]byte(clientsSettings(t, clients)), &settings); err != nil {
+		t.Fatalf("unmarshal settings: %v", err)
+	}
+	settings["method"] = method
+	settings["network"] = "tcp,udp"
+	if serverKey != "" {
+		settings["password"] = serverKey
+	} else {
+		for _, c := range settings["clients"].([]any) {
+			c.(map[string]any)["method"] = method
+		}
+	}
+	out, err := json.Marshal(settings)
+	if err != nil {
+		t.Fatalf("marshal settings: %v", err)
+	}
+	return string(out)
+}
+
+// Multi-user shadowsocks has the same per-user API as vless, so a pushed client
+// change must not rebuild the handler and drop every connection on the port.
+func TestNodeShadowsocksPushChangingOnlyClientsKeepsTheHandler(t *testing.T) {
+	ciphers := []struct{ name, method, serverKey, keyA, keyB string }{
+		{"legacy aead", "aes-256-gcm", "", "a-pass", "b-pass"},
+		{"2022", "2022-blake3-aes-128-gcm", "AAECAwQFBgcICQoLDA0ODw==", "EBESExQVFhcYGRobHB0eHw==", "ICEiIyQlJicoKSorLC0uLw=="},
+	}
+	for _, cipher := range ciphers {
+		a := model.Client{Email: "ss-a", Password: cipher.keyA, Enable: true, CreatedAt: 1, UpdatedAt: 1}
+		b := model.Client{Email: "ss-b", Password: cipher.keyB, Enable: true, CreatedAt: 1, UpdatedAt: 1}
+		bOff := b
+		bOff.Enable = false
+		cases := []struct {
+			name           string
+			stored, pushed []model.Client
+			wantOps        []string
+		}{
+			{"disabling one client removes only that user", []model.Client{a, b}, []model.Client{a, bOff}, []string{"remove-user ss-b"}},
+			{"renewing a disabled client adds only that user", []model.Client{a, bOff}, []model.Client{a, b}, []string{"add-user ss-b"}},
+		}
+		for _, tc := range cases {
+			t.Run(cipher.name+"/"+tc.name, func(t *testing.T) {
+				setupConflictDB(t)
+				setRestartOnClientDisable(t, true)
+				probe := &inboundOpsProbe{}
+				useTestRuntimeManager(t).SetLocalRuntimeOverride(probe)
+
+				seedInboundConflict(t, "hot-ss", "0.0.0.0", 50083, model.Shadowsocks, `{"network":"tcp"}`,
+					ssHotSettings(t, cipher.method, cipher.serverKey, tc.stored))
+				stored := loadInboundByTag(t, "hot-ss")
+				if err := (&ClientService{}).SyncInbound(nil, stored.Id, tc.stored); err != nil {
+					t.Fatalf("SyncInbound: %v", err)
+				}
+
+				pushed := *stored
+				pushed.Settings = ssHotSettings(t, cipher.method, cipher.serverKey, tc.pushed)
+				_, needRestart, err := (&InboundService{FromNodeSync: true}).UpdateInbound(&pushed)
+				if err != nil {
+					t.Fatalf("UpdateInbound: %v", err)
+				}
+				if got := probe.recorded(); !slices.Equal(got, tc.wantOps) {
+					t.Fatalf("core operations = %q, want %q", got, tc.wantOps)
+				}
+				if needRestart {
+					t.Fatal("needRestart = true, want the push applied without restarting the core")
+				}
+			})
+		}
+	}
+}

+ 2 - 2
internal/web/service/panel/sponsor.go

@@ -60,8 +60,8 @@ type sponsorLogo struct {
 }
 }
 
 
 var (
 var (
-	sponsorsURL     = "https://sponsors.sanaei.dev/3X/sponsors.json"
-	sponsorLogoBase = "https://sponsors.sanaei.dev/3X/logos/"
+	sponsorsURL     = "https://raw.githubusercontent.com/MHSanaei/sponsors/refs/heads/main/3X/sponsors.json"
+	sponsorLogoBase = "https://raw.githubusercontent.com/MHSanaei/sponsors/refs/heads/main/3X/logos/"
 	sponsorNow      = time.Now
 	sponsorNow      = time.Now
 
 
 	// The panel proxies logos from sponsorLogoBase: CSP stays 'self' and no third party sees admin IPs.
 	// The panel proxies logos from sponsorLogoBase: CSP stays 'self' and no third party sees admin IPs.

+ 2 - 0
internal/web/translation/ar-EG.json

@@ -505,6 +505,8 @@
         "mtprotoFakeTlsDomainHint": "نطاق FakeTLS الافتراضي المستخدم لإنشاء سر عميل جديد. يمكن لكل عميل استخدام نطاقه الخاص.",
         "mtprotoFakeTlsDomainHint": "نطاق FakeTLS الافتراضي المستخدم لإنشاء سر عميل جديد. يمكن لكل عميل استخدام نطاقه الخاص.",
         "mtgThrottleMaxConnections": "الحد الأقصى للاتصالات",
         "mtgThrottleMaxConnections": "الحد الأقصى للاتصالات",
         "mtgThrottleMaxConnectionsHint": "تحديد الاتصالات المتزامنة لجميع المستخدمين بتوزيع عادل. القيمة 0 تعطّل الميزة.",
         "mtgThrottleMaxConnectionsHint": "تحديد الاتصالات المتزامنة لجميع المستخدمين بتوزيع عادل. القيمة 0 تعطّل الميزة.",
+        "mtgPendingHandshakesMaxPerIp": "الحد الأقصى للمصافحات المعلقة لكل IP",
+        "mtgPendingHandshakesMaxPerIpHint": "تُغلق الاتصالات الجديدة من عنوان IP لديه هذا العدد من المصافحات غير المكتملة. يتشارك المستخدمون خلف عنوان IP مشترك (NAT لدى المشغّل) هذا الحد. القيمة 0 تعطّله.",
         "mtgAdTagInvalid": "يجب أن تتكوّن العلامة الإعلانية من 32 حرفًا ست عشريًا بالضبط.",
         "mtgAdTagInvalid": "يجب أن تتكوّن العلامة الإعلانية من 32 حرفًا ست عشريًا بالضبط.",
         "mtgPublicIpv4": "عنوان IPv4 العام",
         "mtgPublicIpv4": "عنوان IPv4 العام",
         "mtgPublicIpv6": "عنوان IPv6 العام",
         "mtgPublicIpv6": "عنوان IPv6 العام",

+ 2 - 0
internal/web/translation/en-US.json

@@ -507,6 +507,8 @@
         "mtprotoFakeTlsDomainHint": "Default FakeTLS domain used to generate a new client's secret. Each client can front its own domain.",
         "mtprotoFakeTlsDomainHint": "Default FakeTLS domain used to generate a new client's secret. Each client can front its own domain.",
         "mtgThrottleMaxConnections": "Max connections",
         "mtgThrottleMaxConnections": "Max connections",
         "mtgThrottleMaxConnectionsHint": "Cap concurrent connections across all users with a fair-share limit. 0 disables throttling.",
         "mtgThrottleMaxConnectionsHint": "Cap concurrent connections across all users with a fair-share limit. 0 disables throttling.",
+        "mtgPendingHandshakesMaxPerIp": "Max pending handshakes per IP",
+        "mtgPendingHandshakesMaxPerIpHint": "Close new connections from an IP that already has this many unfinished handshakes. Clients behind a shared IP (carrier NAT) share the limit. 0 disables it.",
         "mtgAdTagInvalid": "Ad-tag must be exactly 32 hexadecimal characters.",
         "mtgAdTagInvalid": "Ad-tag must be exactly 32 hexadecimal characters.",
         "mtgPublicIpv4": "Public IPv4",
         "mtgPublicIpv4": "Public IPv4",
         "mtgPublicIpv6": "Public IPv6",
         "mtgPublicIpv6": "Public IPv6",

+ 2 - 0
internal/web/translation/es-ES.json

@@ -505,6 +505,8 @@
         "mtprotoFakeTlsDomainHint": "Dominio FakeTLS predeterminado para generar el secreto de un nuevo cliente. Cada cliente puede usar su propio dominio.",
         "mtprotoFakeTlsDomainHint": "Dominio FakeTLS predeterminado para generar el secreto de un nuevo cliente. Cada cliente puede usar su propio dominio.",
         "mtgThrottleMaxConnections": "Conexiones máximas",
         "mtgThrottleMaxConnections": "Conexiones máximas",
         "mtgThrottleMaxConnectionsHint": "Limita las conexiones simultáneas de todos los usuarios con reparto equitativo. 0 desactiva el límite.",
         "mtgThrottleMaxConnectionsHint": "Limita las conexiones simultáneas de todos los usuarios con reparto equitativo. 0 desactiva el límite.",
+        "mtgPendingHandshakesMaxPerIp": "Máx. handshakes pendientes por IP",
+        "mtgPendingHandshakesMaxPerIpHint": "Cierra las conexiones nuevas de una IP que ya tiene esta cantidad de handshakes sin completar. Los clientes detrás de una IP compartida (NAT del operador) comparten el límite. 0 lo desactiva.",
         "mtgAdTagInvalid": "El ad-tag debe tener exactamente 32 caracteres hexadecimales.",
         "mtgAdTagInvalid": "El ad-tag debe tener exactamente 32 caracteres hexadecimales.",
         "mtgPublicIpv4": "IPv4 pública",
         "mtgPublicIpv4": "IPv4 pública",
         "mtgPublicIpv6": "IPv6 pública",
         "mtgPublicIpv6": "IPv6 pública",

+ 2 - 0
internal/web/translation/fa-IR.json

@@ -505,6 +505,8 @@
         "mtprotoFakeTlsDomainHint": "دامنه پیش‌فرض FakeTLS برای ساخت سکرت کلاینت جدید. هر کلاینت می‌تواند دامنه مخصوص خود را داشته باشد.",
         "mtprotoFakeTlsDomainHint": "دامنه پیش‌فرض FakeTLS برای ساخت سکرت کلاینت جدید. هر کلاینت می‌تواند دامنه مخصوص خود را داشته باشد.",
         "mtgThrottleMaxConnections": "حداکثر اتصالات",
         "mtgThrottleMaxConnections": "حداکثر اتصالات",
         "mtgThrottleMaxConnectionsHint": "محدود کردن اتصالات همزمان همه کاربران با تقسیم منصفانه. مقدار ۰ غیرفعال است.",
         "mtgThrottleMaxConnectionsHint": "محدود کردن اتصالات همزمان همه کاربران با تقسیم منصفانه. مقدار ۰ غیرفعال است.",
+        "mtgPendingHandshakesMaxPerIp": "حداکثر handshake معلق برای هر IP",
+        "mtgPendingHandshakesMaxPerIpHint": "اتصال‌های جدید از IPای که این تعداد handshake ناتمام دارد بسته می‌شوند. کاربرانی که پشت یک IP مشترک (NAT اپراتور) هستند این سقف را با هم شریک‌اند. مقدار ۰ غیرفعال است.",
         "mtgAdTagInvalid": "برچسب تبلیغاتی باید دقیقاً ۳۲ کاراکتر هگزادسیمال باشد.",
         "mtgAdTagInvalid": "برچسب تبلیغاتی باید دقیقاً ۳۲ کاراکتر هگزادسیمال باشد.",
         "mtgPublicIpv4": "IPv4 عمومی",
         "mtgPublicIpv4": "IPv4 عمومی",
         "mtgPublicIpv6": "IPv6 عمومی",
         "mtgPublicIpv6": "IPv6 عمومی",

+ 2 - 0
internal/web/translation/id-ID.json

@@ -505,6 +505,8 @@
         "mtprotoFakeTlsDomainHint": "Domain FakeTLS default untuk membuat secret klien baru. Setiap klien bisa memakai domainnya sendiri.",
         "mtprotoFakeTlsDomainHint": "Domain FakeTLS default untuk membuat secret klien baru. Setiap klien bisa memakai domainnya sendiri.",
         "mtgThrottleMaxConnections": "Koneksi maksimum",
         "mtgThrottleMaxConnections": "Koneksi maksimum",
         "mtgThrottleMaxConnectionsHint": "Batasi koneksi bersamaan semua pengguna dengan pembagian adil. 0 menonaktifkan.",
         "mtgThrottleMaxConnectionsHint": "Batasi koneksi bersamaan semua pengguna dengan pembagian adil. 0 menonaktifkan.",
+        "mtgPendingHandshakesMaxPerIp": "Maks. handshake tertunda per IP",
+        "mtgPendingHandshakesMaxPerIpHint": "Tutup koneksi baru dari IP yang sudah memiliki handshake belum selesai sebanyak ini. Klien di balik IP bersama (NAT operator) berbagi batas ini. 0 menonaktifkan.",
         "mtgAdTagInvalid": "Ad-tag harus tepat 32 karakter heksadesimal.",
         "mtgAdTagInvalid": "Ad-tag harus tepat 32 karakter heksadesimal.",
         "mtgPublicIpv4": "IPv4 Publik",
         "mtgPublicIpv4": "IPv4 Publik",
         "mtgPublicIpv6": "IPv6 Publik",
         "mtgPublicIpv6": "IPv6 Publik",

+ 2 - 0
internal/web/translation/ja-JP.json

@@ -526,6 +526,8 @@
         "mtprotoFakeTlsDomainHint": "新しいクライアントのシークレット生成に使う既定の FakeTLS ドメイン。クライアントごとに別のドメインを使用できます。",
         "mtprotoFakeTlsDomainHint": "新しいクライアントのシークレット生成に使う既定の FakeTLS ドメイン。クライアントごとに別のドメインを使用できます。",
         "mtgThrottleMaxConnections": "最大接続数",
         "mtgThrottleMaxConnections": "最大接続数",
         "mtgThrottleMaxConnectionsHint": "全ユーザーの同時接続数を公平配分で制限します。0 で無効。",
         "mtgThrottleMaxConnectionsHint": "全ユーザーの同時接続数を公平配分で制限します。0 で無効。",
+        "mtgPendingHandshakesMaxPerIp": "IP あたりの未完了ハンドシェイク上限",
+        "mtgPendingHandshakesMaxPerIpHint": "未完了のハンドシェイクをこの数だけ保持している IP からの新規接続を閉じます。共有 IP(キャリア NAT)配下のクライアントはこの上限を共有します。0 で無効。",
         "mtgAdTagInvalid": "広告タグは正確に 32 文字の 16 進数である必要があります。",
         "mtgAdTagInvalid": "広告タグは正確に 32 文字の 16 進数である必要があります。",
         "mtgPublicIpv4": "パブリック IPv4",
         "mtgPublicIpv4": "パブリック IPv4",
         "mtgPublicIpv6": "パブリック IPv6",
         "mtgPublicIpv6": "パブリック IPv6",

+ 2 - 0
internal/web/translation/pt-BR.json

@@ -526,6 +526,8 @@
         "mtprotoFakeTlsDomainHint": "Domínio FakeTLS padrão usado para gerar o segredo de um novo cliente. Cada cliente pode usar seu próprio domínio.",
         "mtprotoFakeTlsDomainHint": "Domínio FakeTLS padrão usado para gerar o segredo de um novo cliente. Cada cliente pode usar seu próprio domínio.",
         "mtgThrottleMaxConnections": "Conexões máximas",
         "mtgThrottleMaxConnections": "Conexões máximas",
         "mtgThrottleMaxConnectionsHint": "Limita conexões simultâneas de todos os usuários com distribuição justa. 0 desativa o limite.",
         "mtgThrottleMaxConnectionsHint": "Limita conexões simultâneas de todos os usuários com distribuição justa. 0 desativa o limite.",
+        "mtgPendingHandshakesMaxPerIp": "Máx. handshakes pendentes por IP",
+        "mtgPendingHandshakesMaxPerIpHint": "Fecha novas conexões de um IP que já tem esta quantidade de handshakes não concluídos. Clientes atrás de um IP compartilhado (NAT da operadora) dividem o limite. 0 desativa.",
         "mtgAdTagInvalid": "A ad-tag deve ter exatamente 32 caracteres hexadecimais.",
         "mtgAdTagInvalid": "A ad-tag deve ter exatamente 32 caracteres hexadecimais.",
         "mtgPublicIpv4": "IPv4 público",
         "mtgPublicIpv4": "IPv4 público",
         "mtgPublicIpv6": "IPv6 público",
         "mtgPublicIpv6": "IPv6 público",

+ 2 - 0
internal/web/translation/ru-RU.json

@@ -528,6 +528,8 @@
         "mtprotoFakeTlsDomainHint": "Домен FakeTLS по умолчанию для генерации секрета нового клиента. Каждый клиент может использовать свой домен.",
         "mtprotoFakeTlsDomainHint": "Домен FakeTLS по умолчанию для генерации секрета нового клиента. Каждый клиент может использовать свой домен.",
         "mtgThrottleMaxConnections": "Макс. подключений",
         "mtgThrottleMaxConnections": "Макс. подключений",
         "mtgThrottleMaxConnectionsHint": "Ограничение одновременных подключений всех пользователей по справедливому распределению. 0 — отключено.",
         "mtgThrottleMaxConnectionsHint": "Ограничение одновременных подключений всех пользователей по справедливому распределению. 0 — отключено.",
+        "mtgPendingHandshakesMaxPerIp": "Макс. незавершённых рукопожатий на IP",
+        "mtgPendingHandshakesMaxPerIpHint": "Новые подключения с IP, у которого уже столько незавершённых рукопожатий, закрываются. Клиенты за общим IP (NAT оператора) делят этот лимит. 0 — отключено.",
         "mtgAdTagInvalid": "Рекламный тег должен содержать ровно 32 шестнадцатеричных символа.",
         "mtgAdTagInvalid": "Рекламный тег должен содержать ровно 32 шестнадцатеричных символа.",
         "mtgPublicIpv4": "Публичный IPv4",
         "mtgPublicIpv4": "Публичный IPv4",
         "mtgPublicIpv6": "Публичный IPv6",
         "mtgPublicIpv6": "Публичный IPv6",

+ 2 - 0
internal/web/translation/tr-TR.json

@@ -505,6 +505,8 @@
         "mtprotoFakeTlsDomainHint": "Yeni bir istemcinin sırrını oluştururken kullanılan varsayılan FakeTLS alan adı. Her istemci kendi alan adını kullanabilir.",
         "mtprotoFakeTlsDomainHint": "Yeni bir istemcinin sırrını oluştururken kullanılan varsayılan FakeTLS alan adı. Her istemci kendi alan adını kullanabilir.",
         "mtgThrottleMaxConnections": "Maks. bağlantı",
         "mtgThrottleMaxConnections": "Maks. bağlantı",
         "mtgThrottleMaxConnectionsHint": "Tüm kullanıcıların eşzamanlı bağlantılarını adil paylaşımla sınırlar. 0 devre dışı bırakır.",
         "mtgThrottleMaxConnectionsHint": "Tüm kullanıcıların eşzamanlı bağlantılarını adil paylaşımla sınırlar. 0 devre dışı bırakır.",
+        "mtgPendingHandshakesMaxPerIp": "IP başına maks. bekleyen el sıkışma",
+        "mtgPendingHandshakesMaxPerIpHint": "Bu sayıda tamamlanmamış el sıkışması olan bir IP'den gelen yeni bağlantıları kapatır. Paylaşılan IP arkasındaki istemciler (operatör NAT) sınırı paylaşır. 0 devre dışı bırakır.",
         "mtgAdTagInvalid": "Reklam etiketi tam olarak 32 onaltılık karakter olmalıdır.",
         "mtgAdTagInvalid": "Reklam etiketi tam olarak 32 onaltılık karakter olmalıdır.",
         "mtgPublicIpv4": "Genel IPv4",
         "mtgPublicIpv4": "Genel IPv4",
         "mtgPublicIpv6": "Genel IPv6",
         "mtgPublicIpv6": "Genel IPv6",

+ 2 - 0
internal/web/translation/uk-UA.json

@@ -505,6 +505,8 @@
         "mtprotoFakeTlsDomainHint": "Домен FakeTLS за замовчуванням для генерації секрету нового клієнта. Кожен клієнт може використовувати власний домен.",
         "mtprotoFakeTlsDomainHint": "Домен FakeTLS за замовчуванням для генерації секрету нового клієнта. Кожен клієнт може використовувати власний домен.",
         "mtgThrottleMaxConnections": "Макс. з'єднань",
         "mtgThrottleMaxConnections": "Макс. з'єднань",
         "mtgThrottleMaxConnectionsHint": "Обмеження одночасних з'єднань усіх користувачів зі справедливим розподілом. 0 — вимкнено.",
         "mtgThrottleMaxConnectionsHint": "Обмеження одночасних з'єднань усіх користувачів зі справедливим розподілом. 0 — вимкнено.",
+        "mtgPendingHandshakesMaxPerIp": "Макс. незавершених рукостискань на IP",
+        "mtgPendingHandshakesMaxPerIpHint": "Нові з'єднання з IP, що вже має стільки незавершених рукостискань, закриваються. Клієнти за спільним IP (NAT оператора) ділять цей ліміт. 0 — вимкнено.",
         "mtgAdTagInvalid": "Рекламний тег має містити рівно 32 шістнадцяткові символи.",
         "mtgAdTagInvalid": "Рекламний тег має містити рівно 32 шістнадцяткові символи.",
         "mtgPublicIpv4": "Публічний IPv4",
         "mtgPublicIpv4": "Публічний IPv4",
         "mtgPublicIpv6": "Публічний IPv6",
         "mtgPublicIpv6": "Публічний IPv6",

+ 2 - 0
internal/web/translation/vi-VN.json

@@ -526,6 +526,8 @@
         "mtprotoFakeTlsDomainHint": "Tên miền FakeTLS mặc định dùng để tạo secret cho client mới. Mỗi client có thể dùng tên miền riêng.",
         "mtprotoFakeTlsDomainHint": "Tên miền FakeTLS mặc định dùng để tạo secret cho client mới. Mỗi client có thể dùng tên miền riêng.",
         "mtgThrottleMaxConnections": "Số kết nối tối đa",
         "mtgThrottleMaxConnections": "Số kết nối tối đa",
         "mtgThrottleMaxConnectionsHint": "Giới hạn kết nối đồng thời của tất cả người dùng theo phân bổ công bằng. 0 để tắt.",
         "mtgThrottleMaxConnectionsHint": "Giới hạn kết nối đồng thời của tất cả người dùng theo phân bổ công bằng. 0 để tắt.",
+        "mtgPendingHandshakesMaxPerIp": "Số bắt tay chờ tối đa mỗi IP",
+        "mtgPendingHandshakesMaxPerIpHint": "Đóng kết nối mới từ IP đang có số bắt tay chưa hoàn tất bằng mức này. Các máy khách dùng chung IP (NAT nhà mạng) chia sẻ giới hạn. 0 để tắt.",
         "mtgAdTagInvalid": "Ad-tag phải có đúng 32 ký tự thập lục phân.",
         "mtgAdTagInvalid": "Ad-tag phải có đúng 32 ký tự thập lục phân.",
         "mtgPublicIpv4": "IPv4 công khai",
         "mtgPublicIpv4": "IPv4 công khai",
         "mtgPublicIpv6": "IPv6 công khai",
         "mtgPublicIpv6": "IPv6 công khai",

+ 2 - 0
internal/web/translation/zh-CN.json

@@ -525,6 +525,8 @@
         "mtprotoFakeTlsDomainHint": "生成新客户端密钥时使用的默认 FakeTLS 域名。每个客户端可使用各自的域名。",
         "mtprotoFakeTlsDomainHint": "生成新客户端密钥时使用的默认 FakeTLS 域名。每个客户端可使用各自的域名。",
         "mtgThrottleMaxConnections": "最大连接数",
         "mtgThrottleMaxConnections": "最大连接数",
         "mtgThrottleMaxConnectionsHint": "按公平分配限制所有用户的并发连接数。0 表示不限制。",
         "mtgThrottleMaxConnectionsHint": "按公平分配限制所有用户的并发连接数。0 表示不限制。",
+        "mtgPendingHandshakesMaxPerIp": "每 IP 最大未完成握手数",
+        "mtgPendingHandshakesMaxPerIpHint": "当某个 IP 的未完成握手达到此数量时,关闭其新连接。共享 IP(运营商 NAT)后的客户端共用此上限。0 表示禁用。",
         "mtgAdTagInvalid": "广告标签必须为 32 位十六进制字符。",
         "mtgAdTagInvalid": "广告标签必须为 32 位十六进制字符。",
         "mtgPublicIpv4": "公网 IPv4",
         "mtgPublicIpv4": "公网 IPv4",
         "mtgPublicIpv6": "公网 IPv6",
         "mtgPublicIpv6": "公网 IPv6",

+ 2 - 0
internal/web/translation/zh-TW.json

@@ -505,6 +505,8 @@
         "mtprotoFakeTlsDomainHint": "產生新用戶端金鑰時使用的預設 FakeTLS 網域。每個用戶端可使用各自的網域。",
         "mtprotoFakeTlsDomainHint": "產生新用戶端金鑰時使用的預設 FakeTLS 網域。每個用戶端可使用各自的網域。",
         "mtgThrottleMaxConnections": "最大連線數",
         "mtgThrottleMaxConnections": "最大連線數",
         "mtgThrottleMaxConnectionsHint": "以公平分配限制所有使用者的並行連線數。0 表示不限制。",
         "mtgThrottleMaxConnectionsHint": "以公平分配限制所有使用者的並行連線數。0 表示不限制。",
+        "mtgPendingHandshakesMaxPerIp": "每 IP 最大未完成交握數",
+        "mtgPendingHandshakesMaxPerIpHint": "當某個 IP 的未完成交握達到此數量時,關閉其新連線。共用 IP(電信 NAT)後的用戶端共用此上限。0 表示停用。",
         "mtgAdTagInvalid": "廣告標籤必須為 32 位十六進位字元。",
         "mtgAdTagInvalid": "廣告標籤必須為 32 位十六進位字元。",
         "mtgPublicIpv4": "公網 IPv4",
         "mtgPublicIpv4": "公網 IPv4",
         "mtgPublicIpv6": "公網 IPv6",
         "mtgPublicIpv6": "公網 IPv6",

+ 29 - 3
internal/xray/hot_diff.go

@@ -3,6 +3,7 @@ package xray
 import (
 import (
 	"bytes"
 	"bytes"
 	"encoding/json"
 	"encoding/json"
+	"maps"
 
 
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"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/json_util"
@@ -234,7 +235,7 @@ func InboundUserOps(oldIb, newIb *InboundConfig) (*HotDiff, bool) {
 	return diff, true
 	return diff, true
 }
 }
 
 
-var userDiffableProtocols = map[string]struct{}{"vless": {}, "vmess": {}, "trojan": {}, "hysteria": {}, "masque": {}}
+var userDiffableProtocols = map[string]struct{}{"vless": {}, "vmess": {}, "trojan": {}, "hysteria": {}, "masque": {}, "shadowsocks": {}}
 
 
 // diffInboundUsers emits per-user AlterInbound ops when two same-tag inbounds
 // diffInboundUsers emits per-user AlterInbound ops when two same-tag inbounds
 // differ only in settings.clients, so the handler (and its listener) survives.
 // differ only in settings.clients, so the handler (and its listener) survives.
@@ -261,6 +262,10 @@ func diffInboundUsers(oldIb, newIb *InboundConfig, diff *HotDiff) bool {
 	if !bytes.Equal(oldRest, newRest) {
 	if !bytes.Equal(oldRest, newRest) {
 		return false
 		return false
 	}
 	}
+	cipher := ""
+	if newIb.Protocol == "shadowsocks" {
+		cipher = shadowsocksSettingsMethod(newRest)
+	}
 	for email, oldC := range oldClients {
 	for email, oldC := range oldClients {
 		newC, exists := newClients[email]
 		newC, exists := newClients[email]
 		if exists && bytes.Equal(oldC.norm, newC.norm) {
 		if exists && bytes.Equal(oldC.norm, newC.norm) {
@@ -268,17 +273,38 @@ func diffInboundUsers(oldIb, newIb *InboundConfig, diff *HotDiff) bool {
 		}
 		}
 		diff.RemovedUsers = append(diff.RemovedUsers, UserOp{Tag: oldIb.Tag, Protocol: oldIb.Protocol, Email: email})
 		diff.RemovedUsers = append(diff.RemovedUsers, UserOp{Tag: oldIb.Tag, Protocol: oldIb.Protocol, Email: email})
 		if exists {
 		if exists {
-			diff.AddedUsers = append(diff.AddedUsers, UserOp{Tag: oldIb.Tag, Protocol: oldIb.Protocol, Email: email, User: newC.user})
+			diff.AddedUsers = append(diff.AddedUsers, UserOp{Tag: oldIb.Tag, Protocol: oldIb.Protocol, Email: email, User: userWithCipher(newC.user, cipher)})
 		}
 		}
 	}
 	}
 	for email, newC := range newClients {
 	for email, newC := range newClients {
 		if _, exists := oldClients[email]; !exists {
 		if _, exists := oldClients[email]; !exists {
-			diff.AddedUsers = append(diff.AddedUsers, UserOp{Tag: oldIb.Tag, Protocol: oldIb.Protocol, Email: email, User: newC.user})
+			diff.AddedUsers = append(diff.AddedUsers, UserOp{Tag: oldIb.Tag, Protocol: oldIb.Protocol, Email: email, User: userWithCipher(newC.user, cipher)})
 		}
 		}
 	}
 	}
 	return true
 	return true
 }
 }
 
 
+// shadowsocksSettingsMethod reads the inbound-level method: 2022 clients carry
+// none, and AddUser needs it to pick the account type the running inbound expects.
+func shadowsocksSettingsMethod(rest []byte) string {
+	var settings struct {
+		Method string `json:"method"`
+	}
+	if json.Unmarshal(rest, &settings) != nil {
+		return ""
+	}
+	return settings.Method
+}
+
+func userWithCipher(user map[string]any, cipher string) map[string]any {
+	if cipher == "" {
+		return user
+	}
+	withCipher := maps.Clone(user)
+	withCipher["cipher"] = cipher
+	return withCipher
+}
+
 type clientEntry struct {
 type clientEntry struct {
 	user map[string]any
 	user map[string]any
 	norm []byte
 	norm []byte

+ 42 - 1
internal/xray/hot_diff_test.go

@@ -9,6 +9,7 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
 
 
 	"github.com/op/go-logging"
 	"github.com/op/go-logging"
+	"github.com/xtls/xray-core/proxy/shadowsocks"
 )
 )
 
 
 func TestMain(m *testing.M) {
 func TestMain(m *testing.M) {
@@ -242,7 +243,7 @@ func TestComputeHotDiff_ClientChangeFallsBackToReplace(t *testing.T) {
 		{
 		{
 			name: "unsupported protocol",
 			name: "unsupported protocol",
 			mutate: func(cfg *Config) {
 			mutate: func(cfg *Config) {
-				cfg.InboundConfigs[1].Protocol = "shadowsocks"
+				cfg.InboundConfigs[1].Protocol = "http"
 			},
 			},
 		},
 		},
 		{
 		{
@@ -559,3 +560,43 @@ func TestComputeHotDiff_HysteriaClientOnlyChangeKeepsListener(t *testing.T) {
 		t.Fatalf("expected a single AddUser op for b, got %+v", diff)
 		t.Fatalf("expected a single AddUser op for b, got %+v", diff)
 	}
 	}
 }
 }
+
+// 2022 clients carry no method of their own, so a user op built from the client
+// object alone has no cipher and its account cannot be built for the core.
+func TestInboundUserOps_ShadowsocksAddedUserBuildsTheInboundAccount(t *testing.T) {
+	cases := []struct {
+		name, settingsHead, clientMethod string
+		wantLegacy                       bool
+	}{
+		{"legacy aead", `"method":"aes-256-gcm"`, `"method":"aes-256-gcm",`, true},
+		{"2022", `"method":"2022-blake3-aes-128-gcm","password":"AAECAwQFBgcICQoLDA0ODw=="`, "", false},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			inbound := func(clients ...string) *InboundConfig {
+				return &InboundConfig{
+					Port: 8388, Protocol: "shadowsocks", Tag: "inbound-8388",
+					Listen:   json_util.RawMessage(`"0.0.0.0"`),
+					Settings: json_util.RawMessage(`{` + tc.settingsHead + `,"network":"tcp,udp","clients":[` + strings.Join(clients, ",") + `]}`),
+				}
+			}
+			clientA := `{` + tc.clientMethod + `"email":"a","password":"EBESExQVFhcYGRobHB0eHw=="}`
+			clientB := `{` + tc.clientMethod + `"email":"b","password":"ICEiIyQlJicoKSorLC0uLw=="}`
+
+			diff, ok := InboundUserOps(inbound(clientA), inbound(clientA, clientB))
+			if !ok {
+				t.Fatal("a client-only shadowsocks change must apply through per-user ops")
+			}
+			if len(diff.RemovedUsers) != 0 || len(diff.AddedUsers) != 1 || diff.AddedUsers[0].Email != "b" {
+				t.Fatalf("expected only user b added, got added %+v removed %+v", diff.AddedUsers, diff.RemovedUsers)
+			}
+			_, legacy, modern := decodeSSAccount(t, diff.AddedUsers[0].User)
+			if tc.wantLegacy && (legacy == nil || legacy.CipherType != shadowsocks.CipherType_AES_256_GCM) {
+				t.Fatalf("want a legacy aes-256-gcm account, got legacy %v modern %v", legacy, modern)
+			}
+			if !tc.wantLegacy && (modern == nil || modern.Key != "ICEiIyQlJicoKSorLC0uLw==") {
+				t.Fatalf("want a 2022 account keyed by b's password, got legacy %v modern %v", legacy, modern)
+			}
+		})
+	}
+}

+ 107 - 68
internal/xray/log_writer.go

@@ -12,8 +12,8 @@ import (
 // Compiled once at package load: Write runs on every line Xray emits, so
 // Compiled once at package load: Write runs on every line Xray emits, so
 // recompiling these per write is wasted work.
 // recompiling these per write is wasted work.
 var (
 var (
-	crashRegex   = regexp.MustCompile(`(?i)(panic|exception|stack trace|fatal error)`)
-	logLineRegex = regexp.MustCompile(`^(\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2}\.\d{6}) \[([^\]]+)\] (.+)$`)
+	crashStartRegex = regexp.MustCompile(`^(panic|fatal error): `)
+	logLineRegex    = regexp.MustCompile(`^(\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2}\.\d{6}) \[([^\]]+)\] (.+)$`)
 )
 )
 
 
 // NewLogWriter returns a new LogWriter for processing Xray log output.
 // NewLogWriter returns a new LogWriter for processing Xray log output.
@@ -21,9 +21,12 @@ func NewLogWriter() *LogWriter {
 	return &LogWriter{}
 	return &LogWriter{}
 }
 }
 
 
-// LogWriter processes and filters log output from the Xray process, handling crash detection and message filtering.
+// LogWriter splits Xray's stdout/stderr into lines and forwards them to the
+// panel log; once a Go panic or fatal error starts, all output is kept for one crash report.
 type LogWriter struct {
 type LogWriter struct {
 	mu       sync.RWMutex
 	mu       sync.RWMutex
+	pending  string
+	crash    strings.Builder
 	lastLine string
 	lastLine string
 }
 }
 
 
@@ -36,86 +39,122 @@ func (lw *LogWriter) LastLine() string {
 	return lw.lastLine
 	return lw.lastLine
 }
 }
 
 
-func (lw *LogWriter) setLastLine(line string) {
-	lw.mu.Lock()
-	lw.lastLine = line
-	lw.mu.Unlock()
-}
-
-// Write processes and filters log output from the Xray process, handling crash detection and message filtering.
+// Write buffers chunks into whole lines: the pipe splits output at arbitrary
+// points, e.g. the Go runtime writes "panic: " on its own before the message.
 func (lw *LogWriter) Write(m []byte) (n int, err error) {
 func (lw *LogWriter) Write(m []byte) (n int, err error) {
-	// Convert the data to a string
-	message := strings.TrimSpace(string(m))
-	msgLowerAll := strings.ToLower(message)
-
-	// Suppress noisy Windows process-kill signal that surfaces as exit status 1
-	if runtime.GOOS == "windows" && strings.Contains(msgLowerAll, "exit status 1") {
+	lw.mu.Lock()
+	defer lw.mu.Unlock()
+	if lw.crash.Len() > 0 {
+		lw.crash.Write(m)
 		return len(m), nil
 		return len(m), nil
 	}
 	}
+	lw.pending += string(m)
+	for {
+		i := strings.IndexByte(lw.pending, '\n')
+		if i < 0 {
+			break
+		}
+		line := lw.pending[:i]
+		lw.pending = lw.pending[i+1:]
+		if lw.startCrashLocked(line + "\n" + lw.pending) {
+			lw.pending = ""
+			break
+		}
+		lw.processLineLocked(line)
+	}
+	return len(m), nil
+}
 
 
-	// Check if the message contains a crash
-	if crashRegex.MatchString(message) {
-		logger.Debug("Core crash detected:\n", message)
-		lw.setLastLine(message)
-		err1 := writeCrashReport(m)
-		if err1 != nil {
-			logger.Error("Unable to write crash report:", err1)
+// Flush handles an unterminated final line and writes the crash report, if any;
+// it must run after cmd.Wait, which guarantees every output chunk was delivered.
+func (lw *LogWriter) Flush() {
+	lw.mu.Lock()
+	defer lw.mu.Unlock()
+	if rest := lw.pending; rest != "" {
+		lw.pending = ""
+		if !lw.startCrashLocked(rest) {
+			lw.processLineLocked(rest)
 		}
 		}
-		return len(m), nil
 	}
 	}
+	if lw.crash.Len() == 0 {
+		return
+	}
+	report := lw.crash.String()
+	lw.crash.Reset()
+	logger.Debug("Core crash detected:\n", report)
+	if err := writeCrashReport([]byte(report)); err != nil {
+		logger.Error("Unable to write crash report:", err)
+	}
+}
+
+// startCrashLocked begins capturing a crash when output opens with a Go runtime
+// crash header; the header line becomes LastLine so GetResult shows the cause.
+func (lw *LogWriter) startCrashLocked(output string) bool {
+	if lw.crash.Len() > 0 || !crashStartRegex.MatchString(output) {
+		return false
+	}
+	header, _, _ := strings.Cut(output, "\n")
+	lw.lastLine = strings.TrimSpace(header)
+	lw.crash.WriteString(output)
+	return true
+}
 
 
-	messages := strings.SplitSeq(message, "\n")
+func (lw *LogWriter) processLineLocked(line string) {
+	msg := strings.TrimSpace(line)
+	if msg == "" {
+		return
+	}
+	msgLower := strings.ToLower(msg)
 
 
-	for msg := range messages {
-		matches := logLineRegex.FindStringSubmatch(msg)
+	// Suppress noisy Windows process-kill signal that surfaces as exit status 1
+	if runtime.GOOS == "windows" && strings.Contains(msgLower, "exit status 1") {
+		return
+	}
 
 
-		if len(matches) > 3 {
-			level := matches[2]
-			msgBody := matches[3]
-			msgBodyLower := strings.ToLower(msgBody)
+	matches := logLineRegex.FindStringSubmatch(msg)
+	if len(matches) > 3 {
+		level := matches[2]
+		msgBody := matches[3]
+		msgBodyLower := strings.ToLower(msgBody)
+
+		if strings.Contains(msgBodyLower, "tls handshake error") ||
+			strings.Contains(msgBodyLower, "connection ends") {
+			logger.Debug("XRAY: " + msgBody)
+			lw.lastLine = ""
+			return
+		}
 
 
-			if strings.Contains(msgBodyLower, "tls handshake error") ||
-				strings.Contains(msgBodyLower, "connection ends") {
+		if strings.Contains(msgBodyLower, "failed") {
+			logger.Error("XRAY: " + msgBody)
+		} else {
+			switch level {
+			case "Debug":
 				logger.Debug("XRAY: " + msgBody)
 				logger.Debug("XRAY: " + msgBody)
-				lw.setLastLine("")
-				continue
-			}
-
-			if strings.Contains(msgBodyLower, "failed") {
+			case "Info":
+				logger.Info("XRAY: " + msgBody)
+			case "Warning":
+				logger.Warning("XRAY: " + msgBody)
+			case "Error":
 				logger.Error("XRAY: " + msgBody)
 				logger.Error("XRAY: " + msgBody)
-			} else {
-				switch level {
-				case "Debug":
-					logger.Debug("XRAY: " + msgBody)
-				case "Info":
-					logger.Info("XRAY: " + msgBody)
-				case "Warning":
-					logger.Warning("XRAY: " + msgBody)
-				case "Error":
-					logger.Error("XRAY: " + msgBody)
-				default:
-					logger.Debug("XRAY: " + msg)
-				}
-			}
-			lw.setLastLine("")
-		} else if msg != "" {
-			msgLower := strings.ToLower(msg)
-
-			if strings.Contains(msgLower, "tls handshake error") ||
-				strings.Contains(msgLower, "connection ends") {
-				logger.Debug("XRAY: " + msg)
-				lw.setLastLine(msg)
-				continue
-			}
-
-			if strings.Contains(msgLower, "failed") {
-				logger.Error("XRAY: " + msg)
-			} else {
+			default:
 				logger.Debug("XRAY: " + msg)
 				logger.Debug("XRAY: " + msg)
 			}
 			}
-			lw.setLastLine(msg)
 		}
 		}
+		lw.lastLine = ""
+		return
 	}
 	}
 
 
-	return len(m), nil
+	if strings.Contains(msgLower, "tls handshake error") ||
+		strings.Contains(msgLower, "connection ends") {
+		logger.Debug("XRAY: " + msg)
+		lw.lastLine = msg
+		return
+	}
+
+	if strings.Contains(msgLower, "failed") {
+		logger.Error("XRAY: " + msg)
+	} else {
+		logger.Debug("XRAY: " + msg)
+	}
+	lw.lastLine = msg
 }
 }

+ 78 - 1
internal/xray/log_writer_race_test.go

@@ -1,6 +1,10 @@
 package xray
 package xray
 
 
 import (
 import (
+	"os"
+	"path/filepath"
+	"slices"
+	"strings"
 	"sync"
 	"sync"
 	"testing"
 	"testing"
 )
 )
@@ -20,7 +24,7 @@ func TestLogWriterLastLineConcurrent(t *testing.T) {
 		go func() {
 		go func() {
 			defer wg.Done()
 			defer wg.Done()
 			for range iterations {
 			for range iterations {
-				_, _ = lw.Write([]byte("2024/01/01 00:00:00.000000 [Info] connection accepted"))
+				_, _ = lw.Write([]byte("2024/01/01 00:00:00.000000 [Info] connection accepted\n"))
 			}
 			}
 		}()
 		}()
 	}
 	}
@@ -34,3 +38,76 @@ func TestLogWriterLastLineConcurrent(t *testing.T) {
 	}
 	}
 	wg.Wait()
 	wg.Wait()
 }
 }
+
+func crashReportsIn(t *testing.T, dir string) []string {
+	t.Helper()
+	entries, err := os.ReadDir(dir)
+	if err != nil {
+		t.Fatal(err)
+	}
+	var contents []string
+	for _, e := range entries {
+		if strings.HasPrefix(e.Name(), crashReportPrefix) {
+			b, err := os.ReadFile(filepath.Join(dir, e.Name()))
+			if err != nil {
+				t.Fatal(err)
+			}
+			contents = append(contents, string(b))
+		}
+	}
+	return contents
+}
+
+// The Go runtime prints "panic: " with its own write(2), so the pipe hands it
+// over as a chunk of its own; the report must still hold the whole traceback.
+func TestLogWriterCrashReportHoldsWholePanic(t *testing.T) {
+	dir := t.TempDir()
+	t.Setenv("XUI_LOG_FOLDER", dir)
+	chunks := []string{
+		"panic: ",
+		"runtime error: index out of range [3] with length 1\n",
+		"\ngoroutine 1 [running]:\nmain.main()\n\t/src/main.go:5 +0x1d\n",
+		"exit status 2",
+	}
+	lw := NewLogWriter()
+	for _, c := range chunks {
+		_, _ = lw.Write([]byte(c))
+	}
+	lw.Flush()
+
+	got := crashReportsIn(t, dir)
+	want := strings.Join(chunks, "")
+	if len(got) != 1 || got[0] != want {
+		t.Fatalf("crash reports = %q, want exactly [%q]", got, want)
+	}
+	if line := lw.LastLine(); line != "panic: runtime error: index out of range [3] with length 1" {
+		t.Fatalf("LastLine = %q", line)
+	}
+}
+
+func TestLogWriterOrdinaryLineMentioningPanicIsNotACrash(t *testing.T) {
+	dir := t.TempDir()
+	t.Setenv("XUI_LOG_FOLDER", dir)
+	lw := NewLogWriter()
+	_, _ = lw.Write([]byte("2026/07/12 15:16:00.000000 [Info] [42] proxy/vless: sniffed domain panic.example.com\n"))
+	lw.Flush()
+
+	if got := crashReportsIn(t, dir); len(got) != 0 {
+		t.Fatalf("ordinary log line produced crash reports %q", got)
+	}
+}
+
+func TestWriteCrashReportKeepsReportsFromTheSameSecond(t *testing.T) {
+	dir := t.TempDir()
+	t.Setenv("XUI_LOG_FOLDER", dir)
+	for _, m := range []string{"panic: first", "panic: second"} {
+		if err := writeCrashReport([]byte(m)); err != nil {
+			t.Fatal(err)
+		}
+	}
+	got := crashReportsIn(t, dir)
+	slices.Sort(got)
+	if !slices.Equal(got, []string{"panic: first", "panic: second"}) {
+		t.Fatalf("crash reports = %q, want both", got)
+	}
+}

+ 31 - 0
internal/xray/mutation_audit_test.go

@@ -5,6 +5,7 @@ import (
 	"os/exec"
 	"os/exec"
 	"path/filepath"
 	"path/filepath"
 	"slices"
 	"slices"
+	"strings"
 	"testing"
 	"testing"
 	"time"
 	"time"
 
 
@@ -280,6 +281,33 @@ func TestWaitForCommand_CrashExitRecordsError(t *testing.T) {
 	}
 	}
 }
 }
 
 
+// A real child panic reaches the LogWriter in several pipe chunks; the crash
+// report is only whole if waitForCommand flushes the writer after cmd.Wait.
+func TestWaitForCommand_PanicWritesWholeCrashReport(t *testing.T) {
+	dir := t.TempDir()
+	t.Setenv("XUI_LOG_FOLDER", dir)
+	cmd := exec.Command(os.Args[0], "-test.run=TestMutationAuditHelper", "--", "panic")
+	cmd.Env = append(os.Environ(), "XRAY_MUT_HELPER=1")
+
+	p := newProcess(nil)
+	cmd.Stdout = p.logWriter
+	cmd.Stderr = p.logWriter
+	if err := p.startCommand(cmd); err != nil {
+		t.Fatalf("startCommand: %v", err)
+	}
+	if err := p.waitForExit(5 * time.Second); err != nil {
+		t.Fatalf("child did not exit: %v", err)
+	}
+
+	reports := crashReportsIn(t, dir)
+	if len(reports) != 1 || !strings.HasPrefix(reports[0], "panic: xray helper panic\n\ngoroutine ") {
+		t.Fatalf("crash reports = %q, want one holding the whole panic traceback", reports)
+	}
+	if got := p.GetResult(); got != "panic: xray helper panic" {
+		t.Fatalf("GetResult = %q, want the panic header", got)
+	}
+}
+
 // TestStop_RemovesTempConfigFile pins process.go:579 — the
 // TestStop_RemovesTempConfigFile pins process.go:579 — the
 // `if p.configPath != ""` guard that removes the per-run temp config file on
 // `if p.configPath != ""` guard that removes the per-run temp config file on
 // Stop (so test runs never disturb the main config.json). A mutated guard
 // Stop (so test runs never disturb the main config.json). A mutated guard
@@ -337,5 +365,8 @@ func TestMutationAuditHelper(t *testing.T) {
 		os.Exit(2)
 		os.Exit(2)
 	case "block":
 	case "block":
 		select {}
 		select {}
+	case "panic":
+		go func() { panic("xray helper panic") }()
+		select {}
 	}
 	}
 }
 }

+ 16 - 5
internal/xray/process.go

@@ -734,6 +734,7 @@ func (p *process) waitForCommand(cmd *exec.Cmd, done chan struct{}) {
 	defer close(done)
 	defer close(done)
 
 
 	err := cmd.Wait()
 	err := cmd.Wait()
+	p.logWriter.Flush()
 	if err == nil || p.intentionalStop.Load() {
 	if err == nil || p.intentionalStop.Load() {
 		return
 		return
 	}
 	}
@@ -830,17 +831,27 @@ const (
 	maxCrashReports   = 10
 	maxCrashReports   = 10
 )
 )
 
 
-// writeCrashReport persists a captured xray crash chunk to the log folder
-// with nanosecond-precision filename so restart-loop bursts don't overwrite
-// each other, and prunes old reports to keep the folder bounded.
+// writeCrashReport persists a captured xray crash to the log folder; CreateTemp's
+// random suffix keeps restart-loop bursts within one second from overwriting each other.
 func writeCrashReport(m []byte) error {
 func writeCrashReport(m []byte) error {
 	dir := config.GetLogFolder()
 	dir := config.GetLogFolder()
 	if err := os.MkdirAll(dir, 0o770); err != nil {
 	if err := os.MkdirAll(dir, 0o770); err != nil {
 		return err
 		return err
 	}
 	}
 	pruneOldCrashReports(dir, maxCrashReports-1)
 	pruneOldCrashReports(dir, maxCrashReports-1)
-	name := crashReportPrefix + time.Now().Format("20060102_150405_000000000") + crashReportSuffix
-	return os.WriteFile(filepath.Join(dir, name), m, 0o640)
+	pattern := crashReportPrefix + time.Now().Format("20060102_150405") + "_*" + crashReportSuffix
+	f, err := os.CreateTemp(dir, pattern)
+	if err != nil {
+		return err
+	}
+	_, err = f.Write(m)
+	if cerr := f.Close(); err == nil {
+		err = cerr
+	}
+	if err == nil {
+		err = os.Chmod(f.Name(), 0o640)
+	}
+	return err
 }
 }
 
 
 func pruneOldCrashReports(dir string, keep int) {
 func pruneOldCrashReports(dir string, keep int) {

BIN
windows_files/SSL/Win64OpenSSL_Light-3_6_0.exe


BIN
windows_files/SSL/Win64OpenSSL_Light-4_0_3.msi


BIN
winres/icon.png


+ 54 - 0
winres/winres.json

@@ -0,0 +1,54 @@
+{
+  "RT_GROUP_ICON": {
+    "APP": {
+      "0000": "icon.png"
+    }
+  },
+  "RT_MANIFEST": {
+    "#1": {
+      "0409": {
+        "identity": {
+          "name": "3x-ui",
+          "version": ""
+        },
+        "description": "3X-UI web panel for Xray-core",
+        "minimum-os": "win7",
+        "execution-level": "as invoker",
+        "ui-access": false,
+        "auto-elevate": false,
+        "dpi-awareness": "system",
+        "disable-theming": false,
+        "disable-window-filtering": false,
+        "high-resolution-scrolling-aware": false,
+        "ultra-high-resolution-scrolling-aware": false,
+        "long-path-aware": false,
+        "printer-driver-isolation": false,
+        "gdi-scaling": false,
+        "segment-heap": false,
+        "use-common-controls-v6": false
+      }
+    }
+  },
+  "RT_VERSION": {
+    "#1": {
+      "0000": {
+        "fixed": {
+          "file_version": "0.0.0.0",
+          "product_version": "0.0.0.0"
+        },
+        "info": {
+          "0409": {
+            "CompanyName": "MHSanaei",
+            "FileDescription": "3X-UI web panel for Xray-core",
+            "FileVersion": "",
+            "InternalName": "3x-ui",
+            "LegalCopyright": "3X-UI contributors, licensed under GPL-3.0",
+            "OriginalFilename": "3x-ui.exe",
+            "ProductName": "3X-UI",
+            "ProductVersion": ""
+          }
+        }
+      }
+    }
+  }
+}