Compare commits

..

No commits in common. "d116d2cbcd10cce02d0f160b3896e0e2cf9644d4" and "91042c083fd556fb818ce4a2b619df7026c3937f" have entirely different histories.

59 changed files with 788 additions and 4832 deletions

1
.idea/.name generated
View File

@ -1 +0,0 @@
punchnet

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>

View File

@ -4,14 +4,6 @@
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-05-30T07:22:02.241059Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="LocalEmulator" identifier="path=/Users/stavid/.android/avd/Medium_Phone.avd" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState>
</selectionStates>
</component>

View File

@ -1,13 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceTable">
<option name="columnSorters">
<list>
<ColumnSorterState>
<option name="column" value="Name" />
<option name="order" value="ASCENDING" />
</ColumnSorterState>
</list>
</option>
</component>
</project>

View File

@ -49,10 +49,6 @@
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />

8
.idea/markdown.xml generated
View File

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MarkdownSettings">
<option name="previewPanelProviderInfo">
<ProviderInfo name="Compose (experimental)" className="com.intellij.markdown.compose.preview.ComposePanelProvider" />
</option>
</component>
</project>

1
.idea/misc.xml generated
View File

@ -1,3 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">

517
API.md
View File

@ -1,517 +0,0 @@
## 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": []
}
}
```

View File

@ -1,777 +0,0 @@
# 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
View File

@ -1,112 +0,0 @@
# 接口签名算法说明
## 概述
本接口使用 **HMAC-MD5** 算法对请求参数进行签名,以确保请求的合法性与完整性,防止请求被篡改或伪造。
生成的sign值放在header中key=X-signvalue=加密值。
---
## 签名生成流程
```
请求参数
① 参数递归排序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&currency=CNY&items=[{"id":1,"name":"商品A"}]&uid=1001
```
### 第三步:计算签名
```
sign = HMAC-MD5("amount=100&currency=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

View File

@ -90,18 +90,12 @@ dependencies {
ksp("androidx.room:room-compiler:2.7.2")
implementation("androidx.room:room-ktx:2.7.2")
// HTTP API Control Plane
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.google.code.gson:gson:2.10.1")
// QUIC Data Plane (Kwik)
implementation("tech.kwik:kwik:0.10.3")
implementation(libs.bcprov.jdk18on)
// implementation(libs.bcprov.jdk18on)
implementation(libs.bcpkix.jdk18on)
// implementation(files("libs/org.asxalex.sdlan/sdlan-1.0-SNAPSHOT.jar"))
// implementation(files("libs/org.asxalex.sdlan/1.0.0/sdlan-1.0.0.jar"))
//implementation("org.jetbrains.kotlin:kotlin-stdlib:2.1.0") {
//version { strictly("2.1.0") }
// }
// Security Crypto
implementation("androidx.security:security-crypto:1.1.0-alpha06")
}

Binary file not shown.

View File

@ -1,37 +0,0 @@
{
"version": 3,
"artifactType": {
"type": "APK",
"kind": "Directory"
},
"applicationId": "com.jihe.punchnet",
"variantName": "release",
"elements": [
{
"type": "SINGLE",
"filters": [],
"attributes": [],
"versionCode": 1,
"versionName": "1.0",
"outputFile": "app-release.apk"
}
],
"elementType": "File",
"baselineProfiles": [
{
"minApi": 28,
"maxApi": 30,
"baselineProfiles": [
"baselineProfiles/1/app-release.dm"
]
},
{
"minApi": 31,
"maxApi": 2147483647,
"baselineProfiles": [
"baselineProfiles/0/app-release.dm"
]
}
],
"minSdkVersionForDexing": 26
}

View File

@ -5,20 +5,17 @@
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"/>
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@drawable/punchnet_log"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Punchnet"
android:usesCleartextTraffic="true"
tools:targetApi="31">
<activity
android:name=".MainActivity"
@ -33,7 +30,7 @@
</activity>
<service android:name=".PunchnetService"
android:exported="true"
android:exported="false"
android:foregroundServiceType="specialUse"
android:permission="android.permission.BIND_VPN_SERVICE">

View File

@ -1,6 +1,5 @@
package com.jihe.punchnet
import android.app.Application
import android.content.Context
import android.content.Intent
import android.net.VpnService
@ -32,21 +31,12 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.compose.rememberNavController
import com.jihe.punchnet.data.ButtonState
import com.jihe.punchnet.data.ButtonViewModel
import com.jihe.punchnet.data.RouteViewModel
import com.jihe.punchnet.data.ServiceViewModel
import com.jihe.punchnet.screen.MainApp
import com.jihe.punchnet.ui.theme.PunchnetTheme
import kotlinx.coroutines.flow.onEach
class PunchnetApp: Application() {
val buttonModel: ButtonViewModel by lazy {
ViewModelProvider.AndroidViewModelFactory.getInstance(this)
.create(ButtonViewModel::class.java)
}
}
class MainActivity : ComponentActivity() {
private val TAG = "MainActivity"
@ -68,18 +58,6 @@ class MainActivity : ComponentActivity() {
}
}
/*
val buttonModel: ButtonViewModel by viewModels {
object: ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun<T: ViewModel> create(modelClass: Class<T>): T {
return ButtonViewModel(application) as T
}
}
}
*/
/*
fun prepareAndStartVPN() {
@ -114,21 +92,14 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Initialize AppAuthRepository for Split Tunneling
com.jihe.punchnet.data.AppAuthRepository.init(this)
enableEdgeToEdge()
Log.d("DIR", "filesdir = ${this.filesDir}")
val buttonModel = ButtonViewModel()
buttonModel.updateState(ButtonState.ButtonStopped)
setContent {
PunchnetTheme {
MainApp(buttonModel, serviceModel, viewModel, rememberNavController())
MainApp(serviceModel, viewModel, rememberNavController())
/*
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
StartStop(

View File

@ -12,9 +12,6 @@ import android.os.ParcelFileDescriptor
import android.util.Log
import android.widget.Toast
import androidx.core.app.NotificationCompat
import com.jihe.punchnet.data.ButtonRepository
import com.jihe.punchnet.data.ButtonState
import com.jihe.punchnet.data.RouteItem
import com.jihe.punchnet.sdlan.config.Arguments
import com.jihe.punchnet.sdlan.config.toIPV4String
import com.jihe.punchnet.sdlan.logs.TerminalLogger
@ -23,10 +20,10 @@ import com.jihe.punchnet.sdlan.network.ARPWaitList
import com.jihe.punchnet.sdlan.network.DeviceConfig
import com.jihe.punchnet.sdlan.network.IfaceTun
import com.jihe.punchnet.sdlan.network.RouteDetail
import com.jihe.punchnet.sdlan.network.RouteTable
import com.jihe.punchnet.sdlan.network.cidrToRouteDetail
import com.jihe.punchnet.sdlan.network.ipInt2ByteArray
import com.jihe.punchnet.sdlan.network.maskIPToDigit
import com.jihe.punchnet.sdlan.network.run_sdlan
import com.jihe.punchnet.sdlan.utils.ipToString
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
@ -36,21 +33,15 @@ import java.io.FileInputStream
import java.io.FileOutputStream
class PunchnetService : VpnService() , IfaceTun {
private val TAG = "PunchnetService"
private var scope = CoroutineScope(Dispatchers.IO)
private val routes: MutableList<RouteItem> = mutableListOf()
private val scope = CoroutineScope(Dispatchers.Default)
private var vpnDescriptor: ParcelFileDescriptor? = null
private val serviceLock = Any()
// private val isRunning = AtomicBoolean(false)
var input: FileInputStream? = null
var output: FileOutputStream? = null
override var arpTable = ARPTable(scope)
override val arpTable = ARPTable()
override val arpWaitList = ARPWaitList()
var config: DeviceConfig = DeviceConfig(1400)
@ -60,16 +51,12 @@ class PunchnetService : VpnService() , IfaceTun {
const val ACTION_DISCONNECT = "com.jihe.punchnet.punchnetservice.DISCONNECT"
}
override fun close() {
vpnDescriptor?.close()
}
override suspend fun recv(): ByteArray {
val result = withContext(Dispatchers.IO) {
val result = ByteArray(1500)
try {
var size = input?.read(result)
if (size == null) {
println("xxx failed to read")
size = 0
} else {
println("xxx got $size bytes")
@ -79,11 +66,6 @@ class PunchnetService : VpnService() , IfaceTun {
// Log.d(TAG, "RECEIVED $size bytes")
result.copyOf(size)
// result.slice(0..<size).toByteArray()
} catch (e: Exception) {
println("xxx read exception: ${e.message}")
e.printStackTrace()
ByteArray(0)
}
}
return result
}
@ -91,11 +73,7 @@ class PunchnetService : VpnService() , IfaceTun {
override suspend fun send(content: ByteArray) {
withContext(Dispatchers.IO) {
// Log.d(TAG, "WROTE bytes to vpn service")
try {
output?.write(content)
} catch (e: Exception) {
TerminalLogger.errorf { "failed to write $e" }
}
}
}
@ -113,97 +91,29 @@ class PunchnetService : VpnService() , IfaceTun {
}
vpnDescriptor?.close()
var tempVpnDescriptor = Builder()
vpnDescriptor = Builder()
.setMtu(config.mtu)
.addAddress(ip, config.ip.netBitLen.toInt())
.setBlocking(true)
.establish()
// Automatically add route for the SDLAN network itself
val maskInt = com.jihe.punchnet.sdlan.network.netmaskBit2Int(config.ip.netBitLen)
val networkInt = config.ip.netAddr and maskInt
val networkStr = ipInt2ByteArray(networkInt).toIPV4String()
tempVpnDescriptor = tempVpnDescriptor.addRoute(networkStr, config.ip.netBitLen.toInt())
for (route in routes) {
tempVpnDescriptor = tempVpnDescriptor.addRoute(ipToString(route.net_ip), maskIPToDigit(route.mask_ip))
}
// Apply App Authorization (Split Tunneling)
val allowedApps = com.jihe.punchnet.data.AppAuthRepository.allowedApps.value
if (allowedApps.isNotEmpty()) {
for (pkg in allowedApps) {
// Ensure we do not add ourselves to the allowed list, otherwise it causes a routing loop
if (pkg == this.packageName) continue
try {
tempVpnDescriptor = tempVpnDescriptor.addAllowedApplication(pkg)
} catch (e: android.content.pm.PackageManager.NameNotFoundException) {
TerminalLogger.warning { "App not found when setting VPN allowed app: $pkg" }
}
}
} else {
// If no apps are specified, VPN intercepts everything matching the routes.
// We ONLY disallow ourselves if there's a 0.0.0.0/0 route, to avoid routing loops.
// If there's no global route, we want the app (e.g. in-app WebViews) to be able to access the VPN routes!
val hasGlobalRoute = routes.any { it.mask_ip == 0 }
if (hasGlobalRoute) {
try {
tempVpnDescriptor = tempVpnDescriptor.addDisallowedApplication(this.packageName)
TerminalLogger.infof { "Disallowed app itself to prevent loop on 0.0.0.0/0" }
} catch (e: Exception) {
TerminalLogger.warning { "Failed to disallow self: $e" }
}
} else {
TerminalLogger.infof { "Did not disallow self. In-app WebViews will be routed via VPN." }
}
}
vpnDescriptor = tempVpnDescriptor
.establish()?.apply {
input = FileInputStream(fileDescriptor)
output = FileOutputStream(fileDescriptor)
}
ButtonRepository.updateState(ButtonState.ButtonStarted)
input = FileInputStream(vpnDescriptor!!.fileDescriptor)
output = FileOutputStream(vpnDescriptor!!.fileDescriptor)
}
private fun disconnect() {
synchronized(serviceLock) {
if (ButtonRepository.buttonState.value != ButtonState.ButtonStarted) {
return
}
/*
if (!isRunning.get()) {
return
}
*/
ButtonRepository.updateState(ButtonState.ButtonStopping)
Toast.makeText(this, "stop vpn called", Toast.LENGTH_LONG).show()
input?.close()
output?.close()
vpnDescriptor?.close()
vpnDescriptor = null
stopForeground(STOP_FOREGROUND_REMOVE)
// stopForeground(true)
stopSelf()
// isRunning.set(false)
}
ButtonRepository.updateState(ButtonState.ButtonStopped)
}
private fun connect(startArg: PunchnetServiceArgument?) {
synchronized(serviceLock) {
if (ButtonRepository.buttonState.value != ButtonState.ButtonStopped) {
return
}
/*
if (isRunning.get()) {
return
}
*/
ButtonRepository.updateState(ButtonState.ButtonStarting)
val iface = this
val server = "punchnet.aioe.tech"
Log.d("DIR", "datadir = ${Environment.getDataDirectory().name}")
@ -211,74 +121,30 @@ class PunchnetService : VpnService() , IfaceTun {
Log.d("DIR", "filesdir = ${this.filesDir.path}")
arpTable.routeTable.clearRoute()
routes.clear()
for (route in startArg?.routes?.toList()?:listOf()) {
val r = RouteDetail(
mask = route.mask_ip,
gw = route.gateway,
maskedAddr = route.net_ip,
)
routes.add(route)
// val r = cidrToRouteDetail(route.targetNetCIDR, route.gateway)
val r = cidrToRouteDetail(route.targetNetCIDR, route.gateway)
if (r != null) {
arpTable.routeTable.addRoute(r)
}
}
val argument = Arguments(
baseDir = this.filesDir.path,
sn = "$server:443",
stun = "$server:1365",
nat_server1 = "$server:1365",
nat_server2 = "$server:1366",
sn = "$server:1265",
tcp = "$server:18083",
nat_server1 = "$server:1265",
nat_server2 = "47.98.178.3:1265",
token = startArg?.token?:"",
name = "tau",
)
scope.launch {
run_sdlan(iface, argument, startArg)
}
val notification = createNotification()
startForeground(1, notification)
// Initialize UniqueNodeID baseDir to app's files directory
com.jihe.punchnet.sdlan.utils.UniqueNodeID.setBaseDir(this.filesDir.absolutePath)
scope.launch {
val apiClient = com.jihe.punchnet.api.ApiClient()
val authDataJson = startArg?.token ?: ""
if (authDataJson.isEmpty()) {
TerminalLogger.errorf { "API connect failed: No auth data found" }
ButtonRepository.updateState(ButtonState.ButtonStopped)
return@launch
}
val authData = try {
com.google.gson.Gson().fromJson(authDataJson, com.jihe.punchnet.api.AuthResponseData::class.java)
} catch (e: Exception) {
TerminalLogger.errorf { "API connect failed: Invalid auth data" }
ButtonRepository.updateState(ButtonState.ButtonStopped)
return@launch
}
// 1. Connect directly using the accessToken obtained from LoginScreen
val connectReq = com.jihe.punchnet.api.ConnectRequest(
clientId = "punchnet_android_client_" + com.jihe.punchnet.sdlan.utils.UniqueNodeID.getUUID(this@PunchnetService),
accessToken = authData.accessToken
)
val connectRes = apiClient.connect(connectReq)
if (connectRes == null || connectRes.code != 0 || connectRes.data == null) {
TerminalLogger.errorf { "API connect failed: ${connectRes?.message}" }
ButtonRepository.updateState(ButtonState.ButtonStopped)
return@launch
}
// Update UI state to Started since connection was successful
ButtonRepository.updateState(ButtonState.ButtonStarted)
com.jihe.punchnet.data.NodeRepository.updateAll(connectRes.data)
run_sdlan(scope, iface, argument, authData, connectRes.data)
}
// isRunning.set(true)
}
// ButtonRepository.updateState(ButtonState.ButtonStarted)
// buttonViewModel.changeButtonState(ButtonState.ButtonStarted)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@ -289,15 +155,12 @@ class PunchnetService : VpnService() , IfaceTun {
disconnect()
START_STICKY
} else {
scope = CoroutineScope(Dispatchers.IO)
arpTable = ARPTable(scope)
val argument = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent?.getParcelableExtra("argument", PunchnetServiceArgument::class.java)
} else {
intent?.getParcelableExtra("argument")
}
TerminalLogger.debugf { "service argument received: routes=${argument?.routes?.size ?: 0}, hasToken=${!argument?.token.isNullOrEmpty()}" }
println("argument = ${argument}")
connect(argument)
START_STICKY
}
@ -324,11 +187,6 @@ class PunchnetService : VpnService() , IfaceTun {
override fun onDestroy() {
// scope.cancel()
// disconnect()
scope.cancel()
disconnect()
com.jihe.punchnet.data.NodeRepository.clear()
Log.e(TAG, "onDestroy is called")
// stopForeground(STOP_FOREGROUND_REMOVE)
super.onDestroy()
}
}

View File

@ -2,7 +2,6 @@ package com.jihe.punchnet
import android.os.Parcel
import android.os.Parcelable
import com.jihe.punchnet.data.RouteItem
data class RouteInfo(
// 192.168.80.0/24
@ -13,16 +12,14 @@ data class RouteInfo(
data class PunchnetServiceArgument(
val token: String,
val routes: Array<RouteItem>,
val routes: Array<RouteInfo>,
): Parcelable {
override fun writeToParcel(dest: Parcel, flags: Int) {
dest.writeString(token)
dest.writeInt(routes.size)
routes.forEach { item ->
dest.writeLong(item.id)
dest.writeInt(item.net_ip)
dest.writeInt(item.mask_ip)
dest.writeInt(item.gateway)
dest.writeString(item.targetNetCIDR)
dest.writeString(item.gateway)
}
}
@ -34,18 +31,13 @@ data class PunchnetServiceArgument(
override fun createFromParcel(source: Parcel): PunchnetServiceArgument {
val token = source.readString()!!
val size = source.readInt()
var routes = mutableListOf<RouteItem>()
var routes = mutableListOf<RouteInfo>()
for (i in 0..<size) {
val id = source.readLong()!!
val netIP = source.readInt()!!
val maskIP = source.readInt()!!
val gateway = source.readInt()!!
routes.add(RouteItem(
id = id,
net_ip = netIP,
mask_ip = maskIP,
gateway = gateway,
val net = source.readString()!!
val gw = source.readString()!!
routes.add(RouteInfo(
targetNetCIDR = net,
gateway = gw,
))
}
return PunchnetServiceArgument(token, routes.toTypedArray())

View File

@ -1,81 +0,0 @@
package com.jihe.punchnet.api
import android.util.Log
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.util.concurrent.TimeUnit
class ApiClient {
private val client = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS)
.addInterceptor(SignInterceptor())
.build()
private val gson = Gson()
private val JSON = "application/json; charset=utf-8".toMediaType()
private val BASE_URL = "https://root.punchsky.com/api"
suspend fun loginWithToken(request: AuthTokenRequest): BaseResponse<AuthResponseData>? {
return post("/auth/token", request)
}
suspend fun connect(request: ConnectRequest): BaseResponse<ConnectResponseData>? {
return post("/connect", request)
}
suspend fun disconnect(request: DisconnectRequest): BaseResponse<Any>? {
return post("/disconnect", request)
}
suspend fun loginWithAccount(request: AuthLoginRequest): BaseResponse<AuthResponseData>? {
return post("/auth/login", request)
}
suspend fun getNodeResources(request: GetNodeResourcesRequest): BaseResponse<GetNodeResourcesResponseData>? {
return post("/get_node_resources", request)
}
suspend fun getAcl(request: AclRequest): BaseResponse<AclResponseData>? {
return post("/acl", request)
}
private suspend inline fun <reified T, reified R> post(path: String, requestBody: T): BaseResponse<R>? {
return withContext(Dispatchers.IO) {
try {
val jsonBody = gson.toJson(requestBody)
Log.i("apiClient", "post [${BASE_URL + path}]")
val body = jsonBody.toRequestBody(JSON)
val request = Request.Builder()
.url(BASE_URL + path)
.post(body)
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
response.body?.close()
Log.e("apiClient", "response error [${BASE_URL + path}]: HTTP ${response.code}")
return@withContext BaseResponse<R>(code = -1, message = "HTTP ${response.code}: ${response.message}", data = null)
}
val responseStr = response.body?.string() ?: return@withContext BaseResponse<R>(code = -1, message = "Empty response body", data = null)
Log.i("apiClient", "response [${BASE_URL + path}]: HTTP ${response.code}")
val type = object : TypeToken<BaseResponse<R>>() {}.type
gson.fromJson<BaseResponse<R>>(responseStr, type)
}
} catch (e: Exception) {
e.printStackTrace()
Log.e("apiClient", "response exception [${BASE_URL + path}]: ${e.message}")
BaseResponse<R>(code = -1, message = e.message ?: "Unknown error", data = null)
}
}
}
}

View File

@ -1,115 +0,0 @@
package com.jihe.punchnet.api
import com.google.gson.annotations.SerializedName
data class AuthTokenRequest(
@SerializedName("client_id") val clientId: String,
@SerializedName("token") val token: String,
@SerializedName("mac") val mac: String? = null,
@SerializedName("system") val system: String? = null,
@SerializedName("version") val version: String? = null,
@SerializedName("hostname") val hostname: String? = null
)
data class NetworkItem(
@SerializedName("network_id") val networkId: Int,
@SerializedName("network_name") val networkName: String
)
data class AuthResponseData(
@SerializedName("access_token") val accessToken: String,
@SerializedName("username") val username: String,
@SerializedName("user_type") val userType: String,
@SerializedName("audit") val audit: Int,
@SerializedName("network_id") val networkId: Int,
@SerializedName("network_name") val networkName: String,
@SerializedName("network_domain") val networkDomain: String,
@SerializedName("domain") val domain: String?,
@SerializedName("my_network_list") val myNetworkList: List<NetworkItem>?
)
data class BaseResponse<T>(
@SerializedName("code") val code: Int,
@SerializedName("message") val message: String,
@SerializedName("data") val data: T?
)
data class ConnectRequest(
@SerializedName("client_id") val clientId: String,
@SerializedName("access_token") val accessToken: String,
@SerializedName("version") val version: String? = null
)
data class ResourceItem(
@SerializedName("id") val id: Int,
@SerializedName("name") val name: String,
@SerializedName("url") val url: String,
@SerializedName("connection_status") val connectionStatus: String
)
data class NodeItem(
@SerializedName("id") val id: Int,
@SerializedName("name") val name: String,
@SerializedName("ip") val ip: String,
@SerializedName("system") val system: String?,
@SerializedName("connection_status") val connectionStatus: String
)
data class ExitNodeItem(
@SerializedName("node_id") val nodeId: Int,
@SerializedName("node_name") val nodeName: String,
@SerializedName("gateway") val gateway: String,
@SerializedName("target_network") val targetNetwork: String
)
data class ConnectResponseData(
@SerializedName("ip") val ip: String,
@SerializedName("mask_len") val maskLen: Int,
@SerializedName("hostname") val hostname: String,
@SerializedName("identity_id") val identityId: Int,
@SerializedName("resource_list") val resourceList: List<ResourceItem>?,
@SerializedName("node_list") val nodeList: List<NodeItem>?,
@SerializedName("acl") val acl: com.google.gson.JsonElement?,
@SerializedName("exit_node") val exitNode: List<ExitNodeItem>?
)
data class AuthLoginRequest(
@SerializedName("client_id") val clientId: String,
@SerializedName("username") val username: String,
@SerializedName("password") val password: String,
@SerializedName("mac") val mac: String? = null,
@SerializedName("system") val system: String? = null,
@SerializedName("version") val version: String? = null,
@SerializedName("hostname") val hostname: String? = null
)
data class GetNodeResourcesRequest(
@SerializedName("client_id") val clientId: String,
@SerializedName("access_token") val accessToken: String,
@SerializedName("id") val id: Int
)
data class GetNodeResourcesResponseData(
@SerializedName("id") val id: Int,
@SerializedName("name") val name: String,
@SerializedName("ip") val ip: String,
@SerializedName("system") val system: String?,
@SerializedName("connection_status") val connectionStatus: String,
@SerializedName("resource_list") val resourceList: List<ResourceItem>?
)
data class AclRequest(
@SerializedName("client_id") val clientId: String,
@SerializedName("access_token") val accessToken: String,
@SerializedName("network_id") val networkId: Int? = null
)
data class AclResponseData(
@SerializedName("tcp") val tcp: List<Int>?,
@SerializedName("udp") val udp: List<Int>?
)
data class DisconnectRequest(
@SerializedName("client_id") val clientId: String,
@SerializedName("access_token") val accessToken: String
)

View File

@ -1,91 +0,0 @@
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) }
}
}

View File

@ -1,35 +0,0 @@
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)
}
}
}

View File

@ -1,34 +0,0 @@
package com.jihe.punchnet.data
import android.app.Application
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
object ButtonRepository {
private val _buttonState: MutableStateFlow<ButtonState> = MutableStateFlow(ButtonState.ButtonStopped)
val buttonState: StateFlow<ButtonState> = _buttonState
fun updateState(state: ButtonState) {
_buttonState.value = state
}
}
sealed class ButtonState(val text: String, val enabled: Boolean) {
object ButtonStarted: ButtonState("停止", true)
object ButtonStarting: ButtonState("启动中", false)
object ButtonStopped: ButtonState("启动", true)
object ButtonStopping: ButtonState("停止中", false)
}
class ButtonViewModel(): ViewModel() {
val buttonState = ButtonRepository.buttonState
fun updateState(state: ButtonState) {
ButtonRepository.updateState(state)
}
}

View File

@ -1,56 +0,0 @@
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
}
}

View File

@ -61,9 +61,7 @@ abstract class AppDatabase: RoomDatabase() {
context.applicationContext,
AppDatabase::class.java,
"app_database"
)
.fallbackToDestructiveMigration()
.build()
).build()
INSTANCE = instance
instance
}

View File

@ -16,38 +16,44 @@ import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.core.content.ContextCompat.startForegroundService
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.jihe.punchnet.PunchnetService
import com.jihe.punchnet.PunchnetServiceArgument
import com.jihe.punchnet.RouteInfo
import com.jihe.punchnet.sdlan.utils.ipToString
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.forEach
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
class ServiceViewModel(application: Application): AndroidViewModel(application) {
/*
private val _isRunning = mutableStateOf(false)
val isRunning: State<Boolean> = _isRunning
*/
fun startService(context: Context, routes: Array<RouteItem>, token: String) {
// should has the permission
startVpnService(context, routes, token)
// ButtonRepository.updateState(ButtonState.ButtonStarting)
// _isRunning.value = true
private val _hasVpnPermission = mutableStateOf(false)
val hasVpnPermission: State<Boolean> = _hasVpnPermission
fun setVPNPermission(isGranted: Boolean) {
_hasVpnPermission.value = isGranted
}
private fun startVpnService(context: Context, routes: Array<RouteItem>, token: String) {
fun startService(context: Context, noPermissionCallback: (Intent)->Unit) {
val intent = VpnService.prepare(context)
if (intent != null) {
noPermissionCallback(intent)
} else {
startVpnService(context)
_isRunning.value = true
}
}
private fun startVpnService(context: Context) {
val intent = Intent(context, PunchnetService::class.java)
intent.putExtra("argument", PunchnetServiceArgument(
token,
routes,
))
"",
arrayOf(
RouteInfo(
"192.168.80.0/24", "10.211.188.2"
)
)
)
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
@ -63,7 +69,7 @@ class ServiceViewModel(application: Application): AndroidViewModel(application)
PunchnetService.ACTION_DISCONNECT })
// stopService(Intent(this, PunchnetService::class.java))
Log.d("STOPPED PUNCHNET", "stopping PUNCHNET")
// ButtonRepository.updateState(ButtonState.ButtonStopping)
_isRunning.value = false
// Toast.makeText(this, "VPN service stopped", Toast.LENGTH_SHORT).show()
}
}

View File

@ -1,158 +1,6 @@
package com.jihe.punchnet.helper
import android.app.Activity
import android.content.Context
import android.content.SharedPreferences
import com.jihe.punchnet.data.RouteItem
import com.jihe.punchnet.sdlan.network.maskDigitToInt
const val PreferenceRepositoryName = "pref"
sealed class PreferenceName(val name: String) {
object PreferenceToken: PreferenceName("token")
}
private fun getEncryptedSharedPrefs(context: Context): SharedPreferences {
val masterKey = androidx.security.crypto.MasterKey.Builder(context)
.setKeyScheme(androidx.security.crypto.MasterKey.KeyScheme.AES256_GCM)
.build()
return androidx.security.crypto.EncryptedSharedPreferences.create(
context,
PreferenceRepositoryName,
masterKey,
androidx.security.crypto.EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
androidx.security.crypto.EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
}
private fun getPlainSharedPrefs(context: Context): SharedPreferences {
return context.getSharedPreferences(PreferenceRepositoryName, Activity.MODE_PRIVATE)
}
private fun migratePlainPreference(context: Context, name: PreferenceName, encryptedPrefs: SharedPreferences) {
if (encryptedPrefs.contains(name.name)) return
val plainPrefs = getPlainSharedPrefs(context)
if (!plainPrefs.contains(name.name)) return
when (val value = plainPrefs.all[name.name]) {
is String -> encryptedPrefs.edit().putString(name.name, value).apply()
is Int -> encryptedPrefs.edit().putInt(name.name, value).apply()
}
plainPrefs.edit().remove(name.name).apply()
}
fun getPreferenceString(context: Context, name: PreferenceName): String? {
try {
val preference = getEncryptedSharedPrefs(context)
migratePlainPreference(context, name, preference)
return preference.getString(name.name, "")
} catch (e: Exception) {
return null
}
}
fun getPreferenceInt(context: Context, name: PreferenceName): Int? {
try {
val preference = getEncryptedSharedPrefs(context)
migratePlainPreference(context, name, preference)
return preference.getInt(name.name, 0)
} catch (e: Exception) {
return null
}
}
fun setPreferenceString(context: Context, name: PreferenceName, value: String) {
try {
val preference = getEncryptedSharedPrefs(context)
preference.edit().putString(name.name, value).apply()
} catch (e: Exception) {
return
}
}
fun setPreferenceInt(context: Context, name: PreferenceName, value: Int) {
try {
val preference = getEncryptedSharedPrefs(context)
preference.edit().putInt(name.name, value).apply()
} catch (e: Exception) {
return
}
}
sealed class Screen(val route: String) {
object ScreenRoutes: Screen("routes")
object ScreenMain: Screen("main")
}
fun IntToIPString(ip: Int): String {
val one = ((ip ushr 24) and 0x000000ff)
val two = ((ip ushr 16) and 0x000000ff)
val three = ((ip ushr 8) and 0x000000ff)
val four = ((ip) and 0x000000ff)
return "${one}.${two}.${three}.${four}"
}
fun IPStringToInt(ipStr: String): Int? {
val digits = ipStr.split('.').filter{
val single = try {
it.toInt()
} catch(e: Exception) {
return@filter false
}
single in 0..255
}
if (digits.size != 4) {
return null
}
val result: Int
try {
result = (digits[0].toInt() shl 24) +
(digits[1].toInt() shl 16) +
(digits[2].toInt() shl 8) +
(digits[3].toInt())
} catch(e: Exception) {
return null
}
return result
}
fun parseCIDRAndGW(cidr: String, gw: String): RouteItem? {
val cidrSplit = cidr.split('/')
var ip = 0
var digit = 0
if (cidrSplit.size == 1) {
digit = 32
} else if (cidrSplit.size == 2) {
try {
val k = cidrSplit[1].toInt()
if (k in 1..32) {
digit = k
} else {
return null
}
} catch (e: Exception) {
return null
}
} else {
return null
}
val ipvalue = IPStringToInt(cidrSplit[0])
val gateway = IPStringToInt(gw)
if (ipvalue == null) {
return null
}
if (gateway == null) {
return null
}
val mask = maskDigitToInt(digit)!!
return RouteItem(
net_ip = ipvalue,
gateway = gateway,
mask_ip = mask,
)
}

View File

@ -1,190 +0,0 @@
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 = "添加应用"
)
}
}
}
}

View File

@ -11,14 +11,12 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Button
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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
@ -36,60 +34,33 @@ import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import com.jihe.punchnet.R
import com.jihe.punchnet.RouteInfo
import com.jihe.punchnet.data.ButtonRepository
import com.jihe.punchnet.data.ButtonState
import com.jihe.punchnet.data.ButtonViewModel
import com.jihe.punchnet.data.RouteViewModel
import com.jihe.punchnet.data.ServiceViewModel
@Composable
fun AppNavHost2(
buttonViewModel: ButtonViewModel,
serviceViewModel: ServiceViewModel,
routeViewModel: RouteViewModel = viewModel(),
navController: NavHostController,
paddingValues: PaddingValues
) {
val context = androidx.compose.ui.platform.LocalContext.current
val token = com.jihe.punchnet.helper.getPreferenceString(context, com.jihe.punchnet.helper.PreferenceName.PreferenceToken)
val startDest = if (token.isNullOrEmpty()) Screen.LoginScreen.route else Screen.MainScreen.route
NavHost(
navController = navController,
startDestination = startDest,
startDestination = Screen.MainScreen.route,
modifier = Modifier.padding(paddingValues)
) {
composable(Screen.LoginScreen.route) {
LoginScreen(navController)
}
composable(Screen.MainScreen.route) {
HomeScreen(buttonViewModel, serviceViewModel, routeViewModel, navController)
HomeScreen(serviceViewModel, routeViewModel)
}
composable(Screen.DeviceScreen.route) {
CustomHeaderScreen("设备") {
DeviceScreen(navController)
composable(Screen.RouteScreen.route) {
CustomHeaderScreen(
"光强计"
) {
// LightScreen()
}
}
composable(Screen.AppAuthScreen.route) {
CustomHeaderScreen("应用授权") {
AppAuthScreen(navController)
}
}
composable(Screen.SettingsScreen.route) {
CustomHeaderScreen("设置") {
SettingsScreen(ButtonRepository.buttonState.collectAsState().value != ButtonState.ButtonStarted, routeViewModel)
}
}
composable(
route = Screen.WebViewScreen.route,
arguments = listOf(navArgument("url") { type = androidx.navigation.NavType.StringType })
) { backStackEntry ->
val url = backStackEntry.arguments?.getString("url") ?: ""
WebViewScreen(
url = url,
onBack = { navController.popBackStack() }
)
composable(Screen.ProfileScreen.route) {
// Profile(dbdao, navController)
}
}
}

View File

@ -37,21 +37,17 @@ fun AppBottomNavigation(
Icon(
imageVector = when (screen) {
Screen.MainScreen -> Icons.Default.Home
Screen.DeviceScreen -> Icons.Default.Menu
Screen.AppAuthScreen -> Icons.Default.Person
else -> Icons.Default.Home
Screen.RouteScreen -> Icons.Default.Menu
// Screen.Search -> Icons.Default.Search
Screen.ProfileScreen -> Icons.Default.Person
},
contentDescription = screen.route
)
},
label = {
val labelText = when (screen) {
Screen.MainScreen -> "首页"
Screen.DeviceScreen -> "设备"
Screen.AppAuthScreen -> "应用授权"
else -> screen.route
}
Text(text = labelText)
Text(
text = screen.route.replaceFirstChar { it.uppercase() },
)
},
selected = currentRoute == screen.route,
onClick = {

View File

@ -1,57 +0,0 @@
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)
}
}
}
}
}

View File

@ -1,26 +1,15 @@
package com.jihe.punchnet.screen
import android.app.Activity.MODE_PRIVATE
import android.app.Activity.RESULT_CANCELED
import android.app.Activity.RESULT_OK
import android.content.Context
import android.content.Intent
import android.net.VpnService
import android.content.pm.PackageManager
import android.os.Build
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.DrawableRes
import androidx.compose.foundation.Image
import androidx.core.content.ContextCompat
import androidx.compose.foundation.clickable
import androidx.compose.foundation.indication
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@ -30,22 +19,12 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@ -57,164 +36,59 @@ import com.jihe.punchnet.PunchnetService
import com.jihe.punchnet.PunchnetServiceArgument
import com.jihe.punchnet.R
import com.jihe.punchnet.RouteInfo
import com.jihe.punchnet.data.ButtonRepository
import com.jihe.punchnet.data.ButtonState
import com.jihe.punchnet.data.ButtonViewModel
import com.jihe.punchnet.data.RouteViewModel
import com.jihe.punchnet.data.ServiceViewModel
import com.jihe.punchnet.helper.PreferenceName
import com.jihe.punchnet.helper.getPreferenceString
import com.jihe.punchnet.helper.setPreferenceString
import kotlin.math.exp
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.filled.Phone
import androidx.compose.material.icons.filled.Person
import androidx.compose.ui.graphics.Color
import androidx.compose.foundation.background
data class HomeDropDownInfo(
@DrawableRes val icon: Int,
val name: String,
val callback: (()->Unit)? = null,
)
@Composable
fun HomeDropdownMenu(navController: androidx.navigation.NavHostController) {
var expanded by remember { mutableStateOf(false) }
val context = LocalContext.current
val homeItems = arrayOf(
HomeDropDownInfo(
R.drawable.tag, // TODO: Use settings icon if available, or just standard icon
"设置",
{
navController.navigate(Screen.SettingsScreen.route)
}
),
HomeDropDownInfo(
R.drawable.tag,
"退出登录",
{
// Clear token and go to LoginScreen
setPreferenceString(context, PreferenceName.PreferenceToken, "")
navController.navigate(Screen.LoginScreen.route) {
popUpTo(Screen.MainScreen.route) { inclusive = true }
}
}
)
)
Box() {
IconButton(
interactionSource = remember { MutableInteractionSource() },
modifier = Modifier.indication(
interactionSource = remember { MutableInteractionSource() },
indication = null,
),
onClick = {
expanded = true
}
) {
Icon(
Icons.Default.Settings,
contentDescription = "settings",
)
}
DropdownMenu(
expanded = expanded,
onDismissRequest = {expanded = !expanded}
) {
homeItems.forEach { item ->
DropdownMenuItem(
onClick = {
expanded = false
item.callback?.invoke()
},
text = {
Row (
verticalAlignment = Alignment.CenterVertically
){
Icon(
painter = painterResource(item.icon),
modifier = Modifier.size(24.dp),
contentDescription = null,
tint = MaterialTheme.colorScheme.onBackground
)
Spacer(
modifier = Modifier.padding(start = 8.dp)
)
Text(text=item.name)
}
}
)
}
}
}
}
@Composable
fun HomeScreen(
buttonViewModel: ButtonViewModel,
serviceViewModel: ServiceViewModel,
routeViewModel: RouteViewModel,
navController: androidx.navigation.NavHostController,
// started: MutableState<Boolean>,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val tkPref = getPreferenceString(context, PreferenceName.PreferenceToken)
val allroutes = routeViewModel.allRoutes.collectAsState(emptyList())
val vpnLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult()
) {result ->
when(result.resultCode) {
RESULT_OK -> {
// serviceViewModel.startService(context)
// serviceViewModel.setVPNPermission(true)
val tk = getPreferenceString(context, PreferenceName.PreferenceToken)?:""
serviceViewModel.startService(context, allroutes.value.toTypedArray(), tk)
serviceViewModel.setVPNPermission(true)
}
RESULT_CANCELED -> {
serviceViewModel.setVPNPermission(false)
Toast.makeText(context, "vpn permission denied", Toast.LENGTH_SHORT).show()
}
}
}
val notificationPermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission()
) { isGranted ->
if (!isGranted) {
Toast.makeText(context, "未授予通知权限,连接可能会在后台被杀", Toast.LENGTH_SHORT).show()
}
val intent = VpnService.prepare(context.applicationContext)
if (intent != null) {
vpnLauncher.launch(intent)
} else {
val tk = getPreferenceString(context, PreferenceName.PreferenceToken)?:""
serviceViewModel.startService(context, allroutes.value.toTypedArray(), tk)
}
}
val buttonState = buttonViewModel.buttonState.collectAsState()
val resourceList by com.jihe.punchnet.data.NodeRepository.resources.collectAsState()
CustomHeaderScreen(
"",
onBack = null,
onMenu = {
HomeDropdownMenu(navController)
Icon(
Icons.Default.Settings,
contentDescription = "settings",
modifier = Modifier.fillMaxHeight()
)
// NavListMenu(navController, dbdao)
/*
navController.navigate(
Screen.ActionDetail.route.replace(
"{action_name}",
""
)
)
*/
}
) {
LazyColumn(
modifier = Modifier.fillMaxWidth().fillMaxHeight(),
horizontalAlignment = Alignment.CenterHorizontally
) {
item {
Spacer(modifier = Modifier.height(50.dp))
Column(
modifier = Modifier.fillMaxWidth()
.padding(top=50.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Image(
painter = painterResource(R.drawable.punchnet_log),
contentDescription = "logo",
@ -237,133 +111,54 @@ fun HomeScreen(
Button(
onClick = {
if (buttonViewModel.buttonState.value == ButtonState.ButtonStarted) {
if (serviceViewModel.isRunning.value) {
// if is running, should stop service
serviceViewModel.stopVpnService(context)
} else if (buttonViewModel.buttonState.value == ButtonState.ButtonStopped){
if (Build.VERSION.SDK_INT >= 33 && ContextCompat.checkSelfPermission(context, android.Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
notificationPermissionLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS)
} else {
val intent = VpnService.prepare(context.applicationContext)
if (intent != null) {
serviceViewModel.startService(
context,
{intent->
vpnLauncher.launch(intent)
} else {
val tk = getPreferenceString(context, PreferenceName.PreferenceToken)?:""
serviceViewModel.startService(context, allroutes.value.toTypedArray(), tk)
}
}
}
},
enabled = buttonState.value.enabled,
)
}
// started.value = !started.value
},
shape = RoundedCornerShape(10.dp),
modifier = Modifier.padding(top=48.dp)
.width(120.dp)
.height(40.dp)
) {
Text(buttonState.value.text)
}
Spacer(modifier = Modifier.height(48.dp))
}
if (resourceList.isNotEmpty()) {
item {
Text(
text = "企业资源 (${resourceList.size})",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 16.dp, start = 24.dp).fillMaxWidth()
if (serviceViewModel.isRunning.value) {
"停止"
} else {
"启动"
}
)
}
items(resourceList) { resource ->
Box(modifier = Modifier.padding(horizontal = 24.dp)) {
ResourceItemRow(resource, navController)
}
}
}
}
// BasicCardList(dbdao = dbdao, navController)
}
}
@Composable
fun ResourceItemRow(resource: com.jihe.punchnet.api.ResourceItem, navController: androidx.navigation.NavHostController) {
androidx.compose.material3.Card(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp).clickable {
navController.navigate(Screen.WebViewScreen.createRoute(resource.url))
},
colors = androidx.compose.material3.CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
)
) {
Row(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = painterResource(R.drawable.tag),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(32.dp)
)
Spacer(modifier = Modifier.width(16.dp))
private fun startVpnService(context: Context) {
val intent = Intent(context, PunchnetService::class.java)
Column(modifier = Modifier.weight(1f)) {
Text(resource.name, fontWeight = FontWeight.Bold, fontSize = 16.sp)
Text(resource.url, fontSize = 14.sp, color = MaterialTheme.colorScheme.primary)
}
Box(
modifier = Modifier
.size(12.dp)
.background(
color = if (resource.connectionStatus == "connected") Color(0xFF4CAF50) else Color.Gray,
shape = androidx.compose.foundation.shape.CircleShape
intent.putExtra("argument", PunchnetServiceArgument(
"",
arrayOf(
RouteInfo(
"192.168.80.0/24", "10.211.188.2"
)
)
)
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}
Toast.makeText(context, "VPN service started", Toast.LENGTH_SHORT).show()
}
@Composable
fun NodeItemRow(node: com.jihe.punchnet.api.NodeItem) {
androidx.compose.material3.Card(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
colors = androidx.compose.material3.CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
)
) {
Row(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
// Icon
val isMobile = node.system?.contains("Android", true) == true || node.system?.contains("iOS", true) == true
Icon(
imageVector = if (isMobile) Icons.Default.Phone else Icons.Default.Person,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(32.dp)
)
Spacer(modifier = Modifier.width(16.dp))
// Texts
Column(modifier = Modifier.weight(1f)) {
Text(node.name, fontWeight = FontWeight.Bold, fontSize = 16.sp)
Text(node.ip, fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
// Status dot
Box(
modifier = Modifier
.size(12.dp)
.background(
color = if (node.connectionStatus == "connected") Color(0xFF4CAF50) else Color.Gray,
shape = androidx.compose.foundation.shape.CircleShape
)
)
}
}
}

View File

@ -1,213 +0,0 @@
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("没有账号?去注册")
}
}
}
}

View File

@ -15,54 +15,37 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
import com.jihe.punchnet.data.ButtonViewModel
import com.jihe.punchnet.data.RouteViewModel
import com.jihe.punchnet.data.ServiceViewModel
sealed class Screen(val route: String) {
object MainScreen: Screen("home") // 首页
object DeviceScreen: Screen("devices") // 设备 (取代原先的 Route)
object AppAuthScreen: Screen("appAuth") // 应用授权 (取代原先的 Profile)
object SettingsScreen: Screen("settings") // 设置
object WebViewScreen: Screen("webview/{url}") {
fun createRoute(url: String) = "webview/${android.net.Uri.encode(url)}"
}
object LoginScreen: Screen("login")
object MainScreen: Screen("main");
object RouteScreen: Screen("routes")
object ProfileScreen: Screen("profile")
}
val bottomNavItems = listOf(
Screen.MainScreen,
Screen.DeviceScreen,
Screen.AppAuthScreen,
Screen.RouteScreen,
Screen.ProfileScreen,
)
@Composable
fun MainApp(
buttonViewModel: ButtonViewModel = viewModel(),
serviceViewModel: ServiceViewModel = viewModel(),
routeModel: RouteViewModel = viewModel(),
navController: NavHostController,
) {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute by remember { derivedStateOf { navBackStackEntry?.destination?.route } }
val showBottomBar = currentRoute != Screen.LoginScreen.route
Scaffold(
bottomBar = {
if (showBottomBar) {
AppBottomNavigation(navController = navController)
}
}
){ paddingValues ->
/*
Image(
@ -71,7 +54,7 @@ fun MainApp(
modifier = Modifier.padding(paddingValues).size(200.dp)
)
*/
AppNavHost2(buttonViewModel, serviceViewModel, routeModel, navController, paddingValues)
AppNavHost2(serviceViewModel, routeModel, navController, paddingValues)
}
}

View File

@ -1,189 +0,0 @@
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)}")
}
}
}

View File

@ -1,94 +0,0 @@
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()
)
}
}

View File

@ -5,9 +5,7 @@ data class Arguments(
// udp info of the super node
val sn: String,
// tcp info of the super node
// udp info of the STUN server
val stun: String,
val tcp: String,
val nat_server1: String,
val nat_server2: String,

View File

@ -16,15 +16,13 @@ const val BroadcastIP = 0xFFFFFFFF
val BroadcastMac = ubyteArrayOf(0xffu, 0xffu, 0xffu, 0xffu, 0xffu, 0xffu).toByteArray()
class ARPTable(
val scope: CoroutineScope,
) {
val routeTable: RouteTable = RouteTable()
val content = ConcurrentHashMap<Int, ARPInfo>()
suspend fun agingARP() {
// CoroutineScope(Dispatchers.Default).async {
scope.async {
CoroutineScope(Dispatchers.Default).async {
while(true) {
delay(20_000)
val now = System.currentTimeMillis()/1000
@ -44,14 +42,12 @@ class ARPTable(
fun getMacFromIP(ip: Int): Pair<ByteArray?, Int> {
val gw = routeTable.getGateway(ip)
if (gw == null || gw == 0) {
if (gw == null) {
// not found in route table, just use the ip
TerminalLogger.debugf { "ARP: IP=${ipToString(ip)} has no gateway (gw=$gw), querying IP directly." }
val value = content.get(ip)
return Pair(value?.mac, ip)
} else {
// gw not null, try find the gw's mac
TerminalLogger.debugf { "ARP: IP=${ipToString(ip)} uses gateway=${ipToString(gw)}." }
val value = content.get(gw)
return Pair(value?.mac, gw)
}
@ -130,14 +126,20 @@ class ARPWaitList {
// just skip the packet
continue
}
TerminalLogger.debugf { "ARP resumed flow: ${describeIpv4Packet(item.originData)}" }
val packet = formEthernetPacket(node.mac.toByteArray(), mac.toByteArray(), item.originData)
val size = packet.remaining()
val packetBytes = ByteArray(size)
packet.get(packetBytes)
val encrypted = node.aes.encrypt(packetBytes)
val encrypted = node.aes.encrypt(packet)
if (encrypted != null) {
sendPacketToNet(node, mac, encrypted, size.toLong())
val data = SDLData.newBuilder()
.setIsP2P(true)
.setNetworkId(networkid)
.setTtl(2)
.setSrcMac(node.mac)
.setDstMac(mac)
.setData(encrypted.toByteString())
.build()
val msg = encodeToUDPMessage(data, PacketType.Data)
sendPacketToNet(node, mac, msg, size.toLong())
}
}

View File

@ -1,14 +1,12 @@
package com.jihe.punchnet.sdlan.network
// import sdlanproto.Message.*
import android.util.Log
import com.google.protobuf.kotlin.toByteString
import com.jihe.punchnet.protobuf.PunchProto
import com.jihe.punchnet.protobuf.PunchProto.SDLStunProbe
import com.jihe.punchnet.protobuf.PunchProto.SDLStunProbeReply
import com.jihe.punchnet.protobuf.PunchProto.SDLStunRequest
import com.jihe.punchnet.sdlan.config.SDLanMulticastConfig
import com.jihe.punchnet.sdlan.logs.TerminalLogger
import com.jihe.punchnet.sdlan.utils.AES
import com.jihe.punchnet.sdlan.utils.RSA
import com.jihe.punchnet.sdlan.utils.UniqueNodeID
@ -52,9 +50,8 @@ class NodeConfig (
val localPort: Int,
val supernode: SDLanSock,
val stunServer: SDLanSock,
val natServer1: java.net.InetSocketAddress,
val natServer2: java.net.InetSocketAddress
val natServer1: InetSocketAddress,
val natServer2: InetSocketAddress
)
class NodeStats private constructor(
@ -129,36 +126,28 @@ class Node private constructor (
val aes: AES,
// var encryptKey: ByteArray,
var config: NodeConfig,
val config: NodeConfig,
val pendingPeers: PeerMap,
val knownPeers: PeerMap,
// 自身的公网ip
val outerIPV4: AtomicInteger,
var udpSockV4: SDLanSocket,
val udpSockV4: SDLanSocket,
var udpSockMulticast: SDLanSocket?,
var multicastSock: SDLanSock,
val udpSockMulticast: SDLanSocket?,
val multicastSock: SDLanSock,
val stats: NodeStats,
// send message to socket, if connected, and authorized
var toSocket: SendChannel<ByteArray>,
var startStopChannel: SendChannel<StartStopChanInfo>,
val startStopChannel: SendChannel<StartStopChanInfo>,
val mac: Mac = generateRandomMAC(),
var nat_type: NatType = NatType.PortRestricted,
var nat_type: NatType = NatType.Invalid,
val natProbeCookie: NatProbeCookie = NatProbeCookie(),
var authData: com.jihe.punchnet.api.AuthResponseData? = null,
var connectData: com.jihe.punchnet.api.ConnectResponseData? = null,
var clientId: String = "",
var sessionToken: com.google.protobuf.ByteString = com.google.protobuf.ByteString.EMPTY,
val queriedPolicies: ConcurrentHashMap<Int, Int> = ConcurrentHashMap(),
val queriedPolicyTargets: ConcurrentHashMap<Int, Int> = ConcurrentHashMap(),
val policyRules: ConcurrentHashMap<Int, ByteArray> = ConcurrentHashMap(),
) {
companion object {
private var instance: Node? = null
@ -241,51 +230,24 @@ class Node private constructor (
}
suspend fun sendStunRequest() {
if (this.clientId.isEmpty()) {
this.clientId = "punchnet_android_client_" + UniqueNodeID.getUUID()
}
val req = SDLStunRequest.newBuilder()
.setClientId(this.clientId)
.setCookie(0)
.setClientId(UniqueNodeID.getUUID())
.setNetworkId(this.networkID.get())
.setIp(this.deviceConfig.ip.netAddr)
.setMac(this.mac)
.setNatType(this.nat_type.toByte().toInt())
.setV6Info(PunchProto.SDLV6Info.getDefaultInstance())
.setSessionToken(this.sessionToken)
.build()
val msg = encodeToUDPMessage(req, PacketType.StunRequest)
TerminalLogger.debugf {
"send STUN_REQUEST to ${config.stunServer}, clientIdTail=${clientId.takeLast(12)}, " +
"network=${req.networkId}, ip=${com.jihe.punchnet.sdlan.utils.ipToString(req.ip)}, " +
"mac=${com.jihe.punchnet.sdlan.utils.macToString(req.mac)}, natType=${nat_type}, " +
"sessionTokenBytes=${sessionToken.size()}"
}
sendToSock(this, msg, config.stunServer)
}
suspend fun ping_to_sn() {
val msg = encodeToControlMessage(null, PacketType.Ping).toByteArray()
_sendDataToSocket(msg)
}
suspend fun _sendDataToSocket(msg: ByteArray) {
if (aes.isAuthorized()) {
TerminalLogger.debugf{"authorized, ping to sn"}
toSocket.send(msg)
} else {
TerminalLogger.debugf{"unauthorized, not ping to sn"}
}
sendToSock(this, msg, config.supernode)
}
suspend fun probeNatType() {
val previousNatType = nat_type
val reply1 = this._sendAndWaitForProbeReply(StunProbeAttr.None, config.natServer1)
if (reply1 == null) {
TerminalLogger.warning { "STUN probe attr=None timed out; keeping natType=${nat_type}" }
nat_type = NatType.Blocked
return
}
if (reply1.ip == (outerIPV4.get())) {
@ -306,14 +268,7 @@ class Node private constructor (
val reply3 = this._sendAndWaitForProbeReply(StunProbeAttr.None, config.natServer2)
if (reply3 == null) {
nat_type = if (previousNatType == NatType.Invalid || previousNatType == NatType.Blocked) {
NatType.PortRestricted
} else {
previousNatType
}
TerminalLogger.warning {
"secondary STUN probe timed out after primary reply; keeping natType=${nat_type}"
}
nat_type = NatType.Blocked
return
}
@ -331,28 +286,34 @@ class Node private constructor (
}
suspend fun _sendAndWaitForProbeReply(attr: StunProbeAttr, toServer: java.net.SocketAddress): SDLStunProbeReply? {
val channel = kotlinx.coroutines.channels.Channel<SDLStunProbeReply>(100)
suspend fun _sendAndWaitForProbeReply(attr: StunProbeAttr, toServer: SocketAddress): SDLStunProbeReply? {
val channel = Channel<SDLStunProbeReply>(100)
val cookie = natProbeCookie.addChannel(channel)
val probe = com.jihe.punchnet.protobuf.PunchProto.SDLStunProbe.newBuilder()
val probe = SDLStunProbe.newBuilder()
.setAttr(attr.ordinal.toInt())
.setCookie(cookie)
.build()
val msg = encodeToUDPMessage(probe, PacketType.StunProbe)
TerminalLogger.debugf { "send STUN_PROBE attr=$attr cookie=$cookie to $toServer" }
this.udpSockV4.send_to(msg.toByteArray(), toServer)
val k = kotlinx.coroutines.withTimeoutOrNull(5000) {
val k = withTimeoutOrNull(5000) {
val response = channel.receive()
return@withTimeoutOrNull response
}
if (k == null) {
TerminalLogger.warning { "STUN_PROBE attr=$attr cookie=$cookie to $toServer timed out" }
} else {
TerminalLogger.debugf { "STUN_PROBE attr=$attr cookie=$cookie reply ip=${k.ip} port=${k.port}" }
}
natProbeCookie.remove(cookie)
return k
}
suspend fun ping_to_sn() {
val msg = encodeToTcpMessage(null, 0, PacketType.Ping).toByteArray()
_sendDataToSocket(msg)
}
suspend fun _sendDataToSocket(msg: ByteArray) {
if (aes.isAuthorized()) {
toSocket.send(msg)
}
}
}

View File

@ -1,35 +1,25 @@
package com.jihe.punchnet.sdlan.network
import com.google.protobuf.kotlin.toByteString
import com.google.gson.JsonElement
import com.jihe.punchnet.protobuf.PunchProto.SDLData
import com.jihe.punchnet.protobuf.PunchProto.SDLPeerInfo
import com.jihe.punchnet.protobuf.PunchProto.SDLQueryInfo
import com.jihe.punchnet.protobuf.PunchProto.SDLRegister
import com.jihe.punchnet.protobuf.PunchProto.SDLRegisterAck
import com.jihe.punchnet.protobuf.PunchProto.SDLArpRequest
import com.jihe.punchnet.protobuf.PunchProto.SDLArpResponse
import com.jihe.punchnet.protobuf.PunchProto.SDLExposedServiceRequest
import com.jihe.punchnet.protobuf.PunchProto.SDLPolicyRequest
import com.jihe.punchnet.protobuf.PunchProto.SDLSendRegisterEvent
import com.jihe.punchnet.sdlan.config.EtherType
import com.jihe.punchnet.sdlan.config.SDLanConfig
import com.jihe.punchnet.sdlan.logs.TerminalLogger
import com.jihe.punchnet.sdlan.utils.isMultiBroadcast
import com.jihe.punchnet.sdlan.utils.ipStringToInt
import com.jihe.punchnet.sdlan.utils.ipToString
import com.jihe.punchnet.sdlan.utils.macToString
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import java.net.URI
import java.nio.ByteBuffer
import java.nio.ByteOrder
suspend fun handlePacketData(
scope: CoroutineScope,
node: Node,
body: ByteBuffer,
senderSock: SDLanSock
@ -46,8 +36,7 @@ suspend fun handlePacketData(
TerminalLogger.debugf {
"[P2P] Rx data from ${senderSock}"
}
// CoroutineScope(Dispatchers.Default).async {
scope.async {
CoroutineScope(Dispatchers.Default).async {
checkPeerRegistrationNeeded(
node,
false,
@ -307,311 +296,15 @@ suspend fun sendRegister(
.build()
val msg = encodeToUDPMessage(register, PacketType.Register)
TerminalLogger.debugf { "send REGISTER to ${macToString(mac)} at $sock, natType=$natType" }
sendToSock(node, msg, sock)
if (natType == NatType.Symmetric || natType == NatType.PortRestricted) {
TerminalLogger.debugf { "guess ports for symmetric/restricted nat" }
val guessOffsets = intArrayOf(-1, 1, -2, 2)
for (offset in guessOffsets) {
val guessPort = sock.port + offset
if (guessPort in 1..65535) {
val guessSock = SDLanSock(sock.family, guessPort, sock.ip)
sendToSock(node, msg, guessSock)
}
}
}
// TODO: need guess port
}
suspend fun sendPacketToNet(node: Node, dstmac: Mac, encrypted: ByteArray, size: Long) {
var is_p2p: Boolean = false
var is_multicast: Boolean = false
var destination: SDLanSock
if (isMultiBroadcast(dstmac)) {
node.stats.txSup.addAndGet(size)
node.stats.txBroadcast.addAndGet(size)
destination = node.config.stunServer
is_multicast = true
} else {
val peer = node.knownPeers.get(dstmac)
if (peer == null) {
node.stats.txSup.addAndGet(size)
destination = node.config.stunServer
} else {
val now = System.currentTimeMillis() / 1000
if (now - peer.lastP2P.get() >= peer.timeout / 2) {
TerminalLogger.warning { "last p2p is too old, deleting from known hosts" }
node.knownPeers.remove(dstmac)
node.stats.txSup.addAndGet(size)
destination = node.config.stunServer
} else {
is_p2p = true
node.stats.txP2P.addAndGet(size)
destination = peer.sock
}
}
}
if (!is_p2p && !is_multicast) {
TerminalLogger.debugf { "check_query_peer_info" }
checkQueryPeerInfo(node, dstmac)
}
val transportIdentityId = node.connectData?.identityId ?: 0
val data = com.jihe.punchnet.protobuf.PunchProto.SDLData.newBuilder()
.setIsP2P(is_p2p)
.setNetworkId(node.networkID.get())
.setTtl(2)
.setSrcMac(node.mac)
.setDstMac(dstmac)
.setData(com.google.protobuf.ByteString.copyFrom(encrypted))
.setSessionToken(node.sessionToken)
.setIdentityId(transportIdentityId)
.build()
val msg = encodeToUDPMessage(data, PacketType.Data)
TerminalLogger.debugf {
"send PACKET to ${destination} with isP2P=$is_p2p, ttl=${data.ttl}, " +
"src=${macToString(data.srcMac)}, dst=${macToString(data.dstMac)}, " +
"network=${data.networkId}, identityId=${data.identityId}, apiIdentity=${node.connectData?.identityId ?: 0}, " +
"sessionTokenBytes=${data.sessionToken.size()}, encryptedBytes=${encrypted.size}"
}
sendToSock(node, msg, destination)
}
// Removed dummy findPeerDestination
fun describeIpv4Packet(data: ByteArray): String {
if (data.size < 20) {
return "short-ipv4 bytes=${data.size}"
}
val version = (data[0].toInt() ushr 4) and 0x0f
if (version != 4) {
return "non-ipv4 version=$version bytes=${data.size}"
}
val ihl = (data[0].toInt() and 0x0f) * 4
if (ihl < 20 || data.size < ihl) {
return "bad-ipv4-header ihl=$ihl bytes=${data.size}"
}
val proto = data[9].toInt() and 0xff
val srcIp = ByteBuffer.wrap(data, 12, 4).order(ByteOrder.BIG_ENDIAN).int
val dstIp = ByteBuffer.wrap(data, 16, 4).order(ByteOrder.BIG_ENDIAN).int
val protoName = when (proto) {
1 -> "ICMP"
6 -> "TCP"
17 -> "UDP"
else -> "proto-$proto"
}
val portInfo = if ((proto == 6 || proto == 17) && data.size >= ihl + 4) {
val srcPort = ByteBuffer.wrap(data, ihl, 2).order(ByteOrder.BIG_ENDIAN).short.toInt() and 0xffff
val dstPort = ByteBuffer.wrap(data, ihl + 2, 2).order(ByteOrder.BIG_ENDIAN).short.toInt() and 0xffff
" srcPort=$srcPort dstPort=$dstPort"
} else {
""
}
return "$protoName ${ipToString(srcIp)} -> ${ipToString(dstIp)}$portInfo bytes=${data.size}"
}
fun describePolicyRules(rules: ByteArray): String {
if (rules.isEmpty()) {
return "empty"
}
val result = mutableListOf<String>()
var offset = 0
while (offset + 3 <= rules.size) {
val proto = rules[offset].toInt() and 0xff
val port = ByteBuffer.wrap(rules, offset + 1, 2).order(ByteOrder.BIG_ENDIAN).short.toInt() and 0xffff
val protoName = when (proto) {
1 -> "ICMP"
6 -> "TCP"
17 -> "UDP"
else -> "proto-$proto"
}
result.add("$protoName/$port")
offset += 3
}
if (offset != rules.size) {
result.add("trailingBytes=${rules.size - offset}")
}
return result.joinToString(",")
}
data class PolicyDecision(
val state: PolicyDecisionState,
val reason: String,
)
enum class PolicyDecisionState {
Allowed,
Pending,
Denied,
}
fun policyDecisionForIpv4Packet(node: Node, data: ByteArray): PolicyDecision {
if (data.size < 20) {
return PolicyDecision(PolicyDecisionState.Denied, "too-short")
}
val version = (data[0].toInt() ushr 4) and 0x0f
if (version != 4) {
return PolicyDecision(PolicyDecisionState.Denied, "non-ipv4")
}
val ihl = (data[0].toInt() and 0x0f) * 4
if (ihl < 20 || data.size < ihl) {
return PolicyDecision(PolicyDecisionState.Denied, "bad-ipv4-header")
}
val proto = data[9].toInt() and 0xff
val targetIp = ByteBuffer.wrap(data, 16, 4).order(ByteOrder.BIG_ENDIAN).int
val port = if ((proto == 6 || proto == 17) && data.size >= ihl + 4) {
ByteBuffer.wrap(data, ihl + 2, 2).order(ByteOrder.BIG_ENDIAN).short.toInt() and 0xffff
} else {
0
}
val candidates = policyCandidateIdsForTarget(node, targetIp)
if (candidates.isEmpty()) {
return PolicyDecision(PolicyDecisionState.Denied, "no policy identity for ${ipToString(targetIp)}")
}
val missing = candidates.filterNot { node.policyRules.containsKey(it) }
if (missing.isNotEmpty()) {
return PolicyDecision(PolicyDecisionState.Pending, "waiting policy for ${ipToString(targetIp)}, ids=${missing.joinToString(",")}")
}
for (candidate in candidates) {
val rules = node.policyRules[candidate] ?: continue
if (policyRulesAllow(rules, proto, port)) {
return PolicyDecision(
PolicyDecisionState.Allowed,
"allowed by policy id=$candidate ${protocolName(proto)}/$port"
)
}
}
return PolicyDecision(
PolicyDecisionState.Denied,
"no allow rule for ${ipToString(targetIp)} ${protocolName(proto)}/$port, ids=${candidates.joinToString(",")}"
)
}
fun policyCandidateIdsForTarget(node: Node, targetIp: Int): List<Int> {
val result = mutableListOf<Int>()
node.connectData?.nodeList.orEmpty().firstOrNull {
runCatching { ipStringToInt(it.ip) == targetIp }.getOrDefault(false)
}?.let { result.add(it.id) }
result.addAll(matchingResourceIdsForTarget(node, targetIp))
return result.distinct()
}
fun policyRulesAllow(rules: ByteArray, proto: Int, port: Int): Boolean {
if (rules.isEmpty()) {
return false
}
var offset = 0
while (offset + 3 <= rules.size) {
val ruleProto = rules[offset].toInt() and 0xff
val rulePort = ByteBuffer.wrap(rules, offset + 1, 2).order(ByteOrder.BIG_ENDIAN).short.toInt() and 0xffff
if (ruleProto == proto && (rulePort == port || rulePort == 0)) {
return true
}
offset += 3
}
return false
}
fun protocolName(proto: Int): String {
return when (proto) {
1 -> "ICMP"
6 -> "TCP"
17 -> "UDP"
else -> "proto-$proto"
}
}
fun describeConnectDataForLog(node: Node): String {
val connectData = node.connectData ?: return "connect data is null"
val localNodeId = localNodeId(node) ?: 0
val nodeSummary = connectData.nodeList.orEmpty().joinToString(";") {
"id=${it.id},ip=${it.ip},status=${it.connectionStatus}"
}.ifEmpty { "none" }
val resourceSummary = connectData.resourceList.orEmpty().map { resource ->
val uri = runCatching { URI(resource.url) }.getOrNull()
val host = uri?.host ?: "invalid-host"
val scheme = uri?.scheme ?: "unknown"
val port = when {
uri == null -> -1
uri.port > 0 -> uri.port
scheme.equals("http", ignoreCase = true) -> 80
scheme.equals("https", ignoreCase = true) -> 443
else -> -1
}
"id=${resource.id},${scheme}://${host}:${port},status=${resource.connectionStatus}"
}.joinToString(";").ifEmpty { "none" }
return "connect summary: ip=${connectData.ip}/${connectData.maskLen}, identity=${connectData.identityId}, localNodeId=$localNodeId, " +
"nodes=${connectData.nodeList.orEmpty().size}[$nodeSummary], " +
"resources=${connectData.resourceList.orEmpty().size}[$resourceSummary], " +
"acl=${describeAclForLog(connectData.acl)}"
}
fun localNodeId(node: Node): Int? {
val localIp = node.connectData?.ip ?: return null
return node.connectData?.nodeList.orEmpty().firstOrNull { it.ip == localIp }?.id
}
fun describeAclForLog(acl: JsonElement?): String {
if (acl == null || acl.isJsonNull) {
return "null"
}
if (acl.isJsonArray) {
val array = acl.asJsonArray
if (array.size() == 0) {
return "array(size=0)"
}
val samples = array.take(3).mapIndexed { index, item ->
"[$index]=${describeAclElementForLog(item)}"
}
return "array(size=${array.size()}, ${samples.joinToString(",")})"
}
if (acl.isJsonObject) {
return describeAclObjectForLog(acl)
}
if (acl.isJsonPrimitive) {
return "primitive"
}
return acl.javaClass.simpleName
}
private fun describeAclElementForLog(element: JsonElement): String {
return when {
element.isJsonObject -> describeAclObjectForLog(element)
element.isJsonArray -> "array(size=${element.asJsonArray.size()})"
element.isJsonNull -> "null"
element.isJsonPrimitive -> "primitive"
else -> element.javaClass.simpleName
}
}
private fun describeAclObjectForLog(element: JsonElement): String {
val obj = element.asJsonObject
val fields = obj.entrySet().take(12).joinToString(",") { (key, value) ->
"$key=${describeAclValueForLog(value)}"
}
val suffix = if (obj.entrySet().size > 12) ",..." else ""
return "object($fields$suffix)"
}
private fun describeAclValueForLog(value: JsonElement): String {
return when {
value.isJsonNull -> "null"
value.isJsonArray -> "array(${value.asJsonArray.size()})"
value.isJsonObject -> "object(${value.asJsonObject.entrySet().joinToString(",") { it.key }})"
value.isJsonPrimitive -> {
val primitive = value.asJsonPrimitive
when {
primitive.isNumber -> primitive.asNumber.toString()
primitive.isBoolean -> primitive.asBoolean.toString()
primitive.isString -> primitive.asString.take(48)
else -> "primitive"
}
}
else -> value.javaClass.simpleName
}
suspend fun sendPacketToNet(node: Node, dstmac: Mac, content: List<Byte>, size: Long) {
val destination = findPeerDestination(node, dstmac, size)
TerminalLogger.debugf { "send PACKET to ${destination}" }
sendToSock(node, content, destination)
}
@ -622,20 +315,20 @@ suspend fun findPeerDestination(node: Node, dstmac: Mac, size: Long): SDLanSock
if (isMultiBroadcast(dstmac)) {
node.stats.txSup.addAndGet(size)
node.stats.txBroadcast.addAndGet(size)
result = node.config.stunServer
result = node.config.supernode
is_multicast = true
} else {
val peer = node.knownPeers.get(dstmac)
if (peer == null) {
node.stats.txSup.addAndGet(size)
result = node.config.stunServer
result = node.config.supernode
} else {
val now = System.currentTimeMillis()/1000
if (now - peer.lastP2P.get() >= peer.timeout/2) {
TerminalLogger.warning { "last p2p is too old, deleting from known hosts" }
node.knownPeers.remove(dstmac)
node.stats.txSup.addAndGet(size)
result = node.config.stunServer
result = node.config.supernode
} else {
is_p2p = true
node.stats.txP2P.addAndGet(size)
@ -693,12 +386,12 @@ suspend fun sendQueryPeer(node: Node, dstmac: Mac) {
val query = SDLQueryInfo.newBuilder()
.setDstMac(dstmac)
.build()
val msg = encodeToControlMessage(query, PacketType.QueryInfo)
val msg = encodeToTcpMessage(query, node.getNextPacketID(), PacketType.QueryInfo)
node.toSocket.send(msg.toByteArray())
}
fun formEthernetPacket(srcmac: ByteArray, dstmac: ByteArray, data: ByteArray): ByteBuffer {
val buffer = ByteBuffer.allocate(14 + data.size).order(ByteOrder.BIG_ENDIAN)
val buffer = ByteBuffer.allocate(14 + data.size + 4).order(ByteOrder.BIG_ENDIAN)
buffer.put(dstmac)
buffer.put(srcmac)
buffer.putShort(EtherType.IPV4)
@ -711,140 +404,54 @@ fun formEthernetPacket(srcmac: ByteArray, dstmac: ByteArray, data: ByteArray): B
// fromip is self ip
// queryip is the targetip
suspend fun sendArpRequest(node: Node, queryip: Int) {
if (!node.aes.isAuthorized()) {
TerminalLogger.errorf { "not authed for send arp request" }
return
}
val arpReq = SDLArpRequest.newBuilder()
.setTargetIp(queryip)
.setOriginIp(node.deviceConfig.ip.netAddr)
println(1)
val frommac = node.mac.toByteArray()
val fromip = node.deviceConfig.ip.netAddr
val buffer = ByteBuffer.allocate(64).order(ByteOrder.BIG_ENDIAN)
buffer.put(ByteArray(6){0xff.toByte()})
buffer.put(frommac)
println(2)
// println("src mac: ${macToString(node.mac)}")
buffer.putShort(EtherType.ARP)
buffer.putShort(EtherType.HWTYPE_ETH)
buffer.putShort(EtherType.IPV4)
buffer.put(6)
buffer.put(4)
buffer.putShort(EtherType.ARP_REQUEST)
buffer.put(frommac)
buffer.putInt(fromip)
buffer.put(ByteArray(6))
buffer.putInt(queryip)
buffer.flip()
println(3)
val size = buffer.remaining()
println(3.1)
val output = node.aes.encrypt(buffer)
println(3.2)
println(4)
if (output != null) {
val d = output.toByteString()
val data = SDLData.newBuilder()
.setIsP2P(true)
.setNetworkId(node.networkID.get())
.setTtl(2)
.setSrcMac(frommac.toByteString())
.setDstMac(SDLanConfig.BROADCASTMAC)
.setData(d)
.build()
val msg = encodeToControlMessage(arpReq, PacketType.ArpRequest)
node.toSocket.send(msg.toByteArray())
TerminalLogger.debugf { "sent QUIC ARP request for ip: ${com.jihe.punchnet.sdlan.utils.ipToString(queryip)}" }
println(5)
val msg = encodeToUDPMessage(data, PacketType.Data)
sendPacketToNet(node, SDLanConfig.BROADCASTMAC, msg, size.toLong())
}
println(6)
}
suspend fun handlePacketArpResponse(node: Node, content: ByteArray) {
val arpRes: SDLArpResponse
try {
arpRes = SDLArpResponse.parseFrom(content)
} catch (e: Exception) {
TerminalLogger.errorf { "failed to decode arp response: $e"}
return
}
val targetIp = arpRes.targetIp
val targetMac = arpRes.targetMac
if (targetMac.isEmpty) {
TerminalLogger.debugf { "ARP response returned empty MAC for IP: ${com.jihe.punchnet.sdlan.utils.ipToString(targetIp)}" }
return
}
TerminalLogger.debugf { "Rx QUIC ARP response for IP: ${com.jihe.punchnet.sdlan.utils.ipToString(targetIp)}, MAC: ${macToString(targetMac)}" }
sendPolicyRequestForTargetIp(node, targetIp)
if (node.iface is IfaceTun) {
val tun = node.iface as IfaceTun
tun.arpTable.addToARPTable(targetIp, targetMac.toByteArray())
tun.arpWaitList.arpArrived(node, targetIp, targetMac)
}
}
suspend fun sendExposedServiceRequest(node: Node) {
if (!node.aes.isAuthorized()) {
return
}
val req = SDLExposedServiceRequest.newBuilder()
.setVersion(0)
.build()
node.toSocket.send(encodeToControlMessage(req, PacketType.ExposedServiceRequest).toByteArray())
TerminalLogger.debugf { "sent ExposedServiceRequest version=0" }
}
suspend fun sendPolicyRequestForTargetIp(node: Node, targetIp: Int) {
if (!node.aes.isAuthorized()) {
return
}
val srcIdentityId = node.connectData?.identityId ?: 0
if (srcIdentityId == 0) {
TerminalLogger.warning { "skip PolicyRequest for ${ipToString(targetIp)}: missing src identity" }
return
}
val dstNode = node.connectData?.nodeList?.firstOrNull {
runCatching { ipStringToInt(it.ip) == targetIp }.getOrDefault(false)
}
if (dstNode == null) {
TerminalLogger.warning { "skip PolicyRequest for ${ipToString(targetIp)}: target not in node_list" }
return
}
TerminalLogger.debugf {
"policy target ${ipToString(targetIp)}: srcIdentity=$srcIdentityId, apiIdentity=${node.connectData?.identityId ?: 0}, " +
"nodeId=${dstNode.id}, nodeStatus=${dstNode.connectionStatus}, " +
describeResourcesForTarget(node, targetIp) + ", acl=${describeAclForLog(node.connectData?.acl)}"
}
sendPolicyRequest(node, srcIdentityId, dstNode.id, targetIp, "node:${dstNode.name}/${dstNode.connectionStatus}")
matchingResourceIdsForTarget(node, targetIp)
.filter { it != dstNode.id }
.forEach { resourceId ->
sendPolicyRequest(node, srcIdentityId, resourceId, targetIp, "resource")
}
}
suspend fun sendPolicyRequest(
node: Node,
srcIdentityId: Int,
dstIdentityId: Int,
targetIp: Int,
source: String,
) {
if (node.queriedPolicies.putIfAbsent(dstIdentityId, 0) != null) {
return
}
node.queriedPolicyTargets[dstIdentityId] = targetIp
val req = SDLPolicyRequest.newBuilder()
.setSrcIdentityId(srcIdentityId)
.setDstIdentityId(dstIdentityId)
.setVersion(0)
.build()
node.toSocket.send(encodeToControlMessage(req, PacketType.PolicyRequest).toByteArray())
TerminalLogger.debugf {
"sent PolicyRequest srcIdentity=$srcIdentityId, dstIdentity=$dstIdentityId, dstIp=${ipToString(targetIp)}, source=$source, version=0"
}
}
fun describeResourcesForTarget(node: Node, targetIp: Int): String {
val targetIpText = ipToString(targetIp)
val matches = node.connectData?.resourceList.orEmpty().mapNotNull { resource ->
val uri = runCatching { URI(resource.url) }.getOrNull() ?: return@mapNotNull null
if (uri.host != targetIpText) {
return@mapNotNull null
}
val port = when {
uri.port > 0 -> uri.port
uri.scheme.equals("http", ignoreCase = true) -> 80
uri.scheme.equals("https", ignoreCase = true) -> 443
else -> -1
}
"id=${resource.id}:${uri.scheme ?: "unknown"}/$port"
}.distinct()
return if (matches.isEmpty()) {
"no matching resource in connect resource_list for $targetIpText"
} else {
"matching resources for $targetIpText: ${matches.joinToString(",")}"
}
}
fun matchingResourceIdsForTarget(node: Node, targetIp: Int): List<Int> {
val targetIpText = ipToString(targetIp)
return node.connectData?.resourceList.orEmpty().mapNotNull { resource ->
val uri = runCatching { URI(resource.url) }.getOrNull() ?: return@mapNotNull null
if (uri.host == targetIpText) resource.id else null
}.distinct()
}
suspend fun handleTcpCommand(node: Node, cmdtype: Byte, cmdprotobuf: ByteArray) {}
suspend fun handlePacketPeerInfo(node: Node, content: ByteArray) {
val pinfo: SDLPeerInfo
@ -860,11 +467,6 @@ suspend fun handlePacketPeerInfo(node: Node, content: ByteArray) {
return
}
if (!pinfo.hasV4Info() || pinfo.v4Info.v4.size() != 4 || pinfo.v4Info.port == 0) {
TerminalLogger.warning { "PeerInfo for ${macToString(pinfo.dstMac)} has no usable IPv4 endpoint" }
return
}
val remoteNat = NatType.fromUByte(pinfo.v4Info.natType.toUByte())
val pending = node.pendingPeers.get(pinfo.dstMac)
if (pending == null) {
@ -877,18 +479,16 @@ suspend fun handlePacketPeerInfo(node: Node, content: ByteArray) {
sendRegister(node, remoteNat, pending.sock, pinfo.dstMac)
}
suspend fun handleEvent(node: Node, cmdprotobuf: ByteArray) {
val evt: com.jihe.punchnet.protobuf.PunchProto.SDLEvent
suspend fun handleTcpEvent(node: Node, event: EventType, cmdprotobuf: ByteArray) {
when(event) {
EventType.SendRegister -> {
val reg: SDLSendRegisterEvent
try {
evt = com.jihe.punchnet.protobuf.PunchProto.SDLEvent.parseFrom(cmdprotobuf)
reg = SDLSendRegisterEvent.parseFrom(cmdprotobuf)
} catch (e: Exception) {
TerminalLogger.errorf {"failed to decode SDLEvent: $e"}
TerminalLogger.errorf {"failed to decode SendRegisterEvent: $e"}
return
}
when(evt.eventCase) {
com.jihe.punchnet.protobuf.PunchProto.SDLEvent.EventCase.SEND_REGISTER -> {
val reg = evt.sendRegister
val remoteNat = NatType.fromUByte(reg.natType.toUByte())
val ip = byteArrayOf(
(reg.natIp ushr 24).and(0xff).toByte(),
@ -899,7 +499,7 @@ suspend fun handleEvent(node: Node, cmdprotobuf: ByteArray) {
checkPeerRegistrationNeeded(node,false, reg.dstMac, remoteNat, SDLanSock(IPFamily.IPV4, reg.natPort, ip))
}
else -> {
TerminalLogger.warning { "unhandled event: ${evt.eventCase}" }
TerminalLogger.warning { "unhandled event: $event" }
}
}
}

View File

@ -11,7 +11,6 @@ import com.jihe.punchnet.sdlan.utils.EthHdr
import com.jihe.punchnet.sdlan.utils.ipToString
import com.jihe.punchnet.sdlan.utils.isMultiBroadcast
import com.jihe.punchnet.sdlan.utils.macToString
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.BufferedReader
@ -22,6 +21,7 @@ import java.net.Inet4Address
import java.net.Socket
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.zip.CRC32
interface Iface {
suspend fun doInit()
@ -29,7 +29,6 @@ interface Iface {
suspend fun send(content: ByteArray)
suspend fun reload_config(config: DeviceConfig)
fun close() {}
suspend fun handleDataFromNet(node: Node, data: ByteArray)
suspend fun handleDataFromDevice(node: Node, data: ByteArray)
}
@ -100,16 +99,24 @@ interface IfaceTun: Iface {
arp.dipaddr = arp.sipaddr
arp.sipaddr = node.deviceConfig.ip.netAddr
val bytesBuf = arp.marshalToBytes()
val bytesArr = ByteArray(bytesBuf.remaining())
bytesBuf.get(bytesArr)
val encrypted = node.aes.encrypt(bytesArr)
val bytes = arp.marshalToBytes()
val encrypted = node.aes.encrypt(bytes)
if (encrypted != null) {
val dstmac = arp.dhwaddr.toByteString()
val data = SDLData.newBuilder()
.setIsP2P(true)
.setTtl(2)
.setNetworkId(node.networkID.get())
.setSrcMac(node.mac)
.setDstMac(dstmac)
.setData(encrypted.toByteString())
.build()
TerminalLogger.debugf {
"send arp reply to ${macToString(dstmac)}"
}
sendPacketToNet(node, dstmac, encrypted, 0)
val content = encodeToUDPMessage(data, PacketType.Data)
sendPacketToNet(node, dstmac, content, 0)
}
}
}
@ -157,28 +164,17 @@ interface IfaceTun: Iface {
TerminalLogger.infof { "dropping tun packet due to not authed" }
return
}
val version = (data[0].toInt() ushr 4) and 0x0f
if (version != 4) {
TerminalLogger.debugf { "dropping non-IPv4 packet from tun: version=$version, bytes=${data.size}" }
return
}
val buffer = ByteBuffer.wrap(data, 12, 8).order(ByteOrder.BIG_ENDIAN)
val srcip = buffer.getInt()
val dstip = buffer.getInt()
TerminalLogger.debugf { "got ${data.size} bytes from tun" }
TerminalLogger.debugf { "IPv4 flow from tun: ${describeIpv4Packet(data)}" }
if (!node.config.allowRouting && (srcip != node.deviceConfig.ip.netAddr)) {
TerminalLogger.infof { "dropping routed packet from tun" }
return
}
if (node.config.dropMulticast && (dstip.toUInt() shr 28) == 14u) {
TerminalLogger.debugf { "dropping multicast packet from tun: ${com.jihe.punchnet.sdlan.utils.ipToString(dstip)}" }
return
}
val (arpinfo, gwip) = arpTable.getMacFromIP(dstip)
if (arpinfo == null) {
println("arp info is null")
@ -192,11 +188,27 @@ interface IfaceTun: Iface {
val buffer = arpinfo + node.mac.toByteArray() +
byteArrayOf((EtherType.IPV4.toInt() shr 8).toByte(), EtherType.IPV4.toByte()) +
data
val size = buffer.size
val encrypted = node.aes.encrypt(buffer)
val crc = CRC32()
crc.update(buffer)
val cksum = ByteBuffer.allocate(4)
.putInt(crc.value.toInt())
.array()
val packet = buffer + cksum
val size = packet.size
val encrypted = node.aes.encrypt(packet)
if (encrypted != null) {
val mac = arpinfo.toByteString()
sendPacketToNet(node, mac, encrypted, size.toLong())
val data = SDLData.newBuilder()
.setIsP2P(true)
.setNetworkId(node.networkID.get())
.setTtl(2)
.setSrcMac(node.mac)
.setDstMac(mac)
.setData(encrypted.toByteString())
.build()
val msg = encodeToUDPMessage(data, PacketType.Data)
sendPacketToNet(node, mac, msg, size.toLong())
}
}
}
@ -204,7 +216,7 @@ interface IfaceTun: Iface {
class IfaceMock: IfaceTun {
val deviceName = "dev0"
override val arpTable = ARPTable(CoroutineScope(Dispatchers.IO))
override val arpTable = ARPTable()
override val arpWaitList = ARPWaitList()
val sock = Socket(Inet4Address.getByName("127.0.0.1"), 1234)
@ -214,9 +226,6 @@ class IfaceMock: IfaceTun {
var config: DeviceConfig = DeviceConfig(0)
override fun close() {
}
override suspend fun recv(): ByteArray {
val result = withContext(Dispatchers.IO) {
val size = input.readInt()

View File

@ -2,14 +2,10 @@ package com.jihe.punchnet.sdlan.network
import android.util.Log
import com.jihe.punchnet.PunchnetServiceArgument
import com.jihe.punchnet.data.ButtonRepository
import com.jihe.punchnet.data.ButtonState
import com.jihe.punchnet.protobuf.PunchProto.SDLDevAddr
import com.jihe.punchnet.protobuf.PunchProto.SDLRegisterSuper
import com.jihe.punchnet.protobuf.PunchProto.SDLRegisterSuperAck
import com.jihe.punchnet.protobuf.PunchProto.SDLRegisterSuperNak
import com.jihe.punchnet.protobuf.PunchProto.SDLExposedServiceResponse
import com.jihe.punchnet.protobuf.PunchProto.SDLPolicyResponse
import com.jihe.punchnet.protobuf.PunchProto.SDLStunProbeReply
import com.jihe.punchnet.sdlan.config.Arguments
import com.jihe.punchnet.sdlan.config.RSAConfig
@ -22,19 +18,14 @@ import com.jihe.punchnet.sdlan.utils.UniqueNodeID
import com.jihe.punchnet.sdlan.utils.ipToString
import com.jihe.punchnet.sdlan.utils.macToString
import com.jihe.punchnet.sdlan.utils.parseScoketAddressV4FromString
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
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 java.io.DataInputStream
@ -49,105 +40,52 @@ import java.util.concurrent.atomic.AtomicLong
import kotlin.io.path.pathString
import kotlin.system.exitProcess
suspend fun onMessage(scope: CoroutineScope, data: SDLanControl) {
suspend fun onMessage(data: SDLanTCP) {
val node = Node.getInstance()
TerminalLogger.debugf {"message received"}
when(data.packetType) {
PacketType.Welcome -> {
TerminalLogger.debugf { "got welcome, sending register super" }
if (node.clientId.isEmpty()) {
node.clientId = "punchnet_android_client_" + UniqueNodeID.getUUID()
}
val registerIp = com.jihe.punchnet.sdlan.utils.ipStringToInt(node.connectData?.ip ?: "0.0.0.0").toUInt().toInt()
val registerSuper = SDLRegisterSuper.newBuilder()
.setClientId(node.clientId)
.setNetworkId(node.authData?.networkId ?: 0)
.setMac(com.google.protobuf.ByteString.copyFrom(node.mac.toByteArray()))
.setIp(registerIp)
.setMaskLen(node.connectData?.maskLen ?: 0)
.setHostname(node.connectData?.hostname ?: "")
.setPubKey(node.rsa.getPublicKeyString())
.setAccessToken(node.authData?.accessToken ?: "")
.build()
val msg = encodeToControlMessage(registerSuper, PacketType.RegisterSuper)
node.toSocket.send(msg.toByteArray())
TerminalLogger.debugf {
"sent RegisterSuper clientIdTail=${node.clientId.takeLast(12)}, network=${registerSuper.networkId}, " +
"ip=${ipToString(registerSuper.ip)}, mask=${registerSuper.maskLen}, mac=${macToString(registerSuper.mac)}, " +
"identity=${node.connectData?.identityId ?: 0}"
}
}
PacketType.RegisterSuperACK -> {
println("11")
val ack = SDLRegisterSuperAck.parseFrom(data.currentPacket)
val algorithm = ack.algorithm.ifEmpty { "aes" }.lowercase()
TerminalLogger.debugf {
"got register super ack: algorithm=$algorithm, region=${ack.regionId}, sessionTokenBytes=${ack.sessionToken.size()}"
}
if (algorithm != "aes" && algorithm != "chacha20") {
TerminalLogger.errorf { "unsupported encryption algorithm from server: ${ack.algorithm}" }
node.startStopChannel.send(StartStopChanInfo(StartStopFlag.IsStop, null))
ButtonRepository.updateState(ButtonState.ButtonStopped)
scope.cancel()
return
}
// TerminalLogger.debugf { "got register super ack: $ack" }
println("ack is ${ack}")
println("ack's key is ${ack.aesKey.size()}")
val aeskey = node.rsa.decrypt(ack.key.toByteArray())
val aeskey = node.rsa.decrypt(ack.aesKey.toByteArray())
if (aeskey == null) {
println("aes key error")
return
}
node.aes.setSecret(aeskey, algorithm, ack.regionId.toLong())
node.sessionToken = ack.sessionToken
node.deviceConfig.ip.netAddr = com.jihe.punchnet.sdlan.utils.ipStringToInt(node.connectData?.ip ?: "0.0.0.0").toUInt().toInt()
node.deviceConfig.ip.netBitLen = (node.connectData?.maskLen ?: 24).toByte()
if (node.iface is IfaceTun) {
val tun = node.iface as IfaceTun
val maskVal = com.jihe.punchnet.sdlan.network.maskDigitToInt(node.deviceConfig.ip.netBitLen.toInt()) ?: 0xffffff00.toInt()
tun.arpTable.routeTable.addRoute(
com.jihe.punchnet.sdlan.network.RouteDetail(
mask = maskVal,
gw = 0,
maskedAddr = node.deviceConfig.ip.netAddr and maskVal
)
)
}
println("decrypted aes is: ${aeskey.contentToString()}")
node.aes.setSecret(aeskey)
val ipStr = ipToString(ack.devAddr.netAddr)
TerminalLogger.debugf { "assigned ip: $ipStr" }
node.deviceConfig.ip.netAddr = ack.devAddr.netAddr
node.deviceConfig.ip.netBitLen = ack.devAddr.netBitLen.toByte()
node.iface?.reload_config(node.deviceConfig)
node.networkID.set(node.authData?.networkId ?: 0)
TerminalLogger.debugf {
"control registered: clientIdTail=${node.clientId.takeLast(12)}, network=${node.networkID.get()}, " +
"ip=${ipToString(node.deviceConfig.ip.netAddr)}, mask=${node.deviceConfig.ip.netBitLen}, " +
"mac=${macToString(node.mac)}, identity=${node.connectData?.identityId ?: 0}"
}
sendExposedServiceRequest(node)
node.networkID.set(ack.devAddr.networkId)
// println("got aes key: ${aeskey.toList()}, length is ${aeskey.size}")
node.sendStunRequest()
scope.launch {
try {
CoroutineScope(Dispatchers.Default).async {
node.probeNatType()
node.sendStunRequest()
TerminalLogger.debugf { "updated NAT type after probe: ${node.nat_type}" }
} catch (e: Exception) {
TerminalLogger.warning { "NAT probe failed: $e" }
}
TerminalLogger.debugf { "nat type is ${node.nat_type}"}
}
}
PacketType.RegisterSuperNAK -> {
println("21")
val nak = SDLRegisterSuperNak.parseFrom(data.currentPacket)
TerminalLogger.debugf { "got register super nak: $nak" }
val nakcode = NakMsgCode.fromByte(nak.errorCode.toByte())
when(nakcode) {
NakMsgCode.InvalidToken, NakMsgCode.NodeDisabled-> {
node.startStopChannel.send(StartStopChanInfo(StartStopFlag.IsStop, null))
ButtonRepository.updateState(ButtonState.ButtonStopped)
scope.cancel()
exitProcess(-1)
}
else -> {
ButtonRepository.updateState(ButtonState.ButtonStopped)
node.startStopChannel.send(StartStopChanInfo(StartStopFlag.IsStop, null))
}
}
@ -159,7 +97,7 @@ suspend fun onMessage(scope: CoroutineScope, data: SDLanControl) {
TerminalLogger.errorf { "malformed command received"}
return
}
// handleTcpCommand(node, data.currentPacket[0], data.currentPacket.sliceArray(1..data.currentPacket.size-1))
handleTcpCommand(node, data.currentPacket[0], data.currentPacket.sliceArray(1..data.currentPacket.size-1))
}
PacketType.PeerInfo -> {
TerminalLogger.debugf { "PeerInfo received" }
@ -167,48 +105,28 @@ suspend fun onMessage(scope: CoroutineScope, data: SDLanControl) {
}
PacketType.Event -> {
TerminalLogger.debugf { "Event received" }
handleEvent(node, data.currentPacket)
if (data.currentPacket.size < 1) {
TerminalLogger.errorf { "malformed event received"}
return
}
val eventType = EventType.fromByte(data.currentPacket[0])
val size = data.currentPacket.size
val content = data.currentPacket.sliceArray(1..size-1)
handleTcpEvent(node, eventType, content)
}
PacketType.Pong -> {
TerminalLogger.debugf { "Pong Received" }
}
PacketType.ArpResponse -> {
TerminalLogger.debugf { "ArpResponse received" }
handlePacketArpResponse(node, data.currentPacket)
}
PacketType.PolicyResponse -> {
val policy = SDLPolicyResponse.parseFrom(data.currentPacket)
node.queriedPolicies[policy.dstIdentityId] = policy.version
node.policyRules[policy.dstIdentityId] = policy.rules.toByteArray()
val ruleSummary = describePolicyRules(policy.rules.toByteArray())
val targetIp = node.queriedPolicyTargets[policy.dstIdentityId]
val targetText = targetIp?.let { ", dstIp=${ipToString(it)}, ${describeResourcesForTarget(node, it)}" } ?: ""
if (policy.rules.isEmpty) {
TerminalLogger.warning {
"PolicyResponse has no allow rules: srcIdentity=${policy.srcIdentityId}, " +
"dstIdentity=${policy.dstIdentityId}, version=${policy.version}$targetText"
}
} else {
TerminalLogger.debugf {
"PolicyResponse received srcIdentity=${policy.srcIdentityId}, dstIdentity=${policy.dstIdentityId}, " +
"version=${policy.version}, rules=$ruleSummary$targetText"
}
}
}
PacketType.ExposedServiceResponse -> {
val exposed = SDLExposedServiceResponse.parseFrom(data.currentPacket)
TerminalLogger.debugf {
"ExposedServiceResponse received version=${exposed.version}, " +
"tcpPorts=${exposed.tcpPortsList.size}, udpPorts=${exposed.udpPortsList.size}"
}
TerminalLogger.debugf { "tcp Pong Received" }
// node.ping_to_sn()
}
else -> {
println("error packet type: ${data.packetType.toUByte()}")
}
}
}
suspend fun run_sdlan(scope: CoroutineScope, iface: Iface, argument: Arguments, authData: com.jihe.punchnet.api.AuthResponseData, connectData: com.jihe.punchnet.api.ConnectResponseData) {
suspend fun run_sdlan(iface: Iface, argument: Arguments, routeinfo: PunchnetServiceArgument?) {
UniqueNodeID.setBaseDir(argument.baseDir)
val edgeUUID = UniqueNodeID.getUUID()
val config = parseConfig(edgeUUID, argument)
@ -219,58 +137,83 @@ suspend fun run_sdlan(scope: CoroutineScope, iface: Iface, argument: Arguments,
return
}
val toSocket = Channel<ByteArray>(100)
val start_stop_channel = Channel<StartStopChanInfo>(100)
initEdge(scope, iface, argument.token, config, toSocket, start_stop_channel)
initEdge(iface, argument.token, config, toSocket, start_stop_channel)
val snParts = argument.sn.split(":")
val tcp = argument.tcp.split(":")
val now = System.currentTimeMillis() / 1000
val node = Node.getInstance()
node.authData = authData
node.connectData = connectData
val before = ubyteArrayOf(126u,162u,25u,63u,148u,147u,198u,41u,69u,165u,149u,101u,153u,82u,190u,21u,48u,120u,26u,64u,142u,103u,159u,60u,47u,129u,176u,17u,232u,210u,36u,56u)
val encrypted = node.rsa.encrypt(before.toByteArray(), use_private_key = false)
println("encrypted: ${encrypted.contentToString()}")
val origin = node.rsa.decrypt(encrypted, use_pub_key = false)
TerminalLogger.debugf { "got encrypted ${encrypted.size}"}
TerminalLogger.debugf { "got origin ${origin.contentToString()}"}
TerminalLogger.debugf { "self mac: ${macToString(node.mac)}"}
TerminalLogger.debugf { describeConnectDataForLog(node) }
val onConnected: suspend (tech.kwik.core.QuicStream) -> Unit = { stream ->
TerminalLogger.debugf { "QUIC stream connected callback" }
// We wait for server's PACKET_WELCOME before sending RegisterSuper
val onConnected: suspend (Socket, Int?) -> Unit = { socket, pktID ->
println("connected callback")
val clientid = UniqueNodeID.getUUID()
println("client id is $clientid")
val registerSuper = SDLRegisterSuper.newBuilder()
.setVersion(1)
.setToken(node.token)
.setInstalledChannel("kotlin")
.setClientId(clientid)
.setDevAddr(
SDLDevAddr.newBuilder().setMac(node.mac)
.setNetAddr(0)
.setNetBitLen(0)
.setNetworkId(0)
.build()
)
.setPubKey(node.rsa.getPublicKeyString())
.build()
val packetID = pktID ?: node.getNextPacketID()
val msg = encodeToTcpMessage(registerSuper, packetID, PacketType.RegisterSuper)
// TerminalLogger.debugf{"sent register super: $registerSuper"}
toSocket.send(msg.toByteArray())
TerminalLogger.debugf{"sent register super!"}
}
/*
val onMessage: suspend (SDLanTCP) -> Unit = { data ->
}
*/
scope.launch {
try {
CoroutineScope((Dispatchers.IO)).async {
while(true) {
if (node.iface == null) {
delay(1000)
continue
}
val data = node.iface?.recv()
Log.d("SDLAN", "async receive data from iface: ${data?.size} bytes")
if (data == null) {
delay(1000)
//delay(1000)
println("got data is null")
continue
}
if (data.isEmpty()) {
delay(1000)
println("got data size 0")
// delay(1000)
continue
}
Log.d("SDLAN", "handle data form device starts")
node.iface?.handleDataFromDevice(node, data)
Log.d("SDLAN", "handle data form device stops")
}
} catch (e: Exception) {
println("iface read is cancelled")
node.iface?.close()
node.udpSockV4.close()
node.udpSockMulticast?.close()
}
}
scope.launch {
initQUICConn(
scope,
snParts[0], snParts[1].toIntOrNull() ?: 1265,
CoroutineScope(Dispatchers.IO).async {
initTCPConn(
tcp[0], tcp[1].toInt(),
start_stop_channel,
AtomicLong(now),
AtomicBoolean(false),
@ -284,12 +227,11 @@ suspend fun run_sdlan(scope: CoroutineScope, iface: Iface, argument: Arguments,
)
}
println("sending start")
start_stop_channel.send(StartStopChanInfo(StartStopFlag.IsStart, null))
println("sent start")
val cancel = Channel<Boolean>(100)
runEdgeLoop(scope, node, cancel)
runEdgeLoop(node, cancel)
while(true) {
TerminalLogger.debugf {"ping to sn"}
@ -298,33 +240,24 @@ suspend fun run_sdlan(scope: CoroutineScope, iface: Iface, argument: Arguments,
}
}
suspend fun runEdgeLoop(scope: CoroutineScope, node: Node, cancel: ReceiveChannel<Boolean>) {
suspend fun runEdgeLoop(node: Node, cancel: ReceiveChannel<Boolean>) {
node.ping_to_sn()
scope.launch {
loopSocketV4(scope, node, node.udpSockV4, cancel)
CoroutineScope(Dispatchers.Default).async {
loopSocketV4(node, node.udpSockV4, cancel)
}
}
suspend fun loopSocketV4(scope: CoroutineScope, node: Node, sock: SDLanSocket, cancel: ReceiveChannel<Boolean>) {
val job_stun_request = scope.async {
try {
suspend fun loopSocketV4(node: Node, sock: SDLanSocket, cancel: ReceiveChannel<Boolean>) {
val job_stun_request = CoroutineScope(Dispatchers.Default).async {
while(true) {
delay(10_000)
node.probeNatType()
node.sendStunRequest()
}
} catch (e: Exception) {
Log.e("Socket", "job stun request exits: $e")
}
}
val job_handle_packet = scope.async {
try {
val job_handle_packet = CoroutineScope(Dispatchers.Default).async {
while(true) {
readAndParsePacket(scope, node, sock)
}
} catch (e: Exception) {
sock.close()
readAndParsePacket(node, sock)
}
}
@ -341,10 +274,9 @@ suspend fun loopSocketV4(scope: CoroutineScope, node: Node, sock: SDLanSocket, c
}
job_stun_request.cancelAndJoin()
job_handle_packet.cancelAndJoin()
Log.e("Socket", "loop socket v4 stops")
}
suspend fun readAndParsePacket(scope: CoroutineScope, node: Node, sock: SDLanSocket) {
suspend fun readAndParsePacket(node: Node, sock: SDLanSocket) {
val packet = sock.receive()
val from = packet.socketAddress
@ -354,10 +286,10 @@ suspend fun readAndParsePacket(scope: CoroutineScope, node: Node, sock: SDLanSoc
}
val data = ByteBuffer.wrap(packet.data, 0, packet.length)
// val data = packet.data.toByteString(0, packet.length)
handleAPacket(scope, node, from, data)
handleAPacket(node, from, data)
}
suspend fun handleAPacket(scope: CoroutineScope, node: Node, from: SocketAddress, data: ByteBuffer) {
suspend fun handleAPacket(node: Node, from: SocketAddress, data: ByteBuffer) {
val pktType = PacketType.fromValue(data.get().toUByte())
if (pktType == null) {
TerminalLogger.errorf { "invalid packet type" }
@ -375,7 +307,7 @@ suspend fun handleAPacket(scope: CoroutineScope, node: Node, from: SocketAddress
if (from is InetSocketAddress) {
TerminalLogger.debugf {"got data"}
val sock = SDLanSock(IPFamily.IPV4, from.port, from.address.address)
handlePacketData(scope, node, data, sock)
handlePacketData(node, data, sock)
}
}
PacketType.StunProbeReply -> {
@ -390,16 +322,7 @@ suspend fun handleAPacket(scope: CoroutineScope, node: Node, from: SocketAddress
TerminalLogger.debugf { "got stun reply" }
return
}
PacketType.PolicyResponse -> {
TerminalLogger.debugf { "got policy response" }
return
}
PacketType.ExposedServiceResponse -> {
TerminalLogger.debugf { "got exposed service response" }
return
}
PacketType.Register -> {
TerminalLogger.debugf { "got REGISTER" }
if (from is InetSocketAddress) {
val sock = SDLanSock(IPFamily.IPV4, from.port, from.address.address)
handlePacketRegister(node, data, sock)
@ -424,18 +347,18 @@ suspend fun handleAPacket(scope: CoroutineScope, node: Node, from: SocketAddress
}
fun initEdge(scope: CoroutineScope, iface: Iface, token: String, config: NodeConfig, toSocket: SendChannel<ByteArray>, startStopChannel: SendChannel<StartStopChanInfo>) {
fun initEdge(iface: Iface, token: String, config: NodeConfig, toSocket: SendChannel<ByteArray>, startStopChannel: SendChannel<StartStopChanInfo>) {
val rsa = RSA.getRSA()
val pathname = Paths.get(config.baseDir, RSAConfig.BASE_DIR).pathString
Log.d("DIR", "pathname = $pathname")
rsa.generateKeyPair(pathname)
//rsa.generateKeyPair(Path.of(config.baseDir, RSAConfig.BASE_DIR).name)
val sockV4 = SDLanSocket(scope, "0.0.0.0", config.localPort)
val sockV4 = SDLanSocket("0.0.0.0", config.localPort)
var sockMulticast: SDLanSocket? = null
if (!config.dropMulticast) {
sockMulticast = SDLanSocket(scope, SDLanMulticastConfig.MULTICAST_V4.toIPV4String(), SDLanMulticastConfig.MULTICAST_PORT)
sockMulticast = SDLanSocket(SDLanMulticastConfig.MULTICAST_V4.toIPV4String(), SDLanMulticastConfig.MULTICAST_PORT)
}
Node.initialize(
@ -449,15 +372,189 @@ fun initEdge(scope: CoroutineScope, iface: Iface, token: String, config: NodeCon
startStopChannel,
)
val instance = Node.getInstance()
instance.token = token
instance.udpSockV4 = sockV4
instance.udpSockMulticast = sockMulticast
instance.config = config
instance.toSocket = toSocket
instance.startStopChannel = startStopChannel
println(instance)
}
suspend fun initTCPConn(
tcpHost: String,
tcpPort: Int,
start_stop: Channel<StartStopChanInfo>,
pong_time: AtomicLong,
connected: AtomicBoolean,
toSocket: ReceiveChannel<ByteArray>,
onConnected: suspend (stream: Socket, pktID: Int?)->Unit,
onMessage: suspend (SDLanTCP)->Unit,
onDisconnected: suspend ()->Unit,
connectingChan: SendChannel<ConnectingState>?
) {
var started: Boolean = false
var startPktID: Int? = null
while(true) {
connectingChan?.send(ConnectingState.NotConnected)
if (!started) {
while(true) {
val startStopInfo = start_stop.receive()
if (startStopInfo.flag == StartStopFlag.IsStart) {
started = true
startPktID = startStopInfo.packetID
break
}
TerminalLogger.debugf {
"start stop chan received ${startStopInfo}"
}
}
}
connectingChan?.send(ConnectingState.Connecting)
TerminalLogger.debugf {"try connecting..."}
val socket: Socket
try {
withContext(Dispatchers.IO) {
TerminalLogger.debugf { "connecting to $tcpHost:$tcpPort" }
socket = Socket(tcpHost, tcpPort)
}
} catch (e: Exception) {
TerminalLogger.errorf { "failed to connect to ${tcpHost}:${tcpPort}: $e"}
delay(3000)
continue
}
val node = Node.getInstance()
val outIP = ByteBuffer.wrap(socket.localAddress.address).getInt()
node.outerIPV4.set(outIP)
val job_read_packet = CoroutineScope(Dispatchers.IO).async {
val input = DataInputStream(socket.getInputStream())
try {
println("job read packet starts")
while(true) {
val tcpPacket = readPacket(input)
if (tcpPacket == null) {
TerminalLogger.errorf {"tcp Packet is null"}
break
}
onMessage(tcpPacket)
}
} finally {
TerminalLogger.errorf {"input closing"}
input.close()
}
}
val job_write_to_packet = CoroutineScope(Dispatchers.IO).async {
val output = DataOutputStream(socket.getOutputStream())
try {
TerminalLogger.debugf {"job write to packet starts"}
while(true) {
try {
val msg = toSocket.receive()
TerminalLogger.debugf{"received message"}
output.write(msg)
} catch (e: Exception) {
output.close()
TerminalLogger.errorf {"failed to receive message to tcp: $e"}
break
}
}
} finally {
output.close()
}
}
val job_check_pong = CoroutineScope(Dispatchers.IO).async {
println("job check pong starts")
while(true) {
delay(10_000)
val now = System.currentTimeMillis()/1000
if (connected.get() && (now-pong_time.get()>SDLanConfig.TCP_PING_TIME*2)) {
TerminalLogger.errorf {"tcp pong check expired"}
break
}
}
}
val job_check_stop = CoroutineScope(Dispatchers.IO).async {
println("job check stop starts")
while(true) {
try {
val v = start_stop.receive()
if (v.flag == StartStopFlag.IsStop) {
started = false
break
}
} catch(e: Exception) {
started = false
break
}
}
}
TerminalLogger.debugf { "connected" }
onConnected(socket, startPktID)
connectingChan?.send(ConnectingState.Connected)
var cancelled: Boolean = false
select<Unit> {
job_read_packet.onAwait() {
println("job read packet exited")
}
job_write_to_packet.onAwait() {
println("job write to packet exited")
}
job_check_pong.onAwait() {
println("job check pong exited")
}
job_check_stop.onAwait() {
println("job check stop exited")
}
}
job_read_packet.cancelAndJoin()
job_write_to_packet.cancelAndJoin()
job_check_pong.cancelAndJoin()
job_check_stop.cancelAndJoin()
delay(1000)
}
}
fun readPacket(input: DataInputStream): SDLanTCP? {
try {
val size = input.readShort()
val packetID = input.readInt().toUInt()
val packetTypeUByte = input.readByte().toUByte()
val packetType = PacketType.fromValue(packetTypeUByte)
if (packetType == null) {
TerminalLogger.errorf{"packet type not found: $packetTypeUByte"}
return null
}
if (size < 5) {
TerminalLogger.errorf {"got input stream size error: ${size}"}
return null
}
var buffsize = size - 5
val data = ByteArray(buffsize)
var toread = buffsize
while(toread > 0) {
val sizeGot = input.read(data, (buffsize-toread), toread)
if (sizeGot <= 0) {
TerminalLogger.errorf {"failed to read further: got ${sizeGot}"}
return null
}
toread -= sizeGot
}
return SDLanTCP(packetID, packetType, data)
} catch (e: Exception) {
TerminalLogger.errorf { "failed to read: $e" }
return null
}
}
fun parseConfig(nodeuuid: String, argument: Arguments): NodeConfig? {
if (argument.sn.length == 0) {
@ -465,23 +562,18 @@ fun parseConfig(nodeuuid: String, argument: Arguments): NodeConfig? {
return null
}
val natServer1 = parseScoketAddressV4FromString(argument.nat_server1)
val natServer2 = parseScoketAddressV4FromString(argument.nat_server2)
if (natServer1 == null || natServer2 == null) {
return null
}
val sn = parseScoketAddressV4FromString(argument.sn)
if (sn == null) {
return null
}
val stun = parseScoketAddressV4FromString(argument.stun)
if (stun == null) {
return null
}
val natServer1 = parseScoketAddressV4FromString(argument.nat_server1)
val natServer2 = parseScoketAddressV4FromString(argument.nat_server2)
if (natServer1 == null || natServer2 == null) {
return null
}
return NodeConfig(
baseDir = argument.baseDir,
name = argument.name,
@ -495,8 +587,7 @@ fun parseConfig(nodeuuid: String, argument: Arguments): NodeConfig? {
registerTTL = argument.registerTTL,
localPort = argument.localPort,
supernode = SDLanSock(IPFamily.IPV4, sn.port, sn.address.address),
stunServer = SDLanSock(IPFamily.IPV4, stun.port, stun.address.address),
natServer1 = natServer1,
natServer2 = natServer2
natServer2 = natServer2,
)
}

View File

@ -49,15 +49,6 @@ enum class PacketType(val id: UByte) {
StunProbe(0x32u),
StunProbeReply(0x33u),
Welcome(0x4fu),
ArpRequest(0x50u),
ArpResponse(0x51u),
PolicyRequest(0xb0u),
PolicyResponse(0xb1u),
ExposedServiceRequest(0xb2u),
ExposedServiceResponse(0xb3u),
Data(0xffu);
companion object {
@ -74,8 +65,9 @@ fun PacketType.toUByte(): UByte {
}
// tcp/quic发送过来的通道里面的信息
class SDLanControl(
// tcp发送过来的通道里面的信息
class SDLanTCP(
val packetID: UInt,
val packetType: PacketType,
val currentPacket: ByteArray,
)
@ -111,13 +103,18 @@ fun encodeToUDPMessage(msg: Message?, packetType: PacketType): List<Byte> {
return result
}
fun encodeToControlMessage(msg: Message?, packetType: PacketType): List<Byte> {
fun encodeToTcpMessage(msg: Message?, packetID: Int, packetType: PacketType): List<Byte> {
val msgByte = msg?.toByteArray()?.toList()?:listOf<Byte>()
val result: MutableList<Byte> = mutableListOf()
result.addAll(ByteBuffer.allocate(2)
.order(ByteOrder.BIG_ENDIAN)
.putShort((msgByte.size + 1).toShort())
.putShort((msgByte.size + 5).toShort())
.array().toList())
result.addAll(ByteBuffer.allocate(4)
.order(ByteOrder.BIG_ENDIAN)
.putInt(packetID)
.array().toList())
result.add(packetType.toUByte().toByte())

View File

@ -1,214 +0,0 @@
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
}

View File

@ -1,106 +0,0 @@
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
}

View File

@ -45,21 +45,11 @@ fun cidrToRouteDetail(cidr: String, gw: String): RouteDetail? {
)
}
fun maskIPToDigit(mask: Int): Int {
var count = 0
var temp = mask
while (temp != 0) {
temp = temp shl 1
count += 1
}
return count
}
fun maskDigitToInt(digit: Int): Int? {
if (digit <= 0 || digit >= 32) {
return null
}
return ((1 shl (32-digit)) - 1).inv()
return ((2 shl (32-digit)) - 1)
}
fun ipStringToInt(ip: String): Int? {
@ -103,19 +93,12 @@ class RouteTable() {
fun getGateway(ip: Int): Int? {
try {
lock.readLock().lock()
var bestGw: Int? = null
var bestMaskLen = -1
for (item in routeInfo) {
if ((ip and item.mask) == item.maskedAddr) {
val maskLen = Integer.bitCount(item.mask)
if (maskLen > bestMaskLen) {
bestMaskLen = maskLen
bestGw = item.gw
return item.gw
}
}
}
TerminalLogger.debugf { "RouteTable LPM: ip=${com.jihe.punchnet.sdlan.utils.ipToString(ip)} -> bestGw=${bestGw?.let { com.jihe.punchnet.sdlan.utils.ipToString(it) } ?: "null"} (maskLen=$bestMaskLen)" }
return bestGw
return null
} finally {
lock.readLock().unlock()
}
@ -129,17 +112,11 @@ class RouteTable2(
val routeInfo: Array<RouteDetail> = initRouteInfo.toTypedArray()
fun getGeteway(ip: Int): Int? {
var bestGw: Int? = null
var bestMaskLen = -1
for (item in routeInfo) {
if ((ip and item.mask) == item.maskedAddr) {
val maskLen = Integer.bitCount(item.mask)
if (maskLen > bestMaskLen) {
bestMaskLen = maskLen
bestGw = item.gw
return item.gw
}
}
}
return bestGw
return null
}
}

View File

@ -1,6 +1,5 @@
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
@ -15,20 +14,20 @@ import java.net.DatagramSocket
import java.net.Inet4Address
import java.net.SocketAddress
class SDLanSocket(val scope: CoroutineScope, val addr: String, var port: Int, val reuseAddress: Boolean = false) {
class SDLanSocket(val addr: String, val port: Int, val reuseAddress: Boolean = false) {
private val connection: DatagramSocket = run {
val sock = DatagramSocket(port, Inet4Address.getByName(addr))
if (reuseAddress) {
sock.reuseAddress = true
}
port = sock.localPort
Log.d("SDLanSocket", "local port = $port")
sock
}
private var job: Job? = null
fun loop(): ReceiveChannel<DatagramPacket> {
val channel = Channel<DatagramPacket>(100)
scope.launch {
job = CoroutineScope(Dispatchers.Default).launch {
while (true) {
val msg = receive()
channel.send(msg)
@ -45,7 +44,7 @@ class SDLanSocket(val scope: CoroutineScope, val addr: String, var port: Int, va
connection.send(packet)
}
} catch(e: Exception) {
TerminalLogger.errorf {"Failed to send to: $e, localport: ${port}"}
TerminalLogger.errorf {"Failed to send to: $e"}
}
}
@ -59,7 +58,8 @@ class SDLanSocket(val scope: CoroutineScope, val addr: String, var port: Int, va
return packet
}
fun close() {
suspend fun close() {
job?.cancelAndJoin()
connection.close()
}
}

View File

@ -2,11 +2,7 @@ package com.jihe.punchnet.sdlan.utils
import com.jihe.punchnet.sdlan.config.AESConfig
import com.jihe.punchnet.sdlan.logs.TerminalLogger
import org.bouncycastle.crypto.engines.ChaCha7539Engine
import org.bouncycastle.crypto.params.KeyParameter
import org.bouncycastle.crypto.params.ParametersWithIV
import java.nio.ByteBuffer
import java.nio.ByteOrder
import javax.crypto.Cipher
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
@ -14,8 +10,6 @@ import javax.crypto.spec.SecretKeySpec
class AES private constructor () {
private var _secret: ByteArray = ByteArray(0)
private var _iv: ByteArray = ByteArray(0)
private var _algorithm: String = "aes"
private var _regionId: Long = 0
companion object {
private var instance: AES? = null
get() {
@ -35,17 +29,13 @@ class AES private constructor () {
return _secret.size != 0
}
fun setSecret(secret: ByteArray?, algorithm: String = "aes", regionId: Long = 0) {
fun setSecret(secret: ByteArray?) {
if (secret == null) {
_secret = ByteArray(0)
_iv = ByteArray(0)
_algorithm = "aes"
_regionId = 0
} else {
_secret = secret
_iv = _secret.sliceArray(0..<16)
_algorithm = algorithm.lowercase()
_regionId = regionId
}
}
@ -53,11 +43,22 @@ class AES private constructor () {
if (!isAuthorized()) {
return null
}
println(11.0)
val cipher = Cipher.getInstance(AESConfig.CIPHER_ALGORITHM)
println(11.1)
val keyspec = SecretKeySpec(_secret, AESConfig.KEY_SPEC)
println(11.2)
println(11.3)
try {
val input = ByteArray(content.remaining())
content.get(input)
val encrypted = encrypt(input) ?: return null
return ByteBuffer.wrap(encrypted)
cipher.init(Cipher.ENCRYPT_MODE, keyspec, IvParameterSpec(_iv))
println(10.0)
val output = ByteBuffer.allocate(cipher.getOutputSize(content.remaining()))
println(10.1)
cipher.doFinal(content, output)
println(10.2)
output.flip()
println(10.3)
return output
} catch (e: Exception) {
println("failed to encrypt: ${e.toString()}")
return null
@ -69,15 +70,12 @@ class AES private constructor () {
TerminalLogger.errorf { "not authed, so not encrypting" }
return null
}
val cipher = Cipher.getInstance(AESConfig.CIPHER_ALGORITHM)
val keyspec = SecretKeySpec(_secret, AESConfig.KEY_SPEC)
cipher.init(Cipher.ENCRYPT_MODE, keyspec, IvParameterSpec(_iv))
try {
return when (_algorithm) {
"aes" -> aesCrypt(Cipher.ENCRYPT_MODE, content)
"chacha20" -> chacha20Crypt(content)
else -> {
TerminalLogger.errorf { "unsupported encryption algorithm: $_algorithm" }
null
}
}
val encrypted = cipher.doFinal(content)
return encrypted
} catch (e: Exception) {
TerminalLogger.errorf {"encrypt failed: $e"}
return null
@ -88,11 +86,14 @@ class AES private constructor () {
if (!isAuthorized()) {
return null
}
val cipher = Cipher.getInstance(AESConfig.CIPHER_ALGORITHM)
val keyspec = SecretKeySpec(_secret, AESConfig.KEY_SPEC)
cipher.init(Cipher.DECRYPT_MODE, keyspec, IvParameterSpec(_iv))
try {
val input = ByteArray(ciphered.remaining())
ciphered.get(input)
val decrypted = decrypt(input) ?: return null
return ByteBuffer.wrap(decrypted)
val output = ByteBuffer.allocate(cipher.getOutputSize(ciphered.remaining()))
cipher.doFinal(ciphered, output)
output.flip()
return output
} catch (e: Exception) {
return null
}
@ -102,40 +103,16 @@ class AES private constructor () {
if (!isAuthorized()) {
return null
}
val cipher = Cipher.getInstance(AESConfig.CIPHER_ALGORITHM)
val keyspec = SecretKeySpec(_secret, AESConfig.KEY_SPEC)
cipher.init(Cipher.DECRYPT_MODE, keyspec, IvParameterSpec(_iv))
try {
return when (_algorithm) {
"aes" -> aesCrypt(Cipher.DECRYPT_MODE, ciphered)
"chacha20" -> chacha20Crypt(ciphered)
else -> null
}
val decrypted = cipher.doFinal(ciphered)
return decrypted
} catch (e: Exception) {
return null
}
}
private fun aesCrypt(mode: Int, content: ByteArray): ByteArray {
val cipher = Cipher.getInstance(AESConfig.CIPHER_ALGORITHM)
val keyspec = SecretKeySpec(_secret, AESConfig.KEY_SPEC)
cipher.init(mode, keyspec, IvParameterSpec(_iv))
return cipher.doFinal(content)
}
private fun chacha20Crypt(content: ByteArray): ByteArray {
val engine = ChaCha7539Engine()
val nonce = chacha20Nonce()
engine.init(true, ParametersWithIV(KeyParameter(_secret), nonce))
val output = ByteArray(content.size)
engine.processBytes(content, 0, content.size, output, 0)
return output
}
private fun chacha20Nonce(): ByteArray {
return ByteBuffer.allocate(12)
.order(ByteOrder.BIG_ENDIAN)
.putLong(0L)
.putInt(_regionId.toInt())
.array()
}
}
fun byteArray2Hex(array: ByteArray): String {

View File

@ -109,6 +109,7 @@ class RSA private constructor () {
}
pubkeyStr = pubFile.readText()
println("pub key is ${pubkeyStr}")
}
fun encrypt(input: ByteArray, use_private_key: Boolean = true): ByteArray {

View File

@ -17,8 +17,6 @@ import kotlin.experimental.inv
import kotlin.experimental.or
import kotlin.random.Random
import android.content.Context
object UniqueNodeID {
var id: String = ""
private var baseDir: String = ""
@ -27,18 +25,11 @@ object UniqueNodeID {
baseDir = basedir
}
fun getUUID(context: Context? = null): String {
if (id.isEmpty() && context != null) {
val androidId = android.provider.Settings.Secure.getString(context.contentResolver, android.provider.Settings.Secure.ANDROID_ID)
if (!androidId.isNullOrEmpty() && androidId != "9774d56d682e549c") { // 9774d56d682e549c is a known bugged ID on old emulators
id = androidId
return id
}
}
if (id.isEmpty()) {
if (baseDir.isEmpty()) {
baseDir = Environment.getExternalStorageDirectory().absolutePath
fun getUUID(): String {
if (baseDir.length == 0) {
baseDir = Environment.getExternalStorageDirectory().name
}
if (id.length == 0) {
val dirpath = File(baseDir)
dirpath.mkdirs()
val f = File(baseDir, SDLanConfig.ID_FILENAME)
@ -80,15 +71,6 @@ fun ipToString(ip: Int): String {
return "$d1.$d2.$d3.$d4"
}
fun ipStringToInt(ipString: String): Int {
val parts = ipString.split(".")
if (parts.size != 4) return 0
return (parts[0].toInt() shl 24) or
(parts[1].toInt() shl 16) or
(parts[2].toInt() shl 8) or
(parts[3].toInt())
}
fun macToString(mac: Mac): String {
return mac.joinToString(separator = ":") { it.toUByte().toString(16) }
}

View File

@ -2,8 +2,8 @@ syntax = "proto3";
option java_package = "com.jihe.punchnet.protobuf";
option java_outer_classname = "PunchProto";
//
message SDLV4Info {
uint32 port = 1;
bytes v4 = 2;
@ -15,30 +15,34 @@ message SDLV6Info {
bytes v6 = 2;
}
message SDLWelcome {
uint32 version = 1;
uint32 max_bidi_streams = 2;
uint32 max_packet_size = 3;
uint32 heartbeat_sec = 4;
SDLV6Info ipv6_assist = 5;
//
message SDLDevAddr {
uint32 network_id = 1;
bytes mac = 2;
uint32 net_addr = 3;
uint32 net_bit_len = 4;
}
// tcp通讯消息
message SDLEmpty {
}
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;
uint32 version = 1;
string installed_channel = 2;
string client_id = 3;
SDLDevAddr dev_addr = 4;
string pub_key = 5;
string token = 6;
}
message SDLRegisterSuperAck {
string algorithm = 1;
bytes key = 2;
uint32 region_id = 3;
bytes session_token = 4;
SDLDevAddr dev_addr = 1;
bytes aes_key = 2;
uint32 upgrade_type = 3;
optional string upgrade_prompt = 4;
optional string upgrade_address = 5;
}
message SDLRegisterSuperNak {
@ -47,115 +51,72 @@ message SDLRegisterSuperNak {
}
//
message SDLQueryInfo {
bytes dst_mac = 1;
}
message SDLPeerInfo {
bytes dst_mac = 1;
optional SDLV4Info v4_info = 2;
SDLV4Info v4_info = 2;
optional SDLV6Info v6_info = 3;
}
message SDLArpRequest {
uint32 target_ip = 1;
uint32 origin_ip = 2;
bytes context = 3;
}
message SDLArpResponse {
uint32 target_ip = 1;
bytes target_mac = 2;
uint32 origin_ip = 3;
bytes context = 4;
}
message SDLPolicyRequest {
uint32 src_identity_id = 1;
uint32 dst_identity_id = 2;
uint32 version = 3;
}
message SDLPolicyResponse {
uint32 src_identity_id = 1;
uint32 dst_identity_id = 2;
uint32 version = 3;
bytes rules = 4;
}
message SDLExposedServiceRequest {
uint32 version = 1;
}
message SDLExposedServiceResponse {
uint32 version = 1;
repeated uint32 tcp_ports = 2;
repeated uint32 udp_ports = 3;
}
//
message SDLEvent {
message NatChanged {
message SDLNatChangedEvent {
bytes mac = 1;
uint32 ip = 2;
}
}
message SendRegister {
message SDLSendRegisterEvent {
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 {
message SDLNetworkShutdownEvent {
string message = 1;
}
oneof event {
NatChanged nat_changed = 1;
SendRegister send_register = 2;
NetworkShutdown shutdown = 3;
ExposedServiceChanged exposed_service_changed = 4;
}
}
//
message SDLCommand {
uint32 pkt_id = 1;
message ExitNodeControl {
int32 action = 1;
string remark = 2;
}
oneof command {
ExitNodeControl exit_node = 2;
}
message SDLChangeNetworkCommand {
SDLDevAddr dev_addr = 1;
bytes aes_key = 2;
}
message SDLCommandAck {
uint32 pkt_id = 1;
int32 code = 2;
string message = 3;
bytes data = 4;
// status = true, status = false message是失败原因描述
bool status = 1;
optional string message = 2;
}
message SDLFlows {
//
uint32 forward_num = 1;
// p2p直接流量
uint32 p2p_num = 2;
//
uint32 inbound_num = 3;
}
// UDP通讯消息
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;
uint32 cookie = 1;
string client_id = 2;
uint32 network_id = 3;
bytes mac = 4;
uint32 ip = 5;
uint32 nat_type = 6;
optional SDLV6Info v6_info = 7;
}
message SDLStunReply {
uint32 cookie = 1;
}
message SDLData {
@ -165,8 +126,6 @@ message SDLData {
bool is_p2p = 4;
uint32 ttl = 5;
bytes data = 6;
bytes session_token = 7;
uint32 identity_id = 8;
}
message SDLRegister {
@ -182,15 +141,14 @@ message SDLRegisterAck {
}
//
message SDLStunProbe {
uint32 cookie = 1;
uint32 attr = 2;
uint32 step = 3;
}
message SDLStunProbeReply {
uint32 cookie = 1;
uint32 step = 2;
uint32 port = 3;
uint32 ip = 4;
uint32 port = 2;
uint32 ip = 3;
}

View File

@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="200dp"
android:height="200dp"
android:viewportWidth="1024"
android:viewportHeight="1024">
<path
android:fillColor="#FF000000"
android:pathData="M802.1,102.4L222.2,102.4C156.3,102.4 102.4,156 102.4,221.9v579.9A120.1,120.1 0,0 0,222.2 921.6L802.1,921.6c65.9,0 119.5,-53.9 119.5,-119.8L921.6,221.9C921.6,156 868,102.4 802.1,102.4zM751.3,767.7h-297v11.6c0,19.1 -15.4,34.1 -34.1,34.1s-34.1,-15 -34.1,-34.1v-11.6h-112.6c-19.1,0 -34.1,-15.4 -34.1,-34.1s15,-34.1 34.1,-34.1h112.6v-11.6c0,-18.8 15.4,-34.1 34.1,-34.1s34.1,15.4 34.1,34.1v11.6h297c18.8,0 34.1,15.4 34.1,34.1s-15.4,34.1 -34.1,34.1zM751.3,543.4h-123.9v11.6c0,18.8 -15.4,34.1 -34.1,34.1s-34.1,-15.4 -34.1,-34.1v-11.6L273.4,543.4c-19.1,0 -34.1,-15.4 -34.1,-34.1 0,-19.1 15,-34.1 34.1,-34.1h285.7v-11.9c0,-18.8 15.4,-34.1 34.1,-34.1s34.1,15.4 34.1,34.1v11.9h123.9c18.8,0 34.1,15 34.1,34.1 0,18.8 -15.4,34.1 -34.1,34.1zM751.3,323.9L405.2,323.9v6.8c0,18.8 -15.4,34.1 -34.1,34.1s-34.1,-15.4 -34.1,-34.1v-6.8L273.4,323.9c-19.1,0 -34.1,-15.4 -34.1,-34.1s15,-34.1 34.1,-34.1h63.5L336.9,238.9c0,-18.8 15.4,-34.1 34.1,-34.1s34.1,15.4 34.1,34.1v16.7h346.1c18.8,0 34.1,15.4 34.1,34.1s-15.4,34.1 -34.1,34.1z"/>
</vector>

View File

@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="200dp"
android:height="200dp"
android:viewportWidth="1024"
android:viewportHeight="1024">
<path
android:pathData="M818.2,268.3c0.2,-34.9 -28.7,-64.3 -63.7,-64.6 -34.2,-0.3 -64.3,29.5 -64.3,64 0,34.3 28.9,63.6 63.3,64C788.2,332.1 818,302.9 818.2,268.3M128,640 L128,595.1c0,-0.4 1,-0.8 1,-1.1C132.3,570.2 144.4,551.5 161.2,534.8c125.6,-125.3 251,-250.8 376.5,-376.2C542.1,154.2 546.8,150 551.7,146.2 564,136.3 578.8,131.8 593.7,128l220.8,0c0.6,0.4 1.2,1 1.8,1.2 43.5,6.2 80.3,46.9 79.6,96.1 -0.8,63.2 -0.3,126.6 -0.1,189.9 0.1,26.6 -9.3,49.2 -28,67.9 -127.9,128 -255.7,256.1 -383.8,383.8 -39.2,39.1 -97.5,38.8 -136.8,-0.1 -63.6,-63.2 -126.7,-126.7 -190.1,-190 -11.9,-11.9 -20.6,-25.5 -25.3,-41.9C130.4,630.1 128,625.3 128,620.6"
android:fillColor="#2c2c2c"/>
</vector>

View File

@ -1,20 +0,0 @@
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) {
}
}
}

View File

@ -21,4 +21,3 @@ kotlin.code.style=official
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true
org.gradle.java.home=/Users/stavid/Library/Java/JavaVirtualMachines/corretto-17.0.17/Contents/Home

0
gradlew vendored Executable file → Normal file
View File

Binary file not shown.

View File

@ -1,10 +0,0 @@
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()