Compare commits

..

2 Commits

Author SHA1 Message Date
9c07b9fadc add more 2026-05-28 23:42:00 +08:00
ad1327bebd add more 2026-05-28 23:41:40 +08:00
14 changed files with 191 additions and 116 deletions

View File

@ -8,7 +8,7 @@ return [
'192.168.0.0/16',
'10.10.0.0/16',
],
'allowed_ports' => [22, 80, 443, 3306, 5432],
'allowed_ports' => [22, 80, 443, '3000-60000', 3306, 5432],
'enabled' => true,
],
];

View File

@ -5,7 +5,7 @@ return [
'policy_id' => 'public-web-egress',
'users' => ['normal-user', 'admin', 'devops'],
'target_hosts' => ['*'],
'target_ports' => [80, 443],
'target_ports' => [80, 443, '3000-60000'],
'protocol' => 'tcp',
'route_type' => 'direct',
'enabled' => true,

View File

@ -151,6 +151,15 @@ Completed in this checkpoint:
* `chacha20` currently uses libsodium XChaCha20 stream encryption with a random nonce per frame.
* Verified `none` and `chacha20` FrameCodec encode/decode round trips.
* Verified POP Server starts with `LAYLINK_FRAME_ENCRYPTION=chacha20`.
* Added port range matching for policy and agent allowlist ports:
* `target_ports` supports exact ports such as `80` and string ranges such as `'8080-10080'`.
* `allowed_ports` supports the same syntax.
* Allowed sample public TCP egress policy on port range `'8080-10080'` for HTTP-alt/speedtest style endpoints.
* Optimized TCP stream `DATA` frames:
* Control frames remain JSON.
* TCP `DATA` payloads now use binary frame encoding when both ends run the updated code.
* This removes base64 expansion and JSON string encoding from the hot TCP data path.
* Verified binary TCP `DATA` frame encode/decode under both `none` and `chacha20`.
Known MVP limitations:
@ -164,6 +173,7 @@ Known MVP limitations:
* No TLS yet.
* No production-grade client identity yet; `dev-token` is hardcoded for MVP development.
* No automated integration test harness yet.
* TCP stream forwarding is still single Agent-to-POP connection based; binary `DATA` frames reduce per-byte overhead, but KCP/multipath/parallel transport and flow-control tuning are still future performance work.
* No explicit idle timeout or connect timeout enforcement yet.
* UDP relay is datagram-oriented and currently creates short-lived POP-side UDP sockets per outbound datagram; pooling and stronger timeout accounting are still future work.
* HTTP proxy supports `CONNECT` and ordinary absolute URL HTTP requests; advanced proxy auth and full HTTP/2 proxying are not implemented.
@ -175,10 +185,12 @@ Next recommended tasks:
3. Add target connect timeout and session idle timeout.
4. Add buffer full/drain handling with audit result `buffer_overflow`.
5. Add README quickstart with exact local commands.
6. Optimize UDP relay with POP-side UDP socket pooling.
7. Add UDP association idle timeouts and cleanup.
8. Aggregate UDP audit records per association instead of per datagram.
9. Add UDP and per-user rate limiting.
6. Add a reproducible throughput benchmark script for direct-vs-LayLink comparisons.
7. Add KCP or another UDP-based reliable transport behind the transport abstraction.
8. Optimize UDP relay with POP-side UDP socket pooling.
9. Add UDP association idle timeouts and cleanup.
10. Aggregate UDP audit records per association instead of per datagram.
11. Add UDP and per-user rate limiting.
## 0. Project Name
@ -421,7 +433,7 @@ Required frame types:
| `OPEN` | Client Agent -> POP | Client Agent requests POP to authorize and open a target stream. |
| `OPEN_OK` | POP -> Client Agent | POP has connected the target and the stream can begin. |
| `OPEN_FAIL` | POP -> Client Agent | POP rejected or failed the requested target stream. |
| `DATA` | Bidirectional | Stream bytes for one `session_id`; MVP payload uses base64. |
| `DATA` | Bidirectional | Stream bytes for one `session_id`; TCP stream payloads use binary frame encoding when both ends are updated. |
| `UDP_DATA` | Bidirectional | UDP datagram bytes for one UDP association; MVP payload uses base64 and includes target metadata. |
| `CLOSE` | Bidirectional | Close one stream session. |
| `ERROR` | Bidirectional | Explicit protocol or session error. |
@ -447,13 +459,11 @@ payload_length
payload
```
Suggested JSON payload for MVP is acceptable.
Binary optimization may be added later.
Control frames use JSON payloads. TCP stream `DATA` frames may use the binary DATA encoding below.
### 6.3 Frame Encoding
For MVP, use length-prefixed JSON frames.
For control frames, use length-prefixed JSON frames.
Format:
@ -462,20 +472,33 @@ uint32_be length
json_payload
```
Example decoded frame:
Example decoded control frame:
```json
{
"version": 1,
"type": "DATA",
"type": "OPEN",
"session_id": "018f6f4a-xxxx-xxxx",
"payload": "base64-encoded-binary"
"payload": {
"target_host": "example.com",
"target_port": 443,
"protocol": "tcp"
}
}
```
For `DATA` frames, binary stream data may be base64 encoded in MVP.
TCP stream `DATA` frames use a binary body before optional encryption:
A later version may replace this with binary headers plus raw binary body.
```text
uint32_be encrypted_or_plain_body_length
"LLB1"
uint8 binary_type=1
uint16_be session_id_length
session_id bytes
raw TCP payload bytes
```
Legacy JSON/base64 `DATA` decoding remains accepted for compatibility, but updated senders should emit binary `DATA`.
---
@ -658,7 +681,7 @@ return [
'policy_id' => 'public-web-egress',
'users' => ['normal-user', 'admin'],
'target_hosts' => ['*'],
'target_ports' => [80, 443],
'target_ports' => [80, 443, '8080-10080'],
'route_type' => 'direct',
'enabled' => true,
],
@ -696,7 +719,7 @@ return [
'192.168.0.0/16',
'10.10.0.0/16',
],
'allowed_ports' => [22, 80, 443, 3306, 5432],
'allowed_ports' => [22, 80, 443, '8080-10080', 3306, 5432],
'enabled' => true,
],
];
@ -773,18 +796,7 @@ On failure:
After `OPEN_OK`, data is exchanged with `DATA` frames.
Example:
```json
{
"version": 1,
"type": "DATA",
"session_id": "018f6f4a-xxxx",
"payload": {
"data": "base64-encoded-binary"
}
}
```
Updated implementations encode TCP `DATA` as the binary frame described in section 6.3. Legacy JSON/base64 `DATA` frames may still be decoded during rolling upgrades.
Both POP Server and Agent must map `session_id` to the corresponding local TCP connection.

