chacha20 added

This commit is contained in:
EchoNoch 2026-05-28 21:07:28 +08:00
parent 070fad058f
commit f274a7dd4b
9 changed files with 180 additions and 8 deletions

View File

@ -5,6 +5,10 @@ LOG_LEVEL=debug
# 日志级别;建议值为 debug、info、warning、error当前 MVP 预留该配置用于后续日志工厂。
AUDIT_LOG=runtime/audit.log
# 审计日志文件路径;建议使用 runtime/*.logMVP 会按 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

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
.env
runtime/*

View File

@ -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';

View File

@ -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';

View File

@ -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:

View File

@ -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` |

View File

@ -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

View File

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

View File

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