12 Комити 4f99e48ab7 ... e079490144

Аутор SHA1 Порука Датум
  MHSanaei e079490144 chore(db): use DELETE journal mode so sqlite stays a single file пре 10 часа
  nima1024m af3f460065 fix(routing): sync xray rules when panel inbound tags change or are deleted (#5367) пре 10 часа
  n0ctal f5e50038f0 fix(nodes): block node delete while inbounds are still attached (#5394) пре 10 часа
  w3struk d01d9867e4 fix(sub): preserve non-default scMinPostsIntervalMs and use per-inbound xmux in JSON subscriptions (#5393) пре 10 часа
  aleskxyz da9ecf6f4d fix(nodes): strip central n<id>- tag prefix when pushing inbounds to remote (#5399) пре 11 часа
  n0ctal 118d1e4398 fix(sub): set read/write/idle timeouts on the subscription server (#5360) пре 11 часа
  n0ctal b0ef60670c fix(runtime): cap remote node response size to bound master memory (#5361) пре 11 часа
  n0ctal f63ed9f510 fix(jobs): isolate per-node background goroutines from panics (#5397) пре 11 часа
  n0ctal bedbe04bf1 fix(web): recover panicking cron jobs instead of crashing the panel (#5363) пре 11 часа
  n0ctal 2bb851dd50 fix(xray): verify the release archive checksum before installing (#5396) пре 11 часа
  n0ctal abffa8f6c9 fix(xray): guard process lifecycle fields against concurrent access (#5395) пре 11 часа
  Younes fb03b0e9f1 fix(traffic): prevent phantom quota consumption from stale node data (#5412) пре 11 часа
30 измењених фајлова са 1647 додато и 129 уклоњено
  1. 6 1
      frontend/src/lib/xray/stream-wire-normalize.ts
  2. 24 10
      frontend/src/pages/inbounds/form/transport/xhttp.tsx
  3. 2 2
      frontend/src/pages/xray/outbounds/transport/xhttp.tsx
  4. 6 3
      frontend/src/schemas/protocols/stream/xhttp.ts
  5. 118 0
      frontend/src/test/stream-wire-normalize.test.ts
  6. 3 3
      internal/database/db.go
  7. 23 13
      internal/sub/json_service.go
  8. 81 0
      internal/sub/json_service_test.go
  9. 3 3
      internal/sub/mutation_audit_test.go
  10. 7 0
      internal/sub/sub.go
  11. 15 0
      internal/util/common/err.go
  12. 41 0
      internal/util/common/gorecover_test.go
  13. 4 2
      internal/web/job/node_heartbeat_job.go
  14. 7 5
      internal/web/job/node_traffic_sync_job.go
  15. 81 9
      internal/web/runtime/remote.go
  16. 131 5
      internal/web/runtime/remote_test.go
  17. 17 10
      internal/web/service/client_traffic.go
  18. 21 0
      internal/web/service/inbound.go
  19. 2 2
      internal/web/service/inbound_node.go
  20. 43 15
      internal/web/service/inbound_traffic.go
  21. 27 8
      internal/web/service/node.go
  22. 60 16
      internal/web/service/node_client_traffic_sum_test.go
  23. 51 0
      internal/web/service/node_delete_orphan_test.go
  24. 62 0
      internal/web/service/server.go
  25. 72 0
      internal/web/service/server_xray_checksum_test.go
  26. 311 0
      internal/web/service/xray_setting_routing_sync.go
  27. 302 0
      internal/web/service/xray_setting_routing_sync_test.go
  28. 9 1
      internal/web/web.go
  29. 58 21
      internal/xray/process.go
  30. 60 0
      internal/xray/process_race_test.go

+ 6 - 1
frontend/src/lib/xray/stream-wire-normalize.ts

@@ -150,7 +150,12 @@ export function normalizeXhttpForWire(
 
   if (side === 'inbound') {
     if (!enableXmux) delete out.xmux;
-    delete out.scMinPostsIntervalMs;
+    // scMinPostsIntervalMs is a client-only tuning knob that subscriptions
+    // must propagate to clients. Only strip the xray-core default ("30")
+    // or empty values — the literal "30" is a known DPI fingerprint (#5141).
+    if (out.scMinPostsIntervalMs === '' || out.scMinPostsIntervalMs === '30') {
+      delete out.scMinPostsIntervalMs;
+    }
     delete out.uplinkChunkSize;
   }
 

+ 24 - 10
frontend/src/pages/inbounds/form/transport/xhttp.tsx

@@ -40,8 +40,14 @@ export default function XhttpForm({ form }: { form: FormInstance<InboundFormValu
           }))}
         />
       </Form.Item>
-      {xhttpMode === 'packet-up' && (
+      {(xhttpMode === 'packet-up' || xhttpMode === 'auto') && (
         <>
+          <Form.Item
+            name={['streamSettings', 'xhttpSettings', 'scMaxEachPostBytes']}
+            label={t('pages.inbounds.form.maxUploadSize')}
+          >
+            <Input />
+          </Form.Item>
           <Form.Item
             name={['streamSettings', 'xhttpSettings', 'scMaxBufferedPosts']}
             label={t('pages.inbounds.form.maxBufferedUpload')}
@@ -49,20 +55,28 @@ export default function XhttpForm({ form }: { form: FormInstance<InboundFormValu
             <InputNumber />
           </Form.Item>
           <Form.Item
-            name={['streamSettings', 'xhttpSettings', 'scMaxEachPostBytes']}
-            label={t('pages.inbounds.form.maxUploadSize')}
+            name={['streamSettings', 'xhttpSettings', 'scMinPostsIntervalMs']}
+            label={t('pages.xray.outboundForm.minUploadInterval')}
           >
-            <Input />
+            <Input placeholder="e.g. 50-150" />
           </Form.Item>
         </>
       )}
       {xhttpMode === 'stream-up' && (
-        <Form.Item
-          name={['streamSettings', 'xhttpSettings', 'scStreamUpServerSecs']}
-          label={t('pages.inbounds.form.streamUpServer')}
-        >
-          <Input />
-        </Form.Item>
+        <>
+          <Form.Item
+            name={['streamSettings', 'xhttpSettings', 'scMaxBufferedPosts']}
+            label={t('pages.inbounds.form.maxBufferedUpload')}
+          >
+            <InputNumber />
+          </Form.Item>
+          <Form.Item
+            name={['streamSettings', 'xhttpSettings', 'scStreamUpServerSecs']}
+            label={t('pages.inbounds.form.streamUpServer')}
+          >
+            <Input />
+          </Form.Item>
+        </>
       )}
       <Form.Item
         name={['streamSettings', 'xhttpSettings', 'serverMaxHeaderBytes']}

+ 2 - 2
frontend/src/pages/xray/outbounds/transport/xhttp.tsx

@@ -212,14 +212,14 @@ export default function XhttpForm({ form, onXmuxToggle }: XhttpFormProps) {
           const mode = form.getFieldValue([
             'streamSettings', 'xhttpSettings', 'mode',
           ]);
-          if (mode !== 'packet-up') return null;
+          if (mode !== 'packet-up' && mode !== 'auto') return null;
           return (
             <>
               <Form.Item
                 label={t('pages.xray.outboundForm.minUploadInterval')}
                 name={['streamSettings', 'xhttpSettings', 'scMinPostsIntervalMs']}
               >
-                <Input placeholder="30" />
+                <Input placeholder="e.g. 50-150" />
               </Form.Item>
               <Form.Item
                 label={t('pages.xray.outboundForm.maxUploadSizeBytes')}

+ 6 - 3
frontend/src/schemas/protocols/stream/xhttp.ts

@@ -51,9 +51,12 @@ export const XHttpStreamSettingsSchema = z.object({
   serverMaxHeaderBytes: z.number().int().min(0).default(0),
   uplinkHTTPMethod: z.string().default(''),
   headers: WsHeaderMapSchema.default({}),
-  // Outbound-only fields. Server (inbound) listener ignores these. The
-  // panel embeds them in share-link `extra` blobs so the same xhttp
-  // config can roundtrip on both sides.
+  // Client-side fields stored on inbound for subscription propagation.
+  // The server listener ignores them at runtime, but the panel embeds
+  // them in share-link `extra` blobs so the same xhttp config can
+  // round-trip on both sides.
+  // - scMinPostsIntervalMs: preserved when non-default (stripped at '' or '30')
+  // - uplinkChunkSize & noGRPCHeader: outbound-only; stripped from inbound wire
   scMinPostsIntervalMs: z.string().default(''),
   uplinkChunkSize: z.number().int().min(0).default(0),
   noGRPCHeader: z.boolean().default(false),

+ 118 - 0
frontend/src/test/stream-wire-normalize.test.ts

@@ -53,6 +53,28 @@ describe('normalizeXhttpForWire stream-one', () => {
     expect(out).not.toHaveProperty('headers');
   });
 
+  it('preserves non-default scMinPostsIntervalMs on inbound for subscriptions', () => {
+    const out = normalizeXhttpForWire({
+      path: '/app',
+      mode: 'packet-up',
+      scMinPostsIntervalMs: '50-150',
+      enableXmux: false,
+    }, 'inbound');
+
+    expect(out.scMinPostsIntervalMs).toBe('50-150');
+  });
+
+  it('strips empty scMinPostsIntervalMs on inbound', () => {
+    const out = normalizeXhttpForWire({
+      path: '/app',
+      mode: 'packet-up',
+      scMinPostsIntervalMs: '',
+      enableXmux: false,
+    }, 'inbound');
+
+    expect(out).not.toHaveProperty('scMinPostsIntervalMs');
+  });
+
   it('keeps xmux on outbound stream-one', () => {
     const out = normalizeXhttpForWire({
       path: '/app',
@@ -340,6 +362,102 @@ describe('inbound formValuesToWirePayload integration', () => {
     const settings = tls.settings as Record<string, unknown>;
     expect(settings).not.toHaveProperty('fingerprint');
   });
+
+  it('preserves non-default scMinPostsIntervalMs in packet-up inbound wire payload for subscriptions', () => {
+    const values = {
+      remark: 't',
+      enable: true,
+      port: 443,
+      listen: '0.0.0.0',
+      tag: 'in-443',
+      expiryTime: 0,
+      sniffing: { enabled: false },
+      up: 0,
+      down: 0,
+      total: 0,
+      trafficReset: 'never',
+      lastTrafficResetTime: 0,
+      nodeId: null,
+      protocol: 'vless',
+      settings: { clients: [{ id: '7eeb09ed-ae97-400d-a1ce-2485fb904407', email: 'n' }], decryption: 'none' },
+      streamSettings: {
+        network: 'xhttp',
+        security: 'reality',
+        realitySettings: {
+          target: 'play.google.com:443',
+          privateKey: 'priv',
+          serverNames: ['play.google.com'],
+          shortIds: ['44003d86dc1e'],
+          settings: { publicKey: 'pub', fingerprint: 'chrome', spiderX: '/' },
+        },
+        xhttpSettings: {
+          path: '/app',
+          host: 'play.google.com',
+          mode: 'packet-up',
+          scMinPostsIntervalMs: '50-150',
+        },
+        sockopt: {},
+      },
+    };
+
+    const parsed = InboundFormSchema.safeParse(values);
+    expect(parsed.success).toBe(true);
+    if (!parsed.success) throw parsed.error;
+
+    const payload = formValuesToWirePayload(parsed.data);
+    const stream = JSON.parse(payload.streamSettings) as Record<string, unknown>;
+    const xhttp = stream.xhttpSettings as Record<string, unknown>;
+
+    expect(xhttp.scMinPostsIntervalMs).toBe('50-150');
+  });
+
+  it('strips default scMinPostsIntervalMs=30 from inbound wire payload', () => {
+    const values = {
+      remark: 't',
+      enable: true,
+      port: 443,
+      listen: '0.0.0.0',
+      tag: 'in-443',
+      expiryTime: 0,
+      sniffing: { enabled: false },
+      up: 0,
+      down: 0,
+      total: 0,
+      trafficReset: 'never',
+      lastTrafficResetTime: 0,
+      nodeId: null,
+      protocol: 'vless',
+      settings: { clients: [{ id: '7eeb09ed-ae97-400d-a1ce-2485fb904407', email: 'n' }], decryption: 'none' },
+      streamSettings: {
+        network: 'xhttp',
+        security: 'reality',
+        realitySettings: {
+          target: 'play.google.com:443',
+          privateKey: 'priv',
+          serverNames: ['play.google.com'],
+          shortIds: ['44003d86dc1e'],
+          settings: { publicKey: 'pub', fingerprint: 'chrome', spiderX: '/' },
+        },
+        xhttpSettings: {
+          path: '/app',
+          host: 'play.google.com',
+          mode: 'packet-up',
+          scMinPostsIntervalMs: '30',
+        },
+        sockopt: {},
+      },
+    };
+
+    const parsed = InboundFormSchema.safeParse(values);
+    expect(parsed.success).toBe(true);
+    if (!parsed.success) throw parsed.error;
+
+    const payload = formValuesToWirePayload(parsed.data);
+    const stream = JSON.parse(payload.streamSettings) as Record<string, unknown>;
+    const xhttp = stream.xhttpSettings as Record<string, unknown>;
+
+    expect(xhttp).not.toHaveProperty('scMinPostsIntervalMs');
+  });
 });
 
 describe('freedom outbound sockopt wire payload', () => {

+ 3 - 3
internal/database/db.go

@@ -886,7 +886,7 @@ func InitDB(dbPath string) error {
 		if err = os.MkdirAll(dir, 0755); err != nil {
 			return err
 		}
-		dsn := dbPath + "?_journal_mode=WAL&_busy_timeout=10000&_synchronous=NORMAL&_txlock=immediate"
+		dsn := dbPath + "?_journal_mode=DELETE&_busy_timeout=10000&_synchronous=FULL&_txlock=immediate"
 		db, err = gorm.Open(sqlite.Open(dsn), c)
 		if err != nil {
 			return err
@@ -895,13 +895,13 @@ func InitDB(dbPath string) error {
 		if err != nil {
 			return err
 		}
-		if _, err := sqlDB.Exec("PRAGMA journal_mode=WAL"); err != nil {
+		if _, err := sqlDB.Exec("PRAGMA journal_mode=DELETE"); err != nil {
 			return err
 		}
 		if _, err := sqlDB.Exec("PRAGMA busy_timeout=10000"); err != nil {
 			return err
 		}
-		if _, err := sqlDB.Exec("PRAGMA synchronous=NORMAL"); err != nil {
+		if _, err := sqlDB.Exec("PRAGMA synchronous=FULL"); err != nil {
 			return err
 		}
 	}

+ 23 - 13
internal/sub/json_service.go

@@ -150,6 +150,16 @@ func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, c
 		defaultDest = host
 	}
 
+	// Per-inbound xmux takes precedence over the global subJsonMux.
+	// When xmux is present inside xhttpSettings, XHTTP multiplexing
+	// is handled by xmux — don't also set the legacy outbound.Mux.
+	mux := s.mux
+	if xhttp, ok := stream["xhttpSettings"].(map[string]any); ok {
+		if _, hasXmux := xhttp["xmux"]; hasXmux {
+			mux = ""
+		}
+	}
+
 	externalProxies, ok := stream["externalProxy"].([]any)
 	hasExternalProxy := ok && len(externalProxies) > 0
 	if !hasExternalProxy {
@@ -197,13 +207,13 @@ func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, c
 
 		switch inbound.Protocol {
 		case "vmess":
-			newOutbounds = append(newOutbounds, s.genVnext(inbound, streamSettings, client, hostMux))
+			newOutbounds = append(newOutbounds, s.genVnext(inbound, streamSettings, client, jsonMux(mux, hostMux)))
 		case "vless":
-			newOutbounds = append(newOutbounds, s.genVless(inbound, streamSettings, client, hostMux))
+			newOutbounds = append(newOutbounds, s.genVless(inbound, streamSettings, client, jsonMux(mux, hostMux)))
 		case "trojan", "shadowsocks":
-			newOutbounds = append(newOutbounds, s.genServer(inbound, streamSettings, client, hostMux))
+			newOutbounds = append(newOutbounds, s.genServer(inbound, streamSettings, client, jsonMux(mux, hostMux)))
 		case "hysteria":
-			newOutbounds = append(newOutbounds, s.genHy(inbound, newStream, client))
+			newOutbounds = append(newOutbounds, s.genHy(inbound, newStream, client, jsonMux(mux, hostMux)))
 		}
 
 		newOutbounds = append(newOutbounds, s.defaultOutbounds...)
@@ -340,12 +350,12 @@ func jsonMux(global, override string) string {
 	return global
 }
 
-func (s *SubJsonService) genVnext(inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client, muxOverride string) json_util.RawMessage {
+func (s *SubJsonService) genVnext(inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client, mux string) json_util.RawMessage {
 	outbound := Outbound{}
 
 	outbound.Protocol = string(inbound.Protocol)
 	outbound.Tag = "proxy"
-	if mux := jsonMux(s.mux, muxOverride); mux != "" {
+	if mux != "" {
 		outbound.Mux = json_util.RawMessage(mux)
 	}
 	outbound.StreamSettings = streamSettings
@@ -366,11 +376,11 @@ func (s *SubJsonService) genVnext(inbound *model.Inbound, streamSettings json_ut
 	return result
 }
 
-func (s *SubJsonService) genVless(inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client, muxOverride string) json_util.RawMessage {
+func (s *SubJsonService) genVless(inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client, mux string) json_util.RawMessage {
 	outbound := Outbound{}
 	outbound.Protocol = string(inbound.Protocol)
 	outbound.Tag = "proxy"
-	if mux := jsonMux(s.mux, muxOverride); mux != "" {
+	if mux != "" {
 		outbound.Mux = json_util.RawMessage(mux)
 	}
 	outbound.StreamSettings = streamSettings
@@ -395,7 +405,7 @@ func (s *SubJsonService) genVless(inbound *model.Inbound, streamSettings json_ut
 	return result
 }
 
-func (s *SubJsonService) genServer(inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client, muxOverride string) json_util.RawMessage {
+func (s *SubJsonService) genServer(inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client, mux string) json_util.RawMessage {
 	outbound := Outbound{}
 
 	serverData := make([]ServerSetting, 1)
@@ -422,7 +432,7 @@ func (s *SubJsonService) genServer(inbound *model.Inbound, streamSettings json_u
 
 	outbound.Protocol = string(inbound.Protocol)
 	outbound.Tag = "proxy"
-	if mux := jsonMux(s.mux, muxOverride); mux != "" {
+	if mux != "" {
 		outbound.Mux = json_util.RawMessage(mux)
 	}
 	outbound.StreamSettings = streamSettings
@@ -448,14 +458,14 @@ func (s *SubJsonService) genServer(inbound *model.Inbound, streamSettings json_u
 	return result
 }
 
-func (s *SubJsonService) genHy(inbound *model.Inbound, newStream map[string]any, client model.Client) json_util.RawMessage {
+func (s *SubJsonService) genHy(inbound *model.Inbound, newStream map[string]any, client model.Client, mux string) json_util.RawMessage {
 	outbound := Outbound{}
 
 	outbound.Protocol = string(inbound.Protocol)
 	outbound.Tag = "proxy"
 
-	if s.mux != "" {
-		outbound.Mux = json_util.RawMessage(s.mux)
+	if mux != "" {
+		outbound.Mux = json_util.RawMessage(mux)
 	}
 
 	var settings, stream map[string]any

+ 81 - 0
internal/sub/json_service_test.go

@@ -173,3 +173,84 @@ func TestSubJsonServiceServerUsesServersArray(t *testing.T) {
 		t.Fatalf("shadowsocks server entry must carry method: %#v", ssServer)
 	}
 }
+
+func TestSubJsonServiceXmuxSuppressesGlobalMux(t *testing.T) {
+	globalMux := `{"enabled":true,"concurrency":8}`
+	svc := NewSubJsonService(globalMux, "", "", nil)
+
+	// When xmux is present in xhttpSettings, the per-inbound xmux handles
+	// multiplexing and the legacy outbound.Mux must NOT be set.
+	stream := `{"network":"xhttp","security":"tls","tlsSettings":{"serverName":"example.com"},"xhttpSettings":{"path":"/api","mode":"packet-up","xmux":{"maxConcurrency":"16-32"}}}`
+	parsed := svc.streamData(stream)
+
+	mux := globalMux
+	if xhttp, ok := parsed["xhttpSettings"].(map[string]any); ok {
+		if _, hasXmux := xhttp["xmux"]; hasXmux {
+			mux = ""
+		}
+	}
+
+	streamSettings, _ := json.Marshal(parsed)
+	inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VLESS, Settings: `{"encryption":"none"}`}
+	client := model.Client{ID: "uuid-1"}
+
+	raw := svc.genVless(inbound, streamSettings, client, mux)
+	var ob map[string]any
+	if err := json.Unmarshal(raw, &ob); err != nil {
+		t.Fatalf("unmarshal outbound: %v", err)
+	}
+	if _, has := ob["mux"]; has {
+		t.Fatal("outbound.Mux must NOT be set when per-inbound xmux is present")
+	}
+
+	// Verify xmux is still inside xhttpSettings in streamSettings.
+	ss, _ := ob["streamSettings"].(map[string]any)
+	if ss == nil {
+		t.Fatal("streamSettings missing from outbound")
+	}
+	xhttp, _ := ss["xhttpSettings"].(map[string]any)
+	if xhttp == nil {
+		t.Fatal("xhttpSettings missing from streamSettings")
+	}
+	xmux, _ := xhttp["xmux"].(map[string]any)
+	if xmux == nil {
+		t.Fatal("xmux missing from xhttpSettings — per-inbound xmux must survive streamData()")
+	}
+	if xmux["maxConcurrency"] != "16-32" {
+		t.Fatalf("xmux.maxConcurrency = %v, want 16-32", xmux["maxConcurrency"])
+	}
+}
+
+func TestSubJsonServiceGlobalMuxWhenNoXmux(t *testing.T) {
+	globalMux := `{"enabled":true,"concurrency":8}`
+	svc := NewSubJsonService(globalMux, "", "", nil)
+
+	// When no xmux is present, the global subJsonMux should be used.
+	stream := `{"network":"xhttp","security":"tls","tlsSettings":{"serverName":"example.com"},"xhttpSettings":{"path":"/api","mode":"packet-up"}}`
+	parsed := svc.streamData(stream)
+
+	mux := globalMux
+	if xhttp, ok := parsed["xhttpSettings"].(map[string]any); ok {
+		if _, hasXmux := xhttp["xmux"]; hasXmux {
+			mux = ""
+		}
+	}
+
+	streamSettings, _ := json.Marshal(parsed)
+	inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VLESS, Settings: `{"encryption":"none"}`}
+	client := model.Client{ID: "uuid-1"}
+
+	raw := svc.genVless(inbound, streamSettings, client, mux)
+	var ob map[string]any
+	if err := json.Unmarshal(raw, &ob); err != nil {
+		t.Fatalf("unmarshal outbound: %v", err)
+	}
+	m, has := ob["mux"]
+	if !has {
+		t.Fatal("outbound.Mux must be set when global subJsonMux is configured and no per-inbound xmux")
+	}
+	mm, _ := m.(map[string]any)
+	if mm["enabled"] != true || mm["concurrency"] != float64(8) {
+		t.Fatalf("mux payload wrong: %#v", m)
+	}
+}

+ 3 - 3
internal/sub/mutation_audit_test.go

@@ -66,9 +66,9 @@ func TestSubJsonService_MuxAttachedWhenConfigured(t *testing.T) {
 		wantMux  bool
 		protocol model.Protocol
 	}{
-		{"vmess mux", NewSubJsonService(mux, "", "", nil).genVnext(&model.Inbound{Protocol: model.VMESS, Settings: `{}`}, nil, client, ""), true, model.VMESS},
-		{"vless mux", NewSubJsonService(mux, "", "", nil).genVless(&model.Inbound{Protocol: model.VLESS, Settings: `{}`}, nil, client, ""), true, model.VLESS},
-		{"server mux", NewSubJsonService(mux, "", "", nil).genServer(&model.Inbound{Protocol: model.Trojan, Settings: `{}`}, nil, client, ""), true, model.Trojan},
+		{"vmess mux", NewSubJsonService(mux, "", "", nil).genVnext(&model.Inbound{Protocol: model.VMESS, Settings: `{}`}, nil, client, mux), true, model.VMESS},
+		{"vless mux", NewSubJsonService(mux, "", "", nil).genVless(&model.Inbound{Protocol: model.VLESS, Settings: `{}`}, nil, client, mux), true, model.VLESS},
+		{"server mux", NewSubJsonService(mux, "", "", nil).genServer(&model.Inbound{Protocol: model.Trojan, Settings: `{}`}, nil, client, mux), true, model.Trojan},
 		{"vmess no mux", NewSubJsonService("", "", "", nil).genVnext(&model.Inbound{Protocol: model.VMESS, Settings: `{}`}, nil, client, ""), false, model.VMESS},
 		{"vless no mux", NewSubJsonService("", "", "", nil).genVless(&model.Inbound{Protocol: model.VLESS, Settings: `{}`}, nil, client, ""), false, model.VLESS},
 		{"server no mux", NewSubJsonService("", "", "", nil).genServer(&model.Inbound{Protocol: model.Trojan, Settings: `{}`}, nil, client, ""), false, model.Trojan},

+ 7 - 0
internal/sub/sub.go

@@ -297,6 +297,13 @@ func (s *Server) Start() (err error) {
 
 	s.httpServer = &http.Server{
 		Handler: engine,
+		// The subscription server is the most exposed (public) listener; without
+		// these a few slow-header connections exhaust it (Slowloris). Mirrors the
+		// panel server timeouts in internal/web/web.go.
+		ReadHeaderTimeout: 5 * time.Second,
+		ReadTimeout:       30 * time.Second,
+		WriteTimeout:      30 * time.Second,
+		IdleTimeout:       120 * time.Second,
 	}
 
 	go func() {

+ 15 - 0
internal/util/common/err.go

@@ -4,6 +4,7 @@ package common
 import (
 	"errors"
 	"fmt"
+	"runtime/debug"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 )
@@ -30,3 +31,17 @@ func Recover(msg string) any {
 	}
 	return panicErr
 }
+
+// GoRecover runs fn in a new goroutine guarded by a recover, so a panic in a
+// background goroutine is logged (with name and a stack trace) instead of taking
+// the whole process down. name identifies the goroutine in the log.
+func GoRecover(name string, fn func()) {
+	go func() {
+		defer func() {
+			if r := recover(); r != nil {
+				logger.Error("panic in goroutine", name, ":", r, "\n"+string(debug.Stack()))
+			}
+		}()
+		fn()
+	}()
+}

+ 41 - 0
internal/util/common/gorecover_test.go

@@ -0,0 +1,41 @@
+package common
+
+import (
+	"os"
+	"testing"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+	"github.com/op/go-logging"
+)
+
+func TestMain(m *testing.M) {
+	logger.InitLogger(logging.ERROR)
+	os.Exit(m.Run())
+}
+
+func TestGoRecover_RunsFn(t *testing.T) {
+	done := make(chan struct{})
+	GoRecover("test-run", func() { close(done) })
+	select {
+	case <-done:
+	case <-time.After(2 * time.Second):
+		t.Fatal("fn did not run")
+	}
+}
+
+func TestGoRecover_RecoversPanic(t *testing.T) {
+	done := make(chan struct{})
+	// If GoRecover did not recover, this panic would crash the test binary.
+	GoRecover("test-panic", func() {
+		defer close(done)
+		panic("boom")
+	})
+	select {
+	case <-done:
+	case <-time.After(2 * time.Second):
+		t.Fatal("goroutine did not complete")
+	}
+	// Let the deferred recover+log run before the test ends.
+	time.Sleep(50 * time.Millisecond)
+}

+ 4 - 2
internal/web/job/node_heartbeat_job.go

@@ -9,6 +9,7 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/websocket"
 )
@@ -50,11 +51,12 @@ func (j *NodeHeartbeatJob) Run() {
 		}
 		wg.Add(1)
 		sem <- struct{}{}
-		go func(n *model.Node) {
+		n := n
+		common.GoRecover("node-heartbeat:"+n.Name, func() {
 			defer wg.Done()
 			defer func() { <-sem }()
 			j.probeOne(n)
-		}(n)
+		})
 	}
 	wg.Wait()
 

+ 7 - 5
internal/web/job/node_traffic_sync_job.go

@@ -8,10 +8,10 @@ import (
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/websocket"
-	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 )
 
 const (
@@ -96,11 +96,12 @@ func (j *NodeTrafficSyncJob) Run() {
 		}
 		wg.Add(1)
 		sem <- struct{}{}
-		go func(n *model.Node) {
+		n := n
+		common.GoRecover("node-traffic-sync:"+n.Name, func() {
 			defer wg.Done()
 			defer func() { <-sem }()
 			j.syncOne(mgr, n, doIpSync)
-		}(n)
+		})
 	}
 	wg.Wait()
 
@@ -211,7 +212,8 @@ func (j *NodeTrafficSyncJob) maybePushGlobals(mgr *runtime.Manager, nodes []*mod
 		}
 		wg.Add(1)
 		sem <- struct{}{}
-		go func(n *model.Node, remote *runtime.Remote, traffics []*xray.ClientTraffic) {
+		n, remote, traffics := n, remote, traffics
+		common.GoRecover("node-global-push:"+n.Name, func() {
 			defer wg.Done()
 			defer func() { <-sem }()
 			ctx, cancel := context.WithTimeout(context.Background(), nodeTrafficSyncRequestTimeout)
@@ -225,7 +227,7 @@ func (j *NodeTrafficSyncJob) maybePushGlobals(mgr *runtime.Manager, nodes []*mod
 					logger.Warning("node traffic sync: push globals to", n.Name, "failed:", err)
 				}
 			}
-		}(n, remote, traffics)
+		})
 	}
 	wg.Wait()
 }

+ 81 - 9
internal/web/runtime/remote.go

@@ -28,6 +28,38 @@ const remoteHTTPTimeout = 10 * time.Second
 // overhead can outweigh the savings.
 const zstdMinBodyBytes = 1024
 
+// maxRemoteResponseBytes caps a single node RPC's response body. It bounds the
+// wire/decompressed size of one response — the real guard against a broken or
+// hostile node streaming an unbounded body. It is NOT a process-wide memory
+// bound: concurrent RPCs and the decoded JSON can each exceed it, so
+// endpoint-specific caps and a concurrency budget remain follow-ups. Node
+// responses (traffic snapshots, client-IP lists, inbound options) are JSON and
+// stay well under it.
+const maxRemoteResponseBytes = 64 << 20 // 64 MiB
+
+// errBodyDiagBytes bounds how much of a non-OK error body we read for a
+// diagnostic snippet (and to let small-error connections be reused) without
+// buffering a potentially huge or hostile error payload.
+const errBodyDiagBytes = 8 << 10 // 8 KiB
+
+// errRemoteResponseTooLarge is returned when a node response exceeds the cap.
+var errRemoteResponseTooLarge = errors.New("remote response exceeds size limit")
+
+// readCappedBody reads all of r but rejects bodies larger than limit, returning
+// errRemoteResponseTooLarge. It reads at most limit+1 bytes so a body of exactly
+// limit is accepted and the first oversize byte is detected without buffering
+// more.
+func readCappedBody(r io.Reader, limit int64) ([]byte, error) {
+	raw, err := io.ReadAll(io.LimitReader(r, limit+1))
+	if err != nil {
+		return nil, err
+	}
+	if int64(len(raw)) > limit {
+		return nil, errRemoteResponseTooLarge
+	}
+	return raw, nil
+}
+
 type envelope struct {
 	Success bool            `json:"success"`
 	Msg     string          `json:"msg"`
@@ -203,14 +235,34 @@ func (r *Remote) do(ctx context.Context, method, path string, body any) (*envelo
 	defer resp.Body.Close()
 	r.recordCaps(resp.Header)
 
-	raw, err := io.ReadAll(resp.Body)
-	if err != nil {
-		return nil, fmt.Errorf("read body: %w", err)
-	}
+	// Validate status before reading a success payload: a non-OK response's
+	// body is never used beyond a short diagnostic, so don't let a node force us
+	// to buffer a large body just to return an HTTP error.
 	if resp.StatusCode != http.StatusOK {
+		snippet, _ := io.ReadAll(io.LimitReader(resp.Body, errBodyDiagBytes))
+		if msg := bytes.TrimSpace(snippet); len(msg) > 0 {
+			// %q quotes/escapes the untrusted node body so control characters or
+			// newlines in it can't garble or inject into the error/log output.
+			return nil, fmt.Errorf("%s %s: HTTP %d: %q", method, path, resp.StatusCode, msg)
+		}
 		return nil, fmt.Errorf("%s %s: HTTP %d", method, path, resp.StatusCode)
 	}
 
+	// Fast-fail on an honestly-declared oversize body; the LimitReader below is
+	// the real guard since Content-Length is untrusted, may be absent, or is -1
+	// under transparent decompression.
+	if resp.ContentLength > maxRemoteResponseBytes {
+		return nil, fmt.Errorf("%s %s: %w (content-length %d, cap %d)", method, path, errRemoteResponseTooLarge, resp.ContentLength, maxRemoteResponseBytes)
+	}
+
+	raw, err := readCappedBody(resp.Body, maxRemoteResponseBytes)
+	if err != nil {
+		if errors.Is(err, errRemoteResponseTooLarge) {
+			return nil, fmt.Errorf("%s %s: %w (cap %d bytes)", method, path, err, maxRemoteResponseBytes)
+		}
+		return nil, fmt.Errorf("read body: %w", err)
+	}
+
 	var env envelope
 	if err := json.Unmarshal(raw, &env); err != nil {
 		return nil, fmt.Errorf("decode envelope: %w", err)
@@ -234,6 +286,22 @@ func (r *Remote) resolveRemoteID(ctx context.Context, tag string) (int, error) {
 	return 0, fmt.Errorf("remote inbound with tag %q not found on node %s", tag, r.node.Name)
 }
 
+// nodeInboundTagPrefix is the central-panel alias for an inbound on nodeID.
+// Kept in sync with service.nodeTagPrefix (port_conflict.go); duplicated here
+// so runtime does not import service.
+func nodeInboundTagPrefix(nodeID int) string {
+	return fmt.Sprintf("n%d-", nodeID)
+}
+
+// stripNodeInboundTagPrefix removes the central-only n<id>- prefix before
+// pushing an inbound to the node so Xray keeps its original tag and routing.
+func stripNodeInboundTagPrefix(nodeID int, tag string) string {
+	if stripped, ok := strings.CutPrefix(tag, nodeInboundTagPrefix(nodeID)); ok {
+		return stripped
+	}
+	return tag
+}
+
 // cacheGetTag looks up a remote inbound id by tag, tolerating an n<id>- prefix
 // that lives on only one of the two panels: the node may carry the bare tag
 // while the central panel stores the prefixed form, or vice versa.
@@ -241,7 +309,7 @@ func (r *Remote) cacheGetTag(tag string) (int, bool) {
 	if id, ok := r.cacheGet(tag); ok {
 		return id, true
 	}
-	prefix := fmt.Sprintf("n%d-", r.node.Id)
+	prefix := nodeInboundTagPrefix(r.node.Id)
 	if stripped, found := strings.CutPrefix(tag, prefix); found {
 		return r.cacheGet(stripped)
 	}
@@ -318,7 +386,7 @@ func (r *Remote) refreshRemoteIDs(ctx context.Context) error {
 }
 
 func (r *Remote) AddInbound(ctx context.Context, ib *model.Inbound) error {
-	payload := wireInbound(ib)
+	payload := wireInbound(ib, r.node.Id)
 	env, err := r.do(ctx, http.MethodPost, "panel/api/inbounds/add", payload)
 	if err != nil {
 		return err
@@ -353,7 +421,7 @@ func (r *Remote) UpdateInbound(ctx context.Context, oldIb, newIb *model.Inbound)
 	if err != nil {
 		return r.AddInbound(ctx, newIb)
 	}
-	payload := wireInbound(newIb)
+	payload := wireInbound(newIb, r.node.Id)
 	if _, err := r.do(ctx, http.MethodPost, "panel/api/inbounds/update/"+strconv.Itoa(id), payload); err != nil {
 		return err
 	}
@@ -557,7 +625,7 @@ func (r *Remote) PushGlobalClientTraffics(ctx context.Context, masterGuid string
 	return err
 }
 
-func wireInbound(ib *model.Inbound) url.Values {
+func wireInbound(ib *model.Inbound, remoteNodeID int) url.Values {
 	v := url.Values{}
 	v.Set("total", strconv.FormatInt(ib.Total, 10))
 	v.Set("remark", ib.Remark)
@@ -569,7 +637,11 @@ func wireInbound(ib *model.Inbound) url.Values {
 	v.Set("protocol", string(ib.Protocol))
 	v.Set("settings", ib.Settings)
 	v.Set("streamSettings", sanitizeStreamSettingsForRemote(ib.StreamSettings))
-	v.Set("tag", ib.Tag)
+	tag := ib.Tag
+	if remoteNodeID > 0 {
+		tag = stripNodeInboundTagPrefix(remoteNodeID, tag)
+	}
+	v.Set("tag", tag)
 	v.Set("sniffing", ib.Sniffing)
 	shareAddrStrategy := strings.TrimSpace(ib.ShareAddrStrategy)
 	switch shareAddrStrategy {

+ 131 - 5
internal/web/runtime/remote_test.go

@@ -1,16 +1,112 @@
 package runtime
 
 import (
+	"bytes"
 	"context"
 	"encoding/json"
+	"errors"
 	"net/http"
 	"net/http/httptest"
 	"net/url"
+	"strings"
 	"testing"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 )
 
+// TestRemoteDo_RejectsOversizeResponse: a node streaming a body larger than
+// maxRemoteResponseBytes must error out instead of the master buffering it
+// unbounded.
+func TestRemoteDo_RejectsOversizeResponse(t *testing.T) {
+	srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+		w.WriteHeader(http.StatusOK)
+		chunk := bytes.Repeat([]byte("a"), 1<<20) // 1 MiB
+		for written := 0; written <= maxRemoteResponseBytes; written += len(chunk) {
+			if _, err := w.Write(chunk); err != nil {
+				return // client stopped reading at the cap
+			}
+		}
+	}))
+	defer srv.Close()
+
+	r := NewRemote(nodeForServer(t, srv, "skip", ""), nil)
+	if _, err := r.do(context.Background(), http.MethodGet, "/probe", nil); !errors.Is(err, errRemoteResponseTooLarge) {
+		t.Fatalf("do() error = %v, want errRemoteResponseTooLarge", err)
+	}
+}
+
+// TestRemoteDo_AcceptsNormalResponse confirms the cap does not break a normal
+// under-limit envelope.
+func TestRemoteDo_AcceptsNormalResponse(t *testing.T) {
+	srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+		w.Header().Set("Content-Type", "application/json")
+		_, _ = w.Write([]byte(`{"success":true,"msg":"ok","obj":{"x":1}}`))
+	}))
+	defer srv.Close()
+
+	r := NewRemote(nodeForServer(t, srv, "skip", ""), nil)
+	env, err := r.do(context.Background(), http.MethodGet, "/probe", nil)
+	if err != nil {
+		t.Fatalf("do() unexpected error: %v", err)
+	}
+	if env == nil || !env.Success {
+		t.Fatalf("env = %+v, want Success=true", env)
+	}
+}
+
+// TestReadCappedBody_Boundary pins the cap+1 contract cheaply (no large allocs):
+// a body of exactly limit is accepted; limit+1 and beyond are rejected.
+func TestReadCappedBody_Boundary(t *testing.T) {
+	const limit = 8
+	cases := []struct {
+		name    string
+		n       int
+		wantErr bool
+	}{
+		{"under", limit - 1, false},
+		{"exact", limit, false},
+		{"over-by-one", limit + 1, true},
+		{"way-over", limit * 4, true},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			raw, err := readCappedBody(bytes.NewReader(bytes.Repeat([]byte("x"), c.n)), limit)
+			if c.wantErr {
+				if !errors.Is(err, errRemoteResponseTooLarge) {
+					t.Fatalf("n=%d: err=%v, want errRemoteResponseTooLarge", c.n, err)
+				}
+				return
+			}
+			if err != nil {
+				t.Fatalf("n=%d: unexpected err %v", c.n, err)
+			}
+			if len(raw) != c.n {
+				t.Fatalf("n=%d: read %d bytes, want %d", c.n, len(raw), c.n)
+			}
+		})
+	}
+}
+
+// TestRemoteDo_NonOKStatusReturnsHTTPError confirms a non-OK status is reported
+// as an HTTP error (with a bounded diagnostic snippet) rather than being read as
+// a success payload — i.e. status precedence over the body.
+func TestRemoteDo_NonOKStatusReturnsHTTPError(t *testing.T) {
+	srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+		w.WriteHeader(http.StatusInternalServerError)
+		_, _ = w.Write([]byte("boom"))
+	}))
+	defer srv.Close()
+
+	r := NewRemote(nodeForServer(t, srv, "skip", ""), nil)
+	_, err := r.do(context.Background(), http.MethodGet, "/probe", nil)
+	if err == nil {
+		t.Fatal("do() error = nil, want HTTP 500 error")
+	}
+	if !strings.Contains(err.Error(), "HTTP 500") || !strings.Contains(err.Error(), "boom") {
+		t.Fatalf("error = %q, want it to mention HTTP 500 and the body snippet", err)
+	}
+}
+
 type stubEgress struct{ url string }
 
 func (s stubEgress) NodeEgressProxyURL(int) string { return s.url }
@@ -48,7 +144,7 @@ func TestWireInboundIncludesShareAddressFields(t *testing.T) {
 	values := wireInbound(&model.Inbound{
 		ShareAddrStrategy: "custom",
 		ShareAddr:         "edge.example.com",
-	})
+	}, 0)
 
 	if got := values.Get("shareAddrStrategy"); got != "custom" {
 		t.Fatalf("shareAddrStrategy = %q, want custom", got)
@@ -156,30 +252,60 @@ func TestIsNonEmptySlice(t *testing.T) {
 }
 
 func TestWireInboundTrafficReset(t *testing.T) {
-	with := wireInbound(&model.Inbound{TrafficReset: "daily"})
+	with := wireInbound(&model.Inbound{TrafficReset: "daily"}, 0)
 	if got := with.Get("trafficReset"); got != "daily" {
 		t.Fatalf("trafficReset = %q, want daily", got)
 	}
 	// Empty TrafficReset must be omitted entirely, not sent as an empty field.
-	without := wireInbound(&model.Inbound{})
+	without := wireInbound(&model.Inbound{}, 0)
 	if without.Has("trafficReset") {
 		t.Fatalf("trafficReset must be omitted when empty, got %q", without.Get("trafficReset"))
 	}
 }
 
 func TestWireInboundDefaultsShareAddressStrategy(t *testing.T) {
-	values := wireInbound(&model.Inbound{})
+	values := wireInbound(&model.Inbound{}, 0)
 
 	if got := values.Get("shareAddrStrategy"); got != "node" {
 		t.Fatalf("shareAddrStrategy = %q, want node", got)
 	}
 
-	values = wireInbound(&model.Inbound{ShareAddrStrategy: "auto"})
+	values = wireInbound(&model.Inbound{ShareAddrStrategy: "auto"}, 0)
 	if got := values.Get("shareAddrStrategy"); got != "node" {
 		t.Fatalf("invalid shareAddrStrategy = %q, want node", got)
 	}
 }
 
+func TestStripNodeInboundTagPrefix(t *testing.T) {
+	cases := []struct {
+		nodeID int
+		tag    string
+		want   string
+	}{
+		{2, "n2-in-443-tcp", "in-443-tcp"},
+		{2, "in-443-tcp", "in-443-tcp"},
+		{2, "my-custom", "my-custom"},
+		{2, "n3-in-443-tcp", "n3-in-443-tcp"},
+		{0, "n2-in-443-tcp", "n2-in-443-tcp"},
+	}
+	for _, c := range cases {
+		if got := stripNodeInboundTagPrefix(c.nodeID, c.tag); got != c.want {
+			t.Fatalf("stripNodeInboundTagPrefix(%d, %q) = %q, want %q", c.nodeID, c.tag, got, c.want)
+		}
+	}
+}
+
+func TestWireInboundStripsNodeTagOnPush(t *testing.T) {
+	values := wireInbound(&model.Inbound{Tag: "n2-in-443-tcp"}, 2)
+	if got := values.Get("tag"); got != "in-443-tcp" {
+		t.Fatalf("tag = %q, want in-443-tcp", got)
+	}
+	values = wireInbound(&model.Inbound{Tag: "n2-in-443-tcp"}, 0)
+	if got := values.Get("tag"); got != "n2-in-443-tcp" {
+		t.Fatalf("nodeID 0 must not strip, got %q", got)
+	}
+}
+
 func TestSanitizeStreamSettingsForRemote(t *testing.T) {
 	tests := []struct {
 		name  string

+ 17 - 10
internal/web/service/client_traffic.go

@@ -121,22 +121,29 @@ func (s *ClientService) resetAllClientTrafficsLocked(id int) error {
 	now := time.Now().Unix() * 1000
 
 	if err := db.Transaction(func(tx *gorm.DB) error {
-		whereText := "inbound_id "
+		// client_traffics.inbound_id is stale: it reflects the inbound the row was
+		// first inserted under and is never refreshed. Use the client_inbounds join
+		// as the authoritative source for which emails belong to a given inbound.
+		var resetEmails []string
 		if id == -1 {
-			whereText += " > ?"
+			if err := tx.Model(xray.ClientTraffic{}).Pluck("email", &resetEmails).Error; err != nil {
+				return err
+			}
 		} else {
-			whereText += " = ?"
+			if err := tx.Table("client_inbounds ci").
+				Select("c.email").
+				Joins("JOIN clients c ON c.id = ci.client_id").
+				Where("ci.inbound_id = ?", id).
+				Pluck("c.email", &resetEmails).Error; err != nil {
+				return err
+			}
 		}
-
-		var resetEmails []string
-		if err := tx.Model(xray.ClientTraffic{}).
-			Where(whereText, id).
-			Pluck("email", &resetEmails).Error; err != nil {
-			return err
+		if len(resetEmails) == 0 {
+			return nil
 		}
 
 		result := tx.Model(xray.ClientTraffic{}).
-			Where(whereText, id).
+			Where("email IN ?", resetEmails).
 			Updates(map[string]any{"enable": true, "up": 0, "down": 0})
 
 		if result.Error != nil {

+ 21 - 0
internal/web/service/inbound.go

@@ -785,6 +785,16 @@ func (s *InboundService) DelInbound(id int) (bool, error) {
 		return false, err
 	}
 
+	// Drop the deleted inbound's tag from any routing rules / loopback outbounds
+	// in xrayTemplateConfig so they don't point at a tag that no longer exists.
+	if loadErr == nil && ib.Tag != "" {
+		if routingChanged, syncErr := (&XraySettingService{}).RemoveInboundTagReferences(ib.Tag); syncErr != nil {
+			logger.Warning("DelInbound: sync routing on inbound delete failed:", syncErr)
+		} else if routingChanged {
+			needRestart = true
+		}
+	}
+
 	if err := db.Delete(model.Inbound{}, id).Error; err != nil {
 		return needRestart, err
 	}
@@ -1158,6 +1168,17 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
 	if txErr != nil {
 		return inbound, false, txErr
 	}
+	// After the rename is committed, point any routing rules / loopback outbounds
+	// in xrayTemplateConfig at the new tag (oldInbound.Tag now holds the resolved
+	// new tag; tag holds the pre-edit one). Done post-commit so a sync failure
+	// can't roll back the inbound edit.
+	if tag != oldInbound.Tag {
+		if routingChanged, syncErr := (&XraySettingService{}).PropagateInboundTagRename(tag, oldInbound.Tag); syncErr != nil {
+			logger.Warning("UpdateInbound: sync routing on tag rename failed:", syncErr)
+		} else if routingChanged {
+			needRestart = true
+		}
+	}
 	if markDirty && oldInbound.NodeID != nil {
 		if dErr := (&NodeService{}).MarkNodeDirty(*oldInbound.NodeID); dErr != nil {
 			logger.Warning("mark node dirty failed:", dErr)

+ 2 - 2
internal/web/service/inbound_node.go

@@ -559,8 +559,8 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 					Total:      cs.Total,
 					ExpiryTime: cs.ExpiryTime,
 					Reset:      cs.Reset,
-					Up:         canon.Up,
-					Down:       canon.Down,
+					Up:         0,
+					Down:       0,
 					LastOnline: cs.LastOnline,
 				}
 				if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "email"}}, DoNothing: true}).

+ 43 - 15
internal/web/service/inbound_traffic.go

@@ -133,9 +133,7 @@ func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTr
 		return err
 	}
 
-	// Index by email for O(N) merge — the previous nested loop was O(N²)
-	// and dominated each cron tick on inbounds with thousands of active
-	// clients (7500 × 7500 = 56M string comparisons every 10 seconds).
+	// Index by email for O(N) merge.
 	trafficByEmail := make(map[string]*xray.ClientTraffic, len(traffics))
 	for i := range traffics {
 		if traffics[i] != nil {
@@ -143,21 +141,39 @@ func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTr
 		}
 	}
 	now := time.Now().UnixMilli()
-	for dbTraffic_index := range dbClientTraffics {
-		t, ok := trafficByEmail[dbClientTraffics[dbTraffic_index].Email]
-		if !ok {
+	// Use atomic per-row UPDATE instead of read-modify-write Save. tx.Save
+	// issues UPDATEs in slice order, which varies between concurrent callers;
+	// on PostgreSQL two transactions locking the same rows in opposite order
+	// deadlock. An atomic "SET up = up + ?" never holds a row lock across a
+	// subsequent lock acquisition, so concurrent writers cannot deadlock.
+	for _, ct := range dbClientTraffics {
+		t, ok := trafficByEmail[ct.Email]
+		if !ok || (t.Up == 0 && t.Down == 0) {
 			continue
 		}
-		dbClientTraffics[dbTraffic_index].Up += t.Up
-		dbClientTraffics[dbTraffic_index].Down += t.Down
-		if t.Up+t.Down > 0 {
-			dbClientTraffics[dbTraffic_index].LastOnline = now
+		if err = tx.Exec(
+			fmt.Sprintf(
+				`UPDATE client_traffics SET up = up + ?, down = down + ?, last_online = %s WHERE email = ?`,
+				database.GreatestExpr("last_online", "?"),
+			),
+			t.Up, t.Down, now, ct.Email,
+		).Error; err != nil {
+			logger.Warning("AddClientTraffic update data ", err)
 		}
 	}
 
-	err = tx.Save(dbClientTraffics).Error
-	if err != nil {
-		logger.Warning("AddClientTraffic update data ", err)
+	// adjustTraffics converts delayed-start rows (negative ExpiryTime → absolute
+	// deadline) in-memory. Persist that conversion now since the traffic UPDATE
+	// above only touches up/down/last_online.
+	for _, ct := range dbClientTraffics {
+		if ct.ExpiryTime > 0 {
+			if err = tx.Exec(
+				`UPDATE client_traffics SET expiry_time = ? WHERE email = ? AND expiry_time < 0`,
+				ct.ExpiryTime, ct.Email,
+			).Error; err != nil {
+				logger.Warning("AddClientTraffic update expiry_time ", err)
+			}
+		}
 	}
 
 	return nil
@@ -272,9 +288,18 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB) (bool, int64, error) {
 	now := time.Now().Unix() * 1000
 	var err, err1 error
 
+	// Filter to clients that have at least one local inbound. Using
+	// client_traffics.inbound_id is wrong: it goes stale after an inbound is
+	// deleted/recreated and always points to the first inbound the client was
+	// attached to, so it could be a node inbound even when the client also has
+	// local inbounds. The email-based join through client_inbounds is authoritative.
 	err = tx.Model(xray.ClientTraffic{}).
 		Where("reset > 0 and expiry_time > 0 and expiry_time <= ?", now).
-		Where("inbound_id NOT IN (?)", tx.Model(&model.Inbound{}).Select("id").Where("node_id IS NOT NULL")).
+		Where("email IN (?)", tx.Table("client_inbounds ci").
+			Select("c.email").
+			Joins("JOIN clients c ON c.id = ci.client_id").
+			Joins("JOIN inbounds i ON i.id = ci.inbound_id").
+			Where("i.node_id IS NULL")).
 		Find(&traffics).Error
 	if err != nil {
 		return false, 0, err
@@ -326,7 +351,10 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB) (bool, int64, error) {
 	for inbound_index := range inbounds {
 		settings := map[string]any{}
 		json.Unmarshal([]byte(inbounds[inbound_index].Settings), &settings)
-		clients := settings["clients"].([]any)
+		clients, _ := settings["clients"].([]any)
+		if len(clients) == 0 {
+			continue
+		}
 		for client_index := range clients {
 			c := clients[client_index].(map[string]any)
 			for traffic_index, traffic := range traffics {

+ 27 - 8
internal/web/service/node.go

@@ -24,6 +24,8 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
 	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+
+	"gorm.io/gorm"
 )
 
 type HeartbeatPatch struct {
@@ -439,6 +441,17 @@ func FilterNodeSnapshot(n *model.Node, snap *runtime.TrafficSnapshot) {
 
 func (s *NodeService) Delete(id int) error {
 	db := database.GetDB()
+	// Refuse to delete a node that still owns inbounds: dropping the node row
+	// while inbounds keep its node_id leaves orphaned, dangling references that
+	// confuse node sync, subscriptions and cleanup. The operator must detach or
+	// remove those inbounds first. (DB-002)
+	var attached int64
+	if err := db.Model(&model.Inbound{}).Where("node_id = ?", id).Count(&attached).Error; err != nil {
+		return err
+	}
+	if attached > 0 {
+		return common.NewError(fmt.Sprintf("cannot delete node: %d inbound(s) still attached to it; detach or delete them first", attached))
+	}
 	// Capture the node's guid before deleting the row so we can drop its per-node
 	// IP attribution (NodeClientIp is keyed by guid, not node id).
 	var guid string
@@ -446,16 +459,22 @@ func (s *NodeService) Delete(id int) error {
 	if err := db.Select("guid").Where("id = ?", id).First(&n).Error; err == nil {
 		guid = n.Guid
 	}
-	if err := db.Where("id = ?", id).Delete(model.Node{}).Error; err != nil {
-		return err
-	}
-	if err := db.Where("node_id = ?", id).Delete(&model.NodeClientTraffic{}).Error; err != nil {
-		return err
-	}
-	if guid != "" {
-		if err := db.Where("node_guid = ?", guid).Delete(&model.NodeClientIp{}).Error; err != nil {
+	// Delete the node row and its per-node child rows atomically. Remove the
+	// children (traffic baselines, IP attribution) before the parent node row so
+	// the ordering already matches a future ON DELETE constraint. Delete stays
+	// tolerant of a missing node row so it can still clean up orphaned baselines.
+	if err := db.Transaction(func(tx *gorm.DB) error {
+		if err := tx.Where("node_id = ?", id).Delete(&model.NodeClientTraffic{}).Error; err != nil {
 			return err
 		}
+		if guid != "" {
+			if err := tx.Where("node_guid = ?", guid).Delete(&model.NodeClientIp{}).Error; err != nil {
+				return err
+			}
+		}
+		return tx.Where("id = ?", id).Delete(&model.Node{}).Error
+	}); err != nil {
+		return err
 	}
 	if mgr := runtime.GetManager(); mgr != nil {
 		mgr.InvalidateNode(id)

+ 60 - 16
internal/web/service/node_client_traffic_sum_test.go

@@ -92,15 +92,17 @@ func TestTwoNodesShareEmail_SumsCorrectly(t *testing.T) {
 
 	const email = "shared"
 
+	// First-ever sync from each node: the row is created at 0 and the current
+	// node counters become the baseline. Only deltas beyond those baselines count.
 	syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 100, Down: 100, Enable: true})
 	syncNode(t, svc, 2, "n2-in", xray.ClientTraffic{Email: email, Up: 200, Down: 200, Enable: true})
 
-	assertUpDown(t, readTraffic(t, db, email), 100, 100, "after baselines")
+	assertUpDown(t, readTraffic(t, db, email), 0, 0, "after baselines — historical node traffic not imported")
 
 	syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 150, Down: 150, Enable: true})
 	syncNode(t, svc, 2, "n2-in", xray.ClientTraffic{Email: email, Up: 260, Down: 260, Enable: true})
 
-	assertUpDown(t, readTraffic(t, db, email), 210, 210, "after both nodes grow")
+	assertUpDown(t, readTraffic(t, db, email), 110, 110, "after both nodes grow — deltas (50+60) accrue")
 }
 
 func TestSingleNode_MirrorsCorrectly(t *testing.T) {
@@ -109,11 +111,13 @@ func TestSingleNode_MirrorsCorrectly(t *testing.T) {
 	svc := &InboundService{}
 
 	const email = "solo"
+	// First sync: row seeded at 0, current counter becomes the baseline.
 	syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 500, Down: 600, Enable: true})
-	assertUpDown(t, readTraffic(t, db, email), 500, 600, "first sync")
+	assertUpDown(t, readTraffic(t, db, email), 0, 0, "first sync — historical traffic not imported")
 
+	// Second sync: delta (700-500=200 / 800-600=200) accrues normally.
 	syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 700, Down: 800, Enable: true})
-	assertUpDown(t, readTraffic(t, db, email), 700, 800, "second sync mirrors cumulative")
+	assertUpDown(t, readTraffic(t, db, email), 200, 200, "second sync — delta accrues")
 }
 
 func TestUpgrade_PreExistingRow_NoDoubleCount(t *testing.T) {
@@ -137,22 +141,52 @@ func TestUpgrade_PreExistingRow_NoDoubleCount(t *testing.T) {
 	assertUpDown(t, readTraffic(t, db, email), 1100, 2100, "growth after upgrade accrues")
 }
 
+// TestGhostData_NoPhantomTraffic reproduces the 50 GB phantom traffic bug:
+// a node retains stale data for a deleted client (e.g. de-1 was offline during
+// deletion). When the master first syncs this email again it must NOT import the
+// stale counter — it sets Up=0 and records the current node value as the
+// baseline so only future deltas count.
+func TestGhostData_NoPhantomTraffic(t *testing.T) {
+	db := initTrafficTestDB(t)
+	createNodeInbound(t, db, 1, "de1-in", 41001)
+	svc := &InboundService{}
+
+	const email = "pouya"
+	const staleBytes int64 = 50 * 1024 * 1024 * 1024 // 50 GB stale on de-1
+
+	// Node reports a client with a large pre-existing counter (ghost data from a
+	// deleted account that survived on the node). Master has no row for this email.
+	syncNode(t, svc, 1, "de1-in", xray.ClientTraffic{Email: email, Up: staleBytes, Down: staleBytes, Enable: true})
+
+	ct := readTraffic(t, db, email)
+	if ct.Up >= staleBytes || ct.Down >= staleBytes {
+		t.Errorf("ghost data imported: up=%d down=%d; stale node counter must not count as new traffic", ct.Up, ct.Down)
+	}
+	assertUpDown(t, ct, 0, 0, "first sync of ghost email — imported as 0, not 50 GB")
+
+	// Subsequent syncs only add incremental usage beyond the baseline.
+	syncNode(t, svc, 1, "de1-in", xray.ClientTraffic{Email: email, Up: staleBytes + 1024, Down: staleBytes + 2048, Enable: true})
+	assertUpDown(t, readTraffic(t, db, email), 1024, 2048, "only incremental traffic beyond baseline counts")
+}
+
 func TestNodeCounterReset_Clamped(t *testing.T) {
 	db := initTrafficTestDB(t)
 	createNodeInbound(t, db, 1, "n1-in", 41001)
 	svc := &InboundService{}
 
 	const email = "restart"
+	// First sync seeds at 0 (baseline=900). Second sync adds delta 950-900=50.
 	syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 900, Down: 900, Enable: true})
 	syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 950, Down: 950, Enable: true})
-	assertUpDown(t, readTraffic(t, db, email), 950, 950, "before node reset")
+	assertUpDown(t, readTraffic(t, db, email), 50, 50, "before node reset")
 
+	// Counter resets to 50 (Xray restart). delta=50-950=-900 → clamped → adds 50.
 	syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 50, Down: 50, Enable: true})
 	ct := readTraffic(t, db, email)
 	if ct.Up < 0 || ct.Down < 0 {
 		t.Fatalf("row went negative after node reset: up=%d down=%d", ct.Up, ct.Down)
 	}
-	assertUpDown(t, ct, 1000, 1000, "after node counter reset (clamped)")
+	assertUpDown(t, ct, 100, 100, "after node counter reset (clamped)")
 }
 
 func TestCentralReset_NoReAdd(t *testing.T) {
@@ -186,11 +220,13 @@ func TestInboundRemoval_KeepsSharedEmailRow(t *testing.T) {
 
 	const email = "shared"
 	settings := fmt.Sprintf(`{"clients": [{"email": %q, "enable": true}]}`, email)
+	// Baselines set: node1=100, node2=200. Row seeded at 0.
 	syncNodeWithSettings(t, svc, 1, "n1-in", settings, xray.ClientTraffic{Email: email, Up: 100, Down: 100, Enable: true})
 	syncNodeWithSettings(t, svc, 2, "n2-in", settings, xray.ClientTraffic{Email: email, Up: 200, Down: 200, Enable: true})
+	// node1 delta=50, node2 delta=60 → total=110.
 	syncNodeWithSettings(t, svc, 1, "n1-in", settings, xray.ClientTraffic{Email: email, Up: 150, Down: 150, Enable: true})
 	syncNodeWithSettings(t, svc, 2, "n2-in", settings, xray.ClientTraffic{Email: email, Up: 260, Down: 260, Enable: true})
-	assertUpDown(t, readTraffic(t, db, email), 210, 210, "baseline sum")
+	assertUpDown(t, readTraffic(t, db, email), 110, 110, "baseline sum")
 
 	// Node 1 rebuilt (reinstall / another master's reconcile): its inbound
 	// vanishes from the snapshot. The shared accumulator must survive — losing
@@ -199,11 +235,11 @@ func TestInboundRemoval_KeepsSharedEmailRow(t *testing.T) {
 	if _, err := svc.setRemoteTrafficLocked(1, &runtime.TrafficSnapshot{}, false); err != nil {
 		t.Fatalf("sync node 1 with empty snapshot: %v", err)
 	}
-	assertUpDown(t, readTraffic(t, db, email), 210, 210, "after node 1 inbound removal")
+	assertUpDown(t, readTraffic(t, db, email), 110, 110, "after node 1 inbound removal")
 
-	// Node 2 keeps accruing onto the surviving row.
+	// Node 2 keeps accruing onto the surviving row. node2 delta=300-260=40 → 150.
 	syncNodeWithSettings(t, svc, 2, "n2-in", settings, xray.ClientTraffic{Email: email, Up: 300, Down: 300, Enable: true})
-	assertUpDown(t, readTraffic(t, db, email), 250, 250, "after node 2 grows")
+	assertUpDown(t, readTraffic(t, db, email), 150, 150, "after node 2 grows")
 }
 
 func TestClientGoneFromOneNode_KeepsSharedEmailRow(t *testing.T) {
@@ -214,16 +250,18 @@ func TestClientGoneFromOneNode_KeepsSharedEmailRow(t *testing.T) {
 
 	const email = "shared"
 	settings := fmt.Sprintf(`{"clients": [{"email": %q, "enable": true}]}`, email)
+	// First syncs set baselines (node1=100, node2=200). Row seeded at 0.
 	syncNodeWithSettings(t, svc, 1, "n1-in", settings, xray.ClientTraffic{Email: email, Up: 100, Down: 100, Enable: true})
 	syncNodeWithSettings(t, svc, 2, "n2-in", settings, xray.ClientTraffic{Email: email, Up: 200, Down: 200, Enable: true})
 
 	// Client detached from node 1's inbound only: its stats vanish from that
 	// inbound's snapshot while node 2 still hosts the email.
 	syncNodeWithSettings(t, svc, 1, "n1-in", `{"clients": []}`)
-	assertUpDown(t, readTraffic(t, db, email), 100, 100, "after client left node 1")
+	assertUpDown(t, readTraffic(t, db, email), 0, 0, "after client left node 1 — row unchanged at 0")
 
+	// Node 2 delta: 240-200=40 → accrues to 40.
 	syncNodeWithSettings(t, svc, 2, "n2-in", settings, xray.ClientTraffic{Email: email, Up: 240, Down: 240, Enable: true})
-	assertUpDown(t, readTraffic(t, db, email), 140, 140, "node 2 keeps accruing")
+	assertUpDown(t, readTraffic(t, db, email), 40, 40, "node 2 keeps accruing")
 }
 
 // TestStatsUnderSiblingInbound_KeepsNodeBaseline reproduces the recurring
@@ -306,15 +344,21 @@ func TestMultiAttach_SameNode_DivergentSiblings(t *testing.T) {
 		}
 	}
 
+	// First-ever sync: row seeded at 0, canon (max of siblings = 100) becomes the
+	// baseline. The node total is NOT imported as historical traffic — this prevents
+	// ghost data from a previously-deleted account being counted as real usage.
 	sync(100, 50, 80)
-	assertUpDown(t, readTraffic(t, db, email), 100, 100, "first sync counts the node total once, not the sum")
+	assertUpDown(t, readTraffic(t, db, email), 0, 0, "first sync seeds at 0 — not the sum (230) nor the max (100)")
 
+	// Second sync: canon grew from 100→150, delta=50 accrues. Siblings 60 and 90
+	// do not re-add their full values — the canon clamp prevents inflation.
 	sync(150, 60, 90)
-	assertUpDown(t, readTraffic(t, db, email), 150, 150, "second sync: grew by 50, not by every sibling")
+	assertUpDown(t, readTraffic(t, db, email), 50, 50, "second sync: only the 50-unit increment counts, not every sibling")
 
-	// Equal siblings (the healthy current-schema case) must still accrue once.
+	// Equal siblings (the healthy current-schema case): canon grew from 150→200,
+	// delta=50 accrues once.
 	sync(200, 200, 200)
-	assertUpDown(t, readTraffic(t, db, email), 200, 200, "equal siblings accrue the single increment")
+	assertUpDown(t, readTraffic(t, db, email), 100, 100, "equal siblings accrue the single increment")
 }
 
 func TestDelClientStat_CleansNodeBaselines(t *testing.T) {

+ 51 - 0
internal/web/service/node_delete_orphan_test.go

@@ -0,0 +1,51 @@
+package service
+
+import (
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// TestNodeDelete_BlocksWhenInboundsAttached guards DB-002: a node that still
+// owns inbounds must not be deletable (which would orphan those inbounds with a
+// dangling node_id), while a node with none deletes cleanly together with its
+// traffic baselines.
+func TestNodeDelete_BlocksWhenInboundsAttached(t *testing.T) {
+	db := initTrafficTestDB(t)
+	svc := &NodeService{}
+
+	node := &model.Node{Name: "n1"}
+	if err := db.Create(node).Error; err != nil {
+		t.Fatalf("create node: %v", err)
+	}
+	createNodeInbound(t, db, node.Id, "n1-in-443", 443)
+
+	// With an inbound attached, Delete must fail and leave node + inbound intact.
+	if err := svc.Delete(node.Id); err == nil {
+		t.Fatal("Delete should fail while an inbound is still attached")
+	}
+	var nodeCnt, ibCnt int64
+	db.Model(&model.Node{}).Where("id = ?", node.Id).Count(&nodeCnt)
+	db.Model(&model.Inbound{}).Where("node_id = ?", node.Id).Count(&ibCnt)
+	if nodeCnt != 1 || ibCnt != 1 {
+		t.Fatalf("after blocked delete: node=%d inbound=%d, want 1/1", nodeCnt, ibCnt)
+	}
+
+	// Detach the inbound and seed a traffic baseline; Delete now succeeds and
+	// cleans the baseline.
+	if err := db.Where("node_id = ?", node.Id).Delete(&model.Inbound{}).Error; err != nil {
+		t.Fatalf("detach inbound: %v", err)
+	}
+	if err := db.Create(&model.NodeClientTraffic{NodeId: node.Id, Email: "gone"}).Error; err != nil {
+		t.Fatalf("seed baseline: %v", err)
+	}
+	if err := svc.Delete(node.Id); err != nil {
+		t.Fatalf("Delete (no inbounds attached): %v", err)
+	}
+	var baseCnt int64
+	db.Model(&model.Node{}).Where("id = ?", node.Id).Count(&nodeCnt)
+	db.Model(&model.NodeClientTraffic{}).Where("node_id = ?", node.Id).Count(&baseCnt)
+	if nodeCnt != 0 || baseCnt != 0 {
+		t.Fatalf("after delete: node=%d baseline=%d, want 0/0", nodeCnt, baseCnt)
+	}
+}

+ 62 - 0
internal/web/service/server.go

@@ -4,6 +4,8 @@ import (
 	"archive/zip"
 	"bufio"
 	"bytes"
+	"crypto/sha256"
+	"encoding/hex"
 	"encoding/json"
 	"fmt"
 	"io"
@@ -685,6 +687,9 @@ func (s *ServerService) sampleCPUUtilization() (float64, error) {
 const (
 	maxXrayArchiveBytes = 200 << 20
 	maxXrayBinaryBytes  = 200 << 20
+	// maxXrayDigestBytes caps the .dgst checksum sidecar read; it is a few
+	// hundred bytes in practice.
+	maxXrayDigestBytes = 64 << 10
 )
 
 func (s *ServerService) GetXrayVersions() ([]string, error) {
@@ -826,10 +831,67 @@ func (s *ServerService) downloadXRay(version string) (string, error) {
 		return "", fmt.Errorf("download xray: archive exceeds %d bytes", maxXrayArchiveBytes)
 	}
 
+	// Verify the archive against the SHA2-256 published in the release's .dgst
+	// sidecar before installing it. TLS protects the transport, not the artifact;
+	// a corrupted or tampered asset must not be installed and run as xray.
+	want, err := s.fetchXrayDigestSHA256(client, url+".dgst")
+	if err != nil {
+		return "", err
+	}
+	if _, err := file.Seek(0, io.SeekStart); err != nil {
+		return "", err
+	}
+	hasher := sha256.New()
+	if _, err := io.Copy(hasher, file); err != nil {
+		return "", err
+	}
+	if got := hex.EncodeToString(hasher.Sum(nil)); !strings.EqualFold(got, want) {
+		// User-facing warning: the archive's SHA-256 does not match the official
+		// release checksum, so the download is corrupted or has been tampered
+		// with. Abort the install so a bad binary is never run, and tell the user
+		// to retry/re-download rather than proceed with a mismatched image.
+		return "", fmt.Errorf("Xray update aborted: the downloaded archive does not match the official SHA-256 checksum, so the image is corrupted or differs from the official release. Please exit and re-download the official image, then try again (expected %s, got %s)", want, got)
+	}
+
 	ok = true
 	return path, nil
 }
 
+// fetchXrayDigestSHA256 downloads the .dgst sidecar XTLS publishes next to each
+// release asset and returns the SHA2-256 hex digest it lists.
+func (s *ServerService) fetchXrayDigestSHA256(client *http.Client, dgstURL string) (string, error) {
+	resp, err := client.Get(dgstURL)
+	if err != nil {
+		return "", fmt.Errorf("download xray checksum: %w", err)
+	}
+	defer resp.Body.Close()
+	if resp.StatusCode != http.StatusOK {
+		return "", fmt.Errorf("download xray checksum: unexpected HTTP %d", resp.StatusCode)
+	}
+	raw, err := io.ReadAll(io.LimitReader(resp.Body, maxXrayDigestBytes))
+	if err != nil {
+		return "", fmt.Errorf("download xray checksum: %w", err)
+	}
+	return parseXrayDigestSHA256(raw)
+}
+
+// parseXrayDigestSHA256 extracts the lowercase SHA2-256 hex from an XTLS .dgst
+// file, whose lines are "ALGO= <hex>" (the relevant one being "SHA2-256= ...").
+func parseXrayDigestSHA256(dgst []byte) (string, error) {
+	for _, line := range strings.Split(string(dgst), "\n") {
+		rest, ok := strings.CutPrefix(strings.TrimSpace(line), "SHA2-256=")
+		if !ok {
+			continue
+		}
+		h := strings.ToLower(strings.TrimSpace(rest))
+		if len(h) != 64 {
+			return "", fmt.Errorf("xray checksum: malformed SHA2-256 entry in digest")
+		}
+		return h, nil
+	}
+	return "", fmt.Errorf("xray checksum: no SHA2-256 entry in digest")
+}
+
 func (s *ServerService) UpdateXray(version string) error {
 	versions, err := s.GetXrayVersions()
 	if err != nil {

+ 72 - 0
internal/web/service/server_xray_checksum_test.go

@@ -0,0 +1,72 @@
+package service
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"testing"
+)
+
+// A real XTLS .dgst sidecar (Xray-linux-64.zip.dgst, v26.3.27): lines are
+// "ALGO= <hex>", and the algorithm label is "SHA2-256", not "SHA256".
+const sampleXrayDgst = `# Hash Values
+
+MD5= ee4e2ff74948a9b464624b1cabc44409
+SHA1= b55b06e74e89083b9cedfdecf0d68b579cd2af72
+SHA2-256= 23cd9af937744d97776ee35ecad4972cf4b2109d1e0fe6be9930467608f7c8ae
+SHA2-512= e8bc40a0687cac184bbe4b5c1f047e69064ccedc489fb25e208889ae287bbf8736dff16b108d68fc00dc33edc8bb53502e47a9698a277f4f51b67b83d899e518
+`
+
+const wantSHA = "23cd9af937744d97776ee35ecad4972cf4b2109d1e0fe6be9930467608f7c8ae"
+
+func TestParseXrayDigestSHA256(t *testing.T) {
+	got, err := parseXrayDigestSHA256([]byte(sampleXrayDgst))
+	if err != nil {
+		t.Fatalf("parse: %v", err)
+	}
+	if got != wantSHA {
+		t.Fatalf("sha = %q, want %q", got, wantSHA)
+	}
+}
+
+func TestParseXrayDigestSHA256_Errors(t *testing.T) {
+	for _, tc := range []struct {
+		name string
+		in   string
+	}{
+		{"no-sha256-line", "MD5= abc\nSHA1= def\n"},
+		{"malformed-short", "SHA2-256= deadbeef\n"},
+		{"empty", ""},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			if _, err := parseXrayDigestSHA256([]byte(tc.in)); err == nil {
+				t.Fatalf("%s: expected an error", tc.name)
+			}
+		})
+	}
+}
+
+func TestFetchXrayDigestSHA256(t *testing.T) {
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		_, _ = w.Write([]byte(sampleXrayDgst))
+	}))
+	defer srv.Close()
+
+	got, err := (&ServerService{}).fetchXrayDigestSHA256(srv.Client(), srv.URL+"/Xray-linux-64.zip.dgst")
+	if err != nil {
+		t.Fatalf("fetch: %v", err)
+	}
+	if got != wantSHA {
+		t.Fatalf("sha = %q, want %q", got, wantSHA)
+	}
+}
+
+func TestFetchXrayDigestSHA256_HTTPError(t *testing.T) {
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		http.Error(w, "nope", http.StatusNotFound)
+	}))
+	defer srv.Close()
+
+	if _, err := (&ServerService{}).fetchXrayDigestSHA256(srv.Client(), srv.URL+"/missing.dgst"); err == nil {
+		t.Fatal("expected an error on HTTP 404")
+	}
+}

+ 311 - 0
internal/web/service/xray_setting_routing_sync.go

@@ -0,0 +1,311 @@
+package service
+
+import (
+	"encoding/json"
+)
+
+var routingMatcherKeys = []string{
+	"domain", "ip", "port", "sourcePort", "localPort", "network",
+	"sourceIP", "localIP", "user", "vlessRoute", "protocol", "attrs", "process",
+}
+
+func readInboundTags(raw any) []string {
+	switch tags := raw.(type) {
+	case []string:
+		return append([]string(nil), tags...)
+	case string:
+		if tags == "" {
+			return nil
+		}
+		return []string{tags}
+	case []any:
+		out := make([]string, 0, len(tags))
+		for _, item := range tags {
+			if s, ok := item.(string); ok && s != "" {
+				out = append(out, s)
+			}
+		}
+		return out
+	default:
+		return nil
+	}
+}
+
+func writeInboundTags(rule map[string]any, tags []string) {
+	if len(tags) == 0 {
+		delete(rule, "inboundTag")
+		return
+	}
+	rule["inboundTag"] = tags
+}
+
+func ruleHasNonInboundMatchers(rule map[string]any) bool {
+	for _, key := range routingMatcherKeys {
+		if hasRoutingMatcherValue(rule[key]) {
+			return true
+		}
+	}
+	return false
+}
+
+func hasRoutingMatcherValue(raw any) bool {
+	switch v := raw.(type) {
+	case nil:
+		return false
+	case string:
+		return v != ""
+	case float64, int, int64, bool:
+		return true
+	case []string:
+		return len(v) > 0
+	case []any:
+		return len(v) > 0
+	case map[string]any:
+		return len(v) > 0
+	default:
+		return true
+	}
+}
+
+func replaceInboundTagInRules(rules []map[string]any, oldTag, newTag string) bool {
+	changed := false
+	for _, rule := range rules {
+		if replaceInboundTagInRule(rule, oldTag, newTag) {
+			changed = true
+		}
+	}
+	return changed
+}
+
+func replaceInboundTagInRule(rule map[string]any, oldTag, newTag string) bool {
+	tags := readInboundTags(rule["inboundTag"])
+	if len(tags) == 0 {
+		return false
+	}
+	updated := false
+	for i, tag := range tags {
+		if tag == oldTag {
+			tags[i] = newTag
+			updated = true
+		}
+	}
+	if updated {
+		writeInboundTags(rule, tags)
+	}
+	return updated
+}
+
+func removeInboundTagFromRules(rules []map[string]any, deletedTag string) ([]map[string]any, bool) {
+	if deletedTag == "" {
+		return rules, false
+	}
+	changed := false
+	out := make([]map[string]any, 0, len(rules))
+	for _, rule := range rules {
+		tags := readInboundTags(rule["inboundTag"])
+		if len(tags) == 0 {
+			out = append(out, rule)
+			continue
+		}
+		nextTags := make([]string, 0, len(tags))
+		hadDeleted := false
+		for _, tag := range tags {
+			if tag == deletedTag {
+				hadDeleted = true
+				continue
+			}
+			nextTags = append(nextTags, tag)
+		}
+		if !hadDeleted {
+			out = append(out, rule)
+			continue
+		}
+		changed = true
+		if len(nextTags) == 0 && !ruleHasNonInboundMatchers(rule) {
+			continue
+		}
+		if len(nextTags) == 0 {
+			delete(rule, "inboundTag")
+		} else {
+			writeInboundTags(rule, nextTags)
+		}
+		out = append(out, rule)
+	}
+	return out, changed
+}
+
+func replaceInboundTagInOutbounds(outbounds []any, oldTag, newTag string) bool {
+	changed := false
+	for _, outIface := range outbounds {
+		out, ok := outIface.(map[string]any)
+		if !ok {
+			continue
+		}
+		proto, _ := out["protocol"].(string)
+		if proto != "loopback" {
+			continue
+		}
+		settings, ok := out["settings"].(map[string]any)
+		if !ok {
+			continue
+		}
+		tag, _ := settings["inboundTag"].(string)
+		if tag != oldTag {
+			continue
+		}
+		settings["inboundTag"] = newTag
+		changed = true
+	}
+	return changed
+}
+
+func removeInboundTagFromOutbounds(outbounds []any, deletedTag string) bool {
+	changed := false
+	for _, outIface := range outbounds {
+		out, ok := outIface.(map[string]any)
+		if !ok {
+			continue
+		}
+		proto, _ := out["protocol"].(string)
+		if proto != "loopback" {
+			continue
+		}
+		settings, ok := out["settings"].(map[string]any)
+		if !ok {
+			continue
+		}
+		tag, _ := settings["inboundTag"].(string)
+		if tag != deletedTag {
+			continue
+		}
+		delete(settings, "inboundTag")
+		changed = true
+	}
+	return changed
+}
+
+func mutateXrayTemplateRouting(raw string, mutate func(cfg map[string]any) bool) (string, bool, error) {
+	raw = UnwrapXrayTemplateConfig(raw)
+	var cfg map[string]any
+	if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
+		return raw, false, err
+	}
+	if !mutate(cfg) {
+		return raw, false, nil
+	}
+	out, err := json.MarshalIndent(cfg, "", "  ")
+	if err != nil {
+		return raw, false, err
+	}
+	return string(out), true, nil
+}
+
+func routingRulesFromCfg(cfg map[string]any) []map[string]any {
+	routing, _ := cfg["routing"].(map[string]any)
+	if routing == nil {
+		return nil
+	}
+	rawRules, ok := routing["rules"].([]any)
+	if !ok {
+		return nil
+	}
+	rules := make([]map[string]any, 0, len(rawRules))
+	for _, item := range rawRules {
+		rule, ok := item.(map[string]any)
+		if !ok {
+			continue
+		}
+		rules = append(rules, rule)
+	}
+	return rules
+}
+
+func setRoutingRulesInCfg(cfg map[string]any, rules []map[string]any) {
+	routing, _ := cfg["routing"].(map[string]any)
+	if routing == nil {
+		routing = map[string]any{}
+		cfg["routing"] = routing
+	}
+	items := make([]any, len(rules))
+	for i, rule := range rules {
+		items[i] = rule
+	}
+	routing["rules"] = items
+}
+
+func outboundsFromCfg(cfg map[string]any) []any {
+	outbounds, _ := cfg["outbounds"].([]any)
+	return outbounds
+}
+
+// PropagateInboundTagRename rewrites routing rules and loopback outbound
+// references when a panel inbound tag changes.
+func (s *XraySettingService) PropagateInboundTagRename(oldTag, newTag string) (bool, error) {
+	if oldTag == "" || newTag == "" || oldTag == newTag {
+		return false, nil
+	}
+	template, err := s.GetXrayConfigTemplate()
+	if err != nil {
+		return false, err
+	}
+	updated, changed, err := mutateXrayTemplateRouting(template, func(cfg map[string]any) bool {
+		mutated := false
+		rules := routingRulesFromCfg(cfg)
+		if len(rules) > 0 {
+			if replaceInboundTagInRules(rules, oldTag, newTag) {
+				setRoutingRulesInCfg(cfg, rules)
+				mutated = true
+			}
+		}
+		outbounds := outboundsFromCfg(cfg)
+		if len(outbounds) > 0 && replaceInboundTagInOutbounds(outbounds, oldTag, newTag) {
+			cfg["outbounds"] = outbounds
+			mutated = true
+		}
+		return mutated
+	})
+	if err != nil || !changed {
+		return false, err
+	}
+	if err := s.SaveXraySetting(updated); err != nil {
+		return false, err
+	}
+	return true, nil
+}
+
+// RemoveInboundTagReferences drops a deleted inbound tag from routing rules.
+// Rules that only matched that inbound are removed; rules with additional
+// matchers keep the rule and only lose the inboundTag entry.
+func (s *XraySettingService) RemoveInboundTagReferences(deletedTag string) (bool, error) {
+	if deletedTag == "" {
+		return false, nil
+	}
+	template, err := s.GetXrayConfigTemplate()
+	if err != nil {
+		return false, err
+	}
+	updated, changed, err := mutateXrayTemplateRouting(template, func(cfg map[string]any) bool {
+		mutated := false
+		rules := routingRulesFromCfg(cfg)
+		if len(rules) > 0 {
+			nextRules, rulesChanged := removeInboundTagFromRules(rules, deletedTag)
+			if rulesChanged {
+				setRoutingRulesInCfg(cfg, nextRules)
+				mutated = true
+			}
+		}
+		outbounds := outboundsFromCfg(cfg)
+		if len(outbounds) > 0 && removeInboundTagFromOutbounds(outbounds, deletedTag) {
+			cfg["outbounds"] = outbounds
+			mutated = true
+		}
+		return mutated
+	})
+	if err != nil || !changed {
+		return false, err
+	}
+	if err := s.SaveXraySetting(updated); err != nil {
+		return false, err
+	}
+	return true, nil
+}

+ 302 - 0
internal/web/service/xray_setting_routing_sync_test.go

@@ -0,0 +1,302 @@
+package service
+
+import (
+	"encoding/json"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+func seedXrayTemplate(t *testing.T, template string) {
+	t.Helper()
+	s := &SettingService{}
+	if err := s.saveSetting("xrayTemplateConfig", template); err != nil {
+		t.Fatalf("saveSetting: %v", err)
+	}
+}
+
+func routingRulesFromTemplate(t *testing.T, template string) []map[string]any {
+	t.Helper()
+	var cfg map[string]any
+	if err := json.Unmarshal([]byte(template), &cfg); err != nil {
+		t.Fatalf("unmarshal template: %v", err)
+	}
+	return routingRulesFromCfg(cfg)
+}
+
+func TestPropagateInboundTagRename_UpdatesRoutingRule(t *testing.T) {
+	setupSettingTestDB(t)
+	seedXrayTemplate(t, `{
+		"routing": {
+			"rules": [
+				{"type":"field","inboundTag":["in-21368-tcp"],"outboundTag":"direct"},
+				{"type":"field","inboundTag":["api"],"outboundTag":"api"}
+			]
+		},
+		"outbounds": [{"tag":"direct","protocol":"freedom"}]
+	}`)
+
+	svc := &XraySettingService{}
+	changed, err := svc.PropagateInboundTagRename("in-21368-tcp", "in-33000-tcp")
+	if err != nil {
+		t.Fatalf("PropagateInboundTagRename: %v", err)
+	}
+	if !changed {
+		t.Fatal("expected routing template to change")
+	}
+
+	got, err := svc.GetXrayConfigTemplate()
+	if err != nil {
+		t.Fatalf("GetXrayConfigTemplate: %v", err)
+	}
+	rules := routingRulesFromTemplate(t, got)
+	if len(rules) != 2 {
+		t.Fatalf("rules len = %d, want 2", len(rules))
+	}
+	if tags := readInboundTags(rules[1]["inboundTag"]); tags[0] != "in-33000-tcp" {
+		t.Fatalf("renamed rule inboundTag = %v, want [in-33000-tcp]", tags)
+	}
+	if tags := readInboundTags(rules[0]["inboundTag"]); tags[0] != "api" {
+		t.Fatalf("api rule should stay untouched, got %v", tags)
+	}
+}
+
+func TestPropagateInboundTagRename_UpdatesLoopbackOutbound(t *testing.T) {
+	setupSettingTestDB(t)
+	seedXrayTemplate(t, `{
+		"routing": {"rules": []},
+		"outbounds": [
+			{"tag":"loop","protocol":"loopback","settings":{"inboundTag":"in-21368-tcp"}}
+		]
+	}`)
+
+	svc := &XraySettingService{}
+	if _, err := svc.PropagateInboundTagRename("in-21368-tcp", "in-33000-tcp"); err != nil {
+		t.Fatalf("PropagateInboundTagRename: %v", err)
+	}
+
+	got, err := svc.GetXrayConfigTemplate()
+	if err != nil {
+		t.Fatalf("GetXrayConfigTemplate: %v", err)
+	}
+	var cfg map[string]any
+	if err := json.Unmarshal([]byte(got), &cfg); err != nil {
+		t.Fatalf("unmarshal: %v", err)
+	}
+	outbounds := outboundsFromCfg(cfg)
+	settings := outbounds[0].(map[string]any)["settings"].(map[string]any)
+	if settings["inboundTag"] != "in-33000-tcp" {
+		t.Fatalf("loopback inboundTag = %v, want in-33000-tcp", settings["inboundTag"])
+	}
+}
+
+func TestRemoveInboundTagReferences_DropsInboundOnlyRule(t *testing.T) {
+	setupSettingTestDB(t)
+	seedXrayTemplate(t, `{
+		"routing": {
+			"rules": [
+				{"type":"field","inboundTag":["in-21368-tcp"],"outboundTag":"direct"},
+				{"type":"field","inboundTag":["api"],"outboundTag":"api"}
+			]
+		}
+	}`)
+
+	svc := &XraySettingService{}
+	changed, err := svc.RemoveInboundTagReferences("in-21368-tcp")
+	if err != nil {
+		t.Fatalf("RemoveInboundTagReferences: %v", err)
+	}
+	if !changed {
+		t.Fatal("expected template to change")
+	}
+
+	got, err := svc.GetXrayConfigTemplate()
+	if err != nil {
+		t.Fatalf("GetXrayConfigTemplate: %v", err)
+	}
+	rules := routingRulesFromTemplate(t, got)
+	if len(rules) != 1 {
+		t.Fatalf("rules len = %d, want 1 (api rule only)", len(rules))
+	}
+	if tags := readInboundTags(rules[0]["inboundTag"]); tags[0] != "api" {
+		t.Fatalf("remaining rule = %v, want api rule", tags)
+	}
+}
+
+func TestRemoveInboundTagReferences_KeepsRuleWithOtherMatchers(t *testing.T) {
+	setupSettingTestDB(t)
+	seedXrayTemplate(t, `{
+		"routing": {
+			"rules": [
+				{"type":"field","inboundTag":["api"],"outboundTag":"api"},
+				{
+					"type":"field",
+					"inboundTag":["in-21368-tcp"],
+					"domain":["example.com"],
+					"outboundTag":"direct"
+				}
+			]
+		}
+	}`)
+
+	svc := &XraySettingService{}
+	if _, err := svc.RemoveInboundTagReferences("in-21368-tcp"); err != nil {
+		t.Fatalf("RemoveInboundTagReferences: %v", err)
+	}
+
+	got, err := svc.GetXrayConfigTemplate()
+	if err != nil {
+		t.Fatalf("GetXrayConfigTemplate: %v", err)
+	}
+	rule := findRuleByOutbound(t, got, "direct")
+	if _, ok := rule["inboundTag"]; ok {
+		t.Fatalf("inboundTag should be removed, rule = %#v", rule)
+	}
+	if domain, _ := rule["domain"].([]any); len(domain) != 1 {
+		t.Fatalf("domain matcher should remain, rule = %#v", rule)
+	}
+}
+
+func TestRemoveInboundTagReferences_RemovesOneTagFromMultiInboundRule(t *testing.T) {
+	setupSettingTestDB(t)
+	seedXrayTemplate(t, `{
+		"routing": {
+			"rules": [
+				{"type":"field","inboundTag":["api"],"outboundTag":"api"},
+				{
+					"type":"field",
+					"inboundTag":["in-21368-tcp","in-443-tcp"],
+					"outboundTag":"direct"
+				}
+			]
+		}
+	}`)
+
+	svc := &XraySettingService{}
+	if _, err := svc.RemoveInboundTagReferences("in-21368-tcp"); err != nil {
+		t.Fatalf("RemoveInboundTagReferences: %v", err)
+	}
+
+	got, err := svc.GetXrayConfigTemplate()
+	if err != nil {
+		t.Fatalf("GetXrayConfigTemplate: %v", err)
+	}
+	rule := findRuleByOutbound(t, got, "direct")
+	if tags := readInboundTags(rule["inboundTag"]); len(tags) != 1 || tags[0] != "in-443-tcp" {
+		t.Fatalf("inboundTag = %v, want [in-443-tcp]", tags)
+	}
+}
+
+func findRuleByOutbound(t *testing.T, template, outbound string) map[string]any {
+	t.Helper()
+	for _, rule := range routingRulesFromTemplate(t, template) {
+		if rule["outboundTag"] == outbound {
+			return rule
+		}
+	}
+	t.Fatalf("no rule with outboundTag %q in %s", outbound, template)
+	return nil
+}
+
+func TestPropagateInboundTagRename_WorksWithConflictDB(t *testing.T) {
+	setupConflictDB(t)
+	seedXrayTemplate(t, `{
+		"routing": {
+			"rules": [
+				{"type":"field","inboundTag":["in-22435-tcp"],"outboundTag":"direct"}
+			]
+		},
+		"outbounds": [{"tag":"direct","protocol":"freedom"}]
+	}`)
+
+	svc := &XraySettingService{}
+	changed, err := svc.PropagateInboundTagRename("in-22435-tcp", "in-33000-tcp")
+	if err != nil {
+		t.Fatalf("PropagateInboundTagRename: %v", err)
+	}
+	if !changed {
+		t.Fatal("expected template to change")
+	}
+}
+
+func TestUpdateInbound_PropagatesRoutingRuleOnPortChange(t *testing.T) {
+	setupConflictDB(t)
+	seedXrayTemplate(t, `{
+		"routing": {
+			"rules": [
+				{"type":"field","inboundTag":["api"],"outboundTag":"api"},
+				{"type":"field","inboundTag":["in-22435-tcp"],"outboundTag":"direct"}
+			]
+		},
+		"outbounds": [{"tag":"direct","protocol":"freedom"}]
+	}`)
+	seedInboundConflict(t, "in-22435-tcp", "0.0.0.0", 22435, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`)
+
+	var existing model.Inbound
+	if err := database.GetDB().Where("tag = ?", "in-22435-tcp").First(&existing).Error; err != nil {
+		t.Fatalf("read seeded row: %v", err)
+	}
+
+	svc := &InboundService{}
+	update := existing
+	update.Port = 33000
+	update.Tag = "in-22435-tcp"
+	got, needRestart, err := svc.UpdateInbound(&update)
+	if err != nil {
+		t.Fatalf("UpdateInbound: %v", err)
+	}
+	if got.Tag != "in-33000-tcp" {
+		t.Fatalf("returned tag = %q, want in-33000-tcp", got.Tag)
+	}
+	if !needRestart {
+		t.Fatal("expected needRestart after routing template sync on tag rename")
+	}
+
+	xraySvc := &XraySettingService{}
+	template, err := xraySvc.GetXrayConfigTemplate()
+	if err != nil {
+		t.Fatalf("GetXrayConfigTemplate: %v", err)
+	}
+	rule := findRuleByOutbound(t, template, "direct")
+	if tags := readInboundTags(rule["inboundTag"]); tags[0] != "in-33000-tcp" {
+		t.Fatalf("routing inboundTag = %v, want [in-33000-tcp]", tags)
+	}
+}
+
+func TestDelInbound_RemovesInboundOnlyRoutingRule(t *testing.T) {
+	setupConflictDB(t)
+	seedXrayTemplate(t, `{
+		"routing": {
+			"rules": [
+				{"type":"field","inboundTag":["api"],"outboundTag":"api"},
+				{"type":"field","inboundTag":["in-22435-tcp"],"outboundTag":"direct"},
+				{"type":"field","inboundTag":["in-443-tcp"],"outboundTag":"blocked"}
+			]
+		}
+	}`)
+	seedInboundConflict(t, "in-22435-tcp", "0.0.0.0", 22435, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`)
+
+	var existing model.Inbound
+	if err := database.GetDB().Where("tag = ?", "in-22435-tcp").First(&existing).Error; err != nil {
+		t.Fatalf("read seeded row: %v", err)
+	}
+
+	svc := &InboundService{}
+	if _, err := svc.DelInbound(existing.Id); err != nil {
+		t.Fatalf("DelInbound: %v", err)
+	}
+
+	xraySvc := &XraySettingService{}
+	template, err := xraySvc.GetXrayConfigTemplate()
+	if err != nil {
+		t.Fatalf("GetXrayConfigTemplate: %v", err)
+	}
+	rules := routingRulesFromTemplate(t, template)
+	for _, rule := range rules {
+		if rule["outboundTag"] == "direct" {
+			t.Fatalf("direct rule should be removed, got %#v", rule)
+		}
+	}
+	findRuleByOutbound(t, template, "blocked")
+}

+ 9 - 1
internal/web/web.go

@@ -53,6 +53,12 @@ var distFS embed.FS
 
 var startTime = time.Now()
 
+// cronPanicLogger adapts the package logger to cron's Printf-style logger so a
+// panicking scheduled job is recovered and logged instead of crashing the panel.
+type cronPanicLogger struct{}
+
+func (cronPanicLogger) Printf(format string, args ...any) { logger.Errorf(format, args...) }
+
 // wrapDistFS adapts the embedded `dist/` directory so it can be mounted
 // as the panel's `/assets/` static route. Vite emits its bundled JS/CSS
 // under `dist/assets/`; serving the FS rooted at `dist/assets` makes
@@ -435,7 +441,9 @@ func (s *Server) start(restartXray bool, startTgBot bool) (err error) {
 	}
 	service.StartTrafficWriter()
 
-	s.cron = cron.New(cron.WithLocation(loc), cron.WithSeconds())
+	// cron.Recover wraps every job so a panic is logged and the scheduler keeps
+	// running, instead of the panic taking down the whole panel process.
+	s.cron = cron.New(cron.WithLocation(loc), cron.WithSeconds(), cron.WithChain(cron.Recover(cron.PrintfLogger(cronPanicLogger{}))))
 	s.cron.Start()
 
 	// Wire the inbound-runtime manager once so InboundService can route

+ 58 - 21
internal/xray/process.go

@@ -127,6 +127,12 @@ func NewTestProcess(xrayConfig *Config, configPath string) *Process {
 }
 
 type process struct {
+	// mu guards the process lifecycle fields (cmd, done, exitErr) which are
+	// written by Start/startCommand and the waitForCommand goroutine while being
+	// read concurrently by IsRunning/GetErr/GetResult/Stop from other goroutines
+	// (status endpoint, check-xray-running job). Snapshot under the lock, then do
+	// any blocking syscall (Wait/Signal/Kill) on the local copy without holding it.
+	mu   sync.RWMutex
 	cmd  *exec.Cmd
 	done chan struct{}
 
@@ -236,31 +242,39 @@ func newTestProcess(config *Config, configPath string) *process {
 
 // IsRunning returns true if the Xray process is currently running.
 func (p *process) IsRunning() bool {
-	if p.cmd == nil || p.cmd.Process == nil {
+	p.mu.RLock()
+	cmd, done := p.cmd, p.done
+	p.mu.RUnlock()
+	if cmd == nil || cmd.Process == nil {
 		return false
 	}
-	if p.done != nil {
+	// done is closed by the waitForCommand goroutine exactly when cmd.Wait
+	// returns, i.e. when the process has exited; it is the race-free signal here
+	// (reading cmd.ProcessState would race with that Wait).
+	if done != nil {
 		select {
-		case <-p.done:
+		case <-done:
 			return false
 		default:
 		}
 	}
-	if p.cmd.ProcessState == nil {
-		return true
-	}
-	return false
+	return true
 }
 
 // GetErr returns the last error encountered by the Xray process.
 func (p *process) GetErr() error {
+	p.mu.RLock()
+	defer p.mu.RUnlock()
 	return p.exitErr
 }
 
 // GetResult returns the last log line or error from the Xray process.
 func (p *process) GetResult() string {
-	if len(p.logWriter.lastLine) == 0 && p.exitErr != nil {
-		return p.exitErr.Error()
+	p.mu.RLock()
+	exitErr := p.exitErr
+	p.mu.RUnlock()
+	if len(p.logWriter.lastLine) == 0 && exitErr != nil {
+		return exitErr.Error()
 	}
 	return p.logWriter.lastLine
 }
@@ -493,7 +507,7 @@ func (p *process) Start() (err error) {
 	defer func() {
 		if err != nil {
 			logger.Error("Failure in running xray-core process: ", err)
-			p.exitErr = err
+			p.setExitErr(err)
 		}
 	}()
 
@@ -532,25 +546,36 @@ func (p *process) Start() (err error) {
 }
 
 func (p *process) startCommand(cmd *exec.Cmd) error {
+	p.mu.Lock()
 	p.cmd = cmd
 	p.done = make(chan struct{})
 	p.exitErr = nil
+	done := p.done
+	p.mu.Unlock()
 	p.intentionalStop.Store(false)
 
 	if err := cmd.Start(); err != nil {
-		close(p.done)
+		close(done)
+		p.mu.Lock()
 		p.cmd = nil
+		p.mu.Unlock()
 		return err
 	}
 
 	attachChildLifetime(cmd)
 
-	go p.waitForCommand(cmd)
+	go p.waitForCommand(cmd, done)
 	return nil
 }
 
-func (p *process) waitForCommand(cmd *exec.Cmd) {
-	defer close(p.done)
+func (p *process) setExitErr(err error) {
+	p.mu.Lock()
+	p.exitErr = err
+	p.mu.Unlock()
+}
+
+func (p *process) waitForCommand(cmd *exec.Cmd, done chan struct{}) {
+	defer close(done)
 
 	err := cmd.Wait()
 	if err == nil || p.intentionalStop.Load() {
@@ -561,13 +586,13 @@ func (p *process) waitForCommand(cmd *exec.Cmd) {
 	if runtime.GOOS == "windows" {
 		errStr := strings.ToLower(err.Error())
 		if strings.Contains(errStr, "exit status 1") {
-			p.exitErr = err
+			p.setExitErr(err)
 			return
 		}
 	}
 
 	logger.Error("Failure in running xray-core:", err)
-	p.exitErr = err
+	p.setExitErr(err)
 	if OnCrash != nil {
 		OnCrash(err)
 	}
@@ -580,6 +605,15 @@ func (p *process) Stop() error {
 	}
 	p.intentionalStop.Store(true)
 
+	// Snapshot cmd once, then run the blocking Signal/Kill/Wait on the local copy
+	// without holding the lock.
+	p.mu.RLock()
+	cmd := p.cmd
+	p.mu.RUnlock()
+	if cmd == nil || cmd.Process == nil {
+		return errors.New("xray is not running")
+	}
+
 	// Remove temporary config file used for test runs so main config is never touched
 	if p.configPath != "" {
 		if p.configPath != GetConfigPath() {
@@ -591,13 +625,13 @@ func (p *process) Stop() error {
 	}
 
 	if runtime.GOOS == "windows" {
-		if err := p.cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
+		if err := cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
 			return err
 		}
 		return p.waitForExit(xrayForceStopTimeout)
 	}
 
-	if err := p.cmd.Process.Signal(syscall.SIGTERM); err != nil {
+	if err := cmd.Process.Signal(syscall.SIGTERM); err != nil {
 		if errors.Is(err, os.ErrProcessDone) {
 			return p.waitForExit(xrayForceStopTimeout)
 		}
@@ -609,14 +643,17 @@ func (p *process) Stop() error {
 	}
 
 	logger.Warning("xray-core did not stop after SIGTERM, killing process")
-	if err := p.cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
+	if err := cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
 		return err
 	}
 	return p.waitForExit(xrayForceStopTimeout)
 }
 
 func (p *process) waitForExit(timeout time.Duration) error {
-	if p.done == nil {
+	p.mu.RLock()
+	done := p.done
+	p.mu.RUnlock()
+	if done == nil {
 		return nil
 	}
 
@@ -624,7 +661,7 @@ func (p *process) waitForExit(timeout time.Duration) error {
 	defer timer.Stop()
 
 	select {
-	case <-p.done:
+	case <-done:
 		return nil
 	case <-timer.C:
 		return common.NewErrorf("timed out waiting for xray-core process to stop after %s", timeout)

+ 60 - 0
internal/xray/process_race_test.go

@@ -0,0 +1,60 @@
+package xray
+
+import (
+	"errors"
+	"os/exec"
+	"sync"
+	"testing"
+	"time"
+)
+
+// TestProcessLifecycleFieldsRaceSafe drives the lifecycle fields (cmd, done,
+// exitErr) the way Start/startCommand and the waitForCommand goroutine do, while
+// the status getters read them concurrently. Run with -race: any unsynchronized
+// access to those fields is reported as a data race.
+func TestProcessLifecycleFieldsRaceSafe(t *testing.T) {
+	p := &process{logWriter: NewLogWriter()}
+
+	var wg sync.WaitGroup
+	stop := make(chan struct{})
+
+	// Writer: churn cmd/done/exitErr like Start + waitForCommand.
+	wg.Add(1)
+	go func() {
+		defer wg.Done()
+		for {
+			select {
+			case <-stop:
+				return
+			default:
+			}
+			p.mu.Lock()
+			p.cmd = &exec.Cmd{}
+			p.done = make(chan struct{})
+			p.mu.Unlock()
+			p.setExitErr(errors.New("boom"))
+		}
+	}()
+
+	// Readers: the concurrent status getters.
+	for i := 0; i < 4; i++ {
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			for {
+				select {
+				case <-stop:
+					return
+				default:
+				}
+				_ = p.IsRunning()
+				_ = p.GetErr()
+				_ = p.GetResult()
+			}
+		}()
+	}
+
+	time.Sleep(50 * time.Millisecond)
+	close(stop)
+	wg.Wait()
+}