This commit is contained in:
2026-05-28 23:41:40 +08:00
parent f274a7dd4b
commit ad1327bebd
14 changed files with 204 additions and 53 deletions
+12 -3
View File
@@ -162,7 +162,7 @@ final class AgentClient
return;
}
$this->send(new Frame(FrameType::DATA, $sessionId, ['data' => base64_encode($data)]));
$this->send(new Frame(FrameType::DATA, $sessionId, ['data_raw' => $data]));
}
private function handleInitialRequest(TcpConnection $connection, string $data): void
@@ -544,7 +544,7 @@ final class AgentClient
$pending = $this->pendingData[$frame->sessionId] ?? '';
unset($this->pendingData[$frame->sessionId]);
if ($pending !== '') {
$this->send(new Frame(FrameType::DATA, $frame->sessionId, ['data' => base64_encode($pending)]));
$this->send(new Frame(FrameType::DATA, $frame->sessionId, ['data_raw' => $pending]));
}
}
@@ -573,7 +573,7 @@ final class AgentClient
return;
}
$data = base64_decode((string)($frame->payload['data'] ?? ''), true);
$data = $this->frameData($frame);
if ($data === false) {
$this->send(new Frame(FrameType::ERROR, $frame->sessionId, ['reason' => 'invalid_frame']));
return;
@@ -582,6 +582,15 @@ final class AgentClient
$this->clients[$frame->sessionId]->send($data);
}
private function frameData(Frame $frame): string|false
{
if (isset($frame->payload['data_raw']) && is_string($frame->payload['data_raw'])) {
return $frame->payload['data_raw'];
}
return base64_decode((string)($frame->payload['data'] ?? ''), true);
}
private function onClientClose(TcpConnection $connection): void
{
unset($this->initialBuffers[$connection->id]);
+2 -1
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace LayLink\Agent;
use LayLink\Auth\PolicyChecker;
use LayLink\Auth\PortMatcher;
final class TargetConnector
{
@@ -14,7 +15,7 @@ final class TargetConnector
public function isAllowed(string $host, int $port): bool
{
if (!in_array($port, $this->nodeConfig['allowed_ports'] ?? [], true)) {
if (!PortMatcher::matches($port, $this->nodeConfig['allowed_ports'] ?? [])) {
return false;
}
+1 -1
View File
@@ -22,7 +22,7 @@ final class PolicyChecker
if (($policy['protocol'] ?? 'tcp') !== $protocol) {
continue;
}
if (!in_array($port, $policy['target_ports'] ?? [], true)) {
if (!PortMatcher::matches($port, $policy['target_ports'] ?? [])) {
continue;
}
if (!$this->hostMatches($host, $policy['target_hosts'] ?? [])) {
+47
View File
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace LayLink\Auth;
final class PortMatcher
{
public static function matches(int $port, array $rules): bool
{
foreach ($rules as $rule) {
if (is_int($rule) && $rule === $port) {
return true;
}
if (is_string($rule) && self::stringRuleMatches($port, $rule)) {
return true;
}
}
return false;
}
private static function stringRuleMatches(int $port, string $rule): bool
{
$rule = trim($rule);
if ($rule === '') {
return false;
}
if (ctype_digit($rule)) {
return (int)$rule === $port;
}
if (!preg_match('/^(\d{1,5})\s*-\s*(\d{1,5})$/', $rule, $matches)) {
return false;
}
$start = (int)$matches[1];
$end = (int)$matches[2];
if ($start < 1 || $end > 65535 || $start > $end) {
return false;
}
return $port >= $start && $port <= $end;
}
}
+52 -8
View File
@@ -9,6 +9,8 @@ use InvalidArgumentException;
final class FrameCodec
{
public const MAX_FRAME_LENGTH = 16 * 1024 * 1024;
private const BINARY_MAGIC = "LLB1";
private const BINARY_TYPE_DATA = 1;
private const ENCRYPTION_NONE = 'none';
private const ENCRYPTION_CHACHA20 = 'chacha20';
@@ -40,20 +42,19 @@ final class FrameCodec
public static function encode(Frame $frame): string
{
$json = json_encode($frame->toArray(), JSON_UNESCAPED_SLASHES);
if ($json === false) {
throw new InvalidArgumentException('invalid_frame_payload');
}
$body = self::encrypt($json);
$body = self::encrypt(self::serializeFrame($frame));
return pack('N', strlen($body)) . $body;
}
public static function decode(string $body): Frame
{
$json = self::decrypt($body);
$data = json_decode($json, true);
$plaintext = self::decrypt($body);
if (str_starts_with($plaintext, self::BINARY_MAGIC)) {
return self::decodeBinaryFrame($plaintext);
}
$data = json_decode($plaintext, true);
if (!is_array($data) || !isset($data['type']) || !is_string($data['type'])) {
throw new InvalidArgumentException('invalid_frame');
}
@@ -76,6 +77,49 @@ final class FrameCodec
);
}
private static function serializeFrame(Frame $frame): string
{
if ($frame->type === FrameType::DATA && isset($frame->payload['data_raw']) && is_string($frame->payload['data_raw'])) {
if ($frame->sessionId === null || strlen($frame->sessionId) > 65535) {
throw new InvalidArgumentException('invalid_session_id');
}
return self::BINARY_MAGIC
. chr(self::BINARY_TYPE_DATA)
. pack('n', strlen($frame->sessionId))
. $frame->sessionId
. $frame->payload['data_raw'];
}
$json = json_encode($frame->toArray(), JSON_UNESCAPED_SLASHES);
if ($json === false) {
throw new InvalidArgumentException('invalid_frame_payload');
}
return $json;
}
private static function decodeBinaryFrame(string $plaintext): Frame
{
if (strlen($plaintext) < 7) {
throw new InvalidArgumentException('invalid_binary_frame');
}
$type = ord($plaintext[4]);
$sessionLength = unpack('n', substr($plaintext, 5, 2))[1];
if (strlen($plaintext) < 7 + $sessionLength) {
throw new InvalidArgumentException('invalid_binary_frame');
}
$sessionId = substr($plaintext, 7, $sessionLength);
$data = substr($plaintext, 7 + $sessionLength);
return match ($type) {
self::BINARY_TYPE_DATA => new Frame(FrameType::DATA, $sessionId, ['data_raw' => $data]),
default => throw new InvalidArgumentException('unsupported_binary_frame'),
};
}
private static function encrypt(string $plaintext): string
{
if (self::$encryption === self::ENCRYPTION_NONE) {
+1 -1
View File
@@ -34,7 +34,7 @@ final class FrameType
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::DATA => 'Bidirectional stream bytes; TCP stream DATA uses binary frame encoding when both ends are updated.',
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.',
+13 -4
View File
@@ -123,7 +123,7 @@ final class AgentListener
}
match ($frame->type) {
FrameType::DATA => $this->forwardDataToTarget($session, (string)($frame->payload['data'] ?? '')),
FrameType::DATA => $this->forwardDataToTarget($session, $frame),
FrameType::CLOSE => $this->closeSession($session, 'closed', null),
default => null,
};
@@ -233,7 +233,7 @@ final class AgentListener
$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),
'data_raw' => $data,
]));
};
$target->onClose = fn () => $this->closeSession($session, 'closed', null);
@@ -241,9 +241,9 @@ final class AgentListener
$target->connect();
}
private function forwardDataToTarget(TunnelSession $session, string $encoded): void
private function forwardDataToTarget(TunnelSession $session, Frame $frame): void
{
$data = base64_decode($encoded, true);
$data = $this->frameData($frame);
if ($data === false) {
$this->closeSession($session, 'failed', 'invalid_frame');
return;
@@ -259,6 +259,15 @@ final class AgentListener
$this->closeSession($session, 'failed', $reason);
}
private function frameData(Frame $frame): string|false
{
if (isset($frame->payload['data_raw']) && is_string($frame->payload['data_raw'])) {
return $frame->payload['data_raw'];
}
return base64_decode((string)($frame->payload['data'] ?? ''), true);
}
private function 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]));