chacha20 added
This commit is contained in:
parent
070fad058f
commit
f274a7dd4b
@ -5,6 +5,10 @@ LOG_LEVEL=debug
|
|||||||
# 日志级别;建议值为 debug、info、warning、error,当前 MVP 预留该配置用于后续日志工厂。
|
# 日志级别;建议值为 debug、info、warning、error,当前 MVP 预留该配置用于后续日志工厂。
|
||||||
AUDIT_LOG=runtime/audit.log
|
AUDIT_LOG=runtime/audit.log
|
||||||
# 审计日志文件路径;建议使用 runtime/*.log,MVP 会按 JSON Lines 格式追加写入。
|
# 审计日志文件路径;建议使用 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]
|
[client-agent]
|
||||||
NODE_ID=client-01
|
NODE_ID=client-01
|
||||||
|
|||||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
.env
|
||||||
|
runtime/*
|
||||||
@ -4,12 +4,17 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use LayLink\Agent\AgentClient;
|
use LayLink\Agent\AgentClient;
|
||||||
|
use LayLink\Protocol\FrameCodec;
|
||||||
use LayLink\Util\Env;
|
use LayLink\Util\Env;
|
||||||
use Workerman\Worker;
|
use Workerman\Worker;
|
||||||
|
|
||||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||||
|
|
||||||
Env::load(dirname(__DIR__) . '/.env');
|
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::$logFile = dirname(__DIR__) . '/runtime/workerman.log';
|
||||||
Worker::$pidFile = dirname(__DIR__) . '/runtime/client-agent.pid';
|
Worker::$pidFile = dirname(__DIR__) . '/runtime/client-agent.pid';
|
||||||
|
|
||||||
|
|||||||
@ -4,12 +4,17 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use LayLink\Server\PopServer;
|
use LayLink\Server\PopServer;
|
||||||
|
use LayLink\Protocol\FrameCodec;
|
||||||
use LayLink\Util\Env;
|
use LayLink\Util\Env;
|
||||||
use Workerman\Worker;
|
use Workerman\Worker;
|
||||||
|
|
||||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||||
|
|
||||||
Env::load(dirname(__DIR__) . '/.env');
|
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::$logFile = dirname(__DIR__) . '/runtime/workerman.log';
|
||||||
Worker::$pidFile = dirname(__DIR__) . '/runtime/pop-server.pid';
|
Worker::$pidFile = dirname(__DIR__) . '/runtime/pop-server.pid';
|
||||||
|
|
||||||
|
|||||||
10
contract.md
10
contract.md
@ -141,6 +141,16 @@ Completed in this checkpoint:
|
|||||||
* `bin/border-agent.php`
|
* `bin/border-agent.php`
|
||||||
* sample border node and border policy docs
|
* sample border node and border policy docs
|
||||||
* Verified POP now starts with only `laylink-pop-agent-listener`.
|
* 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:
|
Known MVP limitations:
|
||||||
|
|
||||||
|
|||||||
25
readme.md
25
readme.md
@ -27,6 +27,29 @@ cp .env.example .env
|
|||||||
|
|
||||||
`.env.example` 中的 `[config]`、`[client-agent]`、`[pop-server]` 是阅读分组标题,当前加载器会忽略这些标题,只读取 `KEY=value` 配置行。
|
`.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
|
||||||
|
|
||||||
POP Server 是控制面和转发入口。
|
POP Server 是控制面和转发入口。
|
||||||
@ -61,6 +84,8 @@ LOG_LEVEL=debug
|
|||||||
| 变量 | 作用 | 常见值 |
|
| 变量 | 作用 | 常见值 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `APP_ENV` | 当前运行环境。开发时使用 `dev`,生产可使用 `prod`。 | `dev`、`test`、`prod` |
|
| `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_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` |
|
| `POP_ALLOWED_AGENT_TRANSPORTS` | POP Server 允许 Agent 使用的底层传输协议,逗号分隔。Agent 认证时会上报自己的选择,不在列表内会被拒绝。 | `tcp`、`tcp,kcp`、`tcp,udp,kcp` |
|
||||||
| `AUDIT_LOG` | 审计日志路径。MVP 使用 JSON Lines 追加写入。 | `runtime/audit.log` |
|
| `AUDIT_LOG` | 审计日志路径。MVP 使用 JSON Lines 追加写入。 | `runtime/audit.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] 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] stopping
|
||||||
2026-05-28 12:08:11 pid:489103 Workerman[pop-server.php] has been stopped
|
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
|
||||||
|
|||||||
@ -167,12 +167,6 @@ final class AgentClient
|
|||||||
|
|
||||||
private function handleInitialRequest(TcpConnection $connection, string $data): void
|
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;
|
$buffer = ($this->initialBuffers[$connection->id] ?? '') . $data;
|
||||||
$this->initialBuffers[$connection->id] = $buffer;
|
$this->initialBuffers[$connection->id] = $buffer;
|
||||||
|
|
||||||
@ -428,6 +422,11 @@ final class AgentClient
|
|||||||
|
|
||||||
private function startPopSession(TcpConnection $connection, array $request, string $payloadBytes, string $ingressProtocol): void
|
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();
|
$sessionId = Uuid::v4();
|
||||||
$this->connectionSessionIds[$connection->id] = $sessionId;
|
$this->connectionSessionIds[$connection->id] = $sessionId;
|
||||||
$this->clients[$sessionId] = $connection;
|
$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
|
private function onSocks5UdpMessage(UdpConnection $connection, string $data): void
|
||||||
{
|
{
|
||||||
if (!$this->authenticated || $this->pop === null || strlen($data) < 10) {
|
if (!$this->authenticated || $this->pop === null || strlen($data) < 10) {
|
||||||
|
|||||||
@ -9,6 +9,34 @@ use InvalidArgumentException;
|
|||||||
final class FrameCodec
|
final class FrameCodec
|
||||||
{
|
{
|
||||||
public const MAX_FRAME_LENGTH = 16 * 1024 * 1024;
|
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
|
public static function encode(Frame $frame): string
|
||||||
{
|
{
|
||||||
@ -17,11 +45,14 @@ final class FrameCodec
|
|||||||
throw new InvalidArgumentException('invalid_frame_payload');
|
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);
|
$data = json_decode($json, true);
|
||||||
if (!is_array($data) || !isset($data['type']) || !is_string($data['type'])) {
|
if (!is_array($data) || !isset($data['type']) || !is_string($data['type'])) {
|
||||||
throw new InvalidArgumentException('invalid_frame');
|
throw new InvalidArgumentException('invalid_frame');
|
||||||
@ -44,4 +75,56 @@ final class FrameCodec
|
|||||||
(int)($data['version'] ?? 1),
|
(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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user