laylink/src/Agent/AgentClient.php
2026-05-28 23:41:40 +08:00

774 lines
28 KiB
PHP

<?php
declare(strict_types=1);
namespace LayLink\Agent;
use LayLink\Protocol\Frame;
use LayLink\Protocol\FrameCodec;
use LayLink\Protocol\FrameParser;
use LayLink\Protocol\FrameType;
use LayLink\Util\Uuid;
use Workerman\Connection\AsyncTcpConnection;
use Workerman\Connection\TcpConnection;
use Workerman\Connection\UdpConnection;
use Workerman\Timer;
use Workerman\Worker;
final class AgentClient
{
private ?AsyncTcpConnection $pop = null;
private ?FrameParser $parser = null;
private bool $authenticated = false;
/** @var array<int, string> */
private array $initialBuffers = [];
/** @var array<int, string> */
private array $connectionSessionIds = [];
/** @var array<string, TcpConnection> */
private array $clients = [];
/** @var array<string, string> */
private array $sessionStates = [];
/** @var array<string, string> */
private array $pendingData = [];
/** @var array<int, string> */
private array $connectionStages = [];
/** @var array<string, string> */
private array $sessionIngressProtocols = [];
/** @var array<string, UdpConnection> */
private array $udpClients = [];
/** @var array<string, array{client_address: string, target_host: string, target_port: int}> */
private array $udpSessions = [];
public function __construct(
private readonly string $clientListen,
private readonly string $ingressProtocol,
private readonly string $popAddress,
private readonly string $nodeId,
private readonly string $nodeType,
private readonly string $nodeToken,
private readonly string $nodeZone,
private readonly string $transportProtocol,
private readonly string $clientAuthToken,
private readonly string $defaultUserId,
private readonly string $socks5AuthMode = 'no-auth',
private readonly string $socks5Username = '',
private readonly string $socks5Password = '',
private readonly ?string $socks5UdpListen = null,
private readonly string $socks5UdpAdvertiseIp = '127.0.0.1',
) {
}
public function boot(string $workerName): void
{
$worker = new Worker('tcp://' . $this->clientListen);
$worker->name = $workerName;
$worker->count = 1;
$worker->onWorkerStart = function (): void {
if ($this->transportProtocol !== 'tcp') {
throw new \RuntimeException("Agent transport '{$this->transportProtocol}' is configured but not implemented yet.");
}
$this->connect();
Timer::add(10, fn () => $this->heartbeat());
};
$worker->onConnect = fn (TcpConnection $connection) => $this->onClientConnect($connection);
$worker->onMessage = fn (TcpConnection $connection, string $data) => $this->onClientMessage($connection, $data);
$worker->onClose = fn (TcpConnection $connection) => $this->onClientClose($connection);
if ($this->ingressProtocol === 'socks5' && $this->socks5UdpListen !== null) {
$udpWorker = new Worker('udp://' . $this->socks5UdpListen);
$udpWorker->name = $workerName . '-udp';
$udpWorker->count = 1;
$udpWorker->onMessage = fn (UdpConnection $connection, string $data) => $this->onSocks5UdpMessage($connection, $data);
}
}
private function connect(): void
{
$this->parser = new FrameParser();
$this->authenticated = false;
$connection = new AsyncTcpConnection($this->popAddress);
$connection->maxSendBufferSize = 8 * 1024 * 1024;
$this->pop = $connection;
$connection->onConnect = function (AsyncTcpConnection $connection): void {
$this->send(new Frame(FrameType::AUTH, null, [
'node_id' => $this->nodeId,
'node_type' => $this->nodeType,
'node_zone' => $this->nodeZone,
'node_token' => $this->nodeToken,
'transport_protocol' => $this->transportProtocol,
'supported_protocols' => ['tcp'],
'supported_transports' => ['tcp', 'udp', 'kcp'],
]));
};
$connection->onMessage = function (AsyncTcpConnection $connection, string $data): void {
try {
foreach ($this->parser?->push($data) ?? [] as $frame) {
$this->handleFrame($frame);
}
} catch (\Throwable $e) {
$connection->close();
}
};
$connection->onClose = function (): void {
$this->authenticated = false;
foreach ($this->clients as $client) {
$client->close();
}
$this->initialBuffers = [];
$this->connectionSessionIds = [];
$this->clients = [];
$this->sessionStates = [];
$this->pendingData = [];
$this->connectionStages = [];
$this->sessionIngressProtocols = [];
Timer::add(3, fn () => $this->connect(), [], false);
};
$connection->connect();
}
private function handleFrame(Frame $frame): void
{
match ($frame->type) {
FrameType::AUTH_OK => $this->authenticated = true,
FrameType::AUTH_FAIL => $this->pop?->close(),
FrameType::PONG => null,
FrameType::OPEN_OK => $this->openClientSession($frame),
FrameType::OPEN_FAIL => $this->failClientSession($frame),
FrameType::DATA => $this->forwardToClient($frame),
FrameType::UDP_DATA => $this->forwardUdpToClient($frame),
FrameType::CLOSE => $this->closeClient($frame->sessionId),
default => null,
};
}
private function onClientConnect(TcpConnection $connection): void
{
$connection->maxSendBufferSize = 8 * 1024 * 1024;
$this->initialBuffers[$connection->id] = '';
$this->connectionStages[$connection->id] = 'init';
}
private function onClientMessage(TcpConnection $connection, string $data): void
{
if (!isset($this->connectionSessionIds[$connection->id])) {
$this->handleInitialRequest($connection, $data);
return;
}
$sessionId = $this->connectionSessionIds[$connection->id];
if (($this->sessionStates[$sessionId] ?? null) !== 'open') {
$this->pendingData[$sessionId] = ($this->pendingData[$sessionId] ?? '') . $data;
return;
}
$this->send(new Frame(FrameType::DATA, $sessionId, ['data_raw' => $data]));
}
private function handleInitialRequest(TcpConnection $connection, string $data): void
{
$buffer = ($this->initialBuffers[$connection->id] ?? '') . $data;
$this->initialBuffers[$connection->id] = $buffer;
match ($this->ingressProtocol) {
'raw-json' => $this->handleRawJsonInitialRequest($connection),
'socks5' => $this->handleSocks5InitialRequest($connection),
'http-proxy' => $this->handleHttpProxyInitialRequest($connection),
default => $this->failLocalClient($connection, 'unsupported_ingress_protocol'),
};
}
private function handleRawJsonInitialRequest(TcpConnection $connection): void
{
$buffer = $this->initialBuffers[$connection->id] ?? '';
$payloadBytes = '';
$requestText = null;
if (($pos = strpos($buffer, "\n")) !== false) {
$requestText = substr($buffer, 0, $pos);
$payloadBytes = substr($buffer, $pos + 1);
} elseif (($decoded = json_decode(trim($buffer), true)) && is_array($decoded)) {
$requestText = trim($buffer);
}
if ($requestText === null) {
if (strlen($buffer) > 8192) {
$connection->send("ERR invalid_frame\n");
$connection->close();
} else {
$this->initialBuffers[$connection->id] = $buffer;
}
return;
}
unset($this->initialBuffers[$connection->id]);
$request = json_decode(trim($requestText), true);
if (!is_array($request)) {
$connection->send("ERR invalid_frame\n");
$connection->close();
return;
}
$this->startPopSession($connection, [
'auth_token' => (string)($request['auth_token'] ?? ''),
'user_id' => (string)($request['user_id'] ?? ''),
'target_host' => (string)($request['target_host'] ?? ''),
'target_port' => (int)($request['target_port'] ?? 0),
'protocol' => (string)($request['protocol'] ?? 'tcp'),
'route_hint' => $request['route_hint'] ?? null,
], $payloadBytes, 'raw-json');
}
private function handleSocks5InitialRequest(TcpConnection $connection): void
{
$buffer = $this->initialBuffers[$connection->id] ?? '';
$stage = $this->connectionStages[$connection->id] ?? 'init';
if ($stage === 'init') {
if (strlen($buffer) < 2) {
return;
}
$version = ord($buffer[0]);
$methods = ord($buffer[1]);
if ($version !== 5) {
$this->failLocalClient($connection, 'invalid_socks_version');
return;
}
if (strlen($buffer) < 2 + $methods) {
return;
}
$offeredMethods = array_map('ord', str_split(substr($buffer, 2, $methods)));
$selectedMethod = $this->selectSocks5AuthMethod($offeredMethods);
if ($selectedMethod === null) {
$connection->send("\x05\xff");
$connection->close();
return;
}
$connection->send("\x05" . chr($selectedMethod));
$buffer = substr($buffer, 2 + $methods);
$this->initialBuffers[$connection->id] = $buffer;
$this->connectionStages[$connection->id] = $selectedMethod === 2 ? 'auth' : 'request';
if ($buffer === '') {
return;
}
$stage = $this->connectionStages[$connection->id];
}
if ($stage === 'auth') {
if (strlen($buffer) < 2) {
return;
}
if (ord($buffer[0]) !== 1) {
$connection->send("\x01\x01");
$connection->close();
return;
}
$usernameLength = ord($buffer[1]);
if (strlen($buffer) < 2 + $usernameLength + 1) {
return;
}
$username = substr($buffer, 2, $usernameLength);
$passwordLengthOffset = 2 + $usernameLength;
$passwordLength = ord($buffer[$passwordLengthOffset]);
if (strlen($buffer) < $passwordLengthOffset + 1 + $passwordLength) {
return;
}
$password = substr($buffer, $passwordLengthOffset + 1, $passwordLength);
if (!hash_equals($this->socks5Username, $username) || !hash_equals($this->socks5Password, $password)) {
$connection->send("\x01\x01");
$connection->close();
return;
}
$connection->send("\x01\x00");
$buffer = substr($buffer, $passwordLengthOffset + 1 + $passwordLength);
$this->initialBuffers[$connection->id] = $buffer;
$this->connectionStages[$connection->id] = 'request';
if ($buffer === '') {
return;
}
}
if (strlen($buffer) < 4) {
return;
}
$version = ord($buffer[0]);
$command = ord($buffer[1]);
$reserved = ord($buffer[2]);
$addressType = ord($buffer[3]);
if ($version !== 5 || $reserved !== 0) {
$connection->send($this->socks5Reply(1));
$connection->close();
return;
}
if ($command !== 1 && $command !== 3) {
$connection->send($this->socks5Reply(7));
$connection->close();
return;
}
$offset = 4;
if ($addressType === 1) {
if (strlen($buffer) < $offset + 4 + 2) {
return;
}
$host = inet_ntop(substr($buffer, $offset, 4));
$offset += 4;
} elseif ($addressType === 3) {
if (strlen($buffer) < $offset + 1) {
return;
}
$length = ord($buffer[$offset]);
$offset++;
if (strlen($buffer) < $offset + $length + 2) {
return;
}
$host = substr($buffer, $offset, $length);
$offset += $length;
} elseif ($addressType === 4) {
if (strlen($buffer) < $offset + 16 + 2) {
return;
}
$host = inet_ntop(substr($buffer, $offset, 16));
$offset += 16;
} else {
$connection->send($this->socks5Reply(8));
$connection->close();
return;
}
$port = unpack('nport', substr($buffer, $offset, 2))['port'];
$offset += 2;
unset($this->initialBuffers[$connection->id], $this->connectionStages[$connection->id]);
if ($command === 3) {
$connection->send($this->socks5UdpAssociateReply());
return;
}
$this->startPopSession($connection, [
'auth_token' => $this->clientAuthToken,
'user_id' => $this->defaultUserId,
'target_host' => (string)$host,
'target_port' => (int)$port,
'protocol' => 'tcp',
], substr($buffer, $offset), 'socks5');
}
private function handleHttpProxyInitialRequest(TcpConnection $connection): void
{
$buffer = $this->initialBuffers[$connection->id] ?? '';
$headerEnd = strpos($buffer, "\r\n\r\n");
if ($headerEnd === false) {
if (strlen($buffer) > 65536) {
$this->failLocalClient($connection, 'invalid_http_proxy_request');
}
return;
}
$headers = substr($buffer, 0, $headerEnd + 4);
$body = substr($buffer, $headerEnd + 4);
$lineEnd = strpos($headers, "\r\n");
if ($lineEnd === false) {
$this->failLocalClient($connection, 'invalid_http_proxy_request');
return;
}
$requestLine = substr($headers, 0, $lineEnd);
$parts = explode(' ', $requestLine, 3);
if (count($parts) !== 3) {
$this->failLocalClient($connection, 'invalid_http_proxy_request');
return;
}
[$method, $target, $version] = $parts;
unset($this->initialBuffers[$connection->id], $this->connectionStages[$connection->id]);
if (strtoupper($method) === 'CONNECT') {
[$host, $port] = $this->splitHostPort($target, 443);
$this->startPopSession($connection, [
'auth_token' => $this->clientAuthToken,
'user_id' => $this->defaultUserId,
'target_host' => $host,
'target_port' => $port,
'protocol' => 'tcp',
], '', 'http-connect');
return;
}
$url = parse_url($target);
if (!is_array($url) || !isset($url['host'])) {
$this->failLocalClient($connection, 'unsupported_http_proxy_request');
return;
}
$scheme = strtolower((string)($url['scheme'] ?? 'http'));
$port = (int)($url['port'] ?? ($scheme === 'https' ? 443 : 80));
$path = (string)($url['path'] ?? '/');
if (isset($url['query'])) {
$path .= '?' . $url['query'];
}
$rewritten = $method . ' ' . $path . ' ' . $version . substr($headers, $lineEnd) . $body;
$this->startPopSession($connection, [
'auth_token' => $this->clientAuthToken,
'user_id' => $this->defaultUserId,
'target_host' => (string)$url['host'],
'target_port' => $port,
'protocol' => 'tcp',
], $rewritten, 'http-proxy');
}
private function startPopSession(TcpConnection $connection, array $request, string $payloadBytes, string $ingressProtocol): void
{
if (!$this->authenticated || $this->pop === null) {
$this->failOpeningLocalClient($connection, $ingressProtocol, 'pop_not_connected');
return;
}
$sessionId = Uuid::v4();
$this->connectionSessionIds[$connection->id] = $sessionId;
$this->clients[$sessionId] = $connection;
$this->sessionStates[$sessionId] = 'opening';
$this->sessionIngressProtocols[$sessionId] = $ingressProtocol;
if ($payloadBytes !== '') {
$this->pendingData[$sessionId] = $payloadBytes;
}
$this->send(new Frame(FrameType::OPEN, $sessionId, [
'auth_token' => (string)($request['auth_token'] ?? ''),
'user_id' => (string)($request['user_id'] ?? ''),
'target_host' => (string)($request['target_host'] ?? ''),
'target_port' => (int)($request['target_port'] ?? 0),
'protocol' => (string)($request['protocol'] ?? 'tcp'),
'route_hint' => $request['route_hint'] ?? null,
'source_ip' => (string)($connection->getRemoteIp() ?? ''),
]));
}
private function failOpeningLocalClient(TcpConnection $connection, string $ingressProtocol, string $reason): void
{
if ($ingressProtocol === 'socks5') {
$connection->send($this->socks5Reply(1));
} elseif (str_starts_with($ingressProtocol, 'http')) {
$connection->send("HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\nX-LayLink-Error: {$reason}\r\n\r\n");
} else {
$connection->send("ERR {$reason}\n");
}
$connection->close();
}
private function onSocks5UdpMessage(UdpConnection $connection, string $data): void
{
if (!$this->authenticated || $this->pop === null || strlen($data) < 10) {
return;
}
$clientAddress = $connection->getRemoteAddress();
$this->udpClients[$clientAddress] = $connection;
$offset = 0;
$reserved = substr($data, $offset, 2);
$offset += 2;
$fragment = ord($data[$offset]);
$offset++;
$addressType = ord($data[$offset]);
$offset++;
if ($reserved !== "\x00\x00" || $fragment !== 0) {
return;
}
$parsed = $this->parseSocksAddress($data, $offset, $addressType);
if ($parsed === null) {
return;
}
[$host, $port, $offset] = $parsed;
$payload = substr($data, $offset);
$sessionId = $this->udpSessionId($clientAddress, $host, $port);
$this->udpSessions[$sessionId] = [
'client_address' => $clientAddress,
'target_host' => $host,
'target_port' => $port,
];
$this->send(new Frame(FrameType::UDP_DATA, $sessionId, [
'auth_token' => $this->clientAuthToken,
'user_id' => $this->defaultUserId,
'target_host' => $host,
'target_port' => $port,
'protocol' => 'udp',
'source_ip' => $connection->getRemoteIp(),
'data' => base64_encode($payload),
]));
}
private function forwardUdpToClient(Frame $frame): void
{
if ($frame->sessionId === null || !isset($this->udpSessions[$frame->sessionId])) {
return;
}
$session = $this->udpSessions[$frame->sessionId];
$clientAddress = $session['client_address'];
$client = $this->udpClients[$clientAddress] ?? null;
$data = base64_decode((string)($frame->payload['data'] ?? ''), true);
if ($client === null || $data === false) {
return;
}
$client->send($this->packSocks5UdpPacket(
(string)($frame->payload['target_host'] ?? $session['target_host']),
(int)($frame->payload['target_port'] ?? $session['target_port']),
$data,
));
}
private function openClientSession(Frame $frame): void
{
if ($frame->sessionId === null || !isset($this->clients[$frame->sessionId])) {
return;
}
$this->sessionStates[$frame->sessionId] = 'open';
$ingressProtocol = $this->sessionIngressProtocols[$frame->sessionId] ?? 'raw-json';
if ($ingressProtocol === 'raw-json') {
$this->clients[$frame->sessionId]->send("OK\n");
} elseif ($ingressProtocol === 'socks5') {
$this->clients[$frame->sessionId]->send($this->socks5Reply(0));
} elseif ($ingressProtocol === 'http-connect') {
$this->clients[$frame->sessionId]->send("HTTP/1.1 200 Connection Established\r\n\r\n");
}
$pending = $this->pendingData[$frame->sessionId] ?? '';
unset($this->pendingData[$frame->sessionId]);
if ($pending !== '') {
$this->send(new Frame(FrameType::DATA, $frame->sessionId, ['data_raw' => $pending]));
}
}
private function failClientSession(Frame $frame): void
{
if ($frame->sessionId === null || !isset($this->clients[$frame->sessionId])) {
return;
}
$reason = (string)($frame->payload['reason'] ?? 'open_failed');
$ingressProtocol = $this->sessionIngressProtocols[$frame->sessionId] ?? 'raw-json';
if ($ingressProtocol === 'socks5') {
$this->clients[$frame->sessionId]->send($this->socks5Reply($this->socks5ReplyCodeForReason($reason)));
} elseif (str_starts_with($ingressProtocol, 'http')) {
$this->clients[$frame->sessionId]->send("HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\nX-LayLink-Error: {$reason}\r\n\r\n");
} else {
$this->clients[$frame->sessionId]->send("ERR {$reason}\n");
}
$this->closeClient($frame->sessionId);
}
private function forwardToClient(Frame $frame): void
{
if ($frame->sessionId === null || !isset($this->clients[$frame->sessionId])) {
$this->send(new Frame(FrameType::ERROR, $frame->sessionId, ['reason' => 'session_not_found']));
return;
}
$data = $this->frameData($frame);
if ($data === false) {
$this->send(new Frame(FrameType::ERROR, $frame->sessionId, ['reason' => 'invalid_frame']));
return;
}
$this->clients[$frame->sessionId]->send($data);
}
private function frameData(Frame $frame): string|false
{
if (isset($frame->payload['data_raw']) && is_string($frame->payload['data_raw'])) {
return $frame->payload['data_raw'];
}
return base64_decode((string)($frame->payload['data'] ?? ''), true);
}
private function onClientClose(TcpConnection $connection): void
{
unset($this->initialBuffers[$connection->id]);
unset($this->connectionStages[$connection->id]);
if (!isset($this->connectionSessionIds[$connection->id])) {
return;
}
$sessionId = $this->connectionSessionIds[$connection->id];
unset($this->connectionSessionIds[$connection->id]);
unset($this->clients[$sessionId], $this->sessionStates[$sessionId], $this->pendingData[$sessionId], $this->sessionIngressProtocols[$sessionId]);
$this->send(new Frame(FrameType::CLOSE, $sessionId, ['reason' => 'client_closed']));
}
private function closeClient(?string $sessionId): void
{
if ($sessionId === null || !isset($this->clients[$sessionId])) {
return;
}
$connection = $this->clients[$sessionId];
unset($this->clients[$sessionId], $this->sessionStates[$sessionId], $this->pendingData[$sessionId], $this->sessionIngressProtocols[$sessionId]);
unset($this->connectionSessionIds[$connection->id]);
$connection->close();
}
private function failLocalClient(TcpConnection $connection, string $reason): void
{
if ($this->ingressProtocol === 'socks5') {
$connection->send($this->socks5Reply(1));
} elseif ($this->ingressProtocol === 'http-proxy') {
$connection->send("HTTP/1.1 400 Bad Request\r\nConnection: close\r\nX-LayLink-Error: {$reason}\r\n\r\n");
} else {
$connection->send("ERR {$reason}\n");
}
$connection->close();
}
/**
* @param int[] $offeredMethods
*/
private function selectSocks5AuthMethod(array $offeredMethods): ?int
{
$mode = strtolower($this->socks5AuthMode);
if (in_array($mode, ['userpass', 'username-password', 'password'], true)) {
return in_array(2, $offeredMethods, true) ? 2 : null;
}
return in_array(0, $offeredMethods, true) ? 0 : null;
}
private function socks5Reply(int $replyCode): string
{
return "\x05" . chr($replyCode) . "\x00\x01\x00\x00\x00\x00\x00\x00";
}
private function socks5UdpAssociateReply(): string
{
if ($this->socks5UdpListen === null) {
return $this->socks5Reply(7);
}
[$listenIp, $listenPort] = $this->splitHostPort($this->socks5UdpListen, 0);
$ip = $this->socks5UdpAdvertiseIp !== '' ? $this->socks5UdpAdvertiseIp : $listenIp;
return "\x05\x00\x00" . $this->packSocksAddress($ip, $listenPort);
}
/**
* @return array{0: string, 1: int, 2: int}|null
*/
private function parseSocksAddress(string $buffer, int $offset, int $addressType): ?array
{
if ($addressType === 1) {
if (strlen($buffer) < $offset + 4 + 2) {
return null;
}
$host = inet_ntop(substr($buffer, $offset, 4));
$offset += 4;
} elseif ($addressType === 3) {
if (strlen($buffer) < $offset + 1) {
return null;
}
$length = ord($buffer[$offset]);
$offset++;
if (strlen($buffer) < $offset + $length + 2) {
return null;
}
$host = substr($buffer, $offset, $length);
$offset += $length;
} elseif ($addressType === 4) {
if (strlen($buffer) < $offset + 16 + 2) {
return null;
}
$host = inet_ntop(substr($buffer, $offset, 16));
$offset += 16;
} else {
return null;
}
$port = unpack('nport', substr($buffer, $offset, 2))['port'];
$offset += 2;
return [(string)$host, (int)$port, $offset];
}
private function packSocks5UdpPacket(string $host, int $port, string $data): string
{
return "\x00\x00\x00" . $this->packSocksAddress($host, $port) . $data;
}
private function packSocksAddress(string $host, int $port): string
{
if (filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return "\x01" . inet_pton($host) . pack('n', $port);
}
if (filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
return "\x04" . inet_pton($host) . pack('n', $port);
}
return "\x03" . chr(strlen($host)) . $host . pack('n', $port);
}
private function udpSessionId(string $clientAddress, string $host, int $port): string
{
return hash('sha256', $this->nodeId . '|' . $clientAddress . '|' . $host . '|' . $port);
}
private function socks5ReplyCodeForReason(string $reason): int
{
return match ($reason) {
'policy_denied', 'invalid_auth' => 2,
'node_offline', 'route_not_found' => 3,
'target_connection_refused' => 5,
'protocol_not_supported', 'route_not_supported' => 7,
default => 1,
};
}
/**
* @return array{0: string, 1: int}
*/
private function splitHostPort(string $target, int $defaultPort): array
{
if (str_starts_with($target, '[')) {
$end = strpos($target, ']');
if ($end !== false) {
$host = substr($target, 1, $end - 1);
$port = substr($target, $end + 1);
return [$host, str_starts_with($port, ':') ? (int)substr($port, 1) : $defaultPort];
}
}
$parts = explode(':', $target);
if (count($parts) >= 2) {
$port = array_pop($parts);
return [implode(':', $parts), (int)$port];
}
return [$target, $defaultPort];
}
private function heartbeat(): void
{
if (!$this->authenticated || $this->pop === null) {
return;
}
$this->send(new Frame(FrameType::PING, null, [
'node_id' => $this->nodeId,
'active_sessions' => count($this->clients),
'load' => sys_getloadavg()[0] ?? 0.0,
'timestamp' => time(),
]));
}
private function send(Frame $frame): void
{
$this->pop?->send(FrameCodec::encode($frame));
}
}