3 Revize d6d2085d60 ... b40f869f2a

Autor SHA1 Zpráva Datum
  MHSanaei b40f869f2a fix(node): keep client/inbound edits working when a node is offline (#4923, #4931) před 17 hodinami
  MHSanaei e08456269b fix(traffic): count local traffic for clients whose shared row is node-owned (#4921) před 19 hodinami
  MHSanaei f8e902a7b6 fix(sub): include ECH config in TLS share links and JSON subscription před 19 hodinami

+ 3 - 0
database/model/model.go

@@ -396,6 +396,9 @@ type Node struct {
 	UptimeSecs    uint64  `json:"uptimeSecs"`
 	LastError     string  `json:"lastError"`
 
+	ConfigDirty   bool  `json:"configDirty" gorm:"default:false"`
+	ConfigDirtyAt int64 `json:"configDirtyAt"`
+
 	InboundCount  int `json:"inboundCount" gorm:"-"`
 	ClientCount   int `json:"clientCount" gorm:"-"`
 	OnlineCount   int `json:"onlineCount" gorm:"-"`

+ 2 - 0
frontend/src/generated/types.ts

@@ -322,6 +322,8 @@ export interface Node {
   apiToken: string;
   basePath: string;
   clientCount: number;
+  configDirty: boolean;
+  configDirtyAt: number;
   cpuPct: number;
   createdAt: number;
   depletedCount: number;

+ 2 - 0
frontend/src/generated/zod.ts

@@ -339,6 +339,8 @@ export const NodeSchema = z.object({
   apiToken: z.string(),
   basePath: z.string(),
   clientCount: z.number().int(),
+  configDirty: z.boolean(),
+  configDirtyAt: z.number().int(),
   cpuPct: z.number(),
   createdAt: z.number().int(),
   depletedCount: z.number().int(),

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

@@ -232,6 +232,7 @@ export function genVmessLink(input: GenVmessLinkInput): string {
     if (tlsSettings.serverName.length > 0) obj.sni = tlsSettings.serverName;
     if (tlsSettings.settings.fingerprint.length > 0) obj.fp = tlsSettings.settings.fingerprint;
     if (tlsSettings.alpn.length > 0) obj.alpn = tlsSettings.alpn.join(',');
+    if (tlsSettings.settings.echConfigList.length > 0) obj.ech = tlsSettings.settings.echConfigList;
     if (tlsSettings.settings.pinnedPeerCertSha256.length > 0) {
       obj.pcs = tlsSettings.settings.pinnedPeerCertSha256.join(',');
     }

+ 9 - 0
frontend/src/utils/index.ts

@@ -1,5 +1,6 @@
 import axios from 'axios';
 import type { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios';
+import i18next from 'i18next';
 import { getMessage } from './messageBus';
 
 type RespEnvelope = { success?: unknown; msg?: unknown; obj?: unknown };
@@ -32,6 +33,14 @@ export class HttpUtil {
     }
     const messageType = msg.success ? 'success' : 'error';
     getMessage()[messageType](msg.msg);
+    if (
+      msg.success &&
+      msg.obj &&
+      typeof msg.obj === 'object' &&
+      (msg.obj as { nodePending?: unknown }).nodePending === true
+    ) {
+      getMessage().warning(i18next.t('pages.inbounds.toasts.savedNodeOfflineWillSync'));
+    }
   }
 
   static _respToMsg(resp: AxiosResponse | undefined): Msg {

+ 3 - 0
sub/subJsonService.go

@@ -258,6 +258,9 @@ func (s *SubJsonService) tlsData(tData map[string]any) map[string]any {
 	if fingerprint, ok := tlsClientSettings["fingerprint"].(string); ok {
 		tlsData["fingerprint"] = fingerprint
 	}
+	if ech, ok := tlsClientSettings["echConfigList"].(string); ok && ech != "" {
+		tlsData["echConfigList"] = ech
+	}
 	if pins, ok := tlsClientSettings["pinnedPeerCertSha256"].([]any); ok && len(pins) > 0 {
 		tlsData["pinnedPeerCertSha256"] = pins
 	}

+ 10 - 0
sub/subService.go

@@ -894,6 +894,11 @@ func applyShareTLSParams(stream map[string]any, params map[string]string) {
 		if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok {
 			params["fp"], _ = fpValue.(string)
 		}
+		if echValue, ok := searchKey(tlsSettings, "echConfigList"); ok {
+			if ech, _ := echValue.(string); ech != "" {
+				params["ech"] = ech
+			}
+		}
 		if pins, ok := pinnedSha256List(tlsSettings); ok {
 			params["pcs"] = strings.Join(pins, ",")
 		}
@@ -919,6 +924,11 @@ func applyVmessTLSParams(stream map[string]any, obj map[string]any) {
 		if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok {
 			obj["fp"], _ = fpValue.(string)
 		}
+		if echValue, ok := searchKey(tlsSettings, "echConfigList"); ok {
+			if ech, _ := echValue.(string); ech != "" {
+				obj["ech"] = ech
+			}
+		}
 		if pins, ok := pinnedSha256List(tlsSettings); ok {
 			obj["pcs"] = strings.Join(pins, ",")
 		}

+ 4 - 4
web/controller/client.go

@@ -132,7 +132,7 @@ func (a *ClientController) create(c *gin.Context) {
 		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
 		return
 	}
-	jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientAddSuccess"), nil)
+	jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientAddSuccess"), pendingNodeObj(a.inboundService.AnyNodePending(payload.InboundIds)), nil)
 	if needRestart {
 		a.xrayService.SetToNeedRestart()
 	}
@@ -152,7 +152,7 @@ func (a *ClientController) update(c *gin.Context) {
 		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
 		return
 	}
-	jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientUpdateSuccess"), nil)
+	jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientUpdateSuccess"), pendingNodeObj(a.clientService.HasPendingNode(&a.inboundService, email)), nil)
 	if needRestart {
 		a.xrayService.SetToNeedRestart()
 	}
@@ -190,7 +190,7 @@ func (a *ClientController) attach(c *gin.Context) {
 		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
 		return
 	}
-	jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientAddSuccess"), nil)
+	jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientAddSuccess"), pendingNodeObj(a.inboundService.AnyNodePending(body.InboundIds)), nil)
 	if needRestart {
 		a.xrayService.SetToNeedRestart()
 	}
@@ -470,7 +470,7 @@ func (a *ClientController) detach(c *gin.Context) {
 		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
 		return
 	}
-	jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientDeleteSuccess"), nil)
+	jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientDeleteSuccess"), pendingNodeObj(a.inboundService.AnyNodePending(body.InboundIds)), nil)
 	if needRestart {
 		a.xrayService.SetToNeedRestart()
 	}

+ 10 - 0
web/controller/util.go

@@ -182,6 +182,16 @@ func jsonMsgObj(c *gin.Context, msg string, obj any, err error) {
 	c.JSON(http.StatusOK, m)
 }
 
+// pendingNodeObj returns a response object flagging that the save committed
+// locally but a backing node was offline/disabled, so the change will be
+// mirrored to the node once it reconnects. Returns nil when nothing is pending.
+func pendingNodeObj(pending bool) any {
+	if pending {
+		return gin.H{"nodePending": true}
+	}
+	return nil
+}
+
 // pureJsonMsg sends a pure JSON message response with custom status code.
 func pureJsonMsg(c *gin.Context, statusCode int, success bool, msg string) {
 	c.JSON(statusCode, entity.Msg{

+ 21 - 4
web/job/node_traffic_sync_job.go

@@ -15,6 +15,7 @@ import (
 const (
 	nodeTrafficSyncConcurrency    = 8
 	nodeTrafficSyncRequestTimeout = 4 * time.Second
+	nodeReconcileTimeout          = 30 * time.Second
 )
 
 type NodeTrafficSyncJob struct {
@@ -151,21 +152,37 @@ func (j *NodeTrafficSyncJob) Run() {
 }
 
 func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node) {
-	ctx, cancel := context.WithTimeout(context.Background(), nodeTrafficSyncRequestTimeout)
-	defer cancel()
-
 	rt, err := mgr.RemoteFor(n)
 	if err != nil {
 		logger.Warning("node traffic sync: remote lookup failed for", n.Name, ":", err)
 		return
 	}
+
+	if n.ConfigDirty {
+		reconcileCtx, reconcileCancel := context.WithTimeout(context.Background(), nodeReconcileTimeout)
+		reconcileErr := j.inboundService.ReconcileNode(reconcileCtx, rt, n.Id)
+		reconcileCancel()
+		if reconcileErr != nil {
+			logger.Warning("node traffic sync: reconcile for", n.Name, "failed:", reconcileErr)
+			return
+		}
+		if clearErr := j.nodeService.ClearNodeDirty(n.Id, n.ConfigDirtyAt); clearErr != nil {
+			logger.Warning("node traffic sync: clear dirty for", n.Name, "failed:", clearErr)
+		}
+		j.structural.set()
+	}
+
+	ctx, cancel := context.WithTimeout(context.Background(), nodeTrafficSyncRequestTimeout)
+	defer cancel()
+
 	snap, err := rt.FetchTrafficSnapshot(ctx)
 	if err != nil {
 		logger.Warning("node traffic sync: fetch from", n.Name, "failed:", err)
 		j.inboundService.ClearNodeOnlineClients(n.Id)
 		return
 	}
-	changed, err := j.inboundService.SetRemoteTraffic(n.Id, snap)
+	_, _, dirty, _, _ := j.nodeService.NodeSyncState(n.Id)
+	changed, err := j.inboundService.SetRemoteTraffic(n.Id, snap, dirty)
 	if err != nil {
 		logger.Warning("node traffic sync: merge for", n.Name, "failed:", err)
 		return

+ 13 - 0
web/runtime/remote.go

@@ -191,6 +191,19 @@ func (r *Remote) cacheDel(tag string) {
 	delete(r.remoteIDByTag, tag)
 }
 
+func (r *Remote) ListRemoteTags(ctx context.Context) ([]string, error) {
+	if err := r.refreshRemoteIDs(ctx); err != nil {
+		return nil, err
+	}
+	r.mu.RLock()
+	defer r.mu.RUnlock()
+	tags := make([]string, 0, len(r.remoteIDByTag))
+	for tag := range r.remoteIDByTag {
+		tags = append(tags, tag)
+	}
+	return tags, nil
+}
+
 func (r *Remote) refreshRemoteIDs(ctx context.Context) error {
 	env, err := r.do(ctx, http.MethodGet, "panel/api/inbounds/list", nil)
 	if err != nil {

+ 216 - 126
web/service/client.go

@@ -547,6 +547,17 @@ func validateClientSubID(subID string) error {
 	return nil
 }
 
+func (s *ClientService) HasPendingNode(inboundSvc *InboundService, email string) bool {
+	if strings.TrimSpace(email) == "" {
+		return false
+	}
+	ids, err := s.GetInboundIdsForEmail(nil, email)
+	if err != nil {
+		return false
+	}
+	return inboundSvc.AnyNodePending(ids)
+}
+
 func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreatePayload) (bool, error) {
 	if payload == nil {
 		return false, common.NewError("empty payload")
@@ -1290,6 +1301,7 @@ func (s *ClientService) delInboundClients(inboundSvc *InboundService, inboundId
 	}
 
 	needRestart := false
+	markDirty := false
 	for _, r := range removed {
 		email := r.email
 		emailShared := sharedSet[strings.ToLower(strings.TrimSpace(email))]
@@ -1324,12 +1336,18 @@ func (s *ClientService) delInboundClients(inboundSvc *InboundService, inboundId
 			}
 		}
 		if oldInbound.NodeID != nil && len(email) > 0 {
-			rt, rterr := inboundSvc.runtimeFor(oldInbound)
-			if rterr != nil {
-				return needRestart, rterr
+			rt, push, dirty, perr := inboundSvc.nodePushPlan(oldInbound)
+			if perr != nil {
+				return needRestart, perr
 			}
-			if err1 := rt.DeleteUser(context.Background(), oldInbound, email); err1 != nil {
-				return needRestart, err1
+			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)
+					markDirty = true
+				}
 			}
 		}
 	}
@@ -1344,6 +1362,11 @@ func (s *ClientService) delInboundClients(inboundSvc *InboundService, inboundId
 	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)
+		}
+	}
 	return needRestart, nil
 }
 
@@ -2722,27 +2745,33 @@ func (s *ClientService) bulkAdjustInboundClients(
 	}
 	oldInbound.Settings = string(newSettings)
 
+	markDirty := false
 	if oldInbound.NodeID != nil {
-		rt, rterr := inboundSvc.runtimeFor(oldInbound)
-		if rterr != nil {
+		rt, push, dirty, perr := inboundSvc.nodePushPlan(oldInbound)
+		if perr != nil {
 			for email := range foundEmails {
-				res.perEmailSkipped[email] = rterr.Error()
+				res.perEmailSkipped[email] = perr.Error()
 				delete(foundEmails, email)
 			}
 		} else {
-			for email := range foundEmails {
-				entry := plan[email]
-				updated := *entry.record.ToClient()
-				if entry.applyExpiry {
-					updated.ExpiryTime = entry.newExpiry
-				}
-				if entry.applyTotal {
-					updated.TotalGB = entry.newTotal
-				}
-				updated.UpdatedAt = nowMs
-				if err1 := rt.UpdateUser(context.Background(), oldInbound, email, updated); err1 != nil {
-					res.perEmailSkipped[email] = err1.Error()
-					delete(foundEmails, email)
+			if dirty {
+				markDirty = true
+			}
+			if push {
+				for email := range foundEmails {
+					entry := plan[email]
+					updated := *entry.record.ToClient()
+					if entry.applyExpiry {
+						updated.ExpiryTime = entry.newExpiry
+					}
+					if entry.applyTotal {
+						updated.TotalGB = entry.newTotal
+					}
+					updated.UpdatedAt = nowMs
+					if err1 := rt.UpdateUser(context.Background(), oldInbound, email, updated); err1 != nil {
+						logger.Warning("Error in updating client on", rt.Name(), ":", err1)
+						markDirty = true
+					}
 				}
 			}
 		}
@@ -2765,6 +2794,10 @@ func (s *ClientService) bulkAdjustInboundClients(
 				res.perEmailSkipped[email] = txErr.Error()
 			}
 		}
+	} else if markDirty && oldInbound.NodeID != nil {
+		if dErr := (&NodeService{}).MarkNodeDirty(*oldInbound.NodeID); dErr != nil {
+			logger.Warning("mark node dirty failed:", dErr)
+		}
 	}
 
 	return res
@@ -3083,6 +3116,7 @@ func (s *ClientService) bulkDelInboundClients(
 		}
 	}
 
+	markDirty := false
 	if oldInbound.NodeID == nil {
 		rt, rterr := inboundSvc.runtimeFor(oldInbound)
 		if rterr != nil {
@@ -3104,17 +3138,22 @@ func (s *ClientService) bulkDelInboundClients(
 			}
 		}
 	} else {
-		rt, rterr := inboundSvc.runtimeFor(oldInbound)
-		if rterr != nil {
+		rt, push, dirty, perr := inboundSvc.nodePushPlan(oldInbound)
+		if perr != nil {
 			for email := range foundEmails {
-				res.perEmailSkipped[email] = rterr.Error()
+				res.perEmailSkipped[email] = perr.Error()
 				delete(foundEmails, email)
 			}
 		} else {
-			for email := range foundEmails {
-				if err1 := rt.DeleteUser(context.Background(), oldInbound, email); err1 != nil {
-					res.perEmailSkipped[email] = err1.Error()
-					delete(foundEmails, email)
+			if dirty {
+				markDirty = true
+			}
+			if push {
+				for email := range foundEmails {
+					if err1 := rt.DeleteUser(context.Background(), oldInbound, email); err1 != nil {
+						logger.Warning("Error in deleting client on", rt.Name(), ":", err1)
+						markDirty = true
+					}
 				}
 			}
 		}
@@ -3136,6 +3175,10 @@ func (s *ClientService) bulkDelInboundClients(
 				res.perEmailSkipped[email] = txErr.Error()
 			}
 		}
+	} else if markDirty && oldInbound.NodeID != nil {
+		if dErr := (&NodeService{}).MarkNodeDirty(*oldInbound.NodeID); dErr != nil {
+			logger.Warning("mark node dirty failed:", dErr)
+		}
 	}
 
 	return res
@@ -3608,50 +3651,61 @@ func (s *ClientService) addInboundClient(inboundSvc *InboundService, data *model
 	db := database.GetDB()
 	tx := db.Begin()
 
+	markDirty := false
 	defer func() {
 		if err != nil {
 			tx.Rollback()
-		} else {
-			tx.Commit()
+			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, rterr := inboundSvc.runtimeFor(oldInbound)
-	if rterr != nil {
-		if oldInbound.NodeID != nil {
-			err = rterr
-			return false, err
-		}
-		needRestart = true
-	} else if oldInbound.NodeID == nil {
-		for _, client := range clients {
-			if len(client.Email) == 0 {
-				needRestart = true
-				continue
-			}
-			inboundSvc.AddClientStat(tx, data.Id, &client)
-			if !client.Enable {
-				continue
-			}
-			cipher := ""
-			if oldInbound.Protocol == "shadowsocks" {
-				cipher = oldSettings["method"].(string)
-			}
-			err1 := rt.AddUser(context.Background(), oldInbound, map[string]any{
-				"email":    client.Email,
-				"id":       client.ID,
-				"auth":     client.Auth,
-				"security": client.Security,
-				"flow":     client.Flow,
-				"password": client.Password,
-				"cipher":   cipher,
-			})
-			if err1 == nil {
-				logger.Debug("Client added on", rt.Name(), ":", client.Email)
-			} else {
-				logger.Debug("Error in adding client on", rt.Name(), ":", err1)
-				needRestart = true
+	rt, push, dirty, perr := inboundSvc.nodePushPlan(oldInbound)
+	if perr != nil {
+		err = perr
+		return false, err
+	}
+	if dirty {
+		markDirty = true
+	}
+	if oldInbound.NodeID == nil {
+		if !push {
+			needRestart = true
+		} else {
+			for _, client := range clients {
+				if len(client.Email) == 0 {
+					needRestart = true
+					continue
+				}
+				inboundSvc.AddClientStat(tx, data.Id, &client)
+				if !client.Enable {
+					continue
+				}
+				cipher := ""
+				if oldInbound.Protocol == "shadowsocks" {
+					cipher = oldSettings["method"].(string)
+				}
+				err1 := rt.AddUser(context.Background(), oldInbound, map[string]any{
+					"email":    client.Email,
+					"id":       client.ID,
+					"auth":     client.Auth,
+					"security": client.Security,
+					"flow":     client.Flow,
+					"password": client.Password,
+					"cipher":   cipher,
+				})
+				if err1 == nil {
+					logger.Debug("Client added on", rt.Name(), ":", client.Email)
+				} else {
+					logger.Debug("Error in adding client on", rt.Name(), ":", err1)
+					needRestart = true
+				}
 			}
 		}
 	} else {
@@ -3659,9 +3713,12 @@ func (s *ClientService) addInboundClient(inboundSvc *InboundService, data *model
 			if len(client.Email) > 0 {
 				inboundSvc.AddClientStat(tx, data.Id, &client)
 			}
-			if err1 := rt.AddClient(context.Background(), oldInbound, client); err1 != nil {
-				err = err1
-				return false, err
+			if push {
+				if err1 := rt.AddClient(context.Background(), oldInbound, client); err1 != nil {
+					logger.Warning("Error in adding client on", rt.Name(), ":", err1)
+					markDirty = true
+					push = false
+				}
 			}
 		}
 	}
@@ -3839,11 +3896,17 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
 	db := database.GetDB()
 	tx := db.Begin()
 
+	markDirty := false
 	defer func() {
 		if err != nil {
 			tx.Rollback()
-		} else {
-			tx.Commit()
+			return
+		}
+		tx.Commit()
+		if markDirty && oldInbound.NodeID != nil {
+			if dErr := (&NodeService{}).MarkNodeDirty(*oldInbound.NodeID); dErr != nil {
+				logger.Warning("mark node dirty failed:", dErr)
+			}
 		}
 	}()
 
@@ -3903,50 +3966,55 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
 	}
 	needRestart := false
 	if len(oldEmail) > 0 {
-		rt, rterr := inboundSvc.runtimeFor(oldInbound)
-		if rterr != nil {
-			if oldInbound.NodeID != nil {
-				err = rterr
-				return false, err
-			}
-			needRestart = true
-		} else if oldInbound.NodeID == nil {
-			if oldClients[clientIndex].Enable {
-				err1 := rt.RemoveUser(context.Background(), oldInbound, oldEmail)
-				if err1 == nil {
-					logger.Debug("Old client deleted on", rt.Name(), ":", oldEmail)
-				} else if strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", oldEmail)) {
-					logger.Debug("User is already deleted. Nothing to do more...")
-				} else {
-					logger.Debug("Error in deleting client on", rt.Name(), ":", err1)
-					needRestart = true
-				}
-			}
-			if clients[0].Enable {
-				cipher := ""
-				if oldInbound.Protocol == "shadowsocks" {
-					cipher = oldSettings["method"].(string)
+		rt, push, dirty, perr := inboundSvc.nodePushPlan(oldInbound)
+		if perr != nil {
+			err = perr
+			return false, err
+		}
+		if dirty {
+			markDirty = true
+		}
+		if oldInbound.NodeID == nil {
+			if !push {
+				needRestart = true
+			} else {
+				if oldClients[clientIndex].Enable {
+					err1 := rt.RemoveUser(context.Background(), oldInbound, oldEmail)
+					if err1 == nil {
+						logger.Debug("Old client deleted on", rt.Name(), ":", oldEmail)
+					} else if strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", oldEmail)) {
+						logger.Debug("User is already deleted. Nothing to do more...")
+					} else {
+						logger.Debug("Error in deleting client on", rt.Name(), ":", err1)
+						needRestart = true
+					}
 				}
-				err1 := rt.AddUser(context.Background(), oldInbound, map[string]any{
-					"email":    clients[0].Email,
-					"id":       clients[0].ID,
-					"security": clients[0].Security,
-					"flow":     clients[0].Flow,
-					"auth":     clients[0].Auth,
-					"password": clients[0].Password,
-					"cipher":   cipher,
-				})
-				if err1 == nil {
-					logger.Debug("Client edited on", rt.Name(), ":", clients[0].Email)
-				} else {
-					logger.Debug("Error in adding client on", rt.Name(), ":", err1)
-					needRestart = true
+				if clients[0].Enable {
+					cipher := ""
+					if oldInbound.Protocol == "shadowsocks" {
+						cipher = oldSettings["method"].(string)
+					}
+					err1 := rt.AddUser(context.Background(), oldInbound, map[string]any{
+						"email":    clients[0].Email,
+						"id":       clients[0].ID,
+						"security": clients[0].Security,
+						"flow":     clients[0].Flow,
+						"auth":     clients[0].Auth,
+						"password": clients[0].Password,
+						"cipher":   cipher,
+					})
+					if err1 == nil {
+						logger.Debug("Client edited on", rt.Name(), ":", clients[0].Email)
+					} else {
+						logger.Debug("Error in adding client on", rt.Name(), ":", err1)
+						needRestart = true
+					}
 				}
 			}
-		} else {
+		} else if push {
 			if err1 := rt.UpdateUser(context.Background(), oldInbound, oldEmail, clients[0]); err1 != nil {
-				err = err1
-				return false, err
+				logger.Warning("Error in updating client on", rt.Name(), ":", err1)
+				markDirty = true
 			}
 		}
 	} else {
@@ -4038,6 +4106,7 @@ func (s *ClientService) DelInboundClient(inboundSvc *InboundService, inboundId i
 		}
 	}
 	needRestart := false
+	markDirty := false
 
 	if len(email) > 0 {
 		var enables []bool
@@ -4073,12 +4142,18 @@ func (s *ClientService) DelInboundClient(inboundSvc *InboundService, inboundId i
 		}
 	}
 	if oldInbound.NodeID != nil && len(email) > 0 {
-		rt, rterr := inboundSvc.runtimeFor(oldInbound)
-		if rterr != nil {
-			return false, rterr
+		rt, push, dirty, perr := inboundSvc.nodePushPlan(oldInbound)
+		if perr != nil {
+			return false, perr
 		}
-		if err1 := rt.DeleteUser(context.Background(), oldInbound, email); err1 != nil {
-			return false, err1
+		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)
+				markDirty = true
+			}
 		}
 	}
 	if err := db.Save(oldInbound).Error; err != nil {
@@ -4091,6 +4166,11 @@ func (s *ClientService) DelInboundClient(inboundSvc *InboundService, inboundId i
 	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)
+		}
+	}
 	return needRestart, nil
 }
 
@@ -4159,6 +4239,7 @@ func (s *ClientService) DelInboundClientByEmail(inboundSvc *InboundService, inbo
 	}
 
 	needRestart := false
+	markDirty := false
 
 	if len(email) > 0 && !emailShared {
 		if !keepTraffic {
@@ -4175,25 +4256,29 @@ func (s *ClientService) DelInboundClientByEmail(inboundSvc *InboundService, inbo
 		}
 
 		if needApiDel {
-			rt, rterr := inboundSvc.runtimeFor(oldInbound)
-			if rterr != nil {
-				if oldInbound.NodeID != nil {
-					return false, rterr
-				}
-				needRestart = true
-			} else if oldInbound.NodeID == nil {
-				if err1 := rt.RemoveUser(context.Background(), oldInbound, email); err1 == nil {
+			rt, push, dirty, perr := inboundSvc.nodePushPlan(oldInbound)
+			if perr != nil {
+				return false, perr
+			}
+			if dirty {
+				markDirty = true
+			}
+			if oldInbound.NodeID == nil {
+				if !push {
+					needRestart = true
+				} else if err1 := rt.RemoveUser(context.Background(), oldInbound, email); err1 == nil {
 					logger.Debug("Client deleted on", rt.Name(), ":", email)
 					needRestart = false
 				} else if strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", email)) {
 					logger.Debug("User is already deleted. Nothing to do more...")
 				} else {
-					logger.Debug("Error in deleting client on", rt.Name(), ":", err1)
+					logger.Debug("Error in deleting client on", rt.Name(), ":", email)
 					needRestart = true
 				}
-			} else {
+			} else if push {
 				if err1 := rt.DeleteUser(context.Background(), oldInbound, email); err1 != nil {
-					return false, err1
+					logger.Warning("Error in deleting client on", rt.Name(), ":", err1)
+					markDirty = true
 				}
 			}
 		}
@@ -4209,6 +4294,11 @@ func (s *ClientService) DelInboundClientByEmail(inboundSvc *InboundService, inbo
 	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)
+		}
+	}
 	return needRestart, nil
 }
 

+ 1 - 1
web/service/client_group_node_sync_test.go

@@ -62,7 +62,7 @@ func TestSetRemoteTraffic_PreservesPanelLocalGroupAndComment(t *testing.T) {
 	}
 
 	svc := InboundService{}
-	if _, err := svc.setRemoteTrafficLocked(nodeID, snap); err != nil {
+	if _, err := svc.setRemoteTrafficLocked(nodeID, snap, false); err != nil {
 		t.Fatalf("setRemoteTrafficLocked: %v", err)
 	}
 

+ 250 - 84
web/service/inbound.go

@@ -41,6 +41,92 @@ func (s *InboundService) runtimeFor(ib *model.Inbound) (runtime.Runtime, error)
 	return mgr.RuntimeFor(ib.NodeID)
 }
 
+func (s *InboundService) nodePushPlan(ib *model.Inbound) (runtime.Runtime, bool, bool, error) {
+	if ib.NodeID == nil {
+		rt, err := s.runtimeFor(ib)
+		if err != nil {
+			return nil, false, false, nil
+		}
+		return rt, true, false, nil
+	}
+	nodeSvc := NodeService{}
+	enabled, status, _, _, err := nodeSvc.NodeSyncState(*ib.NodeID)
+	if err != nil {
+		return nil, false, false, err
+	}
+	if !enabled || status == "offline" {
+		return nil, false, true, nil
+	}
+	rt, err := s.runtimeFor(ib)
+	if err != nil {
+		return nil, false, true, nil
+	}
+	return rt, true, false, nil
+}
+
+func (s *InboundService) NodeIsPending(nodeID *int) bool {
+	if nodeID == nil {
+		return false
+	}
+	return (&NodeService{}).IsNodePending(*nodeID)
+}
+
+func (s *InboundService) AnyNodePending(inboundIds []int) bool {
+	if len(inboundIds) == 0 {
+		return false
+	}
+	nodeSvc := NodeService{}
+	for _, id := range inboundIds {
+		ib, err := s.GetInbound(id)
+		if err != nil || ib.NodeID == nil {
+			continue
+		}
+		if nodeSvc.IsNodePending(*ib.NodeID) {
+			return true
+		}
+	}
+	return false
+}
+
+func (s *InboundService) ReconcileNode(ctx context.Context, rt *runtime.Remote, nodeID int) error {
+	if rt == nil || nodeID <= 0 {
+		return nil
+	}
+	db := database.GetDB()
+	var inbounds []*model.Inbound
+	if err := db.Model(model.Inbound{}).Where("node_id = ?", nodeID).Find(&inbounds).Error; err != nil {
+		return err
+	}
+	remoteTags, err := rt.ListRemoteTags(ctx)
+	if err != nil {
+		return err
+	}
+	prefix := nodeTagPrefix(&nodeID)
+	desiredTags := make(map[string]struct{}, len(inbounds)*2)
+	for _, ib := range inbounds {
+		desiredTags[ib.Tag] = struct{}{}
+		if prefix != "" {
+			if stripped, found := strings.CutPrefix(ib.Tag, prefix); found {
+				desiredTags[stripped] = struct{}{}
+			} else {
+				desiredTags[prefix+ib.Tag] = struct{}{}
+			}
+		}
+		if err := rt.UpdateInbound(ctx, ib, ib); err != nil {
+			return fmt.Errorf("reconcile inbound %q: %w", ib.Tag, err)
+		}
+	}
+	for _, tag := range remoteTags {
+		if _, want := desiredTags[tag]; want {
+			continue
+		}
+		if err := rt.DelInbound(ctx, &model.Inbound{Tag: tag}); err != nil {
+			return fmt.Errorf("reconcile delete %q: %w", tag, err)
+		}
+	}
+	return nil
+}
+
 type CopyClientsResult struct {
 	Added   []string `json:"added"`
 	Skipped []string `json:"skipped"`
@@ -575,11 +661,17 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
 
 	db := database.GetDB()
 	tx := db.Begin()
+	markDirty := false
 	defer func() {
-		if err == nil {
-			tx.Commit()
-		} else {
+		if err != nil {
 			tx.Rollback()
+			return
+		}
+		tx.Commit()
+		if markDirty && inbound.NodeID != nil {
+			if dErr := (&NodeService{}).MarkNodeDirty(*inbound.NodeID); dErr != nil {
+				logger.Warning("mark node dirty failed:", dErr)
+			}
 		}
 	}()
 
@@ -600,20 +692,25 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
 
 	needRestart := false
 	if inbound.Enable {
-		rt, rterr := s.runtimeFor(inbound)
-		if rterr != nil {
-			err = rterr
+		rt, push, dirty, perr := s.nodePushPlan(inbound)
+		if perr != nil {
+			err = perr
 			return inbound, false, err
 		}
-		if err1 := rt.AddInbound(context.Background(), inbound); err1 == nil {
-			logger.Debug("New inbound added on", rt.Name(), ":", inbound.Tag)
-		} else {
-			logger.Debug("Unable to add inbound on", rt.Name(), ":", err1)
-			if inbound.NodeID != nil {
-				err = err1
-				return inbound, false, err
+		if dirty {
+			markDirty = true
+		}
+		if push {
+			if err1 := rt.AddInbound(context.Background(), inbound); err1 == nil {
+				logger.Debug("New inbound added on", rt.Name(), ":", inbound.Tag)
+			} else {
+				logger.Debug("Unable to add inbound on", rt.Name(), ":", err1)
+				if inbound.NodeID != nil {
+					markDirty = true
+				} else {
+					needRestart = true
+				}
 			}
-			needRestart = true
 		}
 	}
 
@@ -624,24 +721,31 @@ func (s *InboundService) DelInbound(id int) (bool, error) {
 	db := database.GetDB()
 
 	needRestart := false
+	markDirty := false
 	var ib model.Inbound
 	loadErr := db.Model(model.Inbound{}).Where("id = ?", id).First(&ib).Error
 	if loadErr == nil {
 		shouldPushToRuntime := ib.NodeID != nil || ib.Enable
 		if shouldPushToRuntime {
-			rt, rterr := s.runtimeFor(&ib)
-			if rterr != nil {
-				logger.Warning("DelInbound: runtime lookup failed, deleting central row anyway:", rterr)
-				if ib.NodeID == nil {
-					needRestart = true
-				}
-			} else if err1 := rt.DelInbound(context.Background(), &ib); err1 == nil {
-				logger.Debug("Inbound deleted on", rt.Name(), ":", ib.Tag)
-			} else {
-				logger.Warning("DelInbound on", rt.Name(), "failed, deleting central row anyway:", err1)
-				if ib.NodeID == nil {
-					needRestart = true
+			rt, push, dirty, perr := s.nodePushPlan(&ib)
+			if perr != nil {
+				logger.Warning("DelInbound: node lookup failed, deleting central row anyway:", perr)
+				markDirty = true
+			} else if push {
+				if err1 := rt.DelInbound(context.Background(), &ib); err1 == nil {
+					logger.Debug("Inbound deleted on", rt.Name(), ":", ib.Tag)
+				} else {
+					logger.Warning("DelInbound on", rt.Name(), "failed, deleting central row anyway:", err1)
+					if ib.NodeID == nil {
+						needRestart = true
+					} else {
+						markDirty = true
+					}
 				}
+			} else if ib.NodeID == nil {
+				needRestart = true
+			} else if dirty {
+				markDirty = true
 			}
 		} else {
 			logger.Debug("DelInbound: skipping runtime push for disabled local inbound id:", id)
@@ -657,6 +761,11 @@ func (s *InboundService) DelInbound(id int) (bool, error) {
 	if err := db.Delete(model.Inbound{}, id).Error; err != nil {
 		return needRestart, err
 	}
+	if markDirty && ib.NodeID != nil {
+		if dErr := (&NodeService{}).MarkNodeDirty(*ib.NodeID); dErr != nil {
+			logger.Warning("mark node dirty failed:", dErr)
+		}
+	}
 	if !database.IsPostgres() {
 		var count int64
 		if err := db.Model(&model.Inbound{}).Count(&count).Error; err != nil {
@@ -740,12 +849,9 @@ func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
 	inbound.Enable = enable
 
 	needRestart := false
-	rt, rterr := s.runtimeFor(inbound)
-	if rterr != nil {
-		if inbound.NodeID != nil {
-			return false, rterr
-		}
-		return true, nil
+	rt, push, dirty, perr := s.nodePushPlan(inbound)
+	if perr != nil {
+		return false, perr
 	}
 
 	// Remote nodes interpret DelInbound as a real row delete (it hits
@@ -754,13 +860,24 @@ func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
 	// PATCH the remote row via UpdateInbound instead — preserves the
 	// settings/client history and just flips the enable flag.
 	if inbound.NodeID != nil {
-		if err := rt.UpdateInbound(context.Background(), inbound, inbound); err != nil {
-			logger.Debug("SetInboundEnable: remote UpdateInbound on", rt.Name(), "failed:", err)
-			return false, err
+		if push {
+			if err := rt.UpdateInbound(context.Background(), inbound, inbound); err != nil {
+				logger.Warning("SetInboundEnable: remote UpdateInbound on", rt.Name(), "failed:", err)
+				dirty = true
+			}
+		}
+		if dirty {
+			if dErr := (&NodeService{}).MarkNodeDirty(*inbound.NodeID); dErr != nil {
+				logger.Warning("mark node dirty failed:", dErr)
+			}
 		}
 		return false, nil
 	}
 
+	if !push {
+		return true, nil
+	}
+
 	if err := rt.DelInbound(context.Background(), inbound); err != nil &&
 		!strings.Contains(err.Error(), "not found") {
 		logger.Debug("SetInboundEnable: DelInbound on", rt.Name(), "failed:", err)
@@ -807,11 +924,17 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
 	db := database.GetDB()
 	tx := db.Begin()
 
+	markDirty := false
 	defer func() {
 		if err != nil {
 			tx.Rollback()
-		} else {
-			tx.Commit()
+			return
+		}
+		tx.Commit()
+		if markDirty && oldInbound.NodeID != nil {
+			if dErr := (&NodeService{}).MarkNodeDirty(*oldInbound.NodeID); dErr != nil {
+				logger.Warning("mark node dirty failed:", dErr)
+			}
 		}
 	}()
 
@@ -900,17 +1023,20 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
 	inbound.Tag = oldInbound.Tag
 
 	needRestart := false
-	rt, rterr := s.runtimeFor(oldInbound)
-	if rterr != nil {
-		if oldInbound.NodeID != nil {
-			err = rterr
-			return inbound, false, err
-		}
-		needRestart = true
-	} else {
-		oldSnapshot := *oldInbound
-		oldSnapshot.Tag = tag
-		if oldInbound.NodeID == nil {
+	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 {
+			oldSnapshot := *oldInbound
+			oldSnapshot.Tag = tag
 			if err2 := rt.DelInbound(context.Background(), &oldSnapshot); err2 == nil {
 				logger.Debug("Old inbound deleted on", rt.Name(), ":", tag)
 			}
@@ -926,16 +1052,18 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
 					needRestart = true
 				}
 			}
-		} else {
-			if !inbound.Enable {
-				if err2 := rt.DelInbound(context.Background(), &oldSnapshot); err2 != nil {
-					err = err2
-					return inbound, false, err
-				}
-			} else if err2 := rt.UpdateInbound(context.Background(), &oldSnapshot, oldInbound); err2 != nil {
-				err = err2
-				return inbound, false, err
+		}
+	} 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)
+				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
 		}
 	}
 
@@ -1303,17 +1431,17 @@ func (s *InboundService) upsertNodeBaseline(tx *gorm.DB, nodeID int, email strin
 	}).Create(&model.NodeClientTraffic{NodeId: nodeID, Email: email, Up: up, Down: down}).Error
 }
 
-func (s *InboundService) SetRemoteTraffic(nodeID int, snap *runtime.TrafficSnapshot) (bool, error) {
+func (s *InboundService) SetRemoteTraffic(nodeID int, snap *runtime.TrafficSnapshot, dirty bool) (bool, error) {
 	var structuralChange bool
 	err := submitTrafficWrite(func() error {
 		var inner error
-		structuralChange, inner = s.setRemoteTrafficLocked(nodeID, snap)
+		structuralChange, inner = s.setRemoteTrafficLocked(nodeID, snap, dirty)
 		return inner
 	})
 	return structuralChange, err
 }
 
-func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.TrafficSnapshot) (bool, error) {
+func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.TrafficSnapshot, dirty bool) (bool, error) {
 	if snap == nil || nodeID <= 0 {
 		return false, nil
 	}
@@ -1425,6 +1553,9 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 
 		c, ok := tagToCentral[snapIb.Tag]
 		if !ok {
+			if dirty {
+				continue
+			}
 			// Try snap.Tag first; on collision fall back to the n<id>-
 			// prefixed form so local+node can both own the same port.
 			pickFreeTag := func() (string, error) {
@@ -1491,42 +1622,48 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 
 		inGrace := c.LastTrafficResetTime > 0 && now-c.LastTrafficResetTime < resetGracePeriodMs
 
-		updates := map[string]any{
-			"enable":          snapIb.Enable,
-			"remark":          snapIb.Remark,
-			"listen":          snapIb.Listen,
-			"port":            snapIb.Port,
-			"protocol":        snapIb.Protocol,
-			"total":           snapIb.Total,
-			"expiry_time":     snapIb.ExpiryTime,
-			"settings":        snapIb.Settings,
-			"stream_settings": snapIb.StreamSettings,
-			"sniffing":        snapIb.Sniffing,
-			"traffic_reset":   snapIb.TrafficReset,
+		updates := map[string]any{}
+		if !dirty {
+			updates["enable"] = snapIb.Enable
+			updates["remark"] = snapIb.Remark
+			updates["listen"] = snapIb.Listen
+			updates["port"] = snapIb.Port
+			updates["protocol"] = snapIb.Protocol
+			updates["total"] = snapIb.Total
+			updates["expiry_time"] = snapIb.ExpiryTime
+			updates["settings"] = snapIb.Settings
+			updates["stream_settings"] = snapIb.StreamSettings
+			updates["sniffing"] = snapIb.Sniffing
+			updates["traffic_reset"] = snapIb.TrafficReset
 		}
 		if !inGrace || (snapIb.Up+snapIb.Down) <= (c.Up+c.Down) {
 			updates["up"] = snapIb.Up
 			updates["down"] = snapIb.Down
 		}
 
-		if c.Settings != snapIb.Settings ||
+		if !dirty && (c.Settings != snapIb.Settings ||
 			c.Remark != snapIb.Remark ||
 			c.Listen != snapIb.Listen ||
 			c.Port != snapIb.Port ||
 			c.Total != snapIb.Total ||
 			c.ExpiryTime != snapIb.ExpiryTime ||
-			c.Enable != snapIb.Enable {
+			c.Enable != snapIb.Enable) {
 			structuralChange = true
 		}
 
-		if err := tx.Model(model.Inbound{}).
-			Where("id = ?", c.Id).
-			Updates(updates).Error; err != nil {
-			return false, err
+		if len(updates) > 0 {
+			if err := tx.Model(model.Inbound{}).
+				Where("id = ?", c.Id).
+				Updates(updates).Error; err != nil {
+				return false, err
+			}
 		}
 	}
 
 	for _, c := range central {
+		if dirty {
+			continue
+		}
 		if _, kept := snapTags[c.Tag]; kept {
 			continue
 		}
@@ -1581,6 +1718,9 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 			}
 
 			if _, rowExists := existingEmails[cs.Email]; !rowExists {
+				if dirty {
+					continue
+				}
 				row := &xray.ClientTraffic{
 					InboundId:  c.Id,
 					Email:      cs.Email,
@@ -1642,6 +1782,9 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 		}
 
 		for k, existing := range centralCS {
+			if dirty {
+				continue
+			}
 			if k.inboundID != c.Id {
 				continue
 			}
@@ -1673,6 +1816,9 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 		if !ok {
 			continue
 		}
+		if dirty {
+			continue
+		}
 		var oldEmailsRows []string
 		if err := tx.Table("clients").
 			Joins("JOIN client_inbounds ON client_inbounds.client_id = clients.id").
@@ -1879,9 +2025,16 @@ func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTr
 		emails = append(emails, traffic.Email)
 	}
 	dbClientTraffics := make([]*xray.ClientTraffic, 0, len(traffics))
+	// Match purely by email. client_traffics is email-keyed (one shared row per
+	// email regardless of how many inbounds the client is attached to), and these
+	// emails come from the local xray's report, so they always belong to a client
+	// attached to a local inbound. The old `inbound_id NOT IN (node inbounds)`
+	// filter dropped the local traffic of a client attached to both a node and the
+	// mother inbound whenever the node inbound happened to be attached first — its
+	// shared row then carried the node inbound's id (AddClientStat uses OnConflict
+	// DoNothing and never refreshes it), so the local poll skipped it entirely.
 	err = tx.Model(xray.ClientTraffic{}).
-		Where("email IN (?) AND inbound_id NOT IN (?)", emails,
-			tx.Model(&model.Inbound{}).Select("id").Where("node_id IS NOT NULL")).
+		Where("email IN (?)", emails).
 		Find(&dbClientTraffics).Error
 	if err != nil {
 		return err
@@ -2667,12 +2820,20 @@ func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (b
 		}
 		for _, client := range clients {
 			if client.Email == clientEmail && client.Enable {
-				rt, rterr := s.runtimeFor(inbound)
-				if rterr != nil {
+				rt, push, dirty, perr := s.nodePushPlan(inbound)
+				if perr != nil {
+					return false, perr
+				}
+				if !push {
 					if inbound.NodeID != nil {
-						return false, rterr
+						if dirty {
+							if dErr := (&NodeService{}).MarkNodeDirty(*inbound.NodeID); dErr != nil {
+								logger.Warning("mark node dirty failed:", dErr)
+							}
+						}
+					} else {
+						needRestart = true
 					}
-					needRestart = true
 					break
 				}
 				cipher := ""
@@ -2695,6 +2856,11 @@ func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (b
 				})
 				if err1 == nil {
 					logger.Debug("Client enabled on", rt.Name(), "due to reset traffic:", clientEmail)
+				} else if inbound.NodeID != nil {
+					logger.Warning("Error in enabling client on", rt.Name(), ":", err1)
+					if dErr := (&NodeService{}).MarkNodeDirty(*inbound.NodeID); dErr != nil {
+						logger.Warning("mark node dirty failed:", dErr)
+					}
 				} else {
 					logger.Debug("Error in enabling client on", rt.Name(), ":", err1)
 					needRestart = true

+ 39 - 30
web/service/inbound_client_traffic_test.go

@@ -10,14 +10,20 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/xray"
 )
 
-// TestAddClientTraffic_MatchesDespiteStaleInboundId reproduces the production bug where
-// client_traffics rows survive an inbound delete+recreate with a stale inbound_id (the
-// shared-by-email row keeps the deleted inbound's id, and AddClientStat's OnConflict-
-// DoNothing never refreshes it). The old `inbound_id IN (local inbounds)` filter dropped
-// those rows, so local traffic and online status stopped updating. The fix matches by
-// email and only excludes rows owned by a node inbound, so a stale local row is still
-// updated while a genuine node-owned row is left untouched.
-func TestAddClientTraffic_MatchesDespiteStaleInboundId(t *testing.T) {
+// TestAddClientTraffic_MatchesByEmail covers two scenarios that share one fix:
+// client_traffics is keyed by email (one shared row per email no matter how many
+// inbounds the client is attached to), so local traffic must be applied by email
+// regardless of which inbound_id the row happens to carry.
+//
+//   - staleEmail: the row points at an inbound id that no longer exists (a deleted
+//     earlier incarnation, AddClientStat's OnConflict-DoNothing never refreshes it).
+//   - dualEmail: the client is attached to both a node inbound and the mother inbound,
+//     but the node inbound was attached first, so the shared row carries the node
+//     inbound's id (issue #4921). The old `inbound_id NOT IN (node inbounds)` filter
+//     dropped this client's local traffic, leaving it stuck at zero and offline.
+//
+// Both must have their local traffic counted.
+func TestAddClientTraffic_MatchesByEmail(t *testing.T) {
 	dbDir := t.TempDir()
 	t.Setenv("XUI_DB_FOLDER", dbDir)
 	if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
@@ -27,11 +33,9 @@ func TestAddClientTraffic_MatchesDespiteStaleInboundId(t *testing.T) {
 
 	db := database.GetDB()
 
-	const localEmail = "local-user"
-	const nodeEmail = "node-user"
+	const staleEmail = "stale-user"
+	const dualEmail = "dual-user"
 
-	// A local inbound exists, but the local client's traffic row points at an inbound id
-	// that no longer exists (a deleted earlier incarnation) — the stale-pointer scenario.
 	localInbound := &model.Inbound{UserId: 1, Tag: "local-in", Enable: true, Port: 40001, Protocol: model.VLESS}
 	if err := db.Create(localInbound).Error; err != nil {
 		t.Fatalf("create local inbound: %v", err)
@@ -42,39 +46,44 @@ func TestAddClientTraffic_MatchesDespiteStaleInboundId(t *testing.T) {
 		t.Fatalf("create node inbound: %v", err)
 	}
 
-	if err := db.Create(&xray.ClientTraffic{InboundId: 9999, Email: localEmail, Enable: true}).Error; err != nil {
-		t.Fatalf("create stale local client_traffics: %v", err)
+	if err := db.Create(&xray.ClientTraffic{InboundId: 9999, Email: staleEmail, Enable: true}).Error; err != nil {
+		t.Fatalf("create stale client_traffics: %v", err)
 	}
-	if err := db.Create(&xray.ClientTraffic{InboundId: nodeInbound.Id, Email: nodeEmail, Enable: true}).Error; err != nil {
-		t.Fatalf("create node client_traffics: %v", err)
+	// Attached to both inbounds, but the node inbound won the OnConflict so the
+	// shared row is owned by the node inbound id.
+	if err := db.Create(&xray.ClientTraffic{InboundId: nodeInbound.Id, Email: dualEmail, Enable: true}).Error; err != nil {
+		t.Fatalf("create dual client_traffics: %v", err)
 	}
 
 	svc := InboundService{}
 	err := svc.addClientTraffic(db, []*xray.ClientTraffic{
-		{Email: localEmail, Up: 10, Down: 20},
-		{Email: nodeEmail, Up: 30, Down: 40},
+		{Email: staleEmail, Up: 10, Down: 20},
+		{Email: dualEmail, Up: 30, Down: 40},
 	})
 	if err != nil {
 		t.Fatalf("addClientTraffic: %v", err)
 	}
 
-	var local xray.ClientTraffic
-	if err := db.Model(xray.ClientTraffic{}).Where("email = ?", localEmail).First(&local).Error; err != nil {
-		t.Fatalf("reload local row: %v", err)
+	var stale xray.ClientTraffic
+	if err := db.Model(xray.ClientTraffic{}).Where("email = ?", staleEmail).First(&stale).Error; err != nil {
+		t.Fatalf("reload stale row: %v", err)
 	}
-	if local.Up != 10 || local.Down != 20 {
-		t.Errorf("stale-pointer local row not updated: up=%d down=%d, want 10/20", local.Up, local.Down)
+	if stale.Up != 10 || stale.Down != 20 {
+		t.Errorf("stale-pointer row not updated: up=%d down=%d, want 10/20", stale.Up, stale.Down)
 	}
-	if local.LastOnline == 0 {
-		t.Errorf("stale-pointer local row LastOnline not set")
+	if stale.LastOnline == 0 {
+		t.Errorf("stale-pointer row LastOnline not set")
 	}
 
-	var node xray.ClientTraffic
-	if err := db.Model(xray.ClientTraffic{}).Where("email = ?", nodeEmail).First(&node).Error; err != nil {
-		t.Fatalf("reload node row: %v", err)
+	var dual xray.ClientTraffic
+	if err := db.Model(xray.ClientTraffic{}).Where("email = ?", dualEmail).First(&dual).Error; err != nil {
+		t.Fatalf("reload dual row: %v", err)
 	}
-	if node.Up != 0 || node.Down != 0 {
-		t.Errorf("node-owned row should not be touched by local traffic: up=%d down=%d, want 0/0", node.Up, node.Down)
+	if dual.Up != 30 || dual.Down != 40 {
+		t.Errorf("node-owned row not updated by local traffic (issue #4921): up=%d down=%d, want 30/40", dual.Up, dual.Down)
+	}
+	if dual.LastOnline == 0 {
+		t.Errorf("node-owned row LastOnline not set (client stayed offline)")
 	}
 }
 

+ 44 - 0
web/service/node.go

@@ -480,6 +480,50 @@ func (s *NodeService) UpdateHeartbeat(id int, p HeartbeatPatch) error {
 	return nil
 }
 
+func (s *NodeService) MarkNodeDirty(id int) error {
+	if id <= 0 {
+		return nil
+	}
+	return database.GetDB().Model(model.Node{}).
+		Where("id = ?", id).
+		Updates(map[string]any{
+			"config_dirty":    true,
+			"config_dirty_at": time.Now().UnixMilli(),
+		}).Error
+}
+
+func (s *NodeService) ClearNodeDirty(id int, dirtyAt int64) error {
+	if id <= 0 {
+		return nil
+	}
+	return database.GetDB().Model(model.Node{}).
+		Where("id = ? AND config_dirty_at = ?", id, dirtyAt).
+		Update("config_dirty", false).Error
+}
+
+func (s *NodeService) NodeSyncState(id int) (enabled bool, status string, dirty bool, dirtyAt int64, err error) {
+	if id <= 0 {
+		return false, "", false, 0, errors.New("invalid node id")
+	}
+	var row model.Node
+	err = database.GetDB().Model(model.Node{}).
+		Select("enable", "status", "config_dirty", "config_dirty_at").
+		Where("id = ?", id).
+		First(&row).Error
+	if err != nil {
+		return false, "", false, 0, err
+	}
+	return row.Enable, row.Status, row.ConfigDirty, row.ConfigDirtyAt, nil
+}
+
+func (s *NodeService) IsNodePending(id int) bool {
+	enabled, status, dirty, _, err := s.NodeSyncState(id)
+	if err != nil {
+		return false
+	}
+	return !enabled || status != "online" || dirty
+}
+
 func nodeMetricKey(id int, metric string) string {
 	return "node:" + strconv.Itoa(id) + ":" + metric
 }

+ 1 - 1
web/service/node_client_traffic_sum_test.go

@@ -36,7 +36,7 @@ func syncNode(t *testing.T, svc *InboundService, nodeID int, tag string, stats .
 	snap := &runtime.TrafficSnapshot{
 		Inbounds: []*model.Inbound{{Tag: tag, ClientStats: stats}},
 	}
-	if _, err := svc.setRemoteTrafficLocked(nodeID, snap); err != nil {
+	if _, err := svc.setRemoteTrafficLocked(nodeID, snap, false); err != nil {
 		t.Fatalf("setRemoteTrafficLocked node %d: %v", nodeID, err)
 	}
 }

+ 104 - 0
web/service/node_dirty_test.go

@@ -0,0 +1,104 @@
+package service
+
+import (
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/database"
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/web/runtime"
+)
+
+// While a node is config-dirty (a local edit committed before it could be
+// mirrored to the node), the traffic pull must not overwrite the central
+// inbound's config columns from the node's stale snapshot — only traffic
+// counters may advance. Otherwise a reconnecting node reverts the edit.
+func TestSetRemoteTraffic_DirtyPreservesConfig(t *testing.T) {
+	setupConflictDB(t)
+	db := database.GetDB()
+
+	node := &model.Node{Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true, Status: "online"}
+	if err := db.Create(node).Error; err != nil {
+		t.Fatalf("create node: %v", err)
+	}
+	id := node.Id
+
+	const desiredSettings = `{"clients":[{"email":"a@x"}]}`
+	central := &model.Inbound{
+		UserId:   1,
+		NodeID:   &id,
+		Tag:      "in-443-tcp",
+		Enable:   true,
+		Port:     443,
+		Protocol: model.VLESS,
+		Settings: desiredSettings,
+	}
+	if err := db.Create(central).Error; err != nil {
+		t.Fatalf("create inbound: %v", err)
+	}
+
+	snap := &runtime.TrafficSnapshot{
+		Inbounds: []*model.Inbound{{
+			Tag:      "in-443-tcp",
+			Enable:   true,
+			Port:     443,
+			Protocol: model.VLESS,
+			Settings: `{"clients":[{"email":"b@x"}]}`,
+			Up:       500,
+			Down:     700,
+		}},
+	}
+
+	svc := InboundService{}
+	if _, err := svc.setRemoteTrafficLocked(id, snap, true); err != nil {
+		t.Fatalf("setRemoteTrafficLocked dirty: %v", err)
+	}
+
+	var got model.Inbound
+	if err := db.First(&got, central.Id).Error; err != nil {
+		t.Fatalf("reload inbound: %v", err)
+	}
+	if got.Settings != desiredSettings {
+		t.Fatalf("dirty pull overwrote settings: want %q got %q", desiredSettings, got.Settings)
+	}
+	if got.Up != 500 || got.Down != 700 {
+		t.Fatalf("traffic counters not applied while dirty: up=%d down=%d", got.Up, got.Down)
+	}
+}
+
+// ClearNodeDirty must be a compare-and-swap on config_dirty_at so a concurrent
+// edit that re-dirties the node during a reconcile is not silently cleared.
+func TestNodeDirty_ClearIsCASOnDirtyAt(t *testing.T) {
+	setupConflictDB(t)
+	db := database.GetDB()
+
+	node := &model.Node{Name: "n2", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true, Status: "online"}
+	if err := db.Create(node).Error; err != nil {
+		t.Fatalf("create node: %v", err)
+	}
+
+	nodeSvc := NodeService{}
+	if err := nodeSvc.MarkNodeDirty(node.Id); err != nil {
+		t.Fatalf("MarkNodeDirty: %v", err)
+	}
+	_, _, dirty, dirtyAt, err := nodeSvc.NodeSyncState(node.Id)
+	if err != nil {
+		t.Fatalf("NodeSyncState: %v", err)
+	}
+	if !dirty {
+		t.Fatal("node should be dirty after MarkNodeDirty")
+	}
+
+	if err := nodeSvc.ClearNodeDirty(node.Id, dirtyAt-1); err != nil {
+		t.Fatalf("ClearNodeDirty stale token: %v", err)
+	}
+	if _, _, stillDirty, _, _ := nodeSvc.NodeSyncState(node.Id); !stillDirty {
+		t.Fatal("stale-token clear must not clear the dirty flag")
+	}
+
+	if err := nodeSvc.ClearNodeDirty(node.Id, dirtyAt); err != nil {
+		t.Fatalf("ClearNodeDirty matching token: %v", err)
+	}
+	if _, _, stillDirty, _, _ := nodeSvc.NodeSyncState(node.Id); stillDirty {
+		t.Fatal("matching-token clear must clear the dirty flag")
+	}
+}

+ 1 - 1
web/service/node_tag_sync_test.go

@@ -46,7 +46,7 @@ func TestSetRemoteTraffic_KeepsInboundOnPrefixMismatch(t *testing.T) {
 	}
 
 	svc := InboundService{}
-	if _, err := svc.setRemoteTrafficLocked(nodeID, snap); err != nil {
+	if _, err := svc.setRemoteTrafficLocked(nodeID, snap, false); err != nil {
 		t.Fatalf("setRemoteTrafficLocked: %v", err)
 	}
 

+ 1 - 0
web/translation/en-US.json

@@ -470,6 +470,7 @@
         "inboundClientAddSuccess": "Inbound client(s) have been added.",
         "inboundClientDeleteSuccess": "Inbound client has been deleted.",
         "inboundClientUpdateSuccess": "Inbound client has been updated.",
+        "savedNodeOfflineWillSync": "Saved locally. A backing node is offline or disabled — the change will sync once it reconnects.",
         "delDepletedClientsSuccess": "All depleted clients have been deleted.",
         "resetAllClientTrafficSuccess": "Traffic for all clients has been reset.",
         "resetAllTrafficSuccess": "All traffic has been reset.",