7 Revize 5038fa1cec ... 4915d6b18d

Autor SHA1 Zpráva Datum
  MHSanaei 4915d6b18d refactor(frontend): move form-item hints from extra to tooltip před 19 hodinami
  MHSanaei d6cddaff12 fix(sub): emit JSON-subscription pinnedPeerCertSha256 as comma-separated string před 19 hodinami
  MHSanaei 3088e96493 fix(client): clear group when removed in the single-client editor před 21 hodinami
  MHSanaei c5d31de4e9 fix(service): serialize client/inbound writes to prevent Postgres deadlock před 21 hodinami
  MHSanaei 340d0df9fc fix(sub): wrap JSON-subscription SS/Trojan outbound in servers[] array před 22 hodinami
  MHSanaei 982595968d fix(inbound): regenerate SS-2022 client PSKs on method key-size change před 22 hodinami
  MHSanaei 21e9b94bb4 fix(sub): emit Shadowsocks http-header links as SIP002 obfs-local plugin před 22 hodinami
35 změnil soubory, kde provedl 950 přidání a 586 odebrání
  1. 12 0
      frontend/src/lib/xray/inbound-link.ts
  2. 1 1
      frontend/src/pages/inbounds/form/security/reality.tsx
  3. 7 7
      frontend/src/pages/nodes/NodeFormModal.tsx
  4. 156 155
      frontend/src/pages/xray/overrides/WarpModal.tsx
  5. 1 1
      internal/database/migrate_data.go
  6. 1 1
      internal/logger/logger_test.go
  7. 4 4
      internal/mtproto/manager.go
  8. 28 0
      internal/sub/characterization_test.go
  9. 2 2
      internal/sub/clash_external.go
  10. 4 4
      internal/sub/controller.go
  11. 13 5
      internal/sub/json_service.go
  12. 34 7
      internal/sub/json_service_test.go
  13. 12 0
      internal/sub/service.go
  14. 3 3
      internal/util/link/outbound.go
  15. 1 1
      internal/web/controller/xray_setting.go
  16. 2 3
      internal/web/middleware/validate.go
  17. 3 3
      internal/web/service/api_scale_postgres_test.go
  18. 40 25
      internal/web/service/client_bulk.go
  19. 55 0
      internal/web/service/client_crud.go
  20. 75 0
      internal/web/service/client_group_node_sync_test.go
  21. 220 173
      internal/web/service/client_inbound_apply.go
  22. 1 1
      internal/web/service/email/email.go
  23. 1 1
      internal/web/service/email/subscriber.go
  24. 174 153
      internal/web/service/inbound.go
  25. 2 2
      internal/web/service/integration/warp.go
  26. 3 2
      internal/web/service/metric_history.go
  27. 3 4
      internal/web/service/node.go
  28. 49 0
      internal/web/service/shadowsocks_client_key_test.go
  29. 4 4
      internal/web/service/sync_scale_postgres_test.go
  30. 1 1
      internal/web/service/tgbot/tgbot_event.go
  31. 20 0
      internal/web/service/traffic_writer.go
  32. 13 13
      internal/web/service/xray_setting.go
  33. 1 1
      internal/web/web.go
  34. 2 6
      internal/xray/mutation_audit_test.go
  35. 2 3
      tools/openapigen/emit_examples.go

+ 12 - 0
frontend/src/lib/xray/inbound-link.ts

@@ -595,6 +595,18 @@ export function genShadowsocksLink(input: GenShadowsocksLinkInput): string {
     applyExternalProxyTLSParams(externalProxy, params, security);
   }
 
+  // SIP002 clients (v2rayN) ignore type/headerType/host/path and only read
+  // `plugin`. Re-encode a TCP http header as obfs-local so they build a
+  // matching tcp/http outbound (v2rayN forces request path "/").
+  if ((stream.network ?? 'tcp') === 'tcp' && params.get('headerType') === 'http') {
+    const host = params.get('host') ?? '';
+    params.delete('type');
+    params.delete('headerType');
+    params.delete('host');
+    params.delete('path');
+    params.set('plugin', `obfs-local;obfs=http;obfs-host=${host}`);
+  }
+
   const isSS2022 = settings.method.substring(0, 4) === '2022';
   const isSSMultiUser = settings.method !== '2022-blake3-chacha20-poly1305';
   const passwords: string[] = [];

+ 1 - 1
frontend/src/pages/inbounds/form/security/reality.tsx

