This commit is contained in:
2026-05-28 20:19:28 +08:00
commit 070fad058f
124 changed files with 22713 additions and 0 deletions
+753
View File
@@ -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));
}
}
+29
View File
@@ -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);
}
}
+28
View File
@@ -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,
);
}
}
+18
View File
@@ -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')];
}
}
+45
View File
@@ -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]];
}
}
+66
View File
@@ -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);
}
}
+22
View File
@@ -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();
}
}
+48
View File
@@ -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);
}
}
+26
View File
@@ -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,
];
}
}
+47
View File
@@ -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),
);
}
}
+38
View File
@@ -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;
}
}
+44
View File
@@ -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.',
];
}
}
+17
View File
@@ -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,
) {
}
}
+39
View File
@@ -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']);
}
}
+349
View File
@@ -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));
}
}
+49
View File
@@ -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,
);
}
}
+36
View File
@@ -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);
}
}
+42
View File
@@ -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);
}
}
+67
View File
@@ -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),
)));
}
}
+17
View File
@@ -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));
}
}