9
problems.md Normal file
View File

@ -0,0 +1,9 @@
当前仍无法实现chacha20加密在开启chacha20之后
Using SOCKS5 proxy: socks5h://127.0.0.1:21080
[1/2] HTTPS connectivity: https://bing.com/
* Trying 127.0.0.1:21080...
* SOCKS5 connect to bing.com:443 (remotely resolved)
* Can't complete SOCKS5 connection to bing.com. (1)
* Closing connection 0
curl: (97) Can't complete SOCKS5 connection to bing.com. (1)
ERR bing_request_failed status=97

View File

@ -181,7 +181,7 @@ Client Agent 的节点身份不是只写在 `.env` 中POP Server 侧还必须
'192.168.0.0/16',
'10.10.0.0/16',
],
'allowed_ports' => [22, 80, 443, 3306, 5432],
'allowed_ports' => [22, 80, 443, '8080-10080', 3306, 5432],
'enabled' => true,
],
```
@ -262,7 +262,7 @@ CLIENT_AGENT_SOCKS5_PASSWORD=change-this-password
| `auth_token` | 客户端认证 token。当前 MVP 固定为 `dev-token`。 | `dev-token` |
| `user_id` | 用户身份。POP Server 会用它匹配 `config/policies.php`。 | `admin`、`devops`、`normal-user` |
| `target_host` | 目标主机。 | `192.168.10.20`、`example.com` |
| `target_port` | 目标端口。 | `22`、`80`、`443`、`5432` |
| `target_port` | 目标端口。 | `22`、`80`、`443`、`8080`、`5432` |
| `protocol` | 目标协议。当前只支持 TCP。 | `tcp` |
| `route_hint` | 预留字段。新的最小路径由 POP Server 直连公网目标,通常不需要填写。 | `null` |
@ -277,7 +277,7 @@ CLIENT_AGENT_SOCKS5_PASSWORD=change-this-password
'policy_id' => 'public-web-egress',
'users' => ['normal-user', 'admin', 'devops'],
'target_hosts' => ['*'],
'target_ports' => [80, 443],
'target_ports' => [80, 443, '8080-10080'],
'protocol' => 'tcp',
'route_type' => 'direct',
'enabled' => true,
@ -286,10 +286,15 @@ CLIENT_AGENT_SOCKS5_PASSWORD=change-this-password
这条策略表示:
* `normal-user`、`admin` 和 `devops` 可以访问任意主机的 `80`、`443` 端口。
* `normal-user`、`admin` 和 `devops` 可以访问任意主机的 `80`、`443`,以及 `8080``10080` 端口。
* Client Agent 只负责把请求封装成 Frame 发到 POP Server。
* POP Server 校验策略后直接连接公网目标。
`target_ports``allowed_ports` 都支持两种写法:
* 单端口:`80`
* 端口范围:`'8080-10080'`
路由类型:
| `route_type` | 作用 |
@ -373,6 +378,8 @@ php bin/client-agent.php start
然后把应用的代理设置为 SOCKS5 `127.0.0.1:1080`。Client Agent 会解析 SOCKS5 `CONNECT`,封装成 `OPEN` 帧发给 POP ServerPOP Server 校验通过后直连公网目标,随后通过 `DATA` 帧转发原始 TCP 数据。
TCP 大流量 `DATA` 帧使用二进制帧编码;`AUTH`、`OPEN`、`CLOSE`、`ERROR` 等控制帧仍使用 JSON 编码。启用 `chacha20` 时,二进制和 JSON Frame body 都会被加密。
验证 SOCKS5 HTTPS 联通性和出口 IP
```bash