@@ -47,7 +47,7 @@ export default function RealityForm({
       </Form.Item>
       <Form.Item
         label={t('pages.inbounds.form.target')}
-        extra={t('pages.inbounds.form.realityTargetHint')}
+        tooltip={t('pages.inbounds.form.realityTargetHint')}
       >
         <Space.Compact block>
           <Form.Item

+ 7 - 7
frontend/src/pages/nodes/NodeFormModal.tsx

@@ -323,7 +323,7 @@ export default function NodeFormModal({
             label={t('pages.nodes.allowPrivateAddress')}
             name="allowPrivateAddress"
             valuePropName="checked"
-            extra={t('pages.nodes.allowPrivateAddressHint')}
+            tooltip={t('pages.nodes.allowPrivateAddressHint')}
           >
             <Switch />
           </Form.Item>
@@ -331,7 +331,7 @@ export default function NodeFormModal({
           <Form.Item
             label={t('pages.nodes.tlsVerifyMode')}
             name="tlsVerifyMode"
-            extra={t('pages.nodes.tlsVerifyModeHint')}
+            tooltip={t('pages.nodes.tlsVerifyModeHint')}
           >
             <Select
               disabled={scheme === 'http'}
@@ -366,7 +366,7 @@ export default function NodeFormModal({
             <Form.Item
               label={t('pages.nodes.pinnedCert')}
               name="pinnedCertSha256"
-              extra={t('pages.nodes.pinnedCertHint')}
+              tooltip={t('pages.nodes.pinnedCertHint')}
             >
               <Input.Search
                 placeholder={t('pages.nodes.pinnedCertPlaceholder')}
@@ -381,7 +381,7 @@ export default function NodeFormModal({
             label={t('pages.nodes.apiToken')}
             name="apiToken"
             rules={[antdRule(NodeFormSchema.shape.apiToken, t)]}
-            extra={t('pages.nodes.apiTokenHint')}
+            tooltip={t('pages.nodes.apiTokenHint')}
           >
             <Input.Password placeholder={t('pages.nodes.apiTokenPlaceholder')} />
           </Form.Item>
@@ -389,7 +389,7 @@ export default function NodeFormModal({
           <Form.Item
             label={t('pages.nodes.outboundTag')}
             name="outboundTag"
-            extra={t('pages.nodes.outboundTagHint')}
+            tooltip={t('pages.nodes.outboundTagHint')}
             getValueProps={(v) => ({ value: (v as string) || undefined })}
           >
             <Select
@@ -403,7 +403,7 @@ export default function NodeFormModal({
           <Form.Item
             label={t('pages.nodes.inboundSyncMode')}
             name="inboundSyncMode"
-            extra={t('pages.nodes.inboundSyncModeHint')}
+            tooltip={t('pages.nodes.inboundSyncModeHint')}
           >
             <Select
               options={[
@@ -417,7 +417,7 @@ export default function NodeFormModal({
             <Form.Item
               label={t('pages.nodes.inboundTags')}
               name="inboundTags"
-              extra={t('pages.nodes.inboundTagsHint')}
+              tooltip={t('pages.nodes.inboundTagsHint')}
             >
               <Select
                 mode="multiple"

+ 156 - 155
frontend/src/pages/xray/overrides/WarpModal.tsx

@@ -266,170 +266,171 @@ export default function WarpModal({
     <>
       {messageContextHolder}
       <Modal open={open} title="Cloudflare WARP" footer={null} onCancel={onClose}>
-      {!hasWarp ? (
-        <Button type="primary" loading={loading} icon={<ApiOutlined />} onClick={register}>
-          {t('pages.xray.warp.createAccount')}
-        </Button>
-      ) : (
-        <>
-          <table className="warp-data-table">
-            <tbody>
-              <tr className="row-odd">
-                <td>{t('pages.xray.warp.accessToken')}</td>
-                <td>{warpData?.access_token}</td>
-              </tr>
-              <tr>
-                <td>{t('pages.xray.warp.deviceId')}</td>
-                <td>{warpData?.device_id}</td>
-              </tr>
-              <tr className="row-odd">
-                <td>{t('pages.xray.warp.licenseKey')}</td>
-                <td>{warpData?.license_key}</td>
-              </tr>
-              <tr>
-                <td>{t('pages.xray.warp.privateKey')}</td>
-                <td>{warpData?.private_key}</td>
-              </tr>
-            </tbody>
-          </table>
-
-          <Button loading={loading} type="primary" danger className="mt-8" icon={<DeleteOutlined />} onClick={delConfig}>
-            {t('pages.xray.warp.deleteAccount')}
+        {!hasWarp ? (
+          <Button type="primary" loading={loading} icon={<ApiOutlined />} onClick={register}>
+            {t('pages.xray.warp.createAccount')}
           </Button>
+        ) : (
+          <>
+            <table className="warp-data-table">
+              <tbody>
+                <tr className="row-odd">
+                  <td>{t('pages.xray.warp.accessToken')}</td>
+                  <td>{warpData?.access_token}</td>
+                </tr>
+                <tr>
+                  <td>{t('pages.xray.warp.deviceId')}</td>
+                  <td>{warpData?.device_id}</td>
+                </tr>
+                <tr className="row-odd">
+                  <td>{t('pages.xray.warp.licenseKey')}</td>
+                  <td>{warpData?.license_key}</td>
+                </tr>
+                <tr>
+                  <td>{t('pages.xray.warp.privateKey')}</td>
+                  <td>{warpData?.private_key}</td>
+                </tr>
+              </tbody>
+            </table>
+
+            <Button loading={loading} type="primary" danger className="mt-8" icon={<DeleteOutlined />} onClick={delConfig}>
+              {t('pages.xray.warp.deleteAccount')}
+            </Button>
 
-          <Divider className="zero-margin">{t('pages.xray.warp.settings')}</Divider>
+            <Divider className="zero-margin">{t('pages.xray.warp.settings')}</Divider>
 
-          <Collapse
-            className="my-10"
-            items={[
-              {
-                key: '1',
-                label: t('pages.xray.warp.licenseKeyLabel'),
-                children: (
-                  <Form colon={false} labelCol={{ md: { span: 6 } }} wrapperCol={{ md: { span: 14 } }}>
-                    <Form.Item label={t('pages.xray.warp.key')}>
-                      <Input
-                        value={warpPlus}
-                        placeholder={t('pages.xray.warp.keyPlaceholder')}
-                        onChange={(e) => {
-                          setWarpPlus(e.target.value);
-                          setLicenseError('');
-                        }}
-                      />
-                      <div className="license-actions mt-8">
-                        <Button
-                          type="primary"
-                          disabled={warpPlus.length < 26}
-                          loading={loading}
-                          onClick={updateLicense}
-                        >
-                          {t('update')}
+            <Collapse
+              className="my-10"
+              items={[
+                {
+                  key: '1',
+                  label: t('pages.xray.warp.licenseKeyLabel'),
+                  children: (
+                    <Form colon={false} labelCol={{ md: { span: 6 } }} wrapperCol={{ md: { span: 14 } }}>
+                      <Form.Item label={t('pages.xray.warp.key')}>
+                        <Input
+                          value={warpPlus}
+                          placeholder={t('pages.xray.warp.keyPlaceholder')}
+                          onChange={(e) => {
+                            setWarpPlus(e.target.value);
+                            setLicenseError('');
+                          }}
+                        />
+                        <div className="license-actions mt-8">
+                          <Button
+                            type="primary"
+                            disabled={warpPlus.length < 26}
+                            loading={loading}
+                            onClick={updateLicense}
+                          >
+                            {t('update')}
+                          </Button>
+                          {licenseError && (
+                            <Alert title={licenseError} type="error" showIcon className="license-error" />
+                          )}
+                        </div>
+                      </Form.Item>
+                    </Form>
+                  ),
+                },
+                {
+                  key: '2',
+                  label: t('pages.xray.warp.autoUpdateIp', 'Auto Update IP Address'),
+                  children: (
+                    <Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 12 } }}>
+                      <Form.Item label={t('pages.xray.warp.intervalDays', 'Interval (Days)')}
+                        tooltip={t('pages.xray.warp.intervalDesc', '0 to disable. Changes IP address automatically.')}>
+                        <Input
+                          type="number"
+                          min={0}
+                          value={updateInterval}
+                          onChange={(e) => setUpdateInterval(Number(e.target.value))}
+                        />
+                        <Button className="mt-8" type="primary" loading={loading} onClick={saveInterval}>
+                          {t('save', 'Save')}
                         </Button>
-                        {licenseError && (
-                          <Alert title={licenseError} type="error" showIcon className="license-error" />
-                        )}
-                      </div>
-                    </Form.Item>
-                  </Form>
-                ),
-              },
-              {
-                key: '2',
-                label: t('pages.xray.warp.autoUpdateIp', 'Auto Update IP Address'),
-                children: (
-                  <Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 12 } }}>
-                    <Form.Item label={t('pages.xray.warp.intervalDays', 'Interval (Days)')} extra={t('pages.xray.warp.intervalDesc', '0 to disable. Changes IP address automatically.')}>
-                      <Input
-                        type="number"
-                        min={0}
-                        value={updateInterval}
-                        onChange={(e) => setUpdateInterval(Number(e.target.value))}
-                      />
-                      <Button className="mt-8" type="primary" loading={loading} onClick={saveInterval}>
-                        {t('save', 'Save')}
-                      </Button>
-                    </Form.Item>
-                  </Form>
-                ),
-              },
-            ]}
-          />
+                      </Form.Item>
+                    </Form>
+                  ),
+                },
+              ]}
+            />
 
-          <Divider className="zero-margin">{t('pages.xray.warp.accountInfo')}</Divider>
-          <div className="my-8">
-            <Button loading={loading} type="primary" icon={<SyncOutlined />} onClick={getConfig}>
-              {t('refresh')}
-            </Button>
-            <Button loading={loading} type="primary" className="ml-8" icon={<SyncOutlined />} onClick={changeIp}>
-              {t('pages.xray.warp.changeIp', 'Change IP')}
-            </Button>
-          </div>
+            <Divider className="zero-margin">{t('pages.xray.warp.accountInfo')}</Divider>
+            <div className="my-8">
+              <Button loading={loading} type="primary" icon={<SyncOutlined />} onClick={getConfig}>
+                {t('refresh')}
+              </Button>
+              <Button loading={loading} type="primary" className="ml-8" icon={<SyncOutlined />} onClick={changeIp}>
+                {t('pages.xray.warp.changeIp', 'Change IP')}
+              </Button>
+            </div>
 
-          {hasConfig && (
-            <>
-              <table className="warp-data-table">
-                <tbody>
-                  <tr className="row-odd">
-                    <td>{t('pages.xray.warp.deviceName')}</td>
-                    <td>{warpConfig?.name}</td>
-                  </tr>
-                  <tr>
-                    <td>{t('pages.xray.warp.deviceModel')}</td>
-                    <td>{warpConfig?.model}</td>
-                  </tr>
-                  <tr className="row-odd">
-                    <td>{t('pages.xray.warp.deviceEnabled')}</td>
-                    <td>{String(warpConfig?.enabled)}</td>
-                  </tr>
-                  {warpConfig?.account && (
-                    <>
-                      <tr>
-                        <td>{t('pages.xray.warp.accountType')}</td>
-                        <td>{warpConfig.account.account_type}</td>
-                      </tr>
-                      <tr className="row-odd">
-                        <td>{t('pages.xray.warp.role')}</td>
-                        <td>{warpConfig.account.role}</td>
-                      </tr>
-                      <tr>
-                        <td>{t('pages.xray.warp.warpPlusData')}</td>
-                        <td>{SizeFormatter.sizeFormat(warpConfig.account.premium_data)}</td>
-                      </tr>
-                      <tr className="row-odd">
-                        <td>{t('pages.xray.warp.quota')}</td>
-                        <td>{SizeFormatter.sizeFormat(warpConfig.account.quota)}</td>
-                      </tr>
-                      {warpConfig.account.usage != null && (
+            {hasConfig && (
+              <>
+                <table className="warp-data-table">
+                  <tbody>
+                    <tr className="row-odd">
+                      <td>{t('pages.xray.warp.deviceName')}</td>
+                      <td>{warpConfig?.name}</td>
+                    </tr>
+                    <tr>
+                      <td>{t('pages.xray.warp.deviceModel')}</td>
+                      <td>{warpConfig?.model}</td>
+                    </tr>
+                    <tr className="row-odd">
+                      <td>{t('pages.xray.warp.deviceEnabled')}</td>
+                      <td>{String(warpConfig?.enabled)}</td>
+                    </tr>
+                    {warpConfig?.account && (
+                      <>
                         <tr>
-                          <td>{t('pages.xray.warp.usage')}</td>
-                          <td>{SizeFormatter.sizeFormat(warpConfig.account.usage)}</td>
+                          <td>{t('pages.xray.warp.accountType')}</td>
+                          <td>{warpConfig.account.account_type}</td>
                         </tr>
-                      )}
-                    </>
-                  )}
-                </tbody>
-              </table>
+                        <tr className="row-odd">
+                          <td>{t('pages.xray.warp.role')}</td>
+                          <td>{warpConfig.account.role}</td>
+                        </tr>
+                        <tr>
+                          <td>{t('pages.xray.warp.warpPlusData')}</td>
+                          <td>{SizeFormatter.sizeFormat(warpConfig.account.premium_data)}</td>
+                        </tr>
+                        <tr className="row-odd">
+                          <td>{t('pages.xray.warp.quota')}</td>
+                          <td>{SizeFormatter.sizeFormat(warpConfig.account.quota)}</td>
+                        </tr>
+                        {warpConfig.account.usage != null && (
+                          <tr>
+                            <td>{t('pages.xray.warp.usage')}</td>
+                            <td>{SizeFormatter.sizeFormat(warpConfig.account.usage)}</td>
+                          </tr>
+                        )}
+                      </>
+                    )}
+                  </tbody>
+                </table>
 
-              <Divider className="my-10">{t('pages.xray.outbound.outboundStatus')}</Divider>
-              {warpOutboundIndex >= 0 ? (
-                <>
-                  <Tag color="green">{t('enabled')}</Tag>
-                  <Button type="primary" danger loading={loading} className="ml-8" onClick={resetOutbound}>
-                    {t('reset')}
-                  </Button>
-                </>
-              ) : (
-                <>
-                  <Tag color="orange">{t('disabled')}</Tag>
-                  <Button type="primary" loading={loading} className="ml-8" icon={<PlusOutlined />} onClick={addOutbound}>
-                    {t('pages.xray.warp.addOutbound')}
-                  </Button>
-                </>
-              )}
-            </>
-          )}
-        </>
-      )}
+                <Divider className="my-10">{t('pages.xray.outbound.outboundStatus')}</Divider>
+                {warpOutboundIndex >= 0 ? (
+                  <>
+                    <Tag color="green">{t('enabled')}</Tag>
+                    <Button type="primary" danger loading={loading} className="ml-8" onClick={resetOutbound}>
+                      {t('reset')}
+                    </Button>
+                  </>
+                ) : (
+                  <>
+                    <Tag color="orange">{t('disabled')}</Tag>
+                    <Button type="primary" loading={loading} className="ml-8" icon={<PlusOutlined />} onClick={addOutbound}>
+                      {t('pages.xray.warp.addOutbound')}
+                    </Button>
+                  </>
+                )}
+              </>
+            )}
+          </>
+        )}
       </Modal>
     </>
   );

+ 1 - 1
internal/database/migrate_data.go

@@ -223,7 +223,7 @@ func copyTable(src, dst *gorm.DB, mdl any) (int, error) {
 		}
 
 		rows := make([]map[string]any, n)
-		for i := 0; i < n; i++ {
+		for i := range n {
 			rv := reflect.Indirect(slice.Index(i))
 			row := make(map[string]any, len(columns))
 			for _, name := range columns {

+ 1 - 1
internal/logger/logger_test.go

@@ -12,7 +12,7 @@ func TestGetLogs_ReturnsAtMostC(t *testing.T) {
 	logBufferMu.Lock()
 	logBuffer = nil
 	logBufferMu.Unlock()
-	for i := 0; i < 5; i++ {
+	for i := range 5 {
 		addToBuffer("ERROR", fmt.Sprintf("m%d", i))
 	}
 

+ 4 - 4
internal/mtproto/manager.go

@@ -425,12 +425,12 @@ func parseMetricLine(line string) (name string, labels map[string]string, value
 		if end < brace {
 			return "", nil, 0, fmt.Errorf("malformed metric line")
 		}
-		for _, kv := range strings.Split(line[brace+1:end], ",") {
-			eq := strings.IndexByte(kv, '=')
-			if eq < 0 {
+		for kv := range strings.SplitSeq(line[brace+1:end], ",") {
+			before, after, ok := strings.Cut(kv, "=")
+			if !ok {
 				continue
 			}
-			labels[strings.TrimSpace(kv[:eq])] = strings.Trim(strings.TrimSpace(kv[eq+1:]), `"`)
+			labels[strings.TrimSpace(before)] = strings.Trim(strings.TrimSpace(after), `"`)
 		}
 		rest = strings.TrimSpace(line[end+1:])
 	} else {

+ 28 - 0
internal/sub/characterization_test.go

@@ -174,6 +174,34 @@ func TestChar_C3_ShadowsocksExternalProxy(t *testing.T) {
 	}
 }
 
+// A TCP http header on Shadowsocks must be emitted as a SIP002 obfs-local
+// plugin (what v2rayN parses), not the xray-native type/headerType/host/path
+// params (which SIP002 clients silently ignore).
+func TestShadowsocksTcpHttpHeaderUsesObfsLocalPlugin(t *testing.T) {
+	stream := `{
+		"network":"tcp","security":"none",
+		"tcpSettings":{"header":{"type":"http","request":{"path":["/"],"headers":{"Host":["test"]}}}}
+	}`
+	in := &model.Inbound{
+		Listen:         "203.0.113.1",
+		Port:           38143,
+		Protocol:       model.Shadowsocks,
+		Remark:         "ss",
+		Settings:       `{"method":"2022-blake3-aes-256-gcm","password":"inboundpw","clients":[{"password":"clientpw","email":"user"}]}`,
+		StreamSettings: stream,
+	}
+	s := &SubService{}
+	got := s.genShadowsocksLink(in, "user")
+	if !strings.Contains(got, "plugin=obfs-local%3Bobfs%3Dhttp%3Bobfs-host%3Dtest") {
+		t.Fatalf("expected obfs-local plugin param, got: %q", got)
+	}
+	for _, leak := range []string{"headerType=", "type=tcp", "host=test", "path="} {
+		if strings.Contains(got, leak) {
+			t.Fatalf("xray-native param %q must not leak into SS link: %q", leak, got)
+		}
+	}
+}
+
 // C6 — Hysteria2, TLS, 1 externalProxy entry with a cert pin. Guards that the
 // Hysteria generator stays on its own path (hex pinSHA256, not pcs) and is NOT
 // folded into the unified builder. Pin hex is derived, so Contains is used.

+ 2 - 2
internal/sub/clash_external.go

@@ -220,8 +220,8 @@ func clashStringList(v any) []string {
 }
 
 func stripCIDR(addr string) string {
-	if i := strings.IndexByte(addr, '/'); i >= 0 {
-		return addr[:i]
+	if before, _, ok := strings.Cut(addr, "/"); ok {
+		return before
 	}
 	return addr
 }

+ 4 - 4
internal/sub/controller.go

@@ -147,9 +147,9 @@ func (a *SUBController) subs(c *gin.Context) {
 	if err != nil || len(subs) == 0 {
 		writeSubError(c, err)
 	} else {
-		result := ""
+		var result strings.Builder
 		for _, sub := range subs {
-			result += sub + "\n"
+			result.WriteString(sub + "\n")
 		}
 
 		// If the request expects HTML (e.g., browser) or explicitly asked (?html=1 or ?view=html), render the info page here
@@ -180,9 +180,9 @@ func (a *SUBController) subs(c *gin.Context) {
 		a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, a.subSupportUrl, profileUrl, a.subAnnounce, a.subEnableRouting, a.subRoutingRules)
 
 		if a.subEncrypt {
-			c.String(200, base64.StdEncoding.EncodeToString([]byte(result)))
+			c.String(200, base64.StdEncoding.EncodeToString([]byte(result.String())))
 		} else {
-			c.String(200, result)
+			c.String(200, result.String())
 		}
 	}
 }

+ 13 - 5
internal/sub/json_service.go

@@ -297,8 +297,10 @@ func (s *SubJsonService) tlsData(tData map[string]any) map[string]any {
 	if ech, ok := tlsClientSettings["echConfigList"].(string); ok && ech != "" {
 		tlsData["echConfigList"] = ech
 	}
-	if pins, ok := tlsClientSettings["pinnedPeerCertSha256"].([]any); ok && len(pins) > 0 {
-		tlsData["pinnedPeerCertSha256"] = pins
+	// xray-core now parses pinnedPeerCertSha256 as a comma-separated string, not
+	// an array; emit the joined form so v2ray clients can import the config (#5401).
+	if pins, ok := pinnedSha256List(tlsClientSettings); ok {
+		tlsData["pinnedPeerCertSha256"] = strings.Join(pins, ",")
 	}
 	return tlsData
 }
@@ -425,16 +427,22 @@ func (s *SubJsonService) genServer(inbound *model.Inbound, streamSettings json_u
 	}
 	outbound.StreamSettings = streamSettings
 
-	settings := map[string]any{
+	// Wrap the endpoint in a "servers" array (the standard Xray schema for
+	// Shadowsocks/Trojan outbounds). The flat top-level form only parses on very
+	// recent xray-core; older bundled cores (e.g. in v2rayN) reject it, so SS
+	// links fail to connect. See genVnext/genVless for the VMess/VLESS shape.
+	server := map[string]any{
 		"address":  serverData[0].Address,
 		"port":     serverData[0].Port,
 		"password": serverData[0].Password,
 		"level":    8,
 	}
 	if inbound.Protocol == model.Shadowsocks {
-		settings["method"] = serverData[0].Method
+		server["method"] = serverData[0].Method
+	}
+	outbound.Settings = map[string]any{
+		"servers": []any{server},
 	}
-	outbound.Settings = settings
 
 	result, _ := json.MarshalIndent(outbound, "", "  ")
 	return result

+ 34 - 7
internal/sub/json_service_test.go

@@ -102,6 +102,22 @@ func TestSubJsonServiceNoFinalMaskWhenEmpty(t *testing.T) {
 	}
 }
 
+// xray-core parses tlsSettings.pinnedPeerCertSha256 as a comma-separated string;
+// the JSON subscription must emit that form, not an array, or v2ray clients fail
+// to import the config (#5401).
+func TestSubJsonServicePinnedCertJoinedToString(t *testing.T) {
+	svc := NewSubJsonService("", "", "", nil)
+	stream := svc.streamData(`{"network":"tcp","security":"tls","tlsSettings":{"serverName":"a.example.com","settings":{"pinnedPeerCertSha256":["aa11","bb22"]}}}`)
+
+	tls, _ := stream["tlsSettings"].(map[string]any)
+	if tls == nil {
+		t.Fatalf("tlsSettings missing: %#v", stream)
+	}
+	if got := tls["pinnedPeerCertSha256"]; got != "aa11,bb22" {
+		t.Fatalf("pinnedPeerCertSha256 = %#v, want comma-separated string \"aa11,bb22\"", got)
+	}
+}
+
 func TestSubJsonServiceVlessFlattened(t *testing.T) {
 	inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VLESS, Settings: `{"encryption":"none"}`}
 	client := model.Client{ID: "uuid-1", Flow: "xtls-rprx-vision"}
@@ -128,21 +144,32 @@ func TestSubJsonServiceVmessFlattened(t *testing.T) {
 	}
 }
 
-func TestSubJsonServiceServerFlattened(t *testing.T) {
+// Shadowsocks/Trojan outbounds must use the standard "servers" array so older
+// bundled xray-cores (e.g. v2rayN) parse them; the flat top-level form only
+// works on very recent xray-core.
+func TestSubJsonServiceServerUsesServersArray(t *testing.T) {
 	trojan := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.Trojan, Settings: `{}`}
 	client := model.Client{Password: "p4ss"}
 
 	settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genServer(trojan, nil, client, ""))
-	if _, ok := settings["servers"]; ok {
-		t.Fatal("trojan outbound must not use servers array")
+	server := firstServer(settings)
+	if server == nil {
+		t.Fatalf("trojan outbound must use a servers array, got: %#v", settings)
 	}
-	if settings["password"] != "p4ss" || settings["address"] != "1.2.3.4" {
-		t.Fatalf("flat trojan settings wrong: %#v", settings)
+	if server["password"] != "p4ss" || server["address"] != "1.2.3.4" {
+		t.Fatalf("trojan server entry wrong: %#v", server)
+	}
+	if _, ok := server["method"]; ok {
+		t.Fatalf("trojan must not carry method: %#v", server)
 	}
 
 	ss := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.Shadowsocks, Settings: `{"method":"aes-256-gcm"}`}
 	ssSettings := outboundSettings(t, NewSubJsonService("", "", "", nil).genServer(ss, nil, client, ""))
-	if ssSettings["method"] != "aes-256-gcm" {
-		t.Fatalf("flat shadowsocks must carry method: %#v", ssSettings)
+	ssServer := firstServer(ssSettings)
+	if ssServer == nil {
+		t.Fatalf("shadowsocks outbound must use a servers array, got: %#v", ssSettings)
+	}
+	if ssServer["method"] != "aes-256-gcm" {
+		t.Fatalf("shadowsocks server entry must carry method: %#v", ssServer)
 	}
 }

+ 12 - 0
internal/sub/service.go

@@ -704,6 +704,18 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st
 		applyShareTLSParams(stream, params)
 	}
 
+	// SIP002 clients (v2rayN) ignore the xray-native type/headerType/host/path
+	// params and only read `plugin`. Re-encode a TCP http header as obfs-local so
+	// they build a matching tcp/http outbound (v2rayN forces request path "/").
+	if streamNetwork == "tcp" && params["headerType"] == "http" {
+		host := params["host"]
+		delete(params, "type")
+		delete(params, "headerType")
+		delete(params, "host")
+		delete(params, "path")
+		params["plugin"] = "obfs-local;obfs=http;obfs-host=" + host
+	}
+
 	encPart := fmt.Sprintf("%s:%s", method, clients[clientIndex].Password)
 	if method[0] == '2' {
 		encPart = fmt.Sprintf("%s:%s:%s", method, inboundPassword, clients[clientIndex].Password)

+ 3 - 3
internal/util/link/outbound.go

@@ -397,11 +397,11 @@ func parseShadowsocks(link string) (*ParseResult, error) {
 }
 
 func splitMethodPass(userInfo string) (string, string) {
-	colon := strings.Index(userInfo, ":")
-	if colon < 0 {
+	before, after, ok := strings.Cut(userInfo, ":")
+	if !ok {
 		return "2022-blake3-aes-128-gcm", userInfo // guess
 	}
-	return userInfo[:colon], userInfo[colon+1:]
+	return before, after
 }
 
 // --- hysteria2 ---

+ 1 - 1
internal/web/controller/xray_setting.go

@@ -325,7 +325,7 @@ func (a *XraySettingController) testOutbounds(c *gin.Context) {
 func (a *XraySettingController) balancerStatus(c *gin.Context) {
 	raw := c.PostForm("tags")
 	var tags []string
-	for _, tag := range strings.Split(raw, ",") {
+	for tag := range strings.SplitSeq(raw, ",") {
 		if tag = strings.TrimSpace(tag); tag != "" {
 			tags = append(tags, tag)
 		}

+ 2 - 3
internal/web/middleware/validate.go

@@ -80,8 +80,7 @@ type ValidationPayload struct {
 func writeBindFailure(c *gin.Context, err error) {
 	payload := ValidationPayload{Issues: []FieldIssue{}, Message: err.Error()}
 
-	var ve validator.ValidationErrors
-	if errors.As(err, &ve) {
+	if ve, ok := errors.AsType[validator.ValidationErrors](err); ok {
 		payload.Issues = make([]FieldIssue, 0, len(ve))
 		for _, fe := range ve {
 			payload.Issues = append(payload.Issues, FieldIssue{
@@ -102,7 +101,7 @@ func writeBindFailure(c *gin.Context, err error) {
 
 func init() {
 	validate.RegisterTagNameFunc(func(fld reflect.StructField) string {
-		name := strings.SplitN(fld.Tag.Get("json"), ",", 2)[0]
+		name, _, _ := strings.Cut(fld.Tag.Get("json"), ",")
 		if name == "-" || name == "" {
 			return fld.Name
 		}

+ 3 - 3
internal/web/service/api_scale_postgres_test.go

@@ -92,7 +92,7 @@ func TestAllAPIsPostgresScale(t *testing.T) {
 			db.Exec("ANALYZE")
 
 			emails := make([]string, n)
-			for i := 0; i < n; i++ {
+			for i := range n {
 				emails[i] = clients[i].Email
 			}
 			emailsM := emails[:m]
@@ -196,7 +196,7 @@ func TestGetClientTrafficByEmailABScale(t *testing.T) {
 			targets := []string{clients[0].Email, clients[n/2].Email, clients[n-1].Email}
 
 			start := time.Now()
-			for i := 0; i < reps; i++ {
+			for i := range reps {
 				if _, err := inboundSvc.GetClientTrafficByEmail(targets[i%len(targets)]); err != nil {
 					t.Fatalf("new GetClientTrafficByEmail: %v", err)
 				}
@@ -204,7 +204,7 @@ func TestGetClientTrafficByEmailABScale(t *testing.T) {
 			newDur := time.Since(start) / reps
 
 			start = time.Now()
-			for i := 0; i < reps; i++ {
+			for i := range reps {
 				if err := oldImpl(targets[i%len(targets)]); err != nil {
 					t.Fatalf("old GetClientTrafficByEmail: %v", err)
 				}

+ 40 - 25
internal/web/service/client_bulk.go

@@ -545,8 +545,9 @@ func (s *ClientService) bulkAdjustInboundClients(
 		}
 	}
 
-	db := database.GetDB()
-	txErr := db.Transaction(func(tx *gorm.DB) error {
+	// Serialize against the traffic poll to avoid the cross-transaction
+	// lock-order deadlock on inbounds/client_records (runSerializedTx).
+	txErr := runSerializedTx(func(tx *gorm.DB) error {
 		if err := tx.Save(oldInbound).Error; err != nil {
 			return err
 		}
@@ -685,25 +686,32 @@ func (s *ClientService) BulkDelete(inboundSvc *InboundService, emails []string,
 	}
 
 	if len(successIds) > 0 {
-		for _, batch := range chunkInts(successIds, sqlInChunk) {
-			if err := db.Where("client_id IN ?", batch).Delete(&model.ClientInbound{}).Error; err != nil {
-				return result, needRestart, err
-			}
-		}
-		if !keepTraffic && len(successEmails) > 0 {
-			for _, batch := range chunkStrings(successEmails, sqlInChunk) {
-				if err := db.Where("email IN ?", batch).Delete(&xray.ClientTraffic{}).Error; err != nil {
-					return result, needRestart, err
+		// Serialize the row cleanup against the traffic poll to avoid the
+		// cross-transaction lock-order deadlock on client_traffics/inbounds.
+		if err := runSerializedTx(func(tx *gorm.DB) error {
+			for _, batch := range chunkInts(successIds, sqlInChunk) {
+				if e := tx.Where("client_id IN ?", batch).Delete(&model.ClientInbound{}).Error; e != nil {
+					return e
 				}
-				if err := db.Where("client_email IN ?", batch).Delete(&model.InboundClientIps{}).Error; err != nil {
-					return result, needRestart, err
+			}
+			if !keepTraffic && len(successEmails) > 0 {
+				for _, batch := range chunkStrings(successEmails, sqlInChunk) {
+					if e := tx.Where("email IN ?", batch).Delete(&xray.ClientTraffic{}).Error; e != nil {
+						return e
+					}
+					if e := tx.Where("client_email IN ?", batch).Delete(&model.InboundClientIps{}).Error; e != nil {
+						return e
+					}
 				}
 			}
-		}
-		for _, batch := range chunkInts(successIds, sqlInChunk) {
-			if err := db.Where("id IN ?", batch).Delete(&model.ClientRecord{}).Error; err != nil {
-				return result, needRestart, err
+			for _, batch := range chunkInts(successIds, sqlInChunk) {
+				if e := tx.Where("id IN ?", batch).Delete(&model.ClientRecord{}).Error; e != nil {
+					return e
+				}
 			}
+			return nil
+		}); err != nil {
+			return result, needRestart, err
 		}
 	}
 
@@ -850,14 +858,19 @@ func (s *ClientService) bulkDelInboundClients(
 			}
 		}
 		if len(purge) > 0 {
-			if delErr := inboundSvc.delClientIPsByEmails(db, purge); delErr != nil {
-				logger.Error("Error in delete client IPs")
-				for _, email := range purge {
-					res.perEmailSkipped[email] = delErr.Error()
-					delete(foundEmails, email)
+			// Serialize the IP/stat purge against the traffic poll to avoid the
+			// cross-transaction lock-order deadlock on client_traffics.
+			if delErr := runSerializedTx(func(tx *gorm.DB) error {
+				if e := inboundSvc.delClientIPsByEmails(tx, purge); e != nil {
+					logger.Error("Error in delete client IPs")
+					return e
+				}
+				if e := inboundSvc.delClientStatsByEmails(tx, purge); e != nil {
+					logger.Error("Delete stats Data Error")
+					return e
 				}
-			} else if delErr := inboundSvc.delClientStatsByEmails(db, purge); delErr != nil {
-				logger.Error("Delete stats Data Error")
+				return nil
+			}); delErr != nil {
 				for _, email := range purge {
 					res.perEmailSkipped[email] = delErr.Error()
 					delete(foundEmails, email)
@@ -909,7 +922,9 @@ func (s *ClientService) bulkDelInboundClients(
 		}
 	}
 
-	txErr := db.Transaction(func(tx *gorm.DB) error {
+	// Serialize against the traffic poll to avoid the cross-transaction
+	// lock-order deadlock on inbounds/client_records (runSerializedTx).
+	txErr := runSerializedTx(func(tx *gorm.DB) error {
 		if err := tx.Save(oldInbound).Error; err != nil {
 			return err
 		}

+ 55 - 0
internal/web/service/client_crud.go

@@ -193,6 +193,49 @@ func shadowsocksKeyBytes(method string) int {
 	return 0
 }
 
+// normalizeShadowsocksClientKeys rewrites any Shadowsocks-2022 client password
+// whose decoded length no longer matches settings.method, which happens after the
+// inbound method is switched between ciphers of different key sizes (e.g.
+// aes-256↔aes-128). A wrong-length uPSK makes xray reject the user, so the link
+// fails to connect; regenerating restores a valid key (clients must re-fetch).
+// Non-Shadowsocks / legacy-SS settings pass through unchanged.
+func normalizeShadowsocksClientKeys(settings string) (string, bool) {
+	method := shadowsocksMethodFromSettings(settings)
+	if shadowsocksKeyBytes(method) == 0 {
+		return settings, false
+	}
+	var m map[string]any
+	if err := json.Unmarshal([]byte(settings), &m); err != nil {
+		return settings, false
+	}
+	clients, ok := m["clients"].([]any)
+	if !ok {
+		return settings, false
+	}
+	changed := false
+	for i := range clients {
+		c, ok := clients[i].(map[string]any)
+		if !ok {
+			continue
+		}
+		if pw, _ := c["password"].(string); validShadowsocksClientKey(method, pw) {
+			continue
+		}
+		c["password"] = randomShadowsocksClientKey(method)
+		clients[i] = c
+		changed = true
+	}
+	if !changed {
+		return settings, false
+	}
+	m["clients"] = clients
+	bs, err := json.MarshalIndent(m, "", "  ")
+	if err != nil {
+		return settings, false
+	}
+	return string(bs), true
+}
+
 func applyShadowsocksClientMethod(clients []any, settings map[string]any) {
 	method, _ := settings["method"].(string)
 	is2022 := strings.HasPrefix(method, "2022-blake3-")
@@ -353,6 +396,18 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
 		return needRestart, err
 	}
 
+	// Persist the group explicitly. SyncInbound deliberately preserves the
+	// stored group when the inbound settings carry none — so a node snapshot or a
+	// group-less settings rebuild can't wipe it (see SyncInbound + its tests).
+	// That guard also meant clearing the group in the client editor never took
+	// effect. The editor always round-trips the field, so apply it here,
+	// including the empty string that removes the client from its group.
+	if err := database.GetDB().Model(&model.ClientRecord{}).
+		Where("id = ?", id).
+		UpdateColumn("group_name", updated.Group).Error; err != nil {
+		return needRestart, err
+	}
+
 	if err := database.GetDB().Model(&model.ClientRecord{}).
 		Where("id = ?", id).
 		UpdateColumn("updated_at", time.Now().UnixMilli()).Error; err != nil {

+ 75 - 0
internal/web/service/client_group_node_sync_test.go

@@ -2,6 +2,7 @@ package service
 
 import (
 	"path/filepath"
+	"strings"
 	"testing"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
@@ -116,3 +117,77 @@ func TestSyncInbound_KeepsGroupWhenIncomingEmpty(t *testing.T) {
 		t.Errorf("group must survive a group-less settings rebuild (it is managed via the Groups page, not Xray settings): got %q, want %q", row.Group, wantGroup)
 	}
 }
+
+// Removing the group in the client editor and saving must clear group_name and
+// drop the settings "group" key, even though SyncInbound preserves a group on a
+// group-less rebuild. The editor round-trips the field, so ClientService.Update
+// applies it explicitly.
+func TestClientUpdate_ClearsGroup(t *testing.T) {
+	dbDir := t.TempDir()
+	t.Setenv("XUI_DB_FOLDER", dbDir)
+	if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+
+	db := database.GetDB()
+
+	const email = "[email protected]"
+	const uid = "ce8d33df-3a64-4f10-8f9b-91c3a8e0c005"
+	const wantGroup = "vip"
+
+	ib := &model.Inbound{
+		UserId:   1,
+		Tag:      "vless-clear",
+		Enable:   true,
+		Port:     20003,
+		Protocol: model.VLESS,
+		Settings: `{"clients":[{"email":"` + email + `","id":"` + uid + `","enable":true,"group":"` + wantGroup + `"}]}`,
+	}
+	if err := db.Create(ib).Error; err != nil {
+		t.Fatalf("create inbound: %v", err)
+	}
+
+	svc := ClientService{}
+	inboundSvc := &InboundService{}
+
+	// Seed the client record + inbound link from the settings.
+	seedClients, err := inboundSvc.GetClients(ib)
+	if err != nil {
+		t.Fatalf("GetClients: %v", err)
+	}
+	if err := svc.SyncInbound(nil, ib.Id, seedClients); err != nil {
+		t.Fatalf("seed SyncInbound: %v", err)
+	}
+
+	var rec model.ClientRecord
+	if err := db.Where("email = ?", email).First(&rec).Error; err != nil {
+		t.Fatalf("lookup seeded record: %v", err)
+	}
+	if rec.Group != wantGroup {
+		t.Fatalf("setup: group not seeded, got %q", rec.Group)
+	}
+
+	// Edit the client and remove the group.
+	updated := *rec.ToClient()
+	updated.Group = ""
+	if _, err := svc.Update(inboundSvc, rec.Id, updated); err != nil {
+		t.Fatalf("Update (clear group): %v", err)
+	}
+
+	var after model.ClientRecord
+	if err := db.Where("email = ?", email).First(&after).Error; err != nil {
+		t.Fatalf("lookup record after update: %v", err)
+	}
+	if after.Group != "" {
+		t.Errorf("group not cleared after editor removed it: got %q, want empty", after.Group)
+	}
+
+	var ibAfter model.Inbound
+	if err := db.First(&ibAfter, ib.Id).Error; err != nil {
+		t.Fatalf("lookup inbound after update: %v", err)
+	}
+	if strings.Contains(ibAfter.Settings, `"group"`) {
+		t.Errorf("inbound settings still carry a group key after removal: %s", ibAfter.Settings)
+	}
+}

+ 220 - 173
internal/web/service/client_inbound_apply.go

@@ -12,7 +12,10 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/random"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
 	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+
+	"gorm.io/gorm"
 )
 
 // delInboundClients removes several clients from a single inbound in one pass:
@@ -105,40 +108,79 @@ func (s *ClientService) delInboundClients(inboundSvc *InboundService, inboundId
 
 	needRestart := false
 	markDirty := false
+
+	// Read each client's live state before the DB write (DelClientStat would
+	// erase the enable flag we need to decide on a runtime removal).
+	type delTarget struct {
+		email       string
+		emailShared bool
+		notDepleted bool
+		needApiDel  bool
+	}
+	targets := make([]delTarget, 0, len(removed))
 	for _, r := range removed {
 		email := r.email
 		emailShared := sharedSet[strings.ToLower(strings.TrimSpace(email))]
-		if !emailShared && !keepTraffic {
-			if err := inboundSvc.DelClientIPs(db, email); err != nil {
-				logger.Error("Error in delete client IPs")
-				return needRestart, err
-			}
-		}
+		notDepleted := false
 		if len(email) > 0 {
 			var enables []bool
 			if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).Limit(1).Pluck("enable", &enables).Error; err != nil {
 				logger.Error("Get stats error")
 				return needRestart, err
 			}
-			notDepleted := len(enables) > 0 && enables[0]
-			if !emailShared && !keepTraffic {
-				if err := inboundSvc.DelClientStat(db, email); err != nil {
+			notDepleted = len(enables) > 0 && enables[0]
+		}
+		targets = append(targets, delTarget{email: email, emailShared: emailShared, notDepleted: notDepleted, needApiDel: r.needApiDel})
+	}
+
+	// Persist the batch deletion atomically, serialized against the traffic poll
+	// to avoid the cross-transaction lock-order deadlock (runSerializedTx).
+	if txErr := runSerializedTx(func(tx *gorm.DB) error {
+		for _, t := range targets {
+			if t.emailShared || keepTraffic {
+				continue
+			}
+			if e := inboundSvc.DelClientIPs(tx, t.email); e != nil {
+				logger.Error("Error in delete client IPs")
+				return e
+			}
+			if len(t.email) > 0 {
+				if e := inboundSvc.DelClientStat(tx, t.email); e != nil {
 					logger.Error("Delete stats Data Error")
-					return needRestart, err
+					return e
 				}
 			}
-			if r.needApiDel && notDepleted && oldInbound.NodeID == nil {
+		}
+		if e := tx.Save(oldInbound).Error; e != nil {
+			return e
+		}
+		finalClients, gcErr := inboundSvc.GetClients(oldInbound)
+		if gcErr != nil {
+			return gcErr
+		}
+		return s.SyncInbound(tx, inboundId, finalClients)
+	}); txErr != nil {
+		return needRestart, txErr
+	}
+
+	// Apply runtime deletes after commit — outside the serialized writer so a
+	// slow node call can't stall traffic accounting.
+	for _, t := range targets {
+		if len(t.email) == 0 {
+			continue
+		}
+		if oldInbound.NodeID == nil {
+			if t.needApiDel && t.notDepleted {
 				rt, rterr := inboundSvc.runtimeFor(oldInbound)
 				if rterr != nil {
 					needRestart = true
-				} else if err1 := rt.RemoveUser(context.Background(), oldInbound, email); err1 != nil {
-					if !strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", email)) {
+				} else if err1 := rt.RemoveUser(context.Background(), oldInbound, t.email); err1 != nil {
+					if !strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", t.email)) {
 						needRestart = true
 					}
 				}
 			}
-		}
-		if oldInbound.NodeID != nil && len(email) > 0 {
+		} else {
 			rt, push, dirty, perr := inboundSvc.nodePushPlan(oldInbound)
 			if perr != nil {
 				return needRestart, perr
@@ -147,7 +189,7 @@ func (s *ClientService) delInboundClients(inboundSvc *InboundService, inboundId
 				markDirty = true
 			}
 			if push {
-				if err1 := rt.DeleteUser(context.Background(), oldInbound, email); err1 != nil {
+				if err1 := rt.DeleteUser(context.Background(), oldInbound, t.email); err1 != nil {
 					logger.Warning("Error in deleting client on", rt.Name(), ":", err1)
 					markDirty = true
 				}
@@ -155,16 +197,6 @@ func (s *ClientService) delInboundClients(inboundSvc *InboundService, inboundId
 		}
 	}
 
-	if err := db.Save(oldInbound).Error; err != nil {
-		return needRestart, err
-	}
-	finalClients, gcErr := inboundSvc.GetClients(oldInbound)
-	if gcErr != nil {
-		return needRestart, gcErr
-	}
-	if err := s.SyncInbound(db, inboundId, finalClients); err != nil {
-		return needRestart, err
-	}
 	if markDirty && oldInbound.NodeID != nil {
 		if dErr := (&NodeService{}).MarkNodeDirty(*oldInbound.NodeID); dErr != nil {
 			logger.Warning("mark node dirty failed:", dErr)
@@ -300,32 +332,42 @@ func (s *ClientService) addInboundClient(inboundSvc *InboundService, data *model
 
 	oldInbound.Settings = string(newSettings)
 
-	db := database.GetDB()
-	tx := db.Begin()
-
+	needRestart := false
 	markDirty := false
-	defer func() {
-		if err != nil {
-			tx.Rollback()
-			return
-		}
-		tx.Commit()
-		if markDirty && oldInbound.NodeID != nil {
-			if dErr := (&NodeService{}).MarkNodeDirty(*oldInbound.NodeID); dErr != nil {
-				logger.Warning("mark node dirty failed:", dErr)
-			}
-		}
-	}()
 
-	needRestart := false
 	rt, push, dirty, perr := inboundSvc.nodePushPlan(oldInbound)
 	if perr != nil {
-		err = perr
-		return false, err
+		return false, perr
 	}
 	if dirty {
 		markDirty = true
 	}
+
+	// Persist client stats + inbound atomically, serialized against the traffic
+	// poll to avoid the cross-transaction lock-order deadlock (runSerializedTx).
+	if txErr := runSerializedTx(func(tx *gorm.DB) error {
+		for i := range clients {
+			if len(clients[i].Email) == 0 {
+				continue
+			}
+			if e := inboundSvc.AddClientStat(tx, data.Id, &clients[i]); e != nil {
+				return e
+			}
+		}
+		if e := tx.Save(oldInbound).Error; e != nil {
+			return e
+		}
+		finalClients, gcErr := inboundSvc.GetClients(oldInbound)
+		if gcErr != nil {
+			return gcErr
+		}
+		return s.SyncInbound(tx, oldInbound.Id, finalClients)
+	}); txErr != nil {
+		return false, txErr
+	}
+
+	// Apply to the running runtime after commit — outside the serialized writer
+	// so a slow node call can't stall traffic accounting.
 	if oldInbound.NodeID == nil {
 		if !push {
 			needRestart = true
@@ -335,7 +377,6 @@ func (s *ClientService) addInboundClient(inboundSvc *InboundService, data *model
 					needRestart = true
 					continue
 				}
-				inboundSvc.AddClientStat(tx, data.Id, &client)
 				if !client.Enable {
 					continue
 				}
@@ -362,9 +403,6 @@ func (s *ClientService) addInboundClient(inboundSvc *InboundService, data *model
 		}
 	} else {
 		for _, client := range clients {
-			if len(client.Email) > 0 {
-				inboundSvc.AddClientStat(tx, data.Id, &client)
-			}
 			if push {
 				if err1 := rt.AddClient(context.Background(), oldInbound, client); err1 != nil {
 					logger.Warning("Error in adding client on", rt.Name(), ":", err1)
@@ -375,16 +413,10 @@ func (s *ClientService) addInboundClient(inboundSvc *InboundService, data *model
 		}
 	}
 
-	if err = tx.Save(oldInbound).Error; err != nil {
-		return false, err
-	}
-	finalClients, gcErr := inboundSvc.GetClients(oldInbound)
-	if gcErr != nil {
-		err = gcErr
-		return false, err
-	}
-	if err = s.SyncInbound(tx, oldInbound.Id, finalClients); err != nil {
-		return false, err
+	if markDirty && oldInbound.NodeID != nil {
+		if dErr := (&NodeService{}).MarkNodeDirty(*oldInbound.NodeID); dErr != nil {
+			logger.Warning("mark node dirty failed:", dErr)
+		}
 	}
 	return needRestart, nil
 }
@@ -519,87 +551,98 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
 	}
 
 	oldInbound.Settings = string(newSettings)
-	db := database.GetDB()
-	tx := db.Begin()
 
+	needRestart := false
 	markDirty := false
-	defer func() {
-		if err != nil {
-			tx.Rollback()
-			return
+
+	// Resolve the push plan before the DB write so a node-state lookup failure
+	// still aborts the whole update without committing anything (it used to roll
+	// the transaction back). nodePushPlan only reads, so order doesn't matter.
+	var rt runtime.Runtime
+	var push bool
+	if len(oldEmail) > 0 {
+		var dirty bool
+		var perr error
+		rt, push, dirty, perr = inboundSvc.nodePushPlan(oldInbound)
+		if perr != nil {
+			return false, perr
 		}
-		tx.Commit()
-		if markDirty && oldInbound.NodeID != nil {
-			if dErr := (&NodeService{}).MarkNodeDirty(*oldInbound.NodeID); dErr != nil {
-				logger.Warning("mark node dirty failed:", dErr)
-			}
+		if dirty {
+			markDirty = true
 		}
-	}()
-
-	if len(clients[0].Email) > 0 {
-		if len(oldEmail) > 0 {
-			emailUnchanged := strings.EqualFold(oldEmail, clients[0].Email)
-			targetExists := int64(0)
-			if !emailUnchanged {
-				if err = tx.Model(xray.ClientTraffic{}).Where("email = ?", clients[0].Email).Count(&targetExists).Error; err != nil {
-					return false, err
-				}
-			}
-			if emailUnchanged || targetExists == 0 {
-				err = inboundSvc.UpdateClientStat(tx, oldEmail, &clients[0])
-				if err != nil {
-					return false, err
-				}
-				err = inboundSvc.UpdateClientIPs(tx, oldEmail, clients[0].Email)
-				if err != nil {
-					return false, err
-				}
-			} else {
-				stillUsed, sErr := inboundSvc.emailUsedByOtherInbounds(oldEmail, data.Id)
-				if sErr != nil {
-					return false, sErr
+	}
+
+	// Persist client stats + inbound atomically, serialized against the traffic
+	// poll to avoid the cross-transaction lock-order deadlock (runSerializedTx).
+	if txErr := runSerializedTx(func(tx *gorm.DB) error {
+		if len(clients[0].Email) > 0 {
+			if len(oldEmail) > 0 {
+				emailUnchanged := strings.EqualFold(oldEmail, clients[0].Email)
+				targetExists := int64(0)
+				if !emailUnchanged {
+					if e := tx.Model(xray.ClientTraffic{}).Where("email = ?", clients[0].Email).Count(&targetExists).Error; e != nil {
+						return e
+					}
 				}
-				if !stillUsed {
-					if err = inboundSvc.DelClientStat(tx, oldEmail); err != nil {
-						return false, err
+				if emailUnchanged || targetExists == 0 {
+					if e := inboundSvc.UpdateClientStat(tx, oldEmail, &clients[0]); e != nil {
+						return e
+					}
+					if e := inboundSvc.UpdateClientIPs(tx, oldEmail, clients[0].Email); e != nil {
+						return e
+					}
+				} else {
+					stillUsed, sErr := inboundSvc.emailUsedByOtherInbounds(oldEmail, data.Id)
+					if sErr != nil {
+						return sErr
 					}
-					if err = inboundSvc.DelClientIPs(tx, oldEmail); err != nil {
-						return false, err
+					if !stillUsed {
+						if e := inboundSvc.DelClientStat(tx, oldEmail); e != nil {
+							return e
+						}
+						if e := inboundSvc.DelClientIPs(tx, oldEmail); e != nil {
+							return e
+						}
+					}
+					if e := inboundSvc.UpdateClientStat(tx, clients[0].Email, &clients[0]); e != nil {
+						return e
 					}
 				}
-				if err = inboundSvc.UpdateClientStat(tx, clients[0].Email, &clients[0]); err != nil {
-					return false, err
+			} else {
+				if e := inboundSvc.AddClientStat(tx, data.Id, &clients[0]); e != nil {
+					return e
 				}
 			}
 		} else {
-			inboundSvc.AddClientStat(tx, data.Id, &clients[0])
-		}
-	} else {
-		stillUsed, err := inboundSvc.emailUsedByOtherInbounds(oldEmail, data.Id)
-		if err != nil {
-			return false, err
-		}
-		if !stillUsed {
-			err = inboundSvc.DelClientStat(tx, oldEmail)
-			if err != nil {
-				return false, err
+			stillUsed, sErr := inboundSvc.emailUsedByOtherInbounds(oldEmail, data.Id)
+			if sErr != nil {
+				return sErr
 			}
-			err = inboundSvc.DelClientIPs(tx, oldEmail)
-			if err != nil {
-				return false, err
+			if !stillUsed {
+				if e := inboundSvc.DelClientStat(tx, oldEmail); e != nil {
+					return e
+				}
+				if e := inboundSvc.DelClientIPs(tx, oldEmail); e != nil {
+					return e
+				}
 			}
 		}
-	}
-	needRestart := false
-	if len(oldEmail) > 0 {
-		rt, push, dirty, perr := inboundSvc.nodePushPlan(oldInbound)
-		if perr != nil {
-			err = perr
-			return false, err
+
+		if e := tx.Save(oldInbound).Error; e != nil {
+			return e
 		}
-		if dirty {
-			markDirty = true
+		finalClients, gcErr := inboundSvc.GetClients(oldInbound)
+		if gcErr != nil {
+			return gcErr
 		}
+		return s.SyncInbound(tx, oldInbound.Id, finalClients)
+	}); txErr != nil {
+		return false, txErr
+	}
+
+	// Apply to the running runtime after the DB is committed — outside the
+	// serialized writer so a slow node call can't stall traffic accounting.
+	if len(oldEmail) > 0 {
 		if oldInbound.NodeID == nil {
 			if !push {
 				needRestart = true
@@ -647,16 +690,11 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
 		logger.Debug("Client old email not found")
 		needRestart = true
 	}
-	if err = tx.Save(oldInbound).Error; err != nil {
-		return false, err
-	}
-	finalClients, gcErr := inboundSvc.GetClients(oldInbound)
-	if gcErr != nil {
-		err = gcErr
-		return false, err
-	}
-	if err = s.SyncInbound(tx, oldInbound.Id, finalClients); err != nil {
-		return false, err
+
+	if markDirty && oldInbound.NodeID != nil {
+		if dErr := (&NodeService{}).MarkNodeDirty(*oldInbound.NodeID); dErr != nil {
+			logger.Warning("mark node dirty failed:", dErr)
+		}
 	}
 	return needRestart, nil
 }
@@ -718,41 +756,67 @@ func (s *ClientService) DelInboundClientByEmail(inboundSvc *InboundService, inbo
 		return false, err
 	}
 
-	if !emailShared && !keepTraffic {
-		if err := inboundSvc.DelClientIPs(db, email); err != nil {
-			logger.Error("Error in delete client IPs")
-			return false, err
+	needRestart := false
+	markDirty := false
+
+	// Decide what to delete and the push plan before the serialized DB write —
+	// these are reads, and nodePushPlan failing should abort before committing.
+	delStat := false
+	if len(email) > 0 && !emailShared && !keepTraffic {
+		traffic, tErr := inboundSvc.GetClientTrafficByEmail(email)
+		if tErr != nil {
+			return false, tErr
 		}
+		delStat = traffic != nil
 	}
 
-	needRestart := false
-	markDirty := false
+	var rt runtime.Runtime
+	var push bool
+	if len(email) > 0 && !emailShared && (oldInbound.NodeID != nil || needApiDel) {
+		r, p, dirty, perr := inboundSvc.nodePushPlan(oldInbound)
+		if perr != nil {
+			return false, perr
+		}
+		rt, push = r, p
+		if dirty {
+			markDirty = true
+		}
+	}
 
-	if len(email) > 0 && !emailShared {
-		if !keepTraffic {
-			traffic, err := inboundSvc.GetClientTrafficByEmail(email)
-			if err != nil {
-				return false, err
+	// Persist the deletion atomically, serialized against the traffic poll to
+	// avoid the cross-transaction lock-order deadlock (runSerializedTx).
+	if txErr := runSerializedTx(func(tx *gorm.DB) error {
+		if !emailShared && !keepTraffic {
+			if e := inboundSvc.DelClientIPs(tx, email); e != nil {
+				logger.Error("Error in delete client IPs")
+				return e
 			}
-			if traffic != nil {
-				if err := inboundSvc.DelClientStat(db, email); err != nil {
-					logger.Error("Delete stats Data Error")
-					return false, err
-				}
+		}
+		if delStat {
+			if e := inboundSvc.DelClientStat(tx, email); e != nil {
+				logger.Error("Delete stats Data Error")
+				return e
 			}
 		}
+		if e := tx.Save(oldInbound).Error; e != nil {
+			return e
+		}
+		finalClients, gcErr := inboundSvc.GetClients(oldInbound)
+		if gcErr != nil {
+			return gcErr
+		}
+		return s.SyncInbound(tx, inboundId, finalClients)
+	}); txErr != nil {
+		return false, txErr
+	}
 
+	// Apply the runtime delete after commit — outside the serialized writer so a
+	// slow node call can't stall traffic accounting.
+	if len(email) > 0 && !emailShared {
 		if oldInbound.NodeID == nil {
 			// Local inbound: a disabled client isn't in the running Xray, so only
 			// a live one (needApiDel) needs an API removal.
 			if needApiDel {
-				rt, push, dirty, perr := inboundSvc.nodePushPlan(oldInbound)
-				if perr != nil {
-					return false, perr
-				}
-				if dirty {
-					markDirty = true
-				}
 				if !push {
 					needRestart = true
 				} else if err1 := rt.RemoveUser(context.Background(), oldInbound, email); err1 == nil {
@@ -769,13 +833,6 @@ func (s *ClientService) DelInboundClientByEmail(inboundSvc *InboundService, inbo
 			// Node inbound: propagate the delete regardless of the enable flag —
 			// the node's own DB still carries a disabled client and would
 			// resurrect it on the next snapshot otherwise.
-			rt, push, dirty, perr := inboundSvc.nodePushPlan(oldInbound)
-			if perr != nil {
-				return false, perr
-			}
-			if dirty {
-				markDirty = true
-			}
 			if push {
 				if err1 := rt.DeleteUser(context.Background(), oldInbound, email); err1 != nil {
 					logger.Warning("Error in deleting client on", rt.Name(), ":", err1)
@@ -785,16 +842,6 @@ func (s *ClientService) DelInboundClientByEmail(inboundSvc *InboundService, inbo
 		}
 	}
 
-	if err := db.Save(oldInbound).Error; err != nil {
-		return false, err
-	}
-	finalClients, gcErr := inboundSvc.GetClients(oldInbound)
-	if gcErr != nil {
-		return false, gcErr
-	}
-	if err := s.SyncInbound(db, inboundId, finalClients); err != nil {
-		return false, err
-	}
 	if markDirty && oldInbound.NodeID != nil {
 		if dErr := (&NodeService{}).MarkNodeDirty(*oldInbound.NodeID); dErr != nil {
 			logger.Warning("mark node dirty failed:", dErr)

+ 1 - 1
internal/web/service/email/email.go

@@ -270,7 +270,7 @@ func parseRecipients(toStr string) []string {
 		return nil
 	}
 	var out []string
-	for _, s := range strings.Split(toStr, ",") {
+	for s := range strings.SplitSeq(toStr, ",") {
 		s = strings.TrimSpace(s)
 		if s != "" {
 			out = append(out, s)

+ 1 - 1
internal/web/service/email/subscriber.go

@@ -53,7 +53,7 @@ func (s *Subscriber) isEventEnabled(t eventbus.EventType) bool {
 	if err != nil || events == "" {
 		return false
 	}
-	for _, e := range strings.Split(events, ",") {
+	for e := range strings.SplitSeq(events, ",") {
 		if strings.TrimSpace(e) == string(t) {
 			return true
 		}

+ 174 - 153
internal/web/service/inbound.go

@@ -619,6 +619,12 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
 		}
 	}
 
+	// Defensively fix any Shadowsocks-2022 client PSK whose length doesn't match
+	// the inbound method (e.g. an API caller supplied a wrong-size key).
+	if normalized, changed := normalizeShadowsocksClientKeys(inbound.Settings); changed {
+		inbound.Settings = normalized
+	}
+
 	// Secure client ID
 	for _, client := range clients {
 		switch inbound.Protocol {
@@ -960,187 +966,202 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
 	oldBits := inboundTransports(oldInbound.Protocol, oldInbound.StreamSettings, oldInbound.Settings)
 	oldTagWasAuto := isAutoGeneratedTag(tag, oldInbound.Port, oldInbound.NodeID, oldBits)
 
-	db := database.GetDB()
-	tx := db.Begin()
-
+	needRestart := false
 	markDirty := false
-	defer func() {
-		if err != nil {
-			tx.Rollback()
-			return
-		}
-		tx.Commit()
-		if markDirty && oldInbound.NodeID != nil {
-			if dErr := (&NodeService{}).MarkNodeDirty(*oldInbound.NodeID); dErr != nil {
-				logger.Warning("mark node dirty failed:", dErr)
-			}
-		}
-	}()
 
-	err = s.updateClientTraffics(tx, oldInbound, inbound)
-	if err != nil {
-		return inbound, false, err
-	}
+	// Persist the client-stat sync, settings munging, runtime push and inbound
+	// save as one transaction routed through the serial traffic writer, so it
+	// never runs concurrently with the @every 5s traffic poll. Both touch
+	// client_traffics and inbounds in opposite order, which Postgres aborts as a
+	// deadlock (40P01); serializing removes the contention (runSerializedTx).
+	//
+	// The runtime push stays inside the transaction here (unlike the client-edit
+	// paths that apply it after commit): EnsureInboundTagAllowed must reach the
+	// node before the central row is committed, or a "selected"-mode node would
+	// sweep the renamed inbound on its next pull. Inbound edits are rare, so
+	// holding the writer across the node call is an acceptable trade.
+	txErr := runSerializedTx(func(tx *gorm.DB) error {
+		if err := s.updateClientTraffics(tx, oldInbound, inbound); err != nil {
+			return err
+		}
 
-	// Ensure created_at and updated_at exist in inbound.Settings clients
-	{
-		var oldSettings map[string]any
-		_ = json.Unmarshal([]byte(oldInbound.Settings), &oldSettings)
-		emailToCreated := map[string]int64{}
-		emailToUpdated := map[string]int64{}
-		if oldSettings != nil {
-			if oc, ok := oldSettings["clients"].([]any); ok {
-				for _, it := range oc {
-					if m, ok2 := it.(map[string]any); ok2 {
-						if email, ok3 := m["email"].(string); ok3 {
-							switch v := m["created_at"].(type) {
-							case float64:
-								emailToCreated[email] = int64(v)
-							case int64:
-								emailToCreated[email] = v
-							}
-							switch v := m["updated_at"].(type) {
-							case float64:
-								emailToUpdated[email] = int64(v)
-							case int64:
-								emailToUpdated[email] = v
+		// Ensure created_at and updated_at exist in inbound.Settings clients
+		{
+			var oldSettings map[string]any
+			_ = json.Unmarshal([]byte(oldInbound.Settings), &oldSettings)
+			emailToCreated := map[string]int64{}
+			emailToUpdated := map[string]int64{}
+			if oldSettings != nil {
+				if oc, ok := oldSettings["clients"].([]any); ok {
+					for _, it := range oc {
+						if m, ok2 := it.(map[string]any); ok2 {
+							if email, ok3 := m["email"].(string); ok3 {
+								switch v := m["created_at"].(type) {
+								case float64:
+									emailToCreated[email] = int64(v)
+								case int64:
+									emailToCreated[email] = v
+								}
+								switch v := m["updated_at"].(type) {
+								case float64:
+									emailToUpdated[email] = int64(v)
+								case int64:
+									emailToUpdated[email] = v
+								}
 							}
 						}
 					}
 				}
 			}
-		}
-		var newSettings map[string]any
-		if err2 := json.Unmarshal([]byte(inbound.Settings), &newSettings); err2 == nil && newSettings != nil {
-			now := time.Now().Unix() * 1000
-			if nSlice, ok := newSettings["clients"].([]any); ok {
-				for i := range nSlice {
-					if m, ok2 := nSlice[i].(map[string]any); ok2 {
-						email, _ := m["email"].(string)
-						if _, ok3 := m["created_at"]; !ok3 {
-							if v, ok4 := emailToCreated[email]; ok4 && v > 0 {
-								m["created_at"] = v
-							} else {
-								m["created_at"] = now
+			var newSettings map[string]any
+			if err2 := json.Unmarshal([]byte(inbound.Settings), &newSettings); err2 == nil && newSettings != nil {
+				now := time.Now().Unix() * 1000
+				if nSlice, ok := newSettings["clients"].([]any); ok {
+					for i := range nSlice {
+						if m, ok2 := nSlice[i].(map[string]any); ok2 {
+							email, _ := m["email"].(string)
+							if _, ok3 := m["created_at"]; !ok3 {
+								if v, ok4 := emailToCreated[email]; ok4 && v > 0 {
+									m["created_at"] = v
+								} else {
+									m["created_at"] = now
+								}
 							}
-						}
-						// Preserve client's updated_at if present; do not bump on parent inbound update
-						if _, hasUpdated := m["updated_at"]; !hasUpdated {
-							if v, ok4 := emailToUpdated[email]; ok4 && v > 0 {
-								m["updated_at"] = v
+							// Preserve client's updated_at if present; do not bump on parent inbound update
+							if _, hasUpdated := m["updated_at"]; !hasUpdated {
+								if v, ok4 := emailToUpdated[email]; ok4 && v > 0 {
+									m["updated_at"] = v
+								}
 							}
+							nSlice[i] = m
 						}
-						nSlice[i] = m
 					}
-				}
-				newSettings["clients"] = nSlice
-				if bs, err3 := json.MarshalIndent(newSettings, "", "  "); err3 == nil {
-					inbound.Settings = string(bs)
+					newSettings["clients"] = nSlice
+					if bs, err3 := json.MarshalIndent(newSettings, "", "  "); err3 == nil {
+						inbound.Settings = string(bs)
+					}
 				}
 			}
 		}
-	}
 
-	oldInbound.Total = inbound.Total
-	oldInbound.Remark = inbound.Remark
-	oldInbound.SubSortIndex = inbound.SubSortIndex
-	oldInbound.Enable = inbound.Enable
-	oldInbound.ExpiryTime = inbound.ExpiryTime
-	oldInbound.TrafficReset = inbound.TrafficReset
-	oldInbound.Listen = inbound.Listen
-	oldInbound.Port = inbound.Port
-	oldInbound.Protocol = inbound.Protocol
-	oldInbound.Settings = inbound.Settings
-	oldInbound.StreamSettings = inbound.StreamSettings
-	oldInbound.Sniffing = inbound.Sniffing
-	if strings.TrimSpace(inbound.ShareAddrStrategy) == "" {
-		normalizeInboundShareAddress(oldInbound)
-		inbound.ShareAddrStrategy = oldInbound.ShareAddrStrategy
-		inbound.ShareAddr = oldInbound.ShareAddr
-	} else {
-		if err := normalizeInboundShareAddressStrict(inbound); err != nil {
-			return inbound, false, err
+		// A Shadowsocks-2022 method change resizes the key, but existing client PSKs
+		// keep their old length and would be rejected by xray. Regenerate mismatched
+		// client keys so the inbound stays connectable.
+		if normalized, changed := normalizeShadowsocksClientKeys(inbound.Settings); changed {
+			inbound.Settings = normalized
+			logger.Warning("Shadowsocks inbound", inbound.Id, "method change resized keys; regenerated mismatched client PSK(s)")
+		}
+
+		oldInbound.Total = inbound.Total
+		oldInbound.Remark = inbound.Remark
+		oldInbound.SubSortIndex = inbound.SubSortIndex
+		oldInbound.Enable = inbound.Enable
+		oldInbound.ExpiryTime = inbound.ExpiryTime
+		oldInbound.TrafficReset = inbound.TrafficReset
+		oldInbound.Listen = inbound.Listen
+		oldInbound.Port = inbound.Port
+		oldInbound.Protocol = inbound.Protocol
+		oldInbound.Settings = inbound.Settings
+		oldInbound.StreamSettings = inbound.StreamSettings
+		oldInbound.Sniffing = inbound.Sniffing
+		if strings.TrimSpace(inbound.ShareAddrStrategy) == "" {
+			normalizeInboundShareAddress(oldInbound)
+			inbound.ShareAddrStrategy = oldInbound.ShareAddrStrategy
+			inbound.ShareAddr = oldInbound.ShareAddr
+		} else {
+			if err := normalizeInboundShareAddressStrict(inbound); err != nil {
+				return err
+			}
+			oldInbound.ShareAddrStrategy = inbound.ShareAddrStrategy
+			oldInbound.ShareAddr = inbound.ShareAddr
 		}
-		oldInbound.ShareAddrStrategy = inbound.ShareAddrStrategy
-		oldInbound.ShareAddr = inbound.ShareAddr
-	}
-	if oldTagWasAuto && inbound.Tag == tag {
-		inbound.Tag = ""
-	}
-	oldInbound.Tag, err = s.resolveInboundTag(inbound, inbound.Id)
-	if err != nil {
-		return inbound, false, err
-	}
-	inbound.Tag = oldInbound.Tag
+		if oldTagWasAuto && inbound.Tag == tag {
+			inbound.Tag = ""
+		}
+		resolvedTag, err := s.resolveInboundTag(inbound, inbound.Id)
+		if err != nil {
+			return err
+		}
+		oldInbound.Tag = resolvedTag
+		inbound.Tag = oldInbound.Tag
 
-	needRestart := false
-	rt, push, dirty, perr := s.nodePushPlan(oldInbound)
-	if perr != nil {
-		err = perr
-		return inbound, false, err
-	}
-	if dirty {
-		markDirty = true
-	}
-	if oldInbound.NodeID == nil {
-		if !push {
-			needRestart = true
-		} else {
+		rt, push, dirty, perr := s.nodePushPlan(oldInbound)
+		if perr != nil {
+			return perr
+		}
+		if dirty {
+			markDirty = true
+		}
+		if oldInbound.NodeID == nil {
+			if !push {
+				needRestart = true
+			} else {
+				oldSnapshot := *oldInbound
+				oldSnapshot.Tag = tag
+				if err2 := rt.DelInbound(context.Background(), &oldSnapshot); err2 == nil {
+					logger.Debug("Old inbound deleted on", rt.Name(), ":", tag)
+				}
+				if inbound.Enable {
+					runtimeInbound, err2 := s.buildRuntimeInboundForAPI(tx, oldInbound)
+					if err2 != nil {
+						logger.Debug("Unable to prepare runtime inbound config:", err2)
+						needRestart = true
+					} else if err2 := rt.AddInbound(context.Background(), runtimeInbound); err2 == nil {
+						logger.Debug("Updated inbound added on", rt.Name(), ":", oldInbound.Tag)
+					} else {
+						logger.Debug("Unable to update inbound on", rt.Name(), ":", err2)
+						needRestart = true
+					}
+				}
+			}
+		} else if push {
 			oldSnapshot := *oldInbound
 			oldSnapshot.Tag = tag
-			if err2 := rt.DelInbound(context.Background(), &oldSnapshot); err2 == nil {
-				logger.Debug("Old inbound deleted on", rt.Name(), ":", tag)
-			}
-			if inbound.Enable {
-				runtimeInbound, err2 := s.buildRuntimeInboundForAPI(tx, oldInbound)
-				if err2 != nil {
-					logger.Debug("Unable to prepare runtime inbound config:", err2)
-					needRestart = true
-				} else if err2 := rt.AddInbound(context.Background(), runtimeInbound); err2 == nil {
-					logger.Debug("Updated inbound added on", rt.Name(), ":", oldInbound.Tag)
-				} else {
-					logger.Debug("Unable to update inbound on", rt.Name(), ":", err2)
-					needRestart = true
+			if !inbound.Enable {
+				if err2 := rt.DelInbound(context.Background(), &oldSnapshot); err2 != nil {
+					logger.Warning("Unable to disable inbound on", rt.Name(), ":", err2)
+					markDirty = true
 				}
-			}
-		}
-	} else if push {
-		oldSnapshot := *oldInbound
-		oldSnapshot.Tag = tag
-		if !inbound.Enable {
-			if err2 := rt.DelInbound(context.Background(), &oldSnapshot); err2 != nil {
-				logger.Warning("Unable to disable inbound on", rt.Name(), ":", err2)
+			} else if err2 := rt.UpdateInbound(context.Background(), &oldSnapshot, oldInbound); err2 != nil {
+				logger.Warning("Unable to update inbound on", rt.Name(), ":", err2)
 				markDirty = true
 			}
-		} else if err2 := rt.UpdateInbound(context.Background(), &oldSnapshot, oldInbound); err2 != nil {
-			logger.Warning("Unable to update inbound on", rt.Name(), ":", err2)
-			markDirty = true
 		}
-	}
 
-	// A rename must allow the new tag before the deferred commit, or a node in
-	// "selected" sync mode would sweep the renamed central row on the next pull.
-	if oldInbound.NodeID != nil {
-		if aErr := (&NodeService{}).EnsureInboundTagAllowed(*oldInbound.NodeID, oldInbound.Tag); aErr != nil {
-			logger.Warning("allow inbound tag on node failed:", aErr)
+		// A rename must allow the new tag before the inbound row is committed, or a
+		// node in "selected" sync mode would sweep the renamed central row on the
+		// next pull.
+		if oldInbound.NodeID != nil {
+			if aErr := (&NodeService{}).EnsureInboundTagAllowed(*oldInbound.NodeID, oldInbound.Tag); aErr != nil {
+				logger.Warning("allow inbound tag on node failed:", aErr)
+			}
 		}
-	}
 
-	if err = tx.Save(oldInbound).Error; err != nil {
-		return inbound, false, err
-	}
-	newClients, gcErr := s.GetClients(oldInbound)
-	if gcErr != nil {
-		err = gcErr
-		return inbound, false, err
-	}
-	if err = s.clientService.SyncInbound(tx, oldInbound.Id, newClients); err != nil {
-		return inbound, false, err
+		if err := tx.Save(oldInbound).Error; err != nil {
+			return err
+		}
+		newClients, gcErr := s.GetClients(oldInbound)
+		if gcErr != nil {
+			return gcErr
+		}
+		if err := s.clientService.SyncInbound(tx, oldInbound.Id, newClients); err != nil {
+			return err
+		}
+		// (Re)generate the Xray config whenever routing was or is now enabled, so
+		// the egress SOCKS bridge is added, moved, or dropped to match the new
+		// settings.
+		if mtprotoRoutesThroughXray(inbound) || oldRoutedMtproto {
+			needRestart = true
+		}
+		return nil
+	})
+	if txErr != nil {
+		return inbound, false, txErr
 	}
-	// (Re)generate the Xray config whenever routing was or is now enabled, so the
-	// egress SOCKS bridge is added, moved, or dropped to match the new settings.
-	if mtprotoRoutesThroughXray(inbound) || oldRoutedMtproto {
-		needRestart = true
+	if markDirty && oldInbound.NodeID != nil {
+		if dErr := (&NodeService{}).MarkNodeDirty(*oldInbound.NodeID); dErr != nil {
+			logger.Warning("mark node dirty failed:", dErr)
+		}
 	}
 	return inbound, needRestart, nil
 }

+ 2 - 2
internal/web/service/integration/warp.go

@@ -190,8 +190,8 @@ func (s *WarpService) ChangeWarpIP() (string, error) {
 	}
 
 	var parsed struct {
-		Data   map[string]string      `json:"data"`
-		Config map[string]interface{} `json:"config"`
+		Data   map[string]string `json:"data"`
+		Config map[string]any    `json:"config"`
 	}
 	if err := json.Unmarshal([]byte(result), &parsed); err != nil {
 		return "", err

+ 3 - 2
internal/web/service/metric_history.go

@@ -4,6 +4,7 @@ import (
 	"encoding/gob"
 	"os"
 	"path/filepath"
+	"slices"
 	"sync"
 	"time"
 
@@ -106,8 +107,8 @@ func (h *metricHistory) aggregate(metric string, bucketSeconds int, maxPoints in
 	h.mu.Lock()
 	hist := h.metrics[metric]
 	startIdx := 0
-	for i := len(hist) - 1; i >= 0; i-- {
-		if hist[i].T < cutoff {
+	for i, h := range slices.Backward(hist) {
+		if h.T < cutoff {
 			startIdx = i + 1
 			break
 		}

+ 3 - 4
internal/web/service/node.go

@@ -11,6 +11,7 @@ import (
 	"net"
 	"net/http"
 	"net/url"
+	"slices"
 	"strconv"
 	"strings"
 	"time"
@@ -405,10 +406,8 @@ func (s *NodeService) EnsureInboundTagAllowed(nodeID int, tag string) error {
 	if node.InboundSyncMode != "selected" {
 		return nil
 	}
-	for _, t := range node.InboundTags {
-		if t == tag {
-			return nil
-		}
+	if slices.Contains(node.InboundTags, tag) {
+		return nil
 	}
 	buf, err := json.Marshal(append(node.InboundTags, tag))
 	if err != nil {

+ 49 - 0
internal/web/service/shadowsocks_client_key_test.go

@@ -0,0 +1,49 @@
+package service
+
+import (
+	"encoding/base64"
+	"encoding/json"
+	"testing"
+)
+
+// A method switch between SS-2022 ciphers of different key sizes must regenerate
+// client PSKs whose length no longer matches; otherwise xray rejects the user.
+func TestNormalizeShadowsocksClientKeys_RegeneratesOnMethodResize(t *testing.T) {
+	// 32-byte (aes-256-sized) client key under an aes-128 (16-byte) method.
+	oversized := base64.StdEncoding.EncodeToString(make([]byte, 32))
+	settings := `{"method":"2022-blake3-aes-128-gcm","password":"` +
+		base64.StdEncoding.EncodeToString(make([]byte, 16)) +
+		`","clients":[{"email":"a","password":"` + oversized + `"}]}`
+
+	out, changed := normalizeShadowsocksClientKeys(settings)
+	if !changed {
+		t.Fatalf("expected mismatched client key to be regenerated")
+	}
+
+	var m map[string]any
+	if err := json.Unmarshal([]byte(out), &m); err != nil {
+		t.Fatalf("unmarshal: %v", err)
+	}
+	clients := m["clients"].([]any)
+	pw := clients[0].(map[string]any)["password"].(string)
+	if pw == oversized {
+		t.Fatalf("client key was not regenerated")
+	}
+	if decoded, err := base64.StdEncoding.DecodeString(pw); err != nil || len(decoded) != 16 {
+		t.Fatalf("regenerated key must be 16 bytes for aes-128, got len=%d err=%v", len(decoded), err)
+	}
+}
+
+// A correctly-sized key (and non-2022 / legacy settings) must pass through untouched.
+func TestNormalizeShadowsocksClientKeys_NoChangeWhenValid(t *testing.T) {
+	valid := base64.StdEncoding.EncodeToString(make([]byte, 32))
+	settings := `{"method":"2022-blake3-aes-256-gcm","clients":[{"email":"a","password":"` + valid + `"}]}`
+	if out, changed := normalizeShadowsocksClientKeys(settings); changed || out != settings {
+		t.Fatalf("valid aes-256 key must be left unchanged")
+	}
+
+	legacy := `{"method":"aes-256-gcm","clients":[{"email":"a","password":"anything"}]}`
+	if out, changed := normalizeShadowsocksClientKeys(legacy); changed || out != legacy {
+		t.Fatalf("legacy (non-2022) SS settings must be left unchanged")
+	}
+}

+ 4 - 4
internal/web/service/sync_scale_postgres_test.go

@@ -70,7 +70,7 @@ func syncInboundOld(tx *gorm.DB, inboundId int, clients []model.Client) error {
 
 func makeScaleClients(n int) []model.Client {
 	out := make([]model.Client, n)
-	for i := 0; i < n; i++ {
+	for i := range n {
 		out[i] = model.Client{
 			ID:     uuid.NewString(),
 			Email:  fmt.Sprintf("user-%07d@scale", i),
@@ -260,7 +260,7 @@ func TestGroupAndListPostgresScale(t *testing.T) {
 			}
 			db.Exec("ANALYZE")
 			emails := make([]string, n)
-			for i := 0; i < n; i++ {
+			for i := range n {
 				emails[i] = clients[i].Email
 			}
 
@@ -382,7 +382,7 @@ func TestBulkOpsPostgresScale(t *testing.T) {
 			}
 
 			emailsM := make([]string, m)
-			for i := 0; i < m; i++ {
+			for i := range m {
 				emailsM[i] = clients[i].Email
 			}
 
@@ -405,7 +405,7 @@ func TestBulkOpsPostgresScale(t *testing.T) {
 			detachDur := time.Since(t0)
 
 			payloads := make([]ClientCreatePayload, m)
-			for i := 0; i < m; i++ {
+			for i := range m {
 				payloads[i] = ClientCreatePayload{
 					Client:     model.Client{ID: uuid.NewString(), Email: fmt.Sprintf("bulknew-%07d@scale", i), SubID: fmt.Sprintf("bnsub-%07d", i), Enable: true},
 					InboundIds: []int{ib.Id},

+ 1 - 1
internal/web/service/tgbot/tgbot_event.go

@@ -49,7 +49,7 @@ func (t *Tgbot) isEventEnabled(eventType eventbus.EventType) bool {
 	if err != nil || events == "" {
 		return false
 	}
-	for _, e := range strings.Split(events, ",") {
+	for e := range strings.SplitSeq(events, ",") {
 		if strings.TrimSpace(e) == string(eventType) {
 			return true
 		}

+ 20 - 0
internal/web/service/traffic_writer.go

@@ -7,7 +7,10 @@ import (
 	"sync"
 	"time"
 
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+
+	"gorm.io/gorm"
 )
 
 const (
@@ -109,6 +112,23 @@ func runTrafficWriter(ctx context.Context, queue chan *trafficWriteRequest, done
 	}
 }
 
+// runSerializedTx runs fn inside one DB transaction on the shared serial
+// traffic-writer goroutine, so it can never execute concurrently with the
+// @every 5s traffic poll (AddTraffic). Both touch the hot client_traffics and
+// inbounds rows, and they acquire them in opposite order (the poll locks
+// inbounds then client_traffics; an admin client/inbound mutation does the
+// reverse), which Postgres aborts as a deadlock (SQLSTATE 40P01). Routing every
+// such mutation through this single writer removes that contention entirely.
+//
+// Keep network I/O (node pushes) OUT of fn: holding the single writer across a
+// remote node call would stall all traffic accounting for up to the remote
+// timeout. Apply runtime changes after this returns.
+func runSerializedTx(fn func(tx *gorm.DB) error) error {
+	return submitTrafficWrite(func() error {
+		return database.GetDB().Transaction(fn)
+	})
+}
+
 func safeApply(fn func() error) (err error) {
 	defer func() {
 		if r := recover(); r != nil {

+ 13 - 13
internal/web/service/xray_setting.go

@@ -41,39 +41,39 @@ func (s *XraySettingService) CheckXrayConfig(XrayTemplateConfig string) error {
 	return nil
 }
 
-func (s *XraySettingService) UpdateWarpXraySetting(warpData map[string]string, warpConfig map[string]interface{}) error {
+func (s *XraySettingService) UpdateWarpXraySetting(warpData map[string]string, warpConfig map[string]any) error {
 	template, err := s.GetXrayConfigTemplate()
 	if err != nil {
 		return err
 	}
 
-	var cfg map[string]interface{}
+	var cfg map[string]any
 	if err := json.Unmarshal([]byte(template), &cfg); err != nil {
 		return err
 	}
 
-	outbounds, ok := cfg["outbounds"].([]interface{})
+	outbounds, ok := cfg["outbounds"].([]any)
 	if !ok {
 		return nil
 	}
 
 	updated := false
 	for _, outIface := range outbounds {
-		out, ok := outIface.(map[string]interface{})
+		out, ok := outIface.(map[string]any)
 		if !ok {
 			continue
 		}
 		if tag, ok := out["tag"].(string); ok && tag == "warp" {
-			settings, ok := out["settings"].(map[string]interface{})
+			settings, ok := out["settings"].(map[string]any)
 			if !ok {
 				continue
 			}
 
 			settings["secretKey"] = warpData["private_key"]
 
-			if conf, ok := warpConfig["config"].(map[string]interface{}); ok {
-				if iface, ok := conf["interface"].(map[string]interface{}); ok {
-					if addrs, ok := iface["addresses"].(map[string]interface{}); ok {
+			if conf, ok := warpConfig["config"].(map[string]any); ok {
+				if iface, ok := conf["interface"].(map[string]any); ok {
+					if addrs, ok := iface["addresses"].(map[string]any); ok {
 						var addrList []string
 						if v4, ok := addrs["v4"].(string); ok && v4 != "" {
 							addrList = append(addrList, v4+"/32")
@@ -100,12 +100,12 @@ func (s *XraySettingService) UpdateWarpXraySetting(warpData map[string]string, w
 					settings["reserved"] = res
 				}
 
-				if peers, ok := conf["peers"].([]interface{}); ok && len(peers) > 0 {
-					if peer, ok := peers[0].(map[string]interface{}); ok {
-						if pSettings, ok := settings["peers"].([]interface{}); ok && len(pSettings) > 0 {
-							if pSet, ok := pSettings[0].(map[string]interface{}); ok {
+				if peers, ok := conf["peers"].([]any); ok && len(peers) > 0 {
+					if peer, ok := peers[0].(map[string]any); ok {
+						if pSettings, ok := settings["peers"].([]any); ok && len(pSettings) > 0 {
+							if pSet, ok := pSettings[0].(map[string]any); ok {
 								pSet["publicKey"] = peer["public_key"]
-								if endpoint, ok := peer["endpoint"].(map[string]interface{}); ok {
+								if endpoint, ok := peer["endpoint"].(map[string]any); ok {
 									pSet["endpoint"] = endpoint["host"]
 								}
 							}

+ 1 - 1
internal/web/web.go

@@ -388,7 +388,7 @@ func (s *Server) cpuAlarmWanted() bool {
 		if threshold <= 0 {
 			return false
 		}
-		for _, e := range strings.Split(events, ",") {
+		for e := range strings.SplitSeq(events, ",") {
 			if strings.TrimSpace(e) == string(eventbus.EventCPUHigh) {
 				return true
 			}

+ 2 - 6
internal/xray/mutation_audit_test.go

@@ -4,6 +4,7 @@ import (
 	"os"
 	"os/exec"
 	"path/filepath"
+	"slices"
 	"testing"
 	"time"
 
@@ -248,12 +249,7 @@ func TestRefreshLocalOnline_GraceBoundaryInbounds(t *testing.T) {
 }
 
 func containsString(s []string, v string) bool {
-	for _, x := range s {
-		if x == v {
-			return true
-		}
-	}
-	return false
+	return slices.Contains(s, v)
 }
 
 // ---------------------------------------------------------------------------

+ 2 - 3
tools/openapigen/emit_examples.go

@@ -4,6 +4,7 @@ import (
 	"encoding/json"
 	"fmt"
 	"io"
+	"maps"
 	"strconv"
 	"strings"
 )
@@ -116,9 +117,7 @@ func isVisitedRef(t TypeRef, visited map[string]bool) bool {
 
 func cloneVisited(in map[string]bool) map[string]bool {
 	out := make(map[string]bool, len(in)+1)
-	for k, v := range in {
-		out[k] = v
-	}
+	maps.Copy(out, in)
 	return out
 }