From ad1327bebde764ec05b46ac0b6621b4f92f6f18f Mon Sep 17 00:00:00 2001 From: EchoNoch Date: Thu, 28 May 2026 23:41:40 +0800 Subject: [PATCH] add more --- config/nodes.php | 2 +- config/policies.php | 2 +- contract.md | 68 ++++++++++++++++++++--------------- problems.md | 9 +++++ readme.md | 15 +++++--- runtime/workerman.log | 13 +++++++ scripts/verify-socks5.sh | 2 +- src/Agent/AgentClient.php | 15 ++++++-- src/Agent/TargetConnector.php | 3 +- src/Auth/PolicyChecker.php | 2 +- src/Auth/PortMatcher.php | 47 ++++++++++++++++++++++++ src/Protocol/FrameCodec.php | 60 ++++++++++++++++++++++++++----- src/Protocol/FrameType.php | 2 +- src/Server/AgentListener.php | 17 ++++++--- 14 files changed, 204 insertions(+), 53 deletions(-) create mode 100644 problems.md create mode 100644 src/Auth/PortMatcher.php diff --git a/config/nodes.php b/config/nodes.php index e63bede..dc222c9 100644 --- a/config/nodes.php +++ b/config/nodes.php @@ -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, ], ]; diff --git a/config/policies.php b/config/policies.php index ab12981..a2376b7 100644 --- a/config/policies.php +++ b/config/policies.php @@ -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, diff --git a/contract.md b/contract.md index a85c8a7..8281124 100644 --- a/contract.md +++ b/contract.md @@ -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. diff --git a/problems.md b/problems.md new file mode 100644 index 0000000..9c0be7d --- /dev/null +++ b/problems.md @@ -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 \ No newline at end of file diff --git a/readme.md b/readme.md index ec01e4b..d262e8e 100644 --- a/readme.md +++ b/readme.md @@ -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 Server;POP Server 校验通过后直连公网目标,随后通过 `DATA` 帧转发原始 TCP 数据。 +TCP 大流量 `DATA` 帧使用二进制帧编码;`AUTH`、`OPEN`、`CLOSE`、`ERROR` 等控制帧仍使用 JSON 编码。启用 `chacha20` 时,二进制和 JSON Frame body 都会被加密。 + 验证 SOCKS5 HTTPS 联通性和出口 IP: ```bash diff --git a/runtime/workerman.log b/runtime/workerman.log index 39657db..a9aff6f 100644 --- a/runtime/workerman.log +++ b/runtime/workerman.log @@ -61,3 +61,16 @@ 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 +2026-05-28 13:27:02 pid:492815 Workerman[client-agent.php] restart +2026-05-28 13:27:02 pid:492815 Workerman[client-agent.php] is stopping ... +2026-05-28 13:27:02 pid:492815 Workerman[client-agent.php] stop success +2026-05-28 15:16:23 pid:494454 Workerman[pop-server.php] start in DAEMON mode +2026-05-28 15:22:27 pid:492815 Workerman[client-agent.php] received signal SIGINT +2026-05-28 15:22:27 pid:492815 Workerman[client-agent.php] stopping +2026-05-28 15:22:27 pid:492815 Workerman[client-agent.php] has been stopped +2026-05-28 15:27:40 pid:495186 Workerman[pop-server.php] restart +2026-05-28 15:27:40 pid:495186 Workerman[pop-server.php] is stopping ... +2026-05-28 15:27:40 pid:494484 Workerman[pop-server.php] received signal SIGINT +2026-05-28 15:27:40 pid:494484 Workerman[pop-server.php] stopping +2026-05-28 15:27:40 pid:494484 Workerman[pop-server.php] has been stopped +2026-05-28 15:27:40 pid:495186 Workerman[pop-server.php] stop success diff --git a/scripts/verify-socks5.sh b/scripts/verify-socks5.sh index 22e7f0b..29d9c8a 100755 --- a/scripts/verify-socks5.sh +++ b/scripts/verify-socks5.sh @@ -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 \ diff --git a/src/Agent/AgentClient.php b/src/Agent/AgentClient.php index 0022121..e901ac3 100644 --- a/src/Agent/AgentClient.php +++ b/src/Agent/AgentClient.php @@ -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]); diff --git a/src/Agent/TargetConnector.php b/src/Agent/TargetConnector.php index 72418f9..807e5df 100644 --- a/src/Agent/TargetConnector.php +++ b/src/Agent/TargetConnector.php @@ -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; } diff --git a/src/Auth/PolicyChecker.php b/src/Auth/PolicyChecker.php index 03b4495..a98750e 100644 --- a/src/Auth/PolicyChecker.php +++ b/src/Auth/PolicyChecker.php @@ -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'] ?? [])) { diff --git a/src/Auth/PortMatcher.php b/src/Auth/PortMatcher.php new file mode 100644 index 0000000..c3fb0d5 --- /dev/null +++ b/src/Auth/PortMatcher.php @@ -0,0 +1,47 @@ + 65535 || $start > $end) { + return false; + } + + return $port >= $start && $port <= $end; + } +} diff --git a/src/Protocol/FrameCodec.php b/src/Protocol/FrameCodec.php index a84d26d..5149418 100644 --- a/src/Protocol/FrameCodec.php +++ b/src/Protocol/FrameCodec.php @@ -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) { diff --git a/src/Protocol/FrameType.php b/src/Protocol/FrameType.php index 6e80278..104c175 100644 --- a/src/Protocol/FrameType.php +++ b/src/Protocol/FrameType.php @@ -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.', diff --git a/src/Server/AgentListener.php b/src/Server/AgentListener.php index 3b5fe1c..1a6fb8a 100644 --- a/src/Server/AgentListener.php +++ b/src/Server/AgentListener.php @@ -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]));