View File

@ -1,63 +0,0 @@
2026-05-28 10:19:07 pid:478270 Workerman[pop-server.php] start in DEBUG mode
2026-05-28 10:19:09 pid:478270 Workerman[pop-server.php] received signal SIGTERM
2026-05-28 10:19:09 pid:478270 Workerman[pop-server.php] stopping
2026-05-28 10:19:09 pid:478270 Workerman[pop-server.php] has been stopped
2026-05-28 11:13:28 pid:481004 Workerman[client-agent.php] start in DEBUG mode
2026-05-28 11:13:30 pid:481004 Workerman[client-agent.php] received signal SIGTERM
2026-05-28 11:13:30 pid:481004 Workerman[client-agent.php] stopping
2026-05-28 11:13:30 pid:481004 Workerman[client-agent.php] has been stopped
2026-05-28 11:21:31 pid:482154 Workerman[client-agent.php] start in DEBUG mode
2026-05-28 11:21:32 pid:482187 Workerman[pop-server.php] start in DEBUG mode
2026-05-28 11:21:33 pid:482154 Workerman[client-agent.php] received signal SIGTERM
2026-05-28 11:21:33 pid:482154 Workerman[client-agent.php] stopping
2026-05-28 11:21:33 pid:482154 Workerman[client-agent.php] has been stopped
2026-05-28 11:21:34 pid:482187 Workerman[pop-server.php] received signal SIGTERM
2026-05-28 11:21:34 pid:482187 Workerman[pop-server.php] stopping
2026-05-28 11:21:34 pid:482187 Workerman[pop-server.php] has been stopped
2026-05-28 11:32:55 pid:484077 Workerman[client-agent.php] start in DEBUG mode
2026-05-28 11:32:57 pid:484077 Workerman[client-agent.php] received signal SIGTERM
2026-05-28 11:32:57 pid:484077 Workerman[client-agent.php] stopping
2026-05-28 11:32:57 pid:484077 Workerman[client-agent.php] has been stopped
2026-05-28 11:34:04 pid:484561 Workerman[client-agent.php] start in DEBUG mode
2026-05-28 11:34:06 pid:484561 Workerman[client-agent.php] received signal SIGTERM
2026-05-28 11:34:06 pid:484561 Workerman[client-agent.php] stopping
2026-05-28 11:34:06 pid:484561 Workerman[client-agent.php] has been stopped
2026-05-28 11:36:48 pid:485045 Workerman[client-agent.php] start in DEBUG mode
2026-05-28 11:36:50 pid:485045 Workerman[client-agent.php] received signal SIGTERM
2026-05-28 11:36:50 pid:485045 Workerman[client-agent.php] stopping
2026-05-28 11:36:50 pid:485045 Workerman[client-agent.php] has been stopped
2026-05-28 11:41:26 pid:486188 Workerman[client-agent.php] start in DEBUG mode
2026-05-28 11:41:28 pid:486188 Workerman[client-agent.php] received signal SIGTERM
2026-05-28 11:41:28 pid:486188 Workerman[client-agent.php] stopping
2026-05-28 11:41:28 pid:486188 Workerman[client-agent.php] has been stopped
2026-05-28 12:08:09 pid:489103 Workerman[pop-server.php] start in DEBUG mode
2026-05-28 12:08:11 pid:489103 Workerman[pop-server.php] received signal SIGTERM
2026-05-28 12:08:11 pid:489103 Workerman[pop-server.php] stopping
2026-05-28 12:08:11 pid:489103 Workerman[pop-server.php] has been stopped
2026-05-28 12:28:14 pid:489644 Workerman[client-agent.php] start in DEBUG mode
2026-05-28 12:34:47 pid:490774 Workerman[client-agent.php] restart
2026-05-28 12:34:47 pid:490774 Workerman[client-agent.php] is stopping ...
2026-05-28 12:34:47 pid:489644 Workerman[client-agent.php] received signal SIGINT
2026-05-28 12:34:47 pid:489644 Workerman[client-agent.php] stopping
2026-05-28 12:34:48 pid:489644 Workerman[client-agent.php] has been stopped
2026-05-28 12:34:48 pid:490774 Workerman[client-agent.php] stop success
2026-05-28 12:35:17 pid:490876 Workerman[client-agent.php] stop
2026-05-28 12:35:17 pid:490876 Workerman[client-agent.php] is stopping ...
2026-05-28 12:35:17 pid:490774 Workerman[client-agent.php] received signal SIGINT
2026-05-28 12:35:17 pid:490774 Workerman[client-agent.php] stopping
2026-05-28 12:35:19 pid:490774 Workerman[client-agent.php] has been stopped
2026-05-28 12:35:19 pid:490876 Workerman[client-agent.php] stop success
2026-05-28 12:35:19 pid:490845 Workerman[client-agent.php] start in DAEMON mode
2026-05-28 12:40:44 pid:491096 Workerman[client-agent.php] restart
2026-05-28 12:40:44 pid:491096 Workerman[client-agent.php] is stopping ...
2026-05-28 12:40:44 pid:490878 Workerman[client-agent.php] received signal SIGINT
2026-05-28 12:40:44 pid:490878 Workerman[client-agent.php] stopping
2026-05-28 12:40:44 pid:490878 Workerman[client-agent.php] has been stopped
2026-05-28 12:40:44 pid:491096 Workerman[client-agent.php] stop success
2026-05-28 13:04:58 pid:492360 Workerman[pop-server.php] start in DEBUG mode
2026-05-28 13:05:00 pid:492360 Workerman[pop-server.php] received signal SIGTERM
2026-05-28 13:05:00 pid:492360 Workerman[pop-server.php] stopping
2026-05-28 13:05:00 pid:492360 Workerman[pop-server.php] has been stopped
2026-05-28 13:07:21 pid:491096 Workerman[client-agent.php] received signal SIGINT
2026-05-28 13:07:21 pid:491096 Workerman[client-agent.php] stopping
2026-05-28 13:07:21 pid:491096 Workerman[client-agent.php] has been stopped

View File

@ -24,7 +24,7 @@ echo "Using SOCKS5 proxy: ${proxy}"
echo
echo "[1/2] HTTPS connectivity: https://bing.com/"
bing_code="$(
curl \
curl -vvvv \
--silent \
--show-error \
--location \

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]);

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;
}

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
src/Auth/PortMatcher.php Normal file
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;
}
}

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) {

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.',

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]));