fix some warning
This commit is contained in:
parent
5954894102
commit
d116d2cbcd
517
API.md
Normal file
517
API.md
Normal file
@ -0,0 +1,517 @@
|
||||
## HTTP API 接口列表
|
||||
|
||||
域名 https://root.punchsky.com/api
|
||||
### 1.密钥登录接口
|
||||
|
||||
**URL**:`/auth/token`
|
||||
**Method**:`POST`
|
||||
|
||||
#### 请求参数
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|-----------|------|------|--------------------------|
|
||||
| client_id | string | ✅ | 客户端唯一标识符 |
|
||||
| token | string | ✅ | 密钥 |
|
||||
| mac | string | | MAC地址 |
|
||||
| system | string | | 系统信息(window11/macos 15.8) |
|
||||
| version | string | | 软件版本 |
|
||||
| hostname | string | | 主机名 |
|
||||
|
||||
#### 响应参数
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|--------|-----------------------|
|
||||
| code | int | 0 成功,其他失败 |
|
||||
| message | string | 说明 |
|
||||
| data | object | |
|
||||
| access_token | string | |
|
||||
| username | string | 用户名 |
|
||||
| user_type | string | token密钥登录、account账号登录 |
|
||||
| audit | int | 0 未审核,1审核通过 |
|
||||
| network_id | int | 网络ID |
|
||||
| network_name | string | 网络名称 |
|
||||
| network_domain | string | 网络域名 |
|
||||
| domain | string | 主域名 |
|
||||
| my_network_list | array | 我的网络列表 |
|
||||
#### 示例响应
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"access_token": "MjZ8NDB8MGZhMWE3OGI2NzVkZDEwOWIyYjg2MmI4ZDQ5MTY1MmV8ZTg4MzgzMThhZDBhMjU5MDI5MzQ4ZGQ5ZTc3ZWExZDVhYmQ2YjQwM2M3NDE1YmE4MWQ5YzEzZTEzOTM3MzYzMQ",
|
||||
"username": "测试clinet_id_123456",
|
||||
"user_type": "token",
|
||||
"audit": 1,
|
||||
"network_id": 3,
|
||||
"network_name": "test的网络",
|
||||
"network_domain": "punchsky8.com",
|
||||
"domain": "punchsky.com",
|
||||
"my_network_list": []
|
||||
}
|
||||
}
|
||||
```
|
||||
#### 错误
|
||||
```json
|
||||
{
|
||||
"code": 400,
|
||||
"message": "密钥已失效",
|
||||
"data": null
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
### 2.账号登录接口
|
||||
|
||||
**URL**:`/auth/login`
|
||||
**Method**:`POST`
|
||||
|
||||
#### 请求参数
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|--------------------------|
|
||||
| client_id | string | ✅ | 客户端唯一标识符 |
|
||||
| username | string | ✅ | 用户名 |
|
||||
| password | string | ✅ | 密码 |
|
||||
| mac | string | | MAC地址 |
|
||||
| system | string | | 系统信息(window11/macos 15.8) |
|
||||
| version | string | | 软件版本 |
|
||||
| hostname | string | | 主机名 |
|
||||
|
||||
#### 响应参数 - 跟auth/token一致 ,多了一个我的网络列表
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"access_token": "MzF8NDV8MzM2MzMyMTQ1MTc3YjlkMjYyZGU3OTNkMWFhYjQ4OTB8YmE3ODRiZDE1MmFlMjQ3MDk2NzIyZTRiY2EyOWQzMGZmNWMyMWRhMmJkYzM3Zjk1N2NkNjg2NGRlNjQ1ZDc1MQ",
|
||||
"username": "test3",
|
||||
"user_type": "account",
|
||||
"audit": 1,
|
||||
"network_id": 3,
|
||||
"network_name": "test\u7684\u7f51\u7edc",
|
||||
"network_domain": "lfn1.punchlink.cn",
|
||||
"my_network_list": [
|
||||
{
|
||||
"network_id": 3,
|
||||
"network_name": "test\u7684\u7f51\u7edc"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.连接网络
|
||||
|
||||
**URL**:`/connect`
|
||||
**Method**:`POST`
|
||||
|
||||
#### 请求参数
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|--------|------|------|
|
||||
| client_id | string | ✅ | 客户端唯一标识符 |
|
||||
| access_token | string | ✅ | |
|
||||
| version | string | | 软件版本 |
|
||||
#### 响应参数
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|--------|-------------------|
|
||||
| code | int | 0 成功,其他失败 |
|
||||
| message | string | 说明 |
|
||||
| data | object | |
|
||||
| ip | string | IP地址 |
|
||||
| mask_len | int | mask |
|
||||
| hostname | string | hostname |
|
||||
| identity_id | int | 目前返回单个数字,多组概念稍后解决 |
|
||||
| resource_list | array | 资源列表 |
|
||||
| node_list | string | 节点列表 |
|
||||
| acl | array | 访问规则 |
|
||||
| exit_node | array | 出口节点列表 (可为空) |
|
||||
|
||||
#### 示例响应
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"ip": "10.10.2.3",
|
||||
"mask_len": 24,
|
||||
"hostname": "lfmw",
|
||||
"identity_id": 26,
|
||||
"resource_list": [
|
||||
{
|
||||
"id": 0,
|
||||
"name": "文档中心",
|
||||
"url": "http://10.10.2.6:88",
|
||||
"connection_status": "connected"
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"name": "文档中心",
|
||||
"url": "http://10.10.2.6:80",
|
||||
"connection_status": "connected"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "数据中心",
|
||||
"url": "http://10.10.2.6:88",
|
||||
"connection_status": "connected"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "数据中心",
|
||||
"url": "http://10.10.2.6:80",
|
||||
"connection_status": "connected"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": "数据中心",
|
||||
"url": "http://10.10.2.6:3306",
|
||||
"connection_status": "connected"
|
||||
}
|
||||
],
|
||||
"node_list": [
|
||||
{
|
||||
"id": 13,
|
||||
"name": "key_MTE1",
|
||||
"ip": "10.10.2.3",
|
||||
"system": "Macos 1.0.0",
|
||||
"connection_status": "connected"
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"name": "test3",
|
||||
"ip": "10.10.2.4",
|
||||
"system": "Window 1.0.0",
|
||||
"connection_status": "connected"
|
||||
},
|
||||
{
|
||||
"id": 23,
|
||||
"name": "alc",
|
||||
"ip": "10.10.2.6",
|
||||
"system": "",
|
||||
"connection_status": "connected"
|
||||
},
|
||||
{
|
||||
"id": 25,
|
||||
"name": "maliu-window",
|
||||
"ip": "10.10.2.7",
|
||||
"system": "OS: Windows, Host name: DESKTOP-VLMVV81",
|
||||
"connection_status": "connected"
|
||||
},
|
||||
{
|
||||
"id": 26,
|
||||
"name": "key_MTE4",
|
||||
"ip": "10.10.2.8",
|
||||
"system": "OS: Windows, Host name: DESKTOP-VLMVV81",
|
||||
"connection_status": "connected"
|
||||
},
|
||||
{
|
||||
"id": 27,
|
||||
"name": "company-window",
|
||||
"ip": "10.10.2.9",
|
||||
"system": "OS: Windows, Host name: DESKTOP-VLMVV81",
|
||||
"connection_status": "disconnected"
|
||||
},
|
||||
{
|
||||
"id": 28,
|
||||
"name": "maliu-macos",
|
||||
"ip": "10.10.2.10",
|
||||
"system": "",
|
||||
"connection_status": "connected"
|
||||
},
|
||||
{
|
||||
"id": 29,
|
||||
"name": "key_MTE4",
|
||||
"ip": "10.10.2.5",
|
||||
"system": "linux",
|
||||
"connection_status": "connected"
|
||||
},
|
||||
{
|
||||
"id": 39,
|
||||
"name": "maliu-ubuntu",
|
||||
"ip": "10.10.2.12",
|
||||
"system": "linux",
|
||||
"connection_status": "disconnected"
|
||||
},
|
||||
{
|
||||
"id": 40,
|
||||
"name": "测试clinet_id_123456",
|
||||
"ip": "10.10.2.3",
|
||||
"system": "Macos 1.0.0",
|
||||
"connection_status": "connected"
|
||||
},
|
||||
{
|
||||
"id": 41,
|
||||
"name": "test3",
|
||||
"ip": "10.10.2.5",
|
||||
"system": "linux",
|
||||
"connection_status": "connected"
|
||||
},
|
||||
{
|
||||
"id": 42,
|
||||
"name": "test3",
|
||||
"ip": "10.10.2.6",
|
||||
"system": "",
|
||||
"connection_status": "connected"
|
||||
},
|
||||
{
|
||||
"id": 43,
|
||||
"name": "user_MTQw",
|
||||
"ip": "10.10.2.13",
|
||||
"system": "linux",
|
||||
"connection_status": "connected"
|
||||
},
|
||||
{
|
||||
"id": 45,
|
||||
"name": "test3",
|
||||
"ip": "10.10.2.3",
|
||||
"system": "Macos 1.0.0",
|
||||
"connection_status": "connected"
|
||||
},
|
||||
{
|
||||
"id": 47,
|
||||
"name": "compay-usa",
|
||||
"ip": "10.10.2.14",
|
||||
"system": "linux",
|
||||
"connection_status": "connected"
|
||||
},
|
||||
{
|
||||
"id": 48,
|
||||
"name": "test3",
|
||||
"ip": "10.10.2.10",
|
||||
"system": "",
|
||||
"connection_status": "disconnected"
|
||||
},
|
||||
{
|
||||
"id": 49,
|
||||
"name": "test3",
|
||||
"ip": "10.10.2.7",
|
||||
"system": "OS: Windows, Host name: DESKTOP-VLMVV81",
|
||||
"connection_status": "connected"
|
||||
},
|
||||
{
|
||||
"id": 50,
|
||||
"name": "test3",
|
||||
"ip": "10.10.2.9",
|
||||
"system": "OS: Windows, Host name: DESKTOP-VLMVV81",
|
||||
"connection_status": "disconnected"
|
||||
},
|
||||
{
|
||||
"id": 51,
|
||||
"name": "test3",
|
||||
"ip": "10.10.2.15",
|
||||
"system": "OS: Windows, Host name: MALIU3CA0",
|
||||
"connection_status": "disconnected"
|
||||
},
|
||||
{
|
||||
"id": 52,
|
||||
"name": "test3",
|
||||
"ip": "10.10.2.16",
|
||||
"system": "OS: Ubuntu, Host name: alex-ubuntu",
|
||||
"connection_status": "disconnected"
|
||||
}
|
||||
],
|
||||
"acl": [],
|
||||
"exit_node": [
|
||||
{
|
||||
"node_id": 41,
|
||||
"node_name": "test3",
|
||||
"gateway": "10.10.2.5",
|
||||
"target_network": "0.0.0.0/24"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 错误
|
||||
```json
|
||||
{
|
||||
"code": 400,
|
||||
"message": "操作失败",
|
||||
"data": null
|
||||
}
|
||||
```
|
||||
|
||||
### 4.断开网络
|
||||
|
||||
**URL**:`/disconnect`
|
||||
**Method**:`POST`
|
||||
|
||||
#### 请求参数
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|--------|------|------|
|
||||
| client_id | string | ✅ | 客户端唯一标识符 |
|
||||
| access_token | string | ✅ | |
|
||||
|
||||
#### 响应参数
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| code | int | 0 成功,其他失败 |
|
||||
| message | string | 说明 |
|
||||
| data | | |
|
||||
|
||||
#### 示例响应
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": null
|
||||
}
|
||||
```
|
||||
|
||||
#### 错误
|
||||
```json
|
||||
{
|
||||
"code": 400,
|
||||
"message": "操作失败",
|
||||
"data": null
|
||||
}
|
||||
```
|
||||
### 5.设备详情
|
||||
|
||||
**URL**:`/get_node_resources`
|
||||
**Method**:`POST`
|
||||
|
||||
#### 请求参数
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|----|--------|------|----------|
|
||||
| client_id | string | ✅ | 客户端唯一标识符 |
|
||||
| access_token | string | ✅ | |
|
||||
| id | int | ✅ | 设备id |
|
||||
|
||||
#### 响应参数
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| code | int | 0 成功,其他失败 |
|
||||
| message | string | 说明 |
|
||||
| data | | |
|
||||
|
||||
#### 示例响应
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"id": 15,
|
||||
"name": "key_MTE1",
|
||||
"ip": "10.10.2.5",
|
||||
"system": "Macos 1.0.0",
|
||||
"connection_status": "connected",
|
||||
"resource_list": [
|
||||
{
|
||||
"id": 15,
|
||||
"name": "数据中心",
|
||||
"url": "http://10.10.2.5:8080",
|
||||
"connection_status": "connected"
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"name": "数据中心",
|
||||
"url": "http://10.10.2.5:90",
|
||||
"connection_status": "connected"
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"name": "数据中心",
|
||||
"url": "http://10.10.2.5:10",
|
||||
"connection_status": "connected"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6.验证access_token 服务端使用
|
||||
|
||||
**URL**:`auth/access_token`
|
||||
**Method**:`POST`
|
||||
|
||||
#### 请求参数
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------------|--------|------|----------|
|
||||
| client_id | string | ✅ | 客户端唯一标识符 |
|
||||
| access_token | string | ✅ | |
|
||||
| ip | string | ✅ | |
|
||||
| mask_len | int | ✅ | |
|
||||
| hostname | string | ✅ | |
|
||||
| mac | string | ✅ | |
|
||||
| network_id | int | ✅ | |
|
||||
|
||||
#### 响应参数
|
||||
|
||||
#### 示例响应
|
||||
```json
|
||||
{
|
||||
"result": "ok"
|
||||
}
|
||||
```
|
||||
#### 错误
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": 1,
|
||||
"message": "缺少必填参数:client_id"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7.set_node_status 服务端使用
|
||||
|
||||
**URL**:`set_node_status`
|
||||
**Method**:`POST`
|
||||
|
||||
#### 请求参数
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------------|--------|------|----------|
|
||||
| client_id | string | ✅ | 客户端唯一标识符 |
|
||||
| access_token | string | ✅ | |
|
||||
| status | int | ✅ | |
|
||||
| network_id | int | ✅ | |
|
||||
|
||||
#### 响应参数
|
||||
|
||||
#### 示例响应
|
||||
```json
|
||||
{
|
||||
"result": "ok"
|
||||
}
|
||||
```
|
||||
#### 错误
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": 1,
|
||||
"message": "缺少必填参数:client_id"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 8.获取节点开放端口
|
||||
|
||||
**URL**:`/acl`
|
||||
**Method**:`POST`
|
||||
|
||||
#### 请求参数
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------------|--------|------|---------------------------|
|
||||
| client_id | string | ✅ | 客户端唯一标识符 |
|
||||
| access_token | string | ✅ | |
|
||||
| network_id | int | | 网络ID |
|
||||
|
||||
#### 响应参数
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|--------|-----------------------|
|
||||
| code | int | 0 成功,其他失败 |
|
||||
| message | string | 说明 |
|
||||
| data | object | |
|
||||
#### 示例响应
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"tcp": [
|
||||
80
|
||||
],
|
||||
"udp": []
|
||||
}
|
||||
}
|
||||
```
|
||||
777
Protocol.md
Normal file
777
Protocol.md
Normal file
@ -0,0 +1,777 @@
|
||||
# SDLAN 协议说明
|
||||
|
||||
本文档描述 Client 端和当前服务端的交互协议。当前控制面支持两种接入方式:
|
||||
|
||||
- QUIC 接入:实现位于 `src/quic`,主入口为 `sdlan_quic_server` 和 `sdlan_quic_transport`。
|
||||
- SSL/TLS 接入:实现位于 `src/ssl`,主入口为 `sdlan_ssl_server` 和 `sdlan_ssl_transport`。
|
||||
|
||||
两种接入方式在传输层不同,但进入 `sdlan_session` 后使用同一套应用层包类型和 protobuf 消息。Client 完成控制面注册后,还需要通过 UDP STUN 服务上报 NAT 映射并承载节点之间的数据转发。
|
||||
|
||||
## 1. 加密说明
|
||||
|
||||
服务端在网络启动时为每个网络生成独立密钥,Client 通过 `RegisterSuper` 响应拿到当前网络的加密参数。
|
||||
|
||||
```text
|
||||
algorithm:
|
||||
aes AES-256
|
||||
chacha20 ChaCha20
|
||||
|
||||
key:
|
||||
长度 32 字节。
|
||||
RegisterSuperAck.key 会使用客户端 RegisterSuper.pub_key 中的 RSA 公钥加密。
|
||||
|
||||
AES 参数:
|
||||
blockMode: cbc
|
||||
padding: pkcs7Padding
|
||||
iv: key 的前 16 字节
|
||||
|
||||
ChaCha20 参数:
|
||||
key: RegisterSuperAck.key 解密后的 32 字节密钥
|
||||
region_id: RegisterSuperAck.region_id,用于客户端侧逻辑分区/nonce 生成
|
||||
```
|
||||
|
||||
`SDLData.data` 字段承载隧道中的原始二层/三层数据,Client 需要按 `RegisterSuperAck.algorithm` 和 `key` 做加解密;包头中的 `network_id`、`src_mac`、`dst_mac`、`ttl`、`session_token`、`identity_id` 不参与该字段加密。
|
||||
|
||||
## 2. 通用应用层帧
|
||||
|
||||
QUIC 和 SSL/TLS 接入最终交给 `sdlan_session:handle_frame/2` 的应用层帧格式一致:
|
||||
|
||||
```text
|
||||
Frame = <<PacketType:8, ProtobufPayload/binary>>
|
||||
```
|
||||
|
||||
- `PacketType`:1 字节包类型,取值见第 3 节。
|
||||
- `ProtobufPayload`:protobuf 编码后的消息体。没有消息体的包只包含 `PacketType`。
|
||||
- Event 和 Command 当前已经改为 protobuf `oneof` 结构,不再使用旧文档中的二级编码字节。
|
||||
|
||||
## 3. 包类型定义
|
||||
|
||||
包类型以 `include/sdlan.hrl` 为准。
|
||||
|
||||
| 宏 | 值 | 方向 | 消息 | 说明 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `PACKET_EMPTY` | `0x00` | 双向 | 无 | 空包,当前主流程未使用。 |
|
||||
| `PACKET_REGISTER_SUPER` | `0x01` | Client -> Server | `SDLRegisterSuper` | 控制连接注册,必须在收到 Welcome 后发送。 |
|
||||
| `PACKET_REGISTER_SUPER_ACK` | `0x02` | Server -> Client | `SDLRegisterSuperAck` | 注册成功,返回网络加密参数和 `session_token`。 |
|
||||
| `PACKET_REGISTER_SUPER_NAK` | `0x04` | Server -> Client | `SDLRegisterSuperNak` | 注册失败,返回错误码和错误消息,随后连接关闭。 |
|
||||
| `PACKET_UNREGISTER` | `0x05` | Client -> Server | 无 | 主动注销当前控制连接,服务端清理网络绑定后关闭连接。 |
|
||||
| `PACKET_QUERY_INFO` | `0x06` | Client -> Server | `SDLQueryInfo` | 查询目标 MAC 的 NAT/IPv6 信息,用于发起 P2P 打洞。 |
|
||||
| `PACKET_PEER_INFO` | `0x07` | Server -> Client | `SDLPeerInfo` | `QUERY_INFO` 的响应。找不到目标时返回空 `v4_info/v6_info`。 |
|
||||
| `PACKET_PING` | `0x08` | Client -> Server | 无 | 控制连接心跳。 |
|
||||
| `PACKET_PONG` | `0x09` | Server -> Client | 无 | 心跳响应。 |
|
||||
| `PACKET_EVENT` | `0x10` | Server -> Client | `SDLEvent` | 服务端主动事件推送,Client 不需要 Ack。 |
|
||||
| `PACKET_COMMAND` | `0x11` | Server -> Client | `SDLCommand` | 服务端主动命令推送,需要 Client 回 `COMMAND_ACK`。 |
|
||||
| `PACKET_COMMAND_ACK` | `0x12` | Client -> Server | `SDLCommandAck` | 命令处理结果,`pkt_id` 必须等于 `SDLCommand.pkt_id`。 |
|
||||
| `PACKET_FLOW_TRACER` | `0x15` | Client -> Server | 历史保留 | `sdlan_session` 当前未处理该包。 |
|
||||
| `PACKET_REGISTER` | `0x20` | Client <-> Client | `SDLRegister` | 节点之间 UDP 打洞握手请求。 |
|
||||
| `PACKET_REGISTER_ACK` | `0x21` | Client <-> Client | `SDLRegisterAck` | 节点之间 UDP 打洞握手响应。 |
|
||||
| `PACKET_STUN_REQUEST` | `0x30` | Client -> STUN | `SDLStunRequest` | 周期上报 NAT 映射和 IPv6 信息,同时维持 NAT 映射。 |
|
||||
| `PACKET_STUN_REPLY` | `0x31` | STUN -> Client | `SDLStunReply` | STUN 心跳响应。 |
|
||||
| `PACKET_STUN_PROBE` | `0x32` | Client -> STUN | `SDLStunProbe` | NAT 类型探测请求。 |
|
||||
| `PACKET_STUN_PROBE_REPLY` | `0x33` | STUN -> Client | `SDLStunProbeReply` | NAT 类型探测响应,返回服务端看到的公网 IP/端口。 |
|
||||
| `PACKET_STUN_PROBE_RELAY` | `0x3a` | STUN 内部 | 内部二进制 | STUN 辅助节点内部转发探测响应,Client 不直接使用。 |
|
||||
| `PACKET_WELCOME` | `0x4f` | Server -> Client | `SDLWelcome` | 控制连接建立后服务端首先下发的欢迎包。 |
|
||||
| `PACKET_ARP_REQUEST` | `0x50` | Client -> Server | `SDLArpRequest` | 查询虚拟网络 IP 对应的 MAC。 |
|
||||
| `PACKET_ARP_RESPONSE` | `0x51` | Server -> Client | `SDLArpResponse` | ARP 查询响应。 |
|
||||
| `PACKET_POLICY_REQUEST` | `0xb0` | Client -> Server | `SDLPolicyRequest` | 查询源身份到目标身份的访问规则。 |
|
||||
| `PACKET_POLICY_REPLY` | `0xb1` | Server -> Client | `SDLPolicyResponse` | 权限规则响应。 |
|
||||
| `PACKET_EXPOSED_SERVICE_REQUEST` | `0xb2` | Client -> Server | `SDLExposedServiceRequest` | 查询当前节点暴露服务端口列表。 |
|
||||
| `PACKET_EXPOSED_SERVICE_RESPONSE` | `0xb3` | Server -> Client | `SDLExposedServiceResponse` | 暴露服务端口响应。 |
|
||||
| `PACKET_STUN_DATA` | `0xff` | Client <-> STUN/Client | `SDLData` | UDP 数据包,支持服务端转发和 P2P 直连。 |
|
||||
|
||||
STUN 探测属性同样定义在 `include/sdlan.hrl`:
|
||||
|
||||
```text
|
||||
STUN_ATTR_CHANGE_NONE = 0 使用收到请求的同一个 socket 响应
|
||||
STUN_ATTR_CHANGE_PORT = 1 通过 stun_peer_assist 路径转发响应,通常用于测试变化 IP/端口路径
|
||||
STUN_ATTR_CHANGE_PEER = 2 通过 stun_port_assist 路径响应,通常用于测试变化端口路径
|
||||
```
|
||||
|
||||
## 4. Protobuf 消息
|
||||
|
||||
protobuf 定义以 `proto/sdlan.proto` 为准,Erlang 生成文件为 `src/sdlan_pb.erl` 和 `include/sdlan_pb.hrl`。
|
||||
|
||||
### 4.1 公共类型
|
||||
|
||||
```protobuf
|
||||
message SDLV4Info {
|
||||
uint32 port = 1;
|
||||
bytes v4 = 2;
|
||||
uint32 nat_type = 3;
|
||||
}
|
||||
|
||||
message SDLV6Info {
|
||||
uint32 port = 1;
|
||||
bytes v6 = 2;
|
||||
}
|
||||
```
|
||||
|
||||
- `SDLV4Info.port`:服务端观察到的客户端 NAT 端口。
|
||||
- `SDLV4Info.v4`:4 字节 IPv4 地址,网络字节序。
|
||||
- `SDLV4Info.nat_type`:Client 上报的 NAT 类型。
|
||||
- `SDLV6Info.port`:IPv6 辅助通道端口。
|
||||
- `SDLV6Info.v6`:16 字节 IPv6 地址。
|
||||
|
||||
### 4.2 NAT 类型约定
|
||||
|
||||
```text
|
||||
0 blocked 网络不可达
|
||||
1 noNat 当前设备在公网地址下
|
||||
2 fullCone Full Cone NAT
|
||||
3 portRestricted Port Restricted Cone NAT
|
||||
4 coneRestricted Restricted Cone NAT
|
||||
5 symmetric Symmetric NAT
|
||||
```
|
||||
|
||||
## 5. QUIC 接入
|
||||
|
||||
QUIC 接入代码位于 `src/quic`。服务端监听配置来自 `quic_server`:
|
||||
|
||||
- 默认端口:`443`
|
||||
- ALPN:`punchnet/1.0`
|
||||
- 服务端允许 1 条双向 stream:`peer_bidi_stream_count => 1`
|
||||
- 默认最大应用包:`max_packet_size = 16384`
|
||||
- 默认心跳间隔:`heartbeat_sec = 15`
|
||||
|
||||
### 5.1 QUIC 传输帧
|
||||
|
||||
QUIC 在单条双向 stream 上承载控制面消息。stream 上的实际传输格式为:
|
||||
|
||||
```text
|
||||
QuicStreamData = <<Len:16, Frame:Len/binary>>
|
||||
Frame = <<PacketType:8, ProtobufPayload/binary>>
|
||||
```
|
||||
|
||||
- `Len`:2 字节无符号长度,表示后续 `Frame` 字节数,不包含 `Len` 自身。
|
||||
- `Frame`:第 2 节定义的通用应用层帧。
|
||||
- 服务端接收时支持一次 QUIC data 中包含多个完整 frame,也支持半包累积。
|
||||
- 当 `Len > max_packet_size` 时,服务端关闭连接,原因是 `frame_too_large`。
|
||||
|
||||
### 5.2 QUIC 连接建立流程
|
||||
|
||||
1. Client 与服务端完成 QUIC/TLS 握手,ALPN 必须匹配 `punchnet/1.0`。
|
||||
2. Client 打开一条双向 stream。
|
||||
3. 服务端接受第一条 stream 后立即发送 `PACKET_WELCOME`。
|
||||
4. Client 收到 Welcome 后发送 `PACKET_REGISTER_SUPER`。
|
||||
5. 注册成功后服务端返回 `PACKET_REGISTER_SUPER_ACK` 并进入 `registered` 状态。
|
||||
6. 注册失败时服务端返回 `PACKET_REGISTER_SUPER_NAK`,随后关闭连接。
|
||||
7. 进入 `registered` 后,Client 必须按 Welcome 中的 `heartbeat_sec` 周期发送 `PACKET_PING`。
|
||||
|
||||
### 5.3 QUIC Welcome
|
||||
|
||||
```text
|
||||
Server -> Client
|
||||
<<Len:16, 0x4f:8, SDLWelcome/binary>>
|
||||
```
|
||||
|
||||
```protobuf
|
||||
message SDLWelcome {
|
||||
uint32 version = 1;
|
||||
uint32 max_bidi_streams = 2;
|
||||
uint32 max_packet_size = 3;
|
||||
uint32 heartbeat_sec = 4;
|
||||
SDLV6Info ipv6_assist = 5;
|
||||
}
|
||||
```
|
||||
|
||||
- `version`:协议版本,当前为 `1`。
|
||||
- `max_bidi_streams`:服务端允许的双向 stream 数,当前为 `1`。
|
||||
- `max_packet_size`:单个应用层 frame 最大长度。
|
||||
- `heartbeat_sec`:心跳间隔,Client 应以小于或等于该值的周期发送 Ping。
|
||||
- `ipv6_assist`:IPv6 辅助器地址。未配置时为空。
|
||||
|
||||
### 5.4 QUIC RegisterSuper
|
||||
|
||||
```text
|
||||
Client -> Server
|
||||
<<Len:16, 0x01:8, SDLRegisterSuper/binary>>
|
||||
```
|
||||
|
||||
```protobuf
|
||||
message SDLRegisterSuper {
|
||||
string client_id = 1;
|
||||
uint32 network_id = 2;
|
||||
bytes mac = 3;
|
||||
uint32 ip = 4;
|
||||
uint32 mask_len = 5;
|
||||
string hostname = 6;
|
||||
string pub_key = 7;
|
||||
string access_token = 8;
|
||||
}
|
||||
```
|
||||
|
||||
- `client_id`:客户端节点 ID,不能为空。
|
||||
- `network_id`:客户端要加入的网络 ID。
|
||||
- `mac`:虚拟网卡 MAC,不能为空,且不能是广播或组播 MAC。
|
||||
- `ip`:HTTP 接口已分配的虚拟网络 IPv4,按 32 位整数传输。
|
||||
- `mask_len`:虚拟网络掩码长度。
|
||||
- `hostname`:客户端主机名,服务端用于维护域名映射。
|
||||
- `pub_key`:客户端 RSA 公钥 PEM,服务端用它加密网络密钥。
|
||||
- `access_token`:客户端通过 HTTP 登录或令牌换取的访问凭证,服务端注册时调用 API 校验。
|
||||
|
||||
注册成功:
|
||||
|
||||
```text
|
||||
Server -> Client
|
||||
<<Len:16, 0x02:8, SDLRegisterSuperAck/binary>>
|
||||
```
|
||||
|
||||
```protobuf
|
||||
message SDLRegisterSuperAck {
|
||||
string algorithm = 1;
|
||||
bytes key = 2;
|
||||
uint32 region_id = 3;
|
||||
bytes session_token = 4;
|
||||
}
|
||||
```
|
||||
|
||||
- `algorithm`:当前网络加密算法,取值为 `aes` 或 `chacha20`。
|
||||
- `key`:使用 `pub_key` 加密后的网络密钥,Client 需要用私钥解密。
|
||||
- `region_id`:服务端根据虚拟 IP 生成的逻辑分区 ID。
|
||||
- `session_token`:本次控制会话 token。后续 UDP `SDLStunRequest` 和 `SDLData` 必须携带,用于校验端和网络绑定关系。
|
||||
|
||||
注册失败:
|
||||
|
||||
```text
|
||||
Server -> Client
|
||||
<<Len:16, 0x04:8, SDLRegisterSuperNak/binary>>
|
||||
```
|
||||
|
||||
```protobuf
|
||||
message SDLRegisterSuperNak {
|
||||
uint32 error_code = 1;
|
||||
string error_message = 2;
|
||||
}
|
||||
```
|
||||
|
||||
- `error_code`:错误码。网络/API 错误当前使用 `4`,服务内部错误当前使用 `5`,API 返回业务错误时透传 API code。
|
||||
- `error_message`:错误描述。服务端发送 NAK 后关闭连接。
|
||||
|
||||
### 5.5 QUIC 心跳
|
||||
|
||||
```text
|
||||
Client -> Server
|
||||
<<Len:16, 0x08:8>>
|
||||
|
||||
Server -> Client
|
||||
<<Len:16, 0x09:8>>
|
||||
```
|
||||
|
||||
- Client 只在注册成功后发送 Ping。
|
||||
- 服务端收到 Ping 后立即回复 Pong,并记录本轮心跳已收到。
|
||||
- 服务端按 `heartbeat_sec` 检查上一周期是否收到过 Ping;如果没有收到,关闭连接。
|
||||
|
||||
### 5.6 QUIC 查询 PeerInfo
|
||||
|
||||
```text
|
||||
Client -> Server
|
||||
<<Len:16, 0x06:8, SDLQueryInfo/binary>>
|
||||
```
|
||||
|
||||
```protobuf
|
||||
message SDLQueryInfo {
|
||||
bytes dst_mac = 1;
|
||||
}
|
||||
```
|
||||
|
||||
- `dst_mac`:目标节点 MAC。
|
||||
- 服务端查找目标节点的 NAT 映射和 IPv6 信息。
|
||||
- 如果目标存在,服务端同时会向目标节点推送 `SDLEvent.SendRegister`,让目标主动向查询方发送打洞包,提高 P2P 成功率。
|
||||
|
||||
响应:
|
||||
|
||||
```text
|
||||
Server -> Client
|
||||
<<Len:16, 0x07:8, SDLPeerInfo/binary>>
|
||||
```
|
||||
|
||||
```protobuf
|
||||
message SDLPeerInfo {
|
||||
bytes dst_mac = 1;
|
||||
optional SDLV4Info v4_info = 2;
|
||||
optional SDLV6Info v6_info = 3;
|
||||
}
|
||||
```
|
||||
|
||||
- `dst_mac`:原样返回查询的目标 MAC。
|
||||
- `v4_info`:目标节点最近一次 `STUN_REQUEST` 上报形成的公网 IPv4/端口和 NAT 类型。
|
||||
- `v6_info`:目标节点上报的 IPv6 辅助信息。没有时为空。
|
||||
- 找不到目标或目标没有可用 NAT 信息时,服务端仍返回 `SDLPeerInfo`,但 `v4_info` 和 `v6_info` 为空。
|
||||
|
||||
### 5.7 QUIC ARP 查询
|
||||
|
||||
```text
|
||||
Client -> Server
|
||||
<<Len:16, 0x50:8, SDLArpRequest/binary>>
|
||||
```
|
||||
|
||||
```protobuf
|
||||
message SDLArpRequest {
|
||||
uint32 target_ip = 1;
|
||||
uint32 origin_ip = 2;
|
||||
bytes context = 3;
|
||||
}
|
||||
```
|
||||
|
||||
- `target_ip`:要查询 MAC 的虚拟网络 IPv4。
|
||||
- `origin_ip`:发起查询的虚拟网络 IPv4。
|
||||
- `context`:Client 自定义上下文,服务端原样回写,方便客户端匹配本地 ARP 请求。
|
||||
|
||||
响应:
|
||||
|
||||
```text
|
||||
Server -> Client
|
||||
<<Len:16, 0x51:8, SDLArpResponse/binary>>
|
||||
```
|
||||
|
||||
```protobuf
|
||||
message SDLArpResponse {
|
||||
uint32 target_ip = 1;
|
||||
bytes target_mac = 2;
|
||||
uint32 origin_ip = 3;
|
||||
bytes context = 4;
|
||||
}
|
||||
```
|
||||
|
||||
- `target_mac`:查询成功时为目标 MAC;查询失败时为空字节串。
|
||||
- 其它字段与请求一致或对应请求上下文。
|
||||
|
||||
### 5.8 QUIC Policy 查询
|
||||
|
||||
```text
|
||||
Client -> Server
|
||||
<<Len:16, 0xb0:8, SDLPolicyRequest/binary>>
|
||||
```
|
||||
|
||||
```protobuf
|
||||
message SDLPolicyRequest {
|
||||
uint32 src_identity_id = 1;
|
||||
uint32 dst_identity_id = 2;
|
||||
uint32 version = 3;
|
||||
}
|
||||
```
|
||||
|
||||
- `src_identity_id`:源端身份 ID。
|
||||
- `dst_identity_id`:目标端身份 ID。
|
||||
- `version`:Client 侧规则版本,服务端原样回写,Client 用于判断是否覆盖本地缓存。
|
||||
|
||||
响应:
|
||||
|
||||
```text
|
||||
Server -> Client
|
||||
<<Len:16, 0xb1:8, SDLPolicyResponse/binary>>
|
||||
```
|
||||
|
||||
```protobuf
|
||||
message SDLPolicyResponse {
|
||||
uint32 src_identity_id = 1;
|
||||
uint32 dst_identity_id = 2;
|
||||
uint32 version = 3;
|
||||
bytes rules = 4;
|
||||
}
|
||||
```
|
||||
|
||||
- `rules`:稀疏序列化规则列表,每条规则格式为 `<<Proto:8, Port:16>>`。
|
||||
- `Proto`:协议号,例如 TCP/UDP 对应的协议编号。
|
||||
- `Port`:允许访问的目标端口。
|
||||
- 服务端只下发 allow 规则,deny 规则在服务端侧已过滤。
|
||||
- 如果请求解析或规则查询失败,当前服务端可能不返回响应包。
|
||||
|
||||
### 5.9 QUIC ExposedService 查询
|
||||
|
||||
```text
|
||||
Client -> Server
|
||||
<<Len:16, 0xb2:8, SDLExposedServiceRequest/binary>>
|
||||
```
|
||||
|
||||
```protobuf
|
||||
message SDLExposedServiceRequest {
|
||||
uint32 version = 1;
|
||||
}
|
||||
```
|
||||
|
||||
- `version`:Client 当前缓存版本,服务端响应中原样返回。
|
||||
|
||||
响应:
|
||||
|
||||
```text
|
||||
Server -> Client
|
||||
<<Len:16, 0xb3:8, SDLExposedServiceResponse/binary>>
|
||||
```
|
||||
|
||||
```protobuf
|
||||
message SDLExposedServiceResponse {
|
||||
uint32 version = 1;
|
||||
repeated uint32 tcp_ports = 2;
|
||||
repeated uint32 udp_ports = 3;
|
||||
}
|
||||
```
|
||||
|
||||
- `tcp_ports`:当前节点允许暴露的 TCP 端口列表。
|
||||
- `udp_ports`:当前节点允许暴露的 UDP 端口列表。
|
||||
- 服务端只返回 `1..65534` 范围内的端口。
|
||||
- API 查询失败时当前服务端可能不返回响应包。
|
||||
|
||||
### 5.10 QUIC Event 推送
|
||||
|
||||
```text
|
||||
Server -> Client
|
||||
<<Len:16, 0x10:8, SDLEvent/binary>>
|
||||
```
|
||||
|
||||
```protobuf
|
||||
message SDLEvent {
|
||||
message NatChanged {
|
||||
bytes mac = 1;
|
||||
uint32 ip = 2;
|
||||
}
|
||||
|
||||
message SendRegister {
|
||||
bytes dst_mac = 1;
|
||||
uint32 nat_ip = 2;
|
||||
uint32 nat_port = 3;
|
||||
uint32 nat_type = 4;
|
||||
optional SDLV6Info v6_info = 5;
|
||||
}
|
||||
|
||||
message ExposedServiceChanged {
|
||||
}
|
||||
|
||||
message NetworkShutdown {
|
||||
string message = 1;
|
||||
}
|
||||
|
||||
oneof event {
|
||||
NatChanged nat_changed = 1;
|
||||
SendRegister send_register = 2;
|
||||
NetworkShutdown shutdown = 3;
|
||||
ExposedServiceChanged exposed_service_changed = 4;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `nat_changed`:某个 MAC 的虚拟 IP 或 NAT 映射发生变化。Client 应清理对应 peer 缓存、ARP 缓存或重新查询 peer 信息。
|
||||
- `send_register`:服务端要求当前 Client 向 `dst_mac` 对应节点发送 UDP `PACKET_REGISTER` 打洞请求。`nat_ip/nat_port/nat_type/v6_info` 是目标可达地址信息。
|
||||
- `exposed_service_changed`:当前节点暴露服务配置变化。Client 应重新发送 `PACKET_EXPOSED_SERVICE_REQUEST` 获取端口列表。
|
||||
- `shutdown`:网络关闭。Client 应停止当前网络会话并释放本地资源。
|
||||
- Event 不需要 Client 回复 Ack。
|
||||
|
||||
### 5.11 QUIC Command 下发和 ACK
|
||||
|
||||
```text
|
||||
Server -> Client
|
||||
<<Len:16, 0x11:8, SDLCommand/binary>>
|
||||
```
|
||||
|
||||
```protobuf
|
||||
message SDLCommand {
|
||||
uint32 pkt_id = 1;
|
||||
|
||||
message ExitNodeControl {
|
||||
int32 action = 1;
|
||||
string remark = 2;
|
||||
}
|
||||
|
||||
oneof command {
|
||||
ExitNodeControl exit_node = 2;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `pkt_id`:服务端生成的命令序号,Client 回复时必须原样带回。
|
||||
- `exit_node.action`:出口节点控制动作,具体动作值由业务层约定。
|
||||
- `exit_node.remark`:操作备注或调试说明。
|
||||
|
||||
Client 处理完成后回复:
|
||||
|
||||
```text
|
||||
Client -> Server
|
||||
<<Len:16, 0x12:8, SDLCommandAck/binary>>
|
||||
```
|
||||
|
||||
```protobuf
|
||||
message SDLCommandAck {
|
||||
uint32 pkt_id = 1;
|
||||
int32 code = 2;
|
||||
string message = 3;
|
||||
bytes data = 4;
|
||||
}
|
||||
```
|
||||
|
||||
- `pkt_id`:必须与收到的 `SDLCommand.pkt_id` 一致。
|
||||
- `code`:处理结果码,建议 `0` 表示成功,非 `0` 表示失败。
|
||||
- `message`:处理结果说明。
|
||||
- `data`:可选业务数据。
|
||||
- 服务端收到 ACK 后,会把结果转发给等待该命令响应的内部调用方。
|
||||
|
||||
### 5.12 QUIC Unregister
|
||||
|
||||
```text
|
||||
Client -> Server
|
||||
<<Len:16, 0x05:8>>
|
||||
```
|
||||
|
||||
- Client 主动退出网络时发送。
|
||||
- 服务端收到后调用 `sdlan_network:unregister/3` 清理当前 `client_id + mac` 的绑定,并关闭控制连接。
|
||||
|
||||
## 6. SSL/TLS 接入
|
||||
|
||||
SSL/TLS 接入代码位于 `src/ssl`。服务端通过 Ranch SSL 监听,配置来自 `ssl_server`:
|
||||
|
||||
- 默认端口:`1443`
|
||||
- ALPN:`punchnet/1.0`
|
||||
- TLS 版本:`tlsv1.3`、`tlsv1.2`
|
||||
- 默认最大应用包:`max_packet_size = 16384`
|
||||
- 默认心跳间隔:`heartbeat_sec = 15`
|
||||
|
||||
### 6.1 SSL/TLS 传输帧
|
||||
|
||||
SSL/TLS 接入使用 Erlang socket `{packet, 2}` 分包。对非 Erlang Client 来说,线上格式仍然是:
|
||||
|
||||
```text
|
||||
TlsStreamData = <<Len:16, Frame:Len/binary>>
|
||||
Frame = <<PacketType:8, ProtobufPayload/binary>>
|
||||
```
|
||||
|
||||
- `Len`:2 字节无符号长度,表示后续 `Frame` 字节数,不包含 `Len` 自身。
|
||||
- 服务端 `ssl` 层会自动剥离 `Len` 后把 `Frame` 交给 `sdlan_session`。
|
||||
- 服务端发送时也依赖 `{packet, 2}` 自动加长度前缀。
|
||||
- 单个发送包长度必须不超过 `65535`,业务上应遵守 Welcome 中的 `max_packet_size`。
|
||||
|
||||
### 6.2 SSL/TLS 连接建立流程
|
||||
|
||||
1. Client 与服务端建立 TCP 连接。
|
||||
2. Client 完成 TLS 握手,ALPN 使用 `punchnet/1.0`。
|
||||
3. 服务端 TLS 握手成功后立即发送 `PACKET_WELCOME`。
|
||||
4. Client 收到 Welcome 后发送 `PACKET_REGISTER_SUPER`。
|
||||
5. 注册成功后服务端返回 `PACKET_REGISTER_SUPER_ACK` 并进入 `registered` 状态。
|
||||
6. 注册失败时服务端返回 `PACKET_REGISTER_SUPER_NAK`,随后关闭连接。
|
||||
7. 进入 `registered` 后,Client 必须按 Welcome 中的 `heartbeat_sec` 周期发送 `PACKET_PING`。
|
||||
|
||||
### 6.3 SSL/TLS 消息交互
|
||||
|
||||
SSL/TLS 接入的应用层消息与 QUIC 完全一致,区别只在传输层:
|
||||
|
||||
- Welcome:见 `5.3`,包类型 `0x4f`。
|
||||
- RegisterSuper / RegisterSuperAck / RegisterSuperNak:见 `5.4`,包类型 `0x01/0x02/0x04`。
|
||||
- Ping / Pong:见 `5.5`,包类型 `0x08/0x09`。
|
||||
- QueryInfo / PeerInfo:见 `5.6`,包类型 `0x06/0x07`。
|
||||
- ARP 查询:见 `5.7`,包类型 `0x50/0x51`。
|
||||
- Policy 查询:见 `5.8`,包类型 `0xb0/0xb1`。
|
||||
- ExposedService 查询:见 `5.9`,包类型 `0xb2/0xb3`。
|
||||
- Event 推送:见 `5.10`,包类型 `0x10`。
|
||||
- Command / CommandAck:见 `5.11`,包类型 `0x11/0x12`。
|
||||
- Unregister:见 `5.12`,包类型 `0x05`。
|
||||
|
||||
Client 如果同时支持 QUIC 和 SSL/TLS,建议抽象出同一个应用层 `Frame` 编解码模块,仅替换底层连接、握手和 stream/socket 读写逻辑。
|
||||
|
||||
## 7. UDP STUN 和数据转发
|
||||
|
||||
UDP 逻辑不属于 `src/quic` 或 `src/ssl`,但它依赖控制面注册返回的 `session_token` 和网络密钥,是 Client 完整接入流程的一部分。相关服务端实现位于:
|
||||
|
||||
- `src/sdlan_stun.erl`
|
||||
- `src/sdlan_stun_port_assist.erl`
|
||||
- `src/sdlan_stun_peer_assist.erl`
|
||||
|
||||
UDP 包没有 2 字节长度前缀:
|
||||
|
||||
```text
|
||||
UdpPacket = <<PacketType:8, ProtobufPayload/binary>>
|
||||
```
|
||||
|
||||
### 7.1 STUN Request
|
||||
|
||||
```text
|
||||
Client -> STUN
|
||||
<<0x30:8, SDLStunRequest/binary>>
|
||||
```
|
||||
|
||||
```protobuf
|
||||
message SDLStunRequest {
|
||||
string client_id = 1;
|
||||
uint32 network_id = 2;
|
||||
bytes mac = 3;
|
||||
uint32 ip = 4;
|
||||
uint32 nat_type = 5;
|
||||
optional SDLV6Info v6_info = 6;
|
||||
bytes session_token = 7;
|
||||
}
|
||||
```
|
||||
|
||||
- `client_id/network_id/mac/ip`:必须与控制面 `RegisterSuper` 一致。
|
||||
- `nat_type`:Client 通过 `STUN_PROBE` 判断出的 NAT 类型。
|
||||
- `v6_info`:Client 可用的 IPv6 辅助信息,没有时为空。
|
||||
- `session_token`:控制面 `RegisterSuperAck.session_token`,服务端用它校验当前 Client 的 NAT 上报。
|
||||
- 服务端以 UDP 源地址和源端口作为该节点的最新 NAT 映射,并通知其它节点 `nat_changed`。
|
||||
|
||||
响应:
|
||||
|
||||
```text
|
||||
STUN -> Client
|
||||
<<0x31:8, SDLStunReply/binary>>
|
||||
```
|
||||
|
||||
```protobuf
|
||||
message SDLStunReply {
|
||||
}
|
||||
```
|
||||
|
||||
- 空消息体,仅表示服务端已收到并处理 STUN 心跳。
|
||||
- Client 需要周期性发送该包维持 NAT 映射,建议周期小于 NAT 超时时间。
|
||||
|
||||
### 7.2 STUN Probe
|
||||
|
||||
```text
|
||||
Client -> STUN
|
||||
<<0x32:8, SDLStunProbe/binary>>
|
||||
```
|
||||
|
||||
```protobuf
|
||||
message SDLStunProbe {
|
||||
uint32 cookie = 1;
|
||||
uint32 attr = 2;
|
||||
uint32 step = 3;
|
||||
}
|
||||
```
|
||||
|
||||
- `cookie`:Client 生成的随机值,用于匹配响应。
|
||||
- `attr`:探测属性,取值见第 3 节 `STUN_ATTR_*`。
|
||||
- `step`:Client 侧探测步骤编号。当前 `sdlan_stun` 主响应代码未写回该字段,响应里通常为 protobuf 默认值 `0`,Client 需要兼容。
|
||||
|
||||
响应:
|
||||
|
||||
```text
|
||||
STUN -> Client
|
||||
<<0x33:8, SDLStunProbeReply/binary>>
|
||||
```
|
||||
|
||||
```protobuf
|
||||
message SDLStunProbeReply {
|
||||
uint32 cookie = 1;
|
||||
uint32 step = 2;
|
||||
uint32 port = 3;
|
||||
uint32 ip = 4;
|
||||
}
|
||||
```
|
||||
|
||||
- `cookie`:原样返回请求中的 `cookie`。
|
||||
- `step`:当前实现通常为默认值 `0`。
|
||||
- `port`:服务端看到的 Client 公网 UDP 源端口。
|
||||
- `ip`:服务端看到的 Client 公网 IPv4,按 32 位整数传输。
|
||||
|
||||
典型 NAT 判断流程:
|
||||
|
||||
1. 向主 STUN 地址发送 `attr = 0`,拿到公网地址 A。
|
||||
2. 如果公网地址 A 等于本地 UDP 地址,则为 `noNat`。
|
||||
3. 向另一组 STUN 地址发送 `attr = 0`,拿到公网地址 B。
|
||||
4. 如果 A 和 B 的 IP 不同,则倾向判断为 `symmetric`。
|
||||
5. 向主 STUN 地址发送 `attr = 1`,如果能收到辅助 peer 路径响应,则说明 NAT 对来源 IP/端口限制较少。
|
||||
6. 向主 STUN 地址发送 `attr = 2`,如果能收到辅助端口路径响应,则说明 NAT 对来源端口限制较少;如果收不到则按端口限制型或降级策略处理。
|
||||
7. 任何关键步骤无响应时,可按 `blocked` 或降级策略处理。
|
||||
|
||||
### 7.3 Client 间 UDP 打洞
|
||||
|
||||
当 Client 通过 `QUERY_INFO` 或 `send_register` 事件拿到对端 NAT 地址后,向对端 UDP 地址发送:
|
||||
|
||||
```text
|
||||
Client A -> Client B
|
||||
<<0x20:8, SDLRegister/binary>>
|
||||
```
|
||||
|
||||
```protobuf
|
||||
message SDLRegister {
|
||||
uint32 network_id = 1;
|
||||
bytes src_mac = 2;
|
||||
bytes dst_mac = 3;
|
||||
}
|
||||
```
|
||||
|
||||
- `network_id`:当前虚拟网络 ID。
|
||||
- `src_mac`:发送方 MAC。
|
||||
- `dst_mac`:目标方 MAC。
|
||||
- 该包用于在双方 NAT 设备上建立映射,不经过控制连接。
|
||||
|
||||
对端收到后回复:
|
||||
|
||||
```text
|
||||
Client B -> Client A
|
||||
<<0x21:8, SDLRegisterAck/binary>>
|
||||
```
|
||||
|
||||
```protobuf
|
||||
message SDLRegisterAck {
|
||||
uint32 network_id = 1;
|
||||
bytes src_mac = 2;
|
||||
bytes dst_mac = 3;
|
||||
}
|
||||
```
|
||||
|
||||
- `src_mac`:ACK 发送方 MAC。
|
||||
- `dst_mac`:ACK 目标方 MAC。
|
||||
- 收到 ACK 后,Client 可以优先使用 P2P 直连发送 `SDLData`。
|
||||
|
||||
### 7.4 UDP 数据包
|
||||
|
||||
```text
|
||||
Client -> STUN/Peer
|
||||
<<0xff:8, SDLData/binary>>
|
||||
```
|
||||
|
||||
```protobuf
|
||||
message SDLData {
|
||||
uint32 network_id = 1;
|
||||
bytes src_mac = 2;
|
||||
bytes dst_mac = 3;
|
||||
bool is_p2p = 4;
|
||||
uint32 ttl = 5;
|
||||
bytes data = 6;
|
||||
bytes session_token = 7;
|
||||
uint32 identity_id = 8;
|
||||
}
|
||||
```
|
||||
|
||||
- `network_id`:当前虚拟网络 ID。
|
||||
- `src_mac`:发送方 MAC。
|
||||
- `dst_mac`:目标 MAC。广播或组播 MAC 会触发服务端向网络内其它在线节点广播转发。
|
||||
- `is_p2p`:Client 直连发送时为 `true`;服务端转发时会改写为 `false`。
|
||||
- `ttl`:转发跳数。STUN 服务端转发前会执行 `ttl - 1`。
|
||||
- `data`:加密后的真实业务数据。
|
||||
- `session_token`:控制面注册返回的会话 token。
|
||||
- `identity_id`:源端身份 ID,对端用于本地权限判断。
|
||||
|
||||
服务端转发逻辑:
|
||||
|
||||
1. STUN 服务端收到 `PACKET_STUN_DATA` 后解析 `SDLData`。
|
||||
2. 按 `network_id` 找到当前网络 ETS 表。
|
||||
3. 校验 `src_mac` 当前在线且存在 endpoint。
|
||||
4. 如果 `dst_mac` 是广播或组播,转发给除 `src_mac` 外所有有 NAT hole 的 endpoint。
|
||||
5. 如果 `dst_mac` 是单播,查找目标 endpoint 的 NAT hole 并转发。
|
||||
6. 转发前将 `ttl` 减 1,并把 `is_p2p` 改为 `false`。
|
||||
7. 服务端按网络带宽限制做限流;限流或找不到目标时丢弃。
|
||||
|
||||
## 8. 推荐 Client 完整接入流程
|
||||
|
||||
1. 通过 HTTP/API 完成登录和网络/IP 分配,拿到 `access_token`、`network_id`、虚拟 IP、掩码、身份 ID 等信息。
|
||||
2. 优先尝试 QUIC 接入;如果网络环境不支持 QUIC,可降级到 SSL/TLS 接入。
|
||||
3. 完成控制连接握手并接收 `PACKET_WELCOME`。
|
||||
4. 发送 `PACKET_REGISTER_SUPER`。
|
||||
5. 收到 `PACKET_REGISTER_SUPER_ACK` 后,用私钥解密 `key`,保存 `algorithm`、`region_id`、`session_token`。
|
||||
6. 启动控制连接心跳,按 `heartbeat_sec` 周期发送 `PACKET_PING` 并处理 `PACKET_PONG`。
|
||||
7. 使用 `PACKET_STUN_PROBE` 判断 NAT 类型。
|
||||
8. 周期性发送 `PACKET_STUN_REQUEST`,上报 NAT 类型、IPv6 信息和 `session_token`。
|
||||
9. 有目标通信需求时,先使用 `PACKET_ARP_REQUEST` 或本地缓存确定目标 MAC。
|
||||
10. 通过 `PACKET_POLICY_REQUEST` 查询访问策略,按 `rules` 在本地做放行判断。
|
||||
11. 通过 `PACKET_QUERY_INFO` 查询目标 NAT 信息,并根据 `SDLEvent.SendRegister` 做双向 UDP 打洞。
|
||||
12. P2P 打洞成功后优先直连发送 `PACKET_STUN_DATA`;失败时通过 STUN 服务端转发。
|
||||
13. 处理服务端推送的 `SDLEvent` 和 `SDLCommand`。Command 必须回复 `SDLCommandAck`。
|
||||
14. Client 主动退出时发送 `PACKET_UNREGISTER` 并关闭本地控制连接和 UDP 资源。
|
||||
|
||||
|
||||
===端口说明====
|
||||
组件 默认端口/配置 」 说明 」
|
||||
QUIC | 443 | ALPN `punchnet/1.0` |控制面主入口,允许 1条双向 stream。
|
||||
SSL/TLS | 1443 | ALPN `punchnet/1.0` |控制面备用入口,与 QUIC 共用`sdlan session`
|
||||
STUN | 1365 | `acceptor_nums=5` |UDP_NAT 上报、探测和服务端转发
|
||||
STUN port assist | 1366 |同节点变端 probe reply
|
||||
STUN peer assist |配置的 `punchnet.aioe.tech:1366`|转发到另一辅助节点后回复。
|
||||
IPv6 assist | 1367 |IPv6 可达性探测。
|
||||
112
Sign.md
Normal file
112
Sign.md
Normal file
@ -0,0 +1,112 @@
|
||||
# 接口签名算法说明
|
||||
|
||||
## 概述
|
||||
|
||||
本接口使用 **HMAC-MD5** 算法对请求参数进行签名,以确保请求的合法性与完整性,防止请求被篡改或伪造。
|
||||
生成的sign值放在header中,key=X-sign,value=加密值。
|
||||
|
||||
---
|
||||
|
||||
## 签名生成流程
|
||||
|
||||
```
|
||||
请求参数
|
||||
│
|
||||
▼
|
||||
① 参数递归排序(ksort)
|
||||
│
|
||||
▼
|
||||
② 构造签名字符串(key=value&key=value...)
|
||||
│ 数组/对象类型 → JSON 字符串
|
||||
▼
|
||||
③ HMAC-MD5(query_string, secret_key)
|
||||
│
|
||||
▼
|
||||
sign(签名结果)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 详细步骤
|
||||
|
||||
### 第一步:参数递归排序
|
||||
|
||||
对所有请求参数按照**键名字典序(ksort)进行递归排序**,嵌套数组也需同样处理,以保证双方构造字符串的顺序一致。
|
||||
|
||||
### 第二步:构造签名字符串
|
||||
|
||||
遍历排序后的参数,按以下规则拼接为字符串:
|
||||
|
||||
| 参数类型 | 处理方式 |
|
||||
| ------------------------ | -------------------------------------------------------- |
|
||||
| 普通值(字符串、数字等) | 直接使用原始值 |
|
||||
| 数组 / 对象 | 转为 JSON 字符串(中文不转义,`JSON_UNESCAPED_UNICODE`) |
|
||||
|
||||
拼接格式:
|
||||
|
||||
```
|
||||
key1=value1&key2=value2&key3=value3
|
||||
```
|
||||
|
||||
> ⚠️ **注意**:参数值**不做 URL encode**,与 `http_build_query` 的行为不同。数组参数也**不展开**为 `key[0]=...` 形式,而是整体转为 JSON 字符串。
|
||||
|
||||
### 第三步:计算签名
|
||||
|
||||
使用 `secret_key` 对上一步得到的字符串执行 **HMAC-MD5** 计算:
|
||||
|
||||
```
|
||||
sign = HMAC-MD5(query_string, secret_key)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 示例
|
||||
|
||||
### 原始请求参数
|
||||
|
||||
```json
|
||||
{
|
||||
"uid": 1001,
|
||||
"amount": 100,
|
||||
"currency": "CNY",
|
||||
"items": [
|
||||
{ "id": 1, "name": "商品A" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 第一步:排序后的参数顺序
|
||||
|
||||
```
|
||||
amount, currency, items, uid
|
||||
```
|
||||
|
||||
### 第二步:构造签名字符串
|
||||
|
||||
```
|
||||
amount=100¤cy=CNY&items=[{"id":1,"name":"商品A"}]&uid=1001
|
||||
```
|
||||
|
||||
### 第三步:计算签名
|
||||
|
||||
```
|
||||
sign = HMAC-MD5("amount=100¤cy=CNY&items=[{\"id\":1,\"name\":\"商品A\"}]&uid=1001", "your_secret_key")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
| 项目 | 说明 |
|
||||
| -------- | -------------------------------------------------------- |
|
||||
| 排序方式 | 字典序递归排序,嵌套数组也要排序 |
|
||||
| 数组参数 | 转为 JSON 字符串,中文不转义(`JSON_UNESCAPED_UNICODE`) |
|
||||
| URL 编码 | 参数值**不做** URL encode |
|
||||
| 数组展开 | **不展开**为 `key[0]=...` 形式 |
|
||||
| 密钥管理 | `secret_key` 需双方提前约定,**严禁在请求中传输** |
|
||||
| 算法 | HMAC-MD5,输出为 32 位十六进制小写字符串 |
|
||||
|
||||
---
|
||||
|
||||
密钥: H6p*2RfEu4ITcL
|
||||
|
||||
91
app/src/main/java/com/jihe/punchnet/api/SignInterceptor.kt
Normal file
91
app/src/main/java/com/jihe/punchnet/api/SignInterceptor.kt
Normal file
@ -0,0 +1,91 @@
|
||||
package com.jihe.punchnet.api
|
||||
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.google.gson.JsonParser
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import okio.Buffer
|
||||
import java.nio.charset.StandardCharsets
|
||||
import javax.crypto.Mac
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
class SignInterceptor : Interceptor {
|
||||
|
||||
private val secretKey = "H6p*2RfEu4ITcL"
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val originalRequest = chain.request()
|
||||
val body = originalRequest.body
|
||||
|
||||
if (originalRequest.method == "POST" && body != null && body.contentType()?.subtype == "json") {
|
||||
try {
|
||||
// Read original JSON body
|
||||
val buffer = Buffer()
|
||||
body.writeTo(buffer)
|
||||
val jsonStr = buffer.readUtf8()
|
||||
|
||||
// Parse into JsonObject
|
||||
val jsonElement = JsonParser.parseString(jsonStr)
|
||||
if (jsonElement.isJsonObject) {
|
||||
val jsonObject = jsonElement.asJsonObject
|
||||
|
||||
// Sort keys
|
||||
val sortedKeys = jsonObject.keySet().sorted()
|
||||
|
||||
// Build query string
|
||||
val queryStringBuilder = StringBuilder()
|
||||
val gson = GsonBuilder().disableHtmlEscaping().create()
|
||||
|
||||
for (key in sortedKeys) {
|
||||
val element = jsonObject.get(key)
|
||||
if (element.isJsonNull) continue
|
||||
|
||||
if (queryStringBuilder.isNotEmpty()) {
|
||||
queryStringBuilder.append("&")
|
||||
}
|
||||
|
||||
queryStringBuilder.append(key).append("=")
|
||||
|
||||
if (element.isJsonPrimitive) {
|
||||
val primitive = element.asJsonPrimitive
|
||||
if (primitive.isString) {
|
||||
queryStringBuilder.append(primitive.asString)
|
||||
} else {
|
||||
// numbers and booleans
|
||||
queryStringBuilder.append(primitive.asString)
|
||||
}
|
||||
} else {
|
||||
// Object or Array - convert to JSON string (JSON_UNESCAPED_UNICODE is default in Gson if disableHtmlEscaping is used)
|
||||
queryStringBuilder.append(gson.toJson(element))
|
||||
}
|
||||
}
|
||||
|
||||
val queryString = queryStringBuilder.toString()
|
||||
|
||||
// Calculate HMAC-MD5
|
||||
val sign = calculateHmacMD5(queryString, secretKey)
|
||||
// Inject sign into HTTP Header X-sign
|
||||
val newRequest = originalRequest.newBuilder()
|
||||
.addHeader("X-sign", sign)
|
||||
.post(body)
|
||||
.build()
|
||||
|
||||
return chain.proceed(newRequest)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
return chain.proceed(originalRequest)
|
||||
}
|
||||
|
||||
private fun calculateHmacMD5(data: String, key: String): String {
|
||||
val algorithm = "HmacMD5"
|
||||
val mac = Mac.getInstance(algorithm)
|
||||
val secretKeySpec = SecretKeySpec(key.toByteArray(StandardCharsets.UTF_8), algorithm)
|
||||
mac.init(secretKeySpec)
|
||||
val hashBytes = mac.doFinal(data.toByteArray(StandardCharsets.UTF_8))
|
||||
return hashBytes.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,35 @@
|
||||
package com.jihe.punchnet.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.content.edit
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
object AppAuthRepository {
|
||||
private const val PREFS_NAME = "punchnet_app_auth"
|
||||
private const val KEY_ALLOWED_APPS = "allowed_apps"
|
||||
|
||||
private val _allowedApps = MutableStateFlow<Set<String>>(emptySet())
|
||||
val allowedApps: StateFlow<Set<String>> = _allowedApps
|
||||
|
||||
fun init(context: Context) {
|
||||
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val savedApps = prefs.getStringSet(KEY_ALLOWED_APPS, emptySet()) ?: emptySet()
|
||||
_allowedApps.value = savedApps
|
||||
}
|
||||
|
||||
fun toggleApp(context: Context, packageName: String, isAllowed: Boolean) {
|
||||
val current = _allowedApps.value.toMutableSet()
|
||||
if (isAllowed) {
|
||||
current.add(packageName)
|
||||
} else {
|
||||
current.remove(packageName)
|
||||
}
|
||||
_allowedApps.value = current
|
||||
|
||||
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit {
|
||||
putStringSet(KEY_ALLOWED_APPS, current)
|
||||
}
|
||||
}
|
||||
}
|
||||
56
app/src/main/java/com/jihe/punchnet/data/NodeRepository.kt
Normal file
56
app/src/main/java/com/jihe/punchnet/data/NodeRepository.kt
Normal file
@ -0,0 +1,56 @@
|
||||
package com.jihe.punchnet.data
|
||||
|
||||
import com.jihe.punchnet.api.NodeItem
|
||||
import com.jihe.punchnet.api.ResourceItem
|
||||
import com.jihe.punchnet.api.ExitNodeItem
|
||||
import com.jihe.punchnet.api.ConnectResponseData
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
data class LocalProfileInfo(
|
||||
val ip: String,
|
||||
val maskLen: Int,
|
||||
val hostname: String,
|
||||
val identityId: Int
|
||||
)
|
||||
|
||||
object NodeRepository {
|
||||
private val _nodes = MutableStateFlow<List<NodeItem>>(emptyList())
|
||||
val nodes: StateFlow<List<NodeItem>> = _nodes
|
||||
|
||||
private val _resources = MutableStateFlow<List<ResourceItem>>(emptyList())
|
||||
val resources: StateFlow<List<ResourceItem>> = _resources
|
||||
|
||||
private val _exitNodes = MutableStateFlow<List<ExitNodeItem>>(emptyList())
|
||||
val exitNodes: StateFlow<List<ExitNodeItem>> = _exitNodes
|
||||
|
||||
private val _localProfile = MutableStateFlow<LocalProfileInfo?>(null)
|
||||
val localProfile: StateFlow<LocalProfileInfo?> = _localProfile
|
||||
|
||||
fun updateAll(data: ConnectResponseData?) {
|
||||
if (data == null) {
|
||||
clear()
|
||||
return
|
||||
}
|
||||
_nodes.value = data.nodeList ?: emptyList()
|
||||
_resources.value = data.resourceList ?: emptyList()
|
||||
_exitNodes.value = data.exitNode ?: emptyList()
|
||||
_localProfile.value = LocalProfileInfo(
|
||||
ip = data.ip,
|
||||
maskLen = data.maskLen,
|
||||
hostname = data.hostname,
|
||||
identityId = data.identityId
|
||||
)
|
||||
}
|
||||
|
||||
fun updateNodes(newNodes: List<NodeItem>?) {
|
||||
_nodes.value = newNodes ?: emptyList()
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
_nodes.value = emptyList()
|
||||
_resources.value = emptyList()
|
||||
_exitNodes.value = emptyList()
|
||||
_localProfile.value = null
|
||||
}
|
||||
}
|
||||
190
app/src/main/java/com/jihe/punchnet/screen/AppAuthScreen.kt
Normal file
190
app/src/main/java/com/jihe/punchnet/screen/AppAuthScreen.kt
Normal file
@ -0,0 +1,190 @@
|
||||
package com.jihe.punchnet.screen
|
||||
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.drawable.Drawable
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Clear
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import androidx.navigation.NavHostController
|
||||
import com.jihe.punchnet.data.AppAuthRepository
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
data class AppInfo(
|
||||
val name: String,
|
||||
val packageName: String,
|
||||
val icon: Drawable
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun AppAuthScreen(navController: NavHostController) {
|
||||
val context = LocalContext.current
|
||||
val allowedApps by AppAuthRepository.allowedApps.collectAsState()
|
||||
|
||||
var installedApps by remember { mutableStateOf<List<AppInfo>>(emptyList()) }
|
||||
var isLoading by remember { mutableStateOf(true) }
|
||||
var showAppSelector by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val pm = context.packageManager
|
||||
val intent = android.content.Intent(android.content.Intent.ACTION_MAIN, null).apply {
|
||||
addCategory(android.content.Intent.CATEGORY_LAUNCHER)
|
||||
}
|
||||
// Use queryIntentActivities to reliably find all user-facing apps
|
||||
val resolveInfos = pm.queryIntentActivities(intent, 0)
|
||||
val apps = resolveInfos.mapNotNull {
|
||||
try {
|
||||
val appInfo = it.activityInfo.applicationInfo
|
||||
if (appInfo.packageName != context.packageName) {
|
||||
AppInfo(
|
||||
name = it.loadLabel(pm).toString(),
|
||||
packageName = appInfo.packageName,
|
||||
icon = it.loadIcon(pm)
|
||||
)
|
||||
} else null
|
||||
} catch (e: Exception) {
|
||||
null // Skip apps that cause PackageManager exceptions
|
||||
}
|
||||
}.distinctBy { it.packageName }.sortedBy { it.name }
|
||||
installedApps = apps
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
if (showAppSelector) {
|
||||
// App Selection View
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
androidx.compose.material3.IconButton(onClick = { showAppSelector = false }) {
|
||||
androidx.compose.material3.Icon(
|
||||
imageVector = Icons.Default.ArrowBack,
|
||||
contentDescription = "返回"
|
||||
)
|
||||
}
|
||||
Text("选择要代理的应用", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("正在加载应用列表...")
|
||||
}
|
||||
} else {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(installedApps) { app ->
|
||||
val isAllowed = allowedApps.contains(app.packageName)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Image(
|
||||
bitmap = app.icon.toBitmap().asImageBitmap(),
|
||||
contentDescription = app.name,
|
||||
modifier = Modifier.size(40.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(text = app.name, fontWeight = FontWeight.Bold, fontSize = 16.sp)
|
||||
Text(text = app.packageName, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
Switch(
|
||||
checked = isAllowed,
|
||||
onCheckedChange = { checked ->
|
||||
AppAuthRepository.toggleApp(context, app.packageName, checked)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Main View
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Text(
|
||||
text = "只有在列表中的应用,其网络请求才会通过 PunchNet 代理。若列表为空,则默认代理所有应用。",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(16.dp)
|
||||
)
|
||||
|
||||
val selectedApps = installedApps.filter { allowedApps.contains(it.packageName) }
|
||||
|
||||
if (selectedApps.isEmpty() && !isLoading) {
|
||||
Box(modifier = Modifier.fillMaxSize().padding(bottom = 80.dp), contentAlignment = Alignment.Center) {
|
||||
Text("当前未选择任何应用\n(将代理全局流量)", textAlign = androidx.compose.ui.text.style.TextAlign.Center, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(bottom = 80.dp)) {
|
||||
items(selectedApps) { app ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Image(
|
||||
bitmap = app.icon.toBitmap().asImageBitmap(),
|
||||
contentDescription = app.name,
|
||||
modifier = Modifier.size(40.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(text = app.name, fontWeight = FontWeight.Bold, fontSize = 16.sp)
|
||||
Text(text = app.packageName, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
androidx.compose.material3.IconButton(
|
||||
onClick = { AppAuthRepository.toggleApp(context, app.packageName, false) }
|
||||
) {
|
||||
androidx.compose.material3.Icon(
|
||||
imageVector = Icons.Default.Clear,
|
||||
contentDescription = "移除"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add Button (FAB)
|
||||
androidx.compose.material3.FloatingActionButton(
|
||||
onClick = { showAppSelector = true },
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(16.dp)
|
||||
) {
|
||||
androidx.compose.material3.Icon(
|
||||
imageVector = Icons.Default.Add,
|
||||
contentDescription = "添加应用"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
57
app/src/main/java/com/jihe/punchnet/screen/DeviceScreen.kt
Normal file
57
app/src/main/java/com/jihe/punchnet/screen/DeviceScreen.kt
Normal file
@ -0,0 +1,57 @@
|
||||
package com.jihe.punchnet.screen
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.Phone
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavHostController
|
||||
|
||||
@Composable
|
||||
fun DeviceScreen(navController: NavHostController) {
|
||||
val nodeList by com.jihe.punchnet.data.NodeRepository.nodes.collectAsState()
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth().fillMaxHeight(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
if (nodeList.isNotEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
text = "网络节点 (${nodeList.count { it.connectionStatus == "connected" }}/${nodeList.size})",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(bottom = 16.dp, start = 24.dp, top = 24.dp).fillMaxWidth()
|
||||
)
|
||||
}
|
||||
|
||||
items(nodeList) { node ->
|
||||
Box(modifier = Modifier.padding(horizontal = 24.dp)) {
|
||||
NodeItemRow(node)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
item {
|
||||
Box(modifier = Modifier.fillMaxSize().padding(top = 100.dp), contentAlignment = Alignment.Center) {
|
||||
Text("暂无设备,请先连接网络", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
213
app/src/main/java/com/jihe/punchnet/screen/LoginScreen.kt
Normal file
213
app/src/main/java/com/jihe/punchnet/screen/LoginScreen.kt
Normal file
@ -0,0 +1,213 @@
|
||||
package com.jihe.punchnet.screen
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavHostController
|
||||
import com.jihe.punchnet.R
|
||||
import com.jihe.punchnet.api.ApiClient
|
||||
import com.jihe.punchnet.api.AuthLoginRequest
|
||||
import com.jihe.punchnet.api.AuthTokenRequest
|
||||
import com.jihe.punchnet.helper.PreferenceName
|
||||
import com.jihe.punchnet.helper.setPreferenceString
|
||||
import com.jihe.punchnet.sdlan.utils.UniqueNodeID
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
fun LoginScreen(navController: NavHostController) {
|
||||
var selectedTabIndex by remember { mutableStateOf(0) }
|
||||
val tabs = listOf("账号密码登录", "Token直连")
|
||||
|
||||
var username by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
var token by remember { mutableStateOf("") }
|
||||
|
||||
var isLoading by remember { mutableStateOf(false) }
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
val apiClient = remember { ApiClient() }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.punchnet_log),
|
||||
contentDescription = "logo",
|
||||
modifier = Modifier.size(100.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = "Welcome to PunchNet",
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
TabRow(selectedTabIndex = selectedTabIndex) {
|
||||
tabs.forEachIndexed { index, title ->
|
||||
Tab(
|
||||
selected = selectedTabIndex == index,
|
||||
onClick = { selectedTabIndex = index },
|
||||
text = { Text(title) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
if (selectedTabIndex == 0) {
|
||||
// Account Login
|
||||
OutlinedTextField(
|
||||
value = username,
|
||||
onValueChange = { username = it },
|
||||
label = { Text("用户名") },
|
||||
leadingIcon = { Icon(Icons.Default.Person, contentDescription = null) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text("密码") },
|
||||
leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null) },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
} else {
|
||||
// Token Login
|
||||
OutlinedTextField(
|
||||
value = token,
|
||||
onValueChange = { token = it },
|
||||
label = { Text("连接 Token") },
|
||||
leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
if (selectedTabIndex == 0 && (username.isEmpty() || password.isEmpty())) {
|
||||
Toast.makeText(context, "请输入用户名和密码", Toast.LENGTH_SHORT).show()
|
||||
return@Button
|
||||
}
|
||||
if (selectedTabIndex == 1 && token.isEmpty()) {
|
||||
Toast.makeText(context, "请输入Token", Toast.LENGTH_SHORT).show()
|
||||
return@Button
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
coroutineScope.launch {
|
||||
UniqueNodeID.setBaseDir(context.filesDir.absolutePath)
|
||||
val clientId = "punchnet_android_client_" + UniqueNodeID.getUUID(context)
|
||||
|
||||
try {
|
||||
val response = if (selectedTabIndex == 0) {
|
||||
val request = AuthLoginRequest(
|
||||
clientId = clientId,
|
||||
username = username,
|
||||
password = password,
|
||||
version = "1.0.0",
|
||||
system = "Android " + android.os.Build.VERSION.RELEASE,
|
||||
hostname = android.os.Build.MODEL
|
||||
)
|
||||
ApiClient().loginWithAccount(request)
|
||||
} else {
|
||||
val request = AuthTokenRequest(
|
||||
clientId = clientId,
|
||||
token = token,
|
||||
version = "1.0.0",
|
||||
system = "Android " + android.os.Build.VERSION.RELEASE,
|
||||
hostname = android.os.Build.MODEL
|
||||
)
|
||||
ApiClient().loginWithToken(request)
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
isLoading = false
|
||||
if (response != null && response.code == 0 && response.data != null) {
|
||||
// Success! Save auth data as JSON and navigate
|
||||
val authDataJson = com.google.gson.Gson().toJson(response.data)
|
||||
setPreferenceString(context, PreferenceName.PreferenceToken, authDataJson)
|
||||
Toast.makeText(context, "登录成功", Toast.LENGTH_SHORT).show()
|
||||
navController.navigate(Screen.MainScreen.route) {
|
||||
popUpTo(Screen.LoginScreen.route) { inclusive = true }
|
||||
}
|
||||
} else {
|
||||
Toast.makeText(context, "登录失败: ${response?.message ?: "网络错误"}", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
withContext(Dispatchers.Main) {
|
||||
isLoading = false
|
||||
Toast.makeText(context, "登录异常: ${e.message}", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(50.dp),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
enabled = !isLoading
|
||||
) {
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
} else {
|
||||
Text("登录", fontSize = 16.sp)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
Toast.makeText(context, "请前往官网注册账号", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
) {
|
||||
Text("没有账号?去注册")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
189
app/src/main/java/com/jihe/punchnet/screen/SettingsScreen.kt
Normal file
189
app/src/main/java/com/jihe/punchnet/screen/SettingsScreen.kt
Normal file
@ -0,0 +1,189 @@
|
||||
package com.jihe.punchnet.screen
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Clear
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.jihe.punchnet.data.RouteItem
|
||||
import com.jihe.punchnet.data.RouteViewModel
|
||||
import com.jihe.punchnet.helper.IntToIPString
|
||||
import com.jihe.punchnet.helper.parseCIDRAndGW
|
||||
import com.jihe.punchnet.sdlan.network.maskIPToDigit
|
||||
import com.jihe.punchnet.data.NodeRepository
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
|
||||
@Composable
|
||||
fun SettingsScreen(enabled: Boolean, routeViewModel: RouteViewModel) {
|
||||
val localProfile by NodeRepository.localProfile.collectAsState()
|
||||
val exitNodes by NodeRepository.exitNodes.collectAsState()
|
||||
val routeItems by routeViewModel.allRoutes.collectAsState(initial = emptyList())
|
||||
|
||||
val showAddRoute = remember { mutableStateOf<RouteItem?>(null) }
|
||||
val shouldShow = remember { mutableStateOf(false) }
|
||||
|
||||
AddOrModifyRouteDialog(routeViewModel, showAddRoute, shouldShow, exitNodes)
|
||||
|
||||
LazyColumn(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
||||
// Section 1: Local Profile
|
||||
item {
|
||||
Text("本机信息", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
if (localProfile != null) {
|
||||
Card(modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp)) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text("Hostname: ${localProfile!!.hostname}")
|
||||
Text("IP: ${localProfile!!.ip}/${localProfile!!.maskLen}")
|
||||
Text("Identity ID: ${localProfile!!.identityId}")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text("暂无本机网络信息", color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(bottom = 16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
// Section 2: Exit Nodes
|
||||
item {
|
||||
Text("出口节点", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
if (exitNodes.isEmpty()) {
|
||||
Text("当前网络无可用出口节点", color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(bottom = 16.dp))
|
||||
}
|
||||
}
|
||||
items(exitNodes) { exitNode ->
|
||||
Card(modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text("名称: ${exitNode.nodeName}", fontWeight = FontWeight.Bold)
|
||||
Text("网关 IP: ${exitNode.gateway}")
|
||||
Text("代理网段: ${exitNode.targetNetwork}")
|
||||
}
|
||||
}
|
||||
}
|
||||
item { Spacer(modifier = Modifier.height(8.dp)) }
|
||||
|
||||
// Section 3: Routes
|
||||
item {
|
||||
Text("本地路由配置", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
items(routeItems) { item ->
|
||||
RouteCard(routeViewModel, item, enabled)
|
||||
}
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Button(
|
||||
enabled = enabled,
|
||||
onClick = { shouldShow.value = true },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("添加路由")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AddOrModifyRouteDialog(
|
||||
routeViewModel: RouteViewModel,
|
||||
initRoute: MutableState<RouteItem?>,
|
||||
shouldShowRoute: MutableState<Boolean>,
|
||||
exitNodes: List<com.jihe.punchnet.api.ExitNodeItem>
|
||||
) {
|
||||
if (shouldShowRoute.value) {
|
||||
val context = LocalContext.current
|
||||
var cidr by remember { mutableStateOf("") }
|
||||
var gw by remember { mutableStateOf("") }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = { shouldShowRoute.value = false },
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
val item = parseCIDRAndGW(cidr, gw)
|
||||
if (item == null) {
|
||||
Toast.makeText(context, "数据解析出错", Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
routeViewModel.insert(item)
|
||||
shouldShowRoute.value = false
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text("确定")
|
||||
}
|
||||
},
|
||||
title = { Text("添加新路由") },
|
||||
text = {
|
||||
Column {
|
||||
TextField(
|
||||
value = cidr,
|
||||
onValueChange = { cidr = it },
|
||||
label = { Text("CIDR") },
|
||||
placeholder = { Text("例如 0.0.0.0/0") }
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
TextField(
|
||||
value = gw,
|
||||
onValueChange = { gw = it },
|
||||
label = { Text("Gateway") },
|
||||
placeholder = { Text("例如 10.10.4.5") }
|
||||
)
|
||||
if (exitNodes.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text("快捷选择出口节点网关:", fontSize = 12.sp)
|
||||
Row(
|
||||
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
exitNodes.forEach { en ->
|
||||
SuggestionChip(
|
||||
onClick = {
|
||||
gw = en.gateway
|
||||
if (cidr.isEmpty()) cidr = en.targetNetwork
|
||||
},
|
||||
label = { Text(en.nodeName) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RouteCard(
|
||||
routeViewModel: RouteViewModel,
|
||||
item: RouteItem,
|
||||
enabled: Boolean,
|
||||
) {
|
||||
Card(modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.height(48.dp).padding(horizontal = 16.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Clear,
|
||||
contentDescription = "delete route",
|
||||
modifier = Modifier.size(24.dp).clickable(enabled = enabled) {
|
||||
routeViewModel.deleteById(item.id)
|
||||
}
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Text("${IntToIPString(item.net_ip)}/${maskIPToDigit(item.mask_ip)}")
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Text("-> ${IntToIPString(item.gateway)}")
|
||||
}
|
||||
}
|
||||
}
|
||||
94
app/src/main/java/com/jihe/punchnet/screen/WebViewScreen.kt
Normal file
94
app/src/main/java/com/jihe/punchnet/screen/WebViewScreen.kt
Normal file
@ -0,0 +1,94 @@
|
||||
package com.jihe.punchnet.screen
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.webkit.WebChromeClient
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
@Composable
|
||||
fun WebViewScreen(url: String, onBack: () -> Unit) {
|
||||
var webViewRef by remember { mutableStateOf<WebView?>(null) }
|
||||
var loadingProgress by remember { mutableStateOf(0f) }
|
||||
var isLoading by remember { mutableStateOf(true) }
|
||||
var currentTitle by remember { mutableStateOf("资源查看") }
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
TopAppBar(
|
||||
title = { Text(currentTitle) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.Default.Close, contentDescription = "关闭")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { webViewRef?.reload() }) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = "刷新")
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
LinearProgressIndicator(
|
||||
progress = { loadingProgress },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
WebView(context).apply {
|
||||
settings.javaScriptEnabled = true
|
||||
settings.domStorageEnabled = true
|
||||
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
|
||||
return false // Let WebView load the URL
|
||||
}
|
||||
}
|
||||
|
||||
webChromeClient = object : WebChromeClient() {
|
||||
override fun onProgressChanged(view: WebView?, newProgress: Int) {
|
||||
loadingProgress = newProgress / 100f
|
||||
isLoading = newProgress < 100
|
||||
}
|
||||
|
||||
override fun onReceivedTitle(view: WebView?, title: String?) {
|
||||
if (!title.isNullOrEmpty()) {
|
||||
currentTitle = title
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
webViewRef = this
|
||||
loadUrl(url)
|
||||
}
|
||||
},
|
||||
update = { webView ->
|
||||
// Don't loadUrl here again, it causes infinite reloading on recomposition
|
||||
},
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,214 @@
|
||||
package com.jihe.punchnet.sdlan.network
|
||||
|
||||
import android.util.Log
|
||||
import com.jihe.punchnet.sdlan.logs.TerminalLogger
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.ReceiveChannel
|
||||
import kotlinx.coroutines.channels.SendChannel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.selects.select
|
||||
import kotlinx.coroutines.withContext
|
||||
import tech.kwik.core.QuicClientConnection
|
||||
import tech.kwik.core.QuicStream
|
||||
import java.net.URI
|
||||
import java.nio.ByteBuffer
|
||||
import java.io.InputStream
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
suspend fun initQUICConn(
|
||||
scope: CoroutineScope,
|
||||
quicHost: String,
|
||||
quicPort: Int,
|
||||
start_stop: Channel<StartStopChanInfo>,
|
||||
pong_time: AtomicLong,
|
||||
connected: AtomicBoolean,
|
||||
toSocket: ReceiveChannel<ByteArray>,
|
||||
onConnected: suspend (stream: QuicStream)->Unit,
|
||||
onMessage: suspend (CoroutineScope, SDLanControl)->Unit,
|
||||
onDisconnected: suspend ()->Unit,
|
||||
connectingChan: SendChannel<ConnectingState>?
|
||||
) {
|
||||
val started = AtomicBoolean(false)
|
||||
val node = Node.getInstance()
|
||||
|
||||
while (scope.isActive) {
|
||||
connectingChan?.send(ConnectingState.NotConnected)
|
||||
|
||||
if (!started.get()) {
|
||||
while (true) {
|
||||
val startStopInfo = start_stop.receive()
|
||||
if (startStopInfo.flag == StartStopFlag.IsStart) {
|
||||
started.set(true)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
connectingChan?.send(ConnectingState.Connecting)
|
||||
TerminalLogger.debugf { "try connecting quic..." }
|
||||
|
||||
var connection: QuicClientConnection? = null
|
||||
var stream: QuicStream? = null
|
||||
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
TerminalLogger.debugf { "connecting to QUIC $quicHost:$quicPort" }
|
||||
val builder = QuicClientConnection.newBuilder()
|
||||
.uri(URI("https://$quicHost:$quicPort"))
|
||||
.applicationProtocol("punchnet/1.0")
|
||||
.noServerCertificateCheck()
|
||||
connection = builder.build()
|
||||
connection!!.connect()
|
||||
|
||||
// Open bidirectional stream
|
||||
stream = connection!!.createStream(true)
|
||||
|
||||
// In QUIC, streams are created implicitly when the first data is sent.
|
||||
// We send a PACKET_EMPTY (0x00) so the server can accept the stream and send Welcome.
|
||||
val emptyPacket = ByteArray(3)
|
||||
emptyPacket[0] = 0 // Len high
|
||||
emptyPacket[1] = 1 // Len low (1 byte for PacketType)
|
||||
emptyPacket[2] = 0 // PacketType.Empty (0x00)
|
||||
TerminalLogger.debugf { "Sending QUIC application packet (PACKET_EMPTY): [00 01 00]" }
|
||||
stream!!.outputStream.write(emptyPacket)
|
||||
stream!!.outputStream.flush()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
TerminalLogger.errorf { "failed to connect to QUIC ${quicHost}:${quicPort}: $e" }
|
||||
delay(3000)
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
val job_read_packet = scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
while (isActive) {
|
||||
val controlPacket = readQUICPacket(stream!!)
|
||||
if (controlPacket == null) {
|
||||
TerminalLogger.errorf { "QUIC Stream EOF or error" }
|
||||
break
|
||||
}
|
||||
onMessage(scope, controlPacket)
|
||||
}
|
||||
} finally {
|
||||
TerminalLogger.errorf { "QUIC read loop closing" }
|
||||
}
|
||||
}
|
||||
|
||||
val job_write_to_packet = scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
while (isActive) {
|
||||
val msg = toSocket.receive()
|
||||
val packetType = if (msg.size >= 3) PacketType.fromValue(msg[2].toUByte()) else null
|
||||
TerminalLogger.debugf { "Sending QUIC application packet: type=${packetType ?: "unknown"}, bytes=${msg.size}" }
|
||||
stream!!.outputStream.write(msg)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
TerminalLogger.errorf { "failed to write to QUIC stream: $e" }
|
||||
}
|
||||
}
|
||||
|
||||
val job_check_pong = scope.launch(Dispatchers.IO) {
|
||||
while (isActive) {
|
||||
delay(10_000)
|
||||
val now = System.currentTimeMillis() / 1000
|
||||
if (connected.get() && (now - pong_time.get() > com.jihe.punchnet.sdlan.config.SDLanConfig.TCP_PING_TIME * 2)) {
|
||||
TerminalLogger.errorf { "QUIC pong check expired" }
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val job_check_stop = scope.launch(Dispatchers.IO) {
|
||||
while (isActive) {
|
||||
try {
|
||||
val v = start_stop.receive()
|
||||
if (v.flag == StartStopFlag.IsStop) {
|
||||
started.set(false)
|
||||
break
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TerminalLogger.debugf { "QUIC Stream connected" }
|
||||
onConnected(stream!!)
|
||||
connectingChan?.send(ConnectingState.Connected)
|
||||
|
||||
select<Unit> {
|
||||
job_read_packet.onJoin { }
|
||||
job_write_to_packet.onJoin { }
|
||||
job_check_pong.onJoin { }
|
||||
job_check_stop.onJoin { }
|
||||
}
|
||||
|
||||
job_read_packet.cancelAndJoin()
|
||||
job_write_to_packet.cancelAndJoin()
|
||||
job_check_pong.cancelAndJoin()
|
||||
job_check_stop.cancelAndJoin()
|
||||
|
||||
delay(1000)
|
||||
} catch (e: Exception) {
|
||||
onDisconnected()
|
||||
} finally {
|
||||
try {
|
||||
connection?.close()
|
||||
} catch (e: Exception) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun readQUICPacket(stream: QuicStream): SDLanControl? = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
// <<Len:16, PacketType:8, ProtobufPayload>>
|
||||
val lenBytes = ByteArray(2)
|
||||
if (!stream.inputStream.readFullyOrEof(lenBytes)) return@withContext null
|
||||
|
||||
val len = ByteBuffer.wrap(lenBytes).short.toInt() and 0xFFFF
|
||||
if (len < 1) return@withContext null
|
||||
|
||||
val typeByte = ByteArray(1)
|
||||
if (!stream.inputStream.readFullyOrEof(typeByte)) return@withContext null
|
||||
|
||||
val packetTypeUByte = typeByte[0].toUByte()
|
||||
val packetType = PacketType.fromValue(packetTypeUByte)
|
||||
if (packetType == null) {
|
||||
TerminalLogger.errorf { "packet type not found: $packetTypeUByte" }
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
val payloadLen = len - 1
|
||||
val payload = ByteArray(payloadLen)
|
||||
var toread = payloadLen
|
||||
var offset = 0
|
||||
while (toread > 0) {
|
||||
val sizeGot = stream.inputStream.read(payload, offset, toread)
|
||||
if (sizeGot <= 0) return@withContext null
|
||||
toread -= sizeGot
|
||||
offset += sizeGot
|
||||
}
|
||||
|
||||
return@withContext SDLanControl(packetType, payload)
|
||||
} catch (e: Exception) {
|
||||
TerminalLogger.errorf { "failed to read QUIC stream: $e" }
|
||||
return@withContext null
|
||||
}
|
||||
}
|
||||
|
||||
private fun InputStream.readFullyOrEof(buffer: ByteArray): Boolean {
|
||||
var offset = 0
|
||||
while (offset < buffer.size) {
|
||||
val readSize = read(buffer, offset, buffer.size - offset)
|
||||
if (readSize <= 0) return false
|
||||
offset += readSize
|
||||
}
|
||||
return true
|
||||
}
|
||||
106
app/src/main/java/com/jihe/punchnet/sdlan/network/quic_data.kt
Normal file
106
app/src/main/java/com/jihe/punchnet/sdlan/network/quic_data.kt
Normal file
@ -0,0 +1,106 @@
|
||||
package com.jihe.punchnet.sdlan.network
|
||||
|
||||
import android.util.Log
|
||||
import com.jihe.punchnet.PunchnetServiceArgument
|
||||
import com.jihe.punchnet.api.ConnectResponseData
|
||||
import com.jihe.punchnet.sdlan.config.Arguments
|
||||
import com.jihe.punchnet.sdlan.logs.TerminalLogger
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import tech.kwik.core.QuicClientConnection
|
||||
import java.net.InetSocketAddress
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
suspend fun run_quic_data_plane(
|
||||
scope: CoroutineScope,
|
||||
service: com.jihe.punchnet.PunchnetService,
|
||||
argument: Arguments,
|
||||
connectData: ConnectResponseData
|
||||
) {
|
||||
TerminalLogger.infof { "Starting QUIC Data Plane..." }
|
||||
|
||||
// Parse the server address from argument.sn (e.g. root.punchsky.com:1265)
|
||||
val snParts = argument.sn.split(":")
|
||||
val host = snParts[0]
|
||||
val port = snParts[1].toIntOrNull() ?: 1265
|
||||
|
||||
while (scope.isActive) {
|
||||
var connection: QuicClientConnection? = null
|
||||
try {
|
||||
TerminalLogger.debugf { "Connecting to QUIC server at $host:$port..." }
|
||||
|
||||
// In Kwik, QuicClientConnection is used to connect to a server.
|
||||
// Note: Kwik API details may require a specific Builder or config.
|
||||
// We use standard connection parameters.
|
||||
val serverAddress = InetSocketAddress(host, port)
|
||||
|
||||
val builder = QuicClientConnection.newBuilder()
|
||||
.uri(java.net.URI("https://$host:$port"))
|
||||
// TODO: configure certificates if self-signed, else default is fine.
|
||||
|
||||
connection = builder.build()
|
||||
connection.connect()
|
||||
|
||||
TerminalLogger.infof { "QUIC connection established!" }
|
||||
|
||||
// Start reading from the TUN interface and sending as QUIC Datagrams
|
||||
val jobWrite = scope.launch(Dispatchers.IO) {
|
||||
while (isActive) {
|
||||
val data = service.recv()
|
||||
if (data.isEmpty()) {
|
||||
delay(1000)
|
||||
continue
|
||||
}
|
||||
|
||||
// Send over QUIC Datagram
|
||||
// Note: Kwik Datagram API might be on the connection object.
|
||||
try {
|
||||
// Using datagram sending API from Kwik
|
||||
// If kwik doesn't support datagram directly, we fallback to streams
|
||||
// For RFC 9221 datagrams:
|
||||
// connection.sendDatagram(data)
|
||||
// Note: Datagram API in Kwik is still experimental in some versions.
|
||||
// We will use stream for fallback if datagram fails compile.
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start reading QUIC Datagrams from server and write to TUN
|
||||
val jobRead = scope.launch(Dispatchers.IO) {
|
||||
while (isActive) {
|
||||
// Receive Datagram
|
||||
// val buf = connection.receiveDatagram()
|
||||
// service.send(buf)
|
||||
delay(100)
|
||||
}
|
||||
}
|
||||
|
||||
jobWrite.join()
|
||||
jobRead.join()
|
||||
|
||||
} catch (e: Exception) {
|
||||
TerminalLogger.errorf { "QUIC connection error: ${e.message}" }
|
||||
e.printStackTrace()
|
||||
delay(3000)
|
||||
} finally {
|
||||
try {
|
||||
connection?.close()
|
||||
} catch (e: Exception) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun ipToInt(ipAddress: String): Int {
|
||||
val parts = ipAddress.split(".")
|
||||
if (parts.size != 4) return 0
|
||||
var result = 0
|
||||
for (i in 0..3) {
|
||||
result = result shl 8 or (parts[i].toInt() and 0xFF)
|
||||
}
|
||||
return result
|
||||
}
|
||||
20
app/src/test/java/com/jihe/punchnet/TestQuic.kt
Normal file
20
app/src/test/java/com/jihe/punchnet/TestQuic.kt
Normal file
@ -0,0 +1,20 @@
|
||||
package com.jihe.punchnet
|
||||
|
||||
import org.junit.Test
|
||||
import tech.kwik.core.QuicClientConnection
|
||||
import java.net.URI
|
||||
|
||||
class TestQuic {
|
||||
@Test
|
||||
fun testQuic() {
|
||||
try {
|
||||
val builder = QuicClientConnection.newBuilder()
|
||||
.uri(URI("https://127.0.0.1:1365"))
|
||||
.applicationProtocol("punchnet/1.0")
|
||||
.noServerCertificateCheck()
|
||||
val connection = builder.build()
|
||||
connection.connect()
|
||||
} catch (e: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
10
udp_dump.py
Normal file
10
udp_dump.py
Normal file
@ -0,0 +1,10 @@
|
||||
import socket
|
||||
import binascii
|
||||
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.bind(('127.0.0.1', 1365))
|
||||
print("Listening on UDP 1365...")
|
||||
data, addr = sock.recvfrom(2048)
|
||||
print("Received QUIC UDP packet:")
|
||||
print(binascii.hexlify(data).decode('utf-8'))
|
||||
sock.close()
|
||||
Loading…
x
Reference in New Issue
Block a user