diff --git a/PasarGuardNodeBridge/abstract_node.py b/PasarGuardNodeBridge/abstract_node.py index fe8a63e..fcab062 100644 --- a/PasarGuardNodeBridge/abstract_node.py +++ b/PasarGuardNodeBridge/abstract_node.py @@ -68,6 +68,47 @@ async def sync_users_chunked( ) -> list[service.User]: raise NotImplementedError + @abstractmethod + async def list_routing_rules(self, timeout: int | None = None) -> service.RoutingRulesResponse | None: + raise NotImplementedError + + @abstractmethod + async def get_balancer_info(self, tag: str, timeout: int | None = None) -> service.BalancerInfoResponse | None: + raise NotImplementedError + + @abstractmethod + async def test_route( + self, + inbound_tag: str = "", + network: str = "", + target_ip: str = "", + target_domain: str = "", + target_port: int = 0, + protocol: str = "", + user: str = "", + attributes: dict[str, str] | None = None, + field_selectors: list[str] | None = None, + publish_result: bool = False, + timeout: int | None = None, + ) -> service.RouteResult | None: + raise NotImplementedError + + @abstractmethod + async def add_routing_rule( + self, rule: str, should_reset: bool = False, timeout: int | None = None + ) -> service.Empty | None: + raise NotImplementedError + + @abstractmethod + async def remove_routing_rule(self, rule_tag: str, timeout: int | None = None) -> service.Empty | None: + raise NotImplementedError + + @abstractmethod + async def override_balancer_target( + self, balancer_tag: str, target: str, timeout: int | None = None + ) -> service.Empty | None: + raise NotImplementedError + @abstractmethod async def _check_node_health(self): raise NotImplementedError diff --git a/PasarGuardNodeBridge/common/service.proto b/PasarGuardNodeBridge/common/service.proto index 85e8f68..738e7b9 100644 --- a/PasarGuardNodeBridge/common/service.proto +++ b/PasarGuardNodeBridge/common/service.proto @@ -162,6 +162,52 @@ message UsersChunk { bool last = 3; } +// Routing (mirrors xray app/router/command, node-friendly shapes) +message RoutingRule { + string outbound_tag = 1; + string rule_tag = 2; +} +message RoutingRulesResponse { repeated RoutingRule rules = 1; } + +message BalancerInfoRequest { string tag = 1; } +message BalancerInfoResponse { + string override_target = 1; + repeated string principle_target = 2; +} + +message TestRouteRequest { + string inbound_tag = 1; + string network = 2; // "tcp" | "udp" + string target_ip = 3; + string target_domain = 4; + uint32 target_port = 5; + string protocol = 6; + string user = 7; + map attributes = 8; + repeated string field_selectors = 9; + bool publish_result = 10; +} +message RouteResult { + string outbound_tag = 1; + repeated string outbound_group_tags = 2; + string inbound_tag = 3; + string network = 4; + string target_domain = 5; +} + +message AddRoutingRuleRequest { + string rule = 1; // JSON for one xray routing rule (same shape as routing.rules[]) + // false (default) appends the rule, keeping existing rules; true resets the + // router (clears all rules + balancers) before adding. Default is the safe, + // non-destructive behavior. + bool should_reset = 2; +} +message RemoveRoutingRuleRequest { string rule_tag = 1; } +message OverrideBalancerTargetRequest { + string balancer_tag = 1; + string target = 2; +} + // Service for node management and connection service NodeService { rpc Start(Backend) returns (BaseInfoResponse) {} @@ -182,4 +228,11 @@ service NodeService { rpc SyncUser(stream User) returns (Empty) {} rpc SyncUsers(Users) returns (Empty) {} rpc SyncUsersChunked(stream UsersChunk) returns (Empty) {} + + rpc ListRoutingRules(Empty) returns (RoutingRulesResponse) {} + rpc GetBalancerInfo(BalancerInfoRequest) returns (BalancerInfoResponse) {} + rpc TestRoute(TestRouteRequest) returns (RouteResult) {} + rpc AddRoutingRule(AddRoutingRuleRequest) returns (Empty) {} + rpc RemoveRoutingRule(RemoveRoutingRuleRequest) returns (Empty) {} + rpc OverrideBalancerTarget(OverrideBalancerTargetRequest) returns (Empty) {} } diff --git a/PasarGuardNodeBridge/common/service_grpc.py b/PasarGuardNodeBridge/common/service_grpc.py index da93671..7bcba74 100644 --- a/PasarGuardNodeBridge/common/service_grpc.py +++ b/PasarGuardNodeBridge/common/service_grpc.py @@ -66,6 +66,30 @@ async def SyncUsers(self, stream: 'grpclib.server.Stream[PasarGuardNodeBridge.co async def SyncUsersChunked(self, stream: 'grpclib.server.Stream[PasarGuardNodeBridge.common.service_pb2.UsersChunk, PasarGuardNodeBridge.common.service_pb2.Empty]') -> None: pass + @abc.abstractmethod + async def ListRoutingRules(self, stream: 'grpclib.server.Stream[PasarGuardNodeBridge.common.service_pb2.Empty, PasarGuardNodeBridge.common.service_pb2.RoutingRulesResponse]') -> None: + pass + + @abc.abstractmethod + async def GetBalancerInfo(self, stream: 'grpclib.server.Stream[PasarGuardNodeBridge.common.service_pb2.BalancerInfoRequest, PasarGuardNodeBridge.common.service_pb2.BalancerInfoResponse]') -> None: + pass + + @abc.abstractmethod + async def TestRoute(self, stream: 'grpclib.server.Stream[PasarGuardNodeBridge.common.service_pb2.TestRouteRequest, PasarGuardNodeBridge.common.service_pb2.RouteResult]') -> None: + pass + + @abc.abstractmethod + async def AddRoutingRule(self, stream: 'grpclib.server.Stream[PasarGuardNodeBridge.common.service_pb2.AddRoutingRuleRequest, PasarGuardNodeBridge.common.service_pb2.Empty]') -> None: + pass + + @abc.abstractmethod + async def RemoveRoutingRule(self, stream: 'grpclib.server.Stream[PasarGuardNodeBridge.common.service_pb2.RemoveRoutingRuleRequest, PasarGuardNodeBridge.common.service_pb2.Empty]') -> None: + pass + + @abc.abstractmethod + async def OverrideBalancerTarget(self, stream: 'grpclib.server.Stream[PasarGuardNodeBridge.common.service_pb2.OverrideBalancerTargetRequest, PasarGuardNodeBridge.common.service_pb2.Empty]') -> None: + pass + def __mapping__(self) -> typing.Dict[str, grpclib.const.Handler]: return { '/service.NodeService/Start': grpclib.const.Handler( @@ -146,6 +170,42 @@ def __mapping__(self) -> typing.Dict[str, grpclib.const.Handler]: PasarGuardNodeBridge.common.service_pb2.UsersChunk, PasarGuardNodeBridge.common.service_pb2.Empty, ), + '/service.NodeService/ListRoutingRules': grpclib.const.Handler( + self.ListRoutingRules, + grpclib.const.Cardinality.UNARY_UNARY, + PasarGuardNodeBridge.common.service_pb2.Empty, + PasarGuardNodeBridge.common.service_pb2.RoutingRulesResponse, + ), + '/service.NodeService/GetBalancerInfo': grpclib.const.Handler( + self.GetBalancerInfo, + grpclib.const.Cardinality.UNARY_UNARY, + PasarGuardNodeBridge.common.service_pb2.BalancerInfoRequest, + PasarGuardNodeBridge.common.service_pb2.BalancerInfoResponse, + ), + '/service.NodeService/TestRoute': grpclib.const.Handler( + self.TestRoute, + grpclib.const.Cardinality.UNARY_UNARY, + PasarGuardNodeBridge.common.service_pb2.TestRouteRequest, + PasarGuardNodeBridge.common.service_pb2.RouteResult, + ), + '/service.NodeService/AddRoutingRule': grpclib.const.Handler( + self.AddRoutingRule, + grpclib.const.Cardinality.UNARY_UNARY, + PasarGuardNodeBridge.common.service_pb2.AddRoutingRuleRequest, + PasarGuardNodeBridge.common.service_pb2.Empty, + ), + '/service.NodeService/RemoveRoutingRule': grpclib.const.Handler( + self.RemoveRoutingRule, + grpclib.const.Cardinality.UNARY_UNARY, + PasarGuardNodeBridge.common.service_pb2.RemoveRoutingRuleRequest, + PasarGuardNodeBridge.common.service_pb2.Empty, + ), + '/service.NodeService/OverrideBalancerTarget': grpclib.const.Handler( + self.OverrideBalancerTarget, + grpclib.const.Cardinality.UNARY_UNARY, + PasarGuardNodeBridge.common.service_pb2.OverrideBalancerTargetRequest, + PasarGuardNodeBridge.common.service_pb2.Empty, + ), } @@ -230,3 +290,39 @@ def __init__(self, channel: grpclib.client.Channel) -> None: PasarGuardNodeBridge.common.service_pb2.UsersChunk, PasarGuardNodeBridge.common.service_pb2.Empty, ) + self.ListRoutingRules = grpclib.client.UnaryUnaryMethod( + channel, + '/service.NodeService/ListRoutingRules', + PasarGuardNodeBridge.common.service_pb2.Empty, + PasarGuardNodeBridge.common.service_pb2.RoutingRulesResponse, + ) + self.GetBalancerInfo = grpclib.client.UnaryUnaryMethod( + channel, + '/service.NodeService/GetBalancerInfo', + PasarGuardNodeBridge.common.service_pb2.BalancerInfoRequest, + PasarGuardNodeBridge.common.service_pb2.BalancerInfoResponse, + ) + self.TestRoute = grpclib.client.UnaryUnaryMethod( + channel, + '/service.NodeService/TestRoute', + PasarGuardNodeBridge.common.service_pb2.TestRouteRequest, + PasarGuardNodeBridge.common.service_pb2.RouteResult, + ) + self.AddRoutingRule = grpclib.client.UnaryUnaryMethod( + channel, + '/service.NodeService/AddRoutingRule', + PasarGuardNodeBridge.common.service_pb2.AddRoutingRuleRequest, + PasarGuardNodeBridge.common.service_pb2.Empty, + ) + self.RemoveRoutingRule = grpclib.client.UnaryUnaryMethod( + channel, + '/service.NodeService/RemoveRoutingRule', + PasarGuardNodeBridge.common.service_pb2.RemoveRoutingRuleRequest, + PasarGuardNodeBridge.common.service_pb2.Empty, + ) + self.OverrideBalancerTarget = grpclib.client.UnaryUnaryMethod( + channel, + '/service.NodeService/OverrideBalancerTarget', + PasarGuardNodeBridge.common.service_pb2.OverrideBalancerTargetRequest, + PasarGuardNodeBridge.common.service_pb2.Empty, + ) diff --git a/PasarGuardNodeBridge/common/service_pb2.py b/PasarGuardNodeBridge/common/service_pb2.py index 863932c..f35327c 100644 --- a/PasarGuardNodeBridge/common/service_pb2.py +++ b/PasarGuardNodeBridge/common/service_pb2.py @@ -24,7 +24,7 @@ -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n)PasarGuardNodeBridge/common/service.proto\x12\x07service\"\x07\n\x05\x45mpty\"O\n\x10\x42\x61seInfoResponse\x12\x0f\n\x07started\x18\x01 \x01(\x08\x12\x14\n\x0c\x63ore_version\x18\x02 \x01(\t\x12\x14\n\x0cnode_version\x18\x03 \x01(\t\"\x89\x01\n\x07\x42\x61\x63kend\x12\"\n\x04type\x18\x01 \x01(\x0e\x32\x14.service.BackendType\x12\x0e\n\x06\x63onfig\x18\x02 \x01(\t\x12\x1c\n\x05users\x18\x03 \x03(\x0b\x32\r.service.User\x12\x12\n\nkeep_alive\x18\x04 \x01(\x04\x12\x18\n\x10\x65xclude_inbounds\x18\x05 \x03(\t\"\x15\n\x03Log\x12\x0e\n\x06\x64\x65tail\x18\x01 \x01(\t\"?\n\x04Stat\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0c\n\x04type\x18\x02 \x01(\t\x12\x0c\n\x04link\x18\x03 \x01(\t\x12\r\n\x05value\x18\x04 \x01(\x03\",\n\x0cStatResponse\x12\x1c\n\x05stats\x18\x01 \x03(\x0b\x32\r.service.Stat\"K\n\x0bStatRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05reset\x18\x02 \x01(\x08\x12\x1f\n\x04type\x18\x03 \x01(\x0e\x32\x11.service.StatType\"1\n\x12OnlineStatResponse\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x03\"\x8f\x01\n\x19StatsOnlineIpListResponse\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x38\n\x03ips\x18\x02 \x03(\x0b\x32+.service.StatsOnlineIpListResponse.IpsEntry\x1a*\n\x08IpsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x03:\x02\x38\x01\"\x82\x01\n\x07Latency\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05\x61live\x18\x02 \x01(\x08\x12\r\n\x05\x64\x65lay\x18\x03 \x01(\x03\x12\x0c\n\x04link\x18\x04 \x01(\t\x12\x16\n\x0elast_seen_time\x18\x05 \x01(\x03\x12\x15\n\rlast_try_time\x18\x06 \x01(\x03\x12\x0e\n\x06source\x18\x07 \x01(\t\"\x1e\n\x0eLatencyRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\"6\n\x0fLatencyResponse\x12#\n\tlatencies\x18\x01 \x03(\x0b\x32\x10.service.Latency\"\xcc\x01\n\x14\x42\x61\x63kendStatsResponse\x12\x15\n\rnum_goroutine\x18\x01 \x01(\r\x12\x0e\n\x06num_gc\x18\x02 \x01(\r\x12\r\n\x05\x61lloc\x18\x03 \x01(\x04\x12\x13\n\x0btotal_alloc\x18\x04 \x01(\x04\x12\x0b\n\x03sys\x18\x05 \x01(\x04\x12\x0f\n\x07mallocs\x18\x06 \x01(\x04\x12\r\n\x05\x66rees\x18\x07 \x01(\x04\x12\x14\n\x0clive_objects\x18\x08 \x01(\x04\x12\x16\n\x0epause_total_ns\x18\t \x01(\x04\x12\x0e\n\x06uptime\x18\n \x01(\r\"\xb4\x01\n\x13SystemStatsResponse\x12\x11\n\tmem_total\x18\x01 \x01(\x04\x12\x10\n\x08mem_used\x18\x02 \x01(\x04\x12\x11\n\tcpu_cores\x18\x03 \x01(\x04\x12\x11\n\tcpu_usage\x18\x04 \x01(\x01\x12 \n\x18incoming_bandwidth_speed\x18\x05 \x01(\x04\x12 \n\x18outgoing_bandwidth_speed\x18\x06 \x01(\x04\x12\x0e\n\x06uptime\x18\x07 \x01(\x04\"\x13\n\x05Vmess\x12\n\n\x02id\x18\x01 \x01(\t\"!\n\x05Vless\x12\n\n\x02id\x18\x01 \x01(\t\x12\x0c\n\x04\x66low\x18\x02 \x01(\t\"\x1a\n\x06Trojan\x12\x10\n\x08password\x18\x01 \x01(\t\"/\n\x0bShadowsocks\x12\x10\n\x08password\x18\x01 \x01(\t\x12\x0e\n\x06method\x18\x02 \x01(\t\"1\n\tWireguard\x12\x12\n\npublic_key\x18\x01 \x01(\t\x12\x10\n\x08peer_ips\x18\x02 \x03(\t\"\x18\n\x08Hysteria\x12\x0c\n\x04\x61uth\x18\x01 \x01(\t\"\xdd\x01\n\x05Proxy\x12\x1d\n\x05vmess\x18\x01 \x01(\x0b\x32\x0e.service.Vmess\x12\x1d\n\x05vless\x18\x02 \x01(\x0b\x32\x0e.service.Vless\x12\x1f\n\x06trojan\x18\x03 \x01(\x0b\x32\x0f.service.Trojan\x12)\n\x0bshadowsocks\x18\x04 \x01(\x0b\x32\x14.service.Shadowsocks\x12%\n\twireguard\x18\x05 \x01(\x0b\x32\x12.service.Wireguard\x12#\n\x08hysteria\x18\x06 \x01(\x0b\x32\x11.service.Hysteria\"H\n\x04User\x12\r\n\x05\x65mail\x18\x01 \x01(\t\x12\x1f\n\x07proxies\x18\x02 \x01(\x0b\x32\x0e.service.Proxy\x12\x10\n\x08inbounds\x18\x03 \x03(\t\"%\n\x05Users\x12\x1c\n\x05users\x18\x01 \x03(\x0b\x32\r.service.User\"G\n\nUsersChunk\x12\x1c\n\x05users\x18\x01 \x03(\x0b\x32\r.service.User\x12\r\n\x05index\x18\x02 \x01(\x04\x12\x0c\n\x04last\x18\x03 \x01(\x08*&\n\x0b\x42\x61\x63kendType\x12\x08\n\x04XRAY\x10\x00\x12\r\n\tWIREGUARD\x10\x01*_\n\x08StatType\x12\r\n\tOutbounds\x10\x00\x12\x0c\n\x08Outbound\x10\x01\x12\x0c\n\x08Inbounds\x10\x02\x12\x0b\n\x07Inbound\x10\x03\x12\r\n\tUsersStat\x10\x04\x12\x0c\n\x08UserStat\x10\x05\x32\xa3\x06\n\x0bNodeService\x12\x36\n\x05Start\x12\x10.service.Backend\x1a\x19.service.BaseInfoResponse\"\x00\x12(\n\x04Stop\x12\x0e.service.Empty\x1a\x0e.service.Empty\"\x00\x12:\n\x0bGetBaseInfo\x12\x0e.service.Empty\x1a\x19.service.BaseInfoResponse\"\x00\x12+\n\x07GetLogs\x12\x0e.service.Empty\x1a\x0c.service.Log\"\x00\x30\x01\x12@\n\x0eGetSystemStats\x12\x0e.service.Empty\x1a\x1c.service.SystemStatsResponse\"\x00\x12\x42\n\x0fGetBackendStats\x12\x0e.service.Empty\x1a\x1d.service.BackendStatsResponse\"\x00\x12\x39\n\x08GetStats\x12\x14.service.StatRequest\x1a\x15.service.StatResponse\"\x00\x12J\n\x13GetOutboundsLatency\x12\x17.service.LatencyRequest\x1a\x18.service.LatencyResponse\"\x00\x12I\n\x12GetUserOnlineStats\x12\x14.service.StatRequest\x1a\x1b.service.OnlineStatResponse\"\x00\x12V\n\x18GetUserOnlineIpListStats\x12\x14.service.StatRequest\x1a\".service.StatsOnlineIpListResponse\"\x00\x12-\n\x08SyncUser\x12\r.service.User\x1a\x0e.service.Empty\"\x00(\x01\x12-\n\tSyncUsers\x12\x0e.service.Users\x1a\x0e.service.Empty\"\x00\x12;\n\x10SyncUsersChunked\x12\x13.service.UsersChunk\x1a\x0e.service.Empty\"\x00(\x01\x42#Z!github.com/pasarguard/node/commonb\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n)PasarGuardNodeBridge/common/service.proto\x12\x07service\"\x07\n\x05\x45mpty\"O\n\x10\x42\x61seInfoResponse\x12\x0f\n\x07started\x18\x01 \x01(\x08\x12\x14\n\x0c\x63ore_version\x18\x02 \x01(\t\x12\x14\n\x0cnode_version\x18\x03 \x01(\t\"\x89\x01\n\x07\x42\x61\x63kend\x12\"\n\x04type\x18\x01 \x01(\x0e\x32\x14.service.BackendType\x12\x0e\n\x06\x63onfig\x18\x02 \x01(\t\x12\x1c\n\x05users\x18\x03 \x03(\x0b\x32\r.service.User\x12\x12\n\nkeep_alive\x18\x04 \x01(\x04\x12\x18\n\x10\x65xclude_inbounds\x18\x05 \x03(\t\"\x15\n\x03Log\x12\x0e\n\x06\x64\x65tail\x18\x01 \x01(\t\"?\n\x04Stat\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0c\n\x04type\x18\x02 \x01(\t\x12\x0c\n\x04link\x18\x03 \x01(\t\x12\r\n\x05value\x18\x04 \x01(\x03\",\n\x0cStatResponse\x12\x1c\n\x05stats\x18\x01 \x03(\x0b\x32\r.service.Stat\"K\n\x0bStatRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05reset\x18\x02 \x01(\x08\x12\x1f\n\x04type\x18\x03 \x01(\x0e\x32\x11.service.StatType\"1\n\x12OnlineStatResponse\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x03\"\x8f\x01\n\x19StatsOnlineIpListResponse\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x38\n\x03ips\x18\x02 \x03(\x0b\x32+.service.StatsOnlineIpListResponse.IpsEntry\x1a*\n\x08IpsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x03:\x02\x38\x01\"\x82\x01\n\x07Latency\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05\x61live\x18\x02 \x01(\x08\x12\r\n\x05\x64\x65lay\x18\x03 \x01(\x03\x12\x0c\n\x04link\x18\x04 \x01(\t\x12\x16\n\x0elast_seen_time\x18\x05 \x01(\x03\x12\x15\n\rlast_try_time\x18\x06 \x01(\x03\x12\x0e\n\x06source\x18\x07 \x01(\t\"\x1e\n\x0eLatencyRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\"6\n\x0fLatencyResponse\x12#\n\tlatencies\x18\x01 \x03(\x0b\x32\x10.service.Latency\"\xcc\x01\n\x14\x42\x61\x63kendStatsResponse\x12\x15\n\rnum_goroutine\x18\x01 \x01(\r\x12\x0e\n\x06num_gc\x18\x02 \x01(\r\x12\r\n\x05\x61lloc\x18\x03 \x01(\x04\x12\x13\n\x0btotal_alloc\x18\x04 \x01(\x04\x12\x0b\n\x03sys\x18\x05 \x01(\x04\x12\x0f\n\x07mallocs\x18\x06 \x01(\x04\x12\r\n\x05\x66rees\x18\x07 \x01(\x04\x12\x14\n\x0clive_objects\x18\x08 \x01(\x04\x12\x16\n\x0epause_total_ns\x18\t \x01(\x04\x12\x0e\n\x06uptime\x18\n \x01(\r\"\xb4\x01\n\x13SystemStatsResponse\x12\x11\n\tmem_total\x18\x01 \x01(\x04\x12\x10\n\x08mem_used\x18\x02 \x01(\x04\x12\x11\n\tcpu_cores\x18\x03 \x01(\x04\x12\x11\n\tcpu_usage\x18\x04 \x01(\x01\x12 \n\x18incoming_bandwidth_speed\x18\x05 \x01(\x04\x12 \n\x18outgoing_bandwidth_speed\x18\x06 \x01(\x04\x12\x0e\n\x06uptime\x18\x07 \x01(\x04\"\x13\n\x05Vmess\x12\n\n\x02id\x18\x01 \x01(\t\"!\n\x05Vless\x12\n\n\x02id\x18\x01 \x01(\t\x12\x0c\n\x04\x66low\x18\x02 \x01(\t\"\x1a\n\x06Trojan\x12\x10\n\x08password\x18\x01 \x01(\t\"/\n\x0bShadowsocks\x12\x10\n\x08password\x18\x01 \x01(\t\x12\x0e\n\x06method\x18\x02 \x01(\t\"1\n\tWireguard\x12\x12\n\npublic_key\x18\x01 \x01(\t\x12\x10\n\x08peer_ips\x18\x02 \x03(\t\"\x18\n\x08Hysteria\x12\x0c\n\x04\x61uth\x18\x01 \x01(\t\"\xdd\x01\n\x05Proxy\x12\x1d\n\x05vmess\x18\x01 \x01(\x0b\x32\x0e.service.Vmess\x12\x1d\n\x05vless\x18\x02 \x01(\x0b\x32\x0e.service.Vless\x12\x1f\n\x06trojan\x18\x03 \x01(\x0b\x32\x0f.service.Trojan\x12)\n\x0bshadowsocks\x18\x04 \x01(\x0b\x32\x14.service.Shadowsocks\x12%\n\twireguard\x18\x05 \x01(\x0b\x32\x12.service.Wireguard\x12#\n\x08hysteria\x18\x06 \x01(\x0b\x32\x11.service.Hysteria\"H\n\x04User\x12\r\n\x05\x65mail\x18\x01 \x01(\t\x12\x1f\n\x07proxies\x18\x02 \x01(\x0b\x32\x0e.service.Proxy\x12\x10\n\x08inbounds\x18\x03 \x03(\t\"%\n\x05Users\x12\x1c\n\x05users\x18\x01 \x03(\x0b\x32\r.service.User\"G\n\nUsersChunk\x12\x1c\n\x05users\x18\x01 \x03(\x0b\x32\r.service.User\x12\r\n\x05index\x18\x02 \x01(\x04\x12\x0c\n\x04last\x18\x03 \x01(\x08\"5\n\x0bRoutingRule\x12\x14\n\x0coutbound_tag\x18\x01 \x01(\t\x12\x10\n\x08rule_tag\x18\x02 \x01(\t\";\n\x14RoutingRulesResponse\x12#\n\x05rules\x18\x01 \x03(\x0b\x32\x14.service.RoutingRule\"\"\n\x13\x42\x61lancerInfoRequest\x12\x0b\n\x03tag\x18\x01 \x01(\t\"I\n\x14\x42\x61lancerInfoResponse\x12\x17\n\x0foverride_target\x18\x01 \x01(\t\x12\x18\n\x10principle_target\x18\x02 \x03(\t\"\xba\x02\n\x10TestRouteRequest\x12\x13\n\x0binbound_tag\x18\x01 \x01(\t\x12\x0f\n\x07network\x18\x02 \x01(\t\x12\x11\n\ttarget_ip\x18\x03 \x01(\t\x12\x15\n\rtarget_domain\x18\x04 \x01(\t\x12\x13\n\x0btarget_port\x18\x05 \x01(\r\x12\x10\n\x08protocol\x18\x06 \x01(\t\x12\x0c\n\x04user\x18\x07 \x01(\t\x12=\n\nattributes\x18\x08 \x03(\x0b\x32).service.TestRouteRequest.AttributesEntry\x12\x17\n\x0f\x66ield_selectors\x18\t \x03(\t\x12\x16\n\x0epublish_result\x18\n \x01(\x08\x1a\x31\n\x0f\x41ttributesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"}\n\x0bRouteResult\x12\x14\n\x0coutbound_tag\x18\x01 \x01(\t\x12\x1b\n\x13outbound_group_tags\x18\x02 \x03(\t\x12\x13\n\x0binbound_tag\x18\x03 \x01(\t\x12\x0f\n\x07network\x18\x04 \x01(\t\x12\x15\n\rtarget_domain\x18\x05 \x01(\t\";\n\x15\x41\x64\x64RoutingRuleRequest\x12\x0c\n\x04rule\x18\x01 \x01(\t\x12\x14\n\x0cshould_reset\x18\x02 \x01(\x08\",\n\x18RemoveRoutingRuleRequest\x12\x10\n\x08rule_tag\x18\x01 \x01(\t\"E\n\x1dOverrideBalancerTargetRequest\x12\x14\n\x0c\x62\x61lancer_tag\x18\x01 \x01(\t\x12\x0e\n\x06target\x18\x02 \x01(\t*&\n\x0b\x42\x61\x63kendType\x12\x08\n\x04XRAY\x10\x00\x12\r\n\tWIREGUARD\x10\x01*_\n\x08StatType\x12\r\n\tOutbounds\x10\x00\x12\x0c\n\x08Outbound\x10\x01\x12\x0c\n\x08Inbounds\x10\x02\x12\x0b\n\x07Inbound\x10\x03\x12\r\n\tUsersStat\x10\x04\x12\x0c\n\x08UserStat\x10\x05\x32\xdc\t\n\x0bNodeService\x12\x36\n\x05Start\x12\x10.service.Backend\x1a\x19.service.BaseInfoResponse\"\x00\x12(\n\x04Stop\x12\x0e.service.Empty\x1a\x0e.service.Empty\"\x00\x12:\n\x0bGetBaseInfo\x12\x0e.service.Empty\x1a\x19.service.BaseInfoResponse\"\x00\x12+\n\x07GetLogs\x12\x0e.service.Empty\x1a\x0c.service.Log\"\x00\x30\x01\x12@\n\x0eGetSystemStats\x12\x0e.service.Empty\x1a\x1c.service.SystemStatsResponse\"\x00\x12\x42\n\x0fGetBackendStats\x12\x0e.service.Empty\x1a\x1d.service.BackendStatsResponse\"\x00\x12\x39\n\x08GetStats\x12\x14.service.StatRequest\x1a\x15.service.StatResponse\"\x00\x12J\n\x13GetOutboundsLatency\x12\x17.service.LatencyRequest\x1a\x18.service.LatencyResponse\"\x00\x12I\n\x12GetUserOnlineStats\x12\x14.service.StatRequest\x1a\x1b.service.OnlineStatResponse\"\x00\x12V\n\x18GetUserOnlineIpListStats\x12\x14.service.StatRequest\x1a\".service.StatsOnlineIpListResponse\"\x00\x12-\n\x08SyncUser\x12\r.service.User\x1a\x0e.service.Empty\"\x00(\x01\x12-\n\tSyncUsers\x12\x0e.service.Users\x1a\x0e.service.Empty\"\x00\x12;\n\x10SyncUsersChunked\x12\x13.service.UsersChunk\x1a\x0e.service.Empty\"\x00(\x01\x12\x43\n\x10ListRoutingRules\x12\x0e.service.Empty\x1a\x1d.service.RoutingRulesResponse\"\x00\x12P\n\x0fGetBalancerInfo\x12\x1c.service.BalancerInfoRequest\x1a\x1d.service.BalancerInfoResponse\"\x00\x12>\n\tTestRoute\x12\x19.service.TestRouteRequest\x1a\x14.service.RouteResult\"\x00\x12\x42\n\x0e\x41\x64\x64RoutingRule\x12\x1e.service.AddRoutingRuleRequest\x1a\x0e.service.Empty\"\x00\x12H\n\x11RemoveRoutingRule\x12!.service.RemoveRoutingRuleRequest\x1a\x0e.service.Empty\"\x00\x12R\n\x16OverrideBalancerTarget\x12&.service.OverrideBalancerTargetRequest\x1a\x0e.service.Empty\"\x00\x42#Z!github.com/pasarguard/node/commonb\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -34,10 +34,12 @@ _globals['DESCRIPTOR']._serialized_options = b'Z!github.com/pasarguard/node/common' _globals['_STATSONLINEIPLISTRESPONSE_IPSENTRY']._loaded_options = None _globals['_STATSONLINEIPLISTRESPONSE_IPSENTRY']._serialized_options = b'8\001' - _globals['_BACKENDTYPE']._serialized_start=1923 - _globals['_BACKENDTYPE']._serialized_end=1961 - _globals['_STATTYPE']._serialized_start=1963 - _globals['_STATTYPE']._serialized_end=2058 + _globals['_TESTROUTEREQUEST_ATTRIBUTESENTRY']._loaded_options = None + _globals['_TESTROUTEREQUEST_ATTRIBUTESENTRY']._serialized_options = b'8\001' + _globals['_BACKENDTYPE']._serialized_start=2772 + _globals['_BACKENDTYPE']._serialized_end=2810 + _globals['_STATTYPE']._serialized_start=2812 + _globals['_STATTYPE']._serialized_end=2907 _globals['_EMPTY']._serialized_start=54 _globals['_EMPTY']._serialized_end=61 _globals['_BASEINFORESPONSE']._serialized_start=63 @@ -88,6 +90,26 @@ _globals['_USERS']._serialized_end=1848 _globals['_USERSCHUNK']._serialized_start=1850 _globals['_USERSCHUNK']._serialized_end=1921 - _globals['_NODESERVICE']._serialized_start=2061 - _globals['_NODESERVICE']._serialized_end=2864 + _globals['_ROUTINGRULE']._serialized_start=1923 + _globals['_ROUTINGRULE']._serialized_end=1976 + _globals['_ROUTINGRULESRESPONSE']._serialized_start=1978 + _globals['_ROUTINGRULESRESPONSE']._serialized_end=2037 + _globals['_BALANCERINFOREQUEST']._serialized_start=2039 + _globals['_BALANCERINFOREQUEST']._serialized_end=2073 + _globals['_BALANCERINFORESPONSE']._serialized_start=2075 + _globals['_BALANCERINFORESPONSE']._serialized_end=2148 + _globals['_TESTROUTEREQUEST']._serialized_start=2151 + _globals['_TESTROUTEREQUEST']._serialized_end=2465 + _globals['_TESTROUTEREQUEST_ATTRIBUTESENTRY']._serialized_start=2416 + _globals['_TESTROUTEREQUEST_ATTRIBUTESENTRY']._serialized_end=2465 + _globals['_ROUTERESULT']._serialized_start=2467 + _globals['_ROUTERESULT']._serialized_end=2592 + _globals['_ADDROUTINGRULEREQUEST']._serialized_start=2594 + _globals['_ADDROUTINGRULEREQUEST']._serialized_end=2653 + _globals['_REMOVEROUTINGRULEREQUEST']._serialized_start=2655 + _globals['_REMOVEROUTINGRULEREQUEST']._serialized_end=2699 + _globals['_OVERRIDEBALANCERTARGETREQUEST']._serialized_start=2701 + _globals['_OVERRIDEBALANCERTARGETREQUEST']._serialized_end=2770 + _globals['_NODESERVICE']._serialized_start=2910 + _globals['_NODESERVICE']._serialized_end=4154 # @@protoc_insertion_point(module_scope) diff --git a/PasarGuardNodeBridge/common/service_pb2.pyi b/PasarGuardNodeBridge/common/service_pb2.pyi index c3c534f..e488a5b 100644 --- a/PasarGuardNodeBridge/common/service_pb2.pyi +++ b/PasarGuardNodeBridge/common/service_pb2.pyi @@ -269,3 +269,98 @@ class UsersChunk(_message.Message): index: int last: bool def __init__(self, users: _Optional[_Iterable[_Union[User, _Mapping]]] = ..., index: _Optional[int] = ..., last: bool = ...) -> None: ... + +class RoutingRule(_message.Message): + __slots__ = ("outbound_tag", "rule_tag") + OUTBOUND_TAG_FIELD_NUMBER: _ClassVar[int] + RULE_TAG_FIELD_NUMBER: _ClassVar[int] + outbound_tag: str + rule_tag: str + def __init__(self, outbound_tag: _Optional[str] = ..., rule_tag: _Optional[str] = ...) -> None: ... + +class RoutingRulesResponse(_message.Message): + __slots__ = ("rules",) + RULES_FIELD_NUMBER: _ClassVar[int] + rules: _containers.RepeatedCompositeFieldContainer[RoutingRule] + def __init__(self, rules: _Optional[_Iterable[_Union[RoutingRule, _Mapping]]] = ...) -> None: ... + +class BalancerInfoRequest(_message.Message): + __slots__ = ("tag",) + TAG_FIELD_NUMBER: _ClassVar[int] + tag: str + def __init__(self, tag: _Optional[str] = ...) -> None: ... + +class BalancerInfoResponse(_message.Message): + __slots__ = ("override_target", "principle_target") + OVERRIDE_TARGET_FIELD_NUMBER: _ClassVar[int] + PRINCIPLE_TARGET_FIELD_NUMBER: _ClassVar[int] + override_target: str + principle_target: _containers.RepeatedScalarFieldContainer[str] + def __init__(self, override_target: _Optional[str] = ..., principle_target: _Optional[_Iterable[str]] = ...) -> None: ... + +class TestRouteRequest(_message.Message): + __slots__ = ("inbound_tag", "network", "target_ip", "target_domain", "target_port", "protocol", "user", "attributes", "field_selectors", "publish_result") + class AttributesEntry(_message.Message): + __slots__ = ("key", "value") + KEY_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + key: str + value: str + def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ... + INBOUND_TAG_FIELD_NUMBER: _ClassVar[int] + NETWORK_FIELD_NUMBER: _ClassVar[int] + TARGET_IP_FIELD_NUMBER: _ClassVar[int] + TARGET_DOMAIN_FIELD_NUMBER: _ClassVar[int] + TARGET_PORT_FIELD_NUMBER: _ClassVar[int] + PROTOCOL_FIELD_NUMBER: _ClassVar[int] + USER_FIELD_NUMBER: _ClassVar[int] + ATTRIBUTES_FIELD_NUMBER: _ClassVar[int] + FIELD_SELECTORS_FIELD_NUMBER: _ClassVar[int] + PUBLISH_RESULT_FIELD_NUMBER: _ClassVar[int] + inbound_tag: str + network: str + target_ip: str + target_domain: str + target_port: int + protocol: str + user: str + attributes: _containers.ScalarMap[str, str] + field_selectors: _containers.RepeatedScalarFieldContainer[str] + publish_result: bool + def __init__(self, inbound_tag: _Optional[str] = ..., network: _Optional[str] = ..., target_ip: _Optional[str] = ..., target_domain: _Optional[str] = ..., target_port: _Optional[int] = ..., protocol: _Optional[str] = ..., user: _Optional[str] = ..., attributes: _Optional[_Mapping[str, str]] = ..., field_selectors: _Optional[_Iterable[str]] = ..., publish_result: bool = ...) -> None: ... + +class RouteResult(_message.Message): + __slots__ = ("outbound_tag", "outbound_group_tags", "inbound_tag", "network", "target_domain") + OUTBOUND_TAG_FIELD_NUMBER: _ClassVar[int] + OUTBOUND_GROUP_TAGS_FIELD_NUMBER: _ClassVar[int] + INBOUND_TAG_FIELD_NUMBER: _ClassVar[int] + NETWORK_FIELD_NUMBER: _ClassVar[int] + TARGET_DOMAIN_FIELD_NUMBER: _ClassVar[int] + outbound_tag: str + outbound_group_tags: _containers.RepeatedScalarFieldContainer[str] + inbound_tag: str + network: str + target_domain: str + def __init__(self, outbound_tag: _Optional[str] = ..., outbound_group_tags: _Optional[_Iterable[str]] = ..., inbound_tag: _Optional[str] = ..., network: _Optional[str] = ..., target_domain: _Optional[str] = ...) -> None: ... + +class AddRoutingRuleRequest(_message.Message): + __slots__ = ("rule", "should_reset") + RULE_FIELD_NUMBER: _ClassVar[int] + SHOULD_RESET_FIELD_NUMBER: _ClassVar[int] + rule: str + should_reset: bool + def __init__(self, rule: _Optional[str] = ..., should_reset: bool = ...) -> None: ... + +class RemoveRoutingRuleRequest(_message.Message): + __slots__ = ("rule_tag",) + RULE_TAG_FIELD_NUMBER: _ClassVar[int] + rule_tag: str + def __init__(self, rule_tag: _Optional[str] = ...) -> None: ... + +class OverrideBalancerTargetRequest(_message.Message): + __slots__ = ("balancer_tag", "target") + BALANCER_TAG_FIELD_NUMBER: _ClassVar[int] + TARGET_FIELD_NUMBER: _ClassVar[int] + balancer_tag: str + target: str + def __init__(self, balancer_tag: _Optional[str] = ..., target: _Optional[str] = ...) -> None: ... diff --git a/PasarGuardNodeBridge/grpclib.py b/PasarGuardNodeBridge/grpclib.py index 5def3dd..3ed5f9a 100644 --- a/PasarGuardNodeBridge/grpclib.py +++ b/PasarGuardNodeBridge/grpclib.py @@ -302,6 +302,88 @@ async def sync_users_chunked( ) return users + async def list_routing_rules(self, timeout: int | None = None) -> service.RoutingRulesResponse | None: + timeout = timeout or self._default_timeout + return await self._handle_grpc_request( + method=self._client.ListRoutingRules, + request=service.Empty(), + timeout=timeout, + ) + + async def get_balancer_info(self, tag: str, timeout: int | None = None) -> service.BalancerInfoResponse | None: + timeout = timeout or self._default_timeout + return await self._handle_grpc_request( + method=self._client.GetBalancerInfo, + request=service.BalancerInfoRequest(tag=tag), + timeout=timeout, + ) + + async def test_route( + self, + inbound_tag: str = "", + network: str = "", + target_ip: str = "", + target_domain: str = "", + target_port: int = 0, + protocol: str = "", + user: str = "", + attributes: dict[str, str] | None = None, + field_selectors: list[str] | None = None, + publish_result: bool = False, + timeout: int | None = None, + ) -> service.RouteResult | None: + timeout = timeout or self._default_timeout + return await self._handle_grpc_request( + method=self._client.TestRoute, + request=service.TestRouteRequest( + inbound_tag=inbound_tag, + network=network, + target_ip=target_ip, + target_domain=target_domain, + target_port=target_port, + protocol=protocol, + user=user, + attributes=attributes or {}, + field_selectors=field_selectors or [], + publish_result=publish_result, + ), + timeout=timeout, + ) + + async def add_routing_rule( + self, rule: str, should_reset: bool = False, timeout: int | None = None + ) -> service.Empty | None: + timeout = timeout or self._default_timeout + # Serialize state-changing routing calls under the node lock, like the other + # mutating ops (start/stop/sync_users), so a rule change can't race a + # concurrent core restart (read-only routing methods stay lock-free). + async with self._node_lock: + return await self._handle_grpc_request( + method=self._client.AddRoutingRule, + request=service.AddRoutingRuleRequest(rule=rule, should_reset=should_reset), + timeout=timeout, + ) + + async def remove_routing_rule(self, rule_tag: str, timeout: int | None = None) -> service.Empty | None: + timeout = timeout or self._default_timeout + async with self._node_lock: + return await self._handle_grpc_request( + method=self._client.RemoveRoutingRule, + request=service.RemoveRoutingRuleRequest(rule_tag=rule_tag), + timeout=timeout, + ) + + async def override_balancer_target( + self, balancer_tag: str, target: str, timeout: int | None = None + ) -> service.Empty | None: + timeout = timeout or self._default_timeout + async with self._node_lock: + return await self._handle_grpc_request( + method=self._client.OverrideBalancerTarget, + request=service.OverrideBalancerTargetRequest(balancer_tag=balancer_tag, target=target), + timeout=timeout, + ) + async def _sync_batch_users(self, users: list[service.User]) -> list[service.User]: """Sync users via gRPC SyncUser stream. Returns failed users.""" failed = [] diff --git a/PasarGuardNodeBridge/rest.py b/PasarGuardNodeBridge/rest.py index e9adcff..ac248f1 100644 --- a/PasarGuardNodeBridge/rest.py +++ b/PasarGuardNodeBridge/rest.py @@ -347,6 +347,99 @@ async def _iter_chunks(): ) return users + async def list_routing_rules(self, timeout: int | None = None) -> service.RoutingRulesResponse | None: + timeout = timeout or self._default_timeout + return await self._make_request( + method="GET", + endpoint="routing/rules", + timeout=timeout, + proto_response_class=service.RoutingRulesResponse, + ) + + async def get_balancer_info(self, tag: str, timeout: int | None = None) -> service.BalancerInfoResponse | None: + timeout = timeout or self._default_timeout + return await self._make_request( + method="GET", + endpoint="routing/balancer", + timeout=timeout, + proto_message=service.BalancerInfoRequest(tag=tag), + proto_response_class=service.BalancerInfoResponse, + ) + + async def test_route( + self, + inbound_tag: str = "", + network: str = "", + target_ip: str = "", + target_domain: str = "", + target_port: int = 0, + protocol: str = "", + user: str = "", + attributes: dict[str, str] | None = None, + field_selectors: list[str] | None = None, + publish_result: bool = False, + timeout: int | None = None, + ) -> service.RouteResult | None: + timeout = timeout or self._default_timeout + return await self._make_request( + method="GET", + endpoint="routing/test", + timeout=timeout, + proto_message=service.TestRouteRequest( + inbound_tag=inbound_tag, + network=network, + target_ip=target_ip, + target_domain=target_domain, + target_port=target_port, + protocol=protocol, + user=user, + attributes=attributes or {}, + field_selectors=field_selectors or [], + publish_result=publish_result, + ), + proto_response_class=service.RouteResult, + ) + + async def add_routing_rule( + self, rule: str, should_reset: bool = False, timeout: int | None = None + ) -> service.Empty | None: + timeout = timeout or self._default_timeout + # Serialize state-changing routing calls under the node lock, like the other + # mutating ops (start/stop/sync_users), so a rule change can't race a + # concurrent core restart (read-only routing methods stay lock-free). + async with self._node_lock: + return await self._make_request( + method="PUT", + endpoint="routing/rules", + timeout=timeout, + proto_message=service.AddRoutingRuleRequest(rule=rule, should_reset=should_reset), + proto_response_class=service.Empty, + ) + + async def remove_routing_rule(self, rule_tag: str, timeout: int | None = None) -> service.Empty | None: + timeout = timeout or self._default_timeout + async with self._node_lock: + return await self._make_request( + method="DELETE", + endpoint="routing/rules", + timeout=timeout, + proto_message=service.RemoveRoutingRuleRequest(rule_tag=rule_tag), + proto_response_class=service.Empty, + ) + + async def override_balancer_target( + self, balancer_tag: str, target: str, timeout: int | None = None + ) -> service.Empty | None: + timeout = timeout or self._default_timeout + async with self._node_lock: + return await self._make_request( + method="PUT", + endpoint="routing/balancer/override", + timeout=timeout, + proto_message=service.OverrideBalancerTargetRequest(balancer_tag=balancer_tag, target=target), + proto_response_class=service.Empty, + ) + async def _sync_batch_users(self, users: list[service.User]) -> list[service.User]: """Sync users individually via PUT user/sync. Returns failed users.""" failed = [] diff --git a/README.md b/README.md index eea3081..04c0edb 100644 --- a/README.md +++ b/README.md @@ -206,6 +206,32 @@ await node.update_core({"version": "latest"}) await node.update_geofiles({"remove_temp": True}) ``` +### 8. Routing APIs + +Routing operations work over both gRPC and REST. They are xray-only: on a non-xray +(e.g. WireGuard) node the call fails with `Bridge.NodeAPIError` code `501`. + +```python +rules = await node.list_routing_rules() +balancer = await node.get_balancer_info("balancer-tag") + +route = await node.test_route( + inbound_tag="inbound-1", + network="tcp", + target_domain="example.com", + target_port=443, +) + +# `rule` is one xray routing rule as JSON (same shape as a routing.rules[] entry). +# Appended by default (keeps existing rules); pass should_reset=True to clear all +# rules + balancers before adding. +await node.add_routing_rule( + '{"type":"field","outboundTag":"direct","domain":["example.com"],"ruleTag":"r1"}' +) +await node.remove_routing_rule("r1") +await node.override_balancer_target("balancer-tag", "outbound-tag") +``` + ## API Reference ### Lifecycle @@ -238,6 +264,17 @@ await node.update_geofiles({"remove_temp": True}) - `sync_users(users, flush_pending=False, timeout=None)` (direct) - `sync_users_chunked(users, chunk_size=100, flush_pending=False, timeout=None)` (direct streaming) +### Routing + +Xray-only (gRPC and REST); on a non-xray backend these raise `NodeAPIError(501)`. + +- `list_routing_rules(timeout=None)` +- `get_balancer_info(tag, timeout=None)` +- `test_route(inbound_tag="", network="", target_ip="", target_domain="", target_port=0, protocol="", user="", attributes=None, field_selectors=None, publish_result=False, timeout=None)` +- `add_routing_rule(rule, should_reset=False, timeout=None)` +- `remove_routing_rule(rule_tag, timeout=None)` +- `override_balancer_target(balancer_tag, target, timeout=None)` + ### Logging - `stream_logs(max_queue_size=1000)` async context manager returning an `asyncio.Queue`