diff --git a/.env.example b/.env.example index 3e8ecc3..b1ff81d 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,10 @@ LOG_LEVEL=debug # 日志级别;建议值为 debug、info、warning、error,当前 MVP 预留该配置用于后续日志工厂。 AUDIT_LOG=runtime/audit.log # 审计日志文件路径;建议使用 runtime/*.log,MVP 会按 JSON Lines 格式追加写入。 +LAYLINK_FRAME_ENCRYPTION=none +# Agent 与 POP Server 之间 Frame 加密方式;可选 none、chacha20,两端必须一致。 +LAYLINK_FRAME_ENCRYPTION_KEY= +# Frame 加密密钥;LAYLINK_FRAME_ENCRYPTION=chacha20 时必填。可填普通口令,或 hex:32字节十六进制,或 base64:32字节base64。 [client-agent] NODE_ID=client-01 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e0b9c9d --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.env +runtime/* \ No newline at end of file diff --git a/bin/client-agent.php b/bin/client-agent.php index 71121b3..04c2a24 100755 --- a/bin/client-agent.php +++ b/bin/client-agent.php @@ -4,12 +4,17 @@ declare(strict_types=1); use LayLink\Agent\AgentClient; +use LayLink\Protocol\FrameCodec; use LayLink\Util\Env; use Workerman\Worker; require dirname(__DIR__) . '/vendor/autoload.php'; Env::load(dirname(__DIR__) . '/.env'); +FrameCodec::configureEncryption( + Env::get('LAYLINK_FRAME_ENCRYPTION', 'none'), + Env::get('LAYLINK_FRAME_ENCRYPTION_KEY', ''), +); Worker::$logFile = dirname(__DIR__) . '/runtime/workerman.log'; Worker::$pidFile = dirname(__DIR__) . '/runtime/client-agent.pid'; diff --git a/bin/pop-server.php b/bin/pop-server.php index 5a61f67..c1531d6 100755 --- a/bin/pop-server.php +++ b/bin/pop-server.php @@ -4,12 +4,17 @@ declare(strict_types=1); use LayLink\Server\PopServer; +use LayLink\Protocol\FrameCodec; use LayLink\Util\Env; use Workerman\Worker; require dirname(__DIR__) . '/vendor/autoload.php'; Env::load(dirname(__DIR__) . '/.env'); +FrameCodec::configureEncryption( + Env::get('LAYLINK_FRAME_ENCRYPTION', 'none'), + Env::get('LAYLINK_FRAME_ENCRYPTION_KEY', ''), +); Worker::$logFile = dirname(__DIR__) . '/runtime/workerman.log'; Worker::$pidFile = dirname(__DIR__) . '/runtime/pop-server.pid'; diff --git a/contract.md b/contract.md index e308f99..a85c8a7 100644 --- a/contract.md +++ b/contract.md @@ -141,6 +141,16 @@ Completed in this checkpoint: * `bin/border-agent.php` * sample border node and border policy docs * Verified POP now starts with only `laylink-pop-agent-listener`. +* Fixed SOCKS5 error behavior when POP is not connected: + * SOCKS5 method negotiation no longer returns text errors. + * POP connection failures during CONNECT now return standard SOCKS5 failure replies. +* Added Agent-to-POP Frame encryption: + * `LAYLINK_FRAME_ENCRYPTION=none|chacha20` + * `LAYLINK_FRAME_ENCRYPTION_KEY` + * POP Server and Client Agent must use identical encryption settings. + * `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`. Known MVP limitations: diff --git a/readme.md b/readme.md index ab15ee7..ec01e4b 100644 --- a/readme.md +++ b/readme.md @@ -27,6 +27,29 @@ cp .env.example .env `.env.example` 中的 `[config]`、`[client-agent]`、`[pop-server]` 是阅读分组标题,当前加载器会忽略这些标题,只读取 `KEY=value` 配置行。 +Agent 与 POP Server 之间的 LayLink Frame 支持加密: + +```env +LAYLINK_FRAME_ENCRYPTION=none +LAYLINK_FRAME_ENCRYPTION_KEY= +``` + +可选值: + +| 值 | 作用 | +| --- | --- | +| `none` | 不加密,开发调试默认值。 | +| `chacha20` | 使用 libsodium XChaCha20 stream 对 Frame body 加密。 | + +启用 `chacha20` 时,POP Server 和 Client Agent 必须配置完全相同的加密方式和密钥: + +```env +LAYLINK_FRAME_ENCRYPTION=chacha20 +LAYLINK_FRAME_ENCRYPTION_KEY=change-this-long-random-secret +``` + +密钥支持普通口令,也支持 `hex:` 或 `base64:` 前缀的 32 字节原始密钥。 + ## POP Server POP Server 是控制面和转发入口。 @@ -61,6 +84,8 @@ LOG_LEVEL=debug | 变量 | 作用 | 常见值 | | --- | --- | --- | | `APP_ENV` | 当前运行环境。开发时使用 `dev`,生产可使用 `prod`。 | `dev`、`test`、`prod` | +| `LAYLINK_FRAME_ENCRYPTION` | Agent 与 POP Server 之间 Frame 加密方式,两端必须一致。 | `none`、`chacha20` | +| `LAYLINK_FRAME_ENCRYPTION_KEY` | Frame 加密密钥,启用 `chacha20` 时必填。 | 普通口令、`hex:...`、`base64:...` | | `POP_AGENT_LISTEN` | POP Server 给 Client Agent 连接的监听地址。Agent 的 `POP_SERVER_ADDRESS` 应指向这里。 | `0.0.0.0:9001`、`127.0.0.1:9001` | | `POP_ALLOWED_AGENT_TRANSPORTS` | POP Server 允许 Agent 使用的底层传输协议,逗号分隔。Agent 认证时会上报自己的选择,不在列表内会被拒绝。 | `tcp`、`tcp,kcp`、`tcp,udp,kcp` | | `AUDIT_LOG` | 审计日志路径。MVP 使用 JSON Lines 追加写入。 | `runtime/audit.log` | diff --git a/runtime/workerman.log b/runtime/workerman.log index 8b07f27..39657db 100644 --- a/runtime/workerman.log +++ b/runtime/workerman.log @@ -34,3 +34,30 @@ 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 diff --git a/src/Agent/AgentClient.php b/src/Agent/AgentClient.php index c9121d1..0022121 100644 --- a/src/Agent/AgentClient.php +++ b/src/Agent/AgentClient.php @@ -167,12 +167,6 @@ final class AgentClient 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; @@ -428,6 +422,11 @@ final class AgentClient private function startPopSession(TcpConnection $connection, array $request, string $payloadBytes, string $ingressProtocol): void { + if (!$this->authenticated || $this->pop === null) { + $this->failOpeningLocalClient($connection, $ingressProtocol, 'pop_not_connected'); + return; + } + $sessionId = Uuid::v4(); $this->connectionSessionIds[$connection->id] = $sessionId; $this->clients[$sessionId] = $connection; @@ -448,6 +447,18 @@ final class AgentClient ])); } + private function failOpeningLocalClient(TcpConnection $connection, string $ingressProtocol, string $reason): void + { + if ($ingressProtocol === 'socks5') { + $connection->send($this->socks5Reply(1)); + } elseif (str_starts_with($ingressProtocol, 'http')) { + $connection->send("HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\nX-LayLink-Error: {$reason}\r\n\r\n"); + } else { + $connection->send("ERR {$reason}\n"); + } + $connection->close(); + } + private function onSocks5UdpMessage(UdpConnection $connection, string $data): void { if (!$this->authenticated || $this->pop === null || strlen($data) < 10) { diff --git a/src/Protocol/FrameCodec.php b/src/Protocol/FrameCodec.php index 6109e9a..a84d26d 100644 --- a/src/Protocol/FrameCodec.php +++ b/src/Protocol/FrameCodec.php @@ -9,6 +9,34 @@ use InvalidArgumentException; final class FrameCodec { public const MAX_FRAME_LENGTH = 16 * 1024 * 1024; + private const ENCRYPTION_NONE = 'none'; + private const ENCRYPTION_CHACHA20 = 'chacha20'; + + private static string $encryption = self::ENCRYPTION_NONE; + private static ?string $key = null; + + public static function configureEncryption(string $method, string $keyMaterial = ''): void + { + $method = strtolower(trim($method)); + if ($method === '') { + $method = self::ENCRYPTION_NONE; + } + + if (!in_array($method, [self::ENCRYPTION_NONE, self::ENCRYPTION_CHACHA20], true)) { + throw new InvalidArgumentException('unsupported_frame_encryption'); + } + + if ($method === self::ENCRYPTION_CHACHA20) { + if (!function_exists('sodium_crypto_stream_xchacha20_xor')) { + throw new InvalidArgumentException('chacha20_unavailable'); + } + self::$key = self::normalizeKey($keyMaterial); + } else { + self::$key = null; + } + + self::$encryption = $method; + } public static function encode(Frame $frame): string { @@ -17,11 +45,14 @@ final class FrameCodec throw new InvalidArgumentException('invalid_frame_payload'); } - return pack('N', strlen($json)) . $json; + $body = self::encrypt($json); + + return pack('N', strlen($body)) . $body; } - public static function decode(string $json): Frame + public static function decode(string $body): Frame { + $json = self::decrypt($body); $data = json_decode($json, true); if (!is_array($data) || !isset($data['type']) || !is_string($data['type'])) { throw new InvalidArgumentException('invalid_frame'); @@ -44,4 +75,56 @@ final class FrameCodec (int)($data['version'] ?? 1), ); } + + private static function encrypt(string $plaintext): string + { + if (self::$encryption === self::ENCRYPTION_NONE) { + return $plaintext; + } + + $nonce = random_bytes(SODIUM_CRYPTO_STREAM_XCHACHA20_NONCEBYTES); + return $nonce . sodium_crypto_stream_xchacha20_xor($plaintext, $nonce, self::$key ?? ''); + } + + private static function decrypt(string $body): string + { + if (self::$encryption === self::ENCRYPTION_NONE) { + return $body; + } + + $nonceLength = SODIUM_CRYPTO_STREAM_XCHACHA20_NONCEBYTES; + if (strlen($body) < $nonceLength) { + throw new InvalidArgumentException('invalid_encrypted_frame'); + } + + $nonce = substr($body, 0, $nonceLength); + $ciphertext = substr($body, $nonceLength); + return sodium_crypto_stream_xchacha20_xor($ciphertext, $nonce, self::$key ?? ''); + } + + private static function normalizeKey(string $keyMaterial): string + { + $keyMaterial = trim($keyMaterial); + if ($keyMaterial === '') { + throw new InvalidArgumentException('missing_frame_encryption_key'); + } + + if (str_starts_with($keyMaterial, 'hex:')) { + $decoded = hex2bin(substr($keyMaterial, 4)); + if ($decoded !== false && strlen($decoded) === SODIUM_CRYPTO_STREAM_XCHACHA20_KEYBYTES) { + return $decoded; + } + throw new InvalidArgumentException('invalid_hex_frame_encryption_key'); + } + + if (str_starts_with($keyMaterial, 'base64:')) { + $decoded = base64_decode(substr($keyMaterial, 7), true); + if ($decoded !== false && strlen($decoded) === SODIUM_CRYPTO_STREAM_XCHACHA20_KEYBYTES) { + return $decoded; + } + throw new InvalidArgumentException('invalid_base64_frame_encryption_key'); + } + + return hash('sha256', $keyMaterial, true); + } }