From d116d2cbcd10cce02d0f160b3896e0e2cf9644d4 Mon Sep 17 00:00:00 2001 From: stavid Date: Sat, 20 Jun 2026 13:28:22 +0800 Subject: [PATCH] fix some warning --- API.md | 517 ++++++++++++ Protocol.md | 777 ++++++++++++++++++ Sign.md | 112 +++ .../com/jihe/punchnet/api/SignInterceptor.kt | 91 ++ .../jihe/punchnet/data/AppAuthRepository.kt | 35 + .../com/jihe/punchnet/data/NodeRepository.kt | 56 ++ .../com/jihe/punchnet/screen/AppAuthScreen.kt | 190 +++++ .../com/jihe/punchnet/screen/DeviceScreen.kt | 57 ++ .../com/jihe/punchnet/screen/LoginScreen.kt | 213 +++++ .../jihe/punchnet/screen/SettingsScreen.kt | 189 +++++ .../com/jihe/punchnet/screen/WebViewScreen.kt | 94 +++ .../punchnet/sdlan/network/quic_control.kt | 214 +++++ .../jihe/punchnet/sdlan/network/quic_data.kt | 106 +++ .../test/java/com/jihe/punchnet/TestQuic.kt | 20 + udp_dump.py | 10 + 15 files changed, 2681 insertions(+) create mode 100644 API.md create mode 100644 Protocol.md create mode 100644 Sign.md create mode 100644 app/src/main/java/com/jihe/punchnet/api/SignInterceptor.kt create mode 100644 app/src/main/java/com/jihe/punchnet/data/AppAuthRepository.kt create mode 100644 app/src/main/java/com/jihe/punchnet/data/NodeRepository.kt create mode 100644 app/src/main/java/com/jihe/punchnet/screen/AppAuthScreen.kt create mode 100644 app/src/main/java/com/jihe/punchnet/screen/DeviceScreen.kt create mode 100644 app/src/main/java/com/jihe/punchnet/screen/LoginScreen.kt create mode 100644 app/src/main/java/com/jihe/punchnet/screen/SettingsScreen.kt create mode 100644 app/src/main/java/com/jihe/punchnet/screen/WebViewScreen.kt create mode 100644 app/src/main/java/com/jihe/punchnet/sdlan/network/quic_control.kt create mode 100644 app/src/main/java/com/jihe/punchnet/sdlan/network/quic_data.kt create mode 100644 app/src/test/java/com/jihe/punchnet/TestQuic.kt create mode 100644 udp_dump.py diff --git a/API.md b/API.md new file mode 100644 index 0000000..d7e8453 --- /dev/null +++ b/API.md @@ -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": [] + } +} +``` \ No newline at end of file diff --git a/Protocol.md b/Protocol.md new file mode 100644 index 0000000..615114c --- /dev/null +++ b/Protocol.md @@ -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`: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 = <> +Frame = <> +``` + +- `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 +<> +``` + +```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 +<> +``` + +```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 +<> +``` + +```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 +<> +``` + +```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 +<> + +Server -> Client +<> +``` + +- Client 只在注册成功后发送 Ping。 +- 服务端收到 Ping 后立即回复 Pong,并记录本轮心跳已收到。 +- 服务端按 `heartbeat_sec` 检查上一周期是否收到过 Ping;如果没有收到,关闭连接。 + +### 5.6 QUIC 查询 PeerInfo + +```text +Client -> Server +<> +``` + +```protobuf +message SDLQueryInfo { + bytes dst_mac = 1; +} +``` + +- `dst_mac`:目标节点 MAC。 +- 服务端查找目标节点的 NAT 映射和 IPv6 信息。 +- 如果目标存在,服务端同时会向目标节点推送 `SDLEvent.SendRegister`,让目标主动向查询方发送打洞包,提高 P2P 成功率。 + +响应: + +```text +Server -> Client +<> +``` + +```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 +<> +``` + +```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 +<> +``` + +```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 +<> +``` + +```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 +<> +``` + +```protobuf +message SDLPolicyResponse { + uint32 src_identity_id = 1; + uint32 dst_identity_id = 2; + uint32 version = 3; + bytes rules = 4; +} +``` + +- `rules`:稀疏序列化规则列表,每条规则格式为 `<>`。 +- `Proto`:协议号,例如 TCP/UDP 对应的协议编号。 +- `Port`:允许访问的目标端口。 +- 服务端只下发 allow 规则,deny 规则在服务端侧已过滤。 +- 如果请求解析或规则查询失败,当前服务端可能不返回响应包。 + +### 5.9 QUIC ExposedService 查询 + +```text +Client -> Server +<> +``` + +```protobuf +message SDLExposedServiceRequest { + uint32 version = 1; +} +``` + +- `version`:Client 当前缓存版本,服务端响应中原样返回。 + +响应: + +```text +Server -> Client +<> +``` + +```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 +<> +``` + +```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 +<> +``` + +```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 +<> +``` + +```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 +<> +``` + +- 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 = <> +Frame = <> +``` + +- `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 = <> +``` + +### 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 可达性探测。 diff --git a/Sign.md b/Sign.md new file mode 100644 index 0000000..eb18831 --- /dev/null +++ b/Sign.md @@ -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 + diff --git a/app/src/main/java/com/jihe/punchnet/api/SignInterceptor.kt b/app/src/main/java/com/jihe/punchnet/api/SignInterceptor.kt new file mode 100644 index 0000000..3356a38 --- /dev/null +++ b/app/src/main/java/com/jihe/punchnet/api/SignInterceptor.kt @@ -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) } + } +} diff --git a/app/src/main/java/com/jihe/punchnet/data/AppAuthRepository.kt b/app/src/main/java/com/jihe/punchnet/data/AppAuthRepository.kt new file mode 100644 index 0000000..9453e83 --- /dev/null +++ b/app/src/main/java/com/jihe/punchnet/data/AppAuthRepository.kt @@ -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>(emptySet()) + val allowedApps: StateFlow> = _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) + } + } +} diff --git a/app/src/main/java/com/jihe/punchnet/data/NodeRepository.kt b/app/src/main/java/com/jihe/punchnet/data/NodeRepository.kt new file mode 100644 index 0000000..b41c4d9 --- /dev/null +++ b/app/src/main/java/com/jihe/punchnet/data/NodeRepository.kt @@ -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>(emptyList()) + val nodes: StateFlow> = _nodes + + private val _resources = MutableStateFlow>(emptyList()) + val resources: StateFlow> = _resources + + private val _exitNodes = MutableStateFlow>(emptyList()) + val exitNodes: StateFlow> = _exitNodes + + private val _localProfile = MutableStateFlow(null) + val localProfile: StateFlow = _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?) { + _nodes.value = newNodes ?: emptyList() + } + + fun clear() { + _nodes.value = emptyList() + _resources.value = emptyList() + _exitNodes.value = emptyList() + _localProfile.value = null + } +} diff --git a/app/src/main/java/com/jihe/punchnet/screen/AppAuthScreen.kt b/app/src/main/java/com/jihe/punchnet/screen/AppAuthScreen.kt new file mode 100644 index 0000000..e860cb8 --- /dev/null +++ b/app/src/main/java/com/jihe/punchnet/screen/AppAuthScreen.kt @@ -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>(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 = "添加应用" + ) + } + } + } +} diff --git a/app/src/main/java/com/jihe/punchnet/screen/DeviceScreen.kt b/app/src/main/java/com/jihe/punchnet/screen/DeviceScreen.kt new file mode 100644 index 0000000..88d1dfc --- /dev/null +++ b/app/src/main/java/com/jihe/punchnet/screen/DeviceScreen.kt @@ -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) + } + } + } + } +} diff --git a/app/src/main/java/com/jihe/punchnet/screen/LoginScreen.kt b/app/src/main/java/com/jihe/punchnet/screen/LoginScreen.kt new file mode 100644 index 0000000..41207ea --- /dev/null +++ b/app/src/main/java/com/jihe/punchnet/screen/LoginScreen.kt @@ -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("没有账号?去注册") + } + } + } +} diff --git a/app/src/main/java/com/jihe/punchnet/screen/SettingsScreen.kt b/app/src/main/java/com/jihe/punchnet/screen/SettingsScreen.kt new file mode 100644 index 0000000..96e8cb8 --- /dev/null +++ b/app/src/main/java/com/jihe/punchnet/screen/SettingsScreen.kt @@ -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(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, + shouldShowRoute: MutableState, + exitNodes: List +) { + 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)}") + } + } +} diff --git a/app/src/main/java/com/jihe/punchnet/screen/WebViewScreen.kt b/app/src/main/java/com/jihe/punchnet/screen/WebViewScreen.kt new file mode 100644 index 0000000..d484383 --- /dev/null +++ b/app/src/main/java/com/jihe/punchnet/screen/WebViewScreen.kt @@ -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(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() + ) + } +} diff --git a/app/src/main/java/com/jihe/punchnet/sdlan/network/quic_control.kt b/app/src/main/java/com/jihe/punchnet/sdlan/network/quic_control.kt new file mode 100644 index 0000000..2845c9e --- /dev/null +++ b/app/src/main/java/com/jihe/punchnet/sdlan/network/quic_control.kt @@ -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, + pong_time: AtomicLong, + connected: AtomicBoolean, + toSocket: ReceiveChannel, + onConnected: suspend (stream: QuicStream)->Unit, + onMessage: suspend (CoroutineScope, SDLanControl)->Unit, + onDisconnected: suspend ()->Unit, + connectingChan: SendChannel? +) { + 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 { + 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 { + // <> + 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 +} diff --git a/app/src/main/java/com/jihe/punchnet/sdlan/network/quic_data.kt b/app/src/main/java/com/jihe/punchnet/sdlan/network/quic_data.kt new file mode 100644 index 0000000..245724b --- /dev/null +++ b/app/src/main/java/com/jihe/punchnet/sdlan/network/quic_data.kt @@ -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 +} diff --git a/app/src/test/java/com/jihe/punchnet/TestQuic.kt b/app/src/test/java/com/jihe/punchnet/TestQuic.kt new file mode 100644 index 0000000..959acc7 --- /dev/null +++ b/app/src/test/java/com/jihe/punchnet/TestQuic.kt @@ -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) { + } + } +} diff --git a/udp_dump.py b/udp_dump.py new file mode 100644 index 0000000..45fa000 --- /dev/null +++ b/udp_dump.py @@ -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()