@@ -475,14 +475,27 @@ def _build_outbound(node: Node) -> Dict[str, Any]:
475475]
476476
477477
478- def _routing_rule_to_xray (rule : RoutingRule , active_node_id : Optional [int ]) -> Optional [Dict [str , Any ]]:
479- """Convert a RoutingRule DB row to an xray routing rule dict."""
478+ def _routing_rule_to_xray (
479+ rule : RoutingRule ,
480+ active_node_id : Optional [int ],
481+ active_balancer_tag : Optional [str ] = None ,
482+ ) -> Optional [Dict [str , Any ]]:
483+ """Convert a RoutingRule DB row to an xray routing rule dict.
484+
485+ When `active_balancer_tag` is set (the active node belongs to a running
486+ NodeCircle), `action == "proxy"` targets that balancer instead of a
487+ single `node-<id>` outbound — so circle rotation can hot-swap the
488+ selected node via the gRPC balancerOverride API without a restart.
489+ """
480490 values = [v .strip () for v in rule .match_value .split ("," ) if v .strip ()]
481491
482492 xray_rule : Dict [str , Any ] = {"type" : "field" }
483493
484494 if rule .action == "proxy" :
485- xray_rule ["outboundTag" ] = f"node-{ active_node_id } " if active_node_id else "direct"
495+ if active_balancer_tag :
496+ xray_rule ["balancerTag" ] = active_balancer_tag
497+ else :
498+ xray_rule ["outboundTag" ] = f"node-{ active_node_id } " if active_node_id else "direct"
486499 elif rule .action == "direct" :
487500 xray_rule ["outboundTag" ] = "direct"
488501 elif rule .action == "block" :
@@ -760,6 +773,29 @@ def _build_tun_inbound(settings_map: Dict[str, str]) -> Dict[str, Any]:
760773 return inbound
761774
762775
776+ def resolve_active_circle (circles , active_node_id ):
777+ """Return (circle_id, member_node_ids) of the enabled NodeCircle that
778+ contains `active_node_id`, else (None, None).
779+
780+ Callers pass the active circle into `generate_config` so the proxy
781+ traffic routes via a per-circle balancer over all members — that's what
782+ lets rotation hot-swap the selected node through the gRPC balancerOverride
783+ API without restarting xray (live connections survive).
784+ """
785+ if not active_node_id :
786+ return None , None
787+ for c in circles or []:
788+ if not getattr (c , "enabled" , False ):
789+ continue
790+ try :
791+ ids = json .loads (c .node_ids ) if isinstance (c .node_ids , str ) else (c .node_ids or [])
792+ except Exception :
793+ ids = []
794+ if active_node_id in ids :
795+ return c .id , list (ids )
796+ return None , None
797+
798+
763799def generate_config (
764800 active_node : Optional [Node ],
765801 all_nodes : List [Node ],
@@ -770,6 +806,8 @@ def generate_config(
770806 balancer_groups : Optional [List [BalancerGroup ]] = None ,
771807 routing_sets : Optional [List [RoutingSet ]] = None ,
772808 device_set_macs : Optional [Dict [int , List [str ]]] = None ,
809+ active_circle_id : Optional [int ] = None ,
810+ active_circle_node_ids : Optional [List [int ]] = None ,
773811) -> Dict [str , Any ]:
774812 """Build full xray JSON configuration.
775813
@@ -984,6 +1022,32 @@ def generate_config(
9841022 except Exception as exc :
9851023 logger .error ("Failed to build outbound for node %d: %s" , active_node .id , exc )
9861024
1025+ # NodeCircle members: when the active node belongs to a running circle,
1026+ # preload EVERY member's outbound (+ its chain) and route proxy traffic at
1027+ # a balancer over them (built below). Rotation then hot-swaps the selected
1028+ # member via the gRPC balancerOverride API — no xray restart, so live
1029+ # connections survive (xray picks the outbound per-connection at dispatch).
1030+ circle_member_tags : List [str ] = []
1031+ circle_active = bool (active_circle_id and active_circle_node_ids and active_node )
1032+ if circle_active :
1033+ for nid in active_circle_node_ids :
1034+ tag = f"node-{ nid } "
1035+ if not any (o .get ("tag" ) == tag for o in outbounds ):
1036+ node = next ((n for n in all_nodes if n .id == nid ), None )
1037+ if node and node .enabled :
1038+ try :
1039+ ob = _build_outbound (node )
1040+ _apply_chain (node , ob , outbounds , used_ids , all_nodes )
1041+ outbounds .append (ob )
1042+ used_ids .add (nid )
1043+ except Exception as exc :
1044+ logger .warning ("Circle member node %d skip: %s" , nid , exc )
1045+ if any (o .get ("tag" ) == tag for o in outbounds ):
1046+ circle_member_tags .append (tag )
1047+ active_balancer_tag = (
1048+ f"circle-{ active_circle_id } " if (circle_active and circle_member_tags ) else None
1049+ )
1050+
9871051 # Additional nodes for "node:<id>" routing rules
9881052 for node in all_nodes :
9891053 if node .id not in used_ids and node .enabled :
@@ -1056,6 +1120,18 @@ def generate_config(
10561120 "strategy" : {"type" : bg .strategy },
10571121 })
10581122
1123+ # Active-NodeCircle balancer. Selector lists every preloaded member; the
1124+ # actually-selected member is pinned at runtime via balancerOverride (the
1125+ # apply path overrides to active_node on (re)load, the scheduler overrides
1126+ # to the next member on rotation). `random` is just a valid cold-start
1127+ # default before the first override lands.
1128+ if active_balancer_tag and circle_member_tags :
1129+ xray_balancers .append ({
1130+ "tag" : active_balancer_tag ,
1131+ "selector" : circle_member_tags ,
1132+ "strategy" : {"type" : "random" },
1133+ })
1134+
10591135 # Routing
10601136 routing_rules : List [Dict [str , Any ]] = [
10611137 # Stats API: route api inbound to api outbound (internal)
@@ -1099,10 +1175,14 @@ def generate_config(
10991175 elif mode == "global" :
11001176 if bypass_private :
11011177 routing_rules .append ({"type" : "field" , "ip" : _PRIVATE_CIDRS , "outboundTag" : "direct" })
1178+ _global_target = (
1179+ {"balancerTag" : active_balancer_tag } if active_balancer_tag
1180+ else {"outboundTag" : f"node-{ active_node .id } " if active_node else "direct" }
1181+ )
11021182 routing_rules .append ({
11031183 "type" : "field" ,
11041184 "ip" : ["0.0.0.0/0" , "::/0" ],
1105- "outboundTag" : f"node- { active_node . id } " if active_node else "direct" ,
1185+ ** _global_target ,
11061186 })
11071187 else :
11081188 # rules mode
@@ -1137,7 +1217,7 @@ def generate_config(
11371217 for rule in sorted_rules :
11381218 if rule .routing_set_id != sid :
11391219 continue
1140- xray_rule = _routing_rule_to_xray (rule , active_node_id )
1220+ xray_rule = _routing_rule_to_xray (rule , active_node_id , active_balancer_tag )
11411221 if xray_rule :
11421222 xray_rule ["inboundTag" ] = [tag ]
11431223 routing_rules .append (xray_rule )
@@ -1148,7 +1228,7 @@ def generate_config(
11481228 # (matched by inboundTag), then falls through to globals.
11491229 for rule in sorted_rules :
11501230 if rule .routing_set_id is None :
1151- xray_rule = _routing_rule_to_xray (rule , active_node_id )
1231+ xray_rule = _routing_rule_to_xray (rule , active_node_id , active_balancer_tag )
11521232 if xray_rule :
11531233 routing_rules .append (xray_rule )
11541234
0 commit comments