init
This commit is contained in:
@@ -0,0 +1,753 @@
|
||||
<?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' => base64_encode($data)]));
|
||||
}
|
||||
|
||||
private function handleInitialRequest(TcpConnection $connection, string $data): void
|
||||
{
|
||||
if (!$this->authenticated || $this->pop === null) {
|
||||
$connection->send("ERR pop_not_connected\n");
|
||||
$connection->close();
|
||||
return;
|
||||
}
|
||||
|
||||
$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
|
||||
{
|
||||
$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 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' => base64_encode($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 = base64_decode((string)($frame->payload['data'] ?? ''), true);
|
||||
if ($data === false) {
|
||||
$this->send(new Frame(FrameType::ERROR, $frame->sessionId, ['reason' => 'invalid_frame']));
|
||||
return;
|
||||
}
|
||||
|
||||
$this->clients[$frame->sessionId]->send($data);
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LayLink\Agent;
|
||||
|
||||
use LayLink\Auth\PolicyChecker;
|
||||
|
||||
final class TargetConnector
|
||||
{
|
||||
public function __construct(private readonly array $nodeConfig)
|
||||
{
|
||||
}
|
||||
|
||||
public function isAllowed(string $host, int $port): bool
|
||||
{
|
||||
if (!in_array($port, $this->nodeConfig['allowed_ports'] ?? [], true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($this->nodeConfig['allowed_cidrs'] ?? [] as $cidr) {
|
||||
if (is_string($cidr) && PolicyChecker::cidrContains($cidr, $host)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return in_array($host, $this->nodeConfig['allowed_hosts'] ?? [], true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LayLink\Audit;
|
||||
|
||||
final class AuditLogger
|
||||
{
|
||||
public function __construct(private readonly string $path)
|
||||
{
|
||||
$dir = dirname($this->path);
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0775, true);
|
||||
}
|
||||
}
|
||||
|
||||
public function write(array $record): void
|
||||
{
|
||||
$record += [
|
||||
'end_time' => date(DATE_ATOM),
|
||||
];
|
||||
file_put_contents(
|
||||
$this->path,
|
||||
json_encode($record, JSON_UNESCAPED_SLASHES) . PHP_EOL,
|
||||
FILE_APPEND | LOCK_EX,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LayLink\Auth;
|
||||
|
||||
final class ClientAuthenticator
|
||||
{
|
||||
public function authenticate(array $request): array
|
||||
{
|
||||
$token = (string)($request['auth_token'] ?? '');
|
||||
if (!hash_equals('dev-token', $token)) {
|
||||
return ['ok' => false, 'reason' => 'invalid_auth'];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'user_id' => (string)($request['user_id'] ?? 'admin')];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LayLink\Auth;
|
||||
|
||||
final class NodeAuthenticator
|
||||
{
|
||||
/**
|
||||
* @param string[] $allowedTransports
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly array $nodes,
|
||||
private readonly array $allowedTransports = ['tcp'],
|
||||
) {
|
||||
}
|
||||
|
||||
public function authenticate(array $payload): array
|
||||
{
|
||||
$nodeId = (string)($payload['node_id'] ?? '');
|
||||
$nodeType = (string)($payload['node_type'] ?? '');
|
||||
$token = (string)($payload['node_token'] ?? '');
|
||||
$transport = strtolower((string)($payload['transport_protocol'] ?? 'tcp'));
|
||||
|
||||
if ($nodeId === '' || !isset($this->nodes[$nodeId])) {
|
||||
return ['ok' => false, 'reason' => 'node_not_found'];
|
||||
}
|
||||
|
||||
$node = $this->nodes[$nodeId];
|
||||
if (($node['enabled'] ?? false) !== true) {
|
||||
return ['ok' => false, 'reason' => 'node_disabled'];
|
||||
}
|
||||
if (($node['node_type'] ?? '') !== $nodeType) {
|
||||
return ['ok' => false, 'reason' => 'node_type_mismatch'];
|
||||
}
|
||||
if (!hash_equals((string)($node['token'] ?? ''), $token)) {
|
||||
return ['ok' => false, 'reason' => 'invalid_node_token'];
|
||||
}
|
||||
if (!in_array($transport, $this->allowedTransports, true)) {
|
||||
return ['ok' => false, 'reason' => 'transport_not_allowed'];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'node' => $node + ['node_id' => $nodeId]];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LayLink\Auth;
|
||||
|
||||
final class PolicyChecker
|
||||
{
|
||||
public function __construct(private readonly array $policies)
|
||||
{
|
||||
}
|
||||
|
||||
public function find(string $userId, string $host, int $port, string $protocol): ?array
|
||||
{
|
||||
foreach ($this->policies as $policy) {
|
||||
if (($policy['enabled'] ?? false) !== true) {
|
||||
continue;
|
||||
}
|
||||
if (!in_array($userId, $policy['users'] ?? [], true)) {
|
||||
continue;
|
||||
}
|
||||
if (($policy['protocol'] ?? 'tcp') !== $protocol) {
|
||||
continue;
|
||||
}
|
||||
if (!in_array($port, $policy['target_ports'] ?? [], true)) {
|
||||
continue;
|
||||
}
|
||||
if (!$this->hostMatches($host, $policy['target_hosts'] ?? [])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return $policy;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function hostMatches(string $host, array $patterns): bool
|
||||
{
|
||||
foreach ($patterns as $pattern) {
|
||||
if ($pattern === '*' || $pattern === $host) {
|
||||
return true;
|
||||
}
|
||||
if (is_string($pattern) && str_contains($pattern, '/') && self::cidrContains($pattern, $host)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function cidrContains(string $cidr, string $host): bool
|
||||
{
|
||||
if (filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
[$subnet, $bits] = array_pad(explode('/', $cidr, 2), 2, null);
|
||||
if ($bits === null || filter_var($subnet, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$mask = -1 << (32 - (int)$bits);
|
||||
return (ip2long($host) & $mask) === (ip2long($subnet) & $mask);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LayLink\Node;
|
||||
|
||||
use Workerman\Connection\TcpConnection;
|
||||
|
||||
final class NodeConnection
|
||||
{
|
||||
public int $lastHeartbeat;
|
||||
public int $activeSessions = 0;
|
||||
|
||||
public function __construct(
|
||||
public readonly string $nodeId,
|
||||
public readonly string $nodeType,
|
||||
public readonly string $nodeZone,
|
||||
public readonly TcpConnection $connection,
|
||||
) {
|
||||
$this->lastHeartbeat = time();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LayLink\Node;
|
||||
|
||||
use Workerman\Connection\TcpConnection;
|
||||
|
||||
final class NodeRegistry
|
||||
{
|
||||
/** @var array<string, NodeConnection> */
|
||||
private array $nodes = [];
|
||||
|
||||
public function register(NodeConnection $node): void
|
||||
{
|
||||
$this->nodes[$node->nodeId] = $node;
|
||||
}
|
||||
|
||||
public function unregisterByConnection(TcpConnection $connection): ?NodeConnection
|
||||
{
|
||||
foreach ($this->nodes as $nodeId => $node) {
|
||||
if ($node->connection === $connection) {
|
||||
unset($this->nodes[$nodeId]);
|
||||
return $node;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function get(string $nodeId): ?NodeConnection
|
||||
{
|
||||
return $this->nodes[$nodeId] ?? null;
|
||||
}
|
||||
|
||||
public function isOnline(string $nodeId): bool
|
||||
{
|
||||
return isset($this->nodes[$nodeId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return NodeConnection[]
|
||||
*/
|
||||
public function all(): array
|
||||
{
|
||||
return array_values($this->nodes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LayLink\Protocol;
|
||||
|
||||
final class Frame
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $type,
|
||||
public readonly ?string $sessionId = null,
|
||||
public readonly array $payload = [],
|
||||
public readonly int $version = 1,
|
||||
) {
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'version' => $this->version,
|
||||
'type' => $this->type,
|
||||
'session_id' => $this->sessionId,
|
||||
'payload' => $this->payload,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LayLink\Protocol;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class FrameCodec
|
||||
{
|
||||
public const MAX_FRAME_LENGTH = 16 * 1024 * 1024;
|
||||
|
||||
public static function encode(Frame $frame): string
|
||||
{
|
||||
$json = json_encode($frame->toArray(), JSON_UNESCAPED_SLASHES);
|
||||
if ($json === false) {
|
||||
throw new InvalidArgumentException('invalid_frame_payload');
|
||||
}
|
||||
|
||||
return pack('N', strlen($json)) . $json;
|
||||
}
|
||||
|
||||
public static function decode(string $json): Frame
|
||||
{
|
||||
$data = json_decode($json, true);
|
||||
if (!is_array($data) || !isset($data['type']) || !is_string($data['type'])) {
|
||||
throw new InvalidArgumentException('invalid_frame');
|
||||
}
|
||||
|
||||
$sessionId = $data['session_id'] ?? null;
|
||||
if ($sessionId !== null && !is_string($sessionId)) {
|
||||
throw new InvalidArgumentException('invalid_session_id');
|
||||
}
|
||||
|
||||
$payload = $data['payload'] ?? [];
|
||||
if (!is_array($payload)) {
|
||||
throw new InvalidArgumentException('invalid_payload');
|
||||
}
|
||||
|
||||
return new Frame(
|
||||
$data['type'],
|
||||
$sessionId,
|
||||
$payload,
|
||||
(int)($data['version'] ?? 1),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LayLink\Protocol;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class FrameParser
|
||||
{
|
||||
private string $buffer = '';
|
||||
|
||||
/**
|
||||
* @return Frame[]
|
||||
*/
|
||||
public function push(string $chunk): array
|
||||
{
|
||||
$this->buffer .= $chunk;
|
||||
$frames = [];
|
||||
|
||||
while (strlen($this->buffer) >= 4) {
|
||||
$header = unpack('Nlength', substr($this->buffer, 0, 4));
|
||||
$length = (int)$header['length'];
|
||||
if ($length < 1 || $length > FrameCodec::MAX_FRAME_LENGTH) {
|
||||
throw new RuntimeException('invalid_frame_length');
|
||||
}
|
||||
if (strlen($this->buffer) < 4 + $length) {
|
||||
break;
|
||||
}
|
||||
|
||||
$json = substr($this->buffer, 4, $length);
|
||||
$this->buffer = substr($this->buffer, 4 + $length);
|
||||
$frames[] = FrameCodec::decode($json);
|
||||
}
|
||||
|
||||
return $frames;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LayLink\Protocol;
|
||||
|
||||
final class FrameType
|
||||
{
|
||||
public const AUTH = 'AUTH';
|
||||
public const AUTH_OK = 'AUTH_OK';
|
||||
public const AUTH_FAIL = 'AUTH_FAIL';
|
||||
public const PING = 'PING';
|
||||
public const PONG = 'PONG';
|
||||
public const OPEN = 'OPEN';
|
||||
public const OPEN_OK = 'OPEN_OK';
|
||||
public const OPEN_FAIL = 'OPEN_FAIL';
|
||||
public const DATA = 'DATA';
|
||||
public const UDP_DATA = 'UDP_DATA';
|
||||
public const CLOSE = 'CLOSE';
|
||||
public const ERROR = 'ERROR';
|
||||
public const WINDOW = 'WINDOW';
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function descriptions(): array
|
||||
{
|
||||
return [
|
||||
self::AUTH => 'Agent authenticates to POP Server.',
|
||||
self::AUTH_OK => 'POP Server accepts Agent authentication.',
|
||||
self::AUTH_FAIL => 'POP Server rejects Agent authentication.',
|
||||
self::PING => 'Agent heartbeat to POP Server.',
|
||||
self::PONG => 'POP Server heartbeat response.',
|
||||
self::OPEN => 'Client Agent asks POP Server to open an authorized target stream.',
|
||||
self::OPEN_OK => 'POP Server confirms target stream is open.',
|
||||
self::OPEN_FAIL => 'POP Server rejects or fails target stream opening.',
|
||||
self::DATA => 'Bidirectional stream bytes, base64 encoded in MVP.',
|
||||
self::UDP_DATA => 'Bidirectional UDP datagram relay, base64 encoded in MVP.',
|
||||
self::CLOSE => 'Either side closes a stream session.',
|
||||
self::ERROR => 'Explicit protocol or session error.',
|
||||
self::WINDOW => 'Flow-control window update, reserved for backpressure.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LayLink\Route;
|
||||
|
||||
final class RouteDecision
|
||||
{
|
||||
public function __construct(
|
||||
public readonly bool $allowed,
|
||||
public readonly string $routeType,
|
||||
public readonly ?string $nodeId,
|
||||
public readonly ?string $policyId,
|
||||
public readonly ?string $reason = null,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LayLink\Route;
|
||||
|
||||
use LayLink\Auth\PolicyChecker;
|
||||
use LayLink\Node\NodeRegistry;
|
||||
|
||||
final class RouteResolver
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PolicyChecker $policyChecker,
|
||||
private readonly NodeRegistry $nodeRegistry,
|
||||
) {
|
||||
}
|
||||
|
||||
public function resolve(string $userId, string $host, int $port, string $protocol, ?string $routeHint): RouteDecision
|
||||
{
|
||||
$policy = $this->policyChecker->find($userId, $host, $port, $protocol);
|
||||
if ($policy === null) {
|
||||
return new RouteDecision(false, 'reject', null, null, 'policy_denied');
|
||||
}
|
||||
|
||||
$routeType = (string)($policy['route_type'] ?? 'reject');
|
||||
$nodeId = $policy['node_id'] ?? null;
|
||||
if (($routeType === 'agent' || $routeType === 'border') && $protocol !== 'udp') {
|
||||
$nodeId = is_string($nodeId) && $nodeId !== '' ? $nodeId : $routeHint;
|
||||
if (!is_string($nodeId) || $nodeId === '') {
|
||||
return new RouteDecision(false, 'reject', null, (string)$policy['policy_id'], 'route_not_found');
|
||||
}
|
||||
if (!$this->nodeRegistry->isOnline($nodeId)) {
|
||||
return new RouteDecision(false, 'reject', $nodeId, (string)$policy['policy_id'], 'node_offline');
|
||||
}
|
||||
}
|
||||
|
||||
return new RouteDecision(true, $routeType, $nodeId, (string)$policy['policy_id']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LayLink\Server;
|
||||
|
||||
use LayLink\Audit\AuditLogger;
|
||||
use LayLink\Auth\ClientAuthenticator;
|
||||
use LayLink\Auth\NodeAuthenticator;
|
||||
use LayLink\Node\NodeConnection;
|
||||
use LayLink\Node\NodeRegistry;
|
||||
use LayLink\Protocol\Frame;
|
||||
use LayLink\Protocol\FrameCodec;
|
||||
use LayLink\Protocol\FrameParser;
|
||||
use LayLink\Protocol\FrameType;
|
||||
use LayLink\Route\RouteResolver;
|
||||
use LayLink\Session\SessionManager;
|
||||
use LayLink\Session\TunnelSession;
|
||||
use Workerman\Connection\AsyncTcpConnection;
|
||||
use Workerman\Connection\AsyncUdpConnection;
|
||||
use Workerman\Connection\TcpConnection;
|
||||
use Workerman\Timer;
|
||||
use Workerman\Worker;
|
||||
|
||||
final class AgentListener
|
||||
{
|
||||
/** @var array<int, FrameParser> */
|
||||
private array $parsers = [];
|
||||
/** @var array<int, string> */
|
||||
private array $connectionNodeIds = [];
|
||||
|
||||
public function __construct(
|
||||
Worker $worker,
|
||||
private readonly NodeAuthenticator $authenticator,
|
||||
private readonly ClientAuthenticator $clientAuthenticator,
|
||||
private readonly RouteResolver $routes,
|
||||
private readonly NodeRegistry $nodes,
|
||||
private readonly SessionManager $sessions,
|
||||
private readonly AuditLogger $audit,
|
||||
) {
|
||||
$worker->onConnect = fn (TcpConnection $connection) => $this->onConnect($connection);
|
||||
$worker->onMessage = fn (TcpConnection $connection, string $data) => $this->onMessage($connection, $data);
|
||||
$worker->onClose = fn (TcpConnection $connection) => $this->onClose($connection);
|
||||
$worker->onWorkerStart = fn () => Timer::add(10, fn () => $this->sweepHeartbeats());
|
||||
}
|
||||
|
||||
private function onConnect(TcpConnection $connection): void
|
||||
{
|
||||
$connection->maxSendBufferSize = 8 * 1024 * 1024;
|
||||
$this->parsers[$connection->id] = new FrameParser();
|
||||
}
|
||||
|
||||
private function onMessage(TcpConnection $connection, string $data): void
|
||||
{
|
||||
try {
|
||||
foreach ($this->parsers[$connection->id]->push($data) as $frame) {
|
||||
$this->handleFrame($connection, $frame);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->send($connection, new Frame(FrameType::ERROR, null, ['reason' => 'invalid_frame']));
|
||||
$connection->close();
|
||||
}
|
||||
}
|
||||
|
||||
private function handleFrame(TcpConnection $connection, Frame $frame): void
|
||||
{
|
||||
if ($frame->type === FrameType::AUTH) {
|
||||
$result = $this->authenticator->authenticate($frame->payload);
|
||||
if (!$result['ok']) {
|
||||
$this->send($connection, new Frame(FrameType::AUTH_FAIL, null, ['reason' => $result['reason']]));
|
||||
$connection->close();
|
||||
return;
|
||||
}
|
||||
|
||||
$nodeId = (string)$frame->payload['node_id'];
|
||||
$node = new NodeConnection(
|
||||
$nodeId,
|
||||
(string)$frame->payload['node_type'],
|
||||
(string)($frame->payload['node_zone'] ?? 'default'),
|
||||
$connection,
|
||||
);
|
||||
$this->nodes->register($node);
|
||||
$this->connectionNodeIds[$connection->id] = $nodeId;
|
||||
$this->send($connection, new Frame(FrameType::AUTH_OK, null, [
|
||||
'node_id' => $nodeId,
|
||||
'heartbeat_interval' => 10,
|
||||
]));
|
||||
echo "Agent online: {$nodeId}\n";
|
||||
return;
|
||||
}
|
||||
|
||||
$nodeId = $this->connectionNodeIds[$connection->id] ?? null;
|
||||
if (!is_string($nodeId) || $this->nodes->get($nodeId) === null) {
|
||||
$this->send($connection, new Frame(FrameType::ERROR, $frame->sessionId, ['reason' => 'invalid_auth']));
|
||||
$connection->close();
|
||||
return;
|
||||
}
|
||||
|
||||
if ($frame->type === FrameType::PING) {
|
||||
$node = $this->nodes->get($nodeId);
|
||||
if ($node !== null) {
|
||||
$node->lastHeartbeat = time();
|
||||
$node->activeSessions = (int)($frame->payload['active_sessions'] ?? $node->activeSessions);
|
||||
}
|
||||
$this->send($connection, new Frame(FrameType::PONG, null, ['timestamp' => time()]));
|
||||
return;
|
||||
}
|
||||
|
||||
if ($frame->type === FrameType::OPEN) {
|
||||
$this->openTargetForAgent($connection, $nodeId, $frame);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($frame->type === FrameType::UDP_DATA) {
|
||||
$this->forwardUdpDatagram($connection, $nodeId, $frame);
|
||||
return;
|
||||
}
|
||||
|
||||
$session = $frame->sessionId === null ? null : $this->sessions->get($frame->sessionId);
|
||||
if ($session === null) {
|
||||
$this->send($connection, new Frame(FrameType::ERROR, $frame->sessionId, ['reason' => 'session_not_found']));
|
||||
return;
|
||||
}
|
||||
|
||||
match ($frame->type) {
|
||||
FrameType::DATA => $this->forwardDataToTarget($session, (string)($frame->payload['data'] ?? '')),
|
||||
FrameType::CLOSE => $this->closeSession($session, 'closed', null),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function forwardUdpDatagram(TcpConnection $agentConnection, string $nodeId, Frame $frame): void
|
||||
{
|
||||
if ($frame->sessionId === null) {
|
||||
$this->send($agentConnection, new Frame(FrameType::ERROR, null, ['reason' => 'invalid_frame']));
|
||||
return;
|
||||
}
|
||||
|
||||
$auth = $this->clientAuthenticator->authenticate($frame->payload);
|
||||
if (!$auth['ok']) {
|
||||
$this->send($agentConnection, new Frame(FrameType::ERROR, $frame->sessionId, ['reason' => 'invalid_auth']));
|
||||
return;
|
||||
}
|
||||
|
||||
$host = (string)($frame->payload['target_host'] ?? '');
|
||||
$port = (int)($frame->payload['target_port'] ?? 0);
|
||||
$data = base64_decode((string)($frame->payload['data'] ?? ''), true);
|
||||
if ($host === '' || $port < 1 || $port > 65535 || $data === false) {
|
||||
$this->send($agentConnection, new Frame(FrameType::ERROR, $frame->sessionId, ['reason' => 'invalid_frame']));
|
||||
return;
|
||||
}
|
||||
|
||||
$decision = $this->routes->resolve((string)$auth['user_id'], $host, $port, 'udp', $frame->payload['route_hint'] ?? null);
|
||||
if (!$decision->allowed || $decision->routeType !== 'direct') {
|
||||
$this->send($agentConnection, new Frame(FrameType::ERROR, $frame->sessionId, [
|
||||
'reason' => $decision->reason ?? 'policy_denied',
|
||||
]));
|
||||
return;
|
||||
}
|
||||
|
||||
$target = new AsyncUdpConnection("udp://{$host}:{$port}");
|
||||
$target->onConnect = fn (AsyncUdpConnection $target) => $target->send($data);
|
||||
$target->onMessage = function (AsyncUdpConnection $target, string $response) use ($agentConnection, $frame, $host, $port): void {
|
||||
$this->send($agentConnection, new Frame(FrameType::UDP_DATA, $frame->sessionId, [
|
||||
'target_host' => $host,
|
||||
'target_port' => $port,
|
||||
'protocol' => 'udp',
|
||||
'data' => base64_encode($response),
|
||||
]));
|
||||
$target->close();
|
||||
};
|
||||
Timer::add(5, fn () => $target->close(), [], false);
|
||||
$target->connect();
|
||||
}
|
||||
|
||||
private function openTargetForAgent(TcpConnection $agentConnection, string $nodeId, Frame $frame): void
|
||||
{
|
||||
if ($frame->sessionId === null || $this->sessions->get($frame->sessionId) !== null) {
|
||||
$this->send($agentConnection, new Frame(FrameType::OPEN_FAIL, $frame->sessionId, ['reason' => 'invalid_frame']));
|
||||
return;
|
||||
}
|
||||
|
||||
$auth = $this->clientAuthenticator->authenticate($frame->payload);
|
||||
if (!$auth['ok']) {
|
||||
$this->rejectOpen($agentConnection, $frame, 'invalid_auth', 'anonymous', $nodeId);
|
||||
return;
|
||||
}
|
||||
|
||||
$host = (string)($frame->payload['target_host'] ?? '');
|
||||
$port = (int)($frame->payload['target_port'] ?? 0);
|
||||
$protocol = (string)($frame->payload['protocol'] ?? 'tcp');
|
||||
if ($host === '' || $port < 1 || $port > 65535 || $protocol !== 'tcp') {
|
||||
$this->rejectOpen($agentConnection, $frame, 'protocol_not_supported', (string)$auth['user_id'], $nodeId);
|
||||
return;
|
||||
}
|
||||
|
||||
$decision = $this->routes->resolve((string)$auth['user_id'], $host, $port, $protocol, $frame->payload['route_hint'] ?? null);
|
||||
if (!$decision->allowed) {
|
||||
$this->rejectOpen($agentConnection, $frame, $decision->reason ?? 'policy_denied', (string)$auth['user_id'], $nodeId, $decision->policyId);
|
||||
return;
|
||||
}
|
||||
if ($decision->routeType !== 'direct') {
|
||||
$this->rejectOpen($agentConnection, $frame, 'route_not_supported', (string)$auth['user_id'], $nodeId, $decision->policyId);
|
||||
return;
|
||||
}
|
||||
|
||||
$session = new TunnelSession(
|
||||
$frame->sessionId,
|
||||
(string)$auth['user_id'],
|
||||
(string)($frame->payload['source_ip'] ?? $agentConnection->getRemoteIp() ?? ''),
|
||||
$host,
|
||||
$port,
|
||||
$protocol,
|
||||
'direct',
|
||||
$decision->policyId,
|
||||
);
|
||||
$session->nodeId = $nodeId;
|
||||
$session->agent = $agentConnection;
|
||||
$session->state = TunnelSession::OPENING;
|
||||
$this->sessions->add($session);
|
||||
|
||||
$target = new AsyncTcpConnection("tcp://{$host}:{$port}");
|
||||
$target->maxSendBufferSize = 8 * 1024 * 1024;
|
||||
$session->target = $target;
|
||||
|
||||
$target->onConnect = function () use ($session, $agentConnection): void {
|
||||
$session->state = TunnelSession::OPEN;
|
||||
$this->send($agentConnection, new Frame(FrameType::OPEN_OK, $session->sessionId, [
|
||||
'target_host' => $session->targetHost,
|
||||
'target_port' => $session->targetPort,
|
||||
]));
|
||||
};
|
||||
$target->onMessage = function (AsyncTcpConnection $target, string $data) use ($session, $agentConnection): void {
|
||||
$session->bytesTargetToClient += strlen($data);
|
||||
$this->send($agentConnection, new Frame(FrameType::DATA, $session->sessionId, [
|
||||
'data' => base64_encode($data),
|
||||
]));
|
||||
};
|
||||
$target->onClose = fn () => $this->closeSession($session, 'closed', null);
|
||||
$target->onError = fn () => $this->failOpenSession($agentConnection, $session, 'target_connection_refused');
|
||||
$target->connect();
|
||||
}
|
||||
|
||||
private function forwardDataToTarget(TunnelSession $session, string $encoded): void
|
||||
{
|
||||
$data = base64_decode($encoded, true);
|
||||
if ($data === false) {
|
||||
$this->closeSession($session, 'failed', 'invalid_frame');
|
||||
return;
|
||||
}
|
||||
|
||||
$session->bytesClientToTarget += strlen($data);
|
||||
$session->target?->send($data);
|
||||
}
|
||||
|
||||
private function failOpenSession(TcpConnection $agentConnection, TunnelSession $session, string $reason): void
|
||||
{
|
||||
$this->send($agentConnection, new Frame(FrameType::OPEN_FAIL, $session->sessionId, ['reason' => $reason]));
|
||||
$this->closeSession($session, 'failed', $reason);
|
||||
}
|
||||
|
||||
private function rejectOpen(TcpConnection $agentConnection, Frame $frame, string $reason, string $userId, string $nodeId, ?string $policyId = null): void
|
||||
{
|
||||
$this->send($agentConnection, new Frame(FrameType::OPEN_FAIL, $frame->sessionId, ['reason' => $reason]));
|
||||
$this->audit->write([
|
||||
'session_id' => $frame->sessionId,
|
||||
'user_id' => $userId,
|
||||
'source_ip' => (string)($frame->payload['source_ip'] ?? $agentConnection->getRemoteIp() ?? ''),
|
||||
'target_host' => (string)($frame->payload['target_host'] ?? ''),
|
||||
'target_port' => (int)($frame->payload['target_port'] ?? 0),
|
||||
'protocol' => (string)($frame->payload['protocol'] ?? 'tcp'),
|
||||
'route_type' => 'reject',
|
||||
'node_id' => $nodeId,
|
||||
'policy_id' => $policyId,
|
||||
'start_time' => date(DATE_ATOM),
|
||||
'duration_ms' => 0,
|
||||
'bytes_client_to_target' => 0,
|
||||
'bytes_target_to_client' => 0,
|
||||
'result' => 'failed',
|
||||
'failure_reason' => $reason,
|
||||
]);
|
||||
}
|
||||
|
||||
public function closeSession(TunnelSession $session, string $result, ?string $reason): void
|
||||
{
|
||||
if ($session->state === TunnelSession::CLOSED) {
|
||||
return;
|
||||
}
|
||||
|
||||
$session->state = TunnelSession::CLOSED;
|
||||
$this->sessions->remove($session->sessionId);
|
||||
|
||||
if ($session->agent !== null) {
|
||||
$this->send($session->agent, new Frame(FrameType::CLOSE, $session->sessionId, ['reason' => $reason ?? $result]));
|
||||
}
|
||||
$session->client?->close();
|
||||
$session->target?->close();
|
||||
|
||||
$endMs = (int)floor(microtime(true) * 1000);
|
||||
$this->audit->write([
|
||||
'session_id' => $session->sessionId,
|
||||
'user_id' => $session->userId,
|
||||
'source_ip' => $session->sourceIp,
|
||||
'target_host' => $session->targetHost,
|
||||
'target_port' => $session->targetPort,
|
||||
'protocol' => $session->protocol,
|
||||
'route_type' => $session->routeType,
|
||||
'node_id' => $session->nodeId,
|
||||
'policy_id' => $session->policyId,
|
||||
'start_time' => $session->startTime,
|
||||
'duration_ms' => $endMs - $session->startedAtMs,
|
||||
'bytes_client_to_target' => $session->bytesClientToTarget,
|
||||
'bytes_target_to_client' => $session->bytesTargetToClient,
|
||||
'result' => $result,
|
||||
'failure_reason' => $reason,
|
||||
]);
|
||||
}
|
||||
|
||||
private function onClose(TcpConnection $connection): void
|
||||
{
|
||||
unset($this->parsers[$connection->id]);
|
||||
unset($this->connectionNodeIds[$connection->id]);
|
||||
$node = $this->nodes->unregisterByConnection($connection);
|
||||
foreach ($this->sessions->all() as $session) {
|
||||
if ($session->agent === $connection) {
|
||||
$this->closeSession($session, 'failed', 'node_offline');
|
||||
}
|
||||
}
|
||||
if ($node === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
echo "Agent offline: {$node->nodeId}\n";
|
||||
}
|
||||
|
||||
private function sweepHeartbeats(): void
|
||||
{
|
||||
foreach ($this->nodes->all() as $node) {
|
||||
if (time() - $node->lastHeartbeat > 30) {
|
||||
$node->connection->close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function send(TcpConnection $connection, Frame $frame): void
|
||||
{
|
||||
$connection->send(FrameCodec::encode($frame));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LayLink\Server;
|
||||
|
||||
use LayLink\Audit\AuditLogger;
|
||||
use LayLink\Auth\ClientAuthenticator;
|
||||
use LayLink\Auth\NodeAuthenticator;
|
||||
use LayLink\Auth\PolicyChecker;
|
||||
use LayLink\Node\NodeRegistry;
|
||||
use LayLink\Route\RouteResolver;
|
||||
use LayLink\Session\SessionManager;
|
||||
use Workerman\Worker;
|
||||
|
||||
final class PopServer
|
||||
{
|
||||
public readonly NodeRegistry $nodes;
|
||||
public readonly SessionManager $sessions;
|
||||
public readonly AuditLogger $audit;
|
||||
|
||||
public function __construct(
|
||||
private readonly string $agentListen,
|
||||
private readonly array $nodeConfig,
|
||||
private readonly array $policies,
|
||||
private readonly array $allowedAgentTransports,
|
||||
string $auditLog,
|
||||
) {
|
||||
$this->nodes = new NodeRegistry();
|
||||
$this->sessions = new SessionManager();
|
||||
$this->audit = new AuditLogger($auditLog);
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
$agentWorker = new Worker('tcp://' . $this->agentListen);
|
||||
$agentWorker->name = 'laylink-pop-agent-listener';
|
||||
$agentWorker->count = 1;
|
||||
new AgentListener(
|
||||
$agentWorker,
|
||||
new NodeAuthenticator($this->nodeConfig, $this->allowedAgentTransports),
|
||||
new ClientAuthenticator(),
|
||||
new RouteResolver(new PolicyChecker($this->policies), $this->nodes),
|
||||
$this->nodes,
|
||||
$this->sessions,
|
||||
$this->audit,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LayLink\Session;
|
||||
|
||||
final class SessionManager
|
||||
{
|
||||
/** @var array<string, TunnelSession> */
|
||||
private array $sessions = [];
|
||||
|
||||
public function add(TunnelSession $session): void
|
||||
{
|
||||
$this->sessions[$session->sessionId] = $session;
|
||||
}
|
||||
|
||||
public function get(string $sessionId): ?TunnelSession
|
||||
{
|
||||
return $this->sessions[$sessionId] ?? null;
|
||||
}
|
||||
|
||||
public function remove(string $sessionId): ?TunnelSession
|
||||
{
|
||||
$session = $this->sessions[$sessionId] ?? null;
|
||||
unset($this->sessions[$sessionId]);
|
||||
return $session;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return TunnelSession[]
|
||||
*/
|
||||
public function all(): array
|
||||
{
|
||||
return array_values($this->sessions);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LayLink\Session;
|
||||
|
||||
use Workerman\Connection\AsyncTcpConnection;
|
||||
use Workerman\Connection\TcpConnection;
|
||||
|
||||
final class TunnelSession
|
||||
{
|
||||
public const NEW = 'NEW';
|
||||
public const OPENING = 'OPENING';
|
||||
public const OPEN = 'OPEN';
|
||||
public const CLOSING = 'CLOSING';
|
||||
public const CLOSED = 'CLOSED';
|
||||
public const FAILED = 'FAILED';
|
||||
|
||||
public string $state = self::NEW;
|
||||
public ?TcpConnection $client = null;
|
||||
public ?TcpConnection $agent = null;
|
||||
public ?AsyncTcpConnection $target = null;
|
||||
public ?string $nodeId = null;
|
||||
public string $startTime;
|
||||
public int $startedAtMs;
|
||||
public int $bytesClientToTarget = 0;
|
||||
public int $bytesTargetToClient = 0;
|
||||
|
||||
public function __construct(
|
||||
public readonly string $sessionId,
|
||||
public readonly string $userId,
|
||||
public readonly string $sourceIp,
|
||||
public readonly string $targetHost,
|
||||
public readonly int $targetPort,
|
||||
public readonly string $protocol,
|
||||
public readonly string $routeType,
|
||||
public readonly ?string $policyId,
|
||||
) {
|
||||
$this->startTime = date(DATE_ATOM);
|
||||
$this->startedAtMs = (int)floor(microtime(true) * 1000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LayLink\Util;
|
||||
|
||||
final class Env
|
||||
{
|
||||
public static function load(string $path): void
|
||||
{
|
||||
if (!is_file($path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
if ($lines === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === '' || str_starts_with($line, '#') || !str_contains($line, '=')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
[$key, $value] = explode('=', $line, 2);
|
||||
$key = trim($key);
|
||||
$value = trim($value, " \t\n\r\0\x0B\"'");
|
||||
if ($key !== '' && getenv($key) === false) {
|
||||
putenv($key . '=' . $value);
|
||||
$_ENV[$key] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static function get(string $key, string $default): string
|
||||
{
|
||||
$value = getenv($key);
|
||||
return $value === false || $value === '' ? $default : $value;
|
||||
}
|
||||
|
||||
public static function bool(string $key, bool $default): bool
|
||||
{
|
||||
$value = getenv($key);
|
||||
if ($value === false || trim($value) === '') {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return in_array(strtolower(trim($value)), ['1', 'true', 'yes', 'on'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public static function csv(string $key, array $default): array
|
||||
{
|
||||
$value = getenv($key);
|
||||
if ($value === false || trim($value) === '') {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return array_values(array_filter(array_map(
|
||||
static fn (string $item): string => strtolower(trim($item)),
|
||||
explode(',', $value),
|
||||
)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LayLink\Util;
|
||||
|
||||
final class Uuid
|
||||
{
|
||||
public static function v4(): string
|
||||
{
|
||||
$data = random_bytes(16);
|
||||
$data[6] = chr((ord($data[6]) & 0x0f) | 0x40);
|
||||
$data[8] = chr((ord($data[8]) & 0x3f) | 0x80);
|
||||
|
||||
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user