selectNode('mobile'); // returns gateway with cap=mobile * $node = $selector->selectNode('proxy'); // returns gateway with cap=proxy * $nodes = $selector->availableNodes('vpn'); // all VPN-capable gateways */ class NodeSelector { public function __construct( private readonly DaemonRpc $rpc, ) {} /** * Get all nodes with a given capability. * * $nodes = $selector->availableNodes('proxy'); */ public function availableNodes(string $capability = ''): array { $aliases = Cache::remember('proxy.nodes', 60, function () { $result = $this->rpc->getAllAliases(); $aliases = $result['aliases'] ?? []; return array_values(array_filter($aliases, function ($alias) { $comment = $alias['comment'] ?? ''; return str_contains($comment, 'type=gateway') || str_contains($comment, 'type=exit'); })); }); if (empty($capability)) { return array_values($aliases); } return array_values(array_filter($aliases, function ($alias) use ($capability) { return str_contains($alias['comment'] ?? '', $capability); })); } /** * Select a single node matching capability. Round-robin via cache counter. * * $node = $selector->selectNode('proxy'); */ public function selectNode(string $capability): ?array { $nodes = $this->availableNodes($capability); if (empty($nodes)) { return null; } // Round-robin selection $counterKey = "proxy.rr:{$capability}"; $index = (int) Cache::get($counterKey, 0); $node = $nodes[$index % count($nodes)]; Cache::put($counterKey, $index + 1, 3600); return $node; } }