mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-05-07 10:14:35 +00:00
feat(credential): implement credential peer auth and trust propagation (#1968)
- add credential manager and RPC/CLI for generate/list/revoke - support credential-based Noise authentication and revocation handling - propagate trusted credential metadata through OSPF route sync - classify direct peers by auth level in session maintenance - normalize sender credential flag for legacy non-secure compatibility - add unit/integration tests for credential join, relay and revocation
This commit is contained in:
@@ -0,0 +1,724 @@
|
|||||||
|
# 临时凭据(Credential)系统实现计划
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
EasyTier 的 secure mode 已实现 Noise XX 握手 + X25519 静态公钥认证。当前节点通过 `network_secret` 双向确认身份。用户需要一种"临时凭据"机制:
|
||||||
|
|
||||||
|
- **管理节点**(任何持有 network_secret 的节点)可为当前网络生成凭据
|
||||||
|
- **新节点**可使用凭据代替 `network_secret` 加入网络
|
||||||
|
- **管理节点**可撤销凭据
|
||||||
|
- **撤销后**,使用该凭据接入的节点被全网踢出
|
||||||
|
|
||||||
|
**核心设计**:凭据 = X25519 密钥对。完全复用现有 Noise `Noise_XX_25519_ChaChaPoly_SHA256` 握手流程,无需修改握手消息格式。通过 OSPF 路由同步传播可信公钥列表,撤销时全网自然断开。
|
||||||
|
|
||||||
|
## 整体架构
|
||||||
|
|
||||||
|
```
|
||||||
|
凭据 = X25519 密钥对
|
||||||
|
- 管理节点生成密钥对,将公钥加入可信列表
|
||||||
|
- 临时节点持有私钥,用作 Noise static key
|
||||||
|
- 全网通过 OSPF 路由同步可信公钥列表
|
||||||
|
|
||||||
|
管理节点 (持有 network_secret):
|
||||||
|
1. generate_credential() → 生成 X25519 密钥对
|
||||||
|
2. 公钥记入 trusted_credential_pubkeys → 随 RoutePeerInfo 通过 OSPF 传播
|
||||||
|
3. revoke → 从 trusted 列表移除 → OSPF 同步 → 全网感知
|
||||||
|
|
||||||
|
临时节点 (持有凭据私钥):
|
||||||
|
1. 使用凭据私钥作为 SecureModeConfig.local_private_key
|
||||||
|
2. Noise 握手完全走现有流程(XX 模式交换 static pubkey)
|
||||||
|
3. 不持有 network_secret,secret_proof 验证会失败,但公钥在可信列表中即可
|
||||||
|
4. RoutePeerInfo.noise_static_pubkey 自然携带凭据公钥
|
||||||
|
|
||||||
|
校验逻辑(每个节点在路由同步时执行):
|
||||||
|
1. 从全网 RoutePeerInfo 中收集管理节点的 trusted_credential_pubkeys(取并集)
|
||||||
|
**安全约束: 仅信任 secure_auth_level=NetworkSecretConfirmed 的节点发布的列表**
|
||||||
|
临时节点(CredentialAuthenticated)发布的 trusted_credential_pubkeys 必须被忽略
|
||||||
|
2. 对每个 peer,如果其 secure_auth_level < NetworkSecretConfirmed:
|
||||||
|
- 检查其 noise_static_pubkey 是否在可信公钥集合中
|
||||||
|
- 不在 → 从路由表移除 → 断开连接
|
||||||
|
```
|
||||||
|
|
||||||
|
## 详细设计
|
||||||
|
|
||||||
|
### Step 1: Protobuf 定义
|
||||||
|
|
||||||
|
**文件: `easytier/src/proto/peer_rpc.proto`**
|
||||||
|
|
||||||
|
在 `RoutePeerInfo` 新增字段(利用已有 `noise_static_pubkey` 字段 #18):
|
||||||
|
```protobuf
|
||||||
|
message TrustedCredentialPubkey {
|
||||||
|
bytes pubkey = 1; // X25519 公钥 (32 bytes)
|
||||||
|
repeated string groups = 2; // 该凭据所属的 ACL group(管理节点声明,无需 proof)
|
||||||
|
bool allow_relay = 3; // 是否允许该临时节点提供 peer relay 能力
|
||||||
|
int64 expiry_unix = 4; // 必选:过期时间(Unix timestamp),过期后自动失效
|
||||||
|
repeated string allowed_proxy_cidrs = 5; // 允许该临时节点声明的 proxy_cidrs 范围
|
||||||
|
}
|
||||||
|
|
||||||
|
message RoutePeerInfo {
|
||||||
|
// ... existing fields 1-18 ...
|
||||||
|
// 管理节点发布的可信凭据公钥列表(含 group 关联)
|
||||||
|
repeated TrustedCredentialPubkey trusted_credential_pubkeys = 19;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
临时节点无需新字段——其 `noise_static_pubkey`(字段 18)已经在 OSPF 中传播,只需在校验端判断该公钥是否在可信列表中。
|
||||||
|
|
||||||
|
新增 `SecureAuthLevel` 枚举值:
|
||||||
|
```protobuf
|
||||||
|
enum SecureAuthLevel {
|
||||||
|
None = 0;
|
||||||
|
EncryptedUnauthenticated = 1;
|
||||||
|
SharedNodePubkeyVerified = 2;
|
||||||
|
NetworkSecretConfirmed = 3;
|
||||||
|
CredentialAuthenticated = 4; // 新增:凭据公钥已验证
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**文件: `easytier/src/proto/api_instance.proto`**
|
||||||
|
|
||||||
|
新增凭据管理 RPC:
|
||||||
|
```protobuf
|
||||||
|
message GenerateCredentialRequest {
|
||||||
|
repeated string groups = 1; // 可选: 凭据关联的 ACL group
|
||||||
|
bool allow_relay = 2; // 可选: 是否允许该临时节点提供 peer relay
|
||||||
|
repeated string allowed_proxy_cidrs = 3; // 可选: 限制可声明的 proxy_cidrs
|
||||||
|
int64 ttl_seconds = 4; // 必选: 凭据有效期(秒)
|
||||||
|
}
|
||||||
|
message GenerateCredentialResponse {
|
||||||
|
string credential_id = 1; // 公钥的 base64
|
||||||
|
string credential_secret = 2; // 私钥的 base64
|
||||||
|
}
|
||||||
|
message RevokeCredentialRequest { string credential_id = 1; }
|
||||||
|
message RevokeCredentialResponse { bool success = 1; }
|
||||||
|
message ListCredentialsRequest {}
|
||||||
|
message CredentialInfo {
|
||||||
|
string credential_id = 1; // 公钥 base64
|
||||||
|
google.protobuf.Timestamp created_at = 2;
|
||||||
|
}
|
||||||
|
message ListCredentialsResponse { repeated CredentialInfo credentials = 1; }
|
||||||
|
|
||||||
|
service CredentialManageRpc {
|
||||||
|
rpc GenerateCredential(GenerateCredentialRequest) returns (GenerateCredentialResponse);
|
||||||
|
rpc RevokeCredential(RevokeCredentialRequest) returns (RevokeCredentialResponse);
|
||||||
|
rpc ListCredentials(ListCredentialsRequest) returns (ListCredentialsResponse);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: 凭据管理模块
|
||||||
|
|
||||||
|
**新文件: `easytier/src/peers/credential_manager.rs`**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use x25519_dalek::{StaticSecret, PublicKey};
|
||||||
|
|
||||||
|
pub struct CredentialManager {
|
||||||
|
// 本节点管理的可信凭据
|
||||||
|
credentials: DashMap<String, CredentialEntry>, // credential_id (pubkey base64) -> entry
|
||||||
|
storage_path: Option<PathBuf>, // 可选: 凭据 JSON 文件路径
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CredentialEntry {
|
||||||
|
pubkey_bytes: [u8; 32],
|
||||||
|
groups: Vec<String>, // 关联的 ACL group(管理节点声明)
|
||||||
|
allow_relay: bool, // 是否允许 relay
|
||||||
|
allowed_proxy_cidrs: Vec<String>, // 允许声明的 proxy_cidrs 范围
|
||||||
|
expiry: SystemTime, // 过期时间(必选)
|
||||||
|
created_at: SystemTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CredentialManager {
|
||||||
|
/// 生成新凭据(含 group 关联)
|
||||||
|
/// 返回 (credential_id=公钥base64, credential_secret=私钥base64)
|
||||||
|
pub fn generate_credential(&self, groups: Vec<String>, allow_relay: bool, expiry: SystemTime) -> (String, String) {
|
||||||
|
let private = StaticSecret::random_from_rng(OsRng);
|
||||||
|
let public = PublicKey::from(&private);
|
||||||
|
let id = BASE64_STANDARD.encode(public.as_bytes());
|
||||||
|
let secret = BASE64_STANDARD.encode(private.as_bytes());
|
||||||
|
self.credentials.insert(id.clone(), CredentialEntry {
|
||||||
|
pubkey_bytes: *public.as_bytes(),
|
||||||
|
groups,
|
||||||
|
allow_relay,
|
||||||
|
expiry, // 由调用方传入
|
||||||
|
created_at: SystemTime::now(),
|
||||||
|
});
|
||||||
|
self.save_to_disk(); // 持久化
|
||||||
|
(id, secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 撤销凭据
|
||||||
|
pub fn revoke_credential(&self, credential_id: &str) -> bool;
|
||||||
|
|
||||||
|
/// 获取可信凭据列表(用于 RoutePeerInfo.trusted_credential_pubkeys)
|
||||||
|
pub fn get_trusted_pubkeys(&self) -> Vec<TrustedCredentialPubkey>;
|
||||||
|
|
||||||
|
/// 列出所有凭据
|
||||||
|
pub fn list_credentials(&self) -> Vec<CredentialInfo>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Noise 握手适配(最小改动)
|
||||||
|
|
||||||
|
**文件: `easytier/src/peers/peer_conn.rs`**
|
||||||
|
|
||||||
|
临时节点的握手流程**完全不需要修改**,因为:
|
||||||
|
- 临时节点配置 `SecureModeConfig { enabled: true, local_private_key: 凭据私钥, local_public_key: 凭据公钥 }`
|
||||||
|
- `get_keypair()` (line 434) 自然返回凭据密钥对
|
||||||
|
- Noise XX 握手正常交换 static pubkey
|
||||||
|
- 唯一区别:`secret_proof_32` 验证会失败(临时节点没有 network_secret)
|
||||||
|
|
||||||
|
需要修改 `do_noise_handshake_as_server()` (line 934):
|
||||||
|
- **当前行为**: `secret_proof` 验证失败 → 返回错误断开连接 (line 1059)
|
||||||
|
- **修改为**: `secret_proof` 验证失败时,不立即断开,而是将 `secure_auth_level` 保持为 `EncryptedUnauthenticated`
|
||||||
|
- 后续由 OSPF 路由同步阶段决定该 peer 是否可信(公钥是否在 trusted 列表中)
|
||||||
|
|
||||||
|
同样修改 `do_noise_handshake_as_client()` (line 680):
|
||||||
|
- 当临时节点连接管理节点时,`secret_proof` 验证失败不应报错
|
||||||
|
- 临时节点可以通过 `pinned_remote_pubkey` 或不验证来处理
|
||||||
|
|
||||||
|
**NoiseHandshakeResult** 新增:
|
||||||
|
```rust
|
||||||
|
// 标记此连接使用了凭据而非 network_secret
|
||||||
|
is_credential_conn: bool,
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: RoutePeerInfo 传播凭据信息
|
||||||
|
|
||||||
|
**文件: `easytier/src/peers/peer_ospf_route.rs`**
|
||||||
|
|
||||||
|
修改 `RoutePeerInfo::new_updated_self()` (line 164):
|
||||||
|
- 管理节点(持有 network_secret): 从 `CredentialManager.get_trusted_pubkeys()` 获取列表,填入 `trusted_credential_pubkeys`
|
||||||
|
- 临时节点: **不填写 `trusted_credential_pubkeys`**(该字段留空),即使收到其他管理节点传播的列表也不转发
|
||||||
|
- 实现方式: 在 `new_updated_self()` 中检查节点身份,临时节点跳过 trusted_credential_pubkeys 填充
|
||||||
|
- 临时节点: 无需额外操作,`noise_static_pubkey` 已自然包含凭据公钥
|
||||||
|
|
||||||
|
### Step 5: 全网校验与自动踢出(核心逻辑)
|
||||||
|
|
||||||
|
**文件: `easytier/src/peers/peer_ospf_route.rs`**
|
||||||
|
|
||||||
|
在 `SyncedRouteInfo` 中新增:
|
||||||
|
```rust
|
||||||
|
// 从全网管理节点汇总的可信凭据公钥集合
|
||||||
|
trusted_credential_pubkeys: DashSet<Vec<u8>>, // pubkey bytes
|
||||||
|
```
|
||||||
|
|
||||||
|
新增校验方法(类似 `verify_and_update_group_trusts` line 743):
|
||||||
|
```rust
|
||||||
|
fn verify_credential_peers(&self, peer_infos: &[RoutePeerInfo]) {
|
||||||
|
// 1. 收集管理节点的 trusted_credential_pubkeys(取并集)
|
||||||
|
// **安全约束: 仅信任 secret_digest 与本网络匹配的节点(即持有 network_secret 的管理节点)**
|
||||||
|
// 临时节点的 trusted_credential_pubkeys 直接忽略,防止恶意临时节点自我授权
|
||||||
|
let mut all_trusted = HashSet::new();
|
||||||
|
for info in peer_infos {
|
||||||
|
if self.is_peer_secret_verified(info.peer_id) {
|
||||||
|
// 该 peer 通过了 network_secret 双向确认,是合法管理节点
|
||||||
|
for tc in &info.trusted_credential_pubkeys {
|
||||||
|
all_trusted.insert(tc.pubkey.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// else: 该 peer 未通过 network_secret 确认(含临时节点),忽略其 trusted 列表
|
||||||
|
}
|
||||||
|
self.trusted_credential_pubkeys = all_trusted;
|
||||||
|
|
||||||
|
// 2. 检查所有 peer 的凭据状态
|
||||||
|
for info in peer_infos {
|
||||||
|
if !self.is_peer_secret_verified(info.peer_id)
|
||||||
|
&& !info.noise_static_pubkey.is_empty()
|
||||||
|
{
|
||||||
|
if !self.trusted_credential_pubkeys.contains(&info.noise_static_pubkey) {
|
||||||
|
// 该 peer 既不持有 network_secret,其公钥也不在可信列表中
|
||||||
|
// → 标记为不可信,后续从路由表移除
|
||||||
|
self.mark_peer_untrusted(info.peer_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
在 `do_sync_route_info()` (line 2614) 中调用此校验。
|
||||||
|
|
||||||
|
在路由表构建中(`update_route_table_and_cached_local_conn_bitmap()`):
|
||||||
|
- 不可信 peer 不加入路由图
|
||||||
|
- 已连接的不可信 peer 调用 `PeerMap::close_peer()` 断开
|
||||||
|
|
||||||
|
**判断 peer 是否持有 network_secret**: 利用现有 `secret_digest` 字段。管理节点的 `RoutePeerInfo` 中 `secret_digest` 与本节点匹配,说明双方持有相同的 network_secret。
|
||||||
|
|
||||||
|
### Step 6: GlobalCtx / Config 集成
|
||||||
|
|
||||||
|
**文件: `easytier/src/common/global_ctx.rs`**
|
||||||
|
|
||||||
|
在 `GlobalCtx` 新增:
|
||||||
|
```rust
|
||||||
|
credential_manager: Arc<CredentialManager>, // 所有节点都持有,管理节点用于生成/撤销
|
||||||
|
```
|
||||||
|
|
||||||
|
**文件: `easytier/src/common/global_ctx.rs` - `GlobalCtxEvent`**
|
||||||
|
|
||||||
|
新增:
|
||||||
|
```rust
|
||||||
|
CredentialChanged, // 触发 OSPF 立即同步
|
||||||
|
```
|
||||||
|
|
||||||
|
**文件: `easytier/src/common/config.rs`**
|
||||||
|
|
||||||
|
临时节点的配置方式: 直接使用凭据私钥作为 `SecureModeConfig.local_private_key`。
|
||||||
|
可在 `TomlConfigLoader` 中新增便捷字段或 CLI 参数:
|
||||||
|
- `--credential <私钥base64>`: 临时节点使用凭据私钥加入网络
|
||||||
|
- `--credential-file <path>`: 管理节点指定凭据存储 JSON 文件路径
|
||||||
|
|
||||||
|
### Step 7: RPC 服务 + CLI
|
||||||
|
|
||||||
|
**文件: `easytier/src/peers/rpc_service.rs`**
|
||||||
|
|
||||||
|
实现 `CredentialManageRpc`,参考 `PeerManagerRpcService` 模式。
|
||||||
|
|
||||||
|
**CLI** (`easytier-cli`):
|
||||||
|
```
|
||||||
|
easytier-cli credential generate
|
||||||
|
输出: credential_id=<公钥base64> credential_secret=<私钥base64>
|
||||||
|
|
||||||
|
easytier-cli credential revoke <credential_id>
|
||||||
|
easytier-cli credential list
|
||||||
|
```
|
||||||
|
|
||||||
|
**临时节点启动**:
|
||||||
|
```bash
|
||||||
|
# 方式1: 直接传入凭据私钥
|
||||||
|
easytier-core --network-name test \
|
||||||
|
--secure-mode \
|
||||||
|
--credential <私钥base64> \
|
||||||
|
--peers tcp://管理节点:11010
|
||||||
|
|
||||||
|
# 内部实现: 将凭据私钥设为 SecureModeConfig.local_private_key
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 8: 连接时验证(握手后快速拒绝,必选)
|
||||||
|
|
||||||
|
在 `do_noise_handshake_as_server()` 完成后,**必须**进行快速检查:
|
||||||
|
- 如果对端 `secret_proof` 验证失败(非管理节点),且对端 `noise_static_pubkey` 不在本节点已知的 `trusted_credential_pubkeys` 中
|
||||||
|
- 立即断开连接
|
||||||
|
|
||||||
|
这是**必选的安全措施**(非可选优化)。因为 Step 3 放宽了 secret_proof 失败的处理,如果不做快速拒绝,任何随机节点都能与管理节点建立加密连接并持有,浪费资源。
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// 在 handshake 完成后
|
||||||
|
if !secret_proof_verified {
|
||||||
|
let remote_pubkey = handshake_result.remote_static_pubkey;
|
||||||
|
if !self.global_ctx.credential_manager.is_pubkey_trusted(&remote_pubkey) {
|
||||||
|
return Err(Error::AuthError("unknown credential".to_string()));
|
||||||
|
}
|
||||||
|
// 公钥在 trusted 列表中 → 允许连接,标记为 CredentialAuthenticated
|
||||||
|
handshake_result.secure_auth_level = SecureAuthLevel::CredentialAuthenticated;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 关键文件清单
|
||||||
|
|
||||||
|
| 文件 | 修改内容 |
|
||||||
|
|------|----------|
|
||||||
|
| `easytier/src/proto/peer_rpc.proto` | `RoutePeerInfo` 加 `trusted_credential_pubkeys`; `SecureAuthLevel` 加 `CredentialAuthenticated` |
|
||||||
|
| `easytier/src/proto/api_instance.proto` | 新增 `CredentialManageRpc` 服务及消息定义 |
|
||||||
|
| `easytier/src/peers/credential_manager.rs` | **新文件** — 凭据管理器(密钥对生成/撤销/列表) |
|
||||||
|
| `easytier/src/peers/mod.rs` | 导出 credential_manager |
|
||||||
|
| `easytier/src/peers/peer_ospf_route.rs` | `new_updated_self()` 填 trusted_pubkeys; 新增 `verify_credential_peers()`; 路由表过滤 |
|
||||||
|
| `easytier/src/peers/peer_conn.rs` | `do_noise_handshake_as_server()` 放宽 secret_proof 失败为非致命; 可选握手阶段快速拒绝 |
|
||||||
|
| `easytier/src/peers/peer_manager.rs` | 集成 CredentialManager; 不可信 peer 断连逻辑 |
|
||||||
|
| `easytier/src/common/global_ctx.rs` | 持有 CredentialManager; 新增 CredentialChanged 事件 |
|
||||||
|
| `easytier/src/common/config.rs` | 新增 `--credential` 参数处理 |
|
||||||
|
| `easytier/src/peers/rpc_service.rs` | 实现 CredentialManageRpc |
|
||||||
|
| `easytier/src/proto/common.rs` | SecureModeConfig 可选: credential 模式识别 |
|
||||||
|
|
||||||
|
## 复用现有机制
|
||||||
|
|
||||||
|
| 现有机制 | 路径 | 复用方式 |
|
||||||
|
|----------|------|----------|
|
||||||
|
| Noise XX 握手 | `peer_conn.rs:680,934` | 临时节点直接使用凭据密钥对走完整 Noise 流程 |
|
||||||
|
| `SecureModeConfig` | `proto/common.rs:367` | 临时节点的凭据私钥直接设为 local_private_key |
|
||||||
|
| `noise_static_pubkey` | `RoutePeerInfo` 字段 18 | 临时节点的凭据公钥已在 OSPF 中传播 |
|
||||||
|
| `verify_and_update_group_trusts()` | `peer_ospf_route.rs:743` | 凭据校验逻辑参考此模式 |
|
||||||
|
| `PeerMap::close_peer()` | `peer_map.rs:317` | 断开不可信 peer |
|
||||||
|
| OSPF 路由同步 | `SyncRouteInfoRequest` | 可信公钥列表随 RoutePeerInfo 自然传播 |
|
||||||
|
| `PeerManagerRpcService` | `rpc_service.rs:24` | RPC 服务实现模式 |
|
||||||
|
| `GlobalCtxEvent` | `global_ctx.rs:32` | 新增事件触发同步 |
|
||||||
|
|
||||||
|
## 验证方案
|
||||||
|
|
||||||
|
1. **单元测试**:
|
||||||
|
- `credential_manager.rs`: 密钥对生成、撤销、列表
|
||||||
|
- `peer_conn.rs`: 凭据节点 Noise 握手成功(无 network_secret)
|
||||||
|
|
||||||
|
2. **集成测试** (参考 `tests/three_node.rs`):
|
||||||
|
- 3 节点: A + B (管理节点, network_secret) + C (临时节点, credential)
|
||||||
|
- A 生成凭据(groups=["guest"])→ C 使用凭据连接 → 验证 C 加入路由表、可达
|
||||||
|
- 验证 C 的 ACL group 为 "guest",配置 group ACL 规则后生效
|
||||||
|
- A 撤销凭据 → 等待 OSPF 同步 (~1-3s) → 验证 C 被 A 和 B 断开
|
||||||
|
- C 尝试重连 → 验证握手阶段被拒
|
||||||
|
|
||||||
|
3. **手动测试**:
|
||||||
|
```bash
|
||||||
|
# A: 管理节点
|
||||||
|
easytier-core -n test -s secret --secure-mode --listeners tcp://0.0.0.0:11010
|
||||||
|
easytier-cli credential generate # → credential_id + credential_secret
|
||||||
|
|
||||||
|
# C: 临时节点
|
||||||
|
easytier-core -n test --secure-mode --credential <私钥base64> --peers tcp://A:11010
|
||||||
|
|
||||||
|
# 验证后撤销
|
||||||
|
easytier-cli credential revoke <credential_id>
|
||||||
|
# C 数秒内被踢出
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 9: 临时节点 OSPF 路由限制
|
||||||
|
|
||||||
|
**约束**: 临时节点传播的路由信息不可信,需严格限制。
|
||||||
|
|
||||||
|
#### 9a. 管理节点不主动发起到临时节点的 OSPF session
|
||||||
|
|
||||||
|
**核心原则**: OSPF `maintain_sessions()` 构建最小生成树时,只在管理节点之间选择 initiator,不将临时节点纳入 `dst_peer_id_to_initiate`。但管理节点**被动接受**临时节点发起的 session。
|
||||||
|
|
||||||
|
**文件: `easytier/src/peers/peer_ospf_route.rs`**
|
||||||
|
|
||||||
|
修改 `maintain_sessions()` (line 2485):
|
||||||
|
- 在构建 `dst_peer_id_to_initiate` 候选列表时,过滤掉临时节点
|
||||||
|
- 管理节点之间的 MST 不受影响
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// 在 maintain_sessions() 中,构建 initiator 候选时过滤临时节点
|
||||||
|
let peers: Vec<PeerId> = peers.into_iter().filter(|peer_id| {
|
||||||
|
// 只主动发起到管理节点的 session,不主动连临时节点
|
||||||
|
!self.is_credential_peer(*peer_id)
|
||||||
|
}).collect();
|
||||||
|
```
|
||||||
|
|
||||||
|
- **临时节点自身**: 在 `maintain_sessions()` 中只将管理节点作为 initiator 候选,跳过其他临时节点
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// 临时节点侧: 只主动连管理节点
|
||||||
|
if self.is_credential_node() {
|
||||||
|
let peers: Vec<PeerId> = peers.into_iter().filter(|peer_id| {
|
||||||
|
!self.is_credential_peer(*peer_id) // 只连管理节点
|
||||||
|
}).collect();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**session 建立方式**:
|
||||||
|
- **管理节点 → 管理节点**: 正常 MST initiator 选择(不变)
|
||||||
|
- **临时节点 → 管理节点**: 临时节点主动发起 session,管理节点被动接受
|
||||||
|
- **临时节点 → 临时节点**: 不建立(双方都过滤掉对方)
|
||||||
|
- **管理节点 → 临时节点**: 不主动发起(不在 initiator 候选中)
|
||||||
|
|
||||||
|
**路由信息传播**: 临时节点通过其主动发起的 session 调用 `sync_route_info` 推送自身 RoutePeerInfo。管理节点在正常 OSPF sync 中将其代理传播给其他管理节点。管理节点也通过该 session 向临时节点推送完整路由表。
|
||||||
|
|
||||||
|
#### 9b. 管理节点只选择性接收临时节点的路由信息
|
||||||
|
|
||||||
|
**文件: `easytier/src/peers/peer_ospf_route.rs`**
|
||||||
|
|
||||||
|
临时节点通过其主动发起的 session 调用 `sync_route_info`,管理节点在处理时需做过滤:
|
||||||
|
|
||||||
|
- 只接收该临时节点**自己的** `RoutePeerInfo`(`route_info.peer_id == dst_peer_id`),丢弃其声称的其他 peer 的路由信息
|
||||||
|
- 对临时节点自身的 RoutePeerInfo,过滤其 `proxy_cidrs`:只保留在 `TrustedCredentialPubkey.allowed_proxy_cidrs` 范围内的网段,移除超出范围的声明
|
||||||
|
- 临时节点的 `foreign_network_infos` 应忽略
|
||||||
|
- 临时节点的 `conn_info`(连接拓扑)**根据 `allow_relay` 标志决定**(见下方)
|
||||||
|
|
||||||
|
修改 `update_peer_infos()` (line 461):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn update_peer_infos(
|
||||||
|
&self, my_peer_id, my_peer_route_id, dst_peer_id,
|
||||||
|
peer_infos, raw_peer_infos,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
let dst_is_credential_peer = self.is_credential_peer(dst_peer_id);
|
||||||
|
|
||||||
|
for (idx, route_info) in peer_infos.iter().enumerate() {
|
||||||
|
// 临时节点只允许传播自己的路由信息
|
||||||
|
if dst_is_credential_peer && route_info.peer_id != dst_peer_id {
|
||||||
|
tracing::debug!(
|
||||||
|
?dst_peer_id, peer_id=?route_info.peer_id,
|
||||||
|
"ignoring route info from credential peer for other peer"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 过滤临时节点的 proxy_cidrs,只保留凭据允许的范围
|
||||||
|
if dst_is_credential_peer {
|
||||||
|
let allowed = self.get_credential_allowed_proxy_cidrs(dst_peer_id);
|
||||||
|
if let Some(allowed_cidrs) = allowed {
|
||||||
|
route_info.proxy_cidrs.retain(|cidr| {
|
||||||
|
allowed_cidrs.iter().any(|a| cidr_is_subset(cidr, a))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ... existing logic ...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
修改 `do_sync_route_info()` (line 2614):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// 在 do_sync_route_info 中
|
||||||
|
let from_is_credential = self.is_credential_peer(from_peer_id);
|
||||||
|
let credential_allows_relay = from_is_credential
|
||||||
|
&& self.is_credential_relay_allowed(from_peer_id);
|
||||||
|
|
||||||
|
if let Some(peer_infos) = &peer_infos {
|
||||||
|
// update_peer_infos 内部会过滤临时节点的非自身信息
|
||||||
|
service_impl.synced_route_info.update_peer_infos(...);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 临时节点的 conn_info: 仅当 allow_relay=true 时接收
|
||||||
|
if let Some(conn_info) = &conn_info {
|
||||||
|
if !from_is_credential || credential_allows_relay {
|
||||||
|
service_impl.synced_route_info.update_conn_info(conn_info);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 临时节点的 foreign_network_infos 始终不接收
|
||||||
|
if let Some(foreign_network) = &foreign_network {
|
||||||
|
if !from_is_credential {
|
||||||
|
service_impl.synced_route_info.update_foreign_network(foreign_network);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**conn_info 处理**:
|
||||||
|
- 临时节点的 `conn_info`: 根据凭据的 `allow_relay` 标志决定是否接收
|
||||||
|
- `allow_relay = true`: 管理节点接收并传播该临时节点的 conn_info,使其参与路由图,可作为 relay 转发数据
|
||||||
|
- `allow_relay = false`(默认): 忽略 conn_info,该临时节点不参与中继(仅作为叶子节点存在于路由图中)
|
||||||
|
- 临时节点的 `foreign_network_infos` 始终忽略
|
||||||
|
|
||||||
|
**`is_credential_relay_allowed()` 实现**:
|
||||||
|
```rust
|
||||||
|
fn is_credential_relay_allowed(&self, peer_id: PeerId) -> bool {
|
||||||
|
// 从全网汇总的 trusted_credential_pubkeys 中查找该 peer 的凭据
|
||||||
|
// 检查对应 TrustedCredentialPubkey.allow_relay 标志
|
||||||
|
let peer_info = self.peer_infos.read();
|
||||||
|
if let Some(info) = peer_info.get(&peer_id) {
|
||||||
|
for tc in &self.all_trusted_credentials {
|
||||||
|
if tc.pubkey == info.noise_static_pubkey {
|
||||||
|
return tc.allow_relay;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**注意**: 即使 `allow_relay=true`,临时节点仍然不能转发握手包(Step 10b 限制不变),因此不会有新节点通过 relay 临时节点接入网络。relay 能力仅用于已建立连接的 peer 之间的数据转发。
|
||||||
|
|
||||||
|
#### 9c. 临时节点的 `RoutePeerInfo` 中的 `trusted_credential_pubkeys` 被忽略
|
||||||
|
|
||||||
|
已在 Step 5 中说明:只信任 `secret_digest` 匹配的管理节点发布的 trusted 列表。
|
||||||
|
|
||||||
|
#### 判断 peer 是否为临时节点的方法
|
||||||
|
|
||||||
|
在 `SyncedRouteInfo` / `PeerRouteServiceImpl` 中新增:
|
||||||
|
```rust
|
||||||
|
fn is_credential_peer(&self, peer_id: PeerId) -> bool {
|
||||||
|
// 方法: 检查该 peer 的 RoutePeerInfo
|
||||||
|
// 1. 如果 peer 的 noise_static_pubkey 在 trusted_credential_pubkeys 中 → 是临时节点
|
||||||
|
// 2. 如果 peer 通过了 network_secret 确认 (secret_digest 匹配) → 是管理节点
|
||||||
|
// 3. 在 peer_conn 握手后,可以记录 secure_auth_level 到连接信息中
|
||||||
|
let peer_info = self.synced_route_info.peer_infos.read();
|
||||||
|
if let Some(info) = peer_info.get(&peer_id) {
|
||||||
|
if !info.noise_static_pubkey.is_empty()
|
||||||
|
&& self.trusted_credential_pubkeys.contains(&info.noise_static_pubkey) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
对于直连 peer,也可以在握手阶段直接记录 `secure_auth_level`,用于快速判断。
|
||||||
|
|
||||||
|
### Step 10: 禁止通过临时节点接入网络
|
||||||
|
|
||||||
|
**约束**: 不得有新节点(无论是否持有 network_secret)通过临时节点的 listener 接入网络。但允许通过管理节点中继后建立 P2P 连接。
|
||||||
|
|
||||||
|
#### 10a. 临时节点天然无法接受新节点接入(无需额外代码)
|
||||||
|
|
||||||
|
临时节点作为 listener 时,新节点的连接会**自然失败**,因为:
|
||||||
|
1. 临时节点没有 `network_secret`,无法验证对端的 `secret_proof` → 无法确认对端是管理节点
|
||||||
|
2. 临时节点不发布 `trusted_credential_pubkeys` → 对端公钥不在可信列表中
|
||||||
|
3. 对端也无法验证临时节点的 `secret_proof`(临时节点没有 network_secret)
|
||||||
|
|
||||||
|
因此 **不需要在 `add_tunnel_as_server()` 中添加显式拦截逻辑**。已有的 Noise 握手 + 凭据校验机制已足够阻止新节点通过临时节点接入。
|
||||||
|
|
||||||
|
**例外**: 已知的管理节点可以连接到临时节点(如 P2P hole punch 场景),因为管理节点的公钥已通过 OSPF 同步被临时节点知晓,握手可以成功。
|
||||||
|
|
||||||
|
#### 10b. 临时节点不转发来自未知 peer 的连接请求
|
||||||
|
|
||||||
|
**文件: `easytier/src/peers/peer_manager.rs`**
|
||||||
|
|
||||||
|
在 packet forwarding 路径 (line 718-766) 中:
|
||||||
|
- 临时节点不应转发 `HandShake` / `NoiseHandshakeMsg*` 类型的包
|
||||||
|
- 这防止新节点通过临时节点的中继接入网络
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// 在 peer_recv 循环的 forward 分支中
|
||||||
|
if to_peer_id != my_peer_id {
|
||||||
|
// 临时节点不转发握手包(阻止新节点通过临时节点接入)
|
||||||
|
if is_credential_node && (
|
||||||
|
hdr.packet_type == PacketType::HandShake as u8
|
||||||
|
|| hdr.packet_type == PacketType::NoiseHandshakeMsg1 as u8
|
||||||
|
|| hdr.packet_type == PacketType::NoiseHandshakeMsg2 as u8
|
||||||
|
|| hdr.packet_type == PacketType::NoiseHandshakeMsg3 as u8
|
||||||
|
) {
|
||||||
|
tracing::debug!("credential node dropping forwarded handshake packet");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// ... existing forward logic ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 10c. P2P 连接通过管理节点中继仍然允许
|
||||||
|
|
||||||
|
P2P hole punch 的流程:
|
||||||
|
1. 两个节点通过管理节点交换打洞信息(RPC)
|
||||||
|
2. 建立直接 P2P tunnel
|
||||||
|
3. 在 P2P tunnel 上握手
|
||||||
|
|
||||||
|
这个流程不受影响,因为:
|
||||||
|
- 打洞信息交换通过管理节点中继(RPC),不经过临时节点
|
||||||
|
- P2P tunnel 建立后的握手是直连,不通过临时节点的 listener
|
||||||
|
- `is_directly_connected=false` 的连接(hole punch 结果)可以被临时节点接受
|
||||||
|
|
||||||
|
**设计思路**: 将凭据映射为 ACL Group,复用现有的 group-based ACL 规则系统。
|
||||||
|
|
||||||
|
现有 ACL 系统已支持基于 group 的规则匹配:
|
||||||
|
- `Rule.source_groups` / `Rule.destination_groups` (acl.proto:72-73)
|
||||||
|
- `PeerGroupInfo` 通过 HMAC proof 验证 peer 所属 group (peer_rpc.rs:8-38)
|
||||||
|
- `verify_and_update_group_trusts()` 在 OSPF 同步时更新 group trust map (peer_ospf_route.rs:743)
|
||||||
|
- `get_peer_groups()` 返回 peer 所属的 group 列表,用于 ACL 匹配 (peer_ospf_route.rs:2287)
|
||||||
|
|
||||||
|
**方案**: 生成凭据时,为每个凭据创建一个隐式 ACL Group。
|
||||||
|
|
||||||
|
1. **凭据生成时**: 管理节点为凭据创建一个关联的 group:
|
||||||
|
- group_name = `"credential:<credential_id>"` 或用户自定义名称
|
||||||
|
- group_secret = 由 credential_secret 派生的密钥
|
||||||
|
- 可选:指定凭据所属的 group_name(批量管理,如 `"guest"`, `"contractor"`)
|
||||||
|
|
||||||
|
2. **临时节点加入时**: 临时节点使用凭据私钥连接。其 group 归属由管理节点在 `TrustedCredentialPubkey.groups` 中声明(无需临时节点自己提供 group proof)。验证节点在 `verify_credential_peers()` 中匹配公钥后,直接将声明的 groups 加入 `group_trust_map`。
|
||||||
|
|
||||||
|
3. **ACL 规则配置**: 管理员可配置基于 group 的 ACL 规则:
|
||||||
|
```toml
|
||||||
|
# 示例配置: 限制 "guest" group 只能访问特定子网
|
||||||
|
[[acl.acl_v1.chains]]
|
||||||
|
name = "inbound"
|
||||||
|
chain_type = "Inbound"
|
||||||
|
default_action = "Allow"
|
||||||
|
|
||||||
|
[[acl.acl_v1.chains.rules]]
|
||||||
|
name = "restrict_guest"
|
||||||
|
source_groups = ["guest"]
|
||||||
|
destination_ips = ["10.0.0.0/24"]
|
||||||
|
action = "Drop"
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **管理节点发布 group 信息**:
|
||||||
|
- 在 `RoutePeerInfo.trusted_credential_pubkeys` 中传播可信公钥时,同时包含关联的 group 信息
|
||||||
|
- 扩展 proto:
|
||||||
|
(使用 Step 1 中定义的 `TrustedCredentialPubkey`,group 归属由管理节点声明,无需 proof 验证)
|
||||||
|
- 替换 `repeated bytes trusted_credential_pubkeys` 为 `repeated TrustedCredentialPubkey trusted_credential_pubkeys`
|
||||||
|
|
||||||
|
5. **校验节点处理**: 在 `verify_credential_peers()` 中:
|
||||||
|
- 验证凭据公钥在可信列表中后
|
||||||
|
- 直接将 `TrustedCredentialPubkey.groups` 中声明的 group 加入 `group_trust_map` / `group_trust_map_cache`(无需验证 group proof,因为管理节点的声明已是可信的)
|
||||||
|
- ACL filter 在处理数据包时自动基于 group 匹配规则
|
||||||
|
|
||||||
|
**API 扩展**:
|
||||||
|
|
||||||
|
生成凭据时可指定 group:
|
||||||
|
```protobuf
|
||||||
|
message GenerateCredentialRequest {
|
||||||
|
repeated string groups = 1; // 可选: 为该凭据关联的 group 名称
|
||||||
|
bool allow_relay = 2; // 可选: 是否允许 relay
|
||||||
|
repeated string allowed_proxy_cidrs = 3; // 可选: 限制可声明的 proxy_cidrs
|
||||||
|
int64 ttl_seconds = 4; // 必选: 凭据有效期(秒)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
CLI:
|
||||||
|
```bash
|
||||||
|
# 生成带 group 的凭据,有效期 24 小时
|
||||||
|
easytier-cli credential generate --groups guest,restricted --ttl 86400
|
||||||
|
|
||||||
|
# 生成允许 relay 的凭据,有效期 7 天
|
||||||
|
easytier-cli credential generate --groups relay-node --allow-relay --ttl 604800
|
||||||
|
|
||||||
|
# 最简用法(默认 group 名为 "credential")
|
||||||
|
easytier-cli credential generate --ttl 3600
|
||||||
|
```
|
||||||
|
|
||||||
|
## 安全审查
|
||||||
|
|
||||||
|
### 已覆盖的安全性
|
||||||
|
|
||||||
|
- **端到端加密**: 数据包在源端加密、目的端解密,relay 节点(含 `allow_relay` 的临时节点)无法看到明文
|
||||||
|
- **临时节点自我授权防护**: 只信任 `secret_digest` 匹配的管理节点发布的 `trusted_credential_pubkeys`
|
||||||
|
- **临时节点路由篡改防护**: 只接收临时节点自身的 RoutePeerInfo,忽略其转发的其他路由
|
||||||
|
- **临时节点网络接入防护**: 临时节点天然无法接受新节点接入(无 network_secret、不发布 trusted 列表)
|
||||||
|
|
||||||
|
### 需要关注的安全问题
|
||||||
|
|
||||||
|
**1. Step 8 握手后快速拒绝应为必选(非可选)**
|
||||||
|
|
||||||
|
当前 Step 8 标记为"可选优化",但实际上是**必须的安全措施**。如果不做快速拒绝:
|
||||||
|
- 任何随机节点(无 credential、无 network_secret)都能完成 Noise 握手(因为 Step 3 放宽了 secret_proof 失败)
|
||||||
|
- 在等待 OSPF 同步验证期间,该节点持有一个有效的加密连接,浪费资源
|
||||||
|
- **修改**: Step 8 改为必选。握手完成后立即检查:对端 secret_proof 失败 + 公钥不在本节点已知的 trusted 列表中 → 立即断开
|
||||||
|
|
||||||
|
**2. Group proof 验证机制需要明确**
|
||||||
|
|
||||||
|
当前方案:临时节点在 `RoutePeerInfo.groups` 中携带 `PeerGroupInfo`(HMAC proof),管理节点在 `TrustedCredentialPubkey` 中传播 `group_secret_hash`。
|
||||||
|
|
||||||
|
问题:HMAC 验证需要**原始 secret**,不是 hash。验证节点如何知道 credential 的 group secret?
|
||||||
|
|
||||||
|
**解决方案**: `TrustedCredentialPubkey.group_secret_hash` 改为 `group_secret_digest`,使用与现有 `NetworkIdentity.network_secret_digest` 相同的 digest 算法。验证时:
|
||||||
|
- 管理节点在 `TrustedCredentialPubkey` 中包含 `group_secret_digest`
|
||||||
|
- 临时节点发送的 `PeerGroupInfo` 中包含 `group_proof`(HMAC)
|
||||||
|
- 验证节点无法直接验证 HMAC(没有原始 secret),但可以信任管理节点的声明:如果管理节点在 `TrustedCredentialPubkey.groups` 中列出了某个 group,且临时节点的公钥匹配,就直接信任该 group 归属
|
||||||
|
- 即:**group 归属由管理节点在 `TrustedCredentialPubkey` 中声明,无需临时节点提供 proof**
|
||||||
|
- 这简化了实现,且安全性不降低(管理节点已是可信源)
|
||||||
|
|
||||||
|
**3. 凭据持久化**
|
||||||
|
|
||||||
|
`CredentialManager` 当前设计为内存存储。管理节点重启后所有凭据丢失,导致使用这些凭据的临时节点被踢出。
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
- 管理节点可配置凭据存储的 JSON 文件路径(如 `--credential-file /path/to/credentials.json`)
|
||||||
|
- `CredentialManager` 启动时从该文件加载已有凭据
|
||||||
|
- 生成/撤销凭据时自动写入该文件
|
||||||
|
- 未配置文件路径时,凭据仅存内存(重启丢失)
|
||||||
|
|
||||||
|
**4. 同一凭据多节点复用**
|
||||||
|
|
||||||
|
同一个 credential 私钥可以被多个节点同时使用。它们有不同的 `peer_id` 但相同的 `noise_static_pubkey`。这会导致:
|
||||||
|
- 路由表中多个 RoutePeerInfo 有相同的 `noise_static_pubkey`
|
||||||
|
- 撤销时所有使用该凭据的节点同时被踢出(符合预期)
|
||||||
|
- **这是预期行为**,但应在文档中说明
|
||||||
|
|
||||||
|
**5. 临时节点 proxy_cidrs 限制**
|
||||||
|
|
||||||
|
临时节点可能声明虚假的 `proxy_cidrs`(子网代理),导致流量黑洞。
|
||||||
|
|
||||||
|
**解决方案**(已纳入设计):
|
||||||
|
- 生成凭据时通过 `allowed_proxy_cidrs` 字段限制该凭据可声明的网段范围
|
||||||
|
- 管理节点在 Step 9b 的 `update_peer_infos()` 中过滤:只保留临时节点声明的 proxy_cidrs 中属于 `allowed_proxy_cidrs` 子集的网段
|
||||||
|
- 未配置 `allowed_proxy_cidrs` 时(空列表),临时节点不允许声明任何 proxy_cidrs
|
||||||
|
|
||||||
|
**6. 凭据过期时间(TTL)**
|
||||||
|
|
||||||
|
凭据必须设置过期时间。过期后自动失效,等同于被撤销。
|
||||||
|
- 生成凭据时必须指定 `--ttl` 或 `--expiry`
|
||||||
|
- `verify_credential_peers()` 中检查 `expiry_unix`,过期的凭据从可信列表中移除
|
||||||
|
- 过期检查在每次路由同步时执行,无需额外定时器
|
||||||
|
|
||||||
|
## 优势
|
||||||
|
|
||||||
|
- **最小改动**: Noise 握手消息格式不变,完全复用现有流程
|
||||||
|
- **安全性**: X25519 密钥对提供强身份认证,不弱于 network_secret;端到端加密保护 relay 场景
|
||||||
|
- **自然传播**: 利用 OSPF 已有基础设施,无需新 RPC
|
||||||
|
- **去中心化撤销**: 任何管理节点都可撤销,全网通过路由同步感知
|
||||||
|
- **ACL 复用**: 凭据映射为 ACL Group,完全复用现有 group-based ACL 规则系统,无需新的 ACL 机制
|
||||||
@@ -244,6 +244,12 @@ core_clap:
|
|||||||
local_public_key:
|
local_public_key:
|
||||||
en: "local public key for secure mode. if not provided, a random key will be generated, or use local private key to derive public key"
|
en: "local public key for secure mode. if not provided, a random key will be generated, or use local private key to derive public key"
|
||||||
zh-CN: "安全模式下的本地公钥。如果未提供,则会随机生成一个密钥,或者使用本地私钥派生公钥"
|
zh-CN: "安全模式下的本地公钥。如果未提供,则会随机生成一个密钥,或者使用本地私钥派生公钥"
|
||||||
|
credential:
|
||||||
|
en: "credential secret (base64-encoded private key) for joining network as a temporary node without network_secret"
|
||||||
|
zh-CN: "凭据密钥(base64编码的私钥),用于作为临时节点加入网络,无需 network_secret"
|
||||||
|
credential_file:
|
||||||
|
en: "path to credential storage file for persisting generated credentials across restarts (admin nodes)"
|
||||||
|
zh-CN: "凭据存储文件路径,用于在管理节点重启后保留已生成的凭据"
|
||||||
check_config:
|
check_config:
|
||||||
en: Check config validity without starting the network
|
en: Check config validity without starting the network
|
||||||
zh-CN: 检查配置文件的有效性并退出
|
zh-CN: 检查配置文件的有效性并退出
|
||||||
|
|||||||
@@ -216,6 +216,11 @@ pub trait ConfigLoader: Send + Sync {
|
|||||||
fn get_secure_mode(&self) -> Option<SecureModeConfig>;
|
fn get_secure_mode(&self) -> Option<SecureModeConfig>;
|
||||||
fn set_secure_mode(&self, secure_mode: Option<SecureModeConfig>);
|
fn set_secure_mode(&self, secure_mode: Option<SecureModeConfig>);
|
||||||
|
|
||||||
|
fn get_credential_file(&self) -> Option<std::path::PathBuf> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
fn set_credential_file(&self, _path: Option<std::path::PathBuf>) {}
|
||||||
|
|
||||||
fn dump(&self) -> String;
|
fn dump(&self) -> String;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,6 +301,16 @@ impl NetworkIdentity {
|
|||||||
network_secret_digest: Some(network_secret_digest),
|
network_secret_digest: Some(network_secret_digest),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create a NetworkIdentity for a credential node (no network_secret).
|
||||||
|
/// The node identifies by network_name only and authenticates via credential keypair.
|
||||||
|
pub fn new_credential(network_name: String) -> Self {
|
||||||
|
NetworkIdentity {
|
||||||
|
network_name,
|
||||||
|
network_secret: None,
|
||||||
|
network_secret_digest: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for NetworkIdentity {
|
impl Default for NetworkIdentity {
|
||||||
@@ -428,6 +443,8 @@ struct Config {
|
|||||||
udp_whitelist: Option<Vec<String>>,
|
udp_whitelist: Option<Vec<String>>,
|
||||||
stun_servers: Option<Vec<String>>,
|
stun_servers: Option<Vec<String>>,
|
||||||
stun_servers_v6: Option<Vec<String>>,
|
stun_servers_v6: Option<Vec<String>>,
|
||||||
|
|
||||||
|
credential_file: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -821,6 +838,14 @@ impl ConfigLoader for TomlConfigLoader {
|
|||||||
self.config.lock().unwrap().secure_mode = secure_mode;
|
self.config.lock().unwrap().secure_mode = secure_mode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn get_credential_file(&self) -> Option<PathBuf> {
|
||||||
|
self.config.lock().unwrap().credential_file.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_credential_file(&self, path: Option<PathBuf>) {
|
||||||
|
self.config.lock().unwrap().credential_file = path;
|
||||||
|
}
|
||||||
|
|
||||||
fn dump(&self) -> String {
|
fn dump(&self) -> String {
|
||||||
let default_flags_json = serde_json::to_string(&gen_default_flags()).unwrap();
|
let default_flags_json = serde_json::to_string(&gen_default_flags()).unwrap();
|
||||||
let default_flags_hashmap =
|
let default_flags_hashmap =
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
use std::collections::hash_map::DefaultHasher;
|
use std::collections::hash_map::DefaultHasher;
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::net::{IpAddr, SocketAddr};
|
use std::net::{IpAddr, SocketAddr};
|
||||||
use std::{
|
use std::{
|
||||||
hash::Hasher,
|
hash::Hasher,
|
||||||
sync::{Arc, Mutex},
|
sync::{Arc, Mutex},
|
||||||
|
time::{SystemTime, UNIX_EPOCH},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use arc_swap::ArcSwap;
|
||||||
|
|
||||||
use crate::common::config::ProxyNetworkConfig;
|
use crate::common::config::ProxyNetworkConfig;
|
||||||
use crate::common::stats_manager::StatsManager;
|
use crate::common::stats_manager::StatsManager;
|
||||||
use crate::common::token_bucket::TokenBucketManager;
|
use crate::common::token_bucket::TokenBucketManager;
|
||||||
use crate::peers::acl_filter::AclFilter;
|
use crate::peers::acl_filter::AclFilter;
|
||||||
|
use crate::peers::credential_manager::CredentialManager;
|
||||||
use crate::proto::acl::GroupIdentity;
|
use crate::proto::acl::GroupIdentity;
|
||||||
use crate::proto::api::config::InstanceConfigPatch;
|
use crate::proto::api::config::InstanceConfigPatch;
|
||||||
use crate::proto::api::instance::PeerConnInfo;
|
use crate::proto::api::instance::PeerConnInfo;
|
||||||
@@ -59,11 +64,43 @@ pub enum GlobalCtxEvent {
|
|||||||
ConfigPatched(InstanceConfigPatch),
|
ConfigPatched(InstanceConfigPatch),
|
||||||
|
|
||||||
ProxyCidrsUpdated(Vec<cidr::Ipv4Cidr>, Vec<cidr::Ipv4Cidr>), // (added, removed)
|
ProxyCidrsUpdated(Vec<cidr::Ipv4Cidr>, Vec<cidr::Ipv4Cidr>), // (added, removed)
|
||||||
|
|
||||||
|
CredentialChanged,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type EventBus = tokio::sync::broadcast::Sender<GlobalCtxEvent>;
|
pub type EventBus = tokio::sync::broadcast::Sender<GlobalCtxEvent>;
|
||||||
pub type EventBusSubscriber = tokio::sync::broadcast::Receiver<GlobalCtxEvent>;
|
pub type EventBusSubscriber = tokio::sync::broadcast::Receiver<GlobalCtxEvent>;
|
||||||
|
|
||||||
|
/// Source of a trusted public key from OSPF route propagation
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum TrustedKeySource {
|
||||||
|
/// Peer node's noise static pubkey
|
||||||
|
OspfNode,
|
||||||
|
/// Admin-declared trusted credential pubkey
|
||||||
|
OspfCredential,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Metadata for a trusted public key
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct TrustedKeyMetadata {
|
||||||
|
pub source: TrustedKeySource,
|
||||||
|
/// Expiry time in Unix seconds. None means never expires.
|
||||||
|
pub expiry_unix: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TrustedKeyMetadata {
|
||||||
|
pub fn is_expired(&self) -> bool {
|
||||||
|
if let Some(expiry) = self.expiry_unix {
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs() as i64;
|
||||||
|
return now >= expiry;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct GlobalCtx {
|
pub struct GlobalCtx {
|
||||||
pub inst_name: String,
|
pub inst_name: String,
|
||||||
pub id: uuid::Uuid,
|
pub id: uuid::Uuid,
|
||||||
@@ -97,6 +134,12 @@ pub struct GlobalCtx {
|
|||||||
stats_manager: Arc<StatsManager>,
|
stats_manager: Arc<StatsManager>,
|
||||||
|
|
||||||
acl_filter: Arc<AclFilter>,
|
acl_filter: Arc<AclFilter>,
|
||||||
|
|
||||||
|
credential_manager: Arc<CredentialManager>,
|
||||||
|
|
||||||
|
/// OSPF propagated trusted keys (peer pubkeys and admin credentials)
|
||||||
|
/// Stored in ArcSwap for lock-free reads and atomic batch updates
|
||||||
|
trusted_keys: ArcSwap<HashMap<Vec<u8>, TrustedKeyMetadata>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Debug for GlobalCtx {
|
impl std::fmt::Debug for GlobalCtx {
|
||||||
@@ -152,6 +195,9 @@ impl GlobalCtx {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let credential_storage_path = config_fs.get_credential_file();
|
||||||
|
let credential_manager = Arc::new(CredentialManager::new(credential_storage_path));
|
||||||
|
|
||||||
GlobalCtx {
|
GlobalCtx {
|
||||||
inst_name: config_fs.get_inst_name(),
|
inst_name: config_fs.get_inst_name(),
|
||||||
id,
|
id,
|
||||||
@@ -187,6 +233,10 @@ impl GlobalCtx {
|
|||||||
stats_manager: Arc::new(StatsManager::new()),
|
stats_manager: Arc::new(StatsManager::new()),
|
||||||
|
|
||||||
acl_filter: Arc::new(AclFilter::new()),
|
acl_filter: Arc::new(AclFilter::new()),
|
||||||
|
|
||||||
|
credential_manager,
|
||||||
|
|
||||||
|
trusted_keys: ArcSwap::new(Arc::new(HashMap::new())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,6 +454,31 @@ impl GlobalCtx {
|
|||||||
&self.acl_filter
|
&self.acl_filter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_credential_manager(&self) -> &Arc<CredentialManager> {
|
||||||
|
&self.credential_manager
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a public key is trusted using two-level lookup:
|
||||||
|
/// 1. OSPF propagated trusted_keys (lock-free)
|
||||||
|
/// 2. Local credential_manager
|
||||||
|
pub fn is_pubkey_trusted(&self, pubkey: &[u8]) -> bool {
|
||||||
|
// First level: check OSPF propagated keys (lock-free)
|
||||||
|
let keys = self.trusted_keys.load();
|
||||||
|
if let Some(metadata) = keys.get(pubkey) {
|
||||||
|
return !metadata.is_expired();
|
||||||
|
}
|
||||||
|
drop(keys);
|
||||||
|
|
||||||
|
// Second level: check local credential_manager
|
||||||
|
self.credential_manager.is_pubkey_trusted(pubkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atomically replace all OSPF trusted keys with a new set
|
||||||
|
/// Called by OSPF route layer after each route update
|
||||||
|
pub fn update_trusted_keys(&self, keys: HashMap<Vec<u8>, TrustedKeyMetadata>) {
|
||||||
|
self.trusted_keys.store(Arc::new(keys));
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get_acl_groups(&self, peer_id: PeerId) -> Vec<PeerGroupInfo> {
|
pub fn get_acl_groups(&self, peer_id: PeerId) -> Vec<PeerGroupInfo> {
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
self.config
|
self.config
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use std::io::IsTerminal as _;
|
||||||
|
|
||||||
use crate::common::config::LoggingConfigLoader;
|
use crate::common::config::LoggingConfigLoader;
|
||||||
use crate::common::get_logger_timer_rfc3339;
|
use crate::common::get_logger_timer_rfc3339;
|
||||||
use crate::common::tracing_rolling_appender::{FileAppenderWrapper, RollingFileAppenderBase};
|
use crate::common::tracing_rolling_appender::{FileAppenderWrapper, RollingFileAppenderBase};
|
||||||
@@ -175,7 +177,8 @@ pub fn init(
|
|||||||
|
|
||||||
let layer = || {
|
let layer = || {
|
||||||
layer()
|
layer()
|
||||||
.pretty()
|
.compact()
|
||||||
|
.with_ansi(std::io::stderr().is_terminal())
|
||||||
.with_timer(get_logger_timer_rfc3339())
|
.with_timer(get_logger_timer_rfc3339())
|
||||||
.with_writer(std::io::stderr)
|
.with_writer(std::io::stderr)
|
||||||
};
|
};
|
||||||
|
|||||||
+38
-6
@@ -636,6 +636,20 @@ struct NetworkOptions {
|
|||||||
help = t!("core_clap.local_public_key").to_string()
|
help = t!("core_clap.local_public_key").to_string()
|
||||||
)]
|
)]
|
||||||
local_public_key: Option<String>,
|
local_public_key: Option<String>,
|
||||||
|
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
env = "ET_CREDENTIAL",
|
||||||
|
help = t!("core_clap.credential").to_string()
|
||||||
|
)]
|
||||||
|
credential: Option<String>,
|
||||||
|
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
env = "ET_CREDENTIAL_FILE",
|
||||||
|
help = t!("core_clap.credential_file").to_string()
|
||||||
|
)]
|
||||||
|
credential_file: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
@@ -802,11 +816,17 @@ impl NetworkOptions {
|
|||||||
|
|
||||||
let old_ns = cfg.get_network_identity();
|
let old_ns = cfg.get_network_identity();
|
||||||
let network_name = self.network_name.clone().unwrap_or(old_ns.network_name);
|
let network_name = self.network_name.clone().unwrap_or(old_ns.network_name);
|
||||||
let network_secret = self
|
|
||||||
.network_secret
|
if self.credential.is_some() {
|
||||||
.clone()
|
// Credential mode: no network_secret, authenticate via credential keypair
|
||||||
.unwrap_or(old_ns.network_secret.unwrap_or_default());
|
cfg.set_network_identity(NetworkIdentity::new_credential(network_name));
|
||||||
cfg.set_network_identity(NetworkIdentity::new(network_name, network_secret));
|
} else {
|
||||||
|
let network_secret = self
|
||||||
|
.network_secret
|
||||||
|
.clone()
|
||||||
|
.unwrap_or(old_ns.network_secret.unwrap_or_default());
|
||||||
|
cfg.set_network_identity(NetworkIdentity::new(network_name, network_secret));
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(dhcp) = self.dhcp {
|
if let Some(dhcp) = self.dhcp {
|
||||||
cfg.set_dhcp(dhcp);
|
cfg.set_dhcp(dhcp);
|
||||||
@@ -975,7 +995,19 @@ impl NetworkOptions {
|
|||||||
cfg.set_port_forwards(old);
|
cfg.set_port_forwards(old);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(secure_mode) = self.secure_mode {
|
if let Some(ref credential_file) = self.credential_file {
|
||||||
|
cfg.set_credential_file(Some(credential_file.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref credential_secret) = self.credential {
|
||||||
|
// --credential implies --secure-mode and sets the credential private key
|
||||||
|
let c = SecureModeConfig {
|
||||||
|
enabled: true,
|
||||||
|
local_private_key: Some(credential_secret.clone()),
|
||||||
|
local_public_key: None,
|
||||||
|
};
|
||||||
|
cfg.set_secure_mode(Some(Self::process_secure_mode_cfg(c)?));
|
||||||
|
} else if let Some(secure_mode) = self.secure_mode {
|
||||||
if secure_mode {
|
if secure_mode {
|
||||||
let c = SecureModeConfig {
|
let c = SecureModeConfig {
|
||||||
enabled: secure_mode,
|
enabled: secure_mode,
|
||||||
|
|||||||
@@ -37,17 +37,18 @@ use easytier::{
|
|||||||
instance::{
|
instance::{
|
||||||
instance_identifier::{InstanceSelector, Selector},
|
instance_identifier::{InstanceSelector, Selector},
|
||||||
list_peer_route_pair, AclManageRpc, AclManageRpcClientFactory, ConnectorManageRpc,
|
list_peer_route_pair, AclManageRpc, AclManageRpcClientFactory, ConnectorManageRpc,
|
||||||
ConnectorManageRpcClientFactory, DumpRouteRequest, GetAclStatsRequest,
|
ConnectorManageRpcClientFactory, CredentialManageRpc,
|
||||||
GetPrometheusStatsRequest, GetStatsRequest, GetVpnPortalInfoRequest,
|
CredentialManageRpcClientFactory, DumpRouteRequest, GenerateCredentialRequest,
|
||||||
GetWhitelistRequest, InstanceIdentifier, ListConnectorRequest,
|
GetAclStatsRequest, GetPrometheusStatsRequest, GetStatsRequest,
|
||||||
ListForeignNetworkRequest, ListGlobalForeignNetworkRequest,
|
GetVpnPortalInfoRequest, GetWhitelistRequest, InstanceIdentifier,
|
||||||
ListMappedListenerRequest, ListPeerRequest, ListPeerResponse,
|
ListConnectorRequest, ListCredentialsRequest, ListForeignNetworkRequest,
|
||||||
ListPortForwardRequest, ListRouteRequest, ListRouteResponse,
|
ListGlobalForeignNetworkRequest, ListMappedListenerRequest, ListPeerRequest,
|
||||||
|
ListPeerResponse, ListPortForwardRequest, ListRouteRequest, ListRouteResponse,
|
||||||
MappedListenerManageRpc, MappedListenerManageRpcClientFactory, NodeInfo,
|
MappedListenerManageRpc, MappedListenerManageRpcClientFactory, NodeInfo,
|
||||||
PeerManageRpc, PeerManageRpcClientFactory, PortForwardManageRpc,
|
PeerManageRpc, PeerManageRpcClientFactory, PortForwardManageRpc,
|
||||||
PortForwardManageRpcClientFactory, ShowNodeInfoRequest, StatsRpc,
|
PortForwardManageRpcClientFactory, RevokeCredentialRequest, ShowNodeInfoRequest,
|
||||||
StatsRpcClientFactory, TcpProxyEntryState, TcpProxyEntryTransportType, TcpProxyRpc,
|
StatsRpc, StatsRpcClientFactory, TcpProxyEntryState, TcpProxyEntryTransportType,
|
||||||
TcpProxyRpcClientFactory, VpnPortalRpc, VpnPortalRpcClientFactory,
|
TcpProxyRpc, TcpProxyRpcClientFactory, VpnPortalRpc, VpnPortalRpcClientFactory,
|
||||||
},
|
},
|
||||||
logger::{
|
logger::{
|
||||||
GetLoggerConfigRequest, LogLevel, LoggerRpc, LoggerRpcClientFactory,
|
GetLoggerConfigRequest, LogLevel, LoggerRpc, LoggerRpcClientFactory,
|
||||||
@@ -134,6 +135,8 @@ enum SubCommand {
|
|||||||
Stats(StatsArgs),
|
Stats(StatsArgs),
|
||||||
#[command(about = "manage logger configuration")]
|
#[command(about = "manage logger configuration")]
|
||||||
Logger(LoggerArgs),
|
Logger(LoggerArgs),
|
||||||
|
#[command(about = "manage temporary credentials")]
|
||||||
|
Credential(CredentialArgs),
|
||||||
#[command(about = t!("core_clap.generate_completions").to_string())]
|
#[command(about = t!("core_clap.generate_completions").to_string())]
|
||||||
GenAutocomplete { shell: ShellType },
|
GenAutocomplete { shell: ShellType },
|
||||||
}
|
}
|
||||||
@@ -340,6 +343,42 @@ enum LoggerSubCommand {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Args, Debug)]
|
||||||
|
struct CredentialArgs {
|
||||||
|
#[command(subcommand)]
|
||||||
|
sub_command: CredentialSubCommand,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand, Debug)]
|
||||||
|
enum CredentialSubCommand {
|
||||||
|
/// Generate a new temporary credential
|
||||||
|
Generate {
|
||||||
|
#[arg(long, help = "TTL in seconds (required)")]
|
||||||
|
ttl: i64,
|
||||||
|
#[arg(long, value_delimiter = ',', help = "ACL groups (comma-separated)")]
|
||||||
|
groups: Option<Vec<String>>,
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
default_value = "false",
|
||||||
|
help = "allow relay through this credential node"
|
||||||
|
)]
|
||||||
|
allow_relay: bool,
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
value_delimiter = ',',
|
||||||
|
help = "allowed proxy CIDRs (comma-separated)"
|
||||||
|
)]
|
||||||
|
allowed_proxy_cidrs: Option<Vec<String>>,
|
||||||
|
},
|
||||||
|
/// Revoke a credential by its ID
|
||||||
|
Revoke {
|
||||||
|
#[arg(help = "credential ID (public key base64)")]
|
||||||
|
credential_id: String,
|
||||||
|
},
|
||||||
|
/// List all active credentials
|
||||||
|
List,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Args, Debug)]
|
#[derive(Args, Debug)]
|
||||||
struct ServiceArgs {
|
struct ServiceArgs {
|
||||||
#[arg(short, long, default_value = env!("CARGO_PKG_NAME"), help = "service name")]
|
#[arg(short, long, default_value = env!("CARGO_PKG_NAME"), help = "service name")]
|
||||||
@@ -537,6 +576,18 @@ impl CommandHandler<'_> {
|
|||||||
.with_context(|| "failed to get config client")?)
|
.with_context(|| "failed to get config client")?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_credential_client(
|
||||||
|
&self,
|
||||||
|
) -> Result<Box<dyn CredentialManageRpc<Controller = BaseController>>, Error> {
|
||||||
|
Ok(self
|
||||||
|
.client
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.scoped_client::<CredentialManageRpcClientFactory<BaseController>>("".to_string())
|
||||||
|
.await
|
||||||
|
.with_context(|| "failed to get credential client")?)
|
||||||
|
}
|
||||||
|
|
||||||
async fn list_peers(&self) -> Result<ListPeerResponse, Error> {
|
async fn list_peers(&self) -> Result<ListPeerResponse, Error> {
|
||||||
let client = self.get_peer_manager_client().await?;
|
let client = self.get_peer_manager_client().await?;
|
||||||
let request = ListPeerRequest {
|
let request = ListPeerRequest {
|
||||||
@@ -1363,6 +1414,121 @@ impl CommandHandler<'_> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn handle_credential_generate(
|
||||||
|
&self,
|
||||||
|
ttl: i64,
|
||||||
|
groups: Vec<String>,
|
||||||
|
allow_relay: bool,
|
||||||
|
allowed_proxy_cidrs: Vec<String>,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
let client = self.get_credential_client().await?;
|
||||||
|
let request = GenerateCredentialRequest {
|
||||||
|
groups,
|
||||||
|
allow_relay,
|
||||||
|
allowed_proxy_cidrs,
|
||||||
|
ttl_seconds: ttl,
|
||||||
|
};
|
||||||
|
let response = client
|
||||||
|
.generate_credential(BaseController::default(), request)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
match self.output_format {
|
||||||
|
OutputFormat::Table => {
|
||||||
|
println!("Credential generated successfully:");
|
||||||
|
println!(" credential_id: {}", response.credential_id);
|
||||||
|
println!(" credential_secret: {}", response.credential_secret);
|
||||||
|
println!();
|
||||||
|
println!("To use this credential on a new node:");
|
||||||
|
println!(
|
||||||
|
" easytier-core --network-name <name> --secure-mode --credential {}",
|
||||||
|
response.credential_secret
|
||||||
|
);
|
||||||
|
}
|
||||||
|
OutputFormat::Json => {
|
||||||
|
let json = serde_json::to_string_pretty(&response)?;
|
||||||
|
println!("{}", json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_credential_revoke(&self, credential_id: &str) -> Result<(), Error> {
|
||||||
|
let client = self.get_credential_client().await?;
|
||||||
|
let request = RevokeCredentialRequest {
|
||||||
|
credential_id: credential_id.to_string(),
|
||||||
|
};
|
||||||
|
let response = client
|
||||||
|
.revoke_credential(BaseController::default(), request)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
match self.output_format {
|
||||||
|
OutputFormat::Table => {
|
||||||
|
if response.success {
|
||||||
|
println!("Credential revoked successfully");
|
||||||
|
} else {
|
||||||
|
println!("Credential not found");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OutputFormat::Json => {
|
||||||
|
let json = serde_json::to_string_pretty(&response)?;
|
||||||
|
println!("{}", json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_credential_list(&self) -> Result<(), Error> {
|
||||||
|
let client = self.get_credential_client().await?;
|
||||||
|
let request = ListCredentialsRequest {};
|
||||||
|
let response = client
|
||||||
|
.list_credentials(BaseController::default(), request)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
match self.output_format {
|
||||||
|
OutputFormat::Table => {
|
||||||
|
if response.credentials.is_empty() {
|
||||||
|
println!("No active credentials");
|
||||||
|
} else {
|
||||||
|
use tabled::{builder::Builder, settings::Style};
|
||||||
|
let mut builder = Builder::default();
|
||||||
|
builder.push_record(["ID", "Groups", "Relay", "Expiry", "Allowed CIDRs"]);
|
||||||
|
for cred in &response.credentials {
|
||||||
|
let expiry = {
|
||||||
|
let secs = cred.expiry_unix;
|
||||||
|
let remaining = secs
|
||||||
|
- std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs() as i64;
|
||||||
|
if remaining > 0 {
|
||||||
|
format!("{}s remaining", remaining)
|
||||||
|
} else {
|
||||||
|
"expired".to_string()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
builder.push_record([
|
||||||
|
&cred.credential_id[..],
|
||||||
|
&cred.groups.join(","),
|
||||||
|
if cred.allow_relay { "yes" } else { "no" },
|
||||||
|
&expiry,
|
||||||
|
&cred.allowed_proxy_cidrs.join(","),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
let table = builder.build().with(Style::rounded()).to_string();
|
||||||
|
println!("{}", table);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OutputFormat::Json => {
|
||||||
|
let json = serde_json::to_string_pretty(&response)?;
|
||||||
|
println!("{}", json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_port_list(ports_str: &str) -> Result<Vec<String>, Error> {
|
fn parse_port_list(ports_str: &str) -> Result<Vec<String>, Error> {
|
||||||
let mut ports = Vec::new();
|
let mut ports = Vec::new();
|
||||||
for port_spec in ports_str.split(',') {
|
for port_spec in ports_str.split(',') {
|
||||||
@@ -2193,6 +2359,29 @@ async fn main() -> Result<(), Error> {
|
|||||||
handler.handle_logger_set(level).await?;
|
handler.handle_logger_set(level).await?;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
SubCommand::Credential(credential_args) => match &credential_args.sub_command {
|
||||||
|
CredentialSubCommand::Generate {
|
||||||
|
ttl,
|
||||||
|
groups,
|
||||||
|
allow_relay,
|
||||||
|
allowed_proxy_cidrs,
|
||||||
|
} => {
|
||||||
|
handler
|
||||||
|
.handle_credential_generate(
|
||||||
|
*ttl,
|
||||||
|
groups.clone().unwrap_or_default(),
|
||||||
|
*allow_relay,
|
||||||
|
allowed_proxy_cidrs.clone().unwrap_or_default(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
CredentialSubCommand::Revoke { credential_id } => {
|
||||||
|
handler.handle_credential_revoke(credential_id).await?;
|
||||||
|
}
|
||||||
|
CredentialSubCommand::List => {
|
||||||
|
handler.handle_credential_list().await?;
|
||||||
|
}
|
||||||
|
},
|
||||||
SubCommand::GenAutocomplete { shell } => {
|
SubCommand::GenAutocomplete { shell } => {
|
||||||
let mut cmd = Cli::command();
|
let mut cmd = Cli::command();
|
||||||
if let Some(shell) = shell.to_shell() {
|
if let Some(shell) = shell.to_shell() {
|
||||||
|
|||||||
@@ -1316,6 +1316,7 @@ impl Instance {
|
|||||||
stats_rpc_service: G,
|
stats_rpc_service: G,
|
||||||
config_rpc_service: H,
|
config_rpc_service: H,
|
||||||
peer_center_rpc_service: Arc<PeerCenterInstanceService>,
|
peer_center_rpc_service: Arc<PeerCenterInstanceService>,
|
||||||
|
credential_manage_rpc_service: PeerManagerRpcService,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
@@ -1383,6 +1384,12 @@ impl Instance {
|
|||||||
) -> Arc<dyn PeerCenterRpc<Controller = BaseController> + Send + Sync> {
|
) -> Arc<dyn PeerCenterRpc<Controller = BaseController> + Send + Sync> {
|
||||||
self.peer_center_rpc_service.clone()
|
self.peer_center_rpc_service.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn get_credential_manage_service(
|
||||||
|
&self,
|
||||||
|
) -> &dyn CredentialManageRpc<Controller = BaseController> {
|
||||||
|
&self.credential_manage_rpc_service
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ApiRpcServiceImpl {
|
ApiRpcServiceImpl {
|
||||||
@@ -1444,6 +1451,7 @@ impl Instance {
|
|||||||
stats_rpc_service: self.get_stats_rpc_service(),
|
stats_rpc_service: self.get_stats_rpc_service(),
|
||||||
config_rpc_service: self.get_config_service(),
|
config_rpc_service: self.get_config_service(),
|
||||||
peer_center_rpc_service: Arc::new(self.peer_center.get_rpc_service()),
|
peer_center_rpc_service: Arc::new(self.peer_center.get_rpc_service()),
|
||||||
|
credential_manage_rpc_service: PeerManagerRpcService::new(self.peer_manager.clone()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -423,6 +423,10 @@ fn handle_event(
|
|||||||
instance_id
|
instance_id
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
GlobalCtxEvent::CredentialChanged => {
|
||||||
|
event!(info, "[{}] credential changed", instance_id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
events = events.resubscribe();
|
events = events.resubscribe();
|
||||||
|
|||||||
@@ -0,0 +1,354 @@
|
|||||||
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
path::PathBuf,
|
||||||
|
sync::Mutex,
|
||||||
|
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||||
|
};
|
||||||
|
|
||||||
|
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||||
|
use base64::Engine;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use x25519_dalek::{PublicKey, StaticSecret};
|
||||||
|
|
||||||
|
use crate::proto::peer_rpc::TrustedCredentialPubkey;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
struct CredentialEntry {
|
||||||
|
pubkey_bytes: Vec<u8>,
|
||||||
|
groups: Vec<String>,
|
||||||
|
allow_relay: bool,
|
||||||
|
allowed_proxy_cidrs: Vec<String>,
|
||||||
|
expiry_unix: i64,
|
||||||
|
created_at_unix: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct CredentialManager {
|
||||||
|
credentials: Mutex<HashMap<String, CredentialEntry>>,
|
||||||
|
storage_path: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CredentialManager {
|
||||||
|
pub fn new(storage_path: Option<PathBuf>) -> Self {
|
||||||
|
let mgr = CredentialManager {
|
||||||
|
credentials: Mutex::new(HashMap::new()),
|
||||||
|
storage_path,
|
||||||
|
};
|
||||||
|
mgr.load_from_disk();
|
||||||
|
mgr
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate_credential(
|
||||||
|
&self,
|
||||||
|
groups: Vec<String>,
|
||||||
|
allow_relay: bool,
|
||||||
|
allowed_proxy_cidrs: Vec<String>,
|
||||||
|
ttl: Duration,
|
||||||
|
) -> (String, String) {
|
||||||
|
let private = StaticSecret::random_from_rng(rand::rngs::OsRng);
|
||||||
|
let public = PublicKey::from(&private);
|
||||||
|
let id = BASE64_STANDARD.encode(public.as_bytes());
|
||||||
|
let secret = BASE64_STANDARD.encode(private.as_bytes());
|
||||||
|
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs() as i64;
|
||||||
|
let expiry_unix = now + ttl.as_secs() as i64;
|
||||||
|
|
||||||
|
let entry = CredentialEntry {
|
||||||
|
pubkey_bytes: public.as_bytes().to_vec(),
|
||||||
|
groups,
|
||||||
|
allow_relay,
|
||||||
|
allowed_proxy_cidrs,
|
||||||
|
expiry_unix,
|
||||||
|
created_at_unix: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
self.credentials.lock().unwrap().insert(id.clone(), entry);
|
||||||
|
self.save_to_disk();
|
||||||
|
(id, secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn revoke_credential(&self, credential_id: &str) -> bool {
|
||||||
|
let removed = self
|
||||||
|
.credentials
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.remove(credential_id)
|
||||||
|
.is_some();
|
||||||
|
if removed {
|
||||||
|
self.save_to_disk();
|
||||||
|
}
|
||||||
|
removed
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_trusted_pubkeys(&self) -> Vec<TrustedCredentialPubkey> {
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs() as i64;
|
||||||
|
|
||||||
|
self.credentials
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.values()
|
||||||
|
.filter(|e| e.expiry_unix > now)
|
||||||
|
.map(|e| TrustedCredentialPubkey {
|
||||||
|
pubkey: e.pubkey_bytes.clone(),
|
||||||
|
groups: e.groups.clone(),
|
||||||
|
allow_relay: e.allow_relay,
|
||||||
|
expiry_unix: e.expiry_unix,
|
||||||
|
allowed_proxy_cidrs: e.allowed_proxy_cidrs.clone(),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_pubkey_trusted(&self, pubkey: &[u8]) -> bool {
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs() as i64;
|
||||||
|
|
||||||
|
self.credentials
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.values()
|
||||||
|
.any(|e| e.pubkey_bytes == pubkey && e.expiry_unix > now)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_credentials(&self) -> Vec<crate::proto::api::instance::CredentialInfo> {
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs() as i64;
|
||||||
|
|
||||||
|
self.credentials
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, e)| e.expiry_unix > now)
|
||||||
|
.map(|(id, e)| crate::proto::api::instance::CredentialInfo {
|
||||||
|
credential_id: id.clone(),
|
||||||
|
groups: e.groups.clone(),
|
||||||
|
allow_relay: e.allow_relay,
|
||||||
|
expiry_unix: e.expiry_unix,
|
||||||
|
allowed_proxy_cidrs: e.allowed_proxy_cidrs.clone(),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_to_disk(&self) {
|
||||||
|
let Some(path) = &self.storage_path else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let creds = self.credentials.lock().unwrap();
|
||||||
|
if let Ok(json) = serde_json::to_string_pretty(&*creds) {
|
||||||
|
if let Err(e) = std::fs::write(path, json) {
|
||||||
|
tracing::warn!(?e, "failed to save credentials to disk");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_from_disk(&self) {
|
||||||
|
let Some(path) = &self.storage_path else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Ok(data) = std::fs::read_to_string(path) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match serde_json::from_str::<HashMap<String, CredentialEntry>>(&data) {
|
||||||
|
Ok(loaded) => {
|
||||||
|
*self.credentials.lock().unwrap() = loaded;
|
||||||
|
tracing::info!("loaded credentials from {}", path.display());
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(?e, "failed to parse credentials file");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_generate_and_revoke() {
|
||||||
|
let mgr = CredentialManager::new(None);
|
||||||
|
let (id, secret) = mgr.generate_credential(
|
||||||
|
vec!["guest".to_string()],
|
||||||
|
false,
|
||||||
|
vec![],
|
||||||
|
Duration::from_secs(3600),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(!id.is_empty());
|
||||||
|
assert!(!secret.is_empty());
|
||||||
|
|
||||||
|
let pubkey_bytes = BASE64_STANDARD.decode(&id).unwrap();
|
||||||
|
assert!(mgr.is_pubkey_trusted(&pubkey_bytes));
|
||||||
|
|
||||||
|
let trusted = mgr.get_trusted_pubkeys();
|
||||||
|
assert_eq!(trusted.len(), 1);
|
||||||
|
assert_eq!(trusted[0].groups, vec!["guest".to_string()]);
|
||||||
|
|
||||||
|
assert!(mgr.revoke_credential(&id));
|
||||||
|
assert!(!mgr.is_pubkey_trusted(&pubkey_bytes));
|
||||||
|
assert!(mgr.get_trusted_pubkeys().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_expired_credential() {
|
||||||
|
let mgr = CredentialManager::new(None);
|
||||||
|
// TTL of 0 seconds - immediately expired
|
||||||
|
let (id, _) = mgr.generate_credential(vec![], false, vec![], Duration::from_secs(0));
|
||||||
|
|
||||||
|
let pubkey_bytes = BASE64_STANDARD.decode(&id).unwrap();
|
||||||
|
assert!(!mgr.is_pubkey_trusted(&pubkey_bytes));
|
||||||
|
assert!(mgr.get_trusted_pubkeys().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_list_credentials() {
|
||||||
|
let mgr = CredentialManager::new(None);
|
||||||
|
mgr.generate_credential(
|
||||||
|
vec!["a".to_string()],
|
||||||
|
true,
|
||||||
|
vec!["10.0.0.0/24".to_string()],
|
||||||
|
Duration::from_secs(3600),
|
||||||
|
);
|
||||||
|
mgr.generate_credential(vec![], false, vec![], Duration::from_secs(3600));
|
||||||
|
|
||||||
|
let list = mgr.list_credentials();
|
||||||
|
assert_eq!(list.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_keypair_validity() {
|
||||||
|
// Verify the generated private key can derive the same public key
|
||||||
|
let mgr = CredentialManager::new(None);
|
||||||
|
let (id, secret) =
|
||||||
|
mgr.generate_credential(vec![], false, vec![], Duration::from_secs(3600));
|
||||||
|
|
||||||
|
let privkey_bytes: [u8; 32] = BASE64_STANDARD.decode(&secret).unwrap().try_into().unwrap();
|
||||||
|
let private = StaticSecret::from(privkey_bytes);
|
||||||
|
let derived_public = PublicKey::from(&private);
|
||||||
|
let derived_id = BASE64_STANDARD.encode(derived_public.as_bytes());
|
||||||
|
|
||||||
|
assert_eq!(id, derived_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_revoke_nonexistent() {
|
||||||
|
let mgr = CredentialManager::new(None);
|
||||||
|
assert!(!mgr.revoke_credential("nonexistent_id"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_multiple_credentials_independent() {
|
||||||
|
let mgr = CredentialManager::new(None);
|
||||||
|
let (id1, _) = mgr.generate_credential(
|
||||||
|
vec!["group1".to_string()],
|
||||||
|
false,
|
||||||
|
vec![],
|
||||||
|
Duration::from_secs(3600),
|
||||||
|
);
|
||||||
|
let (id2, _) = mgr.generate_credential(
|
||||||
|
vec!["group2".to_string()],
|
||||||
|
true,
|
||||||
|
vec!["10.0.0.0/8".to_string()],
|
||||||
|
Duration::from_secs(3600),
|
||||||
|
);
|
||||||
|
|
||||||
|
let pk1 = BASE64_STANDARD.decode(&id1).unwrap();
|
||||||
|
let pk2 = BASE64_STANDARD.decode(&id2).unwrap();
|
||||||
|
|
||||||
|
assert!(mgr.is_pubkey_trusted(&pk1));
|
||||||
|
assert!(mgr.is_pubkey_trusted(&pk2));
|
||||||
|
|
||||||
|
// Revoke first, second should still be trusted
|
||||||
|
mgr.revoke_credential(&id1);
|
||||||
|
assert!(!mgr.is_pubkey_trusted(&pk1));
|
||||||
|
assert!(mgr.is_pubkey_trusted(&pk2));
|
||||||
|
|
||||||
|
let trusted = mgr.get_trusted_pubkeys();
|
||||||
|
assert_eq!(trusted.len(), 1);
|
||||||
|
assert_eq!(trusted[0].groups, vec!["group2".to_string()]);
|
||||||
|
assert!(trusted[0].allow_relay);
|
||||||
|
assert_eq!(
|
||||||
|
trusted[0].allowed_proxy_cidrs,
|
||||||
|
vec!["10.0.0.0/8".to_string()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_trusted_pubkeys_include_metadata() {
|
||||||
|
let mgr = CredentialManager::new(None);
|
||||||
|
let (id, _) = mgr.generate_credential(
|
||||||
|
vec!["admin".to_string(), "ops".to_string()],
|
||||||
|
true,
|
||||||
|
vec!["192.168.0.0/16".to_string(), "10.0.0.0/8".to_string()],
|
||||||
|
Duration::from_secs(7200),
|
||||||
|
);
|
||||||
|
|
||||||
|
let trusted = mgr.get_trusted_pubkeys();
|
||||||
|
assert_eq!(trusted.len(), 1);
|
||||||
|
let tc = &trusted[0];
|
||||||
|
assert_eq!(tc.groups, vec!["admin".to_string(), "ops".to_string()]);
|
||||||
|
assert!(tc.allow_relay);
|
||||||
|
assert_eq!(
|
||||||
|
tc.allowed_proxy_cidrs,
|
||||||
|
vec!["192.168.0.0/16".to_string(), "10.0.0.0/8".to_string()]
|
||||||
|
);
|
||||||
|
assert!(tc.expiry_unix > 0);
|
||||||
|
|
||||||
|
let pk = BASE64_STANDARD.decode(&id).unwrap();
|
||||||
|
assert_eq!(tc.pubkey, pk);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_unknown_pubkey_not_trusted() {
|
||||||
|
let mgr = CredentialManager::new(None);
|
||||||
|
mgr.generate_credential(vec![], false, vec![], Duration::from_secs(3600));
|
||||||
|
|
||||||
|
let random_key = [42u8; 32];
|
||||||
|
assert!(!mgr.is_pubkey_trusted(&random_key));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_persistence_roundtrip() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("creds.json");
|
||||||
|
|
||||||
|
// Create and save
|
||||||
|
{
|
||||||
|
let mgr = CredentialManager::new(Some(path.clone()));
|
||||||
|
mgr.generate_credential(
|
||||||
|
vec!["persist_group".to_string()],
|
||||||
|
true,
|
||||||
|
vec!["10.0.0.0/24".to_string()],
|
||||||
|
Duration::from_secs(3600),
|
||||||
|
);
|
||||||
|
assert_eq!(mgr.list_credentials().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load from disk
|
||||||
|
{
|
||||||
|
let mgr = CredentialManager::new(Some(path));
|
||||||
|
let list = mgr.list_credentials();
|
||||||
|
assert_eq!(list.len(), 1);
|
||||||
|
assert_eq!(list[0].groups, vec!["persist_group".to_string()]);
|
||||||
|
assert!(list[0].allow_relay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_list_credentials_filters_expired() {
|
||||||
|
let mgr = CredentialManager::new(None);
|
||||||
|
mgr.generate_credential(vec![], false, vec![], Duration::from_secs(3600));
|
||||||
|
mgr.generate_credential(vec![], false, vec![], Duration::from_secs(0)); // expired
|
||||||
|
|
||||||
|
let list = mgr.list_credentials();
|
||||||
|
assert_eq!(list.len(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,7 +38,7 @@ impl ForeignNetworkClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn add_new_peer_conn(&self, peer_conn: PeerConn) {
|
pub async fn add_new_peer_conn(&self, peer_conn: PeerConn) -> Result<(), Error> {
|
||||||
tracing::warn!(peer_conn = ?peer_conn.get_conn_info(), network = ?peer_conn.get_network_identity(), "add new peer conn in foreign network client");
|
tracing::warn!(peer_conn = ?peer_conn.get_conn_info(), network = ?peer_conn.get_network_identity(), "add new peer conn in foreign network client");
|
||||||
self.peer_map.add_new_peer_conn(peer_conn).await
|
self.peer_map.add_new_peer_conn(peer_conn).await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -686,7 +686,7 @@ impl ForeignNetworkManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
entry.peer_map.add_new_peer_conn(peer_conn).await;
|
entry.peer_map.add_new_peer_conn(peer_conn).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
mod graph_algo;
|
mod graph_algo;
|
||||||
|
|
||||||
pub mod acl_filter;
|
pub mod acl_filter;
|
||||||
|
pub mod credential_manager;
|
||||||
pub mod peer;
|
pub mod peer;
|
||||||
pub mod peer_conn;
|
pub mod peer_conn;
|
||||||
pub mod peer_conn_ping;
|
pub mod peer_conn_ping;
|
||||||
|
|||||||
+157
-4
@@ -17,6 +17,7 @@ use crate::{
|
|||||||
global_ctx::{ArcGlobalCtx, GlobalCtxEvent},
|
global_ctx::{ArcGlobalCtx, GlobalCtxEvent},
|
||||||
PeerId,
|
PeerId,
|
||||||
},
|
},
|
||||||
|
proto::peer_rpc::PeerIdentityType,
|
||||||
tunnel::packet_def::ZCPacket,
|
tunnel::packet_def::ZCPacket,
|
||||||
};
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -40,6 +41,7 @@ pub struct Peer {
|
|||||||
shutdown_notifier: Arc<tokio::sync::Notify>,
|
shutdown_notifier: Arc<tokio::sync::Notify>,
|
||||||
|
|
||||||
default_conn_id: Arc<AtomicCell<PeerConnId>>,
|
default_conn_id: Arc<AtomicCell<PeerConnId>>,
|
||||||
|
peer_identity_type: Arc<AtomicCell<Option<PeerIdentityType>>>,
|
||||||
default_conn_id_clear_task: ScopedTask<()>,
|
default_conn_id_clear_task: ScopedTask<()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +54,8 @@ impl Peer {
|
|||||||
let conns: ConnMap = Arc::new(DashMap::new());
|
let conns: ConnMap = Arc::new(DashMap::new());
|
||||||
let (close_event_sender, mut close_event_receiver) = mpsc::channel(10);
|
let (close_event_sender, mut close_event_receiver) = mpsc::channel(10);
|
||||||
let shutdown_notifier = Arc::new(tokio::sync::Notify::new());
|
let shutdown_notifier = Arc::new(tokio::sync::Notify::new());
|
||||||
|
let peer_identity_type = Arc::new(AtomicCell::new(None));
|
||||||
|
let peer_identity_type_copy = peer_identity_type.clone();
|
||||||
|
|
||||||
let conns_copy = conns.clone();
|
let conns_copy = conns.clone();
|
||||||
let shutdown_notifier_copy = shutdown_notifier.clone();
|
let shutdown_notifier_copy = shutdown_notifier.clone();
|
||||||
@@ -76,6 +80,9 @@ impl Peer {
|
|||||||
conn.get_conn_info(),
|
conn.get_conn_info(),
|
||||||
));
|
));
|
||||||
shrink_dashmap(&conns_copy, Some(4));
|
shrink_dashmap(&conns_copy, Some(4));
|
||||||
|
if conns_copy.is_empty() {
|
||||||
|
peer_identity_type_copy.store(None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,11 +125,25 @@ impl Peer {
|
|||||||
|
|
||||||
shutdown_notifier,
|
shutdown_notifier,
|
||||||
default_conn_id,
|
default_conn_id,
|
||||||
|
peer_identity_type,
|
||||||
default_conn_id_clear_task,
|
default_conn_id_clear_task,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn add_peer_conn(&self, mut conn: PeerConn) {
|
pub async fn add_peer_conn(&self, mut conn: PeerConn) -> Result<(), Error> {
|
||||||
|
let conn_identity_type = conn.get_peer_identity_type();
|
||||||
|
let peer_identity_type = self.peer_identity_type.load();
|
||||||
|
if let Some(peer_identity_type) = peer_identity_type {
|
||||||
|
if peer_identity_type != conn_identity_type {
|
||||||
|
return Err(Error::SecretKeyError(format!(
|
||||||
|
"peer identity type mismatch. peer: {:?}, conn: {:?}",
|
||||||
|
peer_identity_type, conn_identity_type
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.peer_identity_type.store(Some(conn_identity_type));
|
||||||
|
}
|
||||||
|
|
||||||
let close_notifier = conn.get_close_notifier();
|
let close_notifier = conn.get_close_notifier();
|
||||||
let conn_info = conn.get_conn_info();
|
let conn_info = conn.get_conn_info();
|
||||||
|
|
||||||
@@ -143,6 +164,7 @@ impl Peer {
|
|||||||
|
|
||||||
self.global_ctx
|
self.global_ctx
|
||||||
.issue_event(GlobalCtxEvent::PeerConnAdded(conn_info));
|
.issue_event(GlobalCtxEvent::PeerConnAdded(conn_info));
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn select_conn(&self) -> Option<ArcPeerConn> {
|
async fn select_conn(&self) -> Option<ArcPeerConn> {
|
||||||
@@ -221,6 +243,10 @@ impl Peer {
|
|||||||
pub fn get_default_conn_id(&self) -> PeerConnId {
|
pub fn get_default_conn_id(&self) -> PeerConnId {
|
||||||
self.default_conn_id.load()
|
self.default_conn_id.load()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_peer_identity_type(&self) -> Option<PeerIdentityType> {
|
||||||
|
self.peer_identity_type.load()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// pritn on drop
|
// pritn on drop
|
||||||
@@ -238,17 +264,38 @@ impl Drop for Peer {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use base64::prelude::{Engine as _, BASE64_STANDARD};
|
||||||
|
use rand::rngs::OsRng;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::time::timeout;
|
use tokio::time::timeout;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
common::{global_ctx::tests::get_mock_global_ctx, new_peer_id},
|
common::{
|
||||||
|
config::{NetworkIdentity, PeerConfig},
|
||||||
|
global_ctx::{tests::get_mock_global_ctx, GlobalCtx},
|
||||||
|
new_peer_id,
|
||||||
|
},
|
||||||
peers::{create_packet_recv_chan, peer_conn::PeerConn, peer_session::PeerSessionStore},
|
peers::{create_packet_recv_chan, peer_conn::PeerConn, peer_session::PeerSessionStore},
|
||||||
|
proto::common::SecureModeConfig,
|
||||||
tunnel::ring::create_ring_tunnel_pair,
|
tunnel::ring::create_ring_tunnel_pair,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::Peer;
|
use super::Peer;
|
||||||
|
|
||||||
|
fn set_secure_mode_cfg(global_ctx: &GlobalCtx, enabled: bool) {
|
||||||
|
if !enabled {
|
||||||
|
global_ctx.config.set_secure_mode(None);
|
||||||
|
} else {
|
||||||
|
let private = x25519_dalek::StaticSecret::random_from_rng(OsRng);
|
||||||
|
let public = x25519_dalek::PublicKey::from(&private);
|
||||||
|
global_ctx.config.set_secure_mode(Some(SecureModeConfig {
|
||||||
|
enabled: true,
|
||||||
|
local_private_key: Some(BASE64_STANDARD.encode(private.as_bytes())),
|
||||||
|
local_public_key: Some(BASE64_STANDARD.encode(public.as_bytes())),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn close_peer() {
|
async fn close_peer() {
|
||||||
let (local_packet_send, _local_packet_recv) = create_packet_recv_chan();
|
let (local_packet_send, _local_packet_recv) = create_packet_recv_chan();
|
||||||
@@ -284,8 +331,8 @@ mod tests {
|
|||||||
|
|
||||||
let local_conn_id = local_peer_conn.get_conn_id();
|
let local_conn_id = local_peer_conn.get_conn_id();
|
||||||
|
|
||||||
local_peer.add_peer_conn(local_peer_conn).await;
|
local_peer.add_peer_conn(local_peer_conn).await.unwrap();
|
||||||
remote_peer.add_peer_conn(remote_peer_conn).await;
|
remote_peer.add_peer_conn(remote_peer_conn).await.unwrap();
|
||||||
|
|
||||||
assert_eq!(local_peer.list_peer_conns().await.len(), 1);
|
assert_eq!(local_peer.list_peer_conns().await.len(), 1);
|
||||||
assert_eq!(remote_peer.list_peer_conns().await.len(), 1);
|
assert_eq!(remote_peer.list_peer_conns().await.len(), 1);
|
||||||
@@ -305,4 +352,110 @@ mod tests {
|
|||||||
println!("wait for close handler");
|
println!("wait for close handler");
|
||||||
close_handler.await.unwrap().unwrap();
|
close_handler.await.unwrap().unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn reject_peer_conn_with_mismatched_identity_type() {
|
||||||
|
let (packet_send, _packet_recv) = create_packet_recv_chan();
|
||||||
|
let global_ctx = get_mock_global_ctx();
|
||||||
|
let local_peer_id = new_peer_id();
|
||||||
|
let remote_peer_id = new_peer_id();
|
||||||
|
let peer = Peer::new(remote_peer_id, packet_send, global_ctx);
|
||||||
|
|
||||||
|
let ps = Arc::new(PeerSessionStore::new());
|
||||||
|
|
||||||
|
let (shared_client_tunnel, shared_server_tunnel) = create_ring_tunnel_pair();
|
||||||
|
let shared_client_ctx = get_mock_global_ctx();
|
||||||
|
let shared_server_ctx = get_mock_global_ctx();
|
||||||
|
shared_client_ctx
|
||||||
|
.config
|
||||||
|
.set_network_identity(NetworkIdentity::new("net1".to_string(), "sec2".to_string()));
|
||||||
|
shared_server_ctx
|
||||||
|
.config
|
||||||
|
.set_network_identity(NetworkIdentity {
|
||||||
|
network_name: "net2".to_string(),
|
||||||
|
network_secret: None,
|
||||||
|
network_secret_digest: None,
|
||||||
|
});
|
||||||
|
set_secure_mode_cfg(&shared_client_ctx, true);
|
||||||
|
set_secure_mode_cfg(&shared_server_ctx, true);
|
||||||
|
let remote_url: url::Url = shared_client_tunnel
|
||||||
|
.info()
|
||||||
|
.unwrap()
|
||||||
|
.remote_addr
|
||||||
|
.unwrap()
|
||||||
|
.url
|
||||||
|
.parse()
|
||||||
|
.unwrap();
|
||||||
|
shared_client_ctx.config.set_peers(vec![PeerConfig {
|
||||||
|
uri: remote_url,
|
||||||
|
peer_public_key: Some(
|
||||||
|
shared_server_ctx
|
||||||
|
.config
|
||||||
|
.get_secure_mode()
|
||||||
|
.unwrap()
|
||||||
|
.local_public_key
|
||||||
|
.unwrap(),
|
||||||
|
),
|
||||||
|
}]);
|
||||||
|
let mut shared_client_conn = PeerConn::new(
|
||||||
|
local_peer_id,
|
||||||
|
shared_client_ctx,
|
||||||
|
Box::new(shared_client_tunnel),
|
||||||
|
ps.clone(),
|
||||||
|
);
|
||||||
|
let mut shared_server_conn = PeerConn::new(
|
||||||
|
remote_peer_id,
|
||||||
|
shared_server_ctx,
|
||||||
|
Box::new(shared_server_tunnel),
|
||||||
|
ps.clone(),
|
||||||
|
);
|
||||||
|
let (c1, s1) = tokio::join!(
|
||||||
|
shared_client_conn.do_handshake_as_client(),
|
||||||
|
shared_server_conn.do_handshake_as_server()
|
||||||
|
);
|
||||||
|
c1.unwrap();
|
||||||
|
s1.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
shared_client_conn.get_peer_identity_type(),
|
||||||
|
crate::proto::peer_rpc::PeerIdentityType::SharedNode
|
||||||
|
);
|
||||||
|
|
||||||
|
let (admin_client_tunnel, admin_server_tunnel) = create_ring_tunnel_pair();
|
||||||
|
let admin_client_ctx = get_mock_global_ctx();
|
||||||
|
let admin_server_ctx = get_mock_global_ctx();
|
||||||
|
admin_client_ctx
|
||||||
|
.config
|
||||||
|
.set_network_identity(NetworkIdentity::new("net1".to_string(), "sec2".to_string()));
|
||||||
|
admin_server_ctx
|
||||||
|
.config
|
||||||
|
.set_network_identity(NetworkIdentity::new("net1".to_string(), "sec2".to_string()));
|
||||||
|
set_secure_mode_cfg(&admin_client_ctx, true);
|
||||||
|
set_secure_mode_cfg(&admin_server_ctx, true);
|
||||||
|
let mut admin_client_conn = PeerConn::new(
|
||||||
|
local_peer_id,
|
||||||
|
admin_client_ctx,
|
||||||
|
Box::new(admin_client_tunnel),
|
||||||
|
Arc::new(PeerSessionStore::new()),
|
||||||
|
);
|
||||||
|
let mut admin_server_conn = PeerConn::new(
|
||||||
|
remote_peer_id,
|
||||||
|
admin_server_ctx,
|
||||||
|
Box::new(admin_server_tunnel),
|
||||||
|
Arc::new(PeerSessionStore::new()),
|
||||||
|
);
|
||||||
|
let (c2, s2) = tokio::join!(
|
||||||
|
admin_client_conn.do_handshake_as_client(),
|
||||||
|
admin_server_conn.do_handshake_as_server()
|
||||||
|
);
|
||||||
|
c2.unwrap();
|
||||||
|
s2.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
admin_client_conn.get_peer_identity_type(),
|
||||||
|
crate::proto::peer_rpc::PeerIdentityType::Admin
|
||||||
|
);
|
||||||
|
|
||||||
|
peer.add_peer_conn(shared_client_conn).await.unwrap();
|
||||||
|
let ret = peer.add_peer_conn(admin_client_conn).await;
|
||||||
|
assert!(ret.is_err());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+450
-56
@@ -43,7 +43,7 @@ use crate::{
|
|||||||
common::{LimiterConfig, SecureModeConfig, TunnelInfo},
|
common::{LimiterConfig, SecureModeConfig, TunnelInfo},
|
||||||
peer_rpc::{
|
peer_rpc::{
|
||||||
HandshakeRequest, PeerConnNoiseMsg1Pb, PeerConnNoiseMsg2Pb, PeerConnNoiseMsg3Pb,
|
HandshakeRequest, PeerConnNoiseMsg1Pb, PeerConnNoiseMsg2Pb, PeerConnNoiseMsg3Pb,
|
||||||
PeerConnSessionActionPb, SecureAuthLevel,
|
PeerConnSessionActionPb, PeerIdentityType, SecureAuthLevel,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
tunnel::{
|
tunnel::{
|
||||||
@@ -83,6 +83,7 @@ struct NoiseHandshakeResult {
|
|||||||
remote_static_pubkey: Vec<u8>,
|
remote_static_pubkey: Vec<u8>,
|
||||||
handshake_hash: Vec<u8>,
|
handshake_hash: Vec<u8>,
|
||||||
secure_auth_level: SecureAuthLevel,
|
secure_auth_level: SecureAuthLevel,
|
||||||
|
peer_identity_type: PeerIdentityType,
|
||||||
remote_network_name: String,
|
remote_network_name: String,
|
||||||
|
|
||||||
secret_digest: Vec<u8>,
|
secret_digest: Vec<u8>,
|
||||||
@@ -677,6 +678,99 @@ impl PeerConn {
|
|||||||
Ok(self.sink.send(pkt).await?)
|
Ok(self.sink.send(pkt).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Unified remote peer authentication verification.
|
||||||
|
///
|
||||||
|
/// Auth outcome matrix (current behavior):
|
||||||
|
///
|
||||||
|
/// | Client role | Server role | Typical credential condition | Client auth level | Server auth level | Client sees server type | Server sees client type |
|
||||||
|
/// | --- | --- | --- | --- | --- | --- | --- |
|
||||||
|
/// | Admin | Admin | same network_secret, proof verified | NetworkSecretConfirmed | NetworkSecretConfirmed | Admin | Admin |
|
||||||
|
/// | Credential | Admin | client pubkey is trusted by admin | EncryptedUnauthenticated | PeerVerified | Admin | Credential |
|
||||||
|
/// | Credential | Admin | client pubkey is unknown | handshake may fail | handshake reject | unknown | unknown |
|
||||||
|
/// | Admin | SharedNode | pinned key match | PeerVerified | EncryptedUnauthenticated | SharedNode | SharedNode |
|
||||||
|
/// | Admin | SharedNode | local has no pinned key requirement | EncryptedUnauthenticated | EncryptedUnauthenticated | SharedNode | SharedNode |
|
||||||
|
/// | Credential | SharedNode | no pin and not trusted | EncryptedUnauthenticated | EncryptedUnauthenticated | SharedNode | SharedNode |
|
||||||
|
/// | Credential | Credential | both keys trusted by admin distribution | PeerVerified | PeerVerified | Credential | Credential |
|
||||||
|
///
|
||||||
|
/// Logic (in priority order):
|
||||||
|
/// 1. **NetworkSecretConfirmed**: proof verification succeeds
|
||||||
|
/// 2. **PeerVerified**: pinned_pubkey matches and is in trusted list
|
||||||
|
/// (if no network_secret, pinned_pubkey must be in trusted list)
|
||||||
|
/// 3. **PeerVerified**: pubkey is in trusted list
|
||||||
|
/// 4. **EncryptedUnauthenticated**: initiator without network_secret
|
||||||
|
/// 5. **Reject**: none of the above
|
||||||
|
fn verify_remote_auth(
|
||||||
|
&self,
|
||||||
|
proof: Option<&[u8]>,
|
||||||
|
handshake_hash: &[u8],
|
||||||
|
remote_pubkey: &[u8],
|
||||||
|
pinned_pubkey: Option<&[u8]>,
|
||||||
|
has_network_secret: bool,
|
||||||
|
is_initiator: bool,
|
||||||
|
) -> Result<SecureAuthLevel, Error> {
|
||||||
|
// 1. Verify proof
|
||||||
|
if let Some(proof) = proof {
|
||||||
|
if let Some(mac) = self.global_ctx.get_secret_proof(handshake_hash) {
|
||||||
|
if mac.verify_slice(proof).is_ok() {
|
||||||
|
return Ok(SecureAuthLevel::NetworkSecretConfirmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check pinned pubkey
|
||||||
|
if let Some(pinned) = pinned_pubkey {
|
||||||
|
if pinned != remote_pubkey {
|
||||||
|
return Err(Error::WaitRespError(
|
||||||
|
"pinned remote static pubkey mismatch".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// If no network_secret, pinned key must be in trusted list
|
||||||
|
if !has_network_secret && !self.global_ctx.is_pubkey_trusted(remote_pubkey) {
|
||||||
|
return Err(Error::WaitRespError(
|
||||||
|
"pinned pubkey not in trusted list".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return Ok(SecureAuthLevel::PeerVerified);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Check if pubkey is in trusted list
|
||||||
|
if self.global_ctx.is_pubkey_trusted(remote_pubkey) {
|
||||||
|
return Ok(SecureAuthLevel::PeerVerified);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. If we are the initiator without network_secret, keep encrypted channel only.
|
||||||
|
if is_initiator && !has_network_secret {
|
||||||
|
return Ok(SecureAuthLevel::EncryptedUnauthenticated);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Reject
|
||||||
|
Err(Error::WaitRespError(
|
||||||
|
"authentication failed: invalid proof and unknown credential".to_owned(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn classify_remote_identity(
|
||||||
|
&self,
|
||||||
|
remote_network_name: &str,
|
||||||
|
secure_auth_level: SecureAuthLevel,
|
||||||
|
remote_role_hint_is_same_network: bool,
|
||||||
|
remote_sent_secret_proof: bool,
|
||||||
|
) -> PeerIdentityType {
|
||||||
|
if !remote_role_hint_is_same_network
|
||||||
|
|| remote_network_name != self.global_ctx.get_network_name()
|
||||||
|
{
|
||||||
|
return PeerIdentityType::SharedNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
if matches!(secure_auth_level, SecureAuthLevel::NetworkSecretConfirmed)
|
||||||
|
|| remote_sent_secret_proof
|
||||||
|
{
|
||||||
|
return PeerIdentityType::Admin;
|
||||||
|
}
|
||||||
|
|
||||||
|
PeerIdentityType::Credential
|
||||||
|
}
|
||||||
|
|
||||||
async fn do_noise_handshake_as_client(&self) -> Result<NoiseHandshakeResult, Error> {
|
async fn do_noise_handshake_as_client(&self) -> Result<NoiseHandshakeResult, Error> {
|
||||||
let prologue = b"easytier-peerconn-noise".to_vec();
|
let prologue = b"easytier-peerconn-noise".to_vec();
|
||||||
|
|
||||||
@@ -715,8 +809,6 @@ impl PeerConn {
|
|||||||
.local_private_key(&local_private_key)?
|
.local_private_key(&local_private_key)?
|
||||||
.build_initiator()?;
|
.build_initiator()?;
|
||||||
|
|
||||||
let mut secure_auth_level = SecureAuthLevel::EncryptedUnauthenticated;
|
|
||||||
|
|
||||||
self.send_noise_msg(
|
self.send_noise_msg(
|
||||||
msg1_pb,
|
msg1_pb,
|
||||||
PacketType::NoiseHandshakeMsg1,
|
PacketType::NoiseHandshakeMsg1,
|
||||||
@@ -751,29 +843,12 @@ impl PeerConn {
|
|||||||
let action = PeerConnSessionActionPb::try_from(msg2_pb.action)
|
let action = PeerConnSessionActionPb::try_from(msg2_pb.action)
|
||||||
.map_err(|_| Error::WaitRespError("invalid session action".to_owned()))?;
|
.map_err(|_| Error::WaitRespError("invalid session action".to_owned()))?;
|
||||||
let remote_network_name = msg2_pb.b_network_name.clone();
|
let remote_network_name = msg2_pb.b_network_name.clone();
|
||||||
|
let remote_sent_secret_proof = msg2_pb.secret_proof_32.is_some();
|
||||||
|
|
||||||
if remote_network_name == network.network_name {
|
if remote_network_name == network.network_name && msg2_pb.role_hint != 1 {
|
||||||
if msg2_pb.role_hint != 1 {
|
return Err(Error::WaitRespError(
|
||||||
return Err(Error::WaitRespError(
|
"role_hint must be 1 when network_name is same".to_owned(),
|
||||||
"role_hint must be 1 when network_name is same".to_owned(),
|
));
|
||||||
));
|
|
||||||
}
|
|
||||||
let Some(secret_proof_32) = msg2_pb.secret_proof_32 else {
|
|
||||||
return Err(Error::WaitRespError(
|
|
||||||
"secret_proof_32 must be present when role_hint is 1".to_owned(),
|
|
||||||
));
|
|
||||||
};
|
|
||||||
let verify_result = self
|
|
||||||
.global_ctx
|
|
||||||
.get_secret_proof(&server_handshake_hash)
|
|
||||||
.map(|mac| mac.verify_slice(&secret_proof_32).is_ok());
|
|
||||||
if verify_result != Some(true) {
|
|
||||||
return Err(Error::WaitRespError(format!(
|
|
||||||
"secret_proof_32 verify failed: {verify_result:?}"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
secure_auth_level = secure_auth_level.max(SecureAuthLevel::NetworkSecretConfirmed);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let handshake_hash_for_proof = hs.get_handshake_hash().to_vec();
|
let handshake_hash_for_proof = hs.get_handshake_hash().to_vec();
|
||||||
@@ -817,16 +892,25 @@ impl PeerConn {
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(pinned) = pinned_remote_pubkey.as_ref() {
|
// Verify server authentication using unified logic
|
||||||
if pinned.as_slice() == remote_static.as_slice() {
|
let secure_auth_level = if msg2_pb.role_hint != 1 && pinned_remote_pubkey.is_none() {
|
||||||
secure_auth_level =
|
SecureAuthLevel::EncryptedUnauthenticated
|
||||||
secure_auth_level.max(SecureAuthLevel::SharedNodePubkeyVerified);
|
} else {
|
||||||
} else {
|
self.verify_remote_auth(
|
||||||
return Err(Error::WaitRespError(
|
msg2_pb.secret_proof_32.as_deref(),
|
||||||
"pinned remote static pubkey mismatch".to_owned(),
|
&server_handshake_hash,
|
||||||
));
|
&remote_static,
|
||||||
}
|
pinned_remote_pubkey.as_deref(),
|
||||||
}
|
network.network_secret.is_some(),
|
||||||
|
true, // is_initiator
|
||||||
|
)?
|
||||||
|
};
|
||||||
|
let peer_identity_type = self.classify_remote_identity(
|
||||||
|
&remote_network_name,
|
||||||
|
secure_auth_level,
|
||||||
|
msg2_pb.role_hint == 1,
|
||||||
|
remote_sent_secret_proof,
|
||||||
|
);
|
||||||
|
|
||||||
let handshake_hash = hs.get_handshake_hash().to_vec();
|
let handshake_hash = hs.get_handshake_hash().to_vec();
|
||||||
|
|
||||||
@@ -863,6 +947,7 @@ impl PeerConn {
|
|||||||
remote_static_pubkey: remote_static,
|
remote_static_pubkey: remote_static,
|
||||||
handshake_hash,
|
handshake_hash,
|
||||||
secure_auth_level,
|
secure_auth_level,
|
||||||
|
peer_identity_type,
|
||||||
remote_network_name,
|
remote_network_name,
|
||||||
// we have authorized the peer with noise handshake, so just set secret digest same as us even remote is a shared node.
|
// we have authorized the peer with noise handshake, so just set secret digest same as us even remote is a shared node.
|
||||||
secret_digest,
|
secret_digest,
|
||||||
@@ -1043,24 +1128,6 @@ impl PeerConn {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut secure_auth_level = SecureAuthLevel::EncryptedUnauthenticated;
|
|
||||||
let Some(proof) = msg3_pb.secret_proof_32.as_ref() else {
|
|
||||||
return Err(Error::WaitRespError(
|
|
||||||
"noise msg3 secret_proof_32 is required".to_owned(),
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
if role_hint == 1 {
|
|
||||||
if let Some(mac) = self.global_ctx.get_secret_proof(&handshake_hash_for_proof) {
|
|
||||||
if mac.verify_slice(proof).is_ok() {
|
|
||||||
secure_auth_level =
|
|
||||||
secure_auth_level.max(SecureAuthLevel::NetworkSecretConfirmed);
|
|
||||||
} else {
|
|
||||||
return Err(Error::WaitRespError("invalid secret_proof".to_owned()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let remote_static = hs
|
let remote_static = hs
|
||||||
.get_remote_static()
|
.get_remote_static()
|
||||||
.map(|x: &[u8]| x.to_vec())
|
.map(|x: &[u8]| x.to_vec())
|
||||||
@@ -1074,6 +1141,30 @@ impl PeerConn {
|
|||||||
};
|
};
|
||||||
session.check_or_set_peer_static_pubkey(remote_static_key)?;
|
session.check_or_set_peer_static_pubkey(remote_static_key)?;
|
||||||
|
|
||||||
|
// Verify client authentication using unified logic
|
||||||
|
// Note: Server doesn't use pinned_pubkey since it's the responder
|
||||||
|
let secure_auth_level = if role_hint == 1 {
|
||||||
|
self.verify_remote_auth(
|
||||||
|
msg3_pb.secret_proof_32.as_deref(),
|
||||||
|
&handshake_hash_for_proof,
|
||||||
|
&remote_static,
|
||||||
|
None, // Server doesn't have pinned_remote_pubkey
|
||||||
|
self.global_ctx
|
||||||
|
.get_network_identity()
|
||||||
|
.network_secret
|
||||||
|
.is_some(),
|
||||||
|
false, // is_initiator
|
||||||
|
)?
|
||||||
|
} else {
|
||||||
|
SecureAuthLevel::EncryptedUnauthenticated
|
||||||
|
};
|
||||||
|
let peer_identity_type = self.classify_remote_identity(
|
||||||
|
&remote_network_name,
|
||||||
|
secure_auth_level,
|
||||||
|
role_hint == 1,
|
||||||
|
msg3_pb.secret_proof_32.is_some(),
|
||||||
|
);
|
||||||
|
|
||||||
let handshake_hash = hs.get_handshake_hash().to_vec();
|
let handshake_hash = hs.get_handshake_hash().to_vec();
|
||||||
|
|
||||||
Ok(NoiseHandshakeResult {
|
Ok(NoiseHandshakeResult {
|
||||||
@@ -1083,11 +1174,12 @@ impl PeerConn {
|
|||||||
remote_static_pubkey: remote_static,
|
remote_static_pubkey: remote_static,
|
||||||
handshake_hash,
|
handshake_hash,
|
||||||
secure_auth_level,
|
secure_auth_level,
|
||||||
|
peer_identity_type,
|
||||||
remote_network_name,
|
remote_network_name,
|
||||||
secret_digest: msg3_pb.secret_digest,
|
secret_digest: msg3_pb.secret_digest,
|
||||||
client_secret_proof: Some(SecretProof {
|
client_secret_proof: msg3_pb.secret_proof_32.as_ref().map(|p| SecretProof {
|
||||||
challenge: handshake_hash_for_proof,
|
challenge: handshake_hash_for_proof,
|
||||||
proof: proof.clone(),
|
proof: p.clone(),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
my_encrypt_algo: self.my_encrypt_algo.clone(),
|
my_encrypt_algo: self.my_encrypt_algo.clone(),
|
||||||
@@ -1392,9 +1484,21 @@ impl PeerConn {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|x| x.secure_auth_level as i32)
|
.map(|x| x.secure_auth_level as i32)
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
|
peer_identity_type: self
|
||||||
|
.noise_handshake_result
|
||||||
|
.as_ref()
|
||||||
|
.map(|x| x.peer_identity_type as i32)
|
||||||
|
.unwrap_or(PeerIdentityType::Admin as i32),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_peer_identity_type(&self) -> PeerIdentityType {
|
||||||
|
self.noise_handshake_result
|
||||||
|
.as_ref()
|
||||||
|
.map(|x| x.peer_identity_type)
|
||||||
|
.unwrap_or(PeerIdentityType::Admin)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn set_peer_id(&mut self, peer_id: PeerId) {
|
pub fn set_peer_id(&mut self, peer_id: PeerId) {
|
||||||
if self.info.is_some() {
|
if self.info.is_some() {
|
||||||
panic!("set_peer_id should only be called before handshake");
|
panic!("set_peer_id should only be called before handshake");
|
||||||
@@ -1758,6 +1862,14 @@ pub mod tests {
|
|||||||
s_peer.get_conn_info().secure_auth_level,
|
s_peer.get_conn_info().secure_auth_level,
|
||||||
SecureAuthLevel::NetworkSecretConfirmed as i32,
|
SecureAuthLevel::NetworkSecretConfirmed as i32,
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
c_peer.get_conn_info().peer_identity_type,
|
||||||
|
PeerIdentityType::Admin as i32,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
s_peer.get_conn_info().peer_identity_type,
|
||||||
|
PeerIdentityType::Admin as i32,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -1809,7 +1921,66 @@ pub mod tests {
|
|||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
c_peer.get_conn_info().secure_auth_level,
|
c_peer.get_conn_info().secure_auth_level,
|
||||||
SecureAuthLevel::SharedNodePubkeyVerified as i32,
|
SecureAuthLevel::PeerVerified as i32,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
c_peer.get_conn_info().peer_identity_type,
|
||||||
|
PeerIdentityType::SharedNode as i32,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
s_peer.get_conn_info().peer_identity_type,
|
||||||
|
PeerIdentityType::SharedNode as i32,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn peer_conn_secure_mode_shared_node_without_pin_is_unauthenticated() {
|
||||||
|
let (c, s) = create_ring_tunnel_pair();
|
||||||
|
|
||||||
|
let c_peer_id = new_peer_id();
|
||||||
|
let s_peer_id = new_peer_id();
|
||||||
|
|
||||||
|
let c_ctx = get_mock_global_ctx();
|
||||||
|
let s_ctx = get_mock_global_ctx();
|
||||||
|
|
||||||
|
c_ctx
|
||||||
|
.config
|
||||||
|
.set_network_identity(NetworkIdentity::new("net1".to_string(), "sec2".to_string()));
|
||||||
|
s_ctx.config.set_network_identity(NetworkIdentity {
|
||||||
|
network_name: "net2".to_string(),
|
||||||
|
network_secret: None,
|
||||||
|
network_secret_digest: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
set_secure_mode_cfg(&c_ctx, true);
|
||||||
|
set_secure_mode_cfg(&s_ctx, true);
|
||||||
|
|
||||||
|
let ps = Arc::new(PeerSessionStore::new());
|
||||||
|
let mut c_peer = PeerConn::new(c_peer_id, c_ctx, Box::new(c), ps.clone());
|
||||||
|
let mut s_peer = PeerConn::new(s_peer_id, s_ctx, Box::new(s), ps.clone());
|
||||||
|
|
||||||
|
let (c_ret, s_ret) = tokio::join!(
|
||||||
|
c_peer.do_handshake_as_client(),
|
||||||
|
s_peer.do_handshake_as_server()
|
||||||
|
);
|
||||||
|
c_ret.unwrap();
|
||||||
|
s_ret.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
c_peer.get_conn_info().secure_auth_level,
|
||||||
|
SecureAuthLevel::EncryptedUnauthenticated as i32,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
s_peer.get_conn_info().secure_auth_level,
|
||||||
|
SecureAuthLevel::EncryptedUnauthenticated as i32,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
c_peer.get_conn_info().peer_identity_type,
|
||||||
|
PeerIdentityType::SharedNode as i32,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
s_peer.get_conn_info().peer_identity_type,
|
||||||
|
PeerIdentityType::SharedNode as i32,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1903,4 +2074,227 @@ pub mod tests {
|
|||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
let _ = tokio::join!(j);
|
let _ = tokio::join!(j);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Helper: set up a credential node's GlobalCtx with a specific private key
|
||||||
|
/// (no network_secret, secure mode enabled with the given keypair)
|
||||||
|
fn set_credential_mode_cfg(
|
||||||
|
global_ctx: &GlobalCtx,
|
||||||
|
network_name: &str,
|
||||||
|
private_key: &x25519_dalek::StaticSecret,
|
||||||
|
) {
|
||||||
|
use crate::common::config::NetworkIdentity;
|
||||||
|
let public = x25519_dalek::PublicKey::from(private_key);
|
||||||
|
global_ctx
|
||||||
|
.config
|
||||||
|
.set_network_identity(NetworkIdentity::new_credential(network_name.to_string()));
|
||||||
|
global_ctx.config.set_secure_mode(Some(SecureModeConfig {
|
||||||
|
enabled: true,
|
||||||
|
local_private_key: Some(BASE64_STANDARD.encode(private_key.as_bytes())),
|
||||||
|
local_public_key: Some(BASE64_STANDARD.encode(public.as_bytes())),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test: credential node connects to admin node, admin has credential in trusted list.
|
||||||
|
/// Handshake should succeed with PeerVerified auth level on server side.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn peer_conn_credential_node_connects_to_admin() {
|
||||||
|
let (c, s) = create_ring_tunnel_pair();
|
||||||
|
|
||||||
|
let c_peer_id = new_peer_id();
|
||||||
|
let s_peer_id = new_peer_id();
|
||||||
|
|
||||||
|
// Admin node (server) has network_secret
|
||||||
|
let s_ctx = get_mock_global_ctx();
|
||||||
|
s_ctx.config.set_network_identity(NetworkIdentity::new(
|
||||||
|
"net1".to_string(),
|
||||||
|
"secret".to_string(),
|
||||||
|
));
|
||||||
|
set_secure_mode_cfg(&s_ctx, true);
|
||||||
|
|
||||||
|
// Generate a credential on admin and get the private key for the client
|
||||||
|
let (cred_id, cred_secret) = s_ctx.get_credential_manager().generate_credential(
|
||||||
|
vec!["guest".to_string()],
|
||||||
|
false,
|
||||||
|
vec![],
|
||||||
|
std::time::Duration::from_secs(3600),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Credential node (client) uses credential private key
|
||||||
|
let c_ctx = get_mock_global_ctx();
|
||||||
|
let privkey_bytes: [u8; 32] = BASE64_STANDARD
|
||||||
|
.decode(&cred_secret)
|
||||||
|
.unwrap()
|
||||||
|
.try_into()
|
||||||
|
.unwrap();
|
||||||
|
let private = x25519_dalek::StaticSecret::from(privkey_bytes);
|
||||||
|
set_credential_mode_cfg(&c_ctx, "net1", &private);
|
||||||
|
|
||||||
|
let ps = Arc::new(PeerSessionStore::new());
|
||||||
|
let mut c_peer = PeerConn::new(c_peer_id, c_ctx, Box::new(c), ps.clone());
|
||||||
|
let mut s_peer = PeerConn::new(s_peer_id, s_ctx, Box::new(s), ps.clone());
|
||||||
|
|
||||||
|
let (c_ret, s_ret) = tokio::join!(
|
||||||
|
c_peer.do_handshake_as_client(),
|
||||||
|
s_peer.do_handshake_as_server()
|
||||||
|
);
|
||||||
|
|
||||||
|
c_ret.unwrap();
|
||||||
|
s_ret.unwrap();
|
||||||
|
|
||||||
|
// Server should see credential node as PeerVerified
|
||||||
|
assert_eq!(
|
||||||
|
s_peer.get_conn_info().secure_auth_level,
|
||||||
|
SecureAuthLevel::PeerVerified as i32,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
s_peer.get_conn_info().peer_identity_type,
|
||||||
|
PeerIdentityType::Credential as i32,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Client (credential node) keeps encrypted unauthenticated level
|
||||||
|
assert_eq!(
|
||||||
|
c_peer.get_conn_info().secure_auth_level,
|
||||||
|
SecureAuthLevel::EncryptedUnauthenticated as i32,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
c_peer.get_conn_info().peer_identity_type,
|
||||||
|
PeerIdentityType::Admin as i32,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify credential ID matches
|
||||||
|
let _ = cred_id; // just to use it
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test: unknown credential node (not in trusted list) is rejected by admin.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn peer_conn_unknown_credential_rejected() {
|
||||||
|
let (c, s) = create_ring_tunnel_pair();
|
||||||
|
|
||||||
|
let c_peer_id = new_peer_id();
|
||||||
|
let s_peer_id = new_peer_id();
|
||||||
|
|
||||||
|
// Admin node (server) with no credentials generated
|
||||||
|
let s_ctx = get_mock_global_ctx();
|
||||||
|
s_ctx.config.set_network_identity(NetworkIdentity::new(
|
||||||
|
"net1".to_string(),
|
||||||
|
"secret".to_string(),
|
||||||
|
));
|
||||||
|
set_secure_mode_cfg(&s_ctx, true);
|
||||||
|
|
||||||
|
// Unknown credential node (client) with random key, not in admin's trusted list
|
||||||
|
let c_ctx = get_mock_global_ctx();
|
||||||
|
let random_private = x25519_dalek::StaticSecret::random_from_rng(OsRng);
|
||||||
|
set_credential_mode_cfg(&c_ctx, "net1", &random_private);
|
||||||
|
|
||||||
|
let ps = Arc::new(PeerSessionStore::new());
|
||||||
|
let mut c_peer = PeerConn::new(c_peer_id, c_ctx, Box::new(c), ps.clone());
|
||||||
|
let mut s_peer = PeerConn::new(s_peer_id, s_ctx, Box::new(s), ps.clone());
|
||||||
|
|
||||||
|
let (c_ret, s_ret) = tokio::join!(
|
||||||
|
c_peer.do_handshake_as_client(),
|
||||||
|
s_peer.do_handshake_as_server()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Server should reject the unknown credential
|
||||||
|
assert!(s_ret.is_err(), "server should reject unknown credential");
|
||||||
|
// Client may also fail due to connection being closed
|
||||||
|
let _ = c_ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test: two admin nodes with same network_secret still get NetworkSecretConfirmed.
|
||||||
|
/// (Regression test: credential system should not break normal admin-to-admin auth)
|
||||||
|
#[tokio::test]
|
||||||
|
async fn peer_conn_admin_to_admin_still_works() {
|
||||||
|
let (c, s) = create_ring_tunnel_pair();
|
||||||
|
|
||||||
|
let c_peer_id = new_peer_id();
|
||||||
|
let s_peer_id = new_peer_id();
|
||||||
|
|
||||||
|
let c_ctx = get_mock_global_ctx();
|
||||||
|
let s_ctx = get_mock_global_ctx();
|
||||||
|
|
||||||
|
c_ctx.config.set_network_identity(NetworkIdentity::new(
|
||||||
|
"net1".to_string(),
|
||||||
|
"secret".to_string(),
|
||||||
|
));
|
||||||
|
s_ctx.config.set_network_identity(NetworkIdentity::new(
|
||||||
|
"net1".to_string(),
|
||||||
|
"secret".to_string(),
|
||||||
|
));
|
||||||
|
|
||||||
|
set_secure_mode_cfg(&c_ctx, true);
|
||||||
|
set_secure_mode_cfg(&s_ctx, true);
|
||||||
|
|
||||||
|
let ps = Arc::new(PeerSessionStore::new());
|
||||||
|
let mut c_peer = PeerConn::new(c_peer_id, c_ctx, Box::new(c), ps.clone());
|
||||||
|
let mut s_peer = PeerConn::new(s_peer_id, s_ctx, Box::new(s), ps.clone());
|
||||||
|
|
||||||
|
let (c_ret, s_ret) = tokio::join!(
|
||||||
|
c_peer.do_handshake_as_client(),
|
||||||
|
s_peer.do_handshake_as_server()
|
||||||
|
);
|
||||||
|
|
||||||
|
c_ret.unwrap();
|
||||||
|
s_ret.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
c_peer.get_conn_info().secure_auth_level,
|
||||||
|
SecureAuthLevel::NetworkSecretConfirmed as i32,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
s_peer.get_conn_info().secure_auth_level,
|
||||||
|
SecureAuthLevel::NetworkSecretConfirmed as i32,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test: revoked credential is rejected on new connection attempt.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn peer_conn_revoked_credential_rejected() {
|
||||||
|
// Admin generates credential, then revokes it
|
||||||
|
let admin_ctx = get_mock_global_ctx();
|
||||||
|
admin_ctx.config.set_network_identity(NetworkIdentity::new(
|
||||||
|
"net1".to_string(),
|
||||||
|
"secret".to_string(),
|
||||||
|
));
|
||||||
|
set_secure_mode_cfg(&admin_ctx, true);
|
||||||
|
|
||||||
|
let (cred_id, cred_secret) = admin_ctx.get_credential_manager().generate_credential(
|
||||||
|
vec![],
|
||||||
|
false,
|
||||||
|
vec![],
|
||||||
|
std::time::Duration::from_secs(3600),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Revoke the credential
|
||||||
|
assert!(admin_ctx
|
||||||
|
.get_credential_manager()
|
||||||
|
.revoke_credential(&cred_id));
|
||||||
|
|
||||||
|
// Now try to connect with the revoked credential
|
||||||
|
let (c, s) = create_ring_tunnel_pair();
|
||||||
|
let c_peer_id = new_peer_id();
|
||||||
|
let s_peer_id = new_peer_id();
|
||||||
|
|
||||||
|
let c_ctx = get_mock_global_ctx();
|
||||||
|
let privkey_bytes: [u8; 32] = BASE64_STANDARD
|
||||||
|
.decode(&cred_secret)
|
||||||
|
.unwrap()
|
||||||
|
.try_into()
|
||||||
|
.unwrap();
|
||||||
|
let private = x25519_dalek::StaticSecret::from(privkey_bytes);
|
||||||
|
set_credential_mode_cfg(&c_ctx, "net1", &private);
|
||||||
|
|
||||||
|
let ps = Arc::new(PeerSessionStore::new());
|
||||||
|
let mut c_peer = PeerConn::new(c_peer_id, c_ctx, Box::new(c), ps.clone());
|
||||||
|
let mut s_peer = PeerConn::new(s_peer_id, admin_ctx, Box::new(s), ps.clone());
|
||||||
|
|
||||||
|
let (c_ret, s_ret) = tokio::join!(
|
||||||
|
c_peer.do_handshake_as_client(),
|
||||||
|
s_peer.do_handshake_as_server()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Server should reject the revoked credential
|
||||||
|
assert!(s_ret.is_err(), "server should reject revoked credential");
|
||||||
|
let _ = c_ret;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ use crate::{
|
|||||||
ListGlobalForeignNetworkResponse,
|
ListGlobalForeignNetworkResponse,
|
||||||
},
|
},
|
||||||
peer_rpc::{
|
peer_rpc::{
|
||||||
ForeignNetworkRouteInfoEntry, ForeignNetworkRouteInfoKey, RouteForeignNetworkSummary,
|
ForeignNetworkRouteInfoEntry, ForeignNetworkRouteInfoKey, PeerIdentityType,
|
||||||
|
RouteForeignNetworkSummary,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
tunnel::{
|
tunnel::{
|
||||||
@@ -374,12 +375,34 @@ impl PeerManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn add_new_peer_conn(&self, peer_conn: PeerConn) -> Result<(), Error> {
|
async fn add_new_peer_conn(&self, peer_conn: PeerConn) -> Result<(), Error> {
|
||||||
if self.global_ctx.get_network_identity() != peer_conn.get_network_identity() {
|
let my_identity = self.global_ctx.get_network_identity();
|
||||||
|
let peer_identity = peer_conn.get_network_identity();
|
||||||
|
|
||||||
|
// For credential nodes, network_secret_digest is either None or all-zeros
|
||||||
|
// (all-zeros when received over the wire via handshake).
|
||||||
|
// In this case, only compare network_name.
|
||||||
|
let my_digest_empty = my_identity
|
||||||
|
.network_secret_digest
|
||||||
|
.as_ref()
|
||||||
|
.is_none_or(|d| d.iter().all(|b| *b == 0));
|
||||||
|
let peer_digest_empty = peer_identity
|
||||||
|
.network_secret_digest
|
||||||
|
.as_ref()
|
||||||
|
.is_none_or(|d| d.iter().all(|b| *b == 0));
|
||||||
|
|
||||||
|
let identity_ok = if my_digest_empty || peer_digest_empty {
|
||||||
|
// Credential node: only check network_name
|
||||||
|
my_identity.network_name == peer_identity.network_name
|
||||||
|
} else {
|
||||||
|
my_identity == peer_identity
|
||||||
|
};
|
||||||
|
|
||||||
|
if !identity_ok {
|
||||||
return Err(Error::SecretKeyError(
|
return Err(Error::SecretKeyError(
|
||||||
"network identity not match".to_string(),
|
"network identity not match".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
self.peers.add_new_peer_conn(peer_conn).await;
|
self.peers.add_new_peer_conn(peer_conn).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,7 +437,7 @@ impl PeerManager {
|
|||||||
{
|
{
|
||||||
self.add_new_peer_conn(peer).await?;
|
self.add_new_peer_conn(peer).await?;
|
||||||
} else {
|
} else {
|
||||||
self.foreign_network_client.add_new_peer_conn(peer).await;
|
self.foreign_network_client.add_new_peer_conn(peer).await?;
|
||||||
}
|
}
|
||||||
Ok((peer_id, conn_id))
|
Ok((peer_id, conn_id))
|
||||||
}
|
}
|
||||||
@@ -674,6 +697,12 @@ impl PeerManager {
|
|||||||
let secure_mode_enabled = self.is_secure_mode_enabled;
|
let secure_mode_enabled = self.is_secure_mode_enabled;
|
||||||
let stats_mgr = self.global_ctx.stats_manager().clone();
|
let stats_mgr = self.global_ctx.stats_manager().clone();
|
||||||
let route = self.get_route();
|
let route = self.get_route();
|
||||||
|
let is_credential_node = self
|
||||||
|
.global_ctx
|
||||||
|
.get_network_identity()
|
||||||
|
.network_secret
|
||||||
|
.is_none()
|
||||||
|
&& secure_mode_enabled;
|
||||||
|
|
||||||
let label_set =
|
let label_set =
|
||||||
LabelSet::new().with_label_type(LabelType::NetworkName(global_ctx.get_network_name()));
|
LabelSet::new().with_label_type(LabelType::NetworkName(global_ctx.get_network_name()));
|
||||||
@@ -721,6 +750,17 @@ impl PeerManager {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Step 10b: credential nodes don't forward handshake packets
|
||||||
|
if is_credential_node
|
||||||
|
&& (hdr.packet_type == PacketType::HandShake as u8
|
||||||
|
|| hdr.packet_type == PacketType::NoiseHandshakeMsg1 as u8
|
||||||
|
|| hdr.packet_type == PacketType::NoiseHandshakeMsg2 as u8
|
||||||
|
|| hdr.packet_type == PacketType::NoiseHandshakeMsg3 as u8)
|
||||||
|
{
|
||||||
|
tracing::debug!("credential node dropping forwarded handshake packet");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if hdr.forward_counter > 2 && hdr.is_latency_first() {
|
if hdr.forward_counter > 2 && hdr.is_latency_first() {
|
||||||
tracing::trace!(?hdr, "set_latency_first false because too many hop");
|
tracing::trace!(?hdr, "set_latency_first false because too many hop");
|
||||||
hdr.set_latency_first(false);
|
hdr.set_latency_first(false);
|
||||||
@@ -934,6 +974,11 @@ impl PeerManager {
|
|||||||
self.my_peer_id
|
self.my_peer_id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_peer_identity_type(&self, peer_id: PeerId) -> Option<PeerIdentityType> {
|
||||||
|
let peer_map = self.peers.upgrade()?;
|
||||||
|
peer_map.get_peer_identity_type(peer_id)
|
||||||
|
}
|
||||||
|
|
||||||
async fn list_foreign_networks(&self) -> ForeignNetworkRouteInfoMap {
|
async fn list_foreign_networks(&self) -> ForeignNetworkRouteInfoMap {
|
||||||
let ret = DashMap::new();
|
let ret = DashMap::new();
|
||||||
let Some(foreign_mgr) = self.foreign_network_manager.upgrade() else {
|
let Some(foreign_mgr) = self.foreign_network_manager.upgrade() else {
|
||||||
@@ -1965,7 +2010,7 @@ mod tests {
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
conns.iter().any(|c| {
|
conns.iter().any(|c| {
|
||||||
c.secure_auth_level == SecureAuthLevel::SharedNodePubkeyVerified as i32
|
c.secure_auth_level == SecureAuthLevel::PeerVerified as i32
|
||||||
&& c.noise_local_static_pubkey.len() == 32
|
&& c.noise_local_static_pubkey.len() == 32
|
||||||
&& c.noise_remote_static_pubkey.len() == 32
|
&& c.noise_remote_static_pubkey.len() == 32
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use crate::{
|
|||||||
},
|
},
|
||||||
proto::{
|
proto::{
|
||||||
api::instance::{self, PeerConnInfo},
|
api::instance::{self, PeerConnInfo},
|
||||||
peer_rpc::RoutePeerInfo,
|
peer_rpc::{PeerIdentityType, RoutePeerInfo},
|
||||||
},
|
},
|
||||||
tunnel::{packet_def::ZCPacket, TunnelError},
|
tunnel::{packet_def::ZCPacket, TunnelError},
|
||||||
};
|
};
|
||||||
@@ -56,18 +56,19 @@ impl PeerMap {
|
|||||||
.issue_event(GlobalCtxEvent::PeerAdded(peer_id));
|
.issue_event(GlobalCtxEvent::PeerAdded(peer_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn add_new_peer_conn(&self, peer_conn: PeerConn) {
|
pub async fn add_new_peer_conn(&self, peer_conn: PeerConn) -> Result<(), Error> {
|
||||||
let _ = self.maintain_alive_client_urls(&peer_conn);
|
let _ = self.maintain_alive_client_urls(&peer_conn);
|
||||||
let peer_id = peer_conn.get_peer_id();
|
let peer_id = peer_conn.get_peer_id();
|
||||||
let no_entry = self.peer_map.get(&peer_id).is_none();
|
let no_entry = self.peer_map.get(&peer_id).is_none();
|
||||||
if no_entry {
|
if no_entry {
|
||||||
let new_peer = Peer::new(peer_id, self.packet_send.clone(), self.global_ctx.clone());
|
let new_peer = Peer::new(peer_id, self.packet_send.clone(), self.global_ctx.clone());
|
||||||
new_peer.add_peer_conn(peer_conn).await;
|
new_peer.add_peer_conn(peer_conn).await?;
|
||||||
self.add_new_peer(new_peer).await;
|
self.add_new_peer(new_peer).await;
|
||||||
} else {
|
} else {
|
||||||
let peer = self.peer_map.get(&peer_id).unwrap().clone();
|
let peer = self.peer_map.get(&peer_id).unwrap().clone();
|
||||||
peer.add_peer_conn(peer_conn).await;
|
peer.add_peer_conn(peer_conn).await?;
|
||||||
}
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn maintain_alive_client_urls(&self, peer_conn: &PeerConn) -> Option<()> {
|
fn maintain_alive_client_urls(&self, peer_conn: &PeerConn) -> Option<()> {
|
||||||
@@ -302,6 +303,11 @@ impl PeerMap {
|
|||||||
.map(|p| p.get_default_conn_id())
|
.map(|p| p.get_default_conn_id())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_peer_identity_type(&self, peer_id: PeerId) -> Option<PeerIdentityType> {
|
||||||
|
self.get_peer_by_id(peer_id)
|
||||||
|
.and_then(|p| p.get_peer_identity_type())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn close_peer_conn(
|
pub async fn close_peer_conn(
|
||||||
&self,
|
&self,
|
||||||
peer_id: PeerId,
|
peer_id: PeerId,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use std::{
|
use std::{
|
||||||
collections::{BTreeMap, BTreeSet, HashMap},
|
collections::{BTreeMap, BTreeSet, HashMap, HashSet},
|
||||||
fmt::Debug,
|
fmt::Debug,
|
||||||
net::{IpAddr, Ipv4Addr, Ipv6Addr},
|
net::{IpAddr, Ipv4Addr, Ipv6Addr},
|
||||||
sync::{
|
sync::{
|
||||||
@@ -43,9 +43,10 @@ use crate::{
|
|||||||
route_foreign_network_infos, route_foreign_network_summary,
|
route_foreign_network_infos, route_foreign_network_summary,
|
||||||
sync_route_info_request::ConnInfo, ForeignNetworkRouteInfoEntry,
|
sync_route_info_request::ConnInfo, ForeignNetworkRouteInfoEntry,
|
||||||
ForeignNetworkRouteInfoKey, OspfRouteRpc, OspfRouteRpcClientFactory,
|
ForeignNetworkRouteInfoKey, OspfRouteRpc, OspfRouteRpcClientFactory,
|
||||||
OspfRouteRpcServer, PeerGroupInfo, PeerIdVersion, RouteForeignNetworkInfos,
|
OspfRouteRpcServer, PeerGroupInfo, PeerIdVersion, PeerIdentityType,
|
||||||
RouteForeignNetworkSummary, RoutePeerInfo, RoutePeerInfos, SyncRouteInfoError,
|
RouteForeignNetworkInfos, RouteForeignNetworkSummary, RoutePeerInfo, RoutePeerInfos,
|
||||||
SyncRouteInfoRequest, SyncRouteInfoResponse,
|
SyncRouteInfoError, SyncRouteInfoRequest, SyncRouteInfoResponse,
|
||||||
|
TrustedCredentialPubkey,
|
||||||
},
|
},
|
||||||
rpc_types::{
|
rpc_types::{
|
||||||
self,
|
self,
|
||||||
@@ -80,6 +81,26 @@ static REMOVE_UNREACHABLE_PEER_INFO_AFTER: Duration = Duration::from_secs(90);
|
|||||||
|
|
||||||
type Version = u32;
|
type Version = u32;
|
||||||
|
|
||||||
|
/// Check if `child` CIDR is a subset of `parent` CIDR (both as string representations).
|
||||||
|
/// Returns true if child is contained within parent, or if they are equal.
|
||||||
|
fn cidr_is_subset_str(child: &str, parent: &str) -> bool {
|
||||||
|
let Ok(child_cidr) = child.parse::<IpCidr>() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Ok(parent_cidr) = parent.parse::<IpCidr>() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
match (child_cidr, parent_cidr) {
|
||||||
|
(IpCidr::V4(c), IpCidr::V4(p)) => {
|
||||||
|
p.first_address() <= c.first_address() && c.last_address() <= p.last_address()
|
||||||
|
}
|
||||||
|
(IpCidr::V6(c), IpCidr::V6(p)) => {
|
||||||
|
p.first_address() <= c.first_address() && c.last_address() <= p.last_address()
|
||||||
|
}
|
||||||
|
_ => false, // mixed v4/v6
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
struct AtomicVersion(Arc<AtomicU32>);
|
struct AtomicVersion(Arc<AtomicU32>);
|
||||||
|
|
||||||
@@ -147,6 +168,7 @@ impl RoutePeerInfo {
|
|||||||
|
|
||||||
quic_port: None,
|
quic_port: None,
|
||||||
noise_static_pubkey: Vec::new(),
|
noise_static_pubkey: Vec::new(),
|
||||||
|
trusted_credential_pubkeys: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,6 +228,17 @@ impl RoutePeerInfo {
|
|||||||
|
|
||||||
noise_static_pubkey,
|
noise_static_pubkey,
|
||||||
|
|
||||||
|
// Only admin nodes (holding network_secret) publish trusted credential pubkeys
|
||||||
|
trusted_credential_pubkeys: if global_ctx
|
||||||
|
.get_network_identity()
|
||||||
|
.network_secret
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
global_ctx.get_credential_manager().get_trusted_pubkeys()
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
},
|
||||||
|
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -336,6 +369,10 @@ struct SyncedRouteInfo {
|
|||||||
group_trust_map: DashMap<PeerId, HashMap<String, Vec<u8>>>,
|
group_trust_map: DashMap<PeerId, HashMap<String, Vec<u8>>>,
|
||||||
group_trust_map_cache: DashMap<PeerId, Arc<Vec<String>>>, // cache for group trust map, should sync with group_trust_map
|
group_trust_map_cache: DashMap<PeerId, Arc<Vec<String>>>, // cache for group trust map, should sync with group_trust_map
|
||||||
|
|
||||||
|
// Aggregated trusted credential pubkeys from all admin nodes
|
||||||
|
// Maps pubkey bytes -> TrustedCredentialPubkey
|
||||||
|
trusted_credential_pubkeys: DashMap<Vec<u8>, TrustedCredentialPubkey>,
|
||||||
|
|
||||||
version: AtomicVersion,
|
version: AtomicVersion,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,6 +389,19 @@ impl Debug for SyncedRouteInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl SyncedRouteInfo {
|
impl SyncedRouteInfo {
|
||||||
|
fn mark_credential_peer(info: &mut RoutePeerInfo, is_credential_peer: bool) {
|
||||||
|
let mut feature_flag = info.feature_flag.unwrap_or_default();
|
||||||
|
feature_flag.is_credential_peer = is_credential_peer;
|
||||||
|
info.feature_flag = Some(feature_flag);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_credential_peer_info(info: &RoutePeerInfo) -> bool {
|
||||||
|
info.feature_flag
|
||||||
|
.as_ref()
|
||||||
|
.map(|x| x.is_credential_peer)
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
fn get_connected_peers<T: FromIterator<PeerId>>(&self, peer_id: PeerId) -> Option<T> {
|
fn get_connected_peers<T: FromIterator<PeerId>>(&self, peer_id: PeerId) -> Option<T> {
|
||||||
self.conn_map
|
self.conn_map
|
||||||
.read()
|
.read()
|
||||||
@@ -830,6 +880,160 @@ impl SyncedRouteInfo {
|
|||||||
self.group_trust_map_cache
|
self.group_trust_map_cache
|
||||||
.insert(my_peer_id, Arc::new(my_group_names));
|
.insert(my_peer_id, Arc::new(my_group_names));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Collect trusted credential pubkeys from admin nodes (network_secret holders)
|
||||||
|
/// and verify credential peers. Returns set of peer_ids that should be removed.
|
||||||
|
/// Also returns a HashMap of trusted keys for synchronization to GlobalCtx.
|
||||||
|
fn verify_and_update_credential_trusts(
|
||||||
|
&self,
|
||||||
|
) -> (
|
||||||
|
Vec<PeerId>,
|
||||||
|
HashMap<Vec<u8>, crate::common::global_ctx::TrustedKeyMetadata>,
|
||||||
|
) {
|
||||||
|
use crate::common::global_ctx::{TrustedKeyMetadata, TrustedKeySource};
|
||||||
|
|
||||||
|
let now = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs() as i64;
|
||||||
|
|
||||||
|
// Step 1: Collect trusted credential pubkeys from admin nodes (take union)
|
||||||
|
// Only trust nodes whose secret_digest matches ours (i.e. they hold network_secret)
|
||||||
|
let mut all_trusted: HashMap<Vec<u8>, TrustedCredentialPubkey> = HashMap::new();
|
||||||
|
// Also collect all peer pubkeys for GlobalCtx synchronization
|
||||||
|
let mut global_trusted_keys: HashMap<Vec<u8>, TrustedKeyMetadata> = HashMap::new();
|
||||||
|
|
||||||
|
let peer_infos = self.peer_infos.read();
|
||||||
|
|
||||||
|
for (_, info) in peer_infos.iter() {
|
||||||
|
if !self.is_admin_peer(info) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Collect all peer noise_static_pubkeys as trusted keys
|
||||||
|
if !info.noise_static_pubkey.is_empty() {
|
||||||
|
global_trusted_keys.insert(
|
||||||
|
info.noise_static_pubkey.clone(),
|
||||||
|
TrustedKeyMetadata {
|
||||||
|
source: TrustedKeySource::OspfNode,
|
||||||
|
expiry_unix: None, // Peer pubkeys never expire
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for tc in &info.trusted_credential_pubkeys {
|
||||||
|
if tc.expiry_unix > now {
|
||||||
|
all_trusted
|
||||||
|
.entry(tc.pubkey.clone())
|
||||||
|
.or_insert_with(|| tc.clone());
|
||||||
|
// Also add to global trusted keys
|
||||||
|
global_trusted_keys.insert(
|
||||||
|
tc.pubkey.clone(),
|
||||||
|
TrustedKeyMetadata {
|
||||||
|
source: TrustedKeySource::OspfCredential,
|
||||||
|
expiry_unix: Some(tc.expiry_unix),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the previous trusted set to detect revoked credentials
|
||||||
|
let prev_trusted: HashSet<Vec<u8>> = self
|
||||||
|
.trusted_credential_pubkeys
|
||||||
|
.iter()
|
||||||
|
.map(|r| r.key().clone())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Update the trusted_credential_pubkeys map
|
||||||
|
self.trusted_credential_pubkeys.clear();
|
||||||
|
for (k, v) in &all_trusted {
|
||||||
|
self.trusted_credential_pubkeys.insert(k.clone(), v.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Update group trust map for credential peers
|
||||||
|
// Credential peers get their groups from the TrustedCredentialPubkey declaration
|
||||||
|
for (_, info) in peer_infos.iter() {
|
||||||
|
if info.noise_static_pubkey.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(tc) = all_trusted.get(&info.noise_static_pubkey) {
|
||||||
|
// This peer is a credential peer, assign groups from credential declaration
|
||||||
|
if !tc.groups.is_empty() {
|
||||||
|
let mut group_map = HashMap::new();
|
||||||
|
let mut group_names = Vec::new();
|
||||||
|
for g in &tc.groups {
|
||||||
|
group_map.insert(g.clone(), Vec::new()); // no proof needed, admin-declared
|
||||||
|
group_names.push(g.clone());
|
||||||
|
}
|
||||||
|
self.group_trust_map.insert(info.peer_id, group_map);
|
||||||
|
self.group_trust_map_cache
|
||||||
|
.insert(info.peer_id, Arc::new(group_names));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Find and remove peers with revoked/expired credentials.
|
||||||
|
// A peer is untrusted if:
|
||||||
|
// - Its noise_static_pubkey was in the PREVIOUS trusted set (it was a credential peer)
|
||||||
|
// - Its noise_static_pubkey is NOT in the CURRENT trusted set (credential revoked/expired)
|
||||||
|
let mut untrusted_peers = Vec::new();
|
||||||
|
for (peer_id, info) in peer_infos.iter() {
|
||||||
|
if info.noise_static_pubkey.is_empty() || info.version == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Only remove peers whose pubkey was previously trusted but no longer is
|
||||||
|
if prev_trusted.contains(&info.noise_static_pubkey)
|
||||||
|
&& !all_trusted.contains_key(&info.noise_static_pubkey)
|
||||||
|
{
|
||||||
|
untrusted_peers.push(*peer_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove untrusted peers from peer_infos so they won't appear in route graph
|
||||||
|
if !untrusted_peers.is_empty() {
|
||||||
|
drop(peer_infos); // release read lock before writing
|
||||||
|
let mut peer_infos_write = self.peer_infos.write();
|
||||||
|
for peer_id in &untrusted_peers {
|
||||||
|
tracing::warn!(?peer_id, "removing untrusted peer from route info");
|
||||||
|
peer_infos_write.remove(peer_id);
|
||||||
|
self.raw_peer_infos.remove(peer_id);
|
||||||
|
}
|
||||||
|
drop(peer_infos_write);
|
||||||
|
// Also remove from conn_map
|
||||||
|
let mut conn_map = self.conn_map.write();
|
||||||
|
for peer_id in &untrusted_peers {
|
||||||
|
conn_map.remove(peer_id);
|
||||||
|
}
|
||||||
|
self.version.inc();
|
||||||
|
}
|
||||||
|
|
||||||
|
(untrusted_peers, global_trusted_keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_admin_peer(&self, info: &RoutePeerInfo) -> bool {
|
||||||
|
if info.version == 0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
!Self::is_credential_peer_info(info)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_credential_peer(&self, peer_id: PeerId) -> bool {
|
||||||
|
let peer_infos = self.peer_infos.read();
|
||||||
|
peer_infos
|
||||||
|
.get(&peer_id)
|
||||||
|
.map(Self::is_credential_peer_info)
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_credential_info(&self, peer_id: PeerId) -> Option<TrustedCredentialPubkey> {
|
||||||
|
let peer_infos = self.peer_infos.read();
|
||||||
|
let info = peer_infos.get(&peer_id)?;
|
||||||
|
if info.noise_static_pubkey.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
self.trusted_credential_pubkeys
|
||||||
|
.get(&info.noise_static_pubkey)
|
||||||
|
.map(|r| r.value().clone())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type PeerGraph = Graph<PeerId, usize, Directed>;
|
type PeerGraph = Graph<PeerId, usize, Directed>;
|
||||||
@@ -977,6 +1181,14 @@ impl RouteTable {
|
|||||||
start_node: &NodeIndex,
|
start_node: &NodeIndex,
|
||||||
version: Version,
|
version: Version,
|
||||||
) {
|
) {
|
||||||
|
if graph.node_weight(*start_node).is_none() {
|
||||||
|
tracing::warn!(
|
||||||
|
?start_node,
|
||||||
|
version,
|
||||||
|
"invalid start node for least-hop route rebuild"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
let normalize_edge_cost = |e: petgraph::graph::EdgeReference<usize>| {
|
let normalize_edge_cost = |e: petgraph::graph::EdgeReference<usize>| {
|
||||||
if *e.weight() >= AVOID_RELAY_COST {
|
if *e.weight() >= AVOID_RELAY_COST {
|
||||||
AVOID_RELAY_COST + 1
|
AVOID_RELAY_COST + 1
|
||||||
@@ -1020,6 +1232,14 @@ impl RouteTable {
|
|||||||
start_node: &NodeIndex,
|
start_node: &NodeIndex,
|
||||||
version: Version,
|
version: Version,
|
||||||
) {
|
) {
|
||||||
|
if graph.node_weight(*start_node).is_none() {
|
||||||
|
tracing::warn!(
|
||||||
|
?start_node,
|
||||||
|
version,
|
||||||
|
"invalid start node for least-cost route rebuild"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
let (costs, next_hops) = dijkstra_with_first_hop(&graph, *start_node, |e| *e.weight());
|
let (costs, next_hops) = dijkstra_with_first_hop(&graph, *start_node, |e| *e.weight());
|
||||||
|
|
||||||
for (dst, (next_hop, path_len)) in next_hops.iter() {
|
for (dst, (next_hop, path_len)) in next_hops.iter() {
|
||||||
@@ -1058,6 +1278,18 @@ impl RouteTable {
|
|||||||
|
|
||||||
if graph.node_count() == 0 {
|
if graph.node_count() == 0 {
|
||||||
tracing::warn!("no peer in graph, cannot build next hop map");
|
tracing::warn!("no peer in graph, cannot build next hop map");
|
||||||
|
self.next_hop_map_version.set_if_larger(version);
|
||||||
|
self.clean_expired_route_info();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if start_node == NodeIndex::end() {
|
||||||
|
tracing::warn!(
|
||||||
|
?my_peer_id,
|
||||||
|
version,
|
||||||
|
"my peer id is missing in graph, skip next-hop rebuild this round"
|
||||||
|
);
|
||||||
|
self.next_hop_map_version.set_if_larger(version);
|
||||||
|
self.clean_expired_route_info();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1596,6 +1828,7 @@ impl PeerRouteServiceImpl {
|
|||||||
foreign_network: DashMap::new(),
|
foreign_network: DashMap::new(),
|
||||||
group_trust_map: DashMap::new(),
|
group_trust_map: DashMap::new(),
|
||||||
group_trust_map_cache: DashMap::new(),
|
group_trust_map_cache: DashMap::new(),
|
||||||
|
trusted_credential_pubkeys: DashMap::new(),
|
||||||
version: AtomicVersion::new(),
|
version: AtomicVersion::new(),
|
||||||
},
|
},
|
||||||
cached_local_conn_map: std::sync::Mutex::new(RouteConnBitmap::default()),
|
cached_local_conn_map: std::sync::Mutex::new(RouteConnBitmap::default()),
|
||||||
@@ -1607,6 +1840,24 @@ impl PeerRouteServiceImpl {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn get_my_secret_digest(&self) -> Option<Vec<u8>> {
|
||||||
|
let ni = self.global_ctx.get_network_identity();
|
||||||
|
ni.network_secret_digest.map(|d| d.to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_credential_node(&self) -> bool {
|
||||||
|
self.global_ctx
|
||||||
|
.get_network_identity()
|
||||||
|
.network_secret
|
||||||
|
.is_none()
|
||||||
|
&& self
|
||||||
|
.global_ctx
|
||||||
|
.config
|
||||||
|
.get_secure_mode()
|
||||||
|
.map(|c| c.enabled)
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
fn get_or_create_session(&self, dst_peer_id: PeerId) -> Arc<SyncRouteSession> {
|
fn get_or_create_session(&self, dst_peer_id: PeerId) -> Arc<SyncRouteSession> {
|
||||||
self.sessions
|
self.sessions
|
||||||
.entry(dst_peer_id)
|
.entry(dst_peer_id)
|
||||||
@@ -1640,29 +1891,31 @@ impl PeerRouteServiceImpl {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_peer_identity_type_from_interface(
|
||||||
|
&self,
|
||||||
|
peer_id: PeerId,
|
||||||
|
) -> Option<PeerIdentityType> {
|
||||||
|
self.interface
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.get_peer_identity_type(peer_id)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
fn update_my_peer_info(&self) -> bool {
|
fn update_my_peer_info(&self) -> bool {
|
||||||
if self.synced_route_info.update_my_peer_info(
|
self.synced_route_info.update_my_peer_info(
|
||||||
self.my_peer_id,
|
self.my_peer_id,
|
||||||
self.my_peer_route_id,
|
self.my_peer_route_id,
|
||||||
&self.global_ctx,
|
&self.global_ctx,
|
||||||
) {
|
)
|
||||||
self.update_route_table_and_cached_local_conn_bitmap();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn update_my_conn_info(&self) -> bool {
|
async fn update_my_conn_info(&self) -> bool {
|
||||||
let connected_peers: BTreeSet<PeerId> = self.list_peers_from_interface().await;
|
let connected_peers: BTreeSet<PeerId> = self.list_peers_from_interface().await;
|
||||||
let updated = self
|
self.synced_route_info
|
||||||
.synced_route_info
|
.update_my_conn_info(self.my_peer_id, connected_peers)
|
||||||
.update_my_conn_info(self.my_peer_id, connected_peers);
|
|
||||||
|
|
||||||
if updated {
|
|
||||||
self.update_route_table_and_cached_local_conn_bitmap();
|
|
||||||
}
|
|
||||||
|
|
||||||
updated
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn update_my_foreign_network(&self) -> bool {
|
async fn update_my_foreign_network(&self) -> bool {
|
||||||
@@ -1921,15 +2174,6 @@ impl PeerRouteServiceImpl {
|
|||||||
// stop iter if last_update of conn info is older than session.last_sync_succ_timestamp
|
// stop iter if last_update of conn info is older than session.last_sync_succ_timestamp
|
||||||
let last_update = TryInto::<SystemTime>::try_into(conn_info.last_update).unwrap();
|
let last_update = TryInto::<SystemTime>::try_into(conn_info.last_update).unwrap();
|
||||||
if last_sync_succ_timestamp.is_some_and(|t| last_update < t) {
|
if last_sync_succ_timestamp.is_some_and(|t| last_update < t) {
|
||||||
tracing::debug!(
|
|
||||||
"ignore conn info {:?} because last_update: {:?} is older than last_sync_succ_timestamp: {:?}, conn_map count: {}, my_peer_id: {:?}, session: {:?}",
|
|
||||||
conn_info,
|
|
||||||
last_update,
|
|
||||||
last_sync_succ_timestamp,
|
|
||||||
conn_map.len(),
|
|
||||||
self.my_peer_id,
|
|
||||||
session
|
|
||||||
);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2012,7 +2256,16 @@ impl PeerRouteServiceImpl {
|
|||||||
let my_peer_info_updated = self.update_my_peer_info();
|
let my_peer_info_updated = self.update_my_peer_info();
|
||||||
let my_conn_info_updated = self.update_my_conn_info().await;
|
let my_conn_info_updated = self.update_my_conn_info().await;
|
||||||
let my_foreign_network_updated = self.update_my_foreign_network().await;
|
let my_foreign_network_updated = self.update_my_foreign_network().await;
|
||||||
if my_conn_info_updated || my_peer_info_updated {
|
let mut untrusted_changed = false;
|
||||||
|
if my_peer_info_updated {
|
||||||
|
let (untrusted, global_trusted_keys) =
|
||||||
|
self.synced_route_info.verify_and_update_credential_trusts();
|
||||||
|
self.global_ctx.update_trusted_keys(global_trusted_keys);
|
||||||
|
untrusted_changed = !untrusted.is_empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
if my_peer_info_updated || my_conn_info_updated || untrusted_changed {
|
||||||
|
self.update_route_table_and_cached_local_conn_bitmap();
|
||||||
self.update_foreign_network_owner_map();
|
self.update_foreign_network_owner_map();
|
||||||
}
|
}
|
||||||
if my_peer_info_updated {
|
if my_peer_info_updated {
|
||||||
@@ -2168,7 +2421,7 @@ impl PeerRouteServiceImpl {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
tracing::debug!(?foreign_network, "sync_route request need send to peer. my_id {:?}, pper_id: {:?}, peer_infos: {:?}, conn_info: {:?}, synced_route_info: {:?} session: {:?}",
|
tracing::debug!(?foreign_network, "sync_route request need send to peer. my_id {:?}, dst_peer_id: {:?}, peer_infos: {:?}, conn_info: {:?}, synced_route_info: {:?} session: {:?}",
|
||||||
my_peer_id, dst_peer_id, peer_infos, conn_info, self.synced_route_info, session);
|
my_peer_id, dst_peer_id, peer_infos, conn_info, self.synced_route_info, session);
|
||||||
|
|
||||||
session
|
session
|
||||||
@@ -2504,16 +2757,28 @@ impl RouteSessionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// find peer_ids that are not initiators.
|
// find peer_ids that are not initiators.
|
||||||
let initiator_candidates = peers
|
let mut initiator_candidates = Vec::new();
|
||||||
.iter()
|
for peer_id in peers.iter().copied() {
|
||||||
.filter(|x| {
|
// Step 9a: Filter OSPF session candidates based on direct auth level.
|
||||||
let Some(session) = service_impl.get_session(**x) else {
|
// - Credential nodes only initiate sessions to admin nodes (not other credential nodes)
|
||||||
return true;
|
// - Admin nodes don't initiate sessions to credential nodes
|
||||||
};
|
let identity_type = service_impl
|
||||||
!session.dst_is_initiator.load(Ordering::Relaxed)
|
.get_peer_identity_type_from_interface(peer_id)
|
||||||
})
|
.await
|
||||||
.copied()
|
.unwrap_or(PeerIdentityType::Admin);
|
||||||
.collect::<Vec<_>>();
|
if matches!(identity_type, PeerIdentityType::Credential) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(session) = service_impl.get_session(peer_id) else {
|
||||||
|
initiator_candidates.push(peer_id);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
if !session.dst_is_initiator.load(Ordering::Relaxed) {
|
||||||
|
initiator_candidates.push(peer_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if initiator_candidates.is_empty() {
|
if initiator_candidates.is_empty() {
|
||||||
next_sleep_ms = 1000;
|
next_sleep_ms = 1000;
|
||||||
@@ -2626,6 +2891,12 @@ impl RouteSessionManager {
|
|||||||
let my_peer_id = service_impl.my_peer_id;
|
let my_peer_id = service_impl.my_peer_id;
|
||||||
let session = self.get_or_start_session(from_peer_id)?;
|
let session = self.get_or_start_session(from_peer_id)?;
|
||||||
|
|
||||||
|
let from_identity_type = service_impl
|
||||||
|
.get_peer_identity_type_from_interface(from_peer_id)
|
||||||
|
.await
|
||||||
|
.unwrap_or(PeerIdentityType::Admin);
|
||||||
|
let from_is_credential = matches!(from_identity_type, PeerIdentityType::Credential);
|
||||||
|
|
||||||
let _session_lock = session.lock.lock();
|
let _session_lock = session.lock.lock();
|
||||||
|
|
||||||
session.rpc_rx_count.fetch_add(1, Ordering::Relaxed);
|
session.rpc_rx_count.fetch_add(1, Ordering::Relaxed);
|
||||||
@@ -2635,38 +2906,119 @@ impl RouteSessionManager {
|
|||||||
let mut need_update_route_table = false;
|
let mut need_update_route_table = false;
|
||||||
|
|
||||||
if let Some(peer_infos) = &peer_infos {
|
if let Some(peer_infos) = &peer_infos {
|
||||||
|
// Step 9b: credential peers can only propagate their own route info
|
||||||
|
let normalize_raw = |info: &RoutePeerInfo| {
|
||||||
|
let mut raw = DynamicMessage::new(RoutePeerInfo::default().descriptor());
|
||||||
|
raw.transcode_from(info).unwrap();
|
||||||
|
raw
|
||||||
|
};
|
||||||
|
let normalized_peer_infos: Vec<RoutePeerInfo>;
|
||||||
|
let normalized_raw_peer_infos: Vec<DynamicMessage>;
|
||||||
|
let (pi, rpi) = if from_is_credential {
|
||||||
|
let allowed_cidrs = service_impl
|
||||||
|
.synced_route_info
|
||||||
|
.get_credential_info(from_peer_id)
|
||||||
|
.map(|tc| tc.allowed_proxy_cidrs.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
normalized_peer_infos = peer_infos
|
||||||
|
.iter()
|
||||||
|
.filter(|info| info.peer_id == from_peer_id)
|
||||||
|
.cloned()
|
||||||
|
.map(|mut info| {
|
||||||
|
// Filter proxy_cidrs to only those allowed by credential
|
||||||
|
if !allowed_cidrs.is_empty() {
|
||||||
|
info.proxy_cidrs.retain(|cidr| {
|
||||||
|
allowed_cidrs
|
||||||
|
.iter()
|
||||||
|
.any(|allowed| cidr_is_subset_str(cidr, allowed))
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// No allowed_proxy_cidrs → no proxy_cidrs allowed
|
||||||
|
info.proxy_cidrs.clear();
|
||||||
|
}
|
||||||
|
SyncedRouteInfo::mark_credential_peer(&mut info, true);
|
||||||
|
info
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
normalized_raw_peer_infos =
|
||||||
|
normalized_peer_infos.iter().map(normalize_raw).collect();
|
||||||
|
(&normalized_peer_infos, &normalized_raw_peer_infos)
|
||||||
|
} else {
|
||||||
|
let mut peer_infos_mut = peer_infos.clone();
|
||||||
|
let mut raw_peer_infos_mut = raw_peer_infos
|
||||||
|
.as_ref()
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| peer_infos_mut.iter().map(normalize_raw).collect());
|
||||||
|
if let Some((idx, info)) = peer_infos_mut
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.find(|(_, info)| info.peer_id == from_peer_id)
|
||||||
|
{
|
||||||
|
let mut info = info.clone();
|
||||||
|
SyncedRouteInfo::mark_credential_peer(&mut info, false);
|
||||||
|
peer_infos_mut[idx] = info.clone();
|
||||||
|
raw_peer_infos_mut[idx] = normalize_raw(&info);
|
||||||
|
}
|
||||||
|
normalized_peer_infos = peer_infos_mut;
|
||||||
|
normalized_raw_peer_infos = raw_peer_infos_mut;
|
||||||
|
(&normalized_peer_infos, &normalized_raw_peer_infos)
|
||||||
|
};
|
||||||
|
|
||||||
service_impl.synced_route_info.update_peer_infos(
|
service_impl.synced_route_info.update_peer_infos(
|
||||||
my_peer_id,
|
my_peer_id,
|
||||||
service_impl.my_peer_route_id,
|
service_impl.my_peer_route_id,
|
||||||
from_peer_id,
|
from_peer_id,
|
||||||
peer_infos,
|
pi,
|
||||||
raw_peer_infos.as_ref().unwrap(),
|
rpi,
|
||||||
)?;
|
)?;
|
||||||
service_impl
|
service_impl
|
||||||
.synced_route_info
|
.synced_route_info
|
||||||
.verify_and_update_group_trusts(
|
.verify_and_update_group_trusts(
|
||||||
peer_infos,
|
pi,
|
||||||
&service_impl.global_ctx.get_acl_group_declarations(),
|
&service_impl.global_ctx.get_acl_group_declarations(),
|
||||||
);
|
);
|
||||||
session.update_dst_saved_peer_info_version(peer_infos, from_peer_id);
|
session.update_dst_saved_peer_info_version(pi, from_peer_id);
|
||||||
need_update_route_table = true;
|
need_update_route_table = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Step 9b: credential peers' conn_info depends on allow_relay flag
|
||||||
if let Some(conn_info) = &conn_info {
|
if let Some(conn_info) = &conn_info {
|
||||||
service_impl.synced_route_info.update_conn_info(conn_info);
|
let accept_conn_info = if from_is_credential {
|
||||||
session.update_dst_saved_conn_info_version(conn_info, from_peer_id);
|
service_impl
|
||||||
need_update_route_table = true;
|
.synced_route_info
|
||||||
|
.get_credential_info(from_peer_id)
|
||||||
|
.map(|tc| tc.allow_relay)
|
||||||
|
.unwrap_or(false)
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
};
|
||||||
|
if accept_conn_info {
|
||||||
|
service_impl.synced_route_info.update_conn_info(conn_info);
|
||||||
|
session.update_dst_saved_conn_info_version(conn_info, from_peer_id);
|
||||||
|
need_update_route_table = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if need_update_route_table {
|
if need_update_route_table {
|
||||||
|
// Run credential verification and update route table
|
||||||
|
let (_untrusted, global_trusted_keys) = service_impl
|
||||||
|
.synced_route_info
|
||||||
|
.verify_and_update_credential_trusts();
|
||||||
|
// Sync trusted keys to GlobalCtx for handshake verification
|
||||||
|
service_impl
|
||||||
|
.global_ctx
|
||||||
|
.update_trusted_keys(global_trusted_keys);
|
||||||
service_impl.update_route_table_and_cached_local_conn_bitmap();
|
service_impl.update_route_table_and_cached_local_conn_bitmap();
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(foreign_network) = &foreign_network {
|
if let Some(foreign_network) = &foreign_network {
|
||||||
service_impl
|
// Step 9b: credential peers' foreign_network_infos are always ignored
|
||||||
.synced_route_info
|
if !from_is_credential {
|
||||||
.update_foreign_network(foreign_network);
|
service_impl
|
||||||
session.update_dst_saved_foreign_network_version(foreign_network, from_peer_id);
|
.synced_route_info
|
||||||
|
.update_foreign_network(foreign_network);
|
||||||
|
session.update_dst_saved_foreign_network_version(foreign_network, from_peer_id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if need_update_route_table || foreign_network.is_some() {
|
if need_update_route_table || foreign_network.is_some() {
|
||||||
@@ -3041,12 +3393,15 @@ mod tests {
|
|||||||
create_packet_recv_chan,
|
create_packet_recv_chan,
|
||||||
peer_manager::{PeerManager, RouteAlgoType},
|
peer_manager::{PeerManager, RouteAlgoType},
|
||||||
peer_ospf_route::{PeerIdVersion, PeerRouteServiceImpl, FORCE_USE_CONN_LIST},
|
peer_ospf_route::{PeerIdVersion, PeerRouteServiceImpl, FORCE_USE_CONN_LIST},
|
||||||
route_trait::{NextHopPolicy, Route, RouteCostCalculatorInterface},
|
route_trait::{NextHopPolicy, Route, RouteCostCalculatorInterface, RouteInterface},
|
||||||
tests::{connect_peer_manager, create_mock_peer_manager, wait_route_appear},
|
tests::{connect_peer_manager, create_mock_peer_manager, wait_route_appear},
|
||||||
},
|
},
|
||||||
proto::{
|
proto::{
|
||||||
common::NatType,
|
common::{NatType, PeerFeatureFlag},
|
||||||
peer_rpc::{RoutePeerInfo, RoutePeerInfos, SyncRouteInfoRequest},
|
peer_rpc::{
|
||||||
|
PeerIdentityType, RoutePeerInfo, RoutePeerInfos, SyncRouteInfoRequest,
|
||||||
|
TrustedCredentialPubkey,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
tunnel::common::tests::wait_for_condition,
|
tunnel::common::tests::wait_for_condition,
|
||||||
};
|
};
|
||||||
@@ -3054,6 +3409,26 @@ mod tests {
|
|||||||
|
|
||||||
use super::PeerRoute;
|
use super::PeerRoute;
|
||||||
|
|
||||||
|
struct AuthOnlyInterface {
|
||||||
|
my_peer_id: PeerId,
|
||||||
|
identity_type: DashMap<PeerId, PeerIdentityType>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl RouteInterface for AuthOnlyInterface {
|
||||||
|
async fn list_peers(&self) -> Vec<PeerId> {
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn my_peer_id(&self) -> PeerId {
|
||||||
|
self.my_peer_id
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_peer_identity_type(&self, peer_id: PeerId) -> Option<PeerIdentityType> {
|
||||||
|
self.identity_type.get(&peer_id).map(|x| *x.value())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn create_mock_route(peer_mgr: Arc<PeerManager>) -> Arc<PeerRoute> {
|
async fn create_mock_route(peer_mgr: Arc<PeerManager>) -> Arc<PeerRoute> {
|
||||||
let peer_route = PeerRoute::new(
|
let peer_route = PeerRoute::new(
|
||||||
peer_mgr.my_peer_id(),
|
peer_mgr.my_peer_id(),
|
||||||
@@ -3098,6 +3473,213 @@ mod tests {
|
|||||||
assert!(rx1 <= max_rx);
|
assert!(rx1 <= max_rx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn credential_flag_controls_role_classification() {
|
||||||
|
let service_impl = PeerRouteServiceImpl::new(1, get_mock_global_ctx());
|
||||||
|
|
||||||
|
let mut admin_info = RoutePeerInfo::new();
|
||||||
|
admin_info.peer_id = 10;
|
||||||
|
admin_info.version = 1;
|
||||||
|
admin_info.feature_flag = Some(PeerFeatureFlag {
|
||||||
|
is_credential_peer: false,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut credential_info = RoutePeerInfo::new();
|
||||||
|
credential_info.peer_id = 11;
|
||||||
|
credential_info.version = 1;
|
||||||
|
credential_info.feature_flag = Some(PeerFeatureFlag {
|
||||||
|
is_credential_peer: true,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut guard = service_impl.synced_route_info.peer_infos.write();
|
||||||
|
guard.insert(admin_info.peer_id, admin_info.clone());
|
||||||
|
guard.insert(credential_info.peer_id, credential_info.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(service_impl.synced_route_info.is_admin_peer(&admin_info));
|
||||||
|
assert!(!service_impl
|
||||||
|
.synced_route_info
|
||||||
|
.is_admin_peer(&credential_info));
|
||||||
|
assert!(service_impl
|
||||||
|
.synced_route_info
|
||||||
|
.is_credential_peer(credential_info.peer_id));
|
||||||
|
assert!(!service_impl
|
||||||
|
.synced_route_info
|
||||||
|
.is_credential_peer(admin_info.peer_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn trusted_credentials_only_from_admin_publishers() {
|
||||||
|
let service_impl = PeerRouteServiceImpl::new(1, get_mock_global_ctx());
|
||||||
|
let now = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs() as i64;
|
||||||
|
|
||||||
|
let admin_key = vec![1; 32];
|
||||||
|
let credential_key = vec![2; 32];
|
||||||
|
|
||||||
|
let mut admin_info = RoutePeerInfo::new();
|
||||||
|
admin_info.peer_id = 20;
|
||||||
|
admin_info.version = 1;
|
||||||
|
admin_info.feature_flag = Some(PeerFeatureFlag {
|
||||||
|
is_credential_peer: false,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
admin_info.trusted_credential_pubkeys = vec![TrustedCredentialPubkey {
|
||||||
|
pubkey: admin_key.clone(),
|
||||||
|
expiry_unix: now + 600,
|
||||||
|
..Default::default()
|
||||||
|
}];
|
||||||
|
|
||||||
|
let mut credential_info = RoutePeerInfo::new();
|
||||||
|
credential_info.peer_id = 21;
|
||||||
|
credential_info.version = 1;
|
||||||
|
credential_info.feature_flag = Some(PeerFeatureFlag {
|
||||||
|
is_credential_peer: true,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
credential_info.trusted_credential_pubkeys = vec![TrustedCredentialPubkey {
|
||||||
|
pubkey: credential_key.clone(),
|
||||||
|
expiry_unix: now + 600,
|
||||||
|
..Default::default()
|
||||||
|
}];
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut guard = service_impl.synced_route_info.peer_infos.write();
|
||||||
|
guard.insert(admin_info.peer_id, admin_info);
|
||||||
|
guard.insert(credential_info.peer_id, credential_info);
|
||||||
|
}
|
||||||
|
|
||||||
|
service_impl
|
||||||
|
.synced_route_info
|
||||||
|
.verify_and_update_credential_trusts();
|
||||||
|
|
||||||
|
assert!(service_impl
|
||||||
|
.synced_route_info
|
||||||
|
.trusted_credential_pubkeys
|
||||||
|
.contains_key(&admin_key));
|
||||||
|
assert!(!service_impl
|
||||||
|
.synced_route_info
|
||||||
|
.trusted_credential_pubkeys
|
||||||
|
.contains_key(&credential_key));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn sync_route_info_marks_credential_sender_and_filters_entries() {
|
||||||
|
let peer_mgr = create_mock_pmgr().await;
|
||||||
|
let route = create_mock_route(peer_mgr.clone()).await;
|
||||||
|
let from_peer_id: PeerId = 10001;
|
||||||
|
let forwarded_peer_id: PeerId = 10002;
|
||||||
|
|
||||||
|
let identity_type = DashMap::new();
|
||||||
|
identity_type.insert(from_peer_id, PeerIdentityType::Credential);
|
||||||
|
*route.service_impl.interface.lock().await = Some(Box::new(AuthOnlyInterface {
|
||||||
|
my_peer_id: peer_mgr.my_peer_id(),
|
||||||
|
identity_type,
|
||||||
|
}));
|
||||||
|
|
||||||
|
let mut sender_info = RoutePeerInfo::new();
|
||||||
|
sender_info.peer_id = from_peer_id;
|
||||||
|
sender_info.version = 1;
|
||||||
|
sender_info.proxy_cidrs = vec!["10.10.0.0/24".to_string()];
|
||||||
|
|
||||||
|
let mut forwarded_info = RoutePeerInfo::new();
|
||||||
|
forwarded_info.peer_id = forwarded_peer_id;
|
||||||
|
forwarded_info.version = 1;
|
||||||
|
|
||||||
|
let make_raw = |info: &RoutePeerInfo| {
|
||||||
|
let mut raw = DynamicMessage::new(RoutePeerInfo::default().descriptor());
|
||||||
|
raw.transcode_from(info).unwrap();
|
||||||
|
raw
|
||||||
|
};
|
||||||
|
let raw_infos = vec![make_raw(&sender_info), make_raw(&forwarded_info)];
|
||||||
|
|
||||||
|
route
|
||||||
|
.session_mgr
|
||||||
|
.do_sync_route_info(
|
||||||
|
from_peer_id,
|
||||||
|
1,
|
||||||
|
true,
|
||||||
|
Some(vec![sender_info, forwarded_info]),
|
||||||
|
Some(raw_infos),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let guard = route.service_impl.synced_route_info.peer_infos.read();
|
||||||
|
let stored = guard.get(&from_peer_id).unwrap();
|
||||||
|
assert!(stored
|
||||||
|
.feature_flag
|
||||||
|
.as_ref()
|
||||||
|
.map(|x| x.is_credential_peer)
|
||||||
|
.unwrap_or(false));
|
||||||
|
assert!(stored.proxy_cidrs.is_empty());
|
||||||
|
assert!(guard.get(&forwarded_peer_id).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn sync_route_info_forces_non_credential_for_legacy_admin_sender() {
|
||||||
|
let peer_mgr = create_mock_pmgr().await;
|
||||||
|
let route = create_mock_route(peer_mgr.clone()).await;
|
||||||
|
let from_peer_id: PeerId = 10011;
|
||||||
|
let other_peer_id: PeerId = 10012;
|
||||||
|
|
||||||
|
let identity_type = DashMap::new();
|
||||||
|
identity_type.insert(from_peer_id, PeerIdentityType::Admin);
|
||||||
|
*route.service_impl.interface.lock().await = Some(Box::new(AuthOnlyInterface {
|
||||||
|
my_peer_id: peer_mgr.my_peer_id(),
|
||||||
|
identity_type,
|
||||||
|
}));
|
||||||
|
|
||||||
|
let mut sender_info = RoutePeerInfo::new();
|
||||||
|
sender_info.peer_id = from_peer_id;
|
||||||
|
sender_info.version = 1;
|
||||||
|
sender_info.feature_flag = Some(PeerFeatureFlag {
|
||||||
|
is_credential_peer: true,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut other_info = RoutePeerInfo::new();
|
||||||
|
other_info.peer_id = other_peer_id;
|
||||||
|
other_info.version = 1;
|
||||||
|
|
||||||
|
let make_raw = |info: &RoutePeerInfo| {
|
||||||
|
let mut raw = DynamicMessage::new(RoutePeerInfo::default().descriptor());
|
||||||
|
raw.transcode_from(info).unwrap();
|
||||||
|
raw
|
||||||
|
};
|
||||||
|
let raw_infos = vec![make_raw(&sender_info), make_raw(&other_info)];
|
||||||
|
|
||||||
|
route
|
||||||
|
.session_mgr
|
||||||
|
.do_sync_route_info(
|
||||||
|
from_peer_id,
|
||||||
|
1,
|
||||||
|
true,
|
||||||
|
Some(vec![sender_info, other_info]),
|
||||||
|
Some(raw_infos),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let guard = route.service_impl.synced_route_info.peer_infos.read();
|
||||||
|
let sender = guard.get(&from_peer_id).unwrap();
|
||||||
|
assert!(!sender
|
||||||
|
.feature_flag
|
||||||
|
.as_ref()
|
||||||
|
.map(|x| x.is_credential_peer)
|
||||||
|
.unwrap_or(false));
|
||||||
|
assert!(guard.get(&other_peer_id).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
#[rstest::rstest]
|
#[rstest::rstest]
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn ospf_route_2node(#[values(true, false)] enable_conn_list_sync: bool) {
|
async fn ospf_route_2node(#[values(true, false)] enable_conn_list_sync: bool) {
|
||||||
|
|||||||
@@ -787,7 +787,15 @@ impl PeerSession {
|
|||||||
let encryptor = self
|
let encryptor = self
|
||||||
.get_encryptor(epoch, dir, true)
|
.get_encryptor(epoch, dir, true)
|
||||||
.ok_or_else(|| anyhow!("no key for epoch"))?;
|
.ok_or_else(|| anyhow!("no key for epoch"))?;
|
||||||
let _ = encryptor.encrypt_with_nonce(pkt, Some(nonce_bytes.as_slice()));
|
if let Err(e) = encryptor.encrypt_with_nonce(pkt, Some(nonce_bytes.as_slice())) {
|
||||||
|
tracing::warn!(
|
||||||
|
peer_id = ?self.peer_id,
|
||||||
|
?e,
|
||||||
|
"session encrypt failed, invalidating"
|
||||||
|
);
|
||||||
|
self.invalidate();
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ use dashmap::DashMap;
|
|||||||
use crate::{
|
use crate::{
|
||||||
common::{global_ctx::NetworkIdentity, PeerId},
|
common::{global_ctx::NetworkIdentity, PeerId},
|
||||||
proto::peer_rpc::{
|
proto::peer_rpc::{
|
||||||
ForeignNetworkRouteInfoEntry, ForeignNetworkRouteInfoKey, RouteForeignNetworkInfos,
|
ForeignNetworkRouteInfoEntry, ForeignNetworkRouteInfoKey, PeerIdentityType,
|
||||||
RouteForeignNetworkSummary, RoutePeerInfo,
|
RouteForeignNetworkInfos, RouteForeignNetworkSummary, RoutePeerInfo,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -27,6 +27,9 @@ pub type ForeignNetworkRouteInfoMap =
|
|||||||
pub trait RouteInterface {
|
pub trait RouteInterface {
|
||||||
async fn list_peers(&self) -> Vec<PeerId>;
|
async fn list_peers(&self) -> Vec<PeerId>;
|
||||||
fn my_peer_id(&self) -> PeerId;
|
fn my_peer_id(&self) -> PeerId;
|
||||||
|
async fn get_peer_identity_type(&self, _peer_id: PeerId) -> Option<PeerIdentityType> {
|
||||||
|
None
|
||||||
|
}
|
||||||
async fn list_foreign_networks(&self) -> ForeignNetworkRouteInfoMap {
|
async fn list_foreign_networks(&self) -> ForeignNetworkRouteInfoMap {
|
||||||
DashMap::new()
|
DashMap::new()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
use std::{
|
use std::{
|
||||||
ops::Deref,
|
ops::Deref,
|
||||||
sync::{Arc, Weak},
|
sync::{Arc, Weak},
|
||||||
|
time::Duration,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
proto::{
|
proto::{
|
||||||
api::instance::{
|
api::instance::{
|
||||||
AclManageRpc, DumpRouteRequest, DumpRouteResponse, GetAclStatsRequest,
|
AclManageRpc, CredentialManageRpc, DumpRouteRequest, DumpRouteResponse,
|
||||||
|
GenerateCredentialRequest, GenerateCredentialResponse, GetAclStatsRequest,
|
||||||
GetAclStatsResponse, GetForeignNetworkSummaryRequest, GetForeignNetworkSummaryResponse,
|
GetAclStatsResponse, GetForeignNetworkSummaryRequest, GetForeignNetworkSummaryResponse,
|
||||||
GetWhitelistRequest, GetWhitelistResponse, ListForeignNetworkRequest,
|
GetWhitelistRequest, GetWhitelistResponse, ListCredentialsRequest,
|
||||||
ListForeignNetworkResponse, ListGlobalForeignNetworkRequest,
|
ListCredentialsResponse, ListForeignNetworkRequest, ListForeignNetworkResponse,
|
||||||
ListGlobalForeignNetworkResponse, ListPeerRequest, ListPeerResponse, ListRouteRequest,
|
ListGlobalForeignNetworkRequest, ListGlobalForeignNetworkResponse, ListPeerRequest,
|
||||||
ListRouteResponse, PeerInfo, PeerManageRpc, ShowNodeInfoRequest, ShowNodeInfoResponse,
|
ListPeerResponse, ListRouteRequest, ListRouteResponse, PeerInfo, PeerManageRpc,
|
||||||
|
RevokeCredentialRequest, RevokeCredentialResponse, ShowNodeInfoRequest,
|
||||||
|
ShowNodeInfoResponse,
|
||||||
},
|
},
|
||||||
rpc_types::{self, controller::BaseController},
|
rpc_types::{self, controller::BaseController},
|
||||||
},
|
},
|
||||||
@@ -201,3 +205,77 @@ impl AclManageRpc for PeerManagerRpcService {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl CredentialManageRpc for PeerManagerRpcService {
|
||||||
|
type Controller = BaseController;
|
||||||
|
|
||||||
|
async fn generate_credential(
|
||||||
|
&self,
|
||||||
|
_: BaseController,
|
||||||
|
request: GenerateCredentialRequest,
|
||||||
|
) -> Result<GenerateCredentialResponse, rpc_types::error::Error> {
|
||||||
|
let pm = weak_upgrade(&self.peer_manager)?;
|
||||||
|
let global_ctx = pm.get_global_ctx();
|
||||||
|
|
||||||
|
if global_ctx.get_network_identity().network_secret.is_none() {
|
||||||
|
return Err(rpc_types::error::Error::ExecutionError(anyhow::anyhow!(
|
||||||
|
"only admin nodes (with network_secret) can generate credentials"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let ttl = if request.ttl_seconds > 0 {
|
||||||
|
Duration::from_secs(request.ttl_seconds as u64)
|
||||||
|
} else {
|
||||||
|
return Err(rpc_types::error::Error::ExecutionError(anyhow::anyhow!(
|
||||||
|
"ttl_seconds must be positive"
|
||||||
|
)));
|
||||||
|
};
|
||||||
|
|
||||||
|
let (id, secret) = global_ctx.get_credential_manager().generate_credential(
|
||||||
|
request.groups,
|
||||||
|
request.allow_relay,
|
||||||
|
request.allowed_proxy_cidrs,
|
||||||
|
ttl,
|
||||||
|
);
|
||||||
|
|
||||||
|
global_ctx.issue_event(crate::common::global_ctx::GlobalCtxEvent::CredentialChanged);
|
||||||
|
|
||||||
|
Ok(GenerateCredentialResponse {
|
||||||
|
credential_id: id,
|
||||||
|
credential_secret: secret,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn revoke_credential(
|
||||||
|
&self,
|
||||||
|
_: BaseController,
|
||||||
|
request: RevokeCredentialRequest,
|
||||||
|
) -> Result<RevokeCredentialResponse, rpc_types::error::Error> {
|
||||||
|
let pm = weak_upgrade(&self.peer_manager)?;
|
||||||
|
let global_ctx = pm.get_global_ctx();
|
||||||
|
|
||||||
|
let success = global_ctx
|
||||||
|
.get_credential_manager()
|
||||||
|
.revoke_credential(&request.credential_id);
|
||||||
|
|
||||||
|
if success {
|
||||||
|
global_ctx.issue_event(crate::common::global_ctx::GlobalCtxEvent::CredentialChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(RevokeCredentialResponse { success })
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_credentials(
|
||||||
|
&self,
|
||||||
|
_: BaseController,
|
||||||
|
_request: ListCredentialsRequest,
|
||||||
|
) -> Result<ListCredentialsResponse, rpc_types::error::Error> {
|
||||||
|
let pm = weak_upgrade(&self.peer_manager)?;
|
||||||
|
let global_ctx = pm.get_global_ctx();
|
||||||
|
|
||||||
|
Ok(ListCredentialsResponse {
|
||||||
|
credentials: global_ctx.get_credential_manager().list_credentials(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use base64::Engine as _;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
common::{
|
common::{
|
||||||
error::Error,
|
error::Error,
|
||||||
@@ -707,3 +709,467 @@ async fn relay_peer_map_bidirectional_handshake_race() {
|
|||||||
"peer_c should have session with peer_a"
|
"peer_c should have session with peer_a"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Helper: create a secure peer manager for a credential node.
|
||||||
|
/// Uses the given X25519 private key as the Noise static key, with no network_secret.
|
||||||
|
pub async fn create_mock_peer_manager_credential(
|
||||||
|
network_name: String,
|
||||||
|
private_key: &x25519_dalek::StaticSecret,
|
||||||
|
) -> Arc<PeerManager> {
|
||||||
|
use crate::common::config::NetworkIdentity;
|
||||||
|
use crate::proto::common::SecureModeConfig;
|
||||||
|
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||||
|
use base64::Engine;
|
||||||
|
|
||||||
|
let (s, _r) = create_packet_recv_chan();
|
||||||
|
let g = get_mock_global_ctx_with_network(Some(NetworkIdentity::new_credential(network_name)));
|
||||||
|
|
||||||
|
let public = x25519_dalek::PublicKey::from(private_key);
|
||||||
|
g.config.set_secure_mode(Some(SecureModeConfig {
|
||||||
|
enabled: true,
|
||||||
|
local_private_key: Some(BASE64_STANDARD.encode(private_key.as_bytes())),
|
||||||
|
local_public_key: Some(BASE64_STANDARD.encode(public.as_bytes())),
|
||||||
|
}));
|
||||||
|
|
||||||
|
let peer_mgr = Arc::new(PeerManager::new(RouteAlgoType::Ospf, g, s));
|
||||||
|
peer_mgr.run().await.unwrap();
|
||||||
|
peer_mgr
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test: credential node joins a 2-admin network and routes appear.
|
||||||
|
/// Topology: Admin_A -- Credential_C, Admin_A -- Admin_B
|
||||||
|
/// Credential node connects to the admin that generated the credential.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn credential_node_joins_network() {
|
||||||
|
let admin_a = create_mock_peer_manager_secure("net1".to_string(), "secret".to_string()).await;
|
||||||
|
let admin_b = create_mock_peer_manager_secure("net1".to_string(), "secret".to_string()).await;
|
||||||
|
|
||||||
|
// Generate credential on admin_a
|
||||||
|
let (_cred_id, cred_secret) = admin_a
|
||||||
|
.get_global_ctx()
|
||||||
|
.get_credential_manager()
|
||||||
|
.generate_credential(
|
||||||
|
vec!["guest".to_string()],
|
||||||
|
false,
|
||||||
|
vec![],
|
||||||
|
std::time::Duration::from_secs(3600),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create credential node using the generated key
|
||||||
|
let privkey_bytes: [u8; 32] = base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(&cred_secret)
|
||||||
|
.unwrap()
|
||||||
|
.try_into()
|
||||||
|
.unwrap();
|
||||||
|
let private = x25519_dalek::StaticSecret::from(privkey_bytes);
|
||||||
|
let cred_c = create_mock_peer_manager_credential("net1".to_string(), &private).await;
|
||||||
|
|
||||||
|
// Connect admins first
|
||||||
|
connect_peer_manager(admin_a.clone(), admin_b.clone()).await;
|
||||||
|
|
||||||
|
// Admin A and B should discover each other
|
||||||
|
wait_route_appear(admin_a.clone(), admin_b.clone())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Now connect credential node to admin A (credential as client)
|
||||||
|
connect_peer_manager(cred_c.clone(), admin_a.clone()).await;
|
||||||
|
|
||||||
|
// Credential node C should be reachable from admin B (via A)
|
||||||
|
let cred_c_id = cred_c.my_peer_id();
|
||||||
|
wait_for_condition(
|
||||||
|
|| {
|
||||||
|
let admin_b = admin_b.clone();
|
||||||
|
async move {
|
||||||
|
admin_b
|
||||||
|
.list_routes()
|
||||||
|
.await
|
||||||
|
.iter()
|
||||||
|
.any(|r| r.peer_id == cred_c_id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Duration::from_secs(10),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Credential node C should see admin B
|
||||||
|
wait_for_condition(
|
||||||
|
|| {
|
||||||
|
let cred_c = cred_c.clone();
|
||||||
|
let admin_b_id = admin_b.my_peer_id();
|
||||||
|
async move {
|
||||||
|
cred_c
|
||||||
|
.list_routes()
|
||||||
|
.await
|
||||||
|
.iter()
|
||||||
|
.any(|r| r.peer_id == admin_b_id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Duration::from_secs(10),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test: credential node is rejected when its pubkey is not in any admin's trusted list.
|
||||||
|
/// Topology: Admin_A -- Unknown_B (random key, not in trusted list)
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unknown_credential_node_rejected() {
|
||||||
|
let admin_a = create_mock_peer_manager_secure("net1".to_string(), "secret".to_string()).await;
|
||||||
|
|
||||||
|
// Create a credential node with a random key (NOT generated by admin)
|
||||||
|
let random_private = x25519_dalek::StaticSecret::random_from_rng(rand::rngs::OsRng);
|
||||||
|
let unknown_c = create_mock_peer_manager_credential("net1".to_string(), &random_private).await;
|
||||||
|
|
||||||
|
// Try to connect: C -> A (unknown credential as client, admin as server)
|
||||||
|
connect_peer_manager(unknown_c.clone(), admin_a.clone()).await;
|
||||||
|
|
||||||
|
// The handshake should fail so the connection won't establish.
|
||||||
|
// Wait a bit and verify no route appears.
|
||||||
|
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||||
|
|
||||||
|
let routes = admin_a.list_routes().await;
|
||||||
|
assert!(
|
||||||
|
!routes.iter().any(|r| r.peer_id == unknown_c.my_peer_id()),
|
||||||
|
"unknown credential node should NOT appear in admin's routes"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test: after revocation, the credential node disappears from routes.
|
||||||
|
/// Topology: Admin_A -- Credential_C, Admin_A -- Admin_B
|
||||||
|
/// After revocation on A, C should be removed from B's route table.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn credential_revocation_removes_from_routes() {
|
||||||
|
let admin_a = create_mock_peer_manager_secure("net1".to_string(), "secret".to_string()).await;
|
||||||
|
let admin_b = create_mock_peer_manager_secure("net1".to_string(), "secret".to_string()).await;
|
||||||
|
|
||||||
|
let (cred_id, cred_secret) = admin_a
|
||||||
|
.get_global_ctx()
|
||||||
|
.get_credential_manager()
|
||||||
|
.generate_credential(vec![], false, vec![], std::time::Duration::from_secs(3600));
|
||||||
|
|
||||||
|
let privkey_bytes: [u8; 32] = base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(&cred_secret)
|
||||||
|
.unwrap()
|
||||||
|
.try_into()
|
||||||
|
.unwrap();
|
||||||
|
let private = x25519_dalek::StaticSecret::from(privkey_bytes);
|
||||||
|
let cred_c = create_mock_peer_manager_credential("net1".to_string(), &private).await;
|
||||||
|
|
||||||
|
// Connect: A -- B, C -> A (credential node as client, admin as server)
|
||||||
|
connect_peer_manager(admin_a.clone(), admin_b.clone()).await;
|
||||||
|
connect_peer_manager(cred_c.clone(), admin_a.clone()).await;
|
||||||
|
|
||||||
|
// Wait for credential node to appear in admin_b's routes
|
||||||
|
let cred_c_id = cred_c.my_peer_id();
|
||||||
|
wait_for_condition(
|
||||||
|
|| {
|
||||||
|
let admin_b = admin_b.clone();
|
||||||
|
async move {
|
||||||
|
admin_b
|
||||||
|
.list_routes()
|
||||||
|
.await
|
||||||
|
.iter()
|
||||||
|
.any(|r| r.peer_id == cred_c_id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Duration::from_secs(10),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Now revoke the credential
|
||||||
|
assert!(admin_a
|
||||||
|
.get_global_ctx()
|
||||||
|
.get_credential_manager()
|
||||||
|
.revoke_credential(&cred_id));
|
||||||
|
// Issue event to trigger OSPF sync
|
||||||
|
admin_a
|
||||||
|
.get_global_ctx()
|
||||||
|
.issue_event(crate::common::global_ctx::GlobalCtxEvent::CredentialChanged);
|
||||||
|
|
||||||
|
// Wait for credential node to disappear from admin_b's routes
|
||||||
|
wait_for_condition(
|
||||||
|
|| {
|
||||||
|
let admin_b = admin_b.clone();
|
||||||
|
async move {
|
||||||
|
!admin_b
|
||||||
|
.list_routes()
|
||||||
|
.await
|
||||||
|
.iter()
|
||||||
|
.any(|r| r.peer_id == cred_c_id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Duration::from_secs(15),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test: admin node with credential — credential node gets group assignment.
|
||||||
|
/// Verify that the credential node's groups appear in the OSPF sync data.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn credential_node_group_assignment() {
|
||||||
|
let admin_a = create_mock_peer_manager_secure("net1".to_string(), "secret".to_string()).await;
|
||||||
|
let admin_b = create_mock_peer_manager_secure("net1".to_string(), "secret".to_string()).await;
|
||||||
|
|
||||||
|
let (_cred_id, cred_secret) = admin_a
|
||||||
|
.get_global_ctx()
|
||||||
|
.get_credential_manager()
|
||||||
|
.generate_credential(
|
||||||
|
vec!["guest".to_string(), "limited".to_string()],
|
||||||
|
false,
|
||||||
|
vec![],
|
||||||
|
std::time::Duration::from_secs(3600),
|
||||||
|
);
|
||||||
|
|
||||||
|
let privkey_bytes: [u8; 32] = base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(&cred_secret)
|
||||||
|
.unwrap()
|
||||||
|
.try_into()
|
||||||
|
.unwrap();
|
||||||
|
let private = x25519_dalek::StaticSecret::from(privkey_bytes);
|
||||||
|
let cred_c = create_mock_peer_manager_credential("net1".to_string(), &private).await;
|
||||||
|
|
||||||
|
connect_peer_manager(admin_a.clone(), admin_b.clone()).await;
|
||||||
|
connect_peer_manager(cred_c.clone(), admin_a.clone()).await;
|
||||||
|
|
||||||
|
// Wait for credential node route to appear on admin_b (via OSPF through admin_a)
|
||||||
|
let cred_c_id = cred_c.my_peer_id();
|
||||||
|
wait_for_condition(
|
||||||
|
|| {
|
||||||
|
let admin_b = admin_b.clone();
|
||||||
|
async move {
|
||||||
|
admin_b
|
||||||
|
.list_routes()
|
||||||
|
.await
|
||||||
|
.iter()
|
||||||
|
.any(|r| r.peer_id == cred_c_id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Duration::from_secs(10),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Verify the credential node's groups are assigned via OSPF on admin_b
|
||||||
|
// (admin_b gets the groups from admin_a's TrustedCredentialPubkey via OSPF sync)
|
||||||
|
wait_for_condition(
|
||||||
|
|| {
|
||||||
|
let admin_b = admin_b.clone();
|
||||||
|
async move {
|
||||||
|
let g = admin_b.get_route().get_peer_groups(cred_c_id);
|
||||||
|
g.contains(&"guest".to_string()) && g.contains(&"limited".to_string())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Duration::from_secs(10),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Minimal test: two secure peers connect and discover each other's route.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn two_secure_peers_route_appear() {
|
||||||
|
let peer_a = create_mock_peer_manager_secure("net1".to_string(), "sec1".to_string()).await;
|
||||||
|
let peer_b = create_mock_peer_manager_secure("net1".to_string(), "sec1".to_string()).await;
|
||||||
|
|
||||||
|
connect_peer_manager(peer_a.clone(), peer_b.clone()).await;
|
||||||
|
|
||||||
|
wait_route_appear(peer_a.clone(), peer_b.clone())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn multi_admin_multi_credential_route_and_revocation_isolation() {
|
||||||
|
let admin_a = create_mock_peer_manager_secure("net1".to_string(), "secret".to_string()).await;
|
||||||
|
let admin_b = create_mock_peer_manager_secure("net1".to_string(), "secret".to_string()).await;
|
||||||
|
let admin_d = create_mock_peer_manager_secure("net1".to_string(), "secret".to_string()).await;
|
||||||
|
|
||||||
|
connect_peer_manager(admin_a.clone(), admin_b.clone()).await;
|
||||||
|
connect_peer_manager(admin_b.clone(), admin_d.clone()).await;
|
||||||
|
connect_peer_manager(admin_a.clone(), admin_d.clone()).await;
|
||||||
|
|
||||||
|
wait_route_appear(admin_a.clone(), admin_b.clone())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
wait_route_appear(admin_b.clone(), admin_d.clone())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
wait_route_appear(admin_a.clone(), admin_d.clone())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let (cred1_id, cred1_secret) = admin_a
|
||||||
|
.get_global_ctx()
|
||||||
|
.get_credential_manager()
|
||||||
|
.generate_credential(
|
||||||
|
vec!["guest-a".to_string()],
|
||||||
|
false,
|
||||||
|
vec![],
|
||||||
|
std::time::Duration::from_secs(3600),
|
||||||
|
);
|
||||||
|
let (_cred2_id, cred2_secret) = admin_b
|
||||||
|
.get_global_ctx()
|
||||||
|
.get_credential_manager()
|
||||||
|
.generate_credential(
|
||||||
|
vec!["guest-b".to_string()],
|
||||||
|
false,
|
||||||
|
vec![],
|
||||||
|
std::time::Duration::from_secs(3600),
|
||||||
|
);
|
||||||
|
|
||||||
|
let cred1_private: [u8; 32] = base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(&cred1_secret)
|
||||||
|
.unwrap()
|
||||||
|
.try_into()
|
||||||
|
.unwrap();
|
||||||
|
let cred2_private: [u8; 32] = base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(&cred2_secret)
|
||||||
|
.unwrap()
|
||||||
|
.try_into()
|
||||||
|
.unwrap();
|
||||||
|
let cred_1 = create_mock_peer_manager_credential(
|
||||||
|
"net1".to_string(),
|
||||||
|
&x25519_dalek::StaticSecret::from(cred1_private),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let cred_2 = create_mock_peer_manager_credential(
|
||||||
|
"net1".to_string(),
|
||||||
|
&x25519_dalek::StaticSecret::from(cred2_private),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
connect_peer_manager(cred_1.clone(), admin_a.clone()).await;
|
||||||
|
connect_peer_manager(cred_2.clone(), admin_b.clone()).await;
|
||||||
|
|
||||||
|
let cred_1_id = cred_1.my_peer_id();
|
||||||
|
let cred_2_id = cred_2.my_peer_id();
|
||||||
|
|
||||||
|
wait_for_condition(
|
||||||
|
|| {
|
||||||
|
let admin_d = admin_d.clone();
|
||||||
|
async move {
|
||||||
|
let routes = admin_d.list_routes().await;
|
||||||
|
routes.iter().any(|r| r.peer_id == cred_1_id)
|
||||||
|
&& routes.iter().any(|r| r.peer_id == cred_2_id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Duration::from_secs(15),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
wait_for_condition(
|
||||||
|
|| {
|
||||||
|
let admin_d = admin_d.clone();
|
||||||
|
async move {
|
||||||
|
let g1 = admin_d.get_route().get_peer_groups(cred_1_id);
|
||||||
|
let g2 = admin_d.get_route().get_peer_groups(cred_2_id);
|
||||||
|
g1.contains(&"guest-a".to_string()) && g2.contains(&"guest-b".to_string())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Duration::from_secs(15),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(admin_a
|
||||||
|
.get_global_ctx()
|
||||||
|
.get_credential_manager()
|
||||||
|
.revoke_credential(&cred1_id));
|
||||||
|
admin_a
|
||||||
|
.get_global_ctx()
|
||||||
|
.issue_event(crate::common::global_ctx::GlobalCtxEvent::CredentialChanged);
|
||||||
|
|
||||||
|
wait_for_condition(
|
||||||
|
|| {
|
||||||
|
let admin_d = admin_d.clone();
|
||||||
|
async move {
|
||||||
|
let routes = admin_d.list_routes().await;
|
||||||
|
!routes.iter().any(|r| r.peer_id == cred_1_id)
|
||||||
|
&& routes.iter().any(|r| r.peer_id == cred_2_id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Duration::from_secs(20),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unknown_credential_rejected_while_valid_credential_survives() {
|
||||||
|
let admin_a = create_mock_peer_manager_secure("net1".to_string(), "secret".to_string()).await;
|
||||||
|
let admin_b = create_mock_peer_manager_secure("net1".to_string(), "secret".to_string()).await;
|
||||||
|
|
||||||
|
connect_peer_manager(admin_a.clone(), admin_b.clone()).await;
|
||||||
|
wait_route_appear(admin_a.clone(), admin_b.clone())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let (_cred_id, cred_secret) = admin_a
|
||||||
|
.get_global_ctx()
|
||||||
|
.get_credential_manager()
|
||||||
|
.generate_credential(
|
||||||
|
vec!["stable".to_string()],
|
||||||
|
false,
|
||||||
|
vec![],
|
||||||
|
std::time::Duration::from_secs(3600),
|
||||||
|
);
|
||||||
|
|
||||||
|
let valid_private: [u8; 32] = base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(&cred_secret)
|
||||||
|
.unwrap()
|
||||||
|
.try_into()
|
||||||
|
.unwrap();
|
||||||
|
let valid_cred = create_mock_peer_manager_credential(
|
||||||
|
"net1".to_string(),
|
||||||
|
&x25519_dalek::StaticSecret::from(valid_private),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let unknown_private = x25519_dalek::StaticSecret::random_from_rng(rand::rngs::OsRng);
|
||||||
|
let unknown_cred =
|
||||||
|
create_mock_peer_manager_credential("net1".to_string(), &unknown_private).await;
|
||||||
|
|
||||||
|
connect_peer_manager(valid_cred.clone(), admin_a.clone()).await;
|
||||||
|
let (unknown_ring_client, unknown_ring_server) = create_ring_tunnel_pair();
|
||||||
|
let unknown_connect_client = tokio::spawn({
|
||||||
|
let unknown_cred = unknown_cred.clone();
|
||||||
|
async move {
|
||||||
|
unknown_cred
|
||||||
|
.add_client_tunnel(unknown_ring_client, false)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let unknown_connect_server = tokio::spawn({
|
||||||
|
let admin_a = admin_a.clone();
|
||||||
|
async move {
|
||||||
|
admin_a
|
||||||
|
.add_tunnel_as_server(unknown_ring_server, true)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let (unknown_client_ret, unknown_server_ret) =
|
||||||
|
tokio::join!(unknown_connect_client, unknown_connect_server);
|
||||||
|
assert!(
|
||||||
|
unknown_client_ret.unwrap().is_err() || unknown_server_ret.unwrap().is_err(),
|
||||||
|
"unknown credential connection should fail on at least one side"
|
||||||
|
);
|
||||||
|
|
||||||
|
let valid_id = valid_cred.my_peer_id();
|
||||||
|
let unknown_id = unknown_cred.my_peer_id();
|
||||||
|
|
||||||
|
wait_for_condition(
|
||||||
|
|| {
|
||||||
|
let admin_b = admin_b.clone();
|
||||||
|
async move {
|
||||||
|
admin_b
|
||||||
|
.list_routes()
|
||||||
|
.await
|
||||||
|
.iter()
|
||||||
|
.any(|r| r.peer_id == valid_id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Duration::from_secs(15),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||||
|
|
||||||
|
let routes = admin_b.list_routes().await;
|
||||||
|
assert!(routes.iter().any(|r| r.peer_id == valid_id));
|
||||||
|
assert!(!routes.iter().any(|r| r.peer_id == unknown_id));
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ message PeerConnInfo {
|
|||||||
bytes noise_local_static_pubkey = 11;
|
bytes noise_local_static_pubkey = 11;
|
||||||
bytes noise_remote_static_pubkey = 12;
|
bytes noise_remote_static_pubkey = 12;
|
||||||
peer_rpc.SecureAuthLevel secure_auth_level = 13;
|
peer_rpc.SecureAuthLevel secure_auth_level = 13;
|
||||||
|
peer_rpc.PeerIdentityType peer_identity_type = 14;
|
||||||
}
|
}
|
||||||
|
|
||||||
message PeerInfo {
|
message PeerInfo {
|
||||||
@@ -291,3 +292,45 @@ service StatsRpc {
|
|||||||
rpc GetPrometheusStats(GetPrometheusStatsRequest)
|
rpc GetPrometheusStats(GetPrometheusStatsRequest)
|
||||||
returns (GetPrometheusStatsResponse);
|
returns (GetPrometheusStatsResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Credential management messages
|
||||||
|
|
||||||
|
message GenerateCredentialRequest {
|
||||||
|
repeated string groups = 1; // optional: ACL groups for this credential
|
||||||
|
bool allow_relay = 2; // optional: allow relay through credential node
|
||||||
|
repeated string allowed_proxy_cidrs = 3; // optional: restrict proxy_cidrs
|
||||||
|
int64 ttl_seconds = 4; // must be > 0: credential TTL in seconds (0 / omitted is invalid)
|
||||||
|
}
|
||||||
|
|
||||||
|
message GenerateCredentialResponse {
|
||||||
|
string credential_id = 1; // public key base64
|
||||||
|
string credential_secret = 2; // private key base64
|
||||||
|
}
|
||||||
|
|
||||||
|
message RevokeCredentialRequest {
|
||||||
|
string credential_id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message RevokeCredentialResponse {
|
||||||
|
bool success = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListCredentialsRequest {}
|
||||||
|
|
||||||
|
message CredentialInfo {
|
||||||
|
string credential_id = 1; // public key base64
|
||||||
|
repeated string groups = 2;
|
||||||
|
bool allow_relay = 3;
|
||||||
|
int64 expiry_unix = 4;
|
||||||
|
repeated string allowed_proxy_cidrs = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListCredentialsResponse {
|
||||||
|
repeated CredentialInfo credentials = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
service CredentialManageRpc {
|
||||||
|
rpc GenerateCredential(GenerateCredentialRequest) returns (GenerateCredentialResponse);
|
||||||
|
rpc RevokeCredential(RevokeCredentialRequest) returns (RevokeCredentialResponse);
|
||||||
|
rpc ListCredentials(ListCredentialsRequest) returns (ListCredentialsResponse);
|
||||||
|
}
|
||||||
|
|||||||
@@ -216,6 +216,7 @@ message PeerFeatureFlag {
|
|||||||
bool support_conn_list_sync = 5;
|
bool support_conn_list_sync = 5;
|
||||||
bool quic_input = 6;
|
bool quic_input = 6;
|
||||||
bool no_relay_quic = 7;
|
bool no_relay_quic = 7;
|
||||||
|
bool is_credential_peer = 8;
|
||||||
}
|
}
|
||||||
|
|
||||||
enum SocketType {
|
enum SocketType {
|
||||||
|
|||||||
@@ -5,6 +5,14 @@ import "common.proto";
|
|||||||
|
|
||||||
package peer_rpc;
|
package peer_rpc;
|
||||||
|
|
||||||
|
message TrustedCredentialPubkey {
|
||||||
|
bytes pubkey = 1; // X25519 public key (32 bytes)
|
||||||
|
repeated string groups = 2; // ACL groups this credential belongs to
|
||||||
|
bool allow_relay = 3; // whether this credential node can relay data
|
||||||
|
int64 expiry_unix = 4; // expiry time (Unix timestamp)
|
||||||
|
repeated string allowed_proxy_cidrs = 5; // allowed proxy_cidrs ranges
|
||||||
|
}
|
||||||
|
|
||||||
message RoutePeerInfo {
|
message RoutePeerInfo {
|
||||||
// means next hop in route table.
|
// means next hop in route table.
|
||||||
uint32 peer_id = 1;
|
uint32 peer_id = 1;
|
||||||
@@ -30,6 +38,9 @@ message RoutePeerInfo {
|
|||||||
|
|
||||||
common.NatType tcp_nat_type = 17;
|
common.NatType tcp_nat_type = 17;
|
||||||
bytes noise_static_pubkey = 18;
|
bytes noise_static_pubkey = 18;
|
||||||
|
|
||||||
|
// Trusted credential public keys published by admin nodes (holding network_secret)
|
||||||
|
repeated TrustedCredentialPubkey trusted_credential_pubkeys = 19;
|
||||||
}
|
}
|
||||||
|
|
||||||
message PeerIdVersion {
|
message PeerIdVersion {
|
||||||
@@ -263,10 +274,16 @@ message KcpConnData {
|
|||||||
enum SecureAuthLevel {
|
enum SecureAuthLevel {
|
||||||
None = 0;
|
None = 0;
|
||||||
EncryptedUnauthenticated = 1;
|
EncryptedUnauthenticated = 1;
|
||||||
SharedNodePubkeyVerified = 2;
|
PeerVerified = 2;
|
||||||
NetworkSecretConfirmed = 3;
|
NetworkSecretConfirmed = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum PeerIdentityType {
|
||||||
|
Admin = 0;
|
||||||
|
Credential = 1;
|
||||||
|
SharedNode = 2;
|
||||||
|
}
|
||||||
|
|
||||||
enum PeerConnSessionActionPb {
|
enum PeerConnSessionActionPb {
|
||||||
Join = 0;
|
Join = 0;
|
||||||
Sync = 1;
|
Sync = 1;
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ use crate::{
|
|||||||
api::{
|
api::{
|
||||||
config::ConfigRpcServer,
|
config::ConfigRpcServer,
|
||||||
instance::{
|
instance::{
|
||||||
AclManageRpcServer, ConnectorManageRpcServer, MappedListenerManageRpcServer,
|
AclManageRpcServer, ConnectorManageRpcServer, CredentialManageRpcServer,
|
||||||
PeerManageRpcServer, PortForwardManageRpcServer, StatsRpcServer, TcpProxyRpcServer,
|
MappedListenerManageRpcServer, PeerManageRpcServer, PortForwardManageRpcServer,
|
||||||
VpnPortalRpcServer,
|
StatsRpcServer, TcpProxyRpcServer, VpnPortalRpcServer,
|
||||||
},
|
},
|
||||||
logger::LoggerRpcServer,
|
logger::LoggerRpcServer,
|
||||||
manage::WebClientServiceServer,
|
manage::WebClientServiceServer,
|
||||||
@@ -23,8 +23,9 @@ use crate::{
|
|||||||
},
|
},
|
||||||
rpc_service::{
|
rpc_service::{
|
||||||
acl_manage::AclManageRpcService, config::ConfigRpcService,
|
acl_manage::AclManageRpcService, config::ConfigRpcService,
|
||||||
connector_manage::ConnectorManageRpcService, instance_manage::InstanceManageRpcService,
|
connector_manage::ConnectorManageRpcService, credential_manage::CredentialManageRpcService,
|
||||||
logger::LoggerRpcService, mapped_listener_manage::MappedListenerManageRpcService,
|
instance_manage::InstanceManageRpcService, logger::LoggerRpcService,
|
||||||
|
mapped_listener_manage::MappedListenerManageRpcService,
|
||||||
peer_center::PeerCenterManageRpcService, peer_manage::PeerManageRpcService,
|
peer_center::PeerCenterManageRpcService, peer_manage::PeerManageRpcService,
|
||||||
port_forward_manage::PortForwardManageRpcService, proxy::TcpProxyRpcService,
|
port_forward_manage::PortForwardManageRpcService, proxy::TcpProxyRpcService,
|
||||||
stats::StatsRpcService, vpn_portal::VpnPortalRpcService,
|
stats::StatsRpcService, vpn_portal::VpnPortalRpcService,
|
||||||
@@ -156,6 +157,11 @@ fn register_api_rpc_service(
|
|||||||
PeerCenterRpcServer::new(PeerCenterManageRpcService::new(instance_manager.clone())),
|
PeerCenterRpcServer::new(PeerCenterManageRpcService::new(instance_manager.clone())),
|
||||||
"",
|
"",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
registry.register(
|
||||||
|
CredentialManageRpcServer::new(CredentialManageRpcService::new(instance_manager.clone())),
|
||||||
|
"",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_rpc_portal(rpc_portal: Option<String>) -> anyhow::Result<SocketAddr> {
|
fn parse_rpc_portal(rpc_portal: Option<String>) -> anyhow::Result<SocketAddr> {
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
instance_manager::NetworkInstanceManager,
|
||||||
|
proto::{
|
||||||
|
api::instance::{
|
||||||
|
CredentialManageRpc, GenerateCredentialRequest, GenerateCredentialResponse,
|
||||||
|
ListCredentialsRequest, ListCredentialsResponse, RevokeCredentialRequest,
|
||||||
|
RevokeCredentialResponse,
|
||||||
|
},
|
||||||
|
rpc_types::controller::BaseController,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct CredentialManageRpcService {
|
||||||
|
instance_manager: Arc<NetworkInstanceManager>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CredentialManageRpcService {
|
||||||
|
pub fn new(instance_manager: Arc<NetworkInstanceManager>) -> Self {
|
||||||
|
Self { instance_manager }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl CredentialManageRpc for CredentialManageRpcService {
|
||||||
|
type Controller = BaseController;
|
||||||
|
|
||||||
|
async fn generate_credential(
|
||||||
|
&self,
|
||||||
|
ctrl: Self::Controller,
|
||||||
|
req: GenerateCredentialRequest,
|
||||||
|
) -> crate::proto::rpc_types::error::Result<GenerateCredentialResponse> {
|
||||||
|
super::get_instance_service(&self.instance_manager, &None)?
|
||||||
|
.get_credential_manage_service()
|
||||||
|
.generate_credential(ctrl, req)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn revoke_credential(
|
||||||
|
&self,
|
||||||
|
ctrl: Self::Controller,
|
||||||
|
req: RevokeCredentialRequest,
|
||||||
|
) -> crate::proto::rpc_types::error::Result<RevokeCredentialResponse> {
|
||||||
|
super::get_instance_service(&self.instance_manager, &None)?
|
||||||
|
.get_credential_manage_service()
|
||||||
|
.revoke_credential(ctrl, req)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_credentials(
|
||||||
|
&self,
|
||||||
|
ctrl: Self::Controller,
|
||||||
|
req: ListCredentialsRequest,
|
||||||
|
) -> crate::proto::rpc_types::error::Result<ListCredentialsResponse> {
|
||||||
|
super::get_instance_service(&self.instance_manager, &None)?
|
||||||
|
.get_credential_manage_service()
|
||||||
|
.list_credentials(ctrl, req)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ mod acl_manage;
|
|||||||
mod api;
|
mod api;
|
||||||
mod config;
|
mod config;
|
||||||
mod connector_manage;
|
mod connector_manage;
|
||||||
|
mod credential_manage;
|
||||||
mod mapped_listener_manage;
|
mod mapped_listener_manage;
|
||||||
mod peer_center;
|
mod peer_center;
|
||||||
mod peer_manage;
|
mod peer_manage;
|
||||||
@@ -76,6 +77,11 @@ pub trait InstanceRpcService: Sync + Send {
|
|||||||
> + Send
|
> + Send
|
||||||
+ Sync,
|
+ Sync,
|
||||||
>;
|
>;
|
||||||
|
fn get_credential_manage_service(
|
||||||
|
&self,
|
||||||
|
) -> &dyn crate::proto::api::instance::CredentialManageRpc<
|
||||||
|
Controller = crate::proto::rpc_types::controller::BaseController,
|
||||||
|
>;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_instance_service(
|
fn get_instance_service(
|
||||||
|
|||||||
@@ -0,0 +1,777 @@
|
|||||||
|
//! Credential system integration tests
|
||||||
|
//!
|
||||||
|
//! These tests verify the credential-based authentication system where:
|
||||||
|
//! - Admin nodes hold network_secret and can generate credentials
|
||||||
|
//! - Credential nodes use X25519 keypairs to authenticate without network_secret
|
||||||
|
//! - Credentials can be revoked and propagate across the network
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
common::{
|
||||||
|
config::{ConfigLoader, NetworkIdentity, TomlConfigLoader},
|
||||||
|
global_ctx::GlobalCtxEvent,
|
||||||
|
},
|
||||||
|
instance::instance::Instance,
|
||||||
|
tests::three_node::{generate_secure_mode_config, generate_secure_mode_config_with_key},
|
||||||
|
tunnel::{common::tests::wait_for_condition, tcp::TcpTunnelConnector},
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{add_ns_to_bridge, create_netns, del_netns, drop_insts, ping_test};
|
||||||
|
|
||||||
|
use rstest::rstest;
|
||||||
|
|
||||||
|
/// Prepare network namespaces for credential tests
|
||||||
|
/// Topology:
|
||||||
|
/// br_a (10.1.1.0/24): ns_adm (10.1.1.1), ns_c1 (10.1.1.2), ns_c2 (10.1.1.3), ns_c3 (10.1.1.4)
|
||||||
|
/// br_b (10.1.2.0/24): ns_adm2 (10.1.2.1) - for multi-admin tests
|
||||||
|
/// Note: Using short names (max 15 chars for veth interfaces)
|
||||||
|
pub fn prepare_credential_network() {
|
||||||
|
// Clean up any existing namespaces
|
||||||
|
for ns in ["ns_adm", "ns_c1", "ns_c2", "ns_c3", "ns_adm2"] {
|
||||||
|
del_netns(ns);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create bridge br_a for admin and credentials
|
||||||
|
let _ = std::process::Command::new("ip")
|
||||||
|
.args(["link", "del", "br_a"])
|
||||||
|
.output();
|
||||||
|
let _ = std::process::Command::new("brctl")
|
||||||
|
.args(["delbr", "br_a"])
|
||||||
|
.output();
|
||||||
|
let _ = std::process::Command::new("brctl")
|
||||||
|
.args(["addbr", "br_a"])
|
||||||
|
.output()
|
||||||
|
.expect("Failed to create br_a");
|
||||||
|
let _ = std::process::Command::new("ip")
|
||||||
|
.args(["link", "set", "br_a", "up"])
|
||||||
|
.output();
|
||||||
|
|
||||||
|
// Create namespaces and add to bridge
|
||||||
|
create_netns("ns_adm", "10.1.1.1/24", "fd11::1/64");
|
||||||
|
add_ns_to_bridge("br_a", "ns_adm");
|
||||||
|
|
||||||
|
create_netns("ns_c1", "10.1.1.2/24", "fd11::2/64");
|
||||||
|
add_ns_to_bridge("br_a", "ns_c1");
|
||||||
|
|
||||||
|
create_netns("ns_c2", "10.1.1.3/24", "fd11::3/64");
|
||||||
|
add_ns_to_bridge("br_a", "ns_c2");
|
||||||
|
|
||||||
|
// Create ns_c3 for relay tests (needs 4 nodes)
|
||||||
|
create_netns("ns_c3", "10.1.1.4/24", "fd11::4/64");
|
||||||
|
add_ns_to_bridge("br_a", "ns_c3");
|
||||||
|
|
||||||
|
// Create bridge br_b for second admin (multi-admin tests)
|
||||||
|
let _ = std::process::Command::new("ip")
|
||||||
|
.args(["link", "del", "br_b"])
|
||||||
|
.output();
|
||||||
|
let _ = std::process::Command::new("brctl")
|
||||||
|
.args(["delbr", "br_b"])
|
||||||
|
.output();
|
||||||
|
let _ = std::process::Command::new("brctl")
|
||||||
|
.args(["addbr", "br_b"])
|
||||||
|
.output()
|
||||||
|
.expect("Failed to create br_b");
|
||||||
|
let _ = std::process::Command::new("ip")
|
||||||
|
.args(["link", "set", "br_b", "up"])
|
||||||
|
.output();
|
||||||
|
|
||||||
|
create_netns("ns_adm2", "10.1.2.1/24", "fd12::1/64");
|
||||||
|
add_ns_to_bridge("br_b", "ns_adm2");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper: Create credential node config with generated credential
|
||||||
|
async fn create_credential_config(
|
||||||
|
admin_inst: &Instance,
|
||||||
|
inst_name: &str,
|
||||||
|
ns: Option<&str>,
|
||||||
|
ipv4: &str,
|
||||||
|
ipv6: &str,
|
||||||
|
) -> TomlConfigLoader {
|
||||||
|
use base64::Engine as _;
|
||||||
|
|
||||||
|
// Generate credential on admin
|
||||||
|
let (_cred_id, cred_secret) = admin_inst
|
||||||
|
.get_global_ctx()
|
||||||
|
.get_credential_manager()
|
||||||
|
.generate_credential(vec![], false, vec![], Duration::from_secs(3600));
|
||||||
|
|
||||||
|
// Decode private key
|
||||||
|
let privkey_bytes: [u8; 32] = base64::prelude::BASE64_STANDARD
|
||||||
|
.decode(&cred_secret)
|
||||||
|
.unwrap()
|
||||||
|
.try_into()
|
||||||
|
.unwrap();
|
||||||
|
let private = x25519_dalek::StaticSecret::from(privkey_bytes);
|
||||||
|
|
||||||
|
// Create config
|
||||||
|
let config = TomlConfigLoader::default();
|
||||||
|
config.set_inst_name(inst_name.to_owned());
|
||||||
|
config.set_netns(ns.map(|s| s.to_owned()));
|
||||||
|
config.set_ipv4(Some(ipv4.parse().unwrap()));
|
||||||
|
config.set_ipv6(Some(ipv6.parse().unwrap()));
|
||||||
|
config.set_listeners(vec![]);
|
||||||
|
config.set_network_identity(NetworkIdentity::new_credential(
|
||||||
|
admin_inst
|
||||||
|
.get_global_ctx()
|
||||||
|
.get_network_identity()
|
||||||
|
.network_name
|
||||||
|
.clone(),
|
||||||
|
));
|
||||||
|
config.set_secure_mode(Some(generate_secure_mode_config_with_key(&private)));
|
||||||
|
|
||||||
|
config
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper: Create admin node config
|
||||||
|
fn create_admin_config(
|
||||||
|
inst_name: &str,
|
||||||
|
ns: Option<&str>,
|
||||||
|
ipv4: &str,
|
||||||
|
ipv6: &str,
|
||||||
|
) -> TomlConfigLoader {
|
||||||
|
let config = TomlConfigLoader::default();
|
||||||
|
config.set_inst_name(inst_name.to_owned());
|
||||||
|
config.set_netns(ns.map(|s| s.to_owned()));
|
||||||
|
config.set_ipv4(Some(ipv4.parse().unwrap()));
|
||||||
|
config.set_ipv6(Some(ipv6.parse().unwrap()));
|
||||||
|
config.set_listeners(vec![
|
||||||
|
"tcp://0.0.0.0:11010".parse().unwrap(),
|
||||||
|
"udp://0.0.0.0:11010".parse().unwrap(),
|
||||||
|
]);
|
||||||
|
config.set_network_identity(NetworkIdentity::new(
|
||||||
|
"test_network".to_string(),
|
||||||
|
"test_secret".to_string(),
|
||||||
|
));
|
||||||
|
config.set_secure_mode(Some(generate_secure_mode_config()));
|
||||||
|
|
||||||
|
config
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test 1: Basic credential node connectivity
|
||||||
|
/// Topology: Admin ← Credential
|
||||||
|
/// Verifies that a credential node can connect to an admin node and appears in routes
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
async fn credential_basic_connectivity() {
|
||||||
|
prepare_credential_network();
|
||||||
|
|
||||||
|
// Create admin node
|
||||||
|
let admin_config = create_admin_config("admin", Some("ns_adm"), "10.144.144.1", "fd00::1/64");
|
||||||
|
let mut admin_inst = Instance::new(admin_config);
|
||||||
|
admin_inst.run().await.unwrap();
|
||||||
|
|
||||||
|
// Create credential node
|
||||||
|
let cred_config = create_credential_config(
|
||||||
|
&admin_inst,
|
||||||
|
"cred",
|
||||||
|
Some("ns_c1"),
|
||||||
|
"10.144.144.2",
|
||||||
|
"fd00::2/64",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let mut cred_inst = Instance::new(cred_config);
|
||||||
|
cred_inst.run().await.unwrap();
|
||||||
|
|
||||||
|
// Credential connects to admin
|
||||||
|
cred_inst
|
||||||
|
.get_conn_manager()
|
||||||
|
.add_connector(TcpTunnelConnector::new(
|
||||||
|
"tcp://10.1.1.1:11010".parse().unwrap(),
|
||||||
|
));
|
||||||
|
|
||||||
|
let cred_peer_id = cred_inst.peer_id();
|
||||||
|
let admin_peer_id = admin_inst.peer_id();
|
||||||
|
println!(
|
||||||
|
"Admin peer_id: {}, Credential peer_id: {}",
|
||||||
|
admin_peer_id, cred_peer_id
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wait a bit for connection attempt
|
||||||
|
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||||
|
|
||||||
|
// Check peers and connections
|
||||||
|
let admin_peers = admin_inst.get_peer_manager().get_peer_map().list_peers();
|
||||||
|
let cred_peers = cred_inst.get_peer_manager().get_peer_map().list_peers();
|
||||||
|
println!("Admin peers: {:?}", admin_peers);
|
||||||
|
println!("Credential peers: {:?}", cred_peers);
|
||||||
|
|
||||||
|
// Wait for credential to appear in admin's route table
|
||||||
|
wait_for_condition(
|
||||||
|
|| async {
|
||||||
|
let routes = admin_inst.get_peer_manager().list_routes().await;
|
||||||
|
let cred_routes = cred_inst.get_peer_manager().list_routes().await;
|
||||||
|
let admin_peers = admin_inst.get_peer_manager().get_peer_map().list_peers();
|
||||||
|
let cred_peers = cred_inst.get_peer_manager().get_peer_map().list_peers();
|
||||||
|
println!(
|
||||||
|
"Admin peers: {:?}, routes: {:?}",
|
||||||
|
admin_peers,
|
||||||
|
routes
|
||||||
|
.iter()
|
||||||
|
.map(|r| (r.peer_id, r.ipv4_addr))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"Credential peers: {:?}, routes: {:?}",
|
||||||
|
cred_peers,
|
||||||
|
cred_routes
|
||||||
|
.iter()
|
||||||
|
.map(|r| (r.peer_id, r.ipv4_addr))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
routes.iter().any(|r| r.peer_id == cred_peer_id)
|
||||||
|
},
|
||||||
|
Duration::from_secs(10),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Verify connectivity
|
||||||
|
wait_for_condition(
|
||||||
|
|| async { ping_test("ns_adm", "10.144.144.2", None).await },
|
||||||
|
Duration::from_secs(10),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
wait_for_condition(
|
||||||
|
|| async { ping_test("ns_c1", "10.144.144.1", None).await },
|
||||||
|
Duration::from_secs(10),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
drop_insts(vec![admin_inst, cred_inst]).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test 5-6: Credential relay capability with allow_relay parameter
|
||||||
|
/// Topology: Admin ← Credential_A, Admin ← Credential_B, Admin ← Credential_C(listener, allow_relay)
|
||||||
|
/// Verifies routing behavior based on allow_relay flag:
|
||||||
|
/// - allow_relay=true: A→B route goes through C (cost 2 via C)
|
||||||
|
/// - allow_relay=false: A→B route goes through Admin (cost 2 via Admin)
|
||||||
|
#[rstest]
|
||||||
|
#[case(true)]
|
||||||
|
#[case(false)]
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
async fn credential_relay_capability(#[case] allow_relay: bool) {
|
||||||
|
use crate::peers::route_trait::NextHopPolicy;
|
||||||
|
|
||||||
|
prepare_credential_network();
|
||||||
|
|
||||||
|
// Create admin node
|
||||||
|
let admin_config = create_admin_config("admin", Some("ns_adm"), "10.144.144.1", "fd00::1/64");
|
||||||
|
let mut admin_inst = Instance::new(admin_config);
|
||||||
|
let mut ff = admin_inst.get_global_ctx().get_feature_flags();
|
||||||
|
// if cred c allow relay, we set admin inst avoid relay (if other same-cost path available, admin will not relay data)
|
||||||
|
ff.avoid_relay_data = allow_relay;
|
||||||
|
admin_inst.get_global_ctx().set_feature_flags(ff);
|
||||||
|
admin_inst.run().await.unwrap();
|
||||||
|
|
||||||
|
let admin_peer_id = admin_inst.peer_id();
|
||||||
|
|
||||||
|
// Generate credentials for A, B, C
|
||||||
|
// C has configurable allow_relay
|
||||||
|
let (_cred_a_id, cred_a_secret) = admin_inst
|
||||||
|
.get_global_ctx()
|
||||||
|
.get_credential_manager()
|
||||||
|
.generate_credential(vec![], false, vec![], Duration::from_secs(3600));
|
||||||
|
|
||||||
|
let (_cred_b_id, cred_b_secret) = admin_inst
|
||||||
|
.get_global_ctx()
|
||||||
|
.get_credential_manager()
|
||||||
|
.generate_credential(vec![], false, vec![], Duration::from_secs(3600));
|
||||||
|
|
||||||
|
let (_cred_c_id, cred_c_secret) = admin_inst
|
||||||
|
.get_global_ctx()
|
||||||
|
.get_credential_manager()
|
||||||
|
.generate_credential(vec![], allow_relay, vec![], Duration::from_secs(3600));
|
||||||
|
|
||||||
|
// Create credential A on ns_c1
|
||||||
|
let cred_a_config = {
|
||||||
|
use base64::Engine as _;
|
||||||
|
let privkey_bytes: [u8; 32] = base64::prelude::BASE64_STANDARD
|
||||||
|
.decode(&cred_a_secret)
|
||||||
|
.unwrap()
|
||||||
|
.try_into()
|
||||||
|
.unwrap();
|
||||||
|
let private = x25519_dalek::StaticSecret::from(privkey_bytes);
|
||||||
|
let config = TomlConfigLoader::default();
|
||||||
|
config.set_inst_name("cred_a".to_string());
|
||||||
|
config.set_netns(Some("ns_c1".to_string()));
|
||||||
|
config.set_ipv4(Some("10.144.144.2".parse().unwrap()));
|
||||||
|
config.set_ipv6(Some("fd00::2/64".parse().unwrap()));
|
||||||
|
config.set_listeners(vec!["tcp://0.0.0.0:11021".parse().unwrap()]);
|
||||||
|
config.set_network_identity(NetworkIdentity::new_credential(
|
||||||
|
admin_inst
|
||||||
|
.get_global_ctx()
|
||||||
|
.get_network_identity()
|
||||||
|
.network_name
|
||||||
|
.clone(),
|
||||||
|
));
|
||||||
|
config.set_secure_mode(Some(generate_secure_mode_config_with_key(&private)));
|
||||||
|
config
|
||||||
|
};
|
||||||
|
let mut cred_a_inst = Instance::new(cred_a_config);
|
||||||
|
cred_a_inst.run().await.unwrap();
|
||||||
|
|
||||||
|
// Create credential B on ns_c2
|
||||||
|
let cred_b_config = {
|
||||||
|
use base64::Engine as _;
|
||||||
|
let privkey_bytes: [u8; 32] = base64::prelude::BASE64_STANDARD
|
||||||
|
.decode(&cred_b_secret)
|
||||||
|
.unwrap()
|
||||||
|
.try_into()
|
||||||
|
.unwrap();
|
||||||
|
let private = x25519_dalek::StaticSecret::from(privkey_bytes);
|
||||||
|
let config = TomlConfigLoader::default();
|
||||||
|
config.set_inst_name("cred_b".to_string());
|
||||||
|
config.set_netns(Some("ns_c2".to_string()));
|
||||||
|
config.set_ipv4(Some("10.144.144.3".parse().unwrap()));
|
||||||
|
config.set_ipv6(Some("fd00::3/64".parse().unwrap()));
|
||||||
|
config.set_listeners(vec!["tcp://0.0.0.0:11022".parse().unwrap()]);
|
||||||
|
config.set_network_identity(NetworkIdentity::new_credential(
|
||||||
|
admin_inst
|
||||||
|
.get_global_ctx()
|
||||||
|
.get_network_identity()
|
||||||
|
.network_name
|
||||||
|
.clone(),
|
||||||
|
));
|
||||||
|
config.set_secure_mode(Some(generate_secure_mode_config_with_key(&private)));
|
||||||
|
config
|
||||||
|
};
|
||||||
|
let mut cred_b_inst = Instance::new(cred_b_config);
|
||||||
|
cred_b_inst.run().await.unwrap();
|
||||||
|
|
||||||
|
// Create credential C on ns_c3 WITH listener (so A and B can connect to it)
|
||||||
|
let cred_c_config = {
|
||||||
|
use base64::Engine as _;
|
||||||
|
let privkey_bytes: [u8; 32] = base64::prelude::BASE64_STANDARD
|
||||||
|
.decode(&cred_c_secret)
|
||||||
|
.unwrap()
|
||||||
|
.try_into()
|
||||||
|
.unwrap();
|
||||||
|
let private = x25519_dalek::StaticSecret::from(privkey_bytes);
|
||||||
|
let config = TomlConfigLoader::default();
|
||||||
|
config.set_inst_name("cred_c".to_string());
|
||||||
|
config.set_netns(Some("ns_c3".to_string()));
|
||||||
|
config.set_ipv4(Some("10.144.144.4".parse().unwrap()));
|
||||||
|
config.set_ipv6(Some("fd00::4/64".parse().unwrap()));
|
||||||
|
// C has listener so A and B can connect to it
|
||||||
|
config.set_listeners(vec!["tcp://0.0.0.0:11020".parse().unwrap()]);
|
||||||
|
config.set_network_identity(NetworkIdentity::new_credential(
|
||||||
|
admin_inst
|
||||||
|
.get_global_ctx()
|
||||||
|
.get_network_identity()
|
||||||
|
.network_name
|
||||||
|
.clone(),
|
||||||
|
));
|
||||||
|
config.set_secure_mode(Some(generate_secure_mode_config_with_key(&private)));
|
||||||
|
config
|
||||||
|
};
|
||||||
|
let mut cred_c_inst = Instance::new(cred_c_config);
|
||||||
|
cred_c_inst.run().await.unwrap();
|
||||||
|
|
||||||
|
let cred_a_peer_id = cred_a_inst.peer_id();
|
||||||
|
let cred_b_peer_id = cred_b_inst.peer_id();
|
||||||
|
let cred_c_peer_id = cred_c_inst.peer_id();
|
||||||
|
|
||||||
|
// All credentials connect to admin
|
||||||
|
cred_a_inst
|
||||||
|
.get_conn_manager()
|
||||||
|
.add_connector(TcpTunnelConnector::new(
|
||||||
|
"tcp://10.1.1.1:11010".parse().unwrap(),
|
||||||
|
));
|
||||||
|
cred_b_inst
|
||||||
|
.get_conn_manager()
|
||||||
|
.add_connector(TcpTunnelConnector::new(
|
||||||
|
"tcp://10.1.1.1:11010".parse().unwrap(),
|
||||||
|
));
|
||||||
|
cred_c_inst
|
||||||
|
.get_conn_manager()
|
||||||
|
.add_connector(TcpTunnelConnector::new(
|
||||||
|
"tcp://10.1.1.1:11010".parse().unwrap(),
|
||||||
|
));
|
||||||
|
|
||||||
|
// A and B also connect to C (simulating P2P discovery and connection)
|
||||||
|
// C is on ns_c3 with IP 10.1.1.4, listener on port 11020
|
||||||
|
cred_a_inst
|
||||||
|
.get_conn_manager()
|
||||||
|
.add_connector(TcpTunnelConnector::new(
|
||||||
|
"tcp://10.1.1.4:11020".parse().unwrap(),
|
||||||
|
));
|
||||||
|
cred_b_inst
|
||||||
|
.get_conn_manager()
|
||||||
|
.add_connector(TcpTunnelConnector::new(
|
||||||
|
"tcp://10.1.1.4:11020".parse().unwrap(),
|
||||||
|
));
|
||||||
|
// print all peer ids
|
||||||
|
println!("Admin peer id: {:?}", admin_peer_id);
|
||||||
|
println!("Cred A peer id: {:?}", cred_a_peer_id);
|
||||||
|
println!("Cred B peer id: {:?}", cred_b_peer_id);
|
||||||
|
println!("Cred C peer id: {:?}", cred_c_peer_id);
|
||||||
|
|
||||||
|
// Wait for all nodes to appear in admin's route table
|
||||||
|
wait_for_condition(
|
||||||
|
|| async {
|
||||||
|
let routes = admin_inst.get_peer_manager().list_routes().await;
|
||||||
|
let has_a = routes.iter().any(|r| r.peer_id == cred_a_peer_id);
|
||||||
|
let has_b = routes.iter().any(|r| r.peer_id == cred_b_peer_id);
|
||||||
|
let has_c = routes.iter().any(|r| r.peer_id == cred_c_peer_id);
|
||||||
|
println!("Admin routes: a={}, b={}, c={}", has_a, has_b, has_c);
|
||||||
|
has_a && has_b && has_c
|
||||||
|
},
|
||||||
|
Duration::from_secs(30),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Wait for P2P connections to establish
|
||||||
|
wait_for_condition(
|
||||||
|
|| async {
|
||||||
|
let peers_a = cred_a_inst.get_peer_manager().get_peer_map().list_peers();
|
||||||
|
let peers_b = cred_b_inst.get_peer_manager().get_peer_map().list_peers();
|
||||||
|
let peers_c = cred_c_inst.get_peer_manager().get_peer_map().list_peers();
|
||||||
|
|
||||||
|
let a_connected_c = peers_a.contains(&cred_c_peer_id);
|
||||||
|
let b_connected_c = peers_b.contains(&cred_c_peer_id);
|
||||||
|
let c_connected_a = peers_c.contains(&cred_a_peer_id);
|
||||||
|
let c_connected_b = peers_c.contains(&cred_b_peer_id);
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"P2P: A->C={}, B->C={}, C->A={}, C->B={}, allow_relay={}",
|
||||||
|
a_connected_c, b_connected_c, c_connected_a, c_connected_b, allow_relay
|
||||||
|
);
|
||||||
|
|
||||||
|
if allow_relay {
|
||||||
|
a_connected_c && b_connected_c && c_connected_a && c_connected_b
|
||||||
|
} else {
|
||||||
|
a_connected_c && b_connected_c
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Duration::from_secs(30),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Wait for routes to propagate
|
||||||
|
wait_for_condition(
|
||||||
|
|| async {
|
||||||
|
let routes_a = cred_a_inst.get_peer_manager().list_routes().await;
|
||||||
|
let a_sees_b = routes_a.iter().any(|r| r.peer_id == cred_b_peer_id);
|
||||||
|
let cost_a_to_b = routes_a
|
||||||
|
.iter()
|
||||||
|
.find(|r| r.peer_id == cred_b_peer_id)
|
||||||
|
.map(|r| r.cost);
|
||||||
|
|
||||||
|
println!("Routes: a_sees_b={} (cost={:?})", a_sees_b, cost_a_to_b);
|
||||||
|
a_sees_b
|
||||||
|
},
|
||||||
|
Duration::from_secs(15),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
wait_for_condition(
|
||||||
|
|| async {
|
||||||
|
let next_hop_a_to_b = cred_a_inst
|
||||||
|
.get_peer_manager()
|
||||||
|
.get_route()
|
||||||
|
.get_next_hop_with_policy(cred_b_peer_id, NextHopPolicy::LeastCost)
|
||||||
|
.await;
|
||||||
|
println!(
|
||||||
|
"Next hop convergence A->B={:?} (admin={}, c={}), allow_relay={}",
|
||||||
|
next_hop_a_to_b, admin_peer_id, cred_c_peer_id, allow_relay
|
||||||
|
);
|
||||||
|
if allow_relay {
|
||||||
|
next_hop_a_to_b == Some(cred_c_peer_id)
|
||||||
|
} else {
|
||||||
|
next_hop_a_to_b == Some(admin_peer_id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Duration::from_secs(20),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// wait 5s, make sure the routes are stable
|
||||||
|
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||||
|
|
||||||
|
// Verify next hop from A to B based on allow_relay flag
|
||||||
|
let next_hop_a_to_b = cred_a_inst
|
||||||
|
.get_peer_manager()
|
||||||
|
.get_route()
|
||||||
|
.get_next_hop_with_policy(cred_b_peer_id, NextHopPolicy::LeastCost)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"Next hop A->B={:?} (admin={}, c={}), allow_relay={}",
|
||||||
|
next_hop_a_to_b, admin_peer_id, cred_c_peer_id, allow_relay
|
||||||
|
);
|
||||||
|
|
||||||
|
// When C has allow_relay=false, route should go through Admin
|
||||||
|
// When C has allow_relay=true, route may go through C or Admin depending on routing algorithm
|
||||||
|
if !allow_relay {
|
||||||
|
assert_eq!(
|
||||||
|
next_hop_a_to_b,
|
||||||
|
Some(admin_peer_id),
|
||||||
|
"Route from A to B should go through admin when allow_relay=false"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
assert_eq!(
|
||||||
|
next_hop_a_to_b,
|
||||||
|
Some(cred_c_peer_id),
|
||||||
|
"Route from A to B should go through C when allow_relay=true"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
drop_insts(vec![admin_inst, cred_a_inst, cred_b_inst, cred_c_inst]).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test 2: Two credential nodes connect to same admin
|
||||||
|
/// Topology: Admin ← Credential_A, Admin ← Credential_B
|
||||||
|
/// Verifies that multiple credential nodes can connect to the same admin
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
async fn credential_two_credentials_communicate_tcp() {
|
||||||
|
prepare_credential_network();
|
||||||
|
|
||||||
|
// Create admin node
|
||||||
|
let admin_config = create_admin_config("admin", Some("ns_adm"), "10.144.144.1", "fd00::1/64");
|
||||||
|
let mut admin_inst = Instance::new(admin_config);
|
||||||
|
admin_inst.run().await.unwrap();
|
||||||
|
|
||||||
|
// Create credential1 on ns_c1
|
||||||
|
let cred1_config = create_credential_config(
|
||||||
|
&admin_inst,
|
||||||
|
"cred1",
|
||||||
|
Some("ns_c1"),
|
||||||
|
"10.144.144.2",
|
||||||
|
"fd00::2/64",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let mut cred1_inst = Instance::new(cred1_config);
|
||||||
|
cred1_inst.run().await.unwrap();
|
||||||
|
|
||||||
|
// Create credential2 on ns_c2
|
||||||
|
let cred2_config = create_credential_config(
|
||||||
|
&admin_inst,
|
||||||
|
"cred2",
|
||||||
|
Some("ns_c2"),
|
||||||
|
"10.144.144.3",
|
||||||
|
"fd00::3/64",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let mut cred2_inst = Instance::new(cred2_config);
|
||||||
|
cred2_inst.run().await.unwrap();
|
||||||
|
|
||||||
|
// Both credentials connect to admin
|
||||||
|
cred1_inst
|
||||||
|
.get_conn_manager()
|
||||||
|
.add_connector(TcpTunnelConnector::new(
|
||||||
|
"tcp://10.1.1.1:11010".parse().unwrap(),
|
||||||
|
));
|
||||||
|
cred2_inst
|
||||||
|
.get_conn_manager()
|
||||||
|
.add_connector(TcpTunnelConnector::new(
|
||||||
|
"tcp://10.1.1.1:11010".parse().unwrap(),
|
||||||
|
));
|
||||||
|
|
||||||
|
let cred1_peer_id = cred1_inst.peer_id();
|
||||||
|
let cred2_peer_id = cred2_inst.peer_id();
|
||||||
|
|
||||||
|
// Wait for both credentials to appear in admin's route table
|
||||||
|
wait_for_condition(
|
||||||
|
|| async {
|
||||||
|
let routes = admin_inst.get_peer_manager().list_routes().await;
|
||||||
|
routes.iter().any(|r| r.peer_id == cred1_peer_id)
|
||||||
|
&& routes.iter().any(|r| r.peer_id == cred2_peer_id)
|
||||||
|
},
|
||||||
|
Duration::from_secs(10),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Verify admin can ping both credentials
|
||||||
|
wait_for_condition(
|
||||||
|
|| async { ping_test("ns_adm", "10.144.144.2", None).await },
|
||||||
|
Duration::from_secs(10),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
wait_for_condition(
|
||||||
|
|| async { ping_test("ns_adm", "10.144.144.3", None).await },
|
||||||
|
Duration::from_secs(10),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
drop_insts(vec![admin_inst, cred1_inst, cred2_inst]).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test 3: Credential revocation removes credential from route table
|
||||||
|
/// Topology: Admin ← Credential
|
||||||
|
/// Verifies that when credential is revoked, it's removed from admin's route table
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
async fn credential_revocation_propagates() {
|
||||||
|
prepare_credential_network();
|
||||||
|
|
||||||
|
// Create admin on ns_adm (10.1.1.1)
|
||||||
|
let admin_config = create_admin_config("admin", Some("ns_adm"), "10.144.144.1", "fd00::1/64");
|
||||||
|
let mut admin_inst = Instance::new(admin_config);
|
||||||
|
admin_inst.run().await.unwrap();
|
||||||
|
|
||||||
|
// Generate credential on admin
|
||||||
|
let (cred_id, cred_secret) = admin_inst
|
||||||
|
.get_global_ctx()
|
||||||
|
.get_credential_manager()
|
||||||
|
.generate_credential(vec![], false, vec![], Duration::from_secs(3600));
|
||||||
|
|
||||||
|
// Create credential node
|
||||||
|
let cred_config = {
|
||||||
|
use base64::Engine as _;
|
||||||
|
let privkey_bytes: [u8; 32] = base64::prelude::BASE64_STANDARD
|
||||||
|
.decode(&cred_secret)
|
||||||
|
.unwrap()
|
||||||
|
.try_into()
|
||||||
|
.unwrap();
|
||||||
|
let private = x25519_dalek::StaticSecret::from(privkey_bytes);
|
||||||
|
|
||||||
|
let config = TomlConfigLoader::default();
|
||||||
|
config.set_inst_name("cred".to_string());
|
||||||
|
config.set_netns(Some("ns_c1".to_string()));
|
||||||
|
config.set_ipv4(Some("10.144.144.2".parse().unwrap()));
|
||||||
|
config.set_ipv6(Some("fd00::2/64".parse().unwrap()));
|
||||||
|
config.set_listeners(vec![]);
|
||||||
|
config.set_network_identity(NetworkIdentity::new_credential(
|
||||||
|
admin_inst
|
||||||
|
.get_global_ctx()
|
||||||
|
.get_network_identity()
|
||||||
|
.network_name
|
||||||
|
.clone(),
|
||||||
|
));
|
||||||
|
config.set_secure_mode(Some(generate_secure_mode_config_with_key(&private)));
|
||||||
|
config
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut cred_inst = Instance::new(cred_config);
|
||||||
|
cred_inst.run().await.unwrap();
|
||||||
|
|
||||||
|
// Credential connects to admin
|
||||||
|
cred_inst
|
||||||
|
.get_conn_manager()
|
||||||
|
.add_connector(TcpTunnelConnector::new(
|
||||||
|
"tcp://10.1.1.1:11010".parse().unwrap(),
|
||||||
|
));
|
||||||
|
|
||||||
|
let cred_peer_id = cred_inst.peer_id();
|
||||||
|
|
||||||
|
// Wait for credential to appear in admin's route table
|
||||||
|
wait_for_condition(
|
||||||
|
|| async {
|
||||||
|
admin_inst
|
||||||
|
.get_peer_manager()
|
||||||
|
.list_routes()
|
||||||
|
.await
|
||||||
|
.iter()
|
||||||
|
.any(|r| r.peer_id == cred_peer_id)
|
||||||
|
},
|
||||||
|
Duration::from_secs(10),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Verify connectivity before revocation
|
||||||
|
wait_for_condition(
|
||||||
|
|| async { ping_test("ns_adm", "10.144.144.2", None).await },
|
||||||
|
Duration::from_secs(10),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Revoke the credential
|
||||||
|
assert!(
|
||||||
|
admin_inst
|
||||||
|
.get_global_ctx()
|
||||||
|
.get_credential_manager()
|
||||||
|
.revoke_credential(&cred_id),
|
||||||
|
"Credential should be revoked successfully"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Trigger OSPF sync
|
||||||
|
admin_inst
|
||||||
|
.get_global_ctx()
|
||||||
|
.issue_event(GlobalCtxEvent::CredentialChanged);
|
||||||
|
|
||||||
|
// Wait for credential to disappear from admin's route table
|
||||||
|
wait_for_condition(
|
||||||
|
|| async {
|
||||||
|
!admin_inst
|
||||||
|
.get_peer_manager()
|
||||||
|
.list_routes()
|
||||||
|
.await
|
||||||
|
.iter()
|
||||||
|
.any(|r| r.peer_id == cred_peer_id)
|
||||||
|
},
|
||||||
|
Duration::from_secs(15),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
drop_insts(vec![admin_inst, cred_inst]).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test 4: Unknown credential (not in trusted list) is rejected
|
||||||
|
/// Topology: Admin
|
||||||
|
/// Verifies that credential nodes with unknown/random keys cannot connect
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
async fn credential_unknown_rejected() {
|
||||||
|
prepare_credential_network();
|
||||||
|
|
||||||
|
// Create admin node
|
||||||
|
let admin_config = create_admin_config("admin", Some("ns_adm"), "10.144.144.1", "fd00::1/64");
|
||||||
|
let mut admin_inst = Instance::new(admin_config);
|
||||||
|
admin_inst.run().await.unwrap();
|
||||||
|
|
||||||
|
// Create credential node with random key (not generated by admin)
|
||||||
|
let random_private = x25519_dalek::StaticSecret::random_from_rng(rand::rngs::OsRng);
|
||||||
|
let cred_config = {
|
||||||
|
let config = TomlConfigLoader::default();
|
||||||
|
config.set_inst_name("cred".to_string());
|
||||||
|
config.set_netns(Some("ns_c1".to_string()));
|
||||||
|
config.set_ipv4(Some("10.144.144.2".parse().unwrap()));
|
||||||
|
config.set_ipv6(Some("fd00::2/64".parse().unwrap()));
|
||||||
|
config.set_listeners(vec![]);
|
||||||
|
config.set_network_identity(NetworkIdentity::new_credential(
|
||||||
|
admin_inst
|
||||||
|
.get_global_ctx()
|
||||||
|
.get_network_identity()
|
||||||
|
.network_name
|
||||||
|
.clone(),
|
||||||
|
));
|
||||||
|
config.set_secure_mode(Some(generate_secure_mode_config_with_key(&random_private)));
|
||||||
|
config
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut cred_inst = Instance::new(cred_config);
|
||||||
|
cred_inst.run().await.unwrap();
|
||||||
|
|
||||||
|
// Attempt to connect to admin
|
||||||
|
cred_inst
|
||||||
|
.get_conn_manager()
|
||||||
|
.add_connector(TcpTunnelConnector::new(
|
||||||
|
"tcp://10.1.1.1:11010".parse().unwrap(),
|
||||||
|
));
|
||||||
|
|
||||||
|
let cred_peer_id = cred_inst.peer_id();
|
||||||
|
|
||||||
|
// Wait a bit for connection attempt
|
||||||
|
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||||
|
|
||||||
|
// Verify credential does NOT appear in admin's route table
|
||||||
|
let routes = admin_inst.get_peer_manager().list_routes().await;
|
||||||
|
assert!(
|
||||||
|
!routes.iter().any(|r| r.peer_id == cred_peer_id),
|
||||||
|
"Unknown credential node should NOT appear in admin's route table"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify no connectivity
|
||||||
|
let ping_result = ping_test("ns_adm", "10.144.144.2", None).await;
|
||||||
|
assert!(
|
||||||
|
!ping_result,
|
||||||
|
"Should NOT be able to ping unknown credential node"
|
||||||
|
);
|
||||||
|
|
||||||
|
drop_insts(vec![admin_inst, cred_inst]).await;
|
||||||
|
}
|
||||||
@@ -3,6 +3,11 @@ mod three_node;
|
|||||||
|
|
||||||
mod ipv6_test;
|
mod ipv6_test;
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
mod credential_tests;
|
||||||
|
|
||||||
|
use std::io::IsTerminal as _;
|
||||||
|
|
||||||
use crate::common::PeerId;
|
use crate::common::PeerId;
|
||||||
use crate::peers::peer_manager::PeerManager;
|
use crate::peers::peer_manager::PeerManager;
|
||||||
|
|
||||||
@@ -126,9 +131,12 @@ pub fn enable_log() {
|
|||||||
.from_env()
|
.from_env()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.add_directive("tarpc=error".parse().unwrap());
|
.add_directive("tarpc=error".parse().unwrap());
|
||||||
|
let use_ansi = std::io::stderr().is_terminal();
|
||||||
tracing_subscriber::fmt::fmt()
|
tracing_subscriber::fmt::fmt()
|
||||||
.pretty()
|
.pretty()
|
||||||
|
.with_ansi(use_ansi)
|
||||||
.with_env_filter(filter)
|
.with_env_filter(filter)
|
||||||
|
.with_writer(std::io::stderr)
|
||||||
.init();
|
.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,3 +208,45 @@ fn set_link_status(net_ns: &str, up: bool) {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
tracing::info!("set link status: {:?}, net_ns: {}, up: {}", ret, net_ns, up);
|
tracing::info!("set link status: {:?}, net_ns: {}, up: {}", ret, net_ns, up);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn drop_insts(insts: Vec<crate::instance::instance::Instance>) {
|
||||||
|
let mut set = tokio::task::JoinSet::new();
|
||||||
|
for mut inst in insts {
|
||||||
|
set.spawn(async move {
|
||||||
|
inst.clear_resources().await;
|
||||||
|
let pm = std::sync::Arc::downgrade(&inst.get_peer_manager());
|
||||||
|
drop(inst);
|
||||||
|
let now = std::time::Instant::now();
|
||||||
|
while now.elapsed().as_secs() < 5 && pm.strong_count() > 0 {
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||||
|
}
|
||||||
|
assert_eq!(pm.strong_count(), 0, "PeerManager should be dropped");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
while set.join_next().await.is_some() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ping_test(from_netns: &str, target_ip: &str, payload_size: Option<usize>) -> bool {
|
||||||
|
use crate::common::netns::{NetNS, ROOT_NETNS_NAME};
|
||||||
|
let _g = NetNS::new(Some(ROOT_NETNS_NAME.to_owned())).guard();
|
||||||
|
let code = tokio::process::Command::new("ip")
|
||||||
|
.args([
|
||||||
|
"netns",
|
||||||
|
"exec",
|
||||||
|
from_netns,
|
||||||
|
"ping",
|
||||||
|
"-c",
|
||||||
|
"1",
|
||||||
|
"-s",
|
||||||
|
payload_size.unwrap_or(56).to_string().as_str(),
|
||||||
|
"-W",
|
||||||
|
"1",
|
||||||
|
target_ip.to_string().as_str(),
|
||||||
|
])
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.status()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
code.code().unwrap() == 0
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ use std::{
|
|||||||
time::Duration,
|
time::Duration,
|
||||||
};
|
};
|
||||||
|
|
||||||
use rand::Rng;
|
use rand::{rngs::OsRng, Rng};
|
||||||
use tokio::{net::UdpSocket, task::JoinSet};
|
use tokio::{net::UdpSocket, task::JoinSet};
|
||||||
|
use x25519_dalek::StaticSecret;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
@@ -2763,21 +2764,28 @@ pub async fn config_patch_test() {
|
|||||||
drop_insts(insts).await;
|
drop_insts(insts).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate SecureModeConfig with random x25519 keypair
|
/// Generate SecureModeConfig with specified x25519 private key
|
||||||
fn generate_secure_mode_config() -> SecureModeConfig {
|
pub fn generate_secure_mode_config_with_key(
|
||||||
|
private_key: &x25519_dalek::StaticSecret,
|
||||||
|
) -> SecureModeConfig {
|
||||||
use base64::{prelude::BASE64_STANDARD, Engine};
|
use base64::{prelude::BASE64_STANDARD, Engine};
|
||||||
use rand::rngs::OsRng;
|
use x25519_dalek::PublicKey;
|
||||||
use x25519_dalek::{PublicKey, StaticSecret};
|
|
||||||
|
|
||||||
let private = StaticSecret::random_from_rng(OsRng);
|
let public = PublicKey::from(private_key);
|
||||||
let public = PublicKey::from(&private);
|
|
||||||
|
|
||||||
SecureModeConfig {
|
SecureModeConfig {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
local_private_key: Some(BASE64_STANDARD.encode(private.as_bytes())),
|
local_private_key: Some(BASE64_STANDARD.encode(private_key.as_bytes())),
|
||||||
local_public_key: Some(BASE64_STANDARD.encode(public.as_bytes())),
|
local_public_key: Some(BASE64_STANDARD.encode(public.as_bytes())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Generate SecureModeConfig with random x25519 keypair
|
||||||
|
pub fn generate_secure_mode_config() -> SecureModeConfig {
|
||||||
|
let private = StaticSecret::random_from_rng(OsRng);
|
||||||
|
generate_secure_mode_config_with_key(&private)
|
||||||
|
}
|
||||||
|
|
||||||
/// Test relay peer end-to-end encryption with TCP
|
/// Test relay peer end-to-end encryption with TCP
|
||||||
#[rstest::rstest]
|
#[rstest::rstest]
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
Reference in New Issue
Block a user