Compare commits
10 Commits
91042c083f
...
d116d2cbcd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d116d2cbcd | ||
|
|
5954894102 | ||
| 4b1bae0e55 | |||
| f86069d165 | |||
| 87bee05140 | |||
| 867fb5ca72 | |||
| fa2453a733 | |||
| c7aed59f25 | |||
| 678e9de066 | |||
| f24dd7cfe9 |
1
.idea/.name
generated
Normal file
1
.idea/.name
generated
Normal file
@ -0,0 +1 @@
|
|||||||
|
punchnet
|
||||||
6
.idea/AndroidProjectSystem.xml
generated
Normal file
6
.idea/AndroidProjectSystem.xml
generated
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="AndroidProjectSystem">
|
||||||
|
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
8
.idea/deploymentTargetSelector.xml
generated
8
.idea/deploymentTargetSelector.xml
generated
@ -4,6 +4,14 @@
|
|||||||
<selectionStates>
|
<selectionStates>
|
||||||
<SelectionState runConfigName="app">
|
<SelectionState runConfigName="app">
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
<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>
|
</SelectionState>
|
||||||
</selectionStates>
|
</selectionStates>
|
||||||
</component>
|
</component>
|
||||||
|
|||||||
13
.idea/deviceManager.xml
generated
Normal file
13
.idea/deviceManager.xml
generated
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<?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>
|
||||||
4
.idea/inspectionProfiles/Project_Default.xml
generated
4
.idea/inspectionProfiles/Project_Default.xml
generated
@ -49,6 +49,10 @@
|
|||||||
<option name="composableFile" value="true" />
|
<option name="composableFile" value="true" />
|
||||||
<option name="previewFile" value="true" />
|
<option name="previewFile" value="true" />
|
||||||
</inspection_tool>
|
</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">
|
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
<option name="composableFile" value="true" />
|
<option name="composableFile" value="true" />
|
||||||
<option name="previewFile" value="true" />
|
<option name="previewFile" value="true" />
|
||||||
|
|||||||
8
.idea/markdown.xml
generated
Normal file
8
.idea/markdown.xml
generated
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<?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
1
.idea/misc.xml
generated
@ -1,4 +1,3 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
||||||
|
|||||||
517
API.md
Normal file
517
API.md
Normal file
@ -0,0 +1,517 @@
|
|||||||
|
## HTTP API 接口列表
|
||||||
|
|
||||||
|
域名 https://root.punchsky.com/api
|
||||||
|
### 1.密钥登录接口
|
||||||
|
|
||||||
|
**URL**:`/auth/token`
|
||||||
|
**Method**:`POST`
|
||||||
|
|
||||||
|
#### 请求参数
|
||||||
|
| 参数名 | 类型 | 必填 | 说明 |
|
||||||
|
|-----------|------|------|--------------------------|
|
||||||
|
| client_id | string | ✅ | 客户端唯一标识符 |
|
||||||
|
| token | string | ✅ | 密钥 |
|
||||||
|
| mac | string | | MAC地址 |
|
||||||
|
| system | string | | 系统信息(window11/macos 15.8) |
|
||||||
|
| version | string | | 软件版本 |
|
||||||
|
| hostname | string | | 主机名 |
|
||||||
|
|
||||||
|
#### 响应参数
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|--------|-----------------------|
|
||||||
|
| code | int | 0 成功,其他失败 |
|
||||||
|
| message | string | 说明 |
|
||||||
|
| data | object | |
|
||||||
|
| access_token | string | |
|
||||||
|
| username | string | 用户名 |
|
||||||
|
| user_type | string | token密钥登录、account账号登录 |
|
||||||
|
| audit | int | 0 未审核,1审核通过 |
|
||||||
|
| network_id | int | 网络ID |
|
||||||
|
| network_name | string | 网络名称 |
|
||||||
|
| network_domain | string | 网络域名 |
|
||||||
|
| domain | string | 主域名 |
|
||||||
|
| my_network_list | array | 我的网络列表 |
|
||||||
|
#### 示例响应
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"data": {
|
||||||
|
"access_token": "MjZ8NDB8MGZhMWE3OGI2NzVkZDEwOWIyYjg2MmI4ZDQ5MTY1MmV8ZTg4MzgzMThhZDBhMjU5MDI5MzQ4ZGQ5ZTc3ZWExZDVhYmQ2YjQwM2M3NDE1YmE4MWQ5YzEzZTEzOTM3MzYzMQ",
|
||||||
|
"username": "测试clinet_id_123456",
|
||||||
|
"user_type": "token",
|
||||||
|
"audit": 1,
|
||||||
|
"network_id": 3,
|
||||||
|
"network_name": "test的网络",
|
||||||
|
"network_domain": "punchsky8.com",
|
||||||
|
"domain": "punchsky.com",
|
||||||
|
"my_network_list": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
#### 错误
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 400,
|
||||||
|
"message": "密钥已失效",
|
||||||
|
"data": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
### 2.账号登录接口
|
||||||
|
|
||||||
|
**URL**:`/auth/login`
|
||||||
|
**Method**:`POST`
|
||||||
|
|
||||||
|
#### 请求参数
|
||||||
|
| 参数名 | 类型 | 必填 | 说明 |
|
||||||
|
|--------|------|------|--------------------------|
|
||||||
|
| client_id | string | ✅ | 客户端唯一标识符 |
|
||||||
|
| username | string | ✅ | 用户名 |
|
||||||
|
| password | string | ✅ | 密码 |
|
||||||
|
| mac | string | | MAC地址 |
|
||||||
|
| system | string | | 系统信息(window11/macos 15.8) |
|
||||||
|
| version | string | | 软件版本 |
|
||||||
|
| hostname | string | | 主机名 |
|
||||||
|
|
||||||
|
#### 响应参数 - 跟auth/token一致 ,多了一个我的网络列表
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"data": {
|
||||||
|
"access_token": "MzF8NDV8MzM2MzMyMTQ1MTc3YjlkMjYyZGU3OTNkMWFhYjQ4OTB8YmE3ODRiZDE1MmFlMjQ3MDk2NzIyZTRiY2EyOWQzMGZmNWMyMWRhMmJkYzM3Zjk1N2NkNjg2NGRlNjQ1ZDc1MQ",
|
||||||
|
"username": "test3",
|
||||||
|
"user_type": "account",
|
||||||
|
"audit": 1,
|
||||||
|
"network_id": 3,
|
||||||
|
"network_name": "test\u7684\u7f51\u7edc",
|
||||||
|
"network_domain": "lfn1.punchlink.cn",
|
||||||
|
"my_network_list": [
|
||||||
|
{
|
||||||
|
"network_id": 3,
|
||||||
|
"network_name": "test\u7684\u7f51\u7edc"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.连接网络
|
||||||
|
|
||||||
|
**URL**:`/connect`
|
||||||
|
**Method**:`POST`
|
||||||
|
|
||||||
|
#### 请求参数
|
||||||
|
| 参数名 | 类型 | 必填 | 说明 |
|
||||||
|
|--------|--------|------|------|
|
||||||
|
| client_id | string | ✅ | 客户端唯一标识符 |
|
||||||
|
| access_token | string | ✅ | |
|
||||||
|
| version | string | | 软件版本 |
|
||||||
|
#### 响应参数
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|--------|-------------------|
|
||||||
|
| code | int | 0 成功,其他失败 |
|
||||||
|
| message | string | 说明 |
|
||||||
|
| data | object | |
|
||||||
|
| ip | string | IP地址 |
|
||||||
|
| mask_len | int | mask |
|
||||||
|
| hostname | string | hostname |
|
||||||
|
| identity_id | int | 目前返回单个数字,多组概念稍后解决 |
|
||||||
|
| resource_list | array | 资源列表 |
|
||||||
|
| node_list | string | 节点列表 |
|
||||||
|
| acl | array | 访问规则 |
|
||||||
|
| exit_node | array | 出口节点列表 (可为空) |
|
||||||
|
|
||||||
|
#### 示例响应
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"data": {
|
||||||
|
"ip": "10.10.2.3",
|
||||||
|
"mask_len": 24,
|
||||||
|
"hostname": "lfmw",
|
||||||
|
"identity_id": 26,
|
||||||
|
"resource_list": [
|
||||||
|
{
|
||||||
|
"id": 0,
|
||||||
|
"name": "文档中心",
|
||||||
|
"url": "http://10.10.2.6:88",
|
||||||
|
"connection_status": "connected"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "文档中心",
|
||||||
|
"url": "http://10.10.2.6:80",
|
||||||
|
"connection_status": "connected"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"name": "数据中心",
|
||||||
|
"url": "http://10.10.2.6:88",
|
||||||
|
"connection_status": "connected"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"name": "数据中心",
|
||||||
|
"url": "http://10.10.2.6:80",
|
||||||
|
"connection_status": "connected"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"name": "数据中心",
|
||||||
|
"url": "http://10.10.2.6:3306",
|
||||||
|
"connection_status": "connected"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"node_list": [
|
||||||
|
{
|
||||||
|
"id": 13,
|
||||||
|
"name": "key_MTE1",
|
||||||
|
"ip": "10.10.2.3",
|
||||||
|
"system": "Macos 1.0.0",
|
||||||
|
"connection_status": "connected"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 14,
|
||||||
|
"name": "test3",
|
||||||
|
"ip": "10.10.2.4",
|
||||||
|
"system": "Window 1.0.0",
|
||||||
|
"connection_status": "connected"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 23,
|
||||||
|
"name": "alc",
|
||||||
|
"ip": "10.10.2.6",
|
||||||
|
"system": "",
|
||||||
|
"connection_status": "connected"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 25,
|
||||||
|
"name": "maliu-window",
|
||||||
|
"ip": "10.10.2.7",
|
||||||
|
"system": "OS: Windows, Host name: DESKTOP-VLMVV81",
|
||||||
|
"connection_status": "connected"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 26,
|
||||||
|
"name": "key_MTE4",
|
||||||
|
"ip": "10.10.2.8",
|
||||||
|
"system": "OS: Windows, Host name: DESKTOP-VLMVV81",
|
||||||
|
"connection_status": "connected"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 27,
|
||||||
|
"name": "company-window",
|
||||||
|
"ip": "10.10.2.9",
|
||||||
|
"system": "OS: Windows, Host name: DESKTOP-VLMVV81",
|
||||||
|
"connection_status": "disconnected"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 28,
|
||||||
|
"name": "maliu-macos",
|
||||||
|
"ip": "10.10.2.10",
|
||||||
|
"system": "",
|
||||||
|
"connection_status": "connected"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 29,
|
||||||
|
"name": "key_MTE4",
|
||||||
|
"ip": "10.10.2.5",
|
||||||
|
"system": "linux",
|
||||||
|
"connection_status": "connected"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 39,
|
||||||
|
"name": "maliu-ubuntu",
|
||||||
|
"ip": "10.10.2.12",
|
||||||
|
"system": "linux",
|
||||||
|
"connection_status": "disconnected"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 40,
|
||||||
|
"name": "测试clinet_id_123456",
|
||||||
|
"ip": "10.10.2.3",
|
||||||
|
"system": "Macos 1.0.0",
|
||||||
|
"connection_status": "connected"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 41,
|
||||||
|
"name": "test3",
|
||||||
|
"ip": "10.10.2.5",
|
||||||
|
"system": "linux",
|
||||||
|
"connection_status": "connected"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 42,
|
||||||
|
"name": "test3",
|
||||||
|
"ip": "10.10.2.6",
|
||||||
|
"system": "",
|
||||||
|
"connection_status": "connected"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 43,
|
||||||
|
"name": "user_MTQw",
|
||||||
|
"ip": "10.10.2.13",
|
||||||
|
"system": "linux",
|
||||||
|
"connection_status": "connected"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 45,
|
||||||
|
"name": "test3",
|
||||||
|
"ip": "10.10.2.3",
|
||||||
|
"system": "Macos 1.0.0",
|
||||||
|
"connection_status": "connected"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 47,
|
||||||
|
"name": "compay-usa",
|
||||||
|
"ip": "10.10.2.14",
|
||||||
|
"system": "linux",
|
||||||
|
"connection_status": "connected"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 48,
|
||||||
|
"name": "test3",
|
||||||
|
"ip": "10.10.2.10",
|
||||||
|
"system": "",
|
||||||
|
"connection_status": "disconnected"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 49,
|
||||||
|
"name": "test3",
|
||||||
|
"ip": "10.10.2.7",
|
||||||
|
"system": "OS: Windows, Host name: DESKTOP-VLMVV81",
|
||||||
|
"connection_status": "connected"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 50,
|
||||||
|
"name": "test3",
|
||||||
|
"ip": "10.10.2.9",
|
||||||
|
"system": "OS: Windows, Host name: DESKTOP-VLMVV81",
|
||||||
|
"connection_status": "disconnected"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 51,
|
||||||
|
"name": "test3",
|
||||||
|
"ip": "10.10.2.15",
|
||||||
|
"system": "OS: Windows, Host name: MALIU3CA0",
|
||||||
|
"connection_status": "disconnected"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 52,
|
||||||
|
"name": "test3",
|
||||||
|
"ip": "10.10.2.16",
|
||||||
|
"system": "OS: Ubuntu, Host name: alex-ubuntu",
|
||||||
|
"connection_status": "disconnected"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"acl": [],
|
||||||
|
"exit_node": [
|
||||||
|
{
|
||||||
|
"node_id": 41,
|
||||||
|
"node_name": "test3",
|
||||||
|
"gateway": "10.10.2.5",
|
||||||
|
"target_network": "0.0.0.0/24"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 错误
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 400,
|
||||||
|
"message": "操作失败",
|
||||||
|
"data": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.断开网络
|
||||||
|
|
||||||
|
**URL**:`/disconnect`
|
||||||
|
**Method**:`POST`
|
||||||
|
|
||||||
|
#### 请求参数
|
||||||
|
| 参数名 | 类型 | 必填 | 说明 |
|
||||||
|
|--------|--------|------|------|
|
||||||
|
| client_id | string | ✅ | 客户端唯一标识符 |
|
||||||
|
| access_token | string | ✅ | |
|
||||||
|
|
||||||
|
#### 响应参数
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| code | int | 0 成功,其他失败 |
|
||||||
|
| message | string | 说明 |
|
||||||
|
| data | | |
|
||||||
|
|
||||||
|
#### 示例响应
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"data": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 错误
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 400,
|
||||||
|
"message": "操作失败",
|
||||||
|
"data": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
### 5.设备详情
|
||||||
|
|
||||||
|
**URL**:`/get_node_resources`
|
||||||
|
**Method**:`POST`
|
||||||
|
|
||||||
|
#### 请求参数
|
||||||
|
| 参数名 | 类型 | 必填 | 说明 |
|
||||||
|
|----|--------|------|----------|
|
||||||
|
| client_id | string | ✅ | 客户端唯一标识符 |
|
||||||
|
| access_token | string | ✅ | |
|
||||||
|
| id | int | ✅ | 设备id |
|
||||||
|
|
||||||
|
#### 响应参数
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| code | int | 0 成功,其他失败 |
|
||||||
|
| message | string | 说明 |
|
||||||
|
| data | | |
|
||||||
|
|
||||||
|
#### 示例响应
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"data": {
|
||||||
|
"id": 15,
|
||||||
|
"name": "key_MTE1",
|
||||||
|
"ip": "10.10.2.5",
|
||||||
|
"system": "Macos 1.0.0",
|
||||||
|
"connection_status": "connected",
|
||||||
|
"resource_list": [
|
||||||
|
{
|
||||||
|
"id": 15,
|
||||||
|
"name": "数据中心",
|
||||||
|
"url": "http://10.10.2.5:8080",
|
||||||
|
"connection_status": "connected"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 15,
|
||||||
|
"name": "数据中心",
|
||||||
|
"url": "http://10.10.2.5:90",
|
||||||
|
"connection_status": "connected"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 15,
|
||||||
|
"name": "数据中心",
|
||||||
|
"url": "http://10.10.2.5:10",
|
||||||
|
"connection_status": "connected"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.验证access_token 服务端使用
|
||||||
|
|
||||||
|
**URL**:`auth/access_token`
|
||||||
|
**Method**:`POST`
|
||||||
|
|
||||||
|
#### 请求参数
|
||||||
|
| 参数名 | 类型 | 必填 | 说明 |
|
||||||
|
|--------------|--------|------|----------|
|
||||||
|
| client_id | string | ✅ | 客户端唯一标识符 |
|
||||||
|
| access_token | string | ✅ | |
|
||||||
|
| ip | string | ✅ | |
|
||||||
|
| mask_len | int | ✅ | |
|
||||||
|
| hostname | string | ✅ | |
|
||||||
|
| mac | string | ✅ | |
|
||||||
|
| network_id | int | ✅ | |
|
||||||
|
|
||||||
|
#### 响应参数
|
||||||
|
|
||||||
|
#### 示例响应
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"result": "ok"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
#### 错误
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": {
|
||||||
|
"code": 1,
|
||||||
|
"message": "缺少必填参数:client_id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.set_node_status 服务端使用
|
||||||
|
|
||||||
|
**URL**:`set_node_status`
|
||||||
|
**Method**:`POST`
|
||||||
|
|
||||||
|
#### 请求参数
|
||||||
|
| 参数名 | 类型 | 必填 | 说明 |
|
||||||
|
|--------------|--------|------|----------|
|
||||||
|
| client_id | string | ✅ | 客户端唯一标识符 |
|
||||||
|
| access_token | string | ✅ | |
|
||||||
|
| status | int | ✅ | |
|
||||||
|
| network_id | int | ✅ | |
|
||||||
|
|
||||||
|
#### 响应参数
|
||||||
|
|
||||||
|
#### 示例响应
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"result": "ok"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
#### 错误
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": {
|
||||||
|
"code": 1,
|
||||||
|
"message": "缺少必填参数:client_id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.获取节点开放端口
|
||||||
|
|
||||||
|
**URL**:`/acl`
|
||||||
|
**Method**:`POST`
|
||||||
|
|
||||||
|
#### 请求参数
|
||||||
|
| 参数名 | 类型 | 必填 | 说明 |
|
||||||
|
|--------------|--------|------|---------------------------|
|
||||||
|
| client_id | string | ✅ | 客户端唯一标识符 |
|
||||||
|
| access_token | string | ✅ | |
|
||||||
|
| network_id | int | | 网络ID |
|
||||||
|
|
||||||
|
#### 响应参数
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|--------|-----------------------|
|
||||||
|
| code | int | 0 成功,其他失败 |
|
||||||
|
| message | string | 说明 |
|
||||||
|
| data | object | |
|
||||||
|
#### 示例响应
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"data": {
|
||||||
|
"tcp": [
|
||||||
|
80
|
||||||
|
],
|
||||||
|
"udp": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
777
Protocol.md
Normal file
777
Protocol.md
Normal file
@ -0,0 +1,777 @@
|
|||||||
|
# SDLAN 协议说明
|
||||||
|
|
||||||
|
本文档描述 Client 端和当前服务端的交互协议。当前控制面支持两种接入方式:
|
||||||
|
|
||||||
|
- QUIC 接入:实现位于 `src/quic`,主入口为 `sdlan_quic_server` 和 `sdlan_quic_transport`。
|
||||||
|
- SSL/TLS 接入:实现位于 `src/ssl`,主入口为 `sdlan_ssl_server` 和 `sdlan_ssl_transport`。
|
||||||
|
|
||||||
|
两种接入方式在传输层不同,但进入 `sdlan_session` 后使用同一套应用层包类型和 protobuf 消息。Client 完成控制面注册后,还需要通过 UDP STUN 服务上报 NAT 映射并承载节点之间的数据转发。
|
||||||
|
|
||||||
|
## 1. 加密说明
|
||||||
|
|
||||||
|
服务端在网络启动时为每个网络生成独立密钥,Client 通过 `RegisterSuper` 响应拿到当前网络的加密参数。
|
||||||
|
|
||||||
|
```text
|
||||||
|
algorithm:
|
||||||
|
aes AES-256
|
||||||
|
chacha20 ChaCha20
|
||||||
|
|
||||||
|
key:
|
||||||
|
长度 32 字节。
|
||||||
|
RegisterSuperAck.key 会使用客户端 RegisterSuper.pub_key 中的 RSA 公钥加密。
|
||||||
|
|
||||||
|
AES 参数:
|
||||||
|
blockMode: cbc
|
||||||
|
padding: pkcs7Padding
|
||||||
|
iv: key 的前 16 字节
|
||||||
|
|
||||||
|
ChaCha20 参数:
|
||||||
|
key: RegisterSuperAck.key 解密后的 32 字节密钥
|
||||||
|
region_id: RegisterSuperAck.region_id,用于客户端侧逻辑分区/nonce 生成
|
||||||
|
```
|
||||||
|
|
||||||
|
`SDLData.data` 字段承载隧道中的原始二层/三层数据,Client 需要按 `RegisterSuperAck.algorithm` 和 `key` 做加解密;包头中的 `network_id`、`src_mac`、`dst_mac`、`ttl`、`session_token`、`identity_id` 不参与该字段加密。
|
||||||
|
|
||||||
|
## 2. 通用应用层帧
|
||||||
|
|
||||||
|
QUIC 和 SSL/TLS 接入最终交给 `sdlan_session:handle_frame/2` 的应用层帧格式一致:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Frame = <<PacketType:8, ProtobufPayload/binary>>
|
||||||
|
```
|
||||||
|
|
||||||
|
- `PacketType`:1 字节包类型,取值见第 3 节。
|
||||||
|
- `ProtobufPayload`:protobuf 编码后的消息体。没有消息体的包只包含 `PacketType`。
|
||||||
|
- Event 和 Command 当前已经改为 protobuf `oneof` 结构,不再使用旧文档中的二级编码字节。
|
||||||
|
|
||||||
|
## 3. 包类型定义
|
||||||
|
|
||||||
|
包类型以 `include/sdlan.hrl` 为准。
|
||||||
|
|
||||||
|
| 宏 | 值 | 方向 | 消息 | 说明 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `PACKET_EMPTY` | `0x00` | 双向 | 无 | 空包,当前主流程未使用。 |
|
||||||
|
| `PACKET_REGISTER_SUPER` | `0x01` | Client -> Server | `SDLRegisterSuper` | 控制连接注册,必须在收到 Welcome 后发送。 |
|
||||||
|
| `PACKET_REGISTER_SUPER_ACK` | `0x02` | Server -> Client | `SDLRegisterSuperAck` | 注册成功,返回网络加密参数和 `session_token`。 |
|
||||||
|
| `PACKET_REGISTER_SUPER_NAK` | `0x04` | Server -> Client | `SDLRegisterSuperNak` | 注册失败,返回错误码和错误消息,随后连接关闭。 |
|
||||||
|
| `PACKET_UNREGISTER` | `0x05` | Client -> Server | 无 | 主动注销当前控制连接,服务端清理网络绑定后关闭连接。 |
|
||||||
|
| `PACKET_QUERY_INFO` | `0x06` | Client -> Server | `SDLQueryInfo` | 查询目标 MAC 的 NAT/IPv6 信息,用于发起 P2P 打洞。 |
|
||||||
|
| `PACKET_PEER_INFO` | `0x07` | Server -> Client | `SDLPeerInfo` | `QUERY_INFO` 的响应。找不到目标时返回空 `v4_info/v6_info`。 |
|
||||||
|
| `PACKET_PING` | `0x08` | Client -> Server | 无 | 控制连接心跳。 |
|
||||||
|
| `PACKET_PONG` | `0x09` | Server -> Client | 无 | 心跳响应。 |
|
||||||
|
| `PACKET_EVENT` | `0x10` | Server -> Client | `SDLEvent` | 服务端主动事件推送,Client 不需要 Ack。 |
|
||||||
|
| `PACKET_COMMAND` | `0x11` | Server -> Client | `SDLCommand` | 服务端主动命令推送,需要 Client 回 `COMMAND_ACK`。 |
|
||||||
|
| `PACKET_COMMAND_ACK` | `0x12` | Client -> Server | `SDLCommandAck` | 命令处理结果,`pkt_id` 必须等于 `SDLCommand.pkt_id`。 |
|
||||||
|
| `PACKET_FLOW_TRACER` | `0x15` | Client -> Server | 历史保留 | `sdlan_session` 当前未处理该包。 |
|
||||||
|
| `PACKET_REGISTER` | `0x20` | Client <-> Client | `SDLRegister` | 节点之间 UDP 打洞握手请求。 |
|
||||||
|
| `PACKET_REGISTER_ACK` | `0x21` | Client <-> Client | `SDLRegisterAck` | 节点之间 UDP 打洞握手响应。 |
|
||||||
|
| `PACKET_STUN_REQUEST` | `0x30` | Client -> STUN | `SDLStunRequest` | 周期上报 NAT 映射和 IPv6 信息,同时维持 NAT 映射。 |
|
||||||
|
| `PACKET_STUN_REPLY` | `0x31` | STUN -> Client | `SDLStunReply` | STUN 心跳响应。 |
|
||||||
|
| `PACKET_STUN_PROBE` | `0x32` | Client -> STUN | `SDLStunProbe` | NAT 类型探测请求。 |
|
||||||
|
| `PACKET_STUN_PROBE_REPLY` | `0x33` | STUN -> Client | `SDLStunProbeReply` | NAT 类型探测响应,返回服务端看到的公网 IP/端口。 |
|
||||||
|
| `PACKET_STUN_PROBE_RELAY` | `0x3a` | STUN 内部 | 内部二进制 | STUN 辅助节点内部转发探测响应,Client 不直接使用。 |
|
||||||
|
| `PACKET_WELCOME` | `0x4f` | Server -> Client | `SDLWelcome` | 控制连接建立后服务端首先下发的欢迎包。 |
|
||||||
|
| `PACKET_ARP_REQUEST` | `0x50` | Client -> Server | `SDLArpRequest` | 查询虚拟网络 IP 对应的 MAC。 |
|
||||||
|
| `PACKET_ARP_RESPONSE` | `0x51` | Server -> Client | `SDLArpResponse` | ARP 查询响应。 |
|
||||||
|
| `PACKET_POLICY_REQUEST` | `0xb0` | Client -> Server | `SDLPolicyRequest` | 查询源身份到目标身份的访问规则。 |
|
||||||
|
| `PACKET_POLICY_REPLY` | `0xb1` | Server -> Client | `SDLPolicyResponse` | 权限规则响应。 |
|
||||||
|
| `PACKET_EXPOSED_SERVICE_REQUEST` | `0xb2` | Client -> Server | `SDLExposedServiceRequest` | 查询当前节点暴露服务端口列表。 |
|
||||||
|
| `PACKET_EXPOSED_SERVICE_RESPONSE` | `0xb3` | Server -> Client | `SDLExposedServiceResponse` | 暴露服务端口响应。 |
|
||||||
|
| `PACKET_STUN_DATA` | `0xff` | Client <-> STUN/Client | `SDLData` | UDP 数据包,支持服务端转发和 P2P 直连。 |
|
||||||
|
|
||||||
|
STUN 探测属性同样定义在 `include/sdlan.hrl`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
STUN_ATTR_CHANGE_NONE = 0 使用收到请求的同一个 socket 响应
|
||||||
|
STUN_ATTR_CHANGE_PORT = 1 通过 stun_peer_assist 路径转发响应,通常用于测试变化 IP/端口路径
|
||||||
|
STUN_ATTR_CHANGE_PEER = 2 通过 stun_port_assist 路径响应,通常用于测试变化端口路径
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Protobuf 消息
|
||||||
|
|
||||||
|
protobuf 定义以 `proto/sdlan.proto` 为准,Erlang 生成文件为 `src/sdlan_pb.erl` 和 `include/sdlan_pb.hrl`。
|
||||||
|
|
||||||
|
### 4.1 公共类型
|
||||||
|
|
||||||
|
```protobuf
|
||||||
|
message SDLV4Info {
|
||||||
|
uint32 port = 1;
|
||||||
|
bytes v4 = 2;
|
||||||
|
uint32 nat_type = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SDLV6Info {
|
||||||
|
uint32 port = 1;
|
||||||
|
bytes v6 = 2;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `SDLV4Info.port`:服务端观察到的客户端 NAT 端口。
|
||||||
|
- `SDLV4Info.v4`:4 字节 IPv4 地址,网络字节序。
|
||||||
|
- `SDLV4Info.nat_type`:Client 上报的 NAT 类型。
|
||||||
|
- `SDLV6Info.port`:IPv6 辅助通道端口。
|
||||||
|
- `SDLV6Info.v6`:16 字节 IPv6 地址。
|
||||||
|
|
||||||
|
### 4.2 NAT 类型约定
|
||||||
|
|
||||||
|
```text
|
||||||
|
0 blocked 网络不可达
|
||||||
|
1 noNat 当前设备在公网地址下
|
||||||
|
2 fullCone Full Cone NAT
|
||||||
|
3 portRestricted Port Restricted Cone NAT
|
||||||
|
4 coneRestricted Restricted Cone NAT
|
||||||
|
5 symmetric Symmetric NAT
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. QUIC 接入
|
||||||
|
|
||||||
|
QUIC 接入代码位于 `src/quic`。服务端监听配置来自 `quic_server`:
|
||||||
|
|
||||||
|
- 默认端口:`443`
|
||||||
|
- ALPN:`punchnet/1.0`
|
||||||
|
- 服务端允许 1 条双向 stream:`peer_bidi_stream_count => 1`
|
||||||
|
- 默认最大应用包:`max_packet_size = 16384`
|
||||||
|
- 默认心跳间隔:`heartbeat_sec = 15`
|
||||||
|
|
||||||
|
### 5.1 QUIC 传输帧
|
||||||
|
|
||||||
|
QUIC 在单条双向 stream 上承载控制面消息。stream 上的实际传输格式为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
QuicStreamData = <<Len:16, Frame:Len/binary>>
|
||||||
|
Frame = <<PacketType:8, ProtobufPayload/binary>>
|
||||||
|
```
|
||||||
|
|
||||||
|
- `Len`:2 字节无符号长度,表示后续 `Frame` 字节数,不包含 `Len` 自身。
|
||||||
|
- `Frame`:第 2 节定义的通用应用层帧。
|
||||||
|
- 服务端接收时支持一次 QUIC data 中包含多个完整 frame,也支持半包累积。
|
||||||
|
- 当 `Len > max_packet_size` 时,服务端关闭连接,原因是 `frame_too_large`。
|
||||||
|
|
||||||
|
### 5.2 QUIC 连接建立流程
|
||||||
|
|
||||||
|
1. Client 与服务端完成 QUIC/TLS 握手,ALPN 必须匹配 `punchnet/1.0`。
|
||||||
|
2. Client 打开一条双向 stream。
|
||||||
|
3. 服务端接受第一条 stream 后立即发送 `PACKET_WELCOME`。
|
||||||
|
4. Client 收到 Welcome 后发送 `PACKET_REGISTER_SUPER`。
|
||||||
|
5. 注册成功后服务端返回 `PACKET_REGISTER_SUPER_ACK` 并进入 `registered` 状态。
|
||||||
|
6. 注册失败时服务端返回 `PACKET_REGISTER_SUPER_NAK`,随后关闭连接。
|
||||||
|
7. 进入 `registered` 后,Client 必须按 Welcome 中的 `heartbeat_sec` 周期发送 `PACKET_PING`。
|
||||||
|
|
||||||
|
### 5.3 QUIC Welcome
|
||||||
|
|
||||||
|
```text
|
||||||
|
Server -> Client
|
||||||
|
<<Len:16, 0x4f:8, SDLWelcome/binary>>
|
||||||
|
```
|
||||||
|
|
||||||
|
```protobuf
|
||||||
|
message SDLWelcome {
|
||||||
|
uint32 version = 1;
|
||||||
|
uint32 max_bidi_streams = 2;
|
||||||
|
uint32 max_packet_size = 3;
|
||||||
|
uint32 heartbeat_sec = 4;
|
||||||
|
SDLV6Info ipv6_assist = 5;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `version`:协议版本,当前为 `1`。
|
||||||
|
- `max_bidi_streams`:服务端允许的双向 stream 数,当前为 `1`。
|
||||||
|
- `max_packet_size`:单个应用层 frame 最大长度。
|
||||||
|
- `heartbeat_sec`:心跳间隔,Client 应以小于或等于该值的周期发送 Ping。
|
||||||
|
- `ipv6_assist`:IPv6 辅助器地址。未配置时为空。
|
||||||
|
|
||||||
|
### 5.4 QUIC RegisterSuper
|
||||||
|
|
||||||
|
```text
|
||||||
|
Client -> Server
|
||||||
|
<<Len:16, 0x01:8, SDLRegisterSuper/binary>>
|
||||||
|
```
|
||||||
|
|
||||||
|
```protobuf
|
||||||
|
message SDLRegisterSuper {
|
||||||
|
string client_id = 1;
|
||||||
|
uint32 network_id = 2;
|
||||||
|
bytes mac = 3;
|
||||||
|
uint32 ip = 4;
|
||||||
|
uint32 mask_len = 5;
|
||||||
|
string hostname = 6;
|
||||||
|
string pub_key = 7;
|
||||||
|
string access_token = 8;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `client_id`:客户端节点 ID,不能为空。
|
||||||
|
- `network_id`:客户端要加入的网络 ID。
|
||||||
|
- `mac`:虚拟网卡 MAC,不能为空,且不能是广播或组播 MAC。
|
||||||
|
- `ip`:HTTP 接口已分配的虚拟网络 IPv4,按 32 位整数传输。
|
||||||
|
- `mask_len`:虚拟网络掩码长度。
|
||||||
|
- `hostname`:客户端主机名,服务端用于维护域名映射。
|
||||||
|
- `pub_key`:客户端 RSA 公钥 PEM,服务端用它加密网络密钥。
|
||||||
|
- `access_token`:客户端通过 HTTP 登录或令牌换取的访问凭证,服务端注册时调用 API 校验。
|
||||||
|
|
||||||
|
注册成功:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Server -> Client
|
||||||
|
<<Len:16, 0x02:8, SDLRegisterSuperAck/binary>>
|
||||||
|
```
|
||||||
|
|
||||||
|
```protobuf
|
||||||
|
message SDLRegisterSuperAck {
|
||||||
|
string algorithm = 1;
|
||||||
|
bytes key = 2;
|
||||||
|
uint32 region_id = 3;
|
||||||
|
bytes session_token = 4;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `algorithm`:当前网络加密算法,取值为 `aes` 或 `chacha20`。
|
||||||
|
- `key`:使用 `pub_key` 加密后的网络密钥,Client 需要用私钥解密。
|
||||||
|
- `region_id`:服务端根据虚拟 IP 生成的逻辑分区 ID。
|
||||||
|
- `session_token`:本次控制会话 token。后续 UDP `SDLStunRequest` 和 `SDLData` 必须携带,用于校验端和网络绑定关系。
|
||||||
|
|
||||||
|
注册失败:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Server -> Client
|
||||||
|
<<Len:16, 0x04:8, SDLRegisterSuperNak/binary>>
|
||||||
|
```
|
||||||
|
|
||||||
|
```protobuf
|
||||||
|
message SDLRegisterSuperNak {
|
||||||
|
uint32 error_code = 1;
|
||||||
|
string error_message = 2;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `error_code`:错误码。网络/API 错误当前使用 `4`,服务内部错误当前使用 `5`,API 返回业务错误时透传 API code。
|
||||||
|
- `error_message`:错误描述。服务端发送 NAK 后关闭连接。
|
||||||
|
|
||||||
|
### 5.5 QUIC 心跳
|
||||||
|
|
||||||
|
```text
|
||||||
|
Client -> Server
|
||||||
|
<<Len:16, 0x08:8>>
|
||||||
|
|
||||||
|
Server -> Client
|
||||||
|
<<Len:16, 0x09:8>>
|
||||||
|
```
|
||||||
|
|
||||||
|
- Client 只在注册成功后发送 Ping。
|
||||||
|
- 服务端收到 Ping 后立即回复 Pong,并记录本轮心跳已收到。
|
||||||
|
- 服务端按 `heartbeat_sec` 检查上一周期是否收到过 Ping;如果没有收到,关闭连接。
|
||||||
|
|
||||||
|
### 5.6 QUIC 查询 PeerInfo
|
||||||
|
|
||||||
|
```text
|
||||||
|
Client -> Server
|
||||||
|
<<Len:16, 0x06:8, SDLQueryInfo/binary>>
|
||||||
|
```
|
||||||
|
|
||||||
|
```protobuf
|
||||||
|
message SDLQueryInfo {
|
||||||
|
bytes dst_mac = 1;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `dst_mac`:目标节点 MAC。
|
||||||
|
- 服务端查找目标节点的 NAT 映射和 IPv6 信息。
|
||||||
|
- 如果目标存在,服务端同时会向目标节点推送 `SDLEvent.SendRegister`,让目标主动向查询方发送打洞包,提高 P2P 成功率。
|
||||||
|
|
||||||
|
响应:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Server -> Client
|
||||||
|
<<Len:16, 0x07:8, SDLPeerInfo/binary>>
|
||||||
|
```
|
||||||
|
|
||||||
|
```protobuf
|
||||||
|
message SDLPeerInfo {
|
||||||
|
bytes dst_mac = 1;
|
||||||
|
optional SDLV4Info v4_info = 2;
|
||||||
|
optional SDLV6Info v6_info = 3;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `dst_mac`:原样返回查询的目标 MAC。
|
||||||
|
- `v4_info`:目标节点最近一次 `STUN_REQUEST` 上报形成的公网 IPv4/端口和 NAT 类型。
|
||||||
|
- `v6_info`:目标节点上报的 IPv6 辅助信息。没有时为空。
|
||||||
|
- 找不到目标或目标没有可用 NAT 信息时,服务端仍返回 `SDLPeerInfo`,但 `v4_info` 和 `v6_info` 为空。
|
||||||
|
|
||||||
|
### 5.7 QUIC ARP 查询
|
||||||
|
|
||||||
|
```text
|
||||||
|
Client -> Server
|
||||||
|
<<Len:16, 0x50:8, SDLArpRequest/binary>>
|
||||||
|
```
|
||||||
|
|
||||||
|
```protobuf
|
||||||
|
message SDLArpRequest {
|
||||||
|
uint32 target_ip = 1;
|
||||||
|
uint32 origin_ip = 2;
|
||||||
|
bytes context = 3;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `target_ip`:要查询 MAC 的虚拟网络 IPv4。
|
||||||
|
- `origin_ip`:发起查询的虚拟网络 IPv4。
|
||||||
|
- `context`:Client 自定义上下文,服务端原样回写,方便客户端匹配本地 ARP 请求。
|
||||||
|
|
||||||
|
响应:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Server -> Client
|
||||||
|
<<Len:16, 0x51:8, SDLArpResponse/binary>>
|
||||||
|
```
|
||||||
|
|
||||||
|
```protobuf
|
||||||
|
message SDLArpResponse {
|
||||||
|
uint32 target_ip = 1;
|
||||||
|
bytes target_mac = 2;
|
||||||
|
uint32 origin_ip = 3;
|
||||||
|
bytes context = 4;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `target_mac`:查询成功时为目标 MAC;查询失败时为空字节串。
|
||||||
|
- 其它字段与请求一致或对应请求上下文。
|
||||||
|
|
||||||
|
### 5.8 QUIC Policy 查询
|
||||||
|
|
||||||
|
```text
|
||||||
|
Client -> Server
|
||||||
|
<<Len:16, 0xb0:8, SDLPolicyRequest/binary>>
|
||||||
|
```
|
||||||
|
|
||||||
|
```protobuf
|
||||||
|
message SDLPolicyRequest {
|
||||||
|
uint32 src_identity_id = 1;
|
||||||
|
uint32 dst_identity_id = 2;
|
||||||
|
uint32 version = 3;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `src_identity_id`:源端身份 ID。
|
||||||
|
- `dst_identity_id`:目标端身份 ID。
|
||||||
|
- `version`:Client 侧规则版本,服务端原样回写,Client 用于判断是否覆盖本地缓存。
|
||||||
|
|
||||||
|
响应:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Server -> Client
|
||||||
|
<<Len:16, 0xb1:8, SDLPolicyResponse/binary>>
|
||||||
|
```
|
||||||
|
|
||||||
|
```protobuf
|
||||||
|
message SDLPolicyResponse {
|
||||||
|
uint32 src_identity_id = 1;
|
||||||
|
uint32 dst_identity_id = 2;
|
||||||
|
uint32 version = 3;
|
||||||
|
bytes rules = 4;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `rules`:稀疏序列化规则列表,每条规则格式为 `<<Proto:8, Port:16>>`。
|
||||||
|
- `Proto`:协议号,例如 TCP/UDP 对应的协议编号。
|
||||||
|
- `Port`:允许访问的目标端口。
|
||||||
|
- 服务端只下发 allow 规则,deny 规则在服务端侧已过滤。
|
||||||
|
- 如果请求解析或规则查询失败,当前服务端可能不返回响应包。
|
||||||
|
|
||||||
|
### 5.9 QUIC ExposedService 查询
|
||||||
|
|
||||||
|
```text
|
||||||
|
Client -> Server
|
||||||
|
<<Len:16, 0xb2:8, SDLExposedServiceRequest/binary>>
|
||||||
|
```
|
||||||
|
|
||||||
|
```protobuf
|
||||||
|
message SDLExposedServiceRequest {
|
||||||
|
uint32 version = 1;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `version`:Client 当前缓存版本,服务端响应中原样返回。
|
||||||
|
|
||||||
|
响应:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Server -> Client
|
||||||
|
<<Len:16, 0xb3:8, SDLExposedServiceResponse/binary>>
|
||||||
|
```
|
||||||
|
|
||||||
|
```protobuf
|
||||||
|
message SDLExposedServiceResponse {
|
||||||
|
uint32 version = 1;
|
||||||
|
repeated uint32 tcp_ports = 2;
|
||||||
|
repeated uint32 udp_ports = 3;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `tcp_ports`:当前节点允许暴露的 TCP 端口列表。
|
||||||
|
- `udp_ports`:当前节点允许暴露的 UDP 端口列表。
|
||||||
|
- 服务端只返回 `1..65534` 范围内的端口。
|
||||||
|
- API 查询失败时当前服务端可能不返回响应包。
|
||||||
|
|
||||||
|
### 5.10 QUIC Event 推送
|
||||||
|
|
||||||
|
```text
|
||||||
|
Server -> Client
|
||||||
|
<<Len:16, 0x10:8, SDLEvent/binary>>
|
||||||
|
```
|
||||||
|
|
||||||
|
```protobuf
|
||||||
|
message SDLEvent {
|
||||||
|
message NatChanged {
|
||||||
|
bytes mac = 1;
|
||||||
|
uint32 ip = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SendRegister {
|
||||||
|
bytes dst_mac = 1;
|
||||||
|
uint32 nat_ip = 2;
|
||||||
|
uint32 nat_port = 3;
|
||||||
|
uint32 nat_type = 4;
|
||||||
|
optional SDLV6Info v6_info = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ExposedServiceChanged {
|
||||||
|
}
|
||||||
|
|
||||||
|
message NetworkShutdown {
|
||||||
|
string message = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
oneof event {
|
||||||
|
NatChanged nat_changed = 1;
|
||||||
|
SendRegister send_register = 2;
|
||||||
|
NetworkShutdown shutdown = 3;
|
||||||
|
ExposedServiceChanged exposed_service_changed = 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `nat_changed`:某个 MAC 的虚拟 IP 或 NAT 映射发生变化。Client 应清理对应 peer 缓存、ARP 缓存或重新查询 peer 信息。
|
||||||
|
- `send_register`:服务端要求当前 Client 向 `dst_mac` 对应节点发送 UDP `PACKET_REGISTER` 打洞请求。`nat_ip/nat_port/nat_type/v6_info` 是目标可达地址信息。
|
||||||
|
- `exposed_service_changed`:当前节点暴露服务配置变化。Client 应重新发送 `PACKET_EXPOSED_SERVICE_REQUEST` 获取端口列表。
|
||||||
|
- `shutdown`:网络关闭。Client 应停止当前网络会话并释放本地资源。
|
||||||
|
- Event 不需要 Client 回复 Ack。
|
||||||
|
|
||||||
|
### 5.11 QUIC Command 下发和 ACK
|
||||||
|
|
||||||
|
```text
|
||||||
|
Server -> Client
|
||||||
|
<<Len:16, 0x11:8, SDLCommand/binary>>
|
||||||
|
```
|
||||||
|
|
||||||
|
```protobuf
|
||||||
|
message SDLCommand {
|
||||||
|
uint32 pkt_id = 1;
|
||||||
|
|
||||||
|
message ExitNodeControl {
|
||||||
|
int32 action = 1;
|
||||||
|
string remark = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
oneof command {
|
||||||
|
ExitNodeControl exit_node = 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `pkt_id`:服务端生成的命令序号,Client 回复时必须原样带回。
|
||||||
|
- `exit_node.action`:出口节点控制动作,具体动作值由业务层约定。
|
||||||
|
- `exit_node.remark`:操作备注或调试说明。
|
||||||
|
|
||||||
|
Client 处理完成后回复:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Client -> Server
|
||||||
|
<<Len:16, 0x12:8, SDLCommandAck/binary>>
|
||||||
|
```
|
||||||
|
|
||||||
|
```protobuf
|
||||||
|
message SDLCommandAck {
|
||||||
|
uint32 pkt_id = 1;
|
||||||
|
int32 code = 2;
|
||||||
|
string message = 3;
|
||||||
|
bytes data = 4;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `pkt_id`:必须与收到的 `SDLCommand.pkt_id` 一致。
|
||||||
|
- `code`:处理结果码,建议 `0` 表示成功,非 `0` 表示失败。
|
||||||
|
- `message`:处理结果说明。
|
||||||
|
- `data`:可选业务数据。
|
||||||
|
- 服务端收到 ACK 后,会把结果转发给等待该命令响应的内部调用方。
|
||||||
|
|
||||||
|
### 5.12 QUIC Unregister
|
||||||
|
|
||||||
|
```text
|
||||||
|
Client -> Server
|
||||||
|
<<Len:16, 0x05:8>>
|
||||||
|
```
|
||||||
|
|
||||||
|
- Client 主动退出网络时发送。
|
||||||
|
- 服务端收到后调用 `sdlan_network:unregister/3` 清理当前 `client_id + mac` 的绑定,并关闭控制连接。
|
||||||
|
|
||||||
|
## 6. SSL/TLS 接入
|
||||||
|
|
||||||
|
SSL/TLS 接入代码位于 `src/ssl`。服务端通过 Ranch SSL 监听,配置来自 `ssl_server`:
|
||||||
|
|
||||||
|
- 默认端口:`1443`
|
||||||
|
- ALPN:`punchnet/1.0`
|
||||||
|
- TLS 版本:`tlsv1.3`、`tlsv1.2`
|
||||||
|
- 默认最大应用包:`max_packet_size = 16384`
|
||||||
|
- 默认心跳间隔:`heartbeat_sec = 15`
|
||||||
|
|
||||||
|
### 6.1 SSL/TLS 传输帧
|
||||||
|
|
||||||
|
SSL/TLS 接入使用 Erlang socket `{packet, 2}` 分包。对非 Erlang Client 来说,线上格式仍然是:
|
||||||
|
|
||||||
|
```text
|
||||||
|
TlsStreamData = <<Len:16, Frame:Len/binary>>
|
||||||
|
Frame = <<PacketType:8, ProtobufPayload/binary>>
|
||||||
|
```
|
||||||
|
|
||||||
|
- `Len`:2 字节无符号长度,表示后续 `Frame` 字节数,不包含 `Len` 自身。
|
||||||
|
- 服务端 `ssl` 层会自动剥离 `Len` 后把 `Frame` 交给 `sdlan_session`。
|
||||||
|
- 服务端发送时也依赖 `{packet, 2}` 自动加长度前缀。
|
||||||
|
- 单个发送包长度必须不超过 `65535`,业务上应遵守 Welcome 中的 `max_packet_size`。
|
||||||
|
|
||||||
|
### 6.2 SSL/TLS 连接建立流程
|
||||||
|
|
||||||
|
1. Client 与服务端建立 TCP 连接。
|
||||||
|
2. Client 完成 TLS 握手,ALPN 使用 `punchnet/1.0`。
|
||||||
|
3. 服务端 TLS 握手成功后立即发送 `PACKET_WELCOME`。
|
||||||
|
4. Client 收到 Welcome 后发送 `PACKET_REGISTER_SUPER`。
|
||||||
|
5. 注册成功后服务端返回 `PACKET_REGISTER_SUPER_ACK` 并进入 `registered` 状态。
|
||||||
|
6. 注册失败时服务端返回 `PACKET_REGISTER_SUPER_NAK`,随后关闭连接。
|
||||||
|
7. 进入 `registered` 后,Client 必须按 Welcome 中的 `heartbeat_sec` 周期发送 `PACKET_PING`。
|
||||||
|
|
||||||
|
### 6.3 SSL/TLS 消息交互
|
||||||
|
|
||||||
|
SSL/TLS 接入的应用层消息与 QUIC 完全一致,区别只在传输层:
|
||||||
|
|
||||||
|
- Welcome:见 `5.3`,包类型 `0x4f`。
|
||||||
|
- RegisterSuper / RegisterSuperAck / RegisterSuperNak:见 `5.4`,包类型 `0x01/0x02/0x04`。
|
||||||
|
- Ping / Pong:见 `5.5`,包类型 `0x08/0x09`。
|
||||||
|
- QueryInfo / PeerInfo:见 `5.6`,包类型 `0x06/0x07`。
|
||||||
|
- ARP 查询:见 `5.7`,包类型 `0x50/0x51`。
|
||||||
|
- Policy 查询:见 `5.8`,包类型 `0xb0/0xb1`。
|
||||||
|
- ExposedService 查询:见 `5.9`,包类型 `0xb2/0xb3`。
|
||||||
|
- Event 推送:见 `5.10`,包类型 `0x10`。
|
||||||
|
- Command / CommandAck:见 `5.11`,包类型 `0x11/0x12`。
|
||||||
|
- Unregister:见 `5.12`,包类型 `0x05`。
|
||||||
|
|
||||||
|
Client 如果同时支持 QUIC 和 SSL/TLS,建议抽象出同一个应用层 `Frame` 编解码模块,仅替换底层连接、握手和 stream/socket 读写逻辑。
|
||||||
|
|
||||||
|
## 7. UDP STUN 和数据转发
|
||||||
|
|
||||||
|
UDP 逻辑不属于 `src/quic` 或 `src/ssl`,但它依赖控制面注册返回的 `session_token` 和网络密钥,是 Client 完整接入流程的一部分。相关服务端实现位于:
|
||||||
|
|
||||||
|
- `src/sdlan_stun.erl`
|
||||||
|
- `src/sdlan_stun_port_assist.erl`
|
||||||
|
- `src/sdlan_stun_peer_assist.erl`
|
||||||
|
|
||||||
|
UDP 包没有 2 字节长度前缀:
|
||||||
|
|
||||||
|
```text
|
||||||
|
UdpPacket = <<PacketType:8, ProtobufPayload/binary>>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.1 STUN Request
|
||||||
|
|
||||||
|
```text
|
||||||
|
Client -> STUN
|
||||||
|
<<0x30:8, SDLStunRequest/binary>>
|
||||||
|
```
|
||||||
|
|
||||||
|
```protobuf
|
||||||
|
message SDLStunRequest {
|
||||||
|
string client_id = 1;
|
||||||
|
uint32 network_id = 2;
|
||||||
|
bytes mac = 3;
|
||||||
|
uint32 ip = 4;
|
||||||
|
uint32 nat_type = 5;
|
||||||
|
optional SDLV6Info v6_info = 6;
|
||||||
|
bytes session_token = 7;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `client_id/network_id/mac/ip`:必须与控制面 `RegisterSuper` 一致。
|
||||||
|
- `nat_type`:Client 通过 `STUN_PROBE` 判断出的 NAT 类型。
|
||||||
|
- `v6_info`:Client 可用的 IPv6 辅助信息,没有时为空。
|
||||||
|
- `session_token`:控制面 `RegisterSuperAck.session_token`,服务端用它校验当前 Client 的 NAT 上报。
|
||||||
|
- 服务端以 UDP 源地址和源端口作为该节点的最新 NAT 映射,并通知其它节点 `nat_changed`。
|
||||||
|
|
||||||
|
响应:
|
||||||
|
|
||||||
|
```text
|
||||||
|
STUN -> Client
|
||||||
|
<<0x31:8, SDLStunReply/binary>>
|
||||||
|
```
|
||||||
|
|
||||||
|
```protobuf
|
||||||
|
message SDLStunReply {
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 空消息体,仅表示服务端已收到并处理 STUN 心跳。
|
||||||
|
- Client 需要周期性发送该包维持 NAT 映射,建议周期小于 NAT 超时时间。
|
||||||
|
|
||||||
|
### 7.2 STUN Probe
|
||||||
|
|
||||||
|
```text
|
||||||
|
Client -> STUN
|
||||||
|
<<0x32:8, SDLStunProbe/binary>>
|
||||||
|
```
|
||||||
|
|
||||||
|
```protobuf
|
||||||
|
message SDLStunProbe {
|
||||||
|
uint32 cookie = 1;
|
||||||
|
uint32 attr = 2;
|
||||||
|
uint32 step = 3;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `cookie`:Client 生成的随机值,用于匹配响应。
|
||||||
|
- `attr`:探测属性,取值见第 3 节 `STUN_ATTR_*`。
|
||||||
|
- `step`:Client 侧探测步骤编号。当前 `sdlan_stun` 主响应代码未写回该字段,响应里通常为 protobuf 默认值 `0`,Client 需要兼容。
|
||||||
|
|
||||||
|
响应:
|
||||||
|
|
||||||
|
```text
|
||||||
|
STUN -> Client
|
||||||
|
<<0x33:8, SDLStunProbeReply/binary>>
|
||||||
|
```
|
||||||
|
|
||||||
|
```protobuf
|
||||||
|
message SDLStunProbeReply {
|
||||||
|
uint32 cookie = 1;
|
||||||
|
uint32 step = 2;
|
||||||
|
uint32 port = 3;
|
||||||
|
uint32 ip = 4;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `cookie`:原样返回请求中的 `cookie`。
|
||||||
|
- `step`:当前实现通常为默认值 `0`。
|
||||||
|
- `port`:服务端看到的 Client 公网 UDP 源端口。
|
||||||
|
- `ip`:服务端看到的 Client 公网 IPv4,按 32 位整数传输。
|
||||||
|
|
||||||
|
典型 NAT 判断流程:
|
||||||
|
|
||||||
|
1. 向主 STUN 地址发送 `attr = 0`,拿到公网地址 A。
|
||||||
|
2. 如果公网地址 A 等于本地 UDP 地址,则为 `noNat`。
|
||||||
|
3. 向另一组 STUN 地址发送 `attr = 0`,拿到公网地址 B。
|
||||||
|
4. 如果 A 和 B 的 IP 不同,则倾向判断为 `symmetric`。
|
||||||
|
5. 向主 STUN 地址发送 `attr = 1`,如果能收到辅助 peer 路径响应,则说明 NAT 对来源 IP/端口限制较少。
|
||||||
|
6. 向主 STUN 地址发送 `attr = 2`,如果能收到辅助端口路径响应,则说明 NAT 对来源端口限制较少;如果收不到则按端口限制型或降级策略处理。
|
||||||
|
7. 任何关键步骤无响应时,可按 `blocked` 或降级策略处理。
|
||||||
|
|
||||||
|
### 7.3 Client 间 UDP 打洞
|
||||||
|
|
||||||
|
当 Client 通过 `QUERY_INFO` 或 `send_register` 事件拿到对端 NAT 地址后,向对端 UDP 地址发送:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Client A -> Client B
|
||||||
|
<<0x20:8, SDLRegister/binary>>
|
||||||
|
```
|
||||||
|
|
||||||
|
```protobuf
|
||||||
|
message SDLRegister {
|
||||||
|
uint32 network_id = 1;
|
||||||
|
bytes src_mac = 2;
|
||||||
|
bytes dst_mac = 3;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `network_id`:当前虚拟网络 ID。
|
||||||
|
- `src_mac`:发送方 MAC。
|
||||||
|
- `dst_mac`:目标方 MAC。
|
||||||
|
- 该包用于在双方 NAT 设备上建立映射,不经过控制连接。
|
||||||
|
|
||||||
|
对端收到后回复:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Client B -> Client A
|
||||||
|
<<0x21:8, SDLRegisterAck/binary>>
|
||||||
|
```
|
||||||
|
|
||||||
|
```protobuf
|
||||||
|
message SDLRegisterAck {
|
||||||
|
uint32 network_id = 1;
|
||||||
|
bytes src_mac = 2;
|
||||||
|
bytes dst_mac = 3;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `src_mac`:ACK 发送方 MAC。
|
||||||
|
- `dst_mac`:ACK 目标方 MAC。
|
||||||
|
- 收到 ACK 后,Client 可以优先使用 P2P 直连发送 `SDLData`。
|
||||||
|
|
||||||
|
### 7.4 UDP 数据包
|
||||||
|
|
||||||
|
```text
|
||||||
|
Client -> STUN/Peer
|
||||||
|
<<0xff:8, SDLData/binary>>
|
||||||
|
```
|
||||||
|
|
||||||
|
```protobuf
|
||||||
|
message SDLData {
|
||||||
|
uint32 network_id = 1;
|
||||||
|
bytes src_mac = 2;
|
||||||
|
bytes dst_mac = 3;
|
||||||
|
bool is_p2p = 4;
|
||||||
|
uint32 ttl = 5;
|
||||||
|
bytes data = 6;
|
||||||
|
bytes session_token = 7;
|
||||||
|
uint32 identity_id = 8;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `network_id`:当前虚拟网络 ID。
|
||||||
|
- `src_mac`:发送方 MAC。
|
||||||
|
- `dst_mac`:目标 MAC。广播或组播 MAC 会触发服务端向网络内其它在线节点广播转发。
|
||||||
|
- `is_p2p`:Client 直连发送时为 `true`;服务端转发时会改写为 `false`。
|
||||||
|
- `ttl`:转发跳数。STUN 服务端转发前会执行 `ttl - 1`。
|
||||||
|
- `data`:加密后的真实业务数据。
|
||||||
|
- `session_token`:控制面注册返回的会话 token。
|
||||||
|
- `identity_id`:源端身份 ID,对端用于本地权限判断。
|
||||||
|
|
||||||
|
服务端转发逻辑:
|
||||||
|
|
||||||
|
1. STUN 服务端收到 `PACKET_STUN_DATA` 后解析 `SDLData`。
|
||||||
|
2. 按 `network_id` 找到当前网络 ETS 表。
|
||||||
|
3. 校验 `src_mac` 当前在线且存在 endpoint。
|
||||||
|
4. 如果 `dst_mac` 是广播或组播,转发给除 `src_mac` 外所有有 NAT hole 的 endpoint。
|
||||||
|
5. 如果 `dst_mac` 是单播,查找目标 endpoint 的 NAT hole 并转发。
|
||||||
|
6. 转发前将 `ttl` 减 1,并把 `is_p2p` 改为 `false`。
|
||||||
|
7. 服务端按网络带宽限制做限流;限流或找不到目标时丢弃。
|
||||||
|
|
||||||
|
## 8. 推荐 Client 完整接入流程
|
||||||
|
|
||||||
|
1. 通过 HTTP/API 完成登录和网络/IP 分配,拿到 `access_token`、`network_id`、虚拟 IP、掩码、身份 ID 等信息。
|
||||||
|
2. 优先尝试 QUIC 接入;如果网络环境不支持 QUIC,可降级到 SSL/TLS 接入。
|
||||||
|
3. 完成控制连接握手并接收 `PACKET_WELCOME`。
|
||||||
|
4. 发送 `PACKET_REGISTER_SUPER`。
|
||||||
|
5. 收到 `PACKET_REGISTER_SUPER_ACK` 后,用私钥解密 `key`,保存 `algorithm`、`region_id`、`session_token`。
|
||||||
|
6. 启动控制连接心跳,按 `heartbeat_sec` 周期发送 `PACKET_PING` 并处理 `PACKET_PONG`。
|
||||||
|
7. 使用 `PACKET_STUN_PROBE` 判断 NAT 类型。
|
||||||
|
8. 周期性发送 `PACKET_STUN_REQUEST`,上报 NAT 类型、IPv6 信息和 `session_token`。
|
||||||
|
9. 有目标通信需求时,先使用 `PACKET_ARP_REQUEST` 或本地缓存确定目标 MAC。
|
||||||
|
10. 通过 `PACKET_POLICY_REQUEST` 查询访问策略,按 `rules` 在本地做放行判断。
|
||||||
|
11. 通过 `PACKET_QUERY_INFO` 查询目标 NAT 信息,并根据 `SDLEvent.SendRegister` 做双向 UDP 打洞。
|
||||||
|
12. P2P 打洞成功后优先直连发送 `PACKET_STUN_DATA`;失败时通过 STUN 服务端转发。
|
||||||
|
13. 处理服务端推送的 `SDLEvent` 和 `SDLCommand`。Command 必须回复 `SDLCommandAck`。
|
||||||
|
14. Client 主动退出时发送 `PACKET_UNREGISTER` 并关闭本地控制连接和 UDP 资源。
|
||||||
|
|
||||||
|
|
||||||
|
===端口说明====
|
||||||
|
组件 默认端口/配置 」 说明 」
|
||||||
|
QUIC | 443 | ALPN `punchnet/1.0` |控制面主入口,允许 1条双向 stream。
|
||||||
|
SSL/TLS | 1443 | ALPN `punchnet/1.0` |控制面备用入口,与 QUIC 共用`sdlan session`
|
||||||
|
STUN | 1365 | `acceptor_nums=5` |UDP_NAT 上报、探测和服务端转发
|
||||||
|
STUN port assist | 1366 |同节点变端 probe reply
|
||||||
|
STUN peer assist |配置的 `punchnet.aioe.tech:1366`|转发到另一辅助节点后回复。
|
||||||
|
IPv6 assist | 1367 |IPv6 可达性探测。
|
||||||
112
Sign.md
Normal file
112
Sign.md
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
# 接口签名算法说明
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
本接口使用 **HMAC-MD5** 算法对请求参数进行签名,以确保请求的合法性与完整性,防止请求被篡改或伪造。
|
||||||
|
生成的sign值放在header中,key=X-sign,value=加密值。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 签名生成流程
|
||||||
|
|
||||||
|
```
|
||||||
|
请求参数
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
① 参数递归排序(ksort)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
② 构造签名字符串(key=value&key=value...)
|
||||||
|
│ 数组/对象类型 → JSON 字符串
|
||||||
|
▼
|
||||||
|
③ HMAC-MD5(query_string, secret_key)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
sign(签名结果)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 详细步骤
|
||||||
|
|
||||||
|
### 第一步:参数递归排序
|
||||||
|
|
||||||
|
对所有请求参数按照**键名字典序(ksort)进行递归排序**,嵌套数组也需同样处理,以保证双方构造字符串的顺序一致。
|
||||||
|
|
||||||
|
### 第二步:构造签名字符串
|
||||||
|
|
||||||
|
遍历排序后的参数,按以下规则拼接为字符串:
|
||||||
|
|
||||||
|
| 参数类型 | 处理方式 |
|
||||||
|
| ------------------------ | -------------------------------------------------------- |
|
||||||
|
| 普通值(字符串、数字等) | 直接使用原始值 |
|
||||||
|
| 数组 / 对象 | 转为 JSON 字符串(中文不转义,`JSON_UNESCAPED_UNICODE`) |
|
||||||
|
|
||||||
|
拼接格式:
|
||||||
|
|
||||||
|
```
|
||||||
|
key1=value1&key2=value2&key3=value3
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ **注意**:参数值**不做 URL encode**,与 `http_build_query` 的行为不同。数组参数也**不展开**为 `key[0]=...` 形式,而是整体转为 JSON 字符串。
|
||||||
|
|
||||||
|
### 第三步:计算签名
|
||||||
|
|
||||||
|
使用 `secret_key` 对上一步得到的字符串执行 **HMAC-MD5** 计算:
|
||||||
|
|
||||||
|
```
|
||||||
|
sign = HMAC-MD5(query_string, secret_key)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 示例
|
||||||
|
|
||||||
|
### 原始请求参数
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"uid": 1001,
|
||||||
|
"amount": 100,
|
||||||
|
"currency": "CNY",
|
||||||
|
"items": [
|
||||||
|
{ "id": 1, "name": "商品A" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 第一步:排序后的参数顺序
|
||||||
|
|
||||||
|
```
|
||||||
|
amount, currency, items, uid
|
||||||
|
```
|
||||||
|
|
||||||
|
### 第二步:构造签名字符串
|
||||||
|
|
||||||
|
```
|
||||||
|
amount=100¤cy=CNY&items=[{"id":1,"name":"商品A"}]&uid=1001
|
||||||
|
```
|
||||||
|
|
||||||
|
### 第三步:计算签名
|
||||||
|
|
||||||
|
```
|
||||||
|
sign = HMAC-MD5("amount=100¤cy=CNY&items=[{\"id\":1,\"name\":\"商品A\"}]&uid=1001", "your_secret_key")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
| 项目 | 说明 |
|
||||||
|
| -------- | -------------------------------------------------------- |
|
||||||
|
| 排序方式 | 字典序递归排序,嵌套数组也要排序 |
|
||||||
|
| 数组参数 | 转为 JSON 字符串,中文不转义(`JSON_UNESCAPED_UNICODE`) |
|
||||||
|
| URL 编码 | 参数值**不做** URL encode |
|
||||||
|
| 数组展开 | **不展开**为 `key[0]=...` 形式 |
|
||||||
|
| 密钥管理 | `secret_key` 需双方提前约定,**严禁在请求中传输** |
|
||||||
|
| 算法 | HMAC-MD5,输出为 32 位十六进制小写字符串 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
密钥: H6p*2RfEu4ITcL
|
||||||
|
|
||||||
@ -90,12 +90,18 @@ dependencies {
|
|||||||
ksp("androidx.room:room-compiler:2.7.2")
|
ksp("androidx.room:room-compiler:2.7.2")
|
||||||
implementation("androidx.room:room-ktx:2.7.2")
|
implementation("androidx.room:room-ktx:2.7.2")
|
||||||
|
|
||||||
// implementation(libs.bcprov.jdk18on)
|
// 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.bcpkix.jdk18on)
|
implementation(libs.bcpkix.jdk18on)
|
||||||
// implementation(files("libs/org.asxalex.sdlan/sdlan-1.0-SNAPSHOT.jar"))
|
// 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(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")
|
||||||
|
}
|
||||||
|
|||||||
BIN
app/release/app-release.apk
Normal file
BIN
app/release/app-release.apk
Normal file
Binary file not shown.
BIN
app/release/baselineProfiles/0/app-release.dm
Normal file
BIN
app/release/baselineProfiles/0/app-release.dm
Normal file
Binary file not shown.
BIN
app/release/baselineProfiles/1/app-release.dm
Normal file
BIN
app/release/baselineProfiles/1/app-release.dm
Normal file
Binary file not shown.
37
app/release/output-metadata.json
Normal file
37
app/release/output-metadata.json
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
@ -5,17 +5,20 @@
|
|||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"/>
|
<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
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:fullBackupContent="@xml/backup_rules"
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@drawable/punchnet_log"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.Punchnet"
|
android:theme="@style/Theme.Punchnet"
|
||||||
|
android:usesCleartextTraffic="true"
|
||||||
tools:targetApi="31">
|
tools:targetApi="31">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
@ -30,7 +33,7 @@
|
|||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<service android:name=".PunchnetService"
|
<service android:name=".PunchnetService"
|
||||||
android:exported="false"
|
android:exported="true"
|
||||||
android:foregroundServiceType="specialUse"
|
android:foregroundServiceType="specialUse"
|
||||||
android:permission="android.permission.BIND_VPN_SERVICE">
|
android:permission="android.permission.BIND_VPN_SERVICE">
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
package com.jihe.punchnet
|
package com.jihe.punchnet
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.VpnService
|
import android.net.VpnService
|
||||||
@ -31,12 +32,21 @@ import androidx.compose.ui.tooling.preview.Preview
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import androidx.navigation.compose.rememberNavController
|
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.RouteViewModel
|
||||||
import com.jihe.punchnet.data.ServiceViewModel
|
import com.jihe.punchnet.data.ServiceViewModel
|
||||||
import com.jihe.punchnet.screen.MainApp
|
import com.jihe.punchnet.screen.MainApp
|
||||||
import com.jihe.punchnet.ui.theme.PunchnetTheme
|
import com.jihe.punchnet.ui.theme.PunchnetTheme
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
|
||||||
|
class PunchnetApp: Application() {
|
||||||
|
val buttonModel: ButtonViewModel by lazy {
|
||||||
|
ViewModelProvider.AndroidViewModelFactory.getInstance(this)
|
||||||
|
.create(ButtonViewModel::class.java)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
private val TAG = "MainActivity"
|
private val TAG = "MainActivity"
|
||||||
|
|
||||||
@ -58,6 +68,18 @@ 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() {
|
fun prepareAndStartVPN() {
|
||||||
@ -92,14 +114,21 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
// Initialize AppAuthRepository for Split Tunneling
|
||||||
|
com.jihe.punchnet.data.AppAuthRepository.init(this)
|
||||||
|
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
|
||||||
Log.d("DIR", "filesdir = ${this.filesDir}")
|
Log.d("DIR", "filesdir = ${this.filesDir}")
|
||||||
|
|
||||||
|
|
||||||
|
val buttonModel = ButtonViewModel()
|
||||||
|
buttonModel.updateState(ButtonState.ButtonStopped)
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
PunchnetTheme {
|
PunchnetTheme {
|
||||||
MainApp(serviceModel, viewModel, rememberNavController())
|
MainApp(buttonModel, serviceModel, viewModel, rememberNavController())
|
||||||
/*
|
/*
|
||||||
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
||||||
StartStop(
|
StartStop(
|
||||||
|
|||||||
@ -12,6 +12,9 @@ import android.os.ParcelFileDescriptor
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.core.app.NotificationCompat
|
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.Arguments
|
||||||
import com.jihe.punchnet.sdlan.config.toIPV4String
|
import com.jihe.punchnet.sdlan.config.toIPV4String
|
||||||
import com.jihe.punchnet.sdlan.logs.TerminalLogger
|
import com.jihe.punchnet.sdlan.logs.TerminalLogger
|
||||||
@ -20,10 +23,10 @@ import com.jihe.punchnet.sdlan.network.ARPWaitList
|
|||||||
import com.jihe.punchnet.sdlan.network.DeviceConfig
|
import com.jihe.punchnet.sdlan.network.DeviceConfig
|
||||||
import com.jihe.punchnet.sdlan.network.IfaceTun
|
import com.jihe.punchnet.sdlan.network.IfaceTun
|
||||||
import com.jihe.punchnet.sdlan.network.RouteDetail
|
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.ipInt2ByteArray
|
||||||
|
import com.jihe.punchnet.sdlan.network.maskIPToDigit
|
||||||
import com.jihe.punchnet.sdlan.network.run_sdlan
|
import com.jihe.punchnet.sdlan.network.run_sdlan
|
||||||
|
import com.jihe.punchnet.sdlan.utils.ipToString
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
@ -33,15 +36,21 @@ import java.io.FileInputStream
|
|||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
|
|
||||||
class PunchnetService : VpnService() , IfaceTun {
|
class PunchnetService : VpnService() , IfaceTun {
|
||||||
|
|
||||||
private val TAG = "PunchnetService"
|
private val TAG = "PunchnetService"
|
||||||
private val scope = CoroutineScope(Dispatchers.Default)
|
private var scope = CoroutineScope(Dispatchers.IO)
|
||||||
|
|
||||||
|
private val routes: MutableList<RouteItem> = mutableListOf()
|
||||||
|
|
||||||
private var vpnDescriptor: ParcelFileDescriptor? = null
|
private var vpnDescriptor: ParcelFileDescriptor? = null
|
||||||
|
|
||||||
|
private val serviceLock = Any()
|
||||||
|
// private val isRunning = AtomicBoolean(false)
|
||||||
|
|
||||||
var input: FileInputStream? = null
|
var input: FileInputStream? = null
|
||||||
var output: FileOutputStream? = null
|
var output: FileOutputStream? = null
|
||||||
|
|
||||||
override val arpTable = ARPTable()
|
override var arpTable = ARPTable(scope)
|
||||||
override val arpWaitList = ARPWaitList()
|
override val arpWaitList = ARPWaitList()
|
||||||
|
|
||||||
var config: DeviceConfig = DeviceConfig(1400)
|
var config: DeviceConfig = DeviceConfig(1400)
|
||||||
@ -51,21 +60,30 @@ class PunchnetService : VpnService() , IfaceTun {
|
|||||||
const val ACTION_DISCONNECT = "com.jihe.punchnet.punchnetservice.DISCONNECT"
|
const val ACTION_DISCONNECT = "com.jihe.punchnet.punchnetservice.DISCONNECT"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
vpnDescriptor?.close()
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun recv(): ByteArray {
|
override suspend fun recv(): ByteArray {
|
||||||
val result = withContext(Dispatchers.IO) {
|
val result = withContext(Dispatchers.IO) {
|
||||||
val result = ByteArray(1500)
|
val result = ByteArray(1500)
|
||||||
var size = input?.read(result)
|
try {
|
||||||
if (size == null) {
|
var size = input?.read(result)
|
||||||
println("xxx failed to read")
|
if (size == null) {
|
||||||
size = 0
|
size = 0
|
||||||
} else {
|
} else {
|
||||||
println("xxx got $size bytes")
|
println("xxx got $size bytes")
|
||||||
}
|
}
|
||||||
|
|
||||||
// val size = input?.read(result)?:0
|
// val size = input?.read(result)?:0
|
||||||
// Log.d(TAG, "RECEIVED $size bytes")
|
// Log.d(TAG, "RECEIVED $size bytes")
|
||||||
result.copyOf(size)
|
result.copyOf(size)
|
||||||
// result.slice(0..<size).toByteArray()
|
// result.slice(0..<size).toByteArray()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("xxx read exception: ${e.message}")
|
||||||
|
e.printStackTrace()
|
||||||
|
ByteArray(0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@ -73,7 +91,11 @@ class PunchnetService : VpnService() , IfaceTun {
|
|||||||
override suspend fun send(content: ByteArray) {
|
override suspend fun send(content: ByteArray) {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
// Log.d(TAG, "WROTE bytes to vpn service")
|
// Log.d(TAG, "WROTE bytes to vpn service")
|
||||||
output?.write(content)
|
try {
|
||||||
|
output?.write(content)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
TerminalLogger.errorf { "failed to write $e" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -91,60 +113,172 @@ class PunchnetService : VpnService() , IfaceTun {
|
|||||||
}
|
}
|
||||||
vpnDescriptor?.close()
|
vpnDescriptor?.close()
|
||||||
|
|
||||||
vpnDescriptor = Builder()
|
var tempVpnDescriptor = Builder()
|
||||||
.setMtu(config.mtu)
|
.setMtu(config.mtu)
|
||||||
.addAddress(ip, config.ip.netBitLen.toInt())
|
.addAddress(ip, config.ip.netBitLen.toInt())
|
||||||
.setBlocking(true)
|
.setBlocking(true)
|
||||||
.establish()
|
|
||||||
|
|
||||||
input = FileInputStream(vpnDescriptor!!.fileDescriptor)
|
// Automatically add route for the SDLAN network itself
|
||||||
output = FileOutputStream(vpnDescriptor!!.fileDescriptor)
|
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())
|
||||||
|
|
||||||
private fun disconnect() {
|
for (route in routes) {
|
||||||
Toast.makeText(this, "stop vpn called", Toast.LENGTH_LONG).show()
|
tempVpnDescriptor = tempVpnDescriptor.addRoute(ipToString(route.net_ip), maskIPToDigit(route.mask_ip))
|
||||||
input?.close()
|
}
|
||||||
output?.close()
|
|
||||||
vpnDescriptor?.close()
|
|
||||||
|
|
||||||
// stopForeground(true)
|
// Apply App Authorization (Split Tunneling)
|
||||||
stopSelf()
|
val allowedApps = com.jihe.punchnet.data.AppAuthRepository.allowedApps.value
|
||||||
}
|
if (allowedApps.isNotEmpty()) {
|
||||||
|
for (pkg in allowedApps) {
|
||||||
private fun connect(startArg: PunchnetServiceArgument?) {
|
// Ensure we do not add ourselves to the allowed list, otherwise it causes a routing loop
|
||||||
val iface = this
|
if (pkg == this.packageName) continue
|
||||||
|
try {
|
||||||
val server = "punchnet.aioe.tech"
|
tempVpnDescriptor = tempVpnDescriptor.addAllowedApplication(pkg)
|
||||||
|
} catch (e: android.content.pm.PackageManager.NameNotFoundException) {
|
||||||
Log.d("DIR", "datadir = ${Environment.getDataDirectory().name}")
|
TerminalLogger.warning { "App not found when setting VPN allowed app: $pkg" }
|
||||||
Log.d("DIR", "external storage = ${Environment.getExternalStorageDirectory().name}")
|
}
|
||||||
Log.d("DIR", "filesdir = ${this.filesDir.path}")
|
}
|
||||||
|
} else {
|
||||||
arpTable.routeTable.clearRoute()
|
// If no apps are specified, VPN intercepts everything matching the routes.
|
||||||
for (route in startArg?.routes?.toList()?:listOf()) {
|
// We ONLY disallow ourselves if there's a 0.0.0.0/0 route, to avoid routing loops.
|
||||||
val r = cidrToRouteDetail(route.targetNetCIDR, route.gateway)
|
// If there's no global route, we want the app (e.g. in-app WebViews) to be able to access the VPN routes!
|
||||||
if (r != null) {
|
val hasGlobalRoute = routes.any { it.mask_ip == 0 }
|
||||||
arpTable.routeTable.addRoute(r)
|
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." }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val argument = Arguments(
|
vpnDescriptor = tempVpnDescriptor
|
||||||
baseDir = this.filesDir.path,
|
.establish()?.apply {
|
||||||
sn = "$server:1265",
|
input = FileInputStream(fileDescriptor)
|
||||||
tcp = "$server:18083",
|
output = FileOutputStream(fileDescriptor)
|
||||||
|
}
|
||||||
|
ButtonRepository.updateState(ButtonState.ButtonStarted)
|
||||||
|
}
|
||||||
|
|
||||||
nat_server1 = "$server:1265",
|
private fun disconnect() {
|
||||||
nat_server2 = "47.98.178.3:1265",
|
synchronized(serviceLock) {
|
||||||
token = startArg?.token?:"",
|
if (ButtonRepository.buttonState.value != ButtonState.ButtonStarted) {
|
||||||
name = "tau",
|
return
|
||||||
)
|
}
|
||||||
|
/*
|
||||||
|
if (!isRunning.get()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
ButtonRepository.updateState(ButtonState.ButtonStopping)
|
||||||
|
|
||||||
scope.launch {
|
Toast.makeText(this, "stop vpn called", Toast.LENGTH_LONG).show()
|
||||||
run_sdlan(iface, argument, startArg)
|
input?.close()
|
||||||
|
output?.close()
|
||||||
|
vpnDescriptor?.close()
|
||||||
|
vpnDescriptor = null
|
||||||
|
|
||||||
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
|
stopSelf()
|
||||||
|
// isRunning.set(false)
|
||||||
}
|
}
|
||||||
|
ButtonRepository.updateState(ButtonState.ButtonStopped)
|
||||||
|
}
|
||||||
|
|
||||||
val notification = createNotification()
|
private fun connect(startArg: PunchnetServiceArgument?) {
|
||||||
startForeground(1, notification)
|
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}")
|
||||||
|
Log.d("DIR", "external storage = ${Environment.getExternalStorageDirectory().name}")
|
||||||
|
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)
|
||||||
|
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",
|
||||||
|
token = startArg?.token?:"",
|
||||||
|
name = "tau",
|
||||||
|
)
|
||||||
|
|
||||||
|
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 {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
@ -155,12 +289,15 @@ class PunchnetService : VpnService() , IfaceTun {
|
|||||||
disconnect()
|
disconnect()
|
||||||
START_STICKY
|
START_STICKY
|
||||||
} else {
|
} else {
|
||||||
|
scope = CoroutineScope(Dispatchers.IO)
|
||||||
|
arpTable = ARPTable(scope)
|
||||||
|
|
||||||
val argument = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
val argument = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
intent?.getParcelableExtra("argument", PunchnetServiceArgument::class.java)
|
intent?.getParcelableExtra("argument", PunchnetServiceArgument::class.java)
|
||||||
} else {
|
} else {
|
||||||
intent?.getParcelableExtra("argument")
|
intent?.getParcelableExtra("argument")
|
||||||
}
|
}
|
||||||
println("argument = ${argument}")
|
TerminalLogger.debugf { "service argument received: routes=${argument?.routes?.size ?: 0}, hasToken=${!argument?.token.isNullOrEmpty()}" }
|
||||||
connect(argument)
|
connect(argument)
|
||||||
START_STICKY
|
START_STICKY
|
||||||
}
|
}
|
||||||
@ -187,6 +324,11 @@ class PunchnetService : VpnService() , IfaceTun {
|
|||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
// scope.cancel()
|
// scope.cancel()
|
||||||
// disconnect()
|
// disconnect()
|
||||||
|
scope.cancel()
|
||||||
|
disconnect()
|
||||||
|
com.jihe.punchnet.data.NodeRepository.clear()
|
||||||
|
Log.e(TAG, "onDestroy is called")
|
||||||
|
// stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package com.jihe.punchnet
|
|||||||
|
|
||||||
import android.os.Parcel
|
import android.os.Parcel
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
|
import com.jihe.punchnet.data.RouteItem
|
||||||
|
|
||||||
data class RouteInfo(
|
data class RouteInfo(
|
||||||
// 192.168.80.0/24
|
// 192.168.80.0/24
|
||||||
@ -12,14 +13,16 @@ data class RouteInfo(
|
|||||||
|
|
||||||
data class PunchnetServiceArgument(
|
data class PunchnetServiceArgument(
|
||||||
val token: String,
|
val token: String,
|
||||||
val routes: Array<RouteInfo>,
|
val routes: Array<RouteItem>,
|
||||||
): Parcelable {
|
): Parcelable {
|
||||||
override fun writeToParcel(dest: Parcel, flags: Int) {
|
override fun writeToParcel(dest: Parcel, flags: Int) {
|
||||||
dest.writeString(token)
|
dest.writeString(token)
|
||||||
dest.writeInt(routes.size)
|
dest.writeInt(routes.size)
|
||||||
routes.forEach { item ->
|
routes.forEach { item ->
|
||||||
dest.writeString(item.targetNetCIDR)
|
dest.writeLong(item.id)
|
||||||
dest.writeString(item.gateway)
|
dest.writeInt(item.net_ip)
|
||||||
|
dest.writeInt(item.mask_ip)
|
||||||
|
dest.writeInt(item.gateway)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -31,13 +34,18 @@ data class PunchnetServiceArgument(
|
|||||||
override fun createFromParcel(source: Parcel): PunchnetServiceArgument {
|
override fun createFromParcel(source: Parcel): PunchnetServiceArgument {
|
||||||
val token = source.readString()!!
|
val token = source.readString()!!
|
||||||
val size = source.readInt()
|
val size = source.readInt()
|
||||||
var routes = mutableListOf<RouteInfo>()
|
var routes = mutableListOf<RouteItem>()
|
||||||
for (i in 0..<size) {
|
for (i in 0..<size) {
|
||||||
val net = source.readString()!!
|
val id = source.readLong()!!
|
||||||
val gw = source.readString()!!
|
val netIP = source.readInt()!!
|
||||||
routes.add(RouteInfo(
|
val maskIP = source.readInt()!!
|
||||||
targetNetCIDR = net,
|
val gateway = source.readInt()!!
|
||||||
gateway = gw,
|
|
||||||
|
routes.add(RouteItem(
|
||||||
|
id = id,
|
||||||
|
net_ip = netIP,
|
||||||
|
mask_ip = maskIP,
|
||||||
|
gateway = gateway,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
return PunchnetServiceArgument(token, routes.toTypedArray())
|
return PunchnetServiceArgument(token, routes.toTypedArray())
|
||||||
|
|||||||
81
app/src/main/java/com/jihe/punchnet/api/ApiClient.kt
Normal file
81
app/src/main/java/com/jihe/punchnet/api/ApiClient.kt
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
115
app/src/main/java/com/jihe/punchnet/api/ApiModels.kt
Normal file
115
app/src/main/java/com/jihe/punchnet/api/ApiModels.kt
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
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
|
||||||
|
)
|
||||||
91
app/src/main/java/com/jihe/punchnet/api/SignInterceptor.kt
Normal file
91
app/src/main/java/com/jihe/punchnet/api/SignInterceptor.kt
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
package com.jihe.punchnet.api
|
||||||
|
|
||||||
|
import com.google.gson.GsonBuilder
|
||||||
|
import com.google.gson.JsonParser
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Response
|
||||||
|
import okio.Buffer
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
|
import javax.crypto.Mac
|
||||||
|
import javax.crypto.spec.SecretKeySpec
|
||||||
|
|
||||||
|
class SignInterceptor : Interceptor {
|
||||||
|
|
||||||
|
private val secretKey = "H6p*2RfEu4ITcL"
|
||||||
|
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val originalRequest = chain.request()
|
||||||
|
val body = originalRequest.body
|
||||||
|
|
||||||
|
if (originalRequest.method == "POST" && body != null && body.contentType()?.subtype == "json") {
|
||||||
|
try {
|
||||||
|
// Read original JSON body
|
||||||
|
val buffer = Buffer()
|
||||||
|
body.writeTo(buffer)
|
||||||
|
val jsonStr = buffer.readUtf8()
|
||||||
|
|
||||||
|
// Parse into JsonObject
|
||||||
|
val jsonElement = JsonParser.parseString(jsonStr)
|
||||||
|
if (jsonElement.isJsonObject) {
|
||||||
|
val jsonObject = jsonElement.asJsonObject
|
||||||
|
|
||||||
|
// Sort keys
|
||||||
|
val sortedKeys = jsonObject.keySet().sorted()
|
||||||
|
|
||||||
|
// Build query string
|
||||||
|
val queryStringBuilder = StringBuilder()
|
||||||
|
val gson = GsonBuilder().disableHtmlEscaping().create()
|
||||||
|
|
||||||
|
for (key in sortedKeys) {
|
||||||
|
val element = jsonObject.get(key)
|
||||||
|
if (element.isJsonNull) continue
|
||||||
|
|
||||||
|
if (queryStringBuilder.isNotEmpty()) {
|
||||||
|
queryStringBuilder.append("&")
|
||||||
|
}
|
||||||
|
|
||||||
|
queryStringBuilder.append(key).append("=")
|
||||||
|
|
||||||
|
if (element.isJsonPrimitive) {
|
||||||
|
val primitive = element.asJsonPrimitive
|
||||||
|
if (primitive.isString) {
|
||||||
|
queryStringBuilder.append(primitive.asString)
|
||||||
|
} else {
|
||||||
|
// numbers and booleans
|
||||||
|
queryStringBuilder.append(primitive.asString)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Object or Array - convert to JSON string (JSON_UNESCAPED_UNICODE is default in Gson if disableHtmlEscaping is used)
|
||||||
|
queryStringBuilder.append(gson.toJson(element))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val queryString = queryStringBuilder.toString()
|
||||||
|
|
||||||
|
// Calculate HMAC-MD5
|
||||||
|
val sign = calculateHmacMD5(queryString, secretKey)
|
||||||
|
// Inject sign into HTTP Header X-sign
|
||||||
|
val newRequest = originalRequest.newBuilder()
|
||||||
|
.addHeader("X-sign", sign)
|
||||||
|
.post(body)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
return chain.proceed(newRequest)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return chain.proceed(originalRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun calculateHmacMD5(data: String, key: String): String {
|
||||||
|
val algorithm = "HmacMD5"
|
||||||
|
val mac = Mac.getInstance(algorithm)
|
||||||
|
val secretKeySpec = SecretKeySpec(key.toByteArray(StandardCharsets.UTF_8), algorithm)
|
||||||
|
mac.init(secretKeySpec)
|
||||||
|
val hashBytes = mac.doFinal(data.toByteArray(StandardCharsets.UTF_8))
|
||||||
|
return hashBytes.joinToString("") { "%02x".format(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
package com.jihe.punchnet.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.core.content.edit
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
object AppAuthRepository {
|
||||||
|
private const val PREFS_NAME = "punchnet_app_auth"
|
||||||
|
private const val KEY_ALLOWED_APPS = "allowed_apps"
|
||||||
|
|
||||||
|
private val _allowedApps = MutableStateFlow<Set<String>>(emptySet())
|
||||||
|
val allowedApps: StateFlow<Set<String>> = _allowedApps
|
||||||
|
|
||||||
|
fun init(context: Context) {
|
||||||
|
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
val savedApps = prefs.getStringSet(KEY_ALLOWED_APPS, emptySet()) ?: emptySet()
|
||||||
|
_allowedApps.value = savedApps
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleApp(context: Context, packageName: String, isAllowed: Boolean) {
|
||||||
|
val current = _allowedApps.value.toMutableSet()
|
||||||
|
if (isAllowed) {
|
||||||
|
current.add(packageName)
|
||||||
|
} else {
|
||||||
|
current.remove(packageName)
|
||||||
|
}
|
||||||
|
_allowedApps.value = current
|
||||||
|
|
||||||
|
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
prefs.edit {
|
||||||
|
putStringSet(KEY_ALLOWED_APPS, current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
34
app/src/main/java/com/jihe/punchnet/data/ButtonViewModel.kt
Normal file
34
app/src/main/java/com/jihe/punchnet/data/ButtonViewModel.kt
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
56
app/src/main/java/com/jihe/punchnet/data/NodeRepository.kt
Normal file
56
app/src/main/java/com/jihe/punchnet/data/NodeRepository.kt
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
package com.jihe.punchnet.data
|
||||||
|
|
||||||
|
import com.jihe.punchnet.api.NodeItem
|
||||||
|
import com.jihe.punchnet.api.ResourceItem
|
||||||
|
import com.jihe.punchnet.api.ExitNodeItem
|
||||||
|
import com.jihe.punchnet.api.ConnectResponseData
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
data class LocalProfileInfo(
|
||||||
|
val ip: String,
|
||||||
|
val maskLen: Int,
|
||||||
|
val hostname: String,
|
||||||
|
val identityId: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
object NodeRepository {
|
||||||
|
private val _nodes = MutableStateFlow<List<NodeItem>>(emptyList())
|
||||||
|
val nodes: StateFlow<List<NodeItem>> = _nodes
|
||||||
|
|
||||||
|
private val _resources = MutableStateFlow<List<ResourceItem>>(emptyList())
|
||||||
|
val resources: StateFlow<List<ResourceItem>> = _resources
|
||||||
|
|
||||||
|
private val _exitNodes = MutableStateFlow<List<ExitNodeItem>>(emptyList())
|
||||||
|
val exitNodes: StateFlow<List<ExitNodeItem>> = _exitNodes
|
||||||
|
|
||||||
|
private val _localProfile = MutableStateFlow<LocalProfileInfo?>(null)
|
||||||
|
val localProfile: StateFlow<LocalProfileInfo?> = _localProfile
|
||||||
|
|
||||||
|
fun updateAll(data: ConnectResponseData?) {
|
||||||
|
if (data == null) {
|
||||||
|
clear()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_nodes.value = data.nodeList ?: emptyList()
|
||||||
|
_resources.value = data.resourceList ?: emptyList()
|
||||||
|
_exitNodes.value = data.exitNode ?: emptyList()
|
||||||
|
_localProfile.value = LocalProfileInfo(
|
||||||
|
ip = data.ip,
|
||||||
|
maskLen = data.maskLen,
|
||||||
|
hostname = data.hostname,
|
||||||
|
identityId = data.identityId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateNodes(newNodes: List<NodeItem>?) {
|
||||||
|
_nodes.value = newNodes ?: emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
_nodes.value = emptyList()
|
||||||
|
_resources.value = emptyList()
|
||||||
|
_exitNodes.value = emptyList()
|
||||||
|
_localProfile.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -61,7 +61,9 @@ abstract class AppDatabase: RoomDatabase() {
|
|||||||
context.applicationContext,
|
context.applicationContext,
|
||||||
AppDatabase::class.java,
|
AppDatabase::class.java,
|
||||||
"app_database"
|
"app_database"
|
||||||
).build()
|
)
|
||||||
|
.fallbackToDestructiveMigration()
|
||||||
|
.build()
|
||||||
INSTANCE = instance
|
INSTANCE = instance
|
||||||
instance
|
instance
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,44 +16,38 @@ import androidx.compose.runtime.State
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.core.content.ContextCompat.startForegroundService
|
import androidx.core.content.ContextCompat.startForegroundService
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.jihe.punchnet.PunchnetService
|
import com.jihe.punchnet.PunchnetService
|
||||||
import com.jihe.punchnet.PunchnetServiceArgument
|
import com.jihe.punchnet.PunchnetServiceArgument
|
||||||
import com.jihe.punchnet.RouteInfo
|
import com.jihe.punchnet.RouteInfo
|
||||||
|
import com.jihe.punchnet.sdlan.utils.ipToString
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
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) {
|
class ServiceViewModel(application: Application): AndroidViewModel(application) {
|
||||||
|
/*
|
||||||
private val _isRunning = mutableStateOf(false)
|
private val _isRunning = mutableStateOf(false)
|
||||||
val isRunning: State<Boolean> = _isRunning
|
val isRunning: State<Boolean> = _isRunning
|
||||||
|
*/
|
||||||
|
|
||||||
private val _hasVpnPermission = mutableStateOf(false)
|
fun startService(context: Context, routes: Array<RouteItem>, token: String) {
|
||||||
val hasVpnPermission: State<Boolean> = _hasVpnPermission
|
// should has the permission
|
||||||
|
startVpnService(context, routes, token)
|
||||||
fun setVPNPermission(isGranted: Boolean) {
|
// ButtonRepository.updateState(ButtonState.ButtonStarting)
|
||||||
_hasVpnPermission.value = isGranted
|
// _isRunning.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
fun startService(context: Context, noPermissionCallback: (Intent)->Unit) {
|
private fun startVpnService(context: Context, routes: Array<RouteItem>, token: String) {
|
||||||
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)
|
val intent = Intent(context, PunchnetService::class.java)
|
||||||
intent.putExtra("argument", PunchnetServiceArgument(
|
intent.putExtra("argument", PunchnetServiceArgument(
|
||||||
"",
|
token,
|
||||||
arrayOf(
|
routes,
|
||||||
RouteInfo(
|
))
|
||||||
"192.168.80.0/24", "10.211.188.2"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
context.startForegroundService(intent)
|
context.startForegroundService(intent)
|
||||||
} else {
|
} else {
|
||||||
@ -69,7 +63,7 @@ class ServiceViewModel(application: Application): AndroidViewModel(application)
|
|||||||
PunchnetService.ACTION_DISCONNECT })
|
PunchnetService.ACTION_DISCONNECT })
|
||||||
// stopService(Intent(this, PunchnetService::class.java))
|
// stopService(Intent(this, PunchnetService::class.java))
|
||||||
Log.d("STOPPED PUNCHNET", "stopping PUNCHNET")
|
Log.d("STOPPED PUNCHNET", "stopping PUNCHNET")
|
||||||
_isRunning.value = false
|
// ButtonRepository.updateState(ButtonState.ButtonStopping)
|
||||||
// Toast.makeText(this, "VPN service stopped", Toast.LENGTH_SHORT).show()
|
// Toast.makeText(this, "VPN service stopped", Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,6 +1,158 @@
|
|||||||
package com.jihe.punchnet.helper
|
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) {
|
sealed class Screen(val route: String) {
|
||||||
object ScreenRoutes: Screen("routes")
|
object ScreenRoutes: Screen("routes")
|
||||||
object ScreenMain: Screen("main")
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
190
app/src/main/java/com/jihe/punchnet/screen/AppAuthScreen.kt
Normal file
190
app/src/main/java/com/jihe/punchnet/screen/AppAuthScreen.kt
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
package com.jihe.punchnet.screen
|
||||||
|
|
||||||
|
import android.content.pm.ApplicationInfo
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.graphics.drawable.Drawable
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Clear
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.core.graphics.drawable.toBitmap
|
||||||
|
import androidx.navigation.NavHostController
|
||||||
|
import com.jihe.punchnet.data.AppAuthRepository
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
data class AppInfo(
|
||||||
|
val name: String,
|
||||||
|
val packageName: String,
|
||||||
|
val icon: Drawable
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AppAuthScreen(navController: NavHostController) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val allowedApps by AppAuthRepository.allowedApps.collectAsState()
|
||||||
|
|
||||||
|
var installedApps by remember { mutableStateOf<List<AppInfo>>(emptyList()) }
|
||||||
|
var isLoading by remember { mutableStateOf(true) }
|
||||||
|
var showAppSelector by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val pm = context.packageManager
|
||||||
|
val intent = android.content.Intent(android.content.Intent.ACTION_MAIN, null).apply {
|
||||||
|
addCategory(android.content.Intent.CATEGORY_LAUNCHER)
|
||||||
|
}
|
||||||
|
// Use queryIntentActivities to reliably find all user-facing apps
|
||||||
|
val resolveInfos = pm.queryIntentActivities(intent, 0)
|
||||||
|
val apps = resolveInfos.mapNotNull {
|
||||||
|
try {
|
||||||
|
val appInfo = it.activityInfo.applicationInfo
|
||||||
|
if (appInfo.packageName != context.packageName) {
|
||||||
|
AppInfo(
|
||||||
|
name = it.loadLabel(pm).toString(),
|
||||||
|
packageName = appInfo.packageName,
|
||||||
|
icon = it.loadIcon(pm)
|
||||||
|
)
|
||||||
|
} else null
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null // Skip apps that cause PackageManager exceptions
|
||||||
|
}
|
||||||
|
}.distinctBy { it.packageName }.sortedBy { it.name }
|
||||||
|
installedApps = apps
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showAppSelector) {
|
||||||
|
// App Selection View
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
androidx.compose.material3.IconButton(onClick = { showAppSelector = false }) {
|
||||||
|
androidx.compose.material3.Icon(
|
||||||
|
imageVector = Icons.Default.ArrowBack,
|
||||||
|
contentDescription = "返回"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text("选择要代理的应用", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Text("正在加载应用列表...")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||||
|
items(installedApps) { app ->
|
||||||
|
val isAllowed = allowedApps.contains(app.packageName)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
bitmap = app.icon.toBitmap().asImageBitmap(),
|
||||||
|
contentDescription = app.name,
|
||||||
|
modifier = Modifier.size(40.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(text = app.name, fontWeight = FontWeight.Bold, fontSize = 16.sp)
|
||||||
|
Text(text = app.packageName, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
Switch(
|
||||||
|
checked = isAllowed,
|
||||||
|
onCheckedChange = { checked ->
|
||||||
|
AppAuthRepository.toggleApp(context, app.packageName, checked)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Main View
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
Text(
|
||||||
|
text = "只有在列表中的应用,其网络请求才会通过 PunchNet 代理。若列表为空,则默认代理所有应用。",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(16.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
val selectedApps = installedApps.filter { allowedApps.contains(it.packageName) }
|
||||||
|
|
||||||
|
if (selectedApps.isEmpty() && !isLoading) {
|
||||||
|
Box(modifier = Modifier.fillMaxSize().padding(bottom = 80.dp), contentAlignment = Alignment.Center) {
|
||||||
|
Text("当前未选择任何应用\n(将代理全局流量)", textAlign = androidx.compose.ui.text.style.TextAlign.Center, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyColumn(modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(bottom = 80.dp)) {
|
||||||
|
items(selectedApps) { app ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
bitmap = app.icon.toBitmap().asImageBitmap(),
|
||||||
|
contentDescription = app.name,
|
||||||
|
modifier = Modifier.size(40.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(text = app.name, fontWeight = FontWeight.Bold, fontSize = 16.sp)
|
||||||
|
Text(text = app.packageName, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
androidx.compose.material3.IconButton(
|
||||||
|
onClick = { AppAuthRepository.toggleApp(context, app.packageName, false) }
|
||||||
|
) {
|
||||||
|
androidx.compose.material3.Icon(
|
||||||
|
imageVector = Icons.Default.Clear,
|
||||||
|
contentDescription = "移除"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add Button (FAB)
|
||||||
|
androidx.compose.material3.FloatingActionButton(
|
||||||
|
onClick = { showAppSelector = true },
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomEnd)
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
androidx.compose.material3.Icon(
|
||||||
|
imageVector = Icons.Default.Add,
|
||||||
|
contentDescription = "添加应用"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -11,12 +11,14 @@ import androidx.compose.foundation.layout.size
|
|||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@ -34,33 +36,60 @@ import androidx.navigation.compose.composable
|
|||||||
import androidx.navigation.navArgument
|
import androidx.navigation.navArgument
|
||||||
import com.jihe.punchnet.R
|
import com.jihe.punchnet.R
|
||||||
import com.jihe.punchnet.RouteInfo
|
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.RouteViewModel
|
||||||
import com.jihe.punchnet.data.ServiceViewModel
|
import com.jihe.punchnet.data.ServiceViewModel
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AppNavHost2(
|
fun AppNavHost2(
|
||||||
|
buttonViewModel: ButtonViewModel,
|
||||||
serviceViewModel: ServiceViewModel,
|
serviceViewModel: ServiceViewModel,
|
||||||
routeViewModel: RouteViewModel = viewModel(),
|
routeViewModel: RouteViewModel = viewModel(),
|
||||||
navController: NavHostController,
|
navController: NavHostController,
|
||||||
paddingValues: PaddingValues
|
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(
|
NavHost(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
startDestination = Screen.MainScreen.route,
|
startDestination = startDest,
|
||||||
modifier = Modifier.padding(paddingValues)
|
modifier = Modifier.padding(paddingValues)
|
||||||
) {
|
) {
|
||||||
composable(Screen.MainScreen.route) {
|
composable(Screen.LoginScreen.route) {
|
||||||
HomeScreen(serviceViewModel, routeViewModel)
|
LoginScreen(navController)
|
||||||
}
|
}
|
||||||
composable(Screen.RouteScreen.route) {
|
composable(Screen.MainScreen.route) {
|
||||||
CustomHeaderScreen(
|
HomeScreen(buttonViewModel, serviceViewModel, routeViewModel, navController)
|
||||||
"光强计"
|
}
|
||||||
) {
|
composable(Screen.DeviceScreen.route) {
|
||||||
// LightScreen()
|
CustomHeaderScreen("设备") {
|
||||||
|
DeviceScreen(navController)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
composable(Screen.ProfileScreen.route) {
|
composable(Screen.AppAuthScreen.route) {
|
||||||
// Profile(dbdao, navController)
|
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() }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -37,17 +37,21 @@ fun AppBottomNavigation(
|
|||||||
Icon(
|
Icon(
|
||||||
imageVector = when (screen) {
|
imageVector = when (screen) {
|
||||||
Screen.MainScreen -> Icons.Default.Home
|
Screen.MainScreen -> Icons.Default.Home
|
||||||
Screen.RouteScreen -> Icons.Default.Menu
|
Screen.DeviceScreen -> Icons.Default.Menu
|
||||||
// Screen.Search -> Icons.Default.Search
|
Screen.AppAuthScreen -> Icons.Default.Person
|
||||||
Screen.ProfileScreen -> Icons.Default.Person
|
else -> Icons.Default.Home
|
||||||
},
|
},
|
||||||
contentDescription = screen.route
|
contentDescription = screen.route
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
label = {
|
label = {
|
||||||
Text(
|
val labelText = when (screen) {
|
||||||
text = screen.route.replaceFirstChar { it.uppercase() },
|
Screen.MainScreen -> "首页"
|
||||||
)
|
Screen.DeviceScreen -> "设备"
|
||||||
|
Screen.AppAuthScreen -> "应用授权"
|
||||||
|
else -> screen.route
|
||||||
|
}
|
||||||
|
Text(text = labelText)
|
||||||
},
|
},
|
||||||
selected = currentRoute == screen.route,
|
selected = currentRoute == screen.route,
|
||||||
onClick = {
|
onClick = {
|
||||||
|
|||||||
57
app/src/main/java/com/jihe/punchnet/screen/DeviceScreen.kt
Normal file
57
app/src/main/java/com/jihe/punchnet/screen/DeviceScreen.kt
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
package com.jihe.punchnet.screen
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Person
|
||||||
|
import androidx.compose.material.icons.filled.Phone
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.navigation.NavHostController
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DeviceScreen(navController: NavHostController) {
|
||||||
|
val nodeList by com.jihe.punchnet.data.NodeRepository.nodes.collectAsState()
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxWidth().fillMaxHeight(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
if (nodeList.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
text = "网络节点 (${nodeList.count { it.connectionStatus == "connected" }}/${nodeList.size})",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.padding(bottom = 16.dp, start = 24.dp, top = 24.dp).fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
items(nodeList) { node ->
|
||||||
|
Box(modifier = Modifier.padding(horizontal = 24.dp)) {
|
||||||
|
NodeItemRow(node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
item {
|
||||||
|
Box(modifier = Modifier.fillMaxSize().padding(top = 100.dp), contentAlignment = Alignment.Center) {
|
||||||
|
Text("暂无设备,请先连接网络", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,15 +1,26 @@
|
|||||||
package com.jihe.punchnet.screen
|
package com.jihe.punchnet.screen
|
||||||
|
|
||||||
|
import android.app.Activity.MODE_PRIVATE
|
||||||
import android.app.Activity.RESULT_CANCELED
|
import android.app.Activity.RESULT_CANCELED
|
||||||
import android.app.Activity.RESULT_OK
|
import android.app.Activity.RESULT_OK
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.net.VpnService
|
||||||
|
import android.content.pm.PackageManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.annotation.DrawableRes
|
||||||
import androidx.compose.foundation.Image
|
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.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
@ -19,12 +30,22 @@ import androidx.compose.foundation.layout.width
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextField
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.MutableState
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
@ -36,129 +57,313 @@ import com.jihe.punchnet.PunchnetService
|
|||||||
import com.jihe.punchnet.PunchnetServiceArgument
|
import com.jihe.punchnet.PunchnetServiceArgument
|
||||||
import com.jihe.punchnet.R
|
import com.jihe.punchnet.R
|
||||||
import com.jihe.punchnet.RouteInfo
|
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.RouteViewModel
|
||||||
import com.jihe.punchnet.data.ServiceViewModel
|
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
|
@Composable
|
||||||
fun HomeScreen(
|
fun HomeScreen(
|
||||||
|
buttonViewModel: ButtonViewModel,
|
||||||
serviceViewModel: ServiceViewModel,
|
serviceViewModel: ServiceViewModel,
|
||||||
routeViewModel: RouteViewModel,
|
routeViewModel: RouteViewModel,
|
||||||
// started: MutableState<Boolean>,
|
navController: androidx.navigation.NavHostController,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val tkPref = getPreferenceString(context, PreferenceName.PreferenceToken)
|
||||||
|
|
||||||
|
val allroutes = routeViewModel.allRoutes.collectAsState(emptyList())
|
||||||
val vpnLauncher = rememberLauncherForActivityResult(
|
val vpnLauncher = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.StartActivityForResult()
|
contract = ActivityResultContracts.StartActivityForResult()
|
||||||
) {result ->
|
) {result ->
|
||||||
when(result.resultCode) {
|
when(result.resultCode) {
|
||||||
RESULT_OK -> {
|
RESULT_OK -> {
|
||||||
serviceViewModel.setVPNPermission(true)
|
// serviceViewModel.startService(context)
|
||||||
|
// serviceViewModel.setVPNPermission(true)
|
||||||
|
val tk = getPreferenceString(context, PreferenceName.PreferenceToken)?:""
|
||||||
|
serviceViewModel.startService(context, allroutes.value.toTypedArray(), tk)
|
||||||
}
|
}
|
||||||
RESULT_CANCELED -> {
|
RESULT_CANCELED -> {
|
||||||
serviceViewModel.setVPNPermission(false)
|
|
||||||
Toast.makeText(context, "vpn permission denied", Toast.LENGTH_SHORT).show()
|
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(
|
CustomHeaderScreen(
|
||||||
"",
|
"",
|
||||||
onBack = null,
|
onBack = null,
|
||||||
onMenu = {
|
onMenu = {
|
||||||
Icon(
|
HomeDropdownMenu(navController)
|
||||||
Icons.Default.Settings,
|
|
||||||
contentDescription = "settings",
|
|
||||||
modifier = Modifier.fillMaxHeight()
|
|
||||||
)
|
|
||||||
// NavListMenu(navController, dbdao)
|
|
||||||
/*
|
|
||||||
navController.navigate(
|
|
||||||
Screen.ActionDetail.route.replace(
|
|
||||||
"{action_name}",
|
|
||||||
""
|
|
||||||
)
|
|
||||||
)
|
|
||||||
*/
|
|
||||||
|
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
Column(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth().fillMaxHeight(),
|
||||||
.padding(top=50.dp),
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
) {
|
||||||
|
item {
|
||||||
) {
|
Spacer(modifier = Modifier.height(50.dp))
|
||||||
Image(
|
|
||||||
painter = painterResource(R.drawable.punchnet_log),
|
Image(
|
||||||
contentDescription = "logo",
|
painter = painterResource(R.drawable.punchnet_log),
|
||||||
modifier = Modifier.size(150.dp)
|
contentDescription = "logo",
|
||||||
)
|
modifier = Modifier.size(150.dp)
|
||||||
|
|
||||||
Text(
|
|
||||||
"Connecting the Infinite",
|
|
||||||
fontSize = 30.sp,
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
modifier = Modifier.padding(top=24.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(
|
|
||||||
"Welcome to PunchNet",
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
modifier = Modifier.padding(top=8.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
if (serviceViewModel.isRunning.value) {
|
|
||||||
// if is running, should stop service
|
|
||||||
serviceViewModel.stopVpnService(context)
|
|
||||||
} else {
|
|
||||||
serviceViewModel.startService(
|
|
||||||
context,
|
|
||||||
{intent->
|
|
||||||
vpnLauncher.launch(intent)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// started.value = !started.value
|
|
||||||
},
|
|
||||||
shape = RoundedCornerShape(10.dp),
|
|
||||||
modifier = Modifier.padding(top=48.dp)
|
|
||||||
.width(120.dp)
|
|
||||||
.height(40.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
if (serviceViewModel.isRunning.value) {
|
|
||||||
"停止"
|
|
||||||
} else {
|
|
||||||
"启动"
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
"Connecting the Infinite",
|
||||||
|
fontSize = 30.sp,
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.padding(top=24.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
"Welcome to PunchNet",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
modifier = Modifier.padding(top=8.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
if (buttonViewModel.buttonState.value == ButtonState.ButtonStarted) {
|
||||||
|
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) {
|
||||||
|
vpnLauncher.launch(intent)
|
||||||
|
} else {
|
||||||
|
val tk = getPreferenceString(context, PreferenceName.PreferenceToken)?:""
|
||||||
|
serviceViewModel.startService(context, allroutes.value.toTypedArray(), tk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = buttonState.value.enabled,
|
||||||
|
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()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
items(resourceList) { resource ->
|
||||||
|
Box(modifier = Modifier.padding(horizontal = 24.dp)) {
|
||||||
|
ResourceItemRow(resource, navController)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// BasicCardList(dbdao = dbdao, navController)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
private fun startVpnService(context: Context) {
|
fun ResourceItemRow(resource: com.jihe.punchnet.api.ResourceItem, navController: androidx.navigation.NavHostController) {
|
||||||
val intent = Intent(context, PunchnetService::class.java)
|
androidx.compose.material3.Card(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp).clickable {
|
||||||
intent.putExtra("argument", PunchnetServiceArgument(
|
navController.navigate(Screen.WebViewScreen.createRoute(resource.url))
|
||||||
"",
|
},
|
||||||
arrayOf(
|
colors = androidx.compose.material3.CardDefaults.cardColors(
|
||||||
RouteInfo(
|
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||||
"192.168.80.0/24", "10.211.188.2"
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
)
|
) {
|
||||||
)
|
Row(
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
modifier = Modifier.padding(16.dp).fillMaxWidth(),
|
||||||
context.startForegroundService(intent)
|
verticalAlignment = Alignment.CenterVertically
|
||||||
} else {
|
) {
|
||||||
context.startService(intent)
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.tag),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(32.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
213
app/src/main/java/com/jihe/punchnet/screen/LoginScreen.kt
Normal file
213
app/src/main/java/com/jihe/punchnet/screen/LoginScreen.kt
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
package com.jihe.punchnet.screen
|
||||||
|
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Lock
|
||||||
|
import androidx.compose.material.icons.filled.Person
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.navigation.NavHostController
|
||||||
|
import com.jihe.punchnet.R
|
||||||
|
import com.jihe.punchnet.api.ApiClient
|
||||||
|
import com.jihe.punchnet.api.AuthLoginRequest
|
||||||
|
import com.jihe.punchnet.api.AuthTokenRequest
|
||||||
|
import com.jihe.punchnet.helper.PreferenceName
|
||||||
|
import com.jihe.punchnet.helper.setPreferenceString
|
||||||
|
import com.jihe.punchnet.sdlan.utils.UniqueNodeID
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LoginScreen(navController: NavHostController) {
|
||||||
|
var selectedTabIndex by remember { mutableStateOf(0) }
|
||||||
|
val tabs = listOf("账号密码登录", "Token直连")
|
||||||
|
|
||||||
|
var username by remember { mutableStateOf("") }
|
||||||
|
var password by remember { mutableStateOf("") }
|
||||||
|
var token by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
var isLoading by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
val context = LocalContext.current
|
||||||
|
val apiClient = remember { ApiClient() }
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(R.drawable.punchnet_log),
|
||||||
|
contentDescription = "logo",
|
||||||
|
modifier = Modifier.size(100.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Welcome to PunchNet",
|
||||||
|
fontSize = 24.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
|
||||||
|
TabRow(selectedTabIndex = selectedTabIndex) {
|
||||||
|
tabs.forEachIndexed { index, title ->
|
||||||
|
Tab(
|
||||||
|
selected = selectedTabIndex == index,
|
||||||
|
onClick = { selectedTabIndex = index },
|
||||||
|
text = { Text(title) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
if (selectedTabIndex == 0) {
|
||||||
|
// Account Login
|
||||||
|
OutlinedTextField(
|
||||||
|
value = username,
|
||||||
|
onValueChange = { username = it },
|
||||||
|
label = { Text("用户名") },
|
||||||
|
leadingIcon = { Icon(Icons.Default.Person, contentDescription = null) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = password,
|
||||||
|
onValueChange = { password = it },
|
||||||
|
label = { Text("密码") },
|
||||||
|
leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null) },
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Token Login
|
||||||
|
OutlinedTextField(
|
||||||
|
value = token,
|
||||||
|
onValueChange = { token = it },
|
||||||
|
label = { Text("连接 Token") },
|
||||||
|
leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
if (selectedTabIndex == 0 && (username.isEmpty() || password.isEmpty())) {
|
||||||
|
Toast.makeText(context, "请输入用户名和密码", Toast.LENGTH_SHORT).show()
|
||||||
|
return@Button
|
||||||
|
}
|
||||||
|
if (selectedTabIndex == 1 && token.isEmpty()) {
|
||||||
|
Toast.makeText(context, "请输入Token", Toast.LENGTH_SHORT).show()
|
||||||
|
return@Button
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = true
|
||||||
|
coroutineScope.launch {
|
||||||
|
UniqueNodeID.setBaseDir(context.filesDir.absolutePath)
|
||||||
|
val clientId = "punchnet_android_client_" + UniqueNodeID.getUUID(context)
|
||||||
|
|
||||||
|
try {
|
||||||
|
val response = if (selectedTabIndex == 0) {
|
||||||
|
val request = AuthLoginRequest(
|
||||||
|
clientId = clientId,
|
||||||
|
username = username,
|
||||||
|
password = password,
|
||||||
|
version = "1.0.0",
|
||||||
|
system = "Android " + android.os.Build.VERSION.RELEASE,
|
||||||
|
hostname = android.os.Build.MODEL
|
||||||
|
)
|
||||||
|
ApiClient().loginWithAccount(request)
|
||||||
|
} else {
|
||||||
|
val request = AuthTokenRequest(
|
||||||
|
clientId = clientId,
|
||||||
|
token = token,
|
||||||
|
version = "1.0.0",
|
||||||
|
system = "Android " + android.os.Build.VERSION.RELEASE,
|
||||||
|
hostname = android.os.Build.MODEL
|
||||||
|
)
|
||||||
|
ApiClient().loginWithToken(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
isLoading = false
|
||||||
|
if (response != null && response.code == 0 && response.data != null) {
|
||||||
|
// Success! Save auth data as JSON and navigate
|
||||||
|
val authDataJson = com.google.gson.Gson().toJson(response.data)
|
||||||
|
setPreferenceString(context, PreferenceName.PreferenceToken, authDataJson)
|
||||||
|
Toast.makeText(context, "登录成功", Toast.LENGTH_SHORT).show()
|
||||||
|
navController.navigate(Screen.MainScreen.route) {
|
||||||
|
popUpTo(Screen.LoginScreen.route) { inclusive = true }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Toast.makeText(context, "登录失败: ${response?.message ?: "网络错误"}", Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
isLoading = false
|
||||||
|
Toast.makeText(context, "登录异常: ${e.message}", Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(50.dp),
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
enabled = !isLoading
|
||||||
|
) {
|
||||||
|
if (isLoading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
strokeWidth = 2.dp
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text("登录", fontSize = 16.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
Toast.makeText(context, "请前往官网注册账号", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text("没有账号?去注册")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -15,36 +15,53 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
|
import com.jihe.punchnet.data.ButtonViewModel
|
||||||
import com.jihe.punchnet.data.RouteViewModel
|
import com.jihe.punchnet.data.RouteViewModel
|
||||||
import com.jihe.punchnet.data.ServiceViewModel
|
import com.jihe.punchnet.data.ServiceViewModel
|
||||||
|
|
||||||
sealed class Screen(val route: String) {
|
sealed class Screen(val route: String) {
|
||||||
object MainScreen: Screen("main");
|
object MainScreen: Screen("home") // 首页
|
||||||
object RouteScreen: Screen("routes")
|
object DeviceScreen: Screen("devices") // 设备 (取代原先的 Route)
|
||||||
object ProfileScreen: Screen("profile")
|
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")
|
||||||
}
|
}
|
||||||
|
|
||||||
val bottomNavItems = listOf(
|
val bottomNavItems = listOf(
|
||||||
Screen.MainScreen,
|
Screen.MainScreen,
|
||||||
Screen.RouteScreen,
|
Screen.DeviceScreen,
|
||||||
Screen.ProfileScreen,
|
Screen.AppAuthScreen,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MainApp(
|
fun MainApp(
|
||||||
|
buttonViewModel: ButtonViewModel = viewModel(),
|
||||||
serviceViewModel: ServiceViewModel = viewModel(),
|
serviceViewModel: ServiceViewModel = viewModel(),
|
||||||
routeModel: RouteViewModel = viewModel(),
|
routeModel: RouteViewModel = viewModel(),
|
||||||
navController: NavHostController,
|
navController: NavHostController,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||||
|
val currentRoute by remember { derivedStateOf { navBackStackEntry?.destination?.route } }
|
||||||
|
val showBottomBar = currentRoute != Screen.LoginScreen.route
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
bottomBar = {
|
bottomBar = {
|
||||||
AppBottomNavigation(navController = navController)
|
if (showBottomBar) {
|
||||||
|
AppBottomNavigation(navController = navController)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
){ paddingValues ->
|
){ paddingValues ->
|
||||||
/*
|
/*
|
||||||
@ -54,7 +71,7 @@ fun MainApp(
|
|||||||
modifier = Modifier.padding(paddingValues).size(200.dp)
|
modifier = Modifier.padding(paddingValues).size(200.dp)
|
||||||
)
|
)
|
||||||
*/
|
*/
|
||||||
AppNavHost2(serviceViewModel, routeModel, navController, paddingValues)
|
AppNavHost2(buttonViewModel, serviceViewModel, routeModel, navController, paddingValues)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
189
app/src/main/java/com/jihe/punchnet/screen/SettingsScreen.kt
Normal file
189
app/src/main/java/com/jihe/punchnet/screen/SettingsScreen.kt
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
package com.jihe.punchnet.screen
|
||||||
|
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Clear
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.jihe.punchnet.data.RouteItem
|
||||||
|
import com.jihe.punchnet.data.RouteViewModel
|
||||||
|
import com.jihe.punchnet.helper.IntToIPString
|
||||||
|
import com.jihe.punchnet.helper.parseCIDRAndGW
|
||||||
|
import com.jihe.punchnet.sdlan.network.maskIPToDigit
|
||||||
|
import com.jihe.punchnet.data.NodeRepository
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SettingsScreen(enabled: Boolean, routeViewModel: RouteViewModel) {
|
||||||
|
val localProfile by NodeRepository.localProfile.collectAsState()
|
||||||
|
val exitNodes by NodeRepository.exitNodes.collectAsState()
|
||||||
|
val routeItems by routeViewModel.allRoutes.collectAsState(initial = emptyList())
|
||||||
|
|
||||||
|
val showAddRoute = remember { mutableStateOf<RouteItem?>(null) }
|
||||||
|
val shouldShow = remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
AddOrModifyRouteDialog(routeViewModel, showAddRoute, shouldShow, exitNodes)
|
||||||
|
|
||||||
|
LazyColumn(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
||||||
|
// Section 1: Local Profile
|
||||||
|
item {
|
||||||
|
Text("本机信息", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
if (localProfile != null) {
|
||||||
|
Card(modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp)) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
|
Text("Hostname: ${localProfile!!.hostname}")
|
||||||
|
Text("IP: ${localProfile!!.ip}/${localProfile!!.maskLen}")
|
||||||
|
Text("Identity ID: ${localProfile!!.identityId}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text("暂无本机网络信息", color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(bottom = 16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Section 2: Exit Nodes
|
||||||
|
item {
|
||||||
|
Text("出口节点", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
if (exitNodes.isEmpty()) {
|
||||||
|
Text("当前网络无可用出口节点", color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(bottom = 16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items(exitNodes) { exitNode ->
|
||||||
|
Card(modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
|
Text("名称: ${exitNode.nodeName}", fontWeight = FontWeight.Bold)
|
||||||
|
Text("网关 IP: ${exitNode.gateway}")
|
||||||
|
Text("代理网段: ${exitNode.targetNetwork}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item { Spacer(modifier = Modifier.height(8.dp)) }
|
||||||
|
|
||||||
|
// Section 3: Routes
|
||||||
|
item {
|
||||||
|
Text("本地路由配置", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
items(routeItems) { item ->
|
||||||
|
RouteCard(routeViewModel, item, enabled)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Button(
|
||||||
|
enabled = enabled,
|
||||||
|
onClick = { shouldShow.value = true },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text("添加路由")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AddOrModifyRouteDialog(
|
||||||
|
routeViewModel: RouteViewModel,
|
||||||
|
initRoute: MutableState<RouteItem?>,
|
||||||
|
shouldShowRoute: MutableState<Boolean>,
|
||||||
|
exitNodes: List<com.jihe.punchnet.api.ExitNodeItem>
|
||||||
|
) {
|
||||||
|
if (shouldShowRoute.value) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
var cidr by remember { mutableStateOf("") }
|
||||||
|
var gw by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { shouldShowRoute.value = false },
|
||||||
|
confirmButton = {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
val item = parseCIDRAndGW(cidr, gw)
|
||||||
|
if (item == null) {
|
||||||
|
Toast.makeText(context, "数据解析出错", Toast.LENGTH_SHORT).show()
|
||||||
|
} else {
|
||||||
|
routeViewModel.insert(item)
|
||||||
|
shouldShowRoute.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text("确定")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title = { Text("添加新路由") },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
TextField(
|
||||||
|
value = cidr,
|
||||||
|
onValueChange = { cidr = it },
|
||||||
|
label = { Text("CIDR") },
|
||||||
|
placeholder = { Text("例如 0.0.0.0/0") }
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
TextField(
|
||||||
|
value = gw,
|
||||||
|
onValueChange = { gw = it },
|
||||||
|
label = { Text("Gateway") },
|
||||||
|
placeholder = { Text("例如 10.10.4.5") }
|
||||||
|
)
|
||||||
|
if (exitNodes.isNotEmpty()) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text("快捷选择出口节点网关:", fontSize = 12.sp)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
exitNodes.forEach { en ->
|
||||||
|
SuggestionChip(
|
||||||
|
onClick = {
|
||||||
|
gw = en.gateway
|
||||||
|
if (cidr.isEmpty()) cidr = en.targetNetwork
|
||||||
|
},
|
||||||
|
label = { Text(en.nodeName) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun RouteCard(
|
||||||
|
routeViewModel: RouteViewModel,
|
||||||
|
item: RouteItem,
|
||||||
|
enabled: Boolean,
|
||||||
|
) {
|
||||||
|
Card(modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.height(48.dp).padding(horizontal = 16.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Clear,
|
||||||
|
contentDescription = "delete route",
|
||||||
|
modifier = Modifier.size(24.dp).clickable(enabled = enabled) {
|
||||||
|
routeViewModel.deleteById(item.id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
Text("${IntToIPString(item.net_ip)}/${maskIPToDigit(item.mask_ip)}")
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
Text("-> ${IntToIPString(item.gateway)}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
94
app/src/main/java/com/jihe/punchnet/screen/WebViewScreen.kt
Normal file
94
app/src/main/java/com/jihe/punchnet/screen/WebViewScreen.kt
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
package com.jihe.punchnet.screen
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.webkit.WebChromeClient
|
||||||
|
import android.webkit.WebResourceRequest
|
||||||
|
import android.webkit.WebView
|
||||||
|
import android.webkit.WebViewClient
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
|
@Composable
|
||||||
|
fun WebViewScreen(url: String, onBack: () -> Unit) {
|
||||||
|
var webViewRef by remember { mutableStateOf<WebView?>(null) }
|
||||||
|
var loadingProgress by remember { mutableStateOf(0f) }
|
||||||
|
var isLoading by remember { mutableStateOf(true) }
|
||||||
|
var currentTitle by remember { mutableStateOf("资源查看") }
|
||||||
|
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(currentTitle) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.Default.Close, contentDescription = "关闭")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = { webViewRef?.reload() }) {
|
||||||
|
Icon(Icons.Default.Refresh, contentDescription = "刷新")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = { loadingProgress },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
AndroidView(
|
||||||
|
factory = { context ->
|
||||||
|
WebView(context).apply {
|
||||||
|
settings.javaScriptEnabled = true
|
||||||
|
settings.domStorageEnabled = true
|
||||||
|
|
||||||
|
webViewClient = object : WebViewClient() {
|
||||||
|
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
|
||||||
|
return false // Let WebView load the URL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
webChromeClient = object : WebChromeClient() {
|
||||||
|
override fun onProgressChanged(view: WebView?, newProgress: Int) {
|
||||||
|
loadingProgress = newProgress / 100f
|
||||||
|
isLoading = newProgress < 100
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReceivedTitle(view: WebView?, title: String?) {
|
||||||
|
if (!title.isNullOrEmpty()) {
|
||||||
|
currentTitle = title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
webViewRef = this
|
||||||
|
loadUrl(url)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
update = { webView ->
|
||||||
|
// Don't loadUrl here again, it causes infinite reloading on recomposition
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,7 +5,9 @@ data class Arguments(
|
|||||||
// udp info of the super node
|
// udp info of the super node
|
||||||
val sn: String,
|
val sn: String,
|
||||||
// tcp info of the super node
|
// tcp info of the super node
|
||||||
val tcp: String,
|
|
||||||
|
// udp info of the STUN server
|
||||||
|
val stun: String,
|
||||||
|
|
||||||
val nat_server1: String,
|
val nat_server1: String,
|
||||||
val nat_server2: String,
|
val nat_server2: String,
|
||||||
|
|||||||
@ -16,13 +16,15 @@ const val BroadcastIP = 0xFFFFFFFF
|
|||||||
val BroadcastMac = ubyteArrayOf(0xffu, 0xffu, 0xffu, 0xffu, 0xffu, 0xffu).toByteArray()
|
val BroadcastMac = ubyteArrayOf(0xffu, 0xffu, 0xffu, 0xffu, 0xffu, 0xffu).toByteArray()
|
||||||
|
|
||||||
class ARPTable(
|
class ARPTable(
|
||||||
|
val scope: CoroutineScope,
|
||||||
) {
|
) {
|
||||||
val routeTable: RouteTable = RouteTable()
|
val routeTable: RouteTable = RouteTable()
|
||||||
|
|
||||||
val content = ConcurrentHashMap<Int, ARPInfo>()
|
val content = ConcurrentHashMap<Int, ARPInfo>()
|
||||||
|
|
||||||
suspend fun agingARP() {
|
suspend fun agingARP() {
|
||||||
CoroutineScope(Dispatchers.Default).async {
|
// CoroutineScope(Dispatchers.Default).async {
|
||||||
|
scope.async {
|
||||||
while(true) {
|
while(true) {
|
||||||
delay(20_000)
|
delay(20_000)
|
||||||
val now = System.currentTimeMillis()/1000
|
val now = System.currentTimeMillis()/1000
|
||||||
@ -42,12 +44,14 @@ class ARPTable(
|
|||||||
|
|
||||||
fun getMacFromIP(ip: Int): Pair<ByteArray?, Int> {
|
fun getMacFromIP(ip: Int): Pair<ByteArray?, Int> {
|
||||||
val gw = routeTable.getGateway(ip)
|
val gw = routeTable.getGateway(ip)
|
||||||
if (gw == null) {
|
if (gw == null || gw == 0) {
|
||||||
// not found in route table, just use the ip
|
// 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)
|
val value = content.get(ip)
|
||||||
return Pair(value?.mac, ip)
|
return Pair(value?.mac, ip)
|
||||||
} else {
|
} else {
|
||||||
// gw not null, try find the gw's mac
|
// gw not null, try find the gw's mac
|
||||||
|
TerminalLogger.debugf { "ARP: IP=${ipToString(ip)} uses gateway=${ipToString(gw)}." }
|
||||||
val value = content.get(gw)
|
val value = content.get(gw)
|
||||||
return Pair(value?.mac, gw)
|
return Pair(value?.mac, gw)
|
||||||
}
|
}
|
||||||
@ -126,20 +130,14 @@ class ARPWaitList {
|
|||||||
// just skip the packet
|
// just skip the packet
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
TerminalLogger.debugf { "ARP resumed flow: ${describeIpv4Packet(item.originData)}" }
|
||||||
val packet = formEthernetPacket(node.mac.toByteArray(), mac.toByteArray(), item.originData)
|
val packet = formEthernetPacket(node.mac.toByteArray(), mac.toByteArray(), item.originData)
|
||||||
val size = packet.remaining()
|
val size = packet.remaining()
|
||||||
val encrypted = node.aes.encrypt(packet)
|
val packetBytes = ByteArray(size)
|
||||||
|
packet.get(packetBytes)
|
||||||
|
val encrypted = node.aes.encrypt(packetBytes)
|
||||||
if (encrypted != null) {
|
if (encrypted != null) {
|
||||||
val data = SDLData.newBuilder()
|
sendPacketToNet(node, mac, encrypted, size.toLong())
|
||||||
.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())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,14 @@
|
|||||||
package com.jihe.punchnet.sdlan.network
|
package com.jihe.punchnet.sdlan.network
|
||||||
|
|
||||||
// import sdlanproto.Message.*
|
// import sdlanproto.Message.*
|
||||||
|
import android.util.Log
|
||||||
import com.google.protobuf.kotlin.toByteString
|
import com.google.protobuf.kotlin.toByteString
|
||||||
import com.jihe.punchnet.protobuf.PunchProto
|
import com.jihe.punchnet.protobuf.PunchProto
|
||||||
import com.jihe.punchnet.protobuf.PunchProto.SDLStunProbe
|
import com.jihe.punchnet.protobuf.PunchProto.SDLStunProbe
|
||||||
import com.jihe.punchnet.protobuf.PunchProto.SDLStunProbeReply
|
import com.jihe.punchnet.protobuf.PunchProto.SDLStunProbeReply
|
||||||
import com.jihe.punchnet.protobuf.PunchProto.SDLStunRequest
|
import com.jihe.punchnet.protobuf.PunchProto.SDLStunRequest
|
||||||
import com.jihe.punchnet.sdlan.config.SDLanMulticastConfig
|
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.AES
|
||||||
import com.jihe.punchnet.sdlan.utils.RSA
|
import com.jihe.punchnet.sdlan.utils.RSA
|
||||||
import com.jihe.punchnet.sdlan.utils.UniqueNodeID
|
import com.jihe.punchnet.sdlan.utils.UniqueNodeID
|
||||||
@ -50,8 +52,9 @@ class NodeConfig (
|
|||||||
val localPort: Int,
|
val localPort: Int,
|
||||||
|
|
||||||
val supernode: SDLanSock,
|
val supernode: SDLanSock,
|
||||||
val natServer1: InetSocketAddress,
|
val stunServer: SDLanSock,
|
||||||
val natServer2: InetSocketAddress
|
val natServer1: java.net.InetSocketAddress,
|
||||||
|
val natServer2: java.net.InetSocketAddress
|
||||||
)
|
)
|
||||||
|
|
||||||
class NodeStats private constructor(
|
class NodeStats private constructor(
|
||||||
@ -126,28 +129,36 @@ class Node private constructor (
|
|||||||
val aes: AES,
|
val aes: AES,
|
||||||
// var encryptKey: ByteArray,
|
// var encryptKey: ByteArray,
|
||||||
|
|
||||||
val config: NodeConfig,
|
var config: NodeConfig,
|
||||||
|
|
||||||
val pendingPeers: PeerMap,
|
val pendingPeers: PeerMap,
|
||||||
val knownPeers: PeerMap,
|
val knownPeers: PeerMap,
|
||||||
|
|
||||||
// 自身的公网ip
|
// 自身的公网ip
|
||||||
val outerIPV4: AtomicInteger,
|
val outerIPV4: AtomicInteger,
|
||||||
val udpSockV4: SDLanSocket,
|
var udpSockV4: SDLanSocket,
|
||||||
|
|
||||||
val udpSockMulticast: SDLanSocket?,
|
var udpSockMulticast: SDLanSocket?,
|
||||||
val multicastSock: SDLanSock,
|
var multicastSock: SDLanSock,
|
||||||
|
|
||||||
val stats: NodeStats,
|
val stats: NodeStats,
|
||||||
|
|
||||||
// send message to socket, if connected, and authorized
|
// send message to socket, if connected, and authorized
|
||||||
var toSocket: SendChannel<ByteArray>,
|
var toSocket: SendChannel<ByteArray>,
|
||||||
|
|
||||||
val startStopChannel: SendChannel<StartStopChanInfo>,
|
var startStopChannel: SendChannel<StartStopChanInfo>,
|
||||||
|
|
||||||
val mac: Mac = generateRandomMAC(),
|
val mac: Mac = generateRandomMAC(),
|
||||||
var nat_type: NatType = NatType.Invalid,
|
var nat_type: NatType = NatType.PortRestricted,
|
||||||
val natProbeCookie: NatProbeCookie = NatProbeCookie(),
|
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 {
|
companion object {
|
||||||
private var instance: Node? = null
|
private var instance: Node? = null
|
||||||
@ -230,24 +241,51 @@ class Node private constructor (
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun sendStunRequest() {
|
suspend fun sendStunRequest() {
|
||||||
|
if (this.clientId.isEmpty()) {
|
||||||
|
this.clientId = "punchnet_android_client_" + UniqueNodeID.getUUID()
|
||||||
|
}
|
||||||
val req = SDLStunRequest.newBuilder()
|
val req = SDLStunRequest.newBuilder()
|
||||||
.setCookie(0)
|
.setClientId(this.clientId)
|
||||||
.setClientId(UniqueNodeID.getUUID())
|
|
||||||
.setNetworkId(this.networkID.get())
|
.setNetworkId(this.networkID.get())
|
||||||
.setIp(this.deviceConfig.ip.netAddr)
|
.setIp(this.deviceConfig.ip.netAddr)
|
||||||
.setMac(this.mac)
|
.setMac(this.mac)
|
||||||
.setNatType(this.nat_type.toByte().toInt())
|
.setNatType(this.nat_type.toByte().toInt())
|
||||||
.setV6Info(PunchProto.SDLV6Info.getDefaultInstance())
|
.setV6Info(PunchProto.SDLV6Info.getDefaultInstance())
|
||||||
|
.setSessionToken(this.sessionToken)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
val msg = encodeToUDPMessage(req, PacketType.StunRequest)
|
val msg = encodeToUDPMessage(req, PacketType.StunRequest)
|
||||||
sendToSock(this, msg, config.supernode)
|
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"}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun probeNatType() {
|
suspend fun probeNatType() {
|
||||||
|
val previousNatType = nat_type
|
||||||
val reply1 = this._sendAndWaitForProbeReply(StunProbeAttr.None, config.natServer1)
|
val reply1 = this._sendAndWaitForProbeReply(StunProbeAttr.None, config.natServer1)
|
||||||
if (reply1 == null) {
|
if (reply1 == null) {
|
||||||
nat_type = NatType.Blocked
|
TerminalLogger.warning { "STUN probe attr=None timed out; keeping natType=${nat_type}" }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (reply1.ip == (outerIPV4.get())) {
|
if (reply1.ip == (outerIPV4.get())) {
|
||||||
@ -268,7 +306,14 @@ class Node private constructor (
|
|||||||
|
|
||||||
val reply3 = this._sendAndWaitForProbeReply(StunProbeAttr.None, config.natServer2)
|
val reply3 = this._sendAndWaitForProbeReply(StunProbeAttr.None, config.natServer2)
|
||||||
if (reply3 == null) {
|
if (reply3 == null) {
|
||||||
nat_type = NatType.Blocked
|
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}"
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -286,34 +331,28 @@ class Node private constructor (
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun _sendAndWaitForProbeReply(attr: StunProbeAttr, toServer: SocketAddress): SDLStunProbeReply? {
|
suspend fun _sendAndWaitForProbeReply(attr: StunProbeAttr, toServer: java.net.SocketAddress): SDLStunProbeReply? {
|
||||||
val channel = Channel<SDLStunProbeReply>(100)
|
val channel = kotlinx.coroutines.channels.Channel<SDLStunProbeReply>(100)
|
||||||
val cookie = natProbeCookie.addChannel(channel)
|
val cookie = natProbeCookie.addChannel(channel)
|
||||||
val probe = SDLStunProbe.newBuilder()
|
val probe = com.jihe.punchnet.protobuf.PunchProto.SDLStunProbe.newBuilder()
|
||||||
.setAttr(attr.ordinal.toInt())
|
.setAttr(attr.ordinal.toInt())
|
||||||
.setCookie(cookie)
|
.setCookie(cookie)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
val msg = encodeToUDPMessage(probe, PacketType.StunProbe)
|
val msg = encodeToUDPMessage(probe, PacketType.StunProbe)
|
||||||
|
TerminalLogger.debugf { "send STUN_PROBE attr=$attr cookie=$cookie to $toServer" }
|
||||||
this.udpSockV4.send_to(msg.toByteArray(), toServer)
|
this.udpSockV4.send_to(msg.toByteArray(), toServer)
|
||||||
|
|
||||||
val k = withTimeoutOrNull(5000) {
|
val k = kotlinx.coroutines.withTimeoutOrNull(5000) {
|
||||||
|
|
||||||
val response = channel.receive()
|
val response = channel.receive()
|
||||||
return@withTimeoutOrNull response
|
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)
|
natProbeCookie.remove(cookie)
|
||||||
return k
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,25 +1,35 @@
|
|||||||
package com.jihe.punchnet.sdlan.network
|
package com.jihe.punchnet.sdlan.network
|
||||||
|
|
||||||
import com.google.protobuf.kotlin.toByteString
|
import com.google.protobuf.kotlin.toByteString
|
||||||
|
import com.google.gson.JsonElement
|
||||||
import com.jihe.punchnet.protobuf.PunchProto.SDLData
|
import com.jihe.punchnet.protobuf.PunchProto.SDLData
|
||||||
import com.jihe.punchnet.protobuf.PunchProto.SDLPeerInfo
|
import com.jihe.punchnet.protobuf.PunchProto.SDLPeerInfo
|
||||||
import com.jihe.punchnet.protobuf.PunchProto.SDLQueryInfo
|
import com.jihe.punchnet.protobuf.PunchProto.SDLQueryInfo
|
||||||
import com.jihe.punchnet.protobuf.PunchProto.SDLRegister
|
import com.jihe.punchnet.protobuf.PunchProto.SDLRegister
|
||||||
import com.jihe.punchnet.protobuf.PunchProto.SDLRegisterAck
|
import com.jihe.punchnet.protobuf.PunchProto.SDLRegisterAck
|
||||||
import com.jihe.punchnet.protobuf.PunchProto.SDLSendRegisterEvent
|
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.sdlan.config.EtherType
|
import com.jihe.punchnet.sdlan.config.EtherType
|
||||||
import com.jihe.punchnet.sdlan.config.SDLanConfig
|
import com.jihe.punchnet.sdlan.config.SDLanConfig
|
||||||
import com.jihe.punchnet.sdlan.logs.TerminalLogger
|
import com.jihe.punchnet.sdlan.logs.TerminalLogger
|
||||||
import com.jihe.punchnet.sdlan.utils.isMultiBroadcast
|
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 com.jihe.punchnet.sdlan.utils.macToString
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.net.URI
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import java.nio.ByteOrder
|
import java.nio.ByteOrder
|
||||||
|
|
||||||
|
|
||||||
suspend fun handlePacketData(
|
suspend fun handlePacketData(
|
||||||
|
scope: CoroutineScope,
|
||||||
node: Node,
|
node: Node,
|
||||||
body: ByteBuffer,
|
body: ByteBuffer,
|
||||||
senderSock: SDLanSock
|
senderSock: SDLanSock
|
||||||
@ -36,7 +46,8 @@ suspend fun handlePacketData(
|
|||||||
TerminalLogger.debugf {
|
TerminalLogger.debugf {
|
||||||
"[P2P] Rx data from ${senderSock}"
|
"[P2P] Rx data from ${senderSock}"
|
||||||
}
|
}
|
||||||
CoroutineScope(Dispatchers.Default).async {
|
// CoroutineScope(Dispatchers.Default).async {
|
||||||
|
scope.async {
|
||||||
checkPeerRegistrationNeeded(
|
checkPeerRegistrationNeeded(
|
||||||
node,
|
node,
|
||||||
false,
|
false,
|
||||||
@ -296,15 +307,311 @@ suspend fun sendRegister(
|
|||||||
.build()
|
.build()
|
||||||
val msg = encodeToUDPMessage(register, PacketType.Register)
|
val msg = encodeToUDPMessage(register, PacketType.Register)
|
||||||
|
|
||||||
|
TerminalLogger.debugf { "send REGISTER to ${macToString(mac)} at $sock, natType=$natType" }
|
||||||
sendToSock(node, msg, sock)
|
sendToSock(node, msg, sock)
|
||||||
|
|
||||||
// TODO: need guess port
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun sendPacketToNet(node: Node, dstmac: Mac, content: List<Byte>, size: Long) {
|
suspend fun sendPacketToNet(node: Node, dstmac: Mac, encrypted: ByteArray, size: Long) {
|
||||||
val destination = findPeerDestination(node, dstmac, size)
|
var is_p2p: Boolean = false
|
||||||
TerminalLogger.debugf { "send PACKET to ${destination}" }
|
var is_multicast: Boolean = false
|
||||||
sendToSock(node, content, destination)
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -315,20 +622,20 @@ suspend fun findPeerDestination(node: Node, dstmac: Mac, size: Long): SDLanSock
|
|||||||
if (isMultiBroadcast(dstmac)) {
|
if (isMultiBroadcast(dstmac)) {
|
||||||
node.stats.txSup.addAndGet(size)
|
node.stats.txSup.addAndGet(size)
|
||||||
node.stats.txBroadcast.addAndGet(size)
|
node.stats.txBroadcast.addAndGet(size)
|
||||||
result = node.config.supernode
|
result = node.config.stunServer
|
||||||
is_multicast = true
|
is_multicast = true
|
||||||
} else {
|
} else {
|
||||||
val peer = node.knownPeers.get(dstmac)
|
val peer = node.knownPeers.get(dstmac)
|
||||||
if (peer == null) {
|
if (peer == null) {
|
||||||
node.stats.txSup.addAndGet(size)
|
node.stats.txSup.addAndGet(size)
|
||||||
result = node.config.supernode
|
result = node.config.stunServer
|
||||||
} else {
|
} else {
|
||||||
val now = System.currentTimeMillis()/1000
|
val now = System.currentTimeMillis()/1000
|
||||||
if (now - peer.lastP2P.get() >= peer.timeout/2) {
|
if (now - peer.lastP2P.get() >= peer.timeout/2) {
|
||||||
TerminalLogger.warning { "last p2p is too old, deleting from known hosts" }
|
TerminalLogger.warning { "last p2p is too old, deleting from known hosts" }
|
||||||
node.knownPeers.remove(dstmac)
|
node.knownPeers.remove(dstmac)
|
||||||
node.stats.txSup.addAndGet(size)
|
node.stats.txSup.addAndGet(size)
|
||||||
result = node.config.supernode
|
result = node.config.stunServer
|
||||||
} else {
|
} else {
|
||||||
is_p2p = true
|
is_p2p = true
|
||||||
node.stats.txP2P.addAndGet(size)
|
node.stats.txP2P.addAndGet(size)
|
||||||
@ -386,12 +693,12 @@ suspend fun sendQueryPeer(node: Node, dstmac: Mac) {
|
|||||||
val query = SDLQueryInfo.newBuilder()
|
val query = SDLQueryInfo.newBuilder()
|
||||||
.setDstMac(dstmac)
|
.setDstMac(dstmac)
|
||||||
.build()
|
.build()
|
||||||
val msg = encodeToTcpMessage(query, node.getNextPacketID(), PacketType.QueryInfo)
|
val msg = encodeToControlMessage(query, PacketType.QueryInfo)
|
||||||
node.toSocket.send(msg.toByteArray())
|
node.toSocket.send(msg.toByteArray())
|
||||||
}
|
}
|
||||||
|
|
||||||
fun formEthernetPacket(srcmac: ByteArray, dstmac: ByteArray, data: ByteArray): ByteBuffer {
|
fun formEthernetPacket(srcmac: ByteArray, dstmac: ByteArray, data: ByteArray): ByteBuffer {
|
||||||
val buffer = ByteBuffer.allocate(14 + data.size + 4).order(ByteOrder.BIG_ENDIAN)
|
val buffer = ByteBuffer.allocate(14 + data.size).order(ByteOrder.BIG_ENDIAN)
|
||||||
buffer.put(dstmac)
|
buffer.put(dstmac)
|
||||||
buffer.put(srcmac)
|
buffer.put(srcmac)
|
||||||
buffer.putShort(EtherType.IPV4)
|
buffer.putShort(EtherType.IPV4)
|
||||||
@ -404,54 +711,140 @@ fun formEthernetPacket(srcmac: ByteArray, dstmac: ByteArray, data: ByteArray): B
|
|||||||
// fromip is self ip
|
// fromip is self ip
|
||||||
// queryip is the targetip
|
// queryip is the targetip
|
||||||
suspend fun sendArpRequest(node: Node, queryip: Int) {
|
suspend fun sendArpRequest(node: Node, queryip: Int) {
|
||||||
println(1)
|
if (!node.aes.isAuthorized()) {
|
||||||
val frommac = node.mac.toByteArray()
|
TerminalLogger.errorf { "not authed for send arp request" }
|
||||||
val fromip = node.deviceConfig.ip.netAddr
|
return
|
||||||
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()
|
|
||||||
println(5)
|
|
||||||
val msg = encodeToUDPMessage(data, PacketType.Data)
|
|
||||||
sendPacketToNet(node, SDLanConfig.BROADCASTMAC, msg, size.toLong())
|
|
||||||
}
|
}
|
||||||
println(6)
|
val arpReq = SDLArpRequest.newBuilder()
|
||||||
|
.setTargetIp(queryip)
|
||||||
|
.setOriginIp(node.deviceConfig.ip.netAddr)
|
||||||
|
.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)}" }
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun handleTcpCommand(node: Node, cmdtype: Byte, cmdprotobuf: ByteArray) {}
|
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 handlePacketPeerInfo(node: Node, content: ByteArray) {
|
suspend fun handlePacketPeerInfo(node: Node, content: ByteArray) {
|
||||||
val pinfo: SDLPeerInfo
|
val pinfo: SDLPeerInfo
|
||||||
@ -467,6 +860,11 @@ suspend fun handlePacketPeerInfo(node: Node, content: ByteArray) {
|
|||||||
return
|
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 remoteNat = NatType.fromUByte(pinfo.v4Info.natType.toUByte())
|
||||||
val pending = node.pendingPeers.get(pinfo.dstMac)
|
val pending = node.pendingPeers.get(pinfo.dstMac)
|
||||||
if (pending == null) {
|
if (pending == null) {
|
||||||
@ -479,16 +877,18 @@ suspend fun handlePacketPeerInfo(node: Node, content: ByteArray) {
|
|||||||
sendRegister(node, remoteNat, pending.sock, pinfo.dstMac)
|
sendRegister(node, remoteNat, pending.sock, pinfo.dstMac)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun handleTcpEvent(node: Node, event: EventType, cmdprotobuf: ByteArray) {
|
suspend fun handleEvent(node: Node, cmdprotobuf: ByteArray) {
|
||||||
when(event) {
|
val evt: com.jihe.punchnet.protobuf.PunchProto.SDLEvent
|
||||||
EventType.SendRegister -> {
|
try {
|
||||||
val reg: SDLSendRegisterEvent
|
evt = com.jihe.punchnet.protobuf.PunchProto.SDLEvent.parseFrom(cmdprotobuf)
|
||||||
try {
|
} catch (e: Exception) {
|
||||||
reg = SDLSendRegisterEvent.parseFrom(cmdprotobuf)
|
TerminalLogger.errorf {"failed to decode SDLEvent: $e"}
|
||||||
} catch (e: Exception) {
|
return
|
||||||
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 remoteNat = NatType.fromUByte(reg.natType.toUByte())
|
||||||
val ip = byteArrayOf(
|
val ip = byteArrayOf(
|
||||||
(reg.natIp ushr 24).and(0xff).toByte(),
|
(reg.natIp ushr 24).and(0xff).toByte(),
|
||||||
@ -499,7 +899,7 @@ suspend fun handleTcpEvent(node: Node, event: EventType, cmdprotobuf: ByteArray)
|
|||||||
checkPeerRegistrationNeeded(node,false, reg.dstMac, remoteNat, SDLanSock(IPFamily.IPV4, reg.natPort, ip))
|
checkPeerRegistrationNeeded(node,false, reg.dstMac, remoteNat, SDLanSock(IPFamily.IPV4, reg.natPort, ip))
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
TerminalLogger.warning { "unhandled event: $event" }
|
TerminalLogger.warning { "unhandled event: ${evt.eventCase}" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -511,4 +911,4 @@ fun ipInt2ByteArray(ip: Int): ByteArray {
|
|||||||
(ip ushr 8).and(0xff).toByte(),
|
(ip ushr 8).and(0xff).toByte(),
|
||||||
ip.and(0xff).toByte(),
|
ip.and(0xff).toByte(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import com.jihe.punchnet.sdlan.utils.EthHdr
|
|||||||
import com.jihe.punchnet.sdlan.utils.ipToString
|
import com.jihe.punchnet.sdlan.utils.ipToString
|
||||||
import com.jihe.punchnet.sdlan.utils.isMultiBroadcast
|
import com.jihe.punchnet.sdlan.utils.isMultiBroadcast
|
||||||
import com.jihe.punchnet.sdlan.utils.macToString
|
import com.jihe.punchnet.sdlan.utils.macToString
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.io.BufferedReader
|
import java.io.BufferedReader
|
||||||
@ -21,7 +22,6 @@ import java.net.Inet4Address
|
|||||||
import java.net.Socket
|
import java.net.Socket
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import java.nio.ByteOrder
|
import java.nio.ByteOrder
|
||||||
import java.util.zip.CRC32
|
|
||||||
|
|
||||||
interface Iface {
|
interface Iface {
|
||||||
suspend fun doInit()
|
suspend fun doInit()
|
||||||
@ -29,6 +29,7 @@ interface Iface {
|
|||||||
suspend fun send(content: ByteArray)
|
suspend fun send(content: ByteArray)
|
||||||
suspend fun reload_config(config: DeviceConfig)
|
suspend fun reload_config(config: DeviceConfig)
|
||||||
|
|
||||||
|
fun close() {}
|
||||||
suspend fun handleDataFromNet(node: Node, data: ByteArray)
|
suspend fun handleDataFromNet(node: Node, data: ByteArray)
|
||||||
suspend fun handleDataFromDevice(node: Node, data: ByteArray)
|
suspend fun handleDataFromDevice(node: Node, data: ByteArray)
|
||||||
}
|
}
|
||||||
@ -99,24 +100,16 @@ interface IfaceTun: Iface {
|
|||||||
arp.dipaddr = arp.sipaddr
|
arp.dipaddr = arp.sipaddr
|
||||||
arp.sipaddr = node.deviceConfig.ip.netAddr
|
arp.sipaddr = node.deviceConfig.ip.netAddr
|
||||||
|
|
||||||
val bytes = arp.marshalToBytes()
|
val bytesBuf = arp.marshalToBytes()
|
||||||
val encrypted = node.aes.encrypt(bytes)
|
val bytesArr = ByteArray(bytesBuf.remaining())
|
||||||
|
bytesBuf.get(bytesArr)
|
||||||
|
val encrypted = node.aes.encrypt(bytesArr)
|
||||||
if (encrypted != null) {
|
if (encrypted != null) {
|
||||||
val dstmac = arp.dhwaddr.toByteString()
|
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 {
|
TerminalLogger.debugf {
|
||||||
"send arp reply to ${macToString(dstmac)}"
|
"send arp reply to ${macToString(dstmac)}"
|
||||||
}
|
}
|
||||||
val content = encodeToUDPMessage(data, PacketType.Data)
|
sendPacketToNet(node, dstmac, encrypted, 0)
|
||||||
sendPacketToNet(node, dstmac, content, 0)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -164,17 +157,28 @@ interface IfaceTun: Iface {
|
|||||||
TerminalLogger.infof { "dropping tun packet due to not authed" }
|
TerminalLogger.infof { "dropping tun packet due to not authed" }
|
||||||
return
|
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 buffer = ByteBuffer.wrap(data, 12, 8).order(ByteOrder.BIG_ENDIAN)
|
||||||
val srcip = buffer.getInt()
|
val srcip = buffer.getInt()
|
||||||
val dstip = buffer.getInt()
|
val dstip = buffer.getInt()
|
||||||
|
|
||||||
TerminalLogger.debugf { "got ${data.size} bytes from tun" }
|
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)) {
|
if (!node.config.allowRouting && (srcip != node.deviceConfig.ip.netAddr)) {
|
||||||
TerminalLogger.infof { "dropping routed packet from tun" }
|
TerminalLogger.infof { "dropping routed packet from tun" }
|
||||||
return
|
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)
|
val (arpinfo, gwip) = arpTable.getMacFromIP(dstip)
|
||||||
if (arpinfo == null) {
|
if (arpinfo == null) {
|
||||||
println("arp info is null")
|
println("arp info is null")
|
||||||
@ -188,27 +192,11 @@ interface IfaceTun: Iface {
|
|||||||
val buffer = arpinfo + node.mac.toByteArray() +
|
val buffer = arpinfo + node.mac.toByteArray() +
|
||||||
byteArrayOf((EtherType.IPV4.toInt() shr 8).toByte(), EtherType.IPV4.toByte()) +
|
byteArrayOf((EtherType.IPV4.toInt() shr 8).toByte(), EtherType.IPV4.toByte()) +
|
||||||
data
|
data
|
||||||
|
val size = buffer.size
|
||||||
val crc = CRC32()
|
val encrypted = node.aes.encrypt(buffer)
|
||||||
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) {
|
if (encrypted != null) {
|
||||||
val mac = arpinfo.toByteString()
|
val mac = arpinfo.toByteString()
|
||||||
val data = SDLData.newBuilder()
|
sendPacketToNet(node, mac, encrypted, size.toLong())
|
||||||
.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())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -216,7 +204,7 @@ interface IfaceTun: Iface {
|
|||||||
|
|
||||||
class IfaceMock: IfaceTun {
|
class IfaceMock: IfaceTun {
|
||||||
val deviceName = "dev0"
|
val deviceName = "dev0"
|
||||||
override val arpTable = ARPTable()
|
override val arpTable = ARPTable(CoroutineScope(Dispatchers.IO))
|
||||||
override val arpWaitList = ARPWaitList()
|
override val arpWaitList = ARPWaitList()
|
||||||
|
|
||||||
val sock = Socket(Inet4Address.getByName("127.0.0.1"), 1234)
|
val sock = Socket(Inet4Address.getByName("127.0.0.1"), 1234)
|
||||||
@ -226,6 +214,9 @@ class IfaceMock: IfaceTun {
|
|||||||
|
|
||||||
var config: DeviceConfig = DeviceConfig(0)
|
var config: DeviceConfig = DeviceConfig(0)
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun recv(): ByteArray {
|
override suspend fun recv(): ByteArray {
|
||||||
val result = withContext(Dispatchers.IO) {
|
val result = withContext(Dispatchers.IO) {
|
||||||
val size = input.readInt()
|
val size = input.readInt()
|
||||||
@ -302,4 +293,4 @@ fun macToString(mac: Mac): String {
|
|||||||
}
|
}
|
||||||
return result.joinToString(":")
|
return result.joinToString(":")
|
||||||
}
|
}
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -2,10 +2,14 @@ package com.jihe.punchnet.sdlan.network
|
|||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.jihe.punchnet.PunchnetServiceArgument
|
import com.jihe.punchnet.PunchnetServiceArgument
|
||||||
import com.jihe.punchnet.protobuf.PunchProto.SDLDevAddr
|
import com.jihe.punchnet.data.ButtonRepository
|
||||||
|
import com.jihe.punchnet.data.ButtonState
|
||||||
|
|
||||||
import com.jihe.punchnet.protobuf.PunchProto.SDLRegisterSuper
|
import com.jihe.punchnet.protobuf.PunchProto.SDLRegisterSuper
|
||||||
import com.jihe.punchnet.protobuf.PunchProto.SDLRegisterSuperAck
|
import com.jihe.punchnet.protobuf.PunchProto.SDLRegisterSuperAck
|
||||||
import com.jihe.punchnet.protobuf.PunchProto.SDLRegisterSuperNak
|
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.protobuf.PunchProto.SDLStunProbeReply
|
||||||
import com.jihe.punchnet.sdlan.config.Arguments
|
import com.jihe.punchnet.sdlan.config.Arguments
|
||||||
import com.jihe.punchnet.sdlan.config.RSAConfig
|
import com.jihe.punchnet.sdlan.config.RSAConfig
|
||||||
@ -18,14 +22,19 @@ import com.jihe.punchnet.sdlan.utils.UniqueNodeID
|
|||||||
import com.jihe.punchnet.sdlan.utils.ipToString
|
import com.jihe.punchnet.sdlan.utils.ipToString
|
||||||
import com.jihe.punchnet.sdlan.utils.macToString
|
import com.jihe.punchnet.sdlan.utils.macToString
|
||||||
import com.jihe.punchnet.sdlan.utils.parseScoketAddressV4FromString
|
import com.jihe.punchnet.sdlan.utils.parseScoketAddressV4FromString
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.NonCancellable
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.cancelAndJoin
|
import kotlinx.coroutines.cancelAndJoin
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.channels.ReceiveChannel
|
import kotlinx.coroutines.channels.ReceiveChannel
|
||||||
import kotlinx.coroutines.channels.SendChannel
|
import kotlinx.coroutines.channels.SendChannel
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.selects.select
|
import kotlinx.coroutines.selects.select
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.io.DataInputStream
|
import java.io.DataInputStream
|
||||||
@ -40,52 +49,105 @@ import java.util.concurrent.atomic.AtomicLong
|
|||||||
import kotlin.io.path.pathString
|
import kotlin.io.path.pathString
|
||||||
import kotlin.system.exitProcess
|
import kotlin.system.exitProcess
|
||||||
|
|
||||||
suspend fun onMessage(data: SDLanTCP) {
|
suspend fun onMessage(scope: CoroutineScope, data: SDLanControl) {
|
||||||
val node = Node.getInstance()
|
val node = Node.getInstance()
|
||||||
|
|
||||||
TerminalLogger.debugf {"message received"}
|
TerminalLogger.debugf {"message received"}
|
||||||
when(data.packetType) {
|
when(data.packetType) {
|
||||||
PacketType.RegisterSuperACK -> {
|
PacketType.Welcome -> {
|
||||||
println("11")
|
TerminalLogger.debugf { "got welcome, sending register super" }
|
||||||
val ack = SDLRegisterSuperAck.parseFrom(data.currentPacket)
|
if (node.clientId.isEmpty()) {
|
||||||
// TerminalLogger.debugf { "got register super ack: $ack" }
|
node.clientId = "punchnet_android_client_" + UniqueNodeID.getUUID()
|
||||||
println("ack is ${ack}")
|
}
|
||||||
println("ack's key is ${ack.aesKey.size()}")
|
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 aeskey = node.rsa.decrypt(ack.aesKey.toByteArray())
|
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 -> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
val aeskey = node.rsa.decrypt(ack.key.toByteArray())
|
||||||
if (aeskey == null) {
|
if (aeskey == null) {
|
||||||
println("aes key error")
|
println("aes key error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
println("decrypted aes is: ${aeskey.contentToString()}")
|
node.aes.setSecret(aeskey, algorithm, ack.regionId.toLong())
|
||||||
node.aes.setSecret(aeskey)
|
node.sessionToken = ack.sessionToken
|
||||||
val ipStr = ipToString(ack.devAddr.netAddr)
|
|
||||||
TerminalLogger.debugf { "assigned ip: $ipStr" }
|
node.deviceConfig.ip.netAddr = com.jihe.punchnet.sdlan.utils.ipStringToInt(node.connectData?.ip ?: "0.0.0.0").toUInt().toInt()
|
||||||
node.deviceConfig.ip.netAddr = ack.devAddr.netAddr
|
node.deviceConfig.ip.netBitLen = (node.connectData?.maskLen ?: 24).toByte()
|
||||||
node.deviceConfig.ip.netBitLen = ack.devAddr.netBitLen.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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
node.iface?.reload_config(node.deviceConfig)
|
node.iface?.reload_config(node.deviceConfig)
|
||||||
|
node.networkID.set(node.authData?.networkId ?: 0)
|
||||||
node.networkID.set(ack.devAddr.networkId)
|
TerminalLogger.debugf {
|
||||||
// println("got aes key: ${aeskey.toList()}, length is ${aeskey.size}")
|
"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.sendStunRequest()
|
node.sendStunRequest()
|
||||||
|
scope.launch {
|
||||||
CoroutineScope(Dispatchers.Default).async {
|
try {
|
||||||
node.probeNatType()
|
node.probeNatType()
|
||||||
TerminalLogger.debugf { "nat type is ${node.nat_type}"}
|
node.sendStunRequest()
|
||||||
|
TerminalLogger.debugf { "updated NAT type after probe: ${node.nat_type}" }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
TerminalLogger.warning { "NAT probe failed: $e" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PacketType.RegisterSuperNAK -> {
|
PacketType.RegisterSuperNAK -> {
|
||||||
println("21")
|
|
||||||
val nak = SDLRegisterSuperNak.parseFrom(data.currentPacket)
|
val nak = SDLRegisterSuperNak.parseFrom(data.currentPacket)
|
||||||
TerminalLogger.debugf { "got register super nak: $nak" }
|
TerminalLogger.debugf { "got register super nak: $nak" }
|
||||||
val nakcode = NakMsgCode.fromByte(nak.errorCode.toByte())
|
val nakcode = NakMsgCode.fromByte(nak.errorCode.toByte())
|
||||||
when(nakcode) {
|
when(nakcode) {
|
||||||
NakMsgCode.InvalidToken, NakMsgCode.NodeDisabled-> {
|
NakMsgCode.InvalidToken, NakMsgCode.NodeDisabled-> {
|
||||||
node.startStopChannel.send(StartStopChanInfo(StartStopFlag.IsStop, null))
|
node.startStopChannel.send(StartStopChanInfo(StartStopFlag.IsStop, null))
|
||||||
exitProcess(-1)
|
ButtonRepository.updateState(ButtonState.ButtonStopped)
|
||||||
|
scope.cancel()
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
|
ButtonRepository.updateState(ButtonState.ButtonStopped)
|
||||||
node.startStopChannel.send(StartStopChanInfo(StartStopFlag.IsStop, null))
|
node.startStopChannel.send(StartStopChanInfo(StartStopFlag.IsStop, null))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -97,7 +159,7 @@ suspend fun onMessage(data: SDLanTCP) {
|
|||||||
TerminalLogger.errorf { "malformed command received"}
|
TerminalLogger.errorf { "malformed command received"}
|
||||||
return
|
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 -> {
|
PacketType.PeerInfo -> {
|
||||||
TerminalLogger.debugf { "PeerInfo received" }
|
TerminalLogger.debugf { "PeerInfo received" }
|
||||||
@ -105,28 +167,48 @@ suspend fun onMessage(data: SDLanTCP) {
|
|||||||
}
|
}
|
||||||
PacketType.Event -> {
|
PacketType.Event -> {
|
||||||
TerminalLogger.debugf { "Event received" }
|
TerminalLogger.debugf { "Event received" }
|
||||||
if (data.currentPacket.size < 1) {
|
handleEvent(node, data.currentPacket)
|
||||||
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 -> {
|
PacketType.Pong -> {
|
||||||
TerminalLogger.debugf { "tcp Pong Received" }
|
TerminalLogger.debugf { "Pong Received" }
|
||||||
// node.ping_to_sn()
|
}
|
||||||
|
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}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
println("error packet type: ${data.packetType.toUByte()}")
|
println("error packet type: ${data.packetType.toUByte()}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun run_sdlan(iface: Iface, argument: Arguments, routeinfo: PunchnetServiceArgument?) {
|
suspend fun run_sdlan(scope: CoroutineScope, iface: Iface, argument: Arguments, authData: com.jihe.punchnet.api.AuthResponseData, connectData: com.jihe.punchnet.api.ConnectResponseData) {
|
||||||
UniqueNodeID.setBaseDir(argument.baseDir)
|
UniqueNodeID.setBaseDir(argument.baseDir)
|
||||||
val edgeUUID = UniqueNodeID.getUUID()
|
val edgeUUID = UniqueNodeID.getUUID()
|
||||||
val config = parseConfig(edgeUUID, argument)
|
val config = parseConfig(edgeUUID, argument)
|
||||||
@ -137,83 +219,58 @@ suspend fun run_sdlan(iface: Iface, argument: Arguments, routeinfo: PunchnetServ
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
val toSocket = Channel<ByteArray>(100)
|
val toSocket = Channel<ByteArray>(100)
|
||||||
val start_stop_channel = Channel<StartStopChanInfo>(100)
|
val start_stop_channel = Channel<StartStopChanInfo>(100)
|
||||||
initEdge(iface, argument.token, config, toSocket, start_stop_channel)
|
initEdge(scope, iface, argument.token, config, toSocket, start_stop_channel)
|
||||||
|
|
||||||
val tcp = argument.tcp.split(":")
|
val snParts = argument.sn.split(":")
|
||||||
|
|
||||||
val now = System.currentTimeMillis() / 1000
|
val now = System.currentTimeMillis() / 1000
|
||||||
|
|
||||||
val node = Node.getInstance()
|
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 { "self mac: ${macToString(node.mac)}"}
|
||||||
|
TerminalLogger.debugf { describeConnectDataForLog(node) }
|
||||||
|
|
||||||
|
val onConnected: suspend (tech.kwik.core.QuicStream) -> Unit = { stream ->
|
||||||
val onConnected: suspend (Socket, Int?) -> Unit = { socket, pktID ->
|
TerminalLogger.debugf { "QUIC stream connected callback" }
|
||||||
println("connected callback")
|
// We wait for server's PACKET_WELCOME before sending RegisterSuper
|
||||||
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 ->
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
||||||
CoroutineScope((Dispatchers.IO)).async {
|
scope.launch {
|
||||||
while(true) {
|
try {
|
||||||
val data = node.iface?.recv()
|
while(true) {
|
||||||
Log.d("SDLAN", "async receive data from iface: ${data?.size} bytes")
|
if (node.iface == null) {
|
||||||
if (data == null) {
|
delay(1000)
|
||||||
//delay(1000)
|
continue
|
||||||
println("got data is null")
|
}
|
||||||
continue
|
val data = node.iface?.recv()
|
||||||
|
Log.d("SDLAN", "async receive data from iface: ${data?.size} bytes")
|
||||||
|
if (data == null) {
|
||||||
|
delay(1000)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (data.isEmpty()) {
|
||||||
|
delay(1000)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
Log.d("SDLAN", "handle data form device starts")
|
||||||
|
node.iface?.handleDataFromDevice(node, data)
|
||||||
|
Log.d("SDLAN", "handle data form device stops")
|
||||||
}
|
}
|
||||||
if (data.isEmpty()) {
|
} catch (e: Exception) {
|
||||||
println("got data size 0")
|
println("iface read is cancelled")
|
||||||
// delay(1000)
|
node.iface?.close()
|
||||||
continue
|
node.udpSockV4.close()
|
||||||
}
|
node.udpSockMulticast?.close()
|
||||||
Log.d("SDLAN", "handle data form device starts")
|
|
||||||
node.iface?.handleDataFromDevice(node, data)
|
|
||||||
Log.d("SDLAN", "handle data form device stops")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
CoroutineScope(Dispatchers.IO).async {
|
scope.launch {
|
||||||
initTCPConn(
|
initQUICConn(
|
||||||
tcp[0], tcp[1].toInt(),
|
scope,
|
||||||
|
snParts[0], snParts[1].toIntOrNull() ?: 1265,
|
||||||
start_stop_channel,
|
start_stop_channel,
|
||||||
AtomicLong(now),
|
AtomicLong(now),
|
||||||
AtomicBoolean(false),
|
AtomicBoolean(false),
|
||||||
@ -227,11 +284,12 @@ suspend fun run_sdlan(iface: Iface, argument: Arguments, routeinfo: PunchnetServ
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
println("sending start")
|
||||||
start_stop_channel.send(StartStopChanInfo(StartStopFlag.IsStart, null))
|
start_stop_channel.send(StartStopChanInfo(StartStopFlag.IsStart, null))
|
||||||
|
println("sent start")
|
||||||
|
|
||||||
val cancel = Channel<Boolean>(100)
|
val cancel = Channel<Boolean>(100)
|
||||||
runEdgeLoop(node, cancel)
|
runEdgeLoop(scope, node, cancel)
|
||||||
|
|
||||||
while(true) {
|
while(true) {
|
||||||
TerminalLogger.debugf {"ping to sn"}
|
TerminalLogger.debugf {"ping to sn"}
|
||||||
@ -240,24 +298,33 @@ suspend fun run_sdlan(iface: Iface, argument: Arguments, routeinfo: PunchnetServ
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun runEdgeLoop(node: Node, cancel: ReceiveChannel<Boolean>) {
|
suspend fun runEdgeLoop(scope: CoroutineScope, node: Node, cancel: ReceiveChannel<Boolean>) {
|
||||||
node.ping_to_sn()
|
node.ping_to_sn()
|
||||||
CoroutineScope(Dispatchers.Default).async {
|
scope.launch {
|
||||||
loopSocketV4(node, node.udpSockV4, cancel)
|
loopSocketV4(scope, node, node.udpSockV4, cancel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun loopSocketV4(node: Node, sock: SDLanSocket, cancel: ReceiveChannel<Boolean>) {
|
suspend fun loopSocketV4(scope: CoroutineScope, node: Node, sock: SDLanSocket, cancel: ReceiveChannel<Boolean>) {
|
||||||
val job_stun_request = CoroutineScope(Dispatchers.Default).async {
|
val job_stun_request = scope.async {
|
||||||
while(true) {
|
try {
|
||||||
delay(10_000)
|
while(true) {
|
||||||
node.sendStunRequest()
|
delay(10_000)
|
||||||
|
node.probeNatType()
|
||||||
|
node.sendStunRequest()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("Socket", "job stun request exits: $e")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val job_handle_packet = CoroutineScope(Dispatchers.Default).async {
|
val job_handle_packet = scope.async {
|
||||||
while(true) {
|
try {
|
||||||
readAndParsePacket(node, sock)
|
while(true) {
|
||||||
|
readAndParsePacket(scope, node, sock)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
sock.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -274,9 +341,10 @@ suspend fun loopSocketV4(node: Node, sock: SDLanSocket, cancel: ReceiveChannel<B
|
|||||||
}
|
}
|
||||||
job_stun_request.cancelAndJoin()
|
job_stun_request.cancelAndJoin()
|
||||||
job_handle_packet.cancelAndJoin()
|
job_handle_packet.cancelAndJoin()
|
||||||
|
Log.e("Socket", "loop socket v4 stops")
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun readAndParsePacket(node: Node, sock: SDLanSocket) {
|
suspend fun readAndParsePacket(scope: CoroutineScope, node: Node, sock: SDLanSocket) {
|
||||||
val packet = sock.receive()
|
val packet = sock.receive()
|
||||||
val from = packet.socketAddress
|
val from = packet.socketAddress
|
||||||
|
|
||||||
@ -286,10 +354,10 @@ suspend fun readAndParsePacket(node: Node, sock: SDLanSocket) {
|
|||||||
}
|
}
|
||||||
val data = ByteBuffer.wrap(packet.data, 0, packet.length)
|
val data = ByteBuffer.wrap(packet.data, 0, packet.length)
|
||||||
// val data = packet.data.toByteString(0, packet.length)
|
// val data = packet.data.toByteString(0, packet.length)
|
||||||
handleAPacket(node, from, data)
|
handleAPacket(scope, node, from, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun handleAPacket(node: Node, from: SocketAddress, data: ByteBuffer) {
|
suspend fun handleAPacket(scope: CoroutineScope, node: Node, from: SocketAddress, data: ByteBuffer) {
|
||||||
val pktType = PacketType.fromValue(data.get().toUByte())
|
val pktType = PacketType.fromValue(data.get().toUByte())
|
||||||
if (pktType == null) {
|
if (pktType == null) {
|
||||||
TerminalLogger.errorf { "invalid packet type" }
|
TerminalLogger.errorf { "invalid packet type" }
|
||||||
@ -297,7 +365,7 @@ suspend fun handleAPacket(node: Node, from: SocketAddress, data: ByteBuffer) {
|
|||||||
}
|
}
|
||||||
// val buffer = ByteBuffer.wrap(data, 1, size-1)
|
// val buffer = ByteBuffer.wrap(data, 1, size-1)
|
||||||
|
|
||||||
when (pktType) {
|
when (pktType) {
|
||||||
PacketType.Data -> {
|
PacketType.Data -> {
|
||||||
TerminalLogger.debugf { "got DATA" }
|
TerminalLogger.debugf { "got DATA" }
|
||||||
if (!node.aes.isAuthorized()) {
|
if (!node.aes.isAuthorized()) {
|
||||||
@ -307,7 +375,7 @@ suspend fun handleAPacket(node: Node, from: SocketAddress, data: ByteBuffer) {
|
|||||||
if (from is InetSocketAddress) {
|
if (from is InetSocketAddress) {
|
||||||
TerminalLogger.debugf {"got data"}
|
TerminalLogger.debugf {"got data"}
|
||||||
val sock = SDLanSock(IPFamily.IPV4, from.port, from.address.address)
|
val sock = SDLanSock(IPFamily.IPV4, from.port, from.address.address)
|
||||||
handlePacketData(node, data, sock)
|
handlePacketData(scope, node, data, sock)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PacketType.StunProbeReply -> {
|
PacketType.StunProbeReply -> {
|
||||||
@ -322,7 +390,16 @@ suspend fun handleAPacket(node: Node, from: SocketAddress, data: ByteBuffer) {
|
|||||||
TerminalLogger.debugf { "got stun reply" }
|
TerminalLogger.debugf { "got stun reply" }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
PacketType.PolicyResponse -> {
|
||||||
|
TerminalLogger.debugf { "got policy response" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
PacketType.ExposedServiceResponse -> {
|
||||||
|
TerminalLogger.debugf { "got exposed service response" }
|
||||||
|
return
|
||||||
|
}
|
||||||
PacketType.Register -> {
|
PacketType.Register -> {
|
||||||
|
TerminalLogger.debugf { "got REGISTER" }
|
||||||
if (from is InetSocketAddress) {
|
if (from is InetSocketAddress) {
|
||||||
val sock = SDLanSock(IPFamily.IPV4, from.port, from.address.address)
|
val sock = SDLanSock(IPFamily.IPV4, from.port, from.address.address)
|
||||||
handlePacketRegister(node, data, sock)
|
handlePacketRegister(node, data, sock)
|
||||||
@ -347,18 +424,18 @@ suspend fun handleAPacket(node: Node, from: SocketAddress, data: ByteBuffer) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fun initEdge(iface: Iface, token: String, config: NodeConfig, toSocket: SendChannel<ByteArray>, startStopChannel: SendChannel<StartStopChanInfo>) {
|
fun initEdge(scope: CoroutineScope, iface: Iface, token: String, config: NodeConfig, toSocket: SendChannel<ByteArray>, startStopChannel: SendChannel<StartStopChanInfo>) {
|
||||||
val rsa = RSA.getRSA()
|
val rsa = RSA.getRSA()
|
||||||
val pathname = Paths.get(config.baseDir, RSAConfig.BASE_DIR).pathString
|
val pathname = Paths.get(config.baseDir, RSAConfig.BASE_DIR).pathString
|
||||||
Log.d("DIR", "pathname = $pathname")
|
Log.d("DIR", "pathname = $pathname")
|
||||||
rsa.generateKeyPair(pathname)
|
rsa.generateKeyPair(pathname)
|
||||||
//rsa.generateKeyPair(Path.of(config.baseDir, RSAConfig.BASE_DIR).name)
|
//rsa.generateKeyPair(Path.of(config.baseDir, RSAConfig.BASE_DIR).name)
|
||||||
|
|
||||||
val sockV4 = SDLanSocket("0.0.0.0", config.localPort)
|
val sockV4 = SDLanSocket(scope, "0.0.0.0", config.localPort)
|
||||||
|
|
||||||
var sockMulticast: SDLanSocket? = null
|
var sockMulticast: SDLanSocket? = null
|
||||||
if (!config.dropMulticast) {
|
if (!config.dropMulticast) {
|
||||||
sockMulticast = SDLanSocket(SDLanMulticastConfig.MULTICAST_V4.toIPV4String(), SDLanMulticastConfig.MULTICAST_PORT)
|
sockMulticast = SDLanSocket(scope, SDLanMulticastConfig.MULTICAST_V4.toIPV4String(), SDLanMulticastConfig.MULTICAST_PORT)
|
||||||
}
|
}
|
||||||
|
|
||||||
Node.initialize(
|
Node.initialize(
|
||||||
@ -372,189 +449,15 @@ fun initEdge(iface: Iface, token: String, config: NodeConfig, toSocket: SendChan
|
|||||||
startStopChannel,
|
startStopChannel,
|
||||||
)
|
)
|
||||||
val instance = Node.getInstance()
|
val instance = Node.getInstance()
|
||||||
|
instance.token = token
|
||||||
|
instance.udpSockV4 = sockV4
|
||||||
|
instance.udpSockMulticast = sockMulticast
|
||||||
|
instance.config = config
|
||||||
|
instance.toSocket = toSocket
|
||||||
|
instance.startStopChannel = startStopChannel
|
||||||
println(instance)
|
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? {
|
fun parseConfig(nodeuuid: String, argument: Arguments): NodeConfig? {
|
||||||
if (argument.sn.length == 0) {
|
if (argument.sn.length == 0) {
|
||||||
@ -562,18 +465,23 @@ fun parseConfig(nodeuuid: String, argument: Arguments): NodeConfig? {
|
|||||||
return null
|
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)
|
val sn = parseScoketAddressV4FromString(argument.sn)
|
||||||
if (sn == null) {
|
if (sn == null) {
|
||||||
return 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(
|
return NodeConfig(
|
||||||
baseDir = argument.baseDir,
|
baseDir = argument.baseDir,
|
||||||
name = argument.name,
|
name = argument.name,
|
||||||
@ -587,7 +495,8 @@ fun parseConfig(nodeuuid: String, argument: Arguments): NodeConfig? {
|
|||||||
registerTTL = argument.registerTTL,
|
registerTTL = argument.registerTTL,
|
||||||
localPort = argument.localPort,
|
localPort = argument.localPort,
|
||||||
supernode = SDLanSock(IPFamily.IPV4, sn.port, sn.address.address),
|
supernode = SDLanSock(IPFamily.IPV4, sn.port, sn.address.address),
|
||||||
|
stunServer = SDLanSock(IPFamily.IPV4, stun.port, stun.address.address),
|
||||||
natServer1 = natServer1,
|
natServer1 = natServer1,
|
||||||
natServer2 = natServer2,
|
natServer2 = natServer2
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -49,6 +49,15 @@ enum class PacketType(val id: UByte) {
|
|||||||
StunProbe(0x32u),
|
StunProbe(0x32u),
|
||||||
StunProbeReply(0x33u),
|
StunProbeReply(0x33u),
|
||||||
|
|
||||||
|
Welcome(0x4fu),
|
||||||
|
ArpRequest(0x50u),
|
||||||
|
ArpResponse(0x51u),
|
||||||
|
|
||||||
|
PolicyRequest(0xb0u),
|
||||||
|
PolicyResponse(0xb1u),
|
||||||
|
ExposedServiceRequest(0xb2u),
|
||||||
|
ExposedServiceResponse(0xb3u),
|
||||||
|
|
||||||
Data(0xffu);
|
Data(0xffu);
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@ -65,9 +74,8 @@ fun PacketType.toUByte(): UByte {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// tcp发送过来的通道里面的信息
|
// tcp/quic发送过来的通道里面的信息
|
||||||
class SDLanTCP(
|
class SDLanControl(
|
||||||
val packetID: UInt,
|
|
||||||
val packetType: PacketType,
|
val packetType: PacketType,
|
||||||
val currentPacket: ByteArray,
|
val currentPacket: ByteArray,
|
||||||
)
|
)
|
||||||
@ -103,18 +111,13 @@ fun encodeToUDPMessage(msg: Message?, packetType: PacketType): List<Byte> {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
fun encodeToTcpMessage(msg: Message?, packetID: Int, packetType: PacketType): List<Byte> {
|
fun encodeToControlMessage(msg: Message?, packetType: PacketType): List<Byte> {
|
||||||
val msgByte = msg?.toByteArray()?.toList()?:listOf<Byte>()
|
val msgByte = msg?.toByteArray()?.toList()?:listOf<Byte>()
|
||||||
val result: MutableList<Byte> = mutableListOf()
|
val result: MutableList<Byte> = mutableListOf()
|
||||||
|
|
||||||
result.addAll(ByteBuffer.allocate(2)
|
result.addAll(ByteBuffer.allocate(2)
|
||||||
.order(ByteOrder.BIG_ENDIAN)
|
.order(ByteOrder.BIG_ENDIAN)
|
||||||
.putShort((msgByte.size + 5).toShort())
|
.putShort((msgByte.size + 1).toShort())
|
||||||
.array().toList())
|
|
||||||
|
|
||||||
result.addAll(ByteBuffer.allocate(4)
|
|
||||||
.order(ByteOrder.BIG_ENDIAN)
|
|
||||||
.putInt(packetID)
|
|
||||||
.array().toList())
|
.array().toList())
|
||||||
|
|
||||||
result.add(packetType.toUByte().toByte())
|
result.add(packetType.toUByte().toByte())
|
||||||
@ -127,4 +130,4 @@ enum class StunProbeAttr {
|
|||||||
None,
|
None,
|
||||||
Port,
|
Port,
|
||||||
Peer,
|
Peer,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,214 @@
|
|||||||
|
package com.jihe.punchnet.sdlan.network
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import com.jihe.punchnet.sdlan.logs.TerminalLogger
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.cancelAndJoin
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.channels.ReceiveChannel
|
||||||
|
import kotlinx.coroutines.channels.SendChannel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.selects.select
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import tech.kwik.core.QuicClientConnection
|
||||||
|
import tech.kwik.core.QuicStream
|
||||||
|
import java.net.URI
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
|
|
||||||
|
suspend fun initQUICConn(
|
||||||
|
scope: CoroutineScope,
|
||||||
|
quicHost: String,
|
||||||
|
quicPort: Int,
|
||||||
|
start_stop: Channel<StartStopChanInfo>,
|
||||||
|
pong_time: AtomicLong,
|
||||||
|
connected: AtomicBoolean,
|
||||||
|
toSocket: ReceiveChannel<ByteArray>,
|
||||||
|
onConnected: suspend (stream: QuicStream)->Unit,
|
||||||
|
onMessage: suspend (CoroutineScope, SDLanControl)->Unit,
|
||||||
|
onDisconnected: suspend ()->Unit,
|
||||||
|
connectingChan: SendChannel<ConnectingState>?
|
||||||
|
) {
|
||||||
|
val started = AtomicBoolean(false)
|
||||||
|
val node = Node.getInstance()
|
||||||
|
|
||||||
|
while (scope.isActive) {
|
||||||
|
connectingChan?.send(ConnectingState.NotConnected)
|
||||||
|
|
||||||
|
if (!started.get()) {
|
||||||
|
while (true) {
|
||||||
|
val startStopInfo = start_stop.receive()
|
||||||
|
if (startStopInfo.flag == StartStopFlag.IsStart) {
|
||||||
|
started.set(true)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
connectingChan?.send(ConnectingState.Connecting)
|
||||||
|
TerminalLogger.debugf { "try connecting quic..." }
|
||||||
|
|
||||||
|
var connection: QuicClientConnection? = null
|
||||||
|
var stream: QuicStream? = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
TerminalLogger.debugf { "connecting to QUIC $quicHost:$quicPort" }
|
||||||
|
val builder = QuicClientConnection.newBuilder()
|
||||||
|
.uri(URI("https://$quicHost:$quicPort"))
|
||||||
|
.applicationProtocol("punchnet/1.0")
|
||||||
|
.noServerCertificateCheck()
|
||||||
|
connection = builder.build()
|
||||||
|
connection!!.connect()
|
||||||
|
|
||||||
|
// Open bidirectional stream
|
||||||
|
stream = connection!!.createStream(true)
|
||||||
|
|
||||||
|
// In QUIC, streams are created implicitly when the first data is sent.
|
||||||
|
// We send a PACKET_EMPTY (0x00) so the server can accept the stream and send Welcome.
|
||||||
|
val emptyPacket = ByteArray(3)
|
||||||
|
emptyPacket[0] = 0 // Len high
|
||||||
|
emptyPacket[1] = 1 // Len low (1 byte for PacketType)
|
||||||
|
emptyPacket[2] = 0 // PacketType.Empty (0x00)
|
||||||
|
TerminalLogger.debugf { "Sending QUIC application packet (PACKET_EMPTY): [00 01 00]" }
|
||||||
|
stream!!.outputStream.write(emptyPacket)
|
||||||
|
stream!!.outputStream.flush()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
TerminalLogger.errorf { "failed to connect to QUIC ${quicHost}:${quicPort}: $e" }
|
||||||
|
delay(3000)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val job_read_packet = scope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
while (isActive) {
|
||||||
|
val controlPacket = readQUICPacket(stream!!)
|
||||||
|
if (controlPacket == null) {
|
||||||
|
TerminalLogger.errorf { "QUIC Stream EOF or error" }
|
||||||
|
break
|
||||||
|
}
|
||||||
|
onMessage(scope, controlPacket)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
TerminalLogger.errorf { "QUIC read loop closing" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val job_write_to_packet = scope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
while (isActive) {
|
||||||
|
val msg = toSocket.receive()
|
||||||
|
val packetType = if (msg.size >= 3) PacketType.fromValue(msg[2].toUByte()) else null
|
||||||
|
TerminalLogger.debugf { "Sending QUIC application packet: type=${packetType ?: "unknown"}, bytes=${msg.size}" }
|
||||||
|
stream!!.outputStream.write(msg)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
TerminalLogger.errorf { "failed to write to QUIC stream: $e" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val job_check_pong = scope.launch(Dispatchers.IO) {
|
||||||
|
while (isActive) {
|
||||||
|
delay(10_000)
|
||||||
|
val now = System.currentTimeMillis() / 1000
|
||||||
|
if (connected.get() && (now - pong_time.get() > com.jihe.punchnet.sdlan.config.SDLanConfig.TCP_PING_TIME * 2)) {
|
||||||
|
TerminalLogger.errorf { "QUIC pong check expired" }
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val job_check_stop = scope.launch(Dispatchers.IO) {
|
||||||
|
while (isActive) {
|
||||||
|
try {
|
||||||
|
val v = start_stop.receive()
|
||||||
|
if (v.flag == StartStopFlag.IsStop) {
|
||||||
|
started.set(false)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TerminalLogger.debugf { "QUIC Stream connected" }
|
||||||
|
onConnected(stream!!)
|
||||||
|
connectingChan?.send(ConnectingState.Connected)
|
||||||
|
|
||||||
|
select<Unit> {
|
||||||
|
job_read_packet.onJoin { }
|
||||||
|
job_write_to_packet.onJoin { }
|
||||||
|
job_check_pong.onJoin { }
|
||||||
|
job_check_stop.onJoin { }
|
||||||
|
}
|
||||||
|
|
||||||
|
job_read_packet.cancelAndJoin()
|
||||||
|
job_write_to_packet.cancelAndJoin()
|
||||||
|
job_check_pong.cancelAndJoin()
|
||||||
|
job_check_stop.cancelAndJoin()
|
||||||
|
|
||||||
|
delay(1000)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
onDisconnected()
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
connection?.close()
|
||||||
|
} catch (e: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun readQUICPacket(stream: QuicStream): SDLanControl? = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
// <<Len:16, PacketType:8, ProtobufPayload>>
|
||||||
|
val lenBytes = ByteArray(2)
|
||||||
|
if (!stream.inputStream.readFullyOrEof(lenBytes)) return@withContext null
|
||||||
|
|
||||||
|
val len = ByteBuffer.wrap(lenBytes).short.toInt() and 0xFFFF
|
||||||
|
if (len < 1) return@withContext null
|
||||||
|
|
||||||
|
val typeByte = ByteArray(1)
|
||||||
|
if (!stream.inputStream.readFullyOrEof(typeByte)) return@withContext null
|
||||||
|
|
||||||
|
val packetTypeUByte = typeByte[0].toUByte()
|
||||||
|
val packetType = PacketType.fromValue(packetTypeUByte)
|
||||||
|
if (packetType == null) {
|
||||||
|
TerminalLogger.errorf { "packet type not found: $packetTypeUByte" }
|
||||||
|
return@withContext null
|
||||||
|
}
|
||||||
|
|
||||||
|
val payloadLen = len - 1
|
||||||
|
val payload = ByteArray(payloadLen)
|
||||||
|
var toread = payloadLen
|
||||||
|
var offset = 0
|
||||||
|
while (toread > 0) {
|
||||||
|
val sizeGot = stream.inputStream.read(payload, offset, toread)
|
||||||
|
if (sizeGot <= 0) return@withContext null
|
||||||
|
toread -= sizeGot
|
||||||
|
offset += sizeGot
|
||||||
|
}
|
||||||
|
|
||||||
|
return@withContext SDLanControl(packetType, payload)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
TerminalLogger.errorf { "failed to read QUIC stream: $e" }
|
||||||
|
return@withContext null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun InputStream.readFullyOrEof(buffer: ByteArray): Boolean {
|
||||||
|
var offset = 0
|
||||||
|
while (offset < buffer.size) {
|
||||||
|
val readSize = read(buffer, offset, buffer.size - offset)
|
||||||
|
if (readSize <= 0) return false
|
||||||
|
offset += readSize
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
106
app/src/main/java/com/jihe/punchnet/sdlan/network/quic_data.kt
Normal file
106
app/src/main/java/com/jihe/punchnet/sdlan/network/quic_data.kt
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
package com.jihe.punchnet.sdlan.network
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import com.jihe.punchnet.PunchnetServiceArgument
|
||||||
|
import com.jihe.punchnet.api.ConnectResponseData
|
||||||
|
import com.jihe.punchnet.sdlan.config.Arguments
|
||||||
|
import com.jihe.punchnet.sdlan.logs.TerminalLogger
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import tech.kwik.core.QuicClientConnection
|
||||||
|
import java.net.InetSocketAddress
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
|
||||||
|
suspend fun run_quic_data_plane(
|
||||||
|
scope: CoroutineScope,
|
||||||
|
service: com.jihe.punchnet.PunchnetService,
|
||||||
|
argument: Arguments,
|
||||||
|
connectData: ConnectResponseData
|
||||||
|
) {
|
||||||
|
TerminalLogger.infof { "Starting QUIC Data Plane..." }
|
||||||
|
|
||||||
|
// Parse the server address from argument.sn (e.g. root.punchsky.com:1265)
|
||||||
|
val snParts = argument.sn.split(":")
|
||||||
|
val host = snParts[0]
|
||||||
|
val port = snParts[1].toIntOrNull() ?: 1265
|
||||||
|
|
||||||
|
while (scope.isActive) {
|
||||||
|
var connection: QuicClientConnection? = null
|
||||||
|
try {
|
||||||
|
TerminalLogger.debugf { "Connecting to QUIC server at $host:$port..." }
|
||||||
|
|
||||||
|
// In Kwik, QuicClientConnection is used to connect to a server.
|
||||||
|
// Note: Kwik API details may require a specific Builder or config.
|
||||||
|
// We use standard connection parameters.
|
||||||
|
val serverAddress = InetSocketAddress(host, port)
|
||||||
|
|
||||||
|
val builder = QuicClientConnection.newBuilder()
|
||||||
|
.uri(java.net.URI("https://$host:$port"))
|
||||||
|
// TODO: configure certificates if self-signed, else default is fine.
|
||||||
|
|
||||||
|
connection = builder.build()
|
||||||
|
connection.connect()
|
||||||
|
|
||||||
|
TerminalLogger.infof { "QUIC connection established!" }
|
||||||
|
|
||||||
|
// Start reading from the TUN interface and sending as QUIC Datagrams
|
||||||
|
val jobWrite = scope.launch(Dispatchers.IO) {
|
||||||
|
while (isActive) {
|
||||||
|
val data = service.recv()
|
||||||
|
if (data.isEmpty()) {
|
||||||
|
delay(1000)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send over QUIC Datagram
|
||||||
|
// Note: Kwik Datagram API might be on the connection object.
|
||||||
|
try {
|
||||||
|
// Using datagram sending API from Kwik
|
||||||
|
// If kwik doesn't support datagram directly, we fallback to streams
|
||||||
|
// For RFC 9221 datagrams:
|
||||||
|
// connection.sendDatagram(data)
|
||||||
|
// Note: Datagram API in Kwik is still experimental in some versions.
|
||||||
|
// We will use stream for fallback if datagram fails compile.
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start reading QUIC Datagrams from server and write to TUN
|
||||||
|
val jobRead = scope.launch(Dispatchers.IO) {
|
||||||
|
while (isActive) {
|
||||||
|
// Receive Datagram
|
||||||
|
// val buf = connection.receiveDatagram()
|
||||||
|
// service.send(buf)
|
||||||
|
delay(100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
jobWrite.join()
|
||||||
|
jobRead.join()
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
TerminalLogger.errorf { "QUIC connection error: ${e.message}" }
|
||||||
|
e.printStackTrace()
|
||||||
|
delay(3000)
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
connection?.close()
|
||||||
|
} catch (e: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ipToInt(ipAddress: String): Int {
|
||||||
|
val parts = ipAddress.split(".")
|
||||||
|
if (parts.size != 4) return 0
|
||||||
|
var result = 0
|
||||||
|
for (i in 0..3) {
|
||||||
|
result = result shl 8 or (parts[i].toInt() and 0xFF)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
@ -45,11 +45,21 @@ 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? {
|
fun maskDigitToInt(digit: Int): Int? {
|
||||||
if (digit <= 0 || digit >= 32) {
|
if (digit <= 0 || digit >= 32) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
return ((2 shl (32-digit)) - 1)
|
return ((1 shl (32-digit)) - 1).inv()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun ipStringToInt(ip: String): Int? {
|
fun ipStringToInt(ip: String): Int? {
|
||||||
@ -93,12 +103,19 @@ class RouteTable() {
|
|||||||
fun getGateway(ip: Int): Int? {
|
fun getGateway(ip: Int): Int? {
|
||||||
try {
|
try {
|
||||||
lock.readLock().lock()
|
lock.readLock().lock()
|
||||||
|
var bestGw: Int? = null
|
||||||
|
var bestMaskLen = -1
|
||||||
for (item in routeInfo) {
|
for (item in routeInfo) {
|
||||||
if ((ip and item.mask) == item.maskedAddr) {
|
if ((ip and item.mask) == item.maskedAddr) {
|
||||||
return item.gw
|
val maskLen = Integer.bitCount(item.mask)
|
||||||
|
if (maskLen > bestMaskLen) {
|
||||||
|
bestMaskLen = maskLen
|
||||||
|
bestGw = item.gw
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null
|
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
|
||||||
} finally {
|
} finally {
|
||||||
lock.readLock().unlock()
|
lock.readLock().unlock()
|
||||||
}
|
}
|
||||||
@ -112,11 +129,17 @@ class RouteTable2(
|
|||||||
val routeInfo: Array<RouteDetail> = initRouteInfo.toTypedArray()
|
val routeInfo: Array<RouteDetail> = initRouteInfo.toTypedArray()
|
||||||
|
|
||||||
fun getGeteway(ip: Int): Int? {
|
fun getGeteway(ip: Int): Int? {
|
||||||
|
var bestGw: Int? = null
|
||||||
|
var bestMaskLen = -1
|
||||||
for (item in routeInfo) {
|
for (item in routeInfo) {
|
||||||
if ((ip and item.mask) == item.maskedAddr) {
|
if ((ip and item.mask) == item.maskedAddr) {
|
||||||
return item.gw
|
val maskLen = Integer.bitCount(item.mask)
|
||||||
|
if (maskLen > bestMaskLen) {
|
||||||
|
bestMaskLen = maskLen
|
||||||
|
bestGw = item.gw
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null
|
return bestGw
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
package com.jihe.punchnet.sdlan.network
|
package com.jihe.punchnet.sdlan.network
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
import com.jihe.punchnet.sdlan.logs.TerminalLogger
|
import com.jihe.punchnet.sdlan.logs.TerminalLogger
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@ -14,20 +15,20 @@ import java.net.DatagramSocket
|
|||||||
import java.net.Inet4Address
|
import java.net.Inet4Address
|
||||||
import java.net.SocketAddress
|
import java.net.SocketAddress
|
||||||
|
|
||||||
class SDLanSocket(val addr: String, val port: Int, val reuseAddress: Boolean = false) {
|
class SDLanSocket(val scope: CoroutineScope, val addr: String, var port: Int, val reuseAddress: Boolean = false) {
|
||||||
private val connection: DatagramSocket = run {
|
private val connection: DatagramSocket = run {
|
||||||
val sock = DatagramSocket(port, Inet4Address.getByName(addr))
|
val sock = DatagramSocket(port, Inet4Address.getByName(addr))
|
||||||
if (reuseAddress) {
|
if (reuseAddress) {
|
||||||
sock.reuseAddress = true
|
sock.reuseAddress = true
|
||||||
}
|
}
|
||||||
|
port = sock.localPort
|
||||||
|
Log.d("SDLanSocket", "local port = $port")
|
||||||
sock
|
sock
|
||||||
}
|
}
|
||||||
|
|
||||||
private var job: Job? = null
|
|
||||||
|
|
||||||
fun loop(): ReceiveChannel<DatagramPacket> {
|
fun loop(): ReceiveChannel<DatagramPacket> {
|
||||||
val channel = Channel<DatagramPacket>(100)
|
val channel = Channel<DatagramPacket>(100)
|
||||||
job = CoroutineScope(Dispatchers.Default).launch {
|
scope.launch {
|
||||||
while (true) {
|
while (true) {
|
||||||
val msg = receive()
|
val msg = receive()
|
||||||
channel.send(msg)
|
channel.send(msg)
|
||||||
@ -44,7 +45,7 @@ class SDLanSocket(val addr: String, val port: Int, val reuseAddress: Boolean = f
|
|||||||
connection.send(packet)
|
connection.send(packet)
|
||||||
}
|
}
|
||||||
} catch(e: Exception) {
|
} catch(e: Exception) {
|
||||||
TerminalLogger.errorf {"Failed to send to: $e"}
|
TerminalLogger.errorf {"Failed to send to: $e, localport: ${port}"}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -58,8 +59,7 @@ class SDLanSocket(val addr: String, val port: Int, val reuseAddress: Boolean = f
|
|||||||
return packet
|
return packet
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun close() {
|
fun close() {
|
||||||
job?.cancelAndJoin()
|
|
||||||
connection.close()
|
connection.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,11 @@ package com.jihe.punchnet.sdlan.utils
|
|||||||
|
|
||||||
import com.jihe.punchnet.sdlan.config.AESConfig
|
import com.jihe.punchnet.sdlan.config.AESConfig
|
||||||
import com.jihe.punchnet.sdlan.logs.TerminalLogger
|
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.ByteBuffer
|
||||||
|
import java.nio.ByteOrder
|
||||||
import javax.crypto.Cipher
|
import javax.crypto.Cipher
|
||||||
import javax.crypto.spec.IvParameterSpec
|
import javax.crypto.spec.IvParameterSpec
|
||||||
import javax.crypto.spec.SecretKeySpec
|
import javax.crypto.spec.SecretKeySpec
|
||||||
@ -10,6 +14,8 @@ import javax.crypto.spec.SecretKeySpec
|
|||||||
class AES private constructor () {
|
class AES private constructor () {
|
||||||
private var _secret: ByteArray = ByteArray(0)
|
private var _secret: ByteArray = ByteArray(0)
|
||||||
private var _iv: ByteArray = ByteArray(0)
|
private var _iv: ByteArray = ByteArray(0)
|
||||||
|
private var _algorithm: String = "aes"
|
||||||
|
private var _regionId: Long = 0
|
||||||
companion object {
|
companion object {
|
||||||
private var instance: AES? = null
|
private var instance: AES? = null
|
||||||
get() {
|
get() {
|
||||||
@ -29,13 +35,17 @@ class AES private constructor () {
|
|||||||
return _secret.size != 0
|
return _secret.size != 0
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSecret(secret: ByteArray?) {
|
fun setSecret(secret: ByteArray?, algorithm: String = "aes", regionId: Long = 0) {
|
||||||
if (secret == null) {
|
if (secret == null) {
|
||||||
_secret = ByteArray(0)
|
_secret = ByteArray(0)
|
||||||
_iv = ByteArray(0)
|
_iv = ByteArray(0)
|
||||||
|
_algorithm = "aes"
|
||||||
|
_regionId = 0
|
||||||
} else {
|
} else {
|
||||||
_secret = secret
|
_secret = secret
|
||||||
_iv = _secret.sliceArray(0..<16)
|
_iv = _secret.sliceArray(0..<16)
|
||||||
|
_algorithm = algorithm.lowercase()
|
||||||
|
_regionId = regionId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -43,22 +53,11 @@ class AES private constructor () {
|
|||||||
if (!isAuthorized()) {
|
if (!isAuthorized()) {
|
||||||
return null
|
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 {
|
try {
|
||||||
cipher.init(Cipher.ENCRYPT_MODE, keyspec, IvParameterSpec(_iv))
|
val input = ByteArray(content.remaining())
|
||||||
println(10.0)
|
content.get(input)
|
||||||
val output = ByteBuffer.allocate(cipher.getOutputSize(content.remaining()))
|
val encrypted = encrypt(input) ?: return null
|
||||||
println(10.1)
|
return ByteBuffer.wrap(encrypted)
|
||||||
cipher.doFinal(content, output)
|
|
||||||
println(10.2)
|
|
||||||
output.flip()
|
|
||||||
println(10.3)
|
|
||||||
return output
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
println("failed to encrypt: ${e.toString()}")
|
println("failed to encrypt: ${e.toString()}")
|
||||||
return null
|
return null
|
||||||
@ -70,12 +69,15 @@ class AES private constructor () {
|
|||||||
TerminalLogger.errorf { "not authed, so not encrypting" }
|
TerminalLogger.errorf { "not authed, so not encrypting" }
|
||||||
return null
|
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 {
|
try {
|
||||||
val encrypted = cipher.doFinal(content)
|
return when (_algorithm) {
|
||||||
return encrypted
|
"aes" -> aesCrypt(Cipher.ENCRYPT_MODE, content)
|
||||||
|
"chacha20" -> chacha20Crypt(content)
|
||||||
|
else -> {
|
||||||
|
TerminalLogger.errorf { "unsupported encryption algorithm: $_algorithm" }
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
TerminalLogger.errorf {"encrypt failed: $e"}
|
TerminalLogger.errorf {"encrypt failed: $e"}
|
||||||
return null
|
return null
|
||||||
@ -86,14 +88,11 @@ class AES private constructor () {
|
|||||||
if (!isAuthorized()) {
|
if (!isAuthorized()) {
|
||||||
return null
|
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 {
|
try {
|
||||||
val output = ByteBuffer.allocate(cipher.getOutputSize(ciphered.remaining()))
|
val input = ByteArray(ciphered.remaining())
|
||||||
cipher.doFinal(ciphered, output)
|
ciphered.get(input)
|
||||||
output.flip()
|
val decrypted = decrypt(input) ?: return null
|
||||||
return output
|
return ByteBuffer.wrap(decrypted)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@ -103,16 +102,40 @@ class AES private constructor () {
|
|||||||
if (!isAuthorized()) {
|
if (!isAuthorized()) {
|
||||||
return null
|
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 {
|
try {
|
||||||
val decrypted = cipher.doFinal(ciphered)
|
return when (_algorithm) {
|
||||||
return decrypted
|
"aes" -> aesCrypt(Cipher.DECRYPT_MODE, ciphered)
|
||||||
|
"chacha20" -> chacha20Crypt(ciphered)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
return null
|
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 {
|
fun byteArray2Hex(array: ByteArray): String {
|
||||||
@ -121,4 +144,4 @@ fun byteArray2Hex(array: ByteArray): String {
|
|||||||
result.add(String.format("0x%02x", item.toInt() and 0xff))
|
result.add(String.format("0x%02x", item.toInt() and 0xff))
|
||||||
}
|
}
|
||||||
return result.joinToString(" ")
|
return result.joinToString(" ")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -109,7 +109,6 @@ class RSA private constructor () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pubkeyStr = pubFile.readText()
|
pubkeyStr = pubFile.readText()
|
||||||
println("pub key is ${pubkeyStr}")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun encrypt(input: ByteArray, use_private_key: Boolean = true): ByteArray {
|
fun encrypt(input: ByteArray, use_private_key: Boolean = true): ByteArray {
|
||||||
|
|||||||
@ -17,6 +17,8 @@ import kotlin.experimental.inv
|
|||||||
import kotlin.experimental.or
|
import kotlin.experimental.or
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
|
||||||
object UniqueNodeID {
|
object UniqueNodeID {
|
||||||
var id: String = ""
|
var id: String = ""
|
||||||
private var baseDir: String = ""
|
private var baseDir: String = ""
|
||||||
@ -25,11 +27,18 @@ object UniqueNodeID {
|
|||||||
baseDir = basedir
|
baseDir = basedir
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getUUID(): String {
|
fun getUUID(context: Context? = null): String {
|
||||||
if (baseDir.length == 0) {
|
if (id.isEmpty() && context != null) {
|
||||||
baseDir = Environment.getExternalStorageDirectory().name
|
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.length == 0) {
|
if (id.isEmpty()) {
|
||||||
|
if (baseDir.isEmpty()) {
|
||||||
|
baseDir = Environment.getExternalStorageDirectory().absolutePath
|
||||||
|
}
|
||||||
val dirpath = File(baseDir)
|
val dirpath = File(baseDir)
|
||||||
dirpath.mkdirs()
|
dirpath.mkdirs()
|
||||||
val f = File(baseDir, SDLanConfig.ID_FILENAME)
|
val f = File(baseDir, SDLanConfig.ID_FILENAME)
|
||||||
@ -71,6 +80,15 @@ fun ipToString(ip: Int): String {
|
|||||||
return "$d1.$d2.$d3.$d4"
|
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 {
|
fun macToString(mac: Mac): String {
|
||||||
return mac.joinToString(separator = ":") { it.toUByte().toString(16) }
|
return mac.joinToString(separator = ":") { it.toUByte().toString(16) }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,8 +2,8 @@ syntax = "proto3";
|
|||||||
|
|
||||||
option java_package = "com.jihe.punchnet.protobuf";
|
option java_package = "com.jihe.punchnet.protobuf";
|
||||||
option java_outer_classname = "PunchProto";
|
option java_outer_classname = "PunchProto";
|
||||||
// 基础公共类型定义
|
|
||||||
|
|
||||||
|
// 基础公共类型定义
|
||||||
message SDLV4Info {
|
message SDLV4Info {
|
||||||
uint32 port = 1;
|
uint32 port = 1;
|
||||||
bytes v4 = 2;
|
bytes v4 = 2;
|
||||||
@ -15,34 +15,30 @@ message SDLV6Info {
|
|||||||
bytes v6 = 2;
|
bytes v6 = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设备网络地址信息
|
message SDLWelcome {
|
||||||
message SDLDevAddr {
|
uint32 version = 1;
|
||||||
uint32 network_id = 1;
|
uint32 max_bidi_streams = 2;
|
||||||
bytes mac = 2;
|
uint32 max_packet_size = 3;
|
||||||
uint32 net_addr = 3;
|
uint32 heartbeat_sec = 4;
|
||||||
uint32 net_bit_len = 4;
|
SDLV6Info ipv6_assist = 5;
|
||||||
}
|
|
||||||
|
|
||||||
// tcp通讯消息
|
|
||||||
message SDLEmpty {
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
message SDLRegisterSuper {
|
message SDLRegisterSuper {
|
||||||
uint32 version = 1;
|
string client_id = 1;
|
||||||
string installed_channel = 2;
|
uint32 network_id = 2;
|
||||||
string client_id = 3;
|
bytes mac = 3;
|
||||||
SDLDevAddr dev_addr = 4;
|
uint32 ip = 4;
|
||||||
string pub_key = 5;
|
uint32 mask_len = 5;
|
||||||
string token = 6;
|
string hostname = 6;
|
||||||
|
string pub_key = 7;
|
||||||
|
string access_token = 8;
|
||||||
}
|
}
|
||||||
|
|
||||||
message SDLRegisterSuperAck {
|
message SDLRegisterSuperAck {
|
||||||
SDLDevAddr dev_addr = 1;
|
string algorithm = 1;
|
||||||
bytes aes_key = 2;
|
bytes key = 2;
|
||||||
uint32 upgrade_type = 3;
|
uint32 region_id = 3;
|
||||||
optional string upgrade_prompt = 4;
|
bytes session_token = 4;
|
||||||
optional string upgrade_address = 5;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
message SDLRegisterSuperNak {
|
message SDLRegisterSuperNak {
|
||||||
@ -51,72 +47,115 @@ message SDLRegisterSuperNak {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 网络地址查询
|
// 网络地址查询
|
||||||
|
|
||||||
message SDLQueryInfo {
|
message SDLQueryInfo {
|
||||||
bytes dst_mac = 1;
|
bytes dst_mac = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
message SDLPeerInfo {
|
message SDLPeerInfo {
|
||||||
bytes dst_mac = 1;
|
bytes dst_mac = 1;
|
||||||
SDLV4Info v4_info = 2;
|
optional SDLV4Info v4_info = 2;
|
||||||
optional SDLV6Info v6_info = 3;
|
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 {
|
||||||
|
bytes mac = 1;
|
||||||
|
uint32 ip = 2;
|
||||||
|
}
|
||||||
|
|
||||||
message SDLNatChangedEvent {
|
message SendRegister {
|
||||||
bytes mac = 1;
|
bytes dst_mac = 1;
|
||||||
uint32 ip = 2;
|
uint32 nat_ip = 2;
|
||||||
}
|
uint32 nat_port = 3;
|
||||||
|
uint32 nat_type = 4;
|
||||||
|
optional SDLV6Info v6_info = 5;
|
||||||
|
}
|
||||||
|
|
||||||
message SDLSendRegisterEvent {
|
message ExposedServiceChanged {
|
||||||
bytes dst_mac = 1;
|
}
|
||||||
uint32 nat_ip = 2;
|
|
||||||
uint32 nat_port = 3;
|
|
||||||
uint32 nat_type = 4;
|
|
||||||
optional SDLV6Info v6_info = 5;
|
|
||||||
}
|
|
||||||
|
|
||||||
message SDLNetworkShutdownEvent {
|
message NetworkShutdown {
|
||||||
string message = 1;
|
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 SDLChangeNetworkCommand {
|
message ExitNodeControl {
|
||||||
SDLDevAddr dev_addr = 1;
|
int32 action = 1;
|
||||||
bytes aes_key = 2;
|
string remark = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
oneof command {
|
||||||
|
ExitNodeControl exit_node = 2;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
message SDLCommandAck {
|
message SDLCommandAck {
|
||||||
// status = true, 表示成功;status = false 表示失败,message是失败原因描述
|
uint32 pkt_id = 1;
|
||||||
bool status = 1;
|
int32 code = 2;
|
||||||
optional string message = 2;
|
string message = 3;
|
||||||
}
|
bytes data = 4;
|
||||||
|
|
||||||
message SDLFlows {
|
|
||||||
// 服务器转发流量
|
|
||||||
uint32 forward_num = 1;
|
|
||||||
// p2p直接流量
|
|
||||||
uint32 p2p_num = 2;
|
|
||||||
// 接收的流量
|
|
||||||
uint32 inbound_num = 3;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// UDP通讯消息
|
// UDP通讯消息
|
||||||
|
|
||||||
message SDLStunRequest {
|
message SDLStunRequest {
|
||||||
uint32 cookie = 1;
|
string client_id = 1;
|
||||||
string client_id = 2;
|
uint32 network_id = 2;
|
||||||
uint32 network_id = 3;
|
bytes mac = 3;
|
||||||
bytes mac = 4;
|
uint32 ip = 4;
|
||||||
uint32 ip = 5;
|
uint32 nat_type = 5;
|
||||||
uint32 nat_type = 6;
|
optional SDLV6Info v6_info = 6;
|
||||||
optional SDLV6Info v6_info = 7;
|
bytes session_token = 7;
|
||||||
}
|
}
|
||||||
|
|
||||||
message SDLStunReply {
|
message SDLStunReply {
|
||||||
uint32 cookie = 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
message SDLData {
|
message SDLData {
|
||||||
@ -126,6 +165,8 @@ message SDLData {
|
|||||||
bool is_p2p = 4;
|
bool is_p2p = 4;
|
||||||
uint32 ttl = 5;
|
uint32 ttl = 5;
|
||||||
bytes data = 6;
|
bytes data = 6;
|
||||||
|
bytes session_token = 7;
|
||||||
|
uint32 identity_id = 8;
|
||||||
}
|
}
|
||||||
|
|
||||||
message SDLRegister {
|
message SDLRegister {
|
||||||
@ -141,14 +182,15 @@ message SDLRegisterAck {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 网络类型探测
|
// 网络类型探测
|
||||||
|
|
||||||
message SDLStunProbe {
|
message SDLStunProbe {
|
||||||
uint32 cookie = 1;
|
uint32 cookie = 1;
|
||||||
uint32 attr = 2;
|
uint32 attr = 2;
|
||||||
|
uint32 step = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
message SDLStunProbeReply {
|
message SDLStunProbeReply {
|
||||||
uint32 cookie = 1;
|
uint32 cookie = 1;
|
||||||
uint32 port = 2;
|
uint32 step = 2;
|
||||||
uint32 ip = 3;
|
uint32 port = 3;
|
||||||
|
uint32 ip = 4;
|
||||||
}
|
}
|
||||||
9
app/src/main/res/drawable/preferences.xml
Normal file
9
app/src/main/res/drawable/preferences.xml
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<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>
|
||||||
9
app/src/main/res/drawable/tag.xml
Normal file
9
app/src/main/res/drawable/tag.xml
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<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>
|
||||||
20
app/src/test/java/com/jihe/punchnet/TestQuic.kt
Normal file
20
app/src/test/java/com/jihe/punchnet/TestQuic.kt
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
package com.jihe.punchnet
|
||||||
|
|
||||||
|
import org.junit.Test
|
||||||
|
import tech.kwik.core.QuicClientConnection
|
||||||
|
import java.net.URI
|
||||||
|
|
||||||
|
class TestQuic {
|
||||||
|
@Test
|
||||||
|
fun testQuic() {
|
||||||
|
try {
|
||||||
|
val builder = QuicClientConnection.newBuilder()
|
||||||
|
.uri(URI("https://127.0.0.1:1365"))
|
||||||
|
.applicationProtocol("punchnet/1.0")
|
||||||
|
.noServerCertificateCheck()
|
||||||
|
val connection = builder.build()
|
||||||
|
connection.connect()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -20,4 +20,5 @@ kotlin.code.style=official
|
|||||||
# Enables namespacing of each library's R class so that its R class includes only the
|
# Enables namespacing of each library's R class so that its R class includes only the
|
||||||
# resources declared in the library itself and none from the library's dependencies,
|
# resources declared in the library itself and none from the library's dependencies,
|
||||||
# thereby reducing the size of the R class for that library
|
# thereby reducing the size of the R class for that library
|
||||||
android.nonTransitiveRClass=true
|
android.nonTransitiveRClass=true
|
||||||
|
org.gradle.java.home=/Users/stavid/Library/Java/JavaVirtualMachines/corretto-17.0.17/Contents/Home
|
||||||
|
|||||||
BIN
key-store/punchnet.jks
Normal file
BIN
key-store/punchnet.jks
Normal file
Binary file not shown.
10
udp_dump.py
Normal file
10
udp_dump.py
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import socket
|
||||||
|
import binascii
|
||||||
|
|
||||||
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
sock.bind(('127.0.0.1', 1365))
|
||||||
|
print("Listening on UDP 1365...")
|
||||||
|
data, addr = sock.recvfrom(2048)
|
||||||
|
print("Received QUIC UDP packet:")
|
||||||
|
print(binascii.hexlify(data).decode('utf-8'))
|
||||||
|
sock.close()
|
||||||
Loading…
x
Reference in New Issue
Block a user