Compare commits
No commits in common. "main" and "master" have entirely different histories.
48
.gitignore
vendored
@ -1,35 +1,15 @@
|
|||||||
# ---> Android
|
|
||||||
# Gradle files
|
|
||||||
.gradle/
|
|
||||||
build/
|
|
||||||
|
|
||||||
# Local configuration file (sdk path, etc)
|
|
||||||
local.properties
|
|
||||||
|
|
||||||
# Log/OS Files
|
|
||||||
*.log
|
|
||||||
|
|
||||||
# Android Studio generated files and folders
|
|
||||||
captures/
|
|
||||||
.externalNativeBuild/
|
|
||||||
.cxx/
|
|
||||||
*.apk
|
|
||||||
output.json
|
|
||||||
|
|
||||||
# IntelliJ
|
|
||||||
*.iml
|
*.iml
|
||||||
.idea/
|
.gradle
|
||||||
misc.xml
|
/local.properties
|
||||||
deploymentTargetDropDown.xml
|
/.idea/caches
|
||||||
render.experimental.xml
|
/.idea/libraries
|
||||||
|
/.idea/modules.xml
|
||||||
# Keystore files
|
/.idea/workspace.xml
|
||||||
*.jks
|
/.idea/navEditor.xml
|
||||||
*.keystore
|
/.idea/assetWizardSettings.xml
|
||||||
|
.DS_Store
|
||||||
# Google Services (e.g. APIs or Firebase)
|
/build
|
||||||
google-services.json
|
/captures
|
||||||
|
.externalNativeBuild
|
||||||
# Android Profiling
|
.cxx
|
||||||
*.hprof
|
local.properties
|
||||||
|
|
||||||
|
|||||||
3
.idea/.gitignore
generated
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
1
.idea/.name
generated
Normal file
@ -0,0 +1 @@
|
|||||||
|
punchnet
|
||||||
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>
|
||||||
6
.idea/compiler.xml
generated
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="CompilerConfiguration">
|
||||||
|
<bytecodeTargetLevel target="21" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
18
.idea/deploymentTargetSelector.xml
generated
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="deploymentTargetSelector">
|
||||||
|
<selectionStates>
|
||||||
|
<SelectionState runConfigName="app">
|
||||||
|
<option name="selectionMode" value="DROPDOWN" />
|
||||||
|
<DropdownSelection timestamp="2026-05-30T07:22:02.241059Z">
|
||||||
|
<Target type="DEFAULT_BOOT">
|
||||||
|
<handle>
|
||||||
|
<DeviceId pluginId="LocalEmulator" identifier="path=/Users/stavid/.android/avd/Medium_Phone.avd" />
|
||||||
|
</handle>
|
||||||
|
</Target>
|
||||||
|
</DropdownSelection>
|
||||||
|
<DialogSelection />
|
||||||
|
</SelectionState>
|
||||||
|
</selectionStates>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
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>
|
||||||
20
.idea/gradle.xml
generated
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="GradleMigrationSettings" migrationVersion="1" />
|
||||||
|
<component name="GradleSettings">
|
||||||
|
<option name="linkedExternalProjectsSettings">
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="testRunner" value="CHOOSE_PER_TEST" />
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||||
|
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$" />
|
||||||
|
<option value="$PROJECT_DIR$/app" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
<option name="resolveExternalAnnotations" value="false" />
|
||||||
|
</GradleProjectSettings>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
61
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<profile version="1.0">
|
||||||
|
<option name="myName" value="Project Default" />
|
||||||
|
<inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
<option name="previewFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="ComposePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
<option name="previewFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="ComposePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
<option name="previewFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="ComposePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
<option name="previewFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="GlancePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="GlancePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="GlancePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
<option name="previewFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
<option name="previewFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PreviewDeviceShouldUseNewSpec" enabled="true" level="WEAK WARNING" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
<option name="previewFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
<option name="previewFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
<option name="previewFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
<option name="previewFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
<option name="previewFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
</profile>
|
||||||
|
</component>
|
||||||
6
.idea/kotlinc.xml
generated
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="KotlinJpsPluginSettings">
|
||||||
|
<option name="version" value="2.0.0" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
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>
|
||||||
10
.idea/migrations.xml
generated
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectMigrations">
|
||||||
|
<option name="MigrateToGradleLocalJavaHome">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
9
.idea/misc.xml
generated
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<project version="4">
|
||||||
|
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||||
|
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
||||||
|
<output url="file://$PROJECT_DIR$/build/classes" />
|
||||||
|
</component>
|
||||||
|
<component name="ProjectType">
|
||||||
|
<option name="id" value="Android" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
17
.idea/runConfigurations.xml
generated
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="RunConfigurationProducerService">
|
||||||
|
<option name="ignoredProducers">
|
||||||
|
<set>
|
||||||
|
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
|
||||||
|
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
|
||||||
|
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
|
||||||
|
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
|
||||||
|
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
|
||||||
|
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
|
||||||
|
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
|
||||||
|
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/vcs.xml
generated
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
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
@ -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
@ -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
|
||||||
|
|
||||||
1
app/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/build
|
||||||
107
app/build.gradle.kts
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application)
|
||||||
|
alias(libs.plugins.kotlin.android)
|
||||||
|
alias(libs.plugins.kotlin.compose)
|
||||||
|
id("com.google.protobuf") version "0.9.4"
|
||||||
|
id("com.google.devtools.ksp") version "2.0.0-1.0.22"
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.jihe.punchnet"
|
||||||
|
compileSdk = 35
|
||||||
|
|
||||||
|
packagingOptions {
|
||||||
|
exclude("META-INF/versions/9/OSGI-INF/MANIFEST.MF")
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "com.jihe.punchnet"
|
||||||
|
minSdk = 26
|
||||||
|
targetSdk = 35
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "1.0"
|
||||||
|
|
||||||
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
|
targetCompatibility = JavaVersion.VERSION_11
|
||||||
|
}
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "11"
|
||||||
|
}
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
protobuf {
|
||||||
|
protoc {
|
||||||
|
artifact = "com.google.protobuf:protoc:4.29.3"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
generateProtoTasks {
|
||||||
|
all().forEach {
|
||||||
|
task -> task.builtins {
|
||||||
|
create("kotlin")
|
||||||
|
create("java")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
|
||||||
|
implementation(libs.androidx.core.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
|
implementation(libs.androidx.activity.compose)
|
||||||
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
|
implementation(libs.androidx.ui)
|
||||||
|
implementation(libs.androidx.ui.graphics)
|
||||||
|
implementation(libs.androidx.ui.tooling.preview)
|
||||||
|
implementation(libs.androidx.material3)
|
||||||
|
testImplementation(libs.junit)
|
||||||
|
androidTestImplementation(libs.androidx.junit)
|
||||||
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
|
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||||
|
androidTestImplementation(libs.androidx.ui.test.junit4)
|
||||||
|
debugImplementation(libs.androidx.ui.tooling)
|
||||||
|
debugImplementation(libs.androidx.ui.test.manifest)
|
||||||
|
|
||||||
|
implementation("com.google.protobuf:protobuf-java:4.29.3")
|
||||||
|
implementation("com.google.protobuf:protobuf-kotlin:4.29.3")
|
||||||
|
|
||||||
|
implementation("androidx.navigation:navigation-compose:2.9.0")
|
||||||
|
implementation("io.coil-kt:coil-compose:2.6.0")
|
||||||
|
|
||||||
|
implementation("androidx.room:room-runtime:2.7.2")
|
||||||
|
ksp("androidx.room:room-compiler:2.7.2")
|
||||||
|
implementation("androidx.room:room-ktx:2.7.2")
|
||||||
|
|
||||||
|
// HTTP API Control Plane
|
||||||
|
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||||
|
implementation("com.google.code.gson:gson:2.10.1")
|
||||||
|
|
||||||
|
// QUIC Data Plane (Kwik)
|
||||||
|
implementation("tech.kwik:kwik:0.10.3")
|
||||||
|
|
||||||
|
implementation(libs.bcprov.jdk18on)
|
||||||
|
implementation(libs.bcpkix.jdk18on)
|
||||||
|
// implementation(files("libs/org.asxalex.sdlan/sdlan-1.0-SNAPSHOT.jar"))
|
||||||
|
// implementation(files("libs/org.asxalex.sdlan/1.0.0/sdlan-1.0.0.jar"))
|
||||||
|
|
||||||
|
// Security Crypto
|
||||||
|
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||||
|
}
|
||||||
21
app/proguard-rules.pro
vendored
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
# Add project specific ProGuard rules here.
|
||||||
|
# You can control the set of applied configuration files using the
|
||||||
|
# proguardFiles setting in build.gradle.
|
||||||
|
#
|
||||||
|
# For more details, see
|
||||||
|
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||||
|
|
||||||
|
# If your project uses WebView with JS, uncomment the following
|
||||||
|
# and specify the fully qualified class name to the JavaScript interface
|
||||||
|
# class:
|
||||||
|
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||||
|
# public *;
|
||||||
|
#}
|
||||||
|
|
||||||
|
# Uncomment this to preserve the line number information for
|
||||||
|
# debugging stack traces.
|
||||||
|
#-keepattributes SourceFile,LineNumberTable
|
||||||
|
|
||||||
|
# If you keep the line number information, uncomment this to
|
||||||
|
# hide the original source file name.
|
||||||
|
#-renamesourcefileattribute SourceFile
|
||||||
BIN
app/release/app-release.apk
Normal file
BIN
app/release/baselineProfiles/0/app-release.dm
Normal file
BIN
app/release/baselineProfiles/1/app-release.dm
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
|
||||||
|
}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
package com.jihe.punchnet
|
||||||
|
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instrumented test, which will execute on an Android device.
|
||||||
|
*
|
||||||
|
* See [testing documentation](http://d.android.com/tools/testing).
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class ExampleInstrumentedTest {
|
||||||
|
@Test
|
||||||
|
fun useAppContext() {
|
||||||
|
// Context of the app under test.
|
||||||
|
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
|
assertEquals("com.jihe.punchnet", appContext.packageName)
|
||||||
|
}
|
||||||
|
}
|
||||||
46
app/src/main/AndroidManifest.xml
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"/>
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
|
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"/>
|
||||||
|
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
|
android:icon="@drawable/punchnet_log"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.Punchnet"
|
||||||
|
android:usesCleartextTraffic="true"
|
||||||
|
tools:targetApi="31">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:theme="@style/Theme.Punchnet">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<service android:name=".PunchnetService"
|
||||||
|
android:exported="true"
|
||||||
|
android:foregroundServiceType="specialUse"
|
||||||
|
android:permission="android.permission.BIND_VPN_SERVICE">
|
||||||
|
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.net.VpnService"/>
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
200
app/src/main/java/com/jihe/punchnet/MainActivity.kt
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
package com.jihe.punchnet
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.VpnService
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.activity.viewModels
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import com.jihe.punchnet.data.ButtonState
|
||||||
|
import com.jihe.punchnet.data.ButtonViewModel
|
||||||
|
import com.jihe.punchnet.data.RouteViewModel
|
||||||
|
import com.jihe.punchnet.data.ServiceViewModel
|
||||||
|
import com.jihe.punchnet.screen.MainApp
|
||||||
|
import com.jihe.punchnet.ui.theme.PunchnetTheme
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
|
||||||
|
class PunchnetApp: Application() {
|
||||||
|
val buttonModel: ButtonViewModel by lazy {
|
||||||
|
ViewModelProvider.AndroidViewModelFactory.getInstance(this)
|
||||||
|
.create(ButtonViewModel::class.java)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
private val TAG = "MainActivity"
|
||||||
|
|
||||||
|
private val viewModel: RouteViewModel by viewModels {
|
||||||
|
object: ViewModelProvider.Factory {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
override fun<T: ViewModel> create(modelClass: Class<T>): T {
|
||||||
|
return RouteViewModel(application) as T
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val serviceModel: ServiceViewModel by viewModels {
|
||||||
|
object: ViewModelProvider.Factory {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
override fun<T: ViewModel> create(modelClass: Class<T>): T {
|
||||||
|
return ServiceViewModel(application) as T
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
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() {
|
||||||
|
val intent = VpnService.prepare(this)
|
||||||
|
if (intent != null) {
|
||||||
|
vpnPermissionLauncher.launch(intent)
|
||||||
|
} else {
|
||||||
|
startVpnService(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
private fun startVpnService(context: Context) {
|
||||||
|
val intent = Intent(context, PunchnetService::class.java)
|
||||||
|
intent.putExtra("argument", PunchnetServiceArgument(
|
||||||
|
"",
|
||||||
|
arrayOf(
|
||||||
|
RouteInfo(
|
||||||
|
"192.168.80.0/24", "10.211.188.2"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
))
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
startForegroundService(intent)
|
||||||
|
} else {
|
||||||
|
startService(intent)
|
||||||
|
}
|
||||||
|
Toast.makeText(this, "VPN service started", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
// Initialize AppAuthRepository for Split Tunneling
|
||||||
|
com.jihe.punchnet.data.AppAuthRepository.init(this)
|
||||||
|
|
||||||
|
enableEdgeToEdge()
|
||||||
|
|
||||||
|
Log.d("DIR", "filesdir = ${this.filesDir}")
|
||||||
|
|
||||||
|
|
||||||
|
val buttonModel = ButtonViewModel()
|
||||||
|
buttonModel.updateState(ButtonState.ButtonStopped)
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
PunchnetTheme {
|
||||||
|
MainApp(buttonModel, serviceModel, viewModel, rememberNavController())
|
||||||
|
/*
|
||||||
|
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
||||||
|
StartStop(
|
||||||
|
R.string.start_vpn,
|
||||||
|
R.string.stop_vpn,
|
||||||
|
onStart = {
|
||||||
|
prepareAndStartVPN()
|
||||||
|
},
|
||||||
|
onStop = {
|
||||||
|
// stopVpnService()
|
||||||
|
},
|
||||||
|
modifier = Modifier.padding(innerPadding)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun StartStop(
|
||||||
|
@StringRes start_name_id: Int,
|
||||||
|
@StringRes stop_name_id: Int,
|
||||||
|
onStart: ()->Unit,
|
||||||
|
onStop: ()->Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val start_text = stringResource(start_name_id)
|
||||||
|
val stop_text = stringResource(stop_name_id)
|
||||||
|
var button_state by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var button_text by rememberSaveable() { mutableStateOf(start_text) }
|
||||||
|
Button(
|
||||||
|
modifier = modifier,
|
||||||
|
onClick = {
|
||||||
|
button_state = !button_state
|
||||||
|
if (button_state) {
|
||||||
|
onStart()
|
||||||
|
button_text = stop_text
|
||||||
|
} else {
|
||||||
|
Log.d("STOP", "call on stop")
|
||||||
|
onStop()
|
||||||
|
button_text = start_text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text (
|
||||||
|
text = button_text
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun Greeting(name: String, modifier: Modifier = Modifier) {
|
||||||
|
Text(
|
||||||
|
text = "Hello $name!",
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
fun GreetingPreview() {
|
||||||
|
PunchnetTheme {
|
||||||
|
Greeting("Android")
|
||||||
|
}
|
||||||
|
}
|
||||||
334
app/src/main/java/com/jihe/punchnet/PunchnetService.kt
Normal file
@ -0,0 +1,334 @@
|
|||||||
|
package com.jihe.punchnet
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.VpnService
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Environment
|
||||||
|
import android.os.ParcelFileDescriptor
|
||||||
|
import android.util.Log
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import com.jihe.punchnet.data.ButtonRepository
|
||||||
|
import com.jihe.punchnet.data.ButtonState
|
||||||
|
import com.jihe.punchnet.data.RouteItem
|
||||||
|
import com.jihe.punchnet.sdlan.config.Arguments
|
||||||
|
import com.jihe.punchnet.sdlan.config.toIPV4String
|
||||||
|
import com.jihe.punchnet.sdlan.logs.TerminalLogger
|
||||||
|
import com.jihe.punchnet.sdlan.network.ARPTable
|
||||||
|
import com.jihe.punchnet.sdlan.network.ARPWaitList
|
||||||
|
import com.jihe.punchnet.sdlan.network.DeviceConfig
|
||||||
|
import com.jihe.punchnet.sdlan.network.IfaceTun
|
||||||
|
import com.jihe.punchnet.sdlan.network.RouteDetail
|
||||||
|
import com.jihe.punchnet.sdlan.network.ipInt2ByteArray
|
||||||
|
import com.jihe.punchnet.sdlan.network.maskIPToDigit
|
||||||
|
import com.jihe.punchnet.sdlan.network.run_sdlan
|
||||||
|
import com.jihe.punchnet.sdlan.utils.ipToString
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.FileInputStream
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
|
||||||
|
class PunchnetService : VpnService() , IfaceTun {
|
||||||
|
|
||||||
|
private val TAG = "PunchnetService"
|
||||||
|
private var scope = CoroutineScope(Dispatchers.IO)
|
||||||
|
|
||||||
|
private val routes: MutableList<RouteItem> = mutableListOf()
|
||||||
|
|
||||||
|
private var vpnDescriptor: ParcelFileDescriptor? = null
|
||||||
|
|
||||||
|
private val serviceLock = Any()
|
||||||
|
// private val isRunning = AtomicBoolean(false)
|
||||||
|
|
||||||
|
var input: FileInputStream? = null
|
||||||
|
var output: FileOutputStream? = null
|
||||||
|
|
||||||
|
override var arpTable = ARPTable(scope)
|
||||||
|
override val arpWaitList = ARPWaitList()
|
||||||
|
|
||||||
|
var config: DeviceConfig = DeviceConfig(1400)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val ACTION_CONNECT = "com.jihe.punchnet.punchnetservice.CONNECT"
|
||||||
|
const val ACTION_DISCONNECT = "com.jihe.punchnet.punchnetservice.DISCONNECT"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
vpnDescriptor?.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun recv(): ByteArray {
|
||||||
|
val result = withContext(Dispatchers.IO) {
|
||||||
|
val result = ByteArray(1500)
|
||||||
|
try {
|
||||||
|
var size = input?.read(result)
|
||||||
|
if (size == null) {
|
||||||
|
size = 0
|
||||||
|
} else {
|
||||||
|
println("xxx got $size bytes")
|
||||||
|
}
|
||||||
|
|
||||||
|
// val size = input?.read(result)?:0
|
||||||
|
// Log.d(TAG, "RECEIVED $size bytes")
|
||||||
|
result.copyOf(size)
|
||||||
|
// result.slice(0..<size).toByteArray()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("xxx read exception: ${e.message}")
|
||||||
|
e.printStackTrace()
|
||||||
|
ByteArray(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun send(content: ByteArray) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
// Log.d(TAG, "WROTE bytes to vpn service")
|
||||||
|
try {
|
||||||
|
output?.write(content)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
TerminalLogger.errorf { "failed to write $e" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopVpn() {
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun reload_config(config: DeviceConfig) {
|
||||||
|
config.mtu = 1400
|
||||||
|
this.config = config
|
||||||
|
val ip = ipInt2ByteArray(config.ip.netAddr).toIPV4String()
|
||||||
|
TerminalLogger.debugf {"got ip address from remote: ${ip}"}
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
input?.close()
|
||||||
|
output?.close()
|
||||||
|
}
|
||||||
|
vpnDescriptor?.close()
|
||||||
|
|
||||||
|
var tempVpnDescriptor = Builder()
|
||||||
|
.setMtu(config.mtu)
|
||||||
|
.addAddress(ip, config.ip.netBitLen.toInt())
|
||||||
|
.setBlocking(true)
|
||||||
|
|
||||||
|
// Automatically add route for the SDLAN network itself
|
||||||
|
val maskInt = com.jihe.punchnet.sdlan.network.netmaskBit2Int(config.ip.netBitLen)
|
||||||
|
val networkInt = config.ip.netAddr and maskInt
|
||||||
|
val networkStr = ipInt2ByteArray(networkInt).toIPV4String()
|
||||||
|
tempVpnDescriptor = tempVpnDescriptor.addRoute(networkStr, config.ip.netBitLen.toInt())
|
||||||
|
|
||||||
|
for (route in routes) {
|
||||||
|
tempVpnDescriptor = tempVpnDescriptor.addRoute(ipToString(route.net_ip), maskIPToDigit(route.mask_ip))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply App Authorization (Split Tunneling)
|
||||||
|
val allowedApps = com.jihe.punchnet.data.AppAuthRepository.allowedApps.value
|
||||||
|
if (allowedApps.isNotEmpty()) {
|
||||||
|
for (pkg in allowedApps) {
|
||||||
|
// Ensure we do not add ourselves to the allowed list, otherwise it causes a routing loop
|
||||||
|
if (pkg == this.packageName) continue
|
||||||
|
try {
|
||||||
|
tempVpnDescriptor = tempVpnDescriptor.addAllowedApplication(pkg)
|
||||||
|
} catch (e: android.content.pm.PackageManager.NameNotFoundException) {
|
||||||
|
TerminalLogger.warning { "App not found when setting VPN allowed app: $pkg" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If no apps are specified, VPN intercepts everything matching the routes.
|
||||||
|
// We ONLY disallow ourselves if there's a 0.0.0.0/0 route, to avoid routing loops.
|
||||||
|
// If there's no global route, we want the app (e.g. in-app WebViews) to be able to access the VPN routes!
|
||||||
|
val hasGlobalRoute = routes.any { it.mask_ip == 0 }
|
||||||
|
if (hasGlobalRoute) {
|
||||||
|
try {
|
||||||
|
tempVpnDescriptor = tempVpnDescriptor.addDisallowedApplication(this.packageName)
|
||||||
|
TerminalLogger.infof { "Disallowed app itself to prevent loop on 0.0.0.0/0" }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
TerminalLogger.warning { "Failed to disallow self: $e" }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
TerminalLogger.infof { "Did not disallow self. In-app WebViews will be routed via VPN." }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
vpnDescriptor = tempVpnDescriptor
|
||||||
|
.establish()?.apply {
|
||||||
|
input = FileInputStream(fileDescriptor)
|
||||||
|
output = FileOutputStream(fileDescriptor)
|
||||||
|
}
|
||||||
|
ButtonRepository.updateState(ButtonState.ButtonStarted)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun disconnect() {
|
||||||
|
synchronized(serviceLock) {
|
||||||
|
if (ButtonRepository.buttonState.value != ButtonState.ButtonStarted) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
if (!isRunning.get()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
ButtonRepository.updateState(ButtonState.ButtonStopping)
|
||||||
|
|
||||||
|
Toast.makeText(this, "stop vpn called", Toast.LENGTH_LONG).show()
|
||||||
|
input?.close()
|
||||||
|
output?.close()
|
||||||
|
vpnDescriptor?.close()
|
||||||
|
vpnDescriptor = null
|
||||||
|
|
||||||
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
|
stopSelf()
|
||||||
|
// isRunning.set(false)
|
||||||
|
}
|
||||||
|
ButtonRepository.updateState(ButtonState.ButtonStopped)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun connect(startArg: PunchnetServiceArgument?) {
|
||||||
|
synchronized(serviceLock) {
|
||||||
|
if (ButtonRepository.buttonState.value != ButtonState.ButtonStopped) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
if (isRunning.get()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
ButtonRepository.updateState(ButtonState.ButtonStarting)
|
||||||
|
|
||||||
|
val iface = this
|
||||||
|
val server = "punchnet.aioe.tech"
|
||||||
|
|
||||||
|
Log.d("DIR", "datadir = ${Environment.getDataDirectory().name}")
|
||||||
|
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 {
|
||||||
|
super.onStartCommand(intent, flags, startId)
|
||||||
|
|
||||||
|
return if (intent?.action == ACTION_DISCONNECT) {
|
||||||
|
scope.cancel()
|
||||||
|
disconnect()
|
||||||
|
START_STICKY
|
||||||
|
} else {
|
||||||
|
scope = CoroutineScope(Dispatchers.IO)
|
||||||
|
arpTable = ARPTable(scope)
|
||||||
|
|
||||||
|
val argument = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
intent?.getParcelableExtra("argument", PunchnetServiceArgument::class.java)
|
||||||
|
} else {
|
||||||
|
intent?.getParcelableExtra("argument")
|
||||||
|
}
|
||||||
|
TerminalLogger.debugf { "service argument received: routes=${argument?.routes?.size ?: 0}, hasToken=${!argument?.token.isNullOrEmpty()}" }
|
||||||
|
connect(argument)
|
||||||
|
START_STICKY
|
||||||
|
}
|
||||||
|
// return super.onStartCommand(intent, flags, startId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createNotification(): Notification {
|
||||||
|
val channelId = "punchnet_channel"
|
||||||
|
val channel = NotificationChannel (
|
||||||
|
channelId,
|
||||||
|
"punchnet",
|
||||||
|
NotificationManager.IMPORTANCE_DEFAULT
|
||||||
|
)
|
||||||
|
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
manager.createNotificationChannel(channel)
|
||||||
|
|
||||||
|
return NotificationCompat.Builder(this, channelId)
|
||||||
|
.setContentTitle("Punchnet Service")
|
||||||
|
.setContentText("Punchnet is running")
|
||||||
|
.setSmallIcon(R.drawable.ic_vpn)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
// scope.cancel()
|
||||||
|
// disconnect()
|
||||||
|
scope.cancel()
|
||||||
|
disconnect()
|
||||||
|
com.jihe.punchnet.data.NodeRepository.clear()
|
||||||
|
Log.e(TAG, "onDestroy is called")
|
||||||
|
// stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,58 @@
|
|||||||
|
package com.jihe.punchnet
|
||||||
|
|
||||||
|
import android.os.Parcel
|
||||||
|
import android.os.Parcelable
|
||||||
|
import com.jihe.punchnet.data.RouteItem
|
||||||
|
|
||||||
|
data class RouteInfo(
|
||||||
|
// 192.168.80.0/24
|
||||||
|
val targetNetCIDR: String,
|
||||||
|
// 10.167.69.2
|
||||||
|
val gateway: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class PunchnetServiceArgument(
|
||||||
|
val token: String,
|
||||||
|
val routes: Array<RouteItem>,
|
||||||
|
): Parcelable {
|
||||||
|
override fun writeToParcel(dest: Parcel, flags: Int) {
|
||||||
|
dest.writeString(token)
|
||||||
|
dest.writeInt(routes.size)
|
||||||
|
routes.forEach { item ->
|
||||||
|
dest.writeLong(item.id)
|
||||||
|
dest.writeInt(item.net_ip)
|
||||||
|
dest.writeInt(item.mask_ip)
|
||||||
|
dest.writeInt(item.gateway)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun describeContents(): Int {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object CREATOR: Parcelable.Creator<PunchnetServiceArgument> {
|
||||||
|
override fun createFromParcel(source: Parcel): PunchnetServiceArgument {
|
||||||
|
val token = source.readString()!!
|
||||||
|
val size = source.readInt()
|
||||||
|
var routes = mutableListOf<RouteItem>()
|
||||||
|
for (i in 0..<size) {
|
||||||
|
val id = source.readLong()!!
|
||||||
|
val netIP = source.readInt()!!
|
||||||
|
val maskIP = source.readInt()!!
|
||||||
|
val gateway = source.readInt()!!
|
||||||
|
|
||||||
|
routes.add(RouteItem(
|
||||||
|
id = id,
|
||||||
|
net_ip = netIP,
|
||||||
|
mask_ip = maskIP,
|
||||||
|
gateway = gateway,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
return PunchnetServiceArgument(token, routes.toTypedArray())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun newArray(size: Int): Array<PunchnetServiceArgument?> {
|
||||||
|
return arrayOfNulls(size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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
@ -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
@ -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
@ -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
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
111
app/src/main/java/com/jihe/punchnet/data/RouteItem.kt
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
package com.jihe.punchnet.data
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.room.ColumnInfo
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Database
|
||||||
|
import androidx.room.Delete
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
import androidx.room.Query
|
||||||
|
import androidx.room.Room
|
||||||
|
import androidx.room.RoomDatabase
|
||||||
|
import androidx.room.Update
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@Entity(tableName = "routes")
|
||||||
|
data class RouteItem (
|
||||||
|
@PrimaryKey(autoGenerate = true)
|
||||||
|
val id: Long = 0,
|
||||||
|
|
||||||
|
@ColumnInfo(name = "net_ip")
|
||||||
|
val net_ip: Int,
|
||||||
|
// mask int, Intof(255,255,255,0
|
||||||
|
@ColumnInfo(name = "mask_ip")
|
||||||
|
val mask_ip: Int,
|
||||||
|
@ColumnInfo(name = "gateway")
|
||||||
|
val gateway: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface RouteDAO {
|
||||||
|
@Insert
|
||||||
|
suspend fun insert(item: RouteItem): Long
|
||||||
|
|
||||||
|
@Update
|
||||||
|
suspend fun update(item: RouteItem)
|
||||||
|
|
||||||
|
@Query("DELETE FROM routes WHERE id = :id")
|
||||||
|
suspend fun deleteById(id: Long)
|
||||||
|
|
||||||
|
@Query("SELECT * FROM routes")
|
||||||
|
fun getAll(): Flow<List<RouteItem>>
|
||||||
|
}
|
||||||
|
|
||||||
|
@Database(entities = [RouteItem::class], version = 1)
|
||||||
|
abstract class AppDatabase: RoomDatabase() {
|
||||||
|
abstract fun routeDao(): RouteDAO
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@Volatile
|
||||||
|
private var INSTANCE: AppDatabase? = null
|
||||||
|
|
||||||
|
fun getDatabase(context: Context): AppDatabase {
|
||||||
|
return INSTANCE ?: synchronized(this) {
|
||||||
|
val instance = Room.databaseBuilder(
|
||||||
|
context.applicationContext,
|
||||||
|
AppDatabase::class.java,
|
||||||
|
"app_database"
|
||||||
|
)
|
||||||
|
.fallbackToDestructiveMigration()
|
||||||
|
.build()
|
||||||
|
INSTANCE = instance
|
||||||
|
instance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class RouteRepository(private val routeDao: RouteDAO) {
|
||||||
|
val all_routes: Flow<List<RouteItem>> = routeDao.getAll()
|
||||||
|
|
||||||
|
suspend fun insert(item: RouteItem) {
|
||||||
|
routeDao.insert(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun update(item: RouteItem) {
|
||||||
|
routeDao.update(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun deleteById(id: Long) {
|
||||||
|
routeDao.deleteById(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class RouteViewModel(application: Application): AndroidViewModel(application) {
|
||||||
|
private val repository: RouteRepository
|
||||||
|
|
||||||
|
init {
|
||||||
|
val routeDao = AppDatabase.getDatabase(application).routeDao()
|
||||||
|
repository = RouteRepository(routeDao)
|
||||||
|
}
|
||||||
|
|
||||||
|
val allRoutes: Flow<List<RouteItem>> = repository.all_routes
|
||||||
|
|
||||||
|
fun insert(item: RouteItem) = viewModelScope.launch {
|
||||||
|
repository.insert(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun update(item: RouteItem) = viewModelScope.launch {
|
||||||
|
repository.update(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteById(id: Long) = viewModelScope.launch {
|
||||||
|
repository.deleteById(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
69
app/src/main/java/com/jihe/punchnet/data/ServiceViewModel.kt
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
package com.jihe.punchnet.data
|
||||||
|
|
||||||
|
import android.app.Activity.RESULT_CANCELED
|
||||||
|
import android.app.Activity.RESULT_OK
|
||||||
|
import android.app.Application
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.VpnService
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.runtime.MutableState
|
||||||
|
import androidx.compose.runtime.State
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.core.content.ContextCompat.startForegroundService
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.jihe.punchnet.PunchnetService
|
||||||
|
import com.jihe.punchnet.PunchnetServiceArgument
|
||||||
|
import com.jihe.punchnet.RouteInfo
|
||||||
|
import com.jihe.punchnet.sdlan.utils.ipToString
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.forEach
|
||||||
|
import kotlinx.coroutines.flow.toList
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceViewModel(application: Application): AndroidViewModel(application) {
|
||||||
|
/*
|
||||||
|
private val _isRunning = mutableStateOf(false)
|
||||||
|
val isRunning: State<Boolean> = _isRunning
|
||||||
|
*/
|
||||||
|
|
||||||
|
fun startService(context: Context, routes: Array<RouteItem>, token: String) {
|
||||||
|
// should has the permission
|
||||||
|
startVpnService(context, routes, token)
|
||||||
|
// ButtonRepository.updateState(ButtonState.ButtonStarting)
|
||||||
|
// _isRunning.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startVpnService(context: Context, routes: Array<RouteItem>, token: String) {
|
||||||
|
val intent = Intent(context, PunchnetService::class.java)
|
||||||
|
intent.putExtra("argument", PunchnetServiceArgument(
|
||||||
|
token,
|
||||||
|
routes,
|
||||||
|
))
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
context.startForegroundService(intent)
|
||||||
|
} else {
|
||||||
|
context.startService(intent)
|
||||||
|
}
|
||||||
|
Toast.makeText(context, "VPN service started", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fun stopVpnService(context: Context) {
|
||||||
|
Log.d("STOP PUNCHNET", "stopping PUNCHNET")
|
||||||
|
context.startService(Intent(context, PunchnetService::class.java).also { it.action=
|
||||||
|
PunchnetService.ACTION_DISCONNECT })
|
||||||
|
// stopService(Intent(this, PunchnetService::class.java))
|
||||||
|
Log.d("STOPPED PUNCHNET", "stopping PUNCHNET")
|
||||||
|
// ButtonRepository.updateState(ButtonState.ButtonStopping)
|
||||||
|
// Toast.makeText(this, "VPN service stopped", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
158
app/src/main/java/com/jihe/punchnet/helper/Screens.kt
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
package com.jihe.punchnet.helper
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import com.jihe.punchnet.data.RouteItem
|
||||||
|
import com.jihe.punchnet.sdlan.network.maskDigitToInt
|
||||||
|
|
||||||
|
const val PreferenceRepositoryName = "pref"
|
||||||
|
sealed class PreferenceName(val name: String) {
|
||||||
|
object PreferenceToken: PreferenceName("token")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getEncryptedSharedPrefs(context: Context): SharedPreferences {
|
||||||
|
val masterKey = androidx.security.crypto.MasterKey.Builder(context)
|
||||||
|
.setKeyScheme(androidx.security.crypto.MasterKey.KeyScheme.AES256_GCM)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
return androidx.security.crypto.EncryptedSharedPreferences.create(
|
||||||
|
context,
|
||||||
|
PreferenceRepositoryName,
|
||||||
|
masterKey,
|
||||||
|
androidx.security.crypto.EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
|
androidx.security.crypto.EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getPlainSharedPrefs(context: Context): SharedPreferences {
|
||||||
|
return context.getSharedPreferences(PreferenceRepositoryName, Activity.MODE_PRIVATE)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun migratePlainPreference(context: Context, name: PreferenceName, encryptedPrefs: SharedPreferences) {
|
||||||
|
if (encryptedPrefs.contains(name.name)) return
|
||||||
|
|
||||||
|
val plainPrefs = getPlainSharedPrefs(context)
|
||||||
|
if (!plainPrefs.contains(name.name)) return
|
||||||
|
|
||||||
|
when (val value = plainPrefs.all[name.name]) {
|
||||||
|
is String -> encryptedPrefs.edit().putString(name.name, value).apply()
|
||||||
|
is Int -> encryptedPrefs.edit().putInt(name.name, value).apply()
|
||||||
|
}
|
||||||
|
plainPrefs.edit().remove(name.name).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getPreferenceString(context: Context, name: PreferenceName): String? {
|
||||||
|
try {
|
||||||
|
val preference = getEncryptedSharedPrefs(context)
|
||||||
|
migratePlainPreference(context, name, preference)
|
||||||
|
return preference.getString(name.name, "")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getPreferenceInt(context: Context, name: PreferenceName): Int? {
|
||||||
|
try {
|
||||||
|
val preference = getEncryptedSharedPrefs(context)
|
||||||
|
migratePlainPreference(context, name, preference)
|
||||||
|
return preference.getInt(name.name, 0)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setPreferenceString(context: Context, name: PreferenceName, value: String) {
|
||||||
|
try {
|
||||||
|
val preference = getEncryptedSharedPrefs(context)
|
||||||
|
preference.edit().putString(name.name, value).apply()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setPreferenceInt(context: Context, name: PreferenceName, value: Int) {
|
||||||
|
try {
|
||||||
|
val preference = getEncryptedSharedPrefs(context)
|
||||||
|
preference.edit().putInt(name.name, value).apply()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
sealed class Screen(val route: String) {
|
||||||
|
object ScreenRoutes: Screen("routes")
|
||||||
|
object ScreenMain: Screen("main")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun IntToIPString(ip: Int): String {
|
||||||
|
val one = ((ip ushr 24) and 0x000000ff)
|
||||||
|
val two = ((ip ushr 16) and 0x000000ff)
|
||||||
|
val three = ((ip ushr 8) and 0x000000ff)
|
||||||
|
val four = ((ip) and 0x000000ff)
|
||||||
|
return "${one}.${two}.${three}.${four}"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun IPStringToInt(ipStr: String): Int? {
|
||||||
|
val digits = ipStr.split('.').filter{
|
||||||
|
val single = try {
|
||||||
|
it.toInt()
|
||||||
|
} catch(e: Exception) {
|
||||||
|
return@filter false
|
||||||
|
}
|
||||||
|
single in 0..255
|
||||||
|
}
|
||||||
|
if (digits.size != 4) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val result: Int
|
||||||
|
try {
|
||||||
|
result = (digits[0].toInt() shl 24) +
|
||||||
|
(digits[1].toInt() shl 16) +
|
||||||
|
(digits[2].toInt() shl 8) +
|
||||||
|
(digits[3].toInt())
|
||||||
|
} catch(e: Exception) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parseCIDRAndGW(cidr: String, gw: String): RouteItem? {
|
||||||
|
val cidrSplit = cidr.split('/')
|
||||||
|
var ip = 0
|
||||||
|
var digit = 0
|
||||||
|
|
||||||
|
if (cidrSplit.size == 1) {
|
||||||
|
digit = 32
|
||||||
|
} else if (cidrSplit.size == 2) {
|
||||||
|
try {
|
||||||
|
val k = cidrSplit[1].toInt()
|
||||||
|
if (k in 1..32) {
|
||||||
|
digit = k
|
||||||
|
} else {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val ipvalue = IPStringToInt(cidrSplit[0])
|
||||||
|
val gateway = IPStringToInt(gw)
|
||||||
|
if (ipvalue == null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (gateway == null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val mask = maskDigitToInt(digit)!!
|
||||||
|
return RouteItem(
|
||||||
|
net_ip = ipvalue,
|
||||||
|
gateway = gateway,
|
||||||
|
mask_ip = mask,
|
||||||
|
)
|
||||||
|
}
|
||||||
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 = "添加应用"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,95 @@
|
|||||||
|
package com.jihe.punchnet.screen
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import androidx.navigation.NavHostController
|
||||||
|
import androidx.navigation.compose.NavHost
|
||||||
|
import androidx.navigation.compose.composable
|
||||||
|
import androidx.navigation.navArgument
|
||||||
|
import com.jihe.punchnet.R
|
||||||
|
import com.jihe.punchnet.RouteInfo
|
||||||
|
import com.jihe.punchnet.data.ButtonRepository
|
||||||
|
import com.jihe.punchnet.data.ButtonState
|
||||||
|
import com.jihe.punchnet.data.ButtonViewModel
|
||||||
|
import com.jihe.punchnet.data.RouteViewModel
|
||||||
|
import com.jihe.punchnet.data.ServiceViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AppNavHost2(
|
||||||
|
buttonViewModel: ButtonViewModel,
|
||||||
|
serviceViewModel: ServiceViewModel,
|
||||||
|
routeViewModel: RouteViewModel = viewModel(),
|
||||||
|
navController: NavHostController,
|
||||||
|
paddingValues: PaddingValues
|
||||||
|
) {
|
||||||
|
|
||||||
|
val context = androidx.compose.ui.platform.LocalContext.current
|
||||||
|
val token = com.jihe.punchnet.helper.getPreferenceString(context, com.jihe.punchnet.helper.PreferenceName.PreferenceToken)
|
||||||
|
val startDest = if (token.isNullOrEmpty()) Screen.LoginScreen.route else Screen.MainScreen.route
|
||||||
|
|
||||||
|
NavHost(
|
||||||
|
navController = navController,
|
||||||
|
startDestination = startDest,
|
||||||
|
modifier = Modifier.padding(paddingValues)
|
||||||
|
) {
|
||||||
|
composable(Screen.LoginScreen.route) {
|
||||||
|
LoginScreen(navController)
|
||||||
|
}
|
||||||
|
composable(Screen.MainScreen.route) {
|
||||||
|
HomeScreen(buttonViewModel, serviceViewModel, routeViewModel, navController)
|
||||||
|
}
|
||||||
|
composable(Screen.DeviceScreen.route) {
|
||||||
|
CustomHeaderScreen("设备") {
|
||||||
|
DeviceScreen(navController)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
composable(Screen.AppAuthScreen.route) {
|
||||||
|
CustomHeaderScreen("应用授权") {
|
||||||
|
AppAuthScreen(navController)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
composable(Screen.SettingsScreen.route) {
|
||||||
|
CustomHeaderScreen("设置") {
|
||||||
|
SettingsScreen(ButtonRepository.buttonState.collectAsState().value != ButtonState.ButtonStarted, routeViewModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
composable(
|
||||||
|
route = Screen.WebViewScreen.route,
|
||||||
|
arguments = listOf(navArgument("url") { type = androidx.navigation.NavType.StringType })
|
||||||
|
) { backStackEntry ->
|
||||||
|
val url = backStackEntry.arguments?.getString("url") ?: ""
|
||||||
|
WebViewScreen(
|
||||||
|
url = url,
|
||||||
|
onBack = { navController.popBackStack() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
70
app/src/main/java/com/jihe/punchnet/screen/BottomNavBar.kt
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
package com.jihe.punchnet.screen
|
||||||
|
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Build
|
||||||
|
import androidx.compose.material.icons.filled.Home
|
||||||
|
import androidx.compose.material.icons.filled.Info
|
||||||
|
import androidx.compose.material.icons.filled.Menu
|
||||||
|
import androidx.compose.material.icons.filled.Person
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.NavigationBar
|
||||||
|
import androidx.compose.material3.NavigationBarItem
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||||
|
import androidx.navigation.NavHostController
|
||||||
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AppBottomNavigation(
|
||||||
|
navController: NavHostController
|
||||||
|
) {
|
||||||
|
// val currentRoute = navController.currentBackStackEntry?.destination?.route
|
||||||
|
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||||
|
val currentRoute by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
navBackStackEntry?.destination?.route
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NavigationBar {
|
||||||
|
bottomNavItems.forEach { screen ->
|
||||||
|
NavigationBarItem(
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = when (screen) {
|
||||||
|
Screen.MainScreen -> Icons.Default.Home
|
||||||
|
Screen.DeviceScreen -> Icons.Default.Menu
|
||||||
|
Screen.AppAuthScreen -> Icons.Default.Person
|
||||||
|
else -> Icons.Default.Home
|
||||||
|
},
|
||||||
|
contentDescription = screen.route
|
||||||
|
)
|
||||||
|
},
|
||||||
|
label = {
|
||||||
|
val labelText = when (screen) {
|
||||||
|
Screen.MainScreen -> "首页"
|
||||||
|
Screen.DeviceScreen -> "设备"
|
||||||
|
Screen.AppAuthScreen -> "应用授权"
|
||||||
|
else -> screen.route
|
||||||
|
}
|
||||||
|
Text(text = labelText)
|
||||||
|
},
|
||||||
|
selected = currentRoute == screen.route,
|
||||||
|
onClick = {
|
||||||
|
navController.navigate(screen.route) {
|
||||||
|
launchSingleTop = true
|
||||||
|
restoreState= true
|
||||||
|
popUpTo(navController.graph.findStartDestination().id) {
|
||||||
|
saveState = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println("navigate to ${screen.route}")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
369
app/src/main/java/com/jihe/punchnet/screen/HomeScreen.kt
Normal file
@ -0,0 +1,369 @@
|
|||||||
|
package com.jihe.punchnet.screen
|
||||||
|
|
||||||
|
import android.app.Activity.MODE_PRIVATE
|
||||||
|
import android.app.Activity.RESULT_CANCELED
|
||||||
|
import android.app.Activity.RESULT_OK
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.VpnService
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.annotation.DrawableRes
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.indication
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextField
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.MutableState
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.jihe.punchnet.PunchnetService
|
||||||
|
import com.jihe.punchnet.PunchnetServiceArgument
|
||||||
|
import com.jihe.punchnet.R
|
||||||
|
import com.jihe.punchnet.RouteInfo
|
||||||
|
import com.jihe.punchnet.data.ButtonRepository
|
||||||
|
import com.jihe.punchnet.data.ButtonState
|
||||||
|
import com.jihe.punchnet.data.ButtonViewModel
|
||||||
|
import com.jihe.punchnet.data.RouteViewModel
|
||||||
|
import com.jihe.punchnet.data.ServiceViewModel
|
||||||
|
import com.jihe.punchnet.helper.PreferenceName
|
||||||
|
import com.jihe.punchnet.helper.getPreferenceString
|
||||||
|
import com.jihe.punchnet.helper.setPreferenceString
|
||||||
|
import kotlin.math.exp
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.filled.Phone
|
||||||
|
import androidx.compose.material.icons.filled.Person
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
|
||||||
|
data class HomeDropDownInfo(
|
||||||
|
@DrawableRes val icon: Int,
|
||||||
|
val name: String,
|
||||||
|
val callback: (()->Unit)? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun HomeDropdownMenu(navController: androidx.navigation.NavHostController) {
|
||||||
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
val context = LocalContext.current
|
||||||
|
val homeItems = arrayOf(
|
||||||
|
HomeDropDownInfo(
|
||||||
|
R.drawable.tag, // TODO: Use settings icon if available, or just standard icon
|
||||||
|
"设置",
|
||||||
|
{
|
||||||
|
navController.navigate(Screen.SettingsScreen.route)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
HomeDropDownInfo(
|
||||||
|
R.drawable.tag,
|
||||||
|
"退出登录",
|
||||||
|
{
|
||||||
|
// Clear token and go to LoginScreen
|
||||||
|
setPreferenceString(context, PreferenceName.PreferenceToken, "")
|
||||||
|
navController.navigate(Screen.LoginScreen.route) {
|
||||||
|
popUpTo(Screen.MainScreen.route) { inclusive = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Box() {
|
||||||
|
IconButton(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
modifier = Modifier.indication(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null,
|
||||||
|
),
|
||||||
|
onClick = {
|
||||||
|
expanded = true
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Settings,
|
||||||
|
contentDescription = "settings",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = expanded,
|
||||||
|
onDismissRequest = {expanded = !expanded}
|
||||||
|
) {
|
||||||
|
homeItems.forEach { item ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
onClick = {
|
||||||
|
expanded = false
|
||||||
|
item.callback?.invoke()
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Row (
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
){
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(item.icon),
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onBackground
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(
|
||||||
|
modifier = Modifier.padding(start = 8.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(text=item.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun HomeScreen(
|
||||||
|
buttonViewModel: ButtonViewModel,
|
||||||
|
serviceViewModel: ServiceViewModel,
|
||||||
|
routeViewModel: RouteViewModel,
|
||||||
|
navController: androidx.navigation.NavHostController,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
|
||||||
|
val context = LocalContext.current
|
||||||
|
val tkPref = getPreferenceString(context, PreferenceName.PreferenceToken)
|
||||||
|
|
||||||
|
val allroutes = routeViewModel.allRoutes.collectAsState(emptyList())
|
||||||
|
val vpnLauncher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.StartActivityForResult()
|
||||||
|
) {result ->
|
||||||
|
when(result.resultCode) {
|
||||||
|
RESULT_OK -> {
|
||||||
|
// serviceViewModel.startService(context)
|
||||||
|
// serviceViewModel.setVPNPermission(true)
|
||||||
|
val tk = getPreferenceString(context, PreferenceName.PreferenceToken)?:""
|
||||||
|
serviceViewModel.startService(context, allroutes.value.toTypedArray(), tk)
|
||||||
|
}
|
||||||
|
RESULT_CANCELED -> {
|
||||||
|
Toast.makeText(context, "vpn permission denied", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val notificationPermissionLauncher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.RequestPermission()
|
||||||
|
) { isGranted ->
|
||||||
|
if (!isGranted) {
|
||||||
|
Toast.makeText(context, "未授予通知权限,连接可能会在后台被杀", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
val intent = VpnService.prepare(context.applicationContext)
|
||||||
|
if (intent != null) {
|
||||||
|
vpnLauncher.launch(intent)
|
||||||
|
} else {
|
||||||
|
val tk = getPreferenceString(context, PreferenceName.PreferenceToken)?:""
|
||||||
|
serviceViewModel.startService(context, allroutes.value.toTypedArray(), tk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val buttonState = buttonViewModel.buttonState.collectAsState()
|
||||||
|
val resourceList by com.jihe.punchnet.data.NodeRepository.resources.collectAsState()
|
||||||
|
|
||||||
|
CustomHeaderScreen(
|
||||||
|
"",
|
||||||
|
onBack = null,
|
||||||
|
onMenu = {
|
||||||
|
HomeDropdownMenu(navController)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxWidth().fillMaxHeight(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
Spacer(modifier = Modifier.height(50.dp))
|
||||||
|
|
||||||
|
Image(
|
||||||
|
painter = painterResource(R.drawable.punchnet_log),
|
||||||
|
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 (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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ResourceItemRow(resource: com.jihe.punchnet.api.ResourceItem, navController: androidx.navigation.NavHostController) {
|
||||||
|
androidx.compose.material3.Card(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp).clickable {
|
||||||
|
navController.navigate(Screen.WebViewScreen.createRoute(resource.url))
|
||||||
|
},
|
||||||
|
colors = androidx.compose.material3.CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(16.dp).fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.tag),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(32.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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
@ -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("没有账号?去注册")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
136
app/src/main/java/com/jihe/punchnet/screen/MainScreen.kt
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
package com.jihe.punchnet.screen
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import androidx.navigation.NavHostController
|
||||||
|
import com.jihe.punchnet.data.ButtonViewModel
|
||||||
|
import com.jihe.punchnet.data.RouteViewModel
|
||||||
|
import com.jihe.punchnet.data.ServiceViewModel
|
||||||
|
|
||||||
|
sealed class Screen(val route: String) {
|
||||||
|
object MainScreen: Screen("home") // 首页
|
||||||
|
object DeviceScreen: Screen("devices") // 设备 (取代原先的 Route)
|
||||||
|
object AppAuthScreen: Screen("appAuth") // 应用授权 (取代原先的 Profile)
|
||||||
|
object SettingsScreen: Screen("settings") // 设置
|
||||||
|
object WebViewScreen: Screen("webview/{url}") {
|
||||||
|
fun createRoute(url: String) = "webview/${android.net.Uri.encode(url)}"
|
||||||
|
}
|
||||||
|
object LoginScreen: Screen("login")
|
||||||
|
}
|
||||||
|
|
||||||
|
val bottomNavItems = listOf(
|
||||||
|
Screen.MainScreen,
|
||||||
|
Screen.DeviceScreen,
|
||||||
|
Screen.AppAuthScreen,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MainApp(
|
||||||
|
buttonViewModel: ButtonViewModel = viewModel(),
|
||||||
|
serviceViewModel: ServiceViewModel = viewModel(),
|
||||||
|
routeModel: RouteViewModel = viewModel(),
|
||||||
|
navController: NavHostController,
|
||||||
|
) {
|
||||||
|
|
||||||
|
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||||
|
val currentRoute by remember { derivedStateOf { navBackStackEntry?.destination?.route } }
|
||||||
|
val showBottomBar = currentRoute != Screen.LoginScreen.route
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
bottomBar = {
|
||||||
|
if (showBottomBar) {
|
||||||
|
AppBottomNavigation(navController = navController)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
){ paddingValues ->
|
||||||
|
/*
|
||||||
|
Image(
|
||||||
|
painter = painterResource(R.drawable.punchnet_log),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.padding(paddingValues).size(200.dp)
|
||||||
|
)
|
||||||
|
*/
|
||||||
|
AppNavHost2(buttonViewModel, serviceViewModel, routeModel, navController, paddingValues)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CustomHeaderScreen(
|
||||||
|
text: String,
|
||||||
|
onBack: @Composable (() -> Unit)? = null,
|
||||||
|
onMenu: @Composable (() -> Unit)? = null,
|
||||||
|
body: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Box (
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(56.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.background),
|
||||||
|
//.padding(horizontal = 16.dp),
|
||||||
|
contentAlignment = Alignment.CenterStart
|
||||||
|
) {
|
||||||
|
Row (
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxHeight()
|
||||||
|
.padding(horizontal = 8.dp)
|
||||||
|
.width(35.dp)
|
||||||
|
) {
|
||||||
|
if (onBack != null) {
|
||||||
|
onBack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
// color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxHeight()
|
||||||
|
.padding(horizontal = 8.dp)
|
||||||
|
.width(35.dp)
|
||||||
|
) {
|
||||||
|
if (onMenu != null) {
|
||||||
|
onMenu()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
body()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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
@ -0,0 +1,94 @@
|
|||||||
|
package com.jihe.punchnet.screen
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.webkit.WebChromeClient
|
||||||
|
import android.webkit.WebResourceRequest
|
||||||
|
import android.webkit.WebView
|
||||||
|
import android.webkit.WebViewClient
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
|
@Composable
|
||||||
|
fun WebViewScreen(url: String, onBack: () -> Unit) {
|
||||||
|
var webViewRef by remember { mutableStateOf<WebView?>(null) }
|
||||||
|
var loadingProgress by remember { mutableStateOf(0f) }
|
||||||
|
var isLoading by remember { mutableStateOf(true) }
|
||||||
|
var currentTitle by remember { mutableStateOf("资源查看") }
|
||||||
|
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(currentTitle) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.Default.Close, contentDescription = "关闭")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = { webViewRef?.reload() }) {
|
||||||
|
Icon(Icons.Default.Refresh, contentDescription = "刷新")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = { loadingProgress },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
AndroidView(
|
||||||
|
factory = { context ->
|
||||||
|
WebView(context).apply {
|
||||||
|
settings.javaScriptEnabled = true
|
||||||
|
settings.domStorageEnabled = true
|
||||||
|
|
||||||
|
webViewClient = object : WebViewClient() {
|
||||||
|
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
|
||||||
|
return false // Let WebView load the URL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
webChromeClient = object : WebChromeClient() {
|
||||||
|
override fun onProgressChanged(view: WebView?, newProgress: Int) {
|
||||||
|
loadingProgress = newProgress / 100f
|
||||||
|
isLoading = newProgress < 100
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReceivedTitle(view: WebView?, title: String?) {
|
||||||
|
if (!title.isNullOrEmpty()) {
|
||||||
|
currentTitle = title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
webViewRef = this
|
||||||
|
loadUrl(url)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
update = { webView ->
|
||||||
|
// Don't loadUrl here again, it causes infinite reloading on recomposition
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
package com.jihe.punchnet.sdlan.config
|
||||||
|
|
||||||
|
data class Arguments(
|
||||||
|
val baseDir: String,
|
||||||
|
// udp info of the super node
|
||||||
|
val sn: String,
|
||||||
|
// tcp info of the super node
|
||||||
|
|
||||||
|
// udp info of the STUN server
|
||||||
|
val stun: String,
|
||||||
|
|
||||||
|
val nat_server1: String,
|
||||||
|
val nat_server2: String,
|
||||||
|
|
||||||
|
val mtu: Int = 1400,
|
||||||
|
val token: String,
|
||||||
|
|
||||||
|
val allowRouting: Boolean = true,
|
||||||
|
val dropMulticast: Boolean = true,
|
||||||
|
|
||||||
|
val registerTTL: Int = 1,
|
||||||
|
|
||||||
|
val name: String,
|
||||||
|
// type of service
|
||||||
|
val tos: Int = 0,
|
||||||
|
// local udp port, default use 0, for random
|
||||||
|
val localPort: Int = 0,
|
||||||
|
|
||||||
|
val allowP2P: Boolean = true,
|
||||||
|
)
|
||||||
44
app/src/main/java/com/jihe/punchnet/sdlan/config/config.kt
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
package com.jihe.punchnet.sdlan.config
|
||||||
|
|
||||||
|
import com.google.protobuf.ByteString
|
||||||
|
|
||||||
|
object SDLanConfig {
|
||||||
|
const val RegisterSuperInterval: Byte = 20
|
||||||
|
const val RegisterInterval: Int = 5
|
||||||
|
|
||||||
|
const val ID_FILENAME: String = ".id"
|
||||||
|
|
||||||
|
const val TCP_PING_TIME: Long = 7
|
||||||
|
|
||||||
|
val BROADCASTMAC: ByteString = ByteString.copyFrom(byteArrayOf(0xff.toByte(), 0xff.toByte(), 0xff.toByte(), 0xff.toByte(), 0xff.toByte(), 0xff.toByte()))
|
||||||
|
var BROADCASTMAC_BYTEARRAY = BROADCASTMAC.toByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
object SDLanMulticastConfig {
|
||||||
|
const val MULTICAST_PORT: Int = 1070
|
||||||
|
val MULTICAST_V4: ByteArray = byteArrayOf(224.toByte(),0u.toByte(),0.toByte(),70.toByte())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ByteArray.toIPV4String(): String {
|
||||||
|
if (this.size != 4) {
|
||||||
|
return "0.0.0.0"
|
||||||
|
}
|
||||||
|
return "${this[0].toUByte()}.${this[1].toUByte()}.${this[2].toUByte()}.${this[3].toUByte()}"
|
||||||
|
}
|
||||||
|
|
||||||
|
object RSAConfig {
|
||||||
|
const val BASE_DIR: String = ".keys"
|
||||||
|
|
||||||
|
const val RSAMODE: String = "RSA/ECB/PKCS1Padding"
|
||||||
|
|
||||||
|
const val PUBLIC_FILE_NAME: String = "id_rsa.pub"
|
||||||
|
const val PRIVATE_FILE_NAME: String = "id_rsa"
|
||||||
|
|
||||||
|
const val ENCRYPT_MAX_SIZE: Int = 245
|
||||||
|
const val DECRYPT_MAX_SIZE: Int = 256
|
||||||
|
}
|
||||||
|
|
||||||
|
object AESConfig {
|
||||||
|
const val CIPHER_ALGORITHM: String = "AES/CBC/PKCS7Padding"
|
||||||
|
const val KEY_SPEC: String = "AES"
|
||||||
|
}
|
||||||
17
app/src/main/java/com/jihe/punchnet/sdlan/config/ethernet.kt
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
package com.jihe.punchnet.sdlan.config
|
||||||
|
|
||||||
|
object EtherType {
|
||||||
|
const val IPV4: Short = 0x0800.toShort()
|
||||||
|
const val IPV6: Short = 0x86dd.toShort()
|
||||||
|
|
||||||
|
const val ARP: Short = 0x0806.toShort()
|
||||||
|
|
||||||
|
const val HWTYPE_ETH: Short = 1
|
||||||
|
|
||||||
|
const val ARP_REQUEST: Short = 1
|
||||||
|
const val ARP_REPLY: Short = 2
|
||||||
|
|
||||||
|
const val ARP_TABLE_SIZE: Int = 100
|
||||||
|
const val ARP_EXPIRE_TIME: Int = 60
|
||||||
|
const val ARP_DETECT_TIMES: Int = 3
|
||||||
|
}
|
||||||
39
app/src/main/java/com/jihe/punchnet/sdlan/logs/logs.kt
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
package com.jihe.punchnet.sdlan.logs
|
||||||
|
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
const val ColorPrefix = "\u001b"
|
||||||
|
const val GreenColor = "${ColorPrefix}[0;32m"
|
||||||
|
const val BlueColor = "${ColorPrefix}[0;34m"
|
||||||
|
const val YellowColor = "${ColorPrefix}[0;33m"
|
||||||
|
const val RedColor = "${ColorPrefix}[0;31m"
|
||||||
|
const val ColorReset = "${ColorPrefix}[0;0m"
|
||||||
|
|
||||||
|
interface Logger {
|
||||||
|
val format: DateTimeFormatter
|
||||||
|
fun debugf(formatter: ()->String) {
|
||||||
|
val now = LocalDateTime.now().format(format)
|
||||||
|
println("${GreenColor}DEBU:${ColorReset}[$now] ${formatter()}")
|
||||||
|
}
|
||||||
|
fun infof(formatter: ()->String) {
|
||||||
|
val now = LocalDateTime.now().format(format)
|
||||||
|
println("${BlueColor}INFO:${ColorReset}[$now] ${formatter()}")
|
||||||
|
}
|
||||||
|
fun warning(formatter: ()->String) {
|
||||||
|
val now = LocalDateTime.now().format(format)
|
||||||
|
println("${YellowColor}WARN:${ColorReset}[$now] ${formatter()}")
|
||||||
|
}
|
||||||
|
fun errorf(formatter: ()->String) {
|
||||||
|
val now = LocalDateTime.now().format(format)
|
||||||
|
println("${RedColor}ERRO:${ColorReset}[$now] ${formatter()}")
|
||||||
|
}
|
||||||
|
fun criticalf(formatter: ()->String) {
|
||||||
|
val now = LocalDateTime.now().format(format)
|
||||||
|
println("${RedColor}CRIT:${ColorReset}[$now] ${formatter()}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object TerminalLogger: Logger {
|
||||||
|
override val format: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm::s")
|
||||||
|
}
|
||||||
146
app/src/main/java/com/jihe/punchnet/sdlan/network/arp.kt
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
package com.jihe.punchnet.sdlan.network
|
||||||
|
|
||||||
|
import com.google.protobuf.kotlin.toByteString
|
||||||
|
import com.jihe.punchnet.protobuf.PunchProto.SDLData
|
||||||
|
import com.jihe.punchnet.sdlan.config.EtherType
|
||||||
|
import com.jihe.punchnet.sdlan.logs.TerminalLogger
|
||||||
|
import com.jihe.punchnet.sdlan.utils.ipToString
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
const val BroadcastIP = 0xFFFFFFFF
|
||||||
|
@OptIn(ExperimentalUnsignedTypes::class)
|
||||||
|
val BroadcastMac = ubyteArrayOf(0xffu, 0xffu, 0xffu, 0xffu, 0xffu, 0xffu).toByteArray()
|
||||||
|
|
||||||
|
class ARPTable(
|
||||||
|
val scope: CoroutineScope,
|
||||||
|
) {
|
||||||
|
val routeTable: RouteTable = RouteTable()
|
||||||
|
|
||||||
|
val content = ConcurrentHashMap<Int, ARPInfo>()
|
||||||
|
|
||||||
|
suspend fun agingARP() {
|
||||||
|
// CoroutineScope(Dispatchers.Default).async {
|
||||||
|
scope.async {
|
||||||
|
while(true) {
|
||||||
|
delay(20_000)
|
||||||
|
val now = System.currentTimeMillis()/1000
|
||||||
|
val toRemove = mutableListOf<Int>()
|
||||||
|
for ((k, v) in content) {
|
||||||
|
if (now-v.timestamp > EtherType.ARP_EXPIRE_TIME) {
|
||||||
|
toRemove.add(k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (item in toRemove) {
|
||||||
|
content.remove(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getMacFromIP(ip: Int): Pair<ByteArray?, Int> {
|
||||||
|
val gw = routeTable.getGateway(ip)
|
||||||
|
if (gw == null || gw == 0) {
|
||||||
|
// not found in route table, just use the ip
|
||||||
|
TerminalLogger.debugf { "ARP: IP=${ipToString(ip)} has no gateway (gw=$gw), querying IP directly." }
|
||||||
|
val value = content.get(ip)
|
||||||
|
return Pair(value?.mac, ip)
|
||||||
|
} else {
|
||||||
|
// gw not null, try find the gw's mac
|
||||||
|
TerminalLogger.debugf { "ARP: IP=${ipToString(ip)} uses gateway=${ipToString(gw)}." }
|
||||||
|
val value = content.get(gw)
|
||||||
|
return Pair(value?.mac, gw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addToARPTable(ip: Int, mac: ByteArray) {
|
||||||
|
val origin = content.get(ip)
|
||||||
|
val now = System.currentTimeMillis()/1000
|
||||||
|
if (origin != null) {
|
||||||
|
TerminalLogger.debugf { "adding ${ipToString(ip)} to arptable" }
|
||||||
|
origin.timestamp = now
|
||||||
|
origin.mac = mac
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (content.size >= EtherType.ARP_TABLE_SIZE) {
|
||||||
|
// loop and clear the old aged item, or if no one expires,
|
||||||
|
// remove the oldest one
|
||||||
|
var maxage = -1L
|
||||||
|
|
||||||
|
val toRemove = mutableListOf<Int>()
|
||||||
|
var maxindex = -1
|
||||||
|
val expireage = EtherType.ARP_EXPIRE_TIME + EtherType.ARP_DETECT_TIMES*5
|
||||||
|
for ((k, v) in content) {
|
||||||
|
if (now-v.timestamp > expireage) {
|
||||||
|
toRemove.add(k)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (now-v.timestamp > maxage) {
|
||||||
|
maxage = now-v.timestamp
|
||||||
|
maxindex = k
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (toRemove.size == 0 && maxindex != -1) {
|
||||||
|
toRemove.add(maxindex)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (item in toRemove) {
|
||||||
|
content.remove(item)
|
||||||
|
}
|
||||||
|
println("adding ${ipToString(ip)}")
|
||||||
|
}
|
||||||
|
content[ip] = ARPInfo(now, mac)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ARPInfo(var timestamp: Long, var mac: ByteArray)
|
||||||
|
|
||||||
|
|
||||||
|
class ARPWaitList {
|
||||||
|
val content = ConcurrentHashMap<Int, MutableList<ARPWaitInfo>>()
|
||||||
|
|
||||||
|
fun addToWaitList(ip: Int, originData: ByteArray) {
|
||||||
|
val origin = content.getOrPut(ip, {mutableListOf<ARPWaitInfo>()})
|
||||||
|
|
||||||
|
val now = System.currentTimeMillis()/1000
|
||||||
|
if (origin.size > 5) {
|
||||||
|
origin.removeAt(0)
|
||||||
|
// origin.removeFirst()
|
||||||
|
}
|
||||||
|
origin.add(ARPWaitInfo(now, originData))
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun arpArrived(node: Node, ip: Int, mac: Mac) {
|
||||||
|
val waitlist = content.remove(ip)
|
||||||
|
if (waitlist == null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!node.aes.isAuthorized()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val now = System.currentTimeMillis()/1000
|
||||||
|
val networkid = node.networkID.get()
|
||||||
|
for (item in waitlist) {
|
||||||
|
if (now - item.timestamp > 5) {
|
||||||
|
// just skip the packet
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
TerminalLogger.debugf { "ARP resumed flow: ${describeIpv4Packet(item.originData)}" }
|
||||||
|
val packet = formEthernetPacket(node.mac.toByteArray(), mac.toByteArray(), item.originData)
|
||||||
|
val size = packet.remaining()
|
||||||
|
val packetBytes = ByteArray(size)
|
||||||
|
packet.get(packetBytes)
|
||||||
|
val encrypted = node.aes.encrypt(packetBytes)
|
||||||
|
if (encrypted != null) {
|
||||||
|
sendPacketToNet(node, mac, encrypted, size.toLong())
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class ARPWaitInfo(val timestamp: Long, val originData: ByteArray)
|
||||||
358
app/src/main/java/com/jihe/punchnet/sdlan/network/edge.kt
Normal file
@ -0,0 +1,358 @@
|
|||||||
|
package com.jihe.punchnet.sdlan.network
|
||||||
|
|
||||||
|
// import sdlanproto.Message.*
|
||||||
|
import android.util.Log
|
||||||
|
import com.google.protobuf.kotlin.toByteString
|
||||||
|
import com.jihe.punchnet.protobuf.PunchProto
|
||||||
|
import com.jihe.punchnet.protobuf.PunchProto.SDLStunProbe
|
||||||
|
import com.jihe.punchnet.protobuf.PunchProto.SDLStunProbeReply
|
||||||
|
import com.jihe.punchnet.protobuf.PunchProto.SDLStunRequest
|
||||||
|
import com.jihe.punchnet.sdlan.config.SDLanMulticastConfig
|
||||||
|
import com.jihe.punchnet.sdlan.logs.TerminalLogger
|
||||||
|
import com.jihe.punchnet.sdlan.utils.AES
|
||||||
|
import com.jihe.punchnet.sdlan.utils.RSA
|
||||||
|
import com.jihe.punchnet.sdlan.utils.UniqueNodeID
|
||||||
|
import com.jihe.punchnet.sdlan.utils.generateRandomMAC
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.channels.SendChannel
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
import java.net.InetSocketAddress
|
||||||
|
import java.net.SocketAddress
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
|
|
||||||
|
class DeviceConfig (
|
||||||
|
var mtu: Int,
|
||||||
|
var mac: Mac,
|
||||||
|
var ip: IPSubnet,
|
||||||
|
) {
|
||||||
|
constructor(mtu: Int): this(
|
||||||
|
mtu,
|
||||||
|
ByteArray(6).toByteString(),
|
||||||
|
IPSubnet(0, 0),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class NodeConfig (
|
||||||
|
val baseDir: String,
|
||||||
|
val name: String,
|
||||||
|
val nodeUUID: String,
|
||||||
|
|
||||||
|
val allowRouting: Boolean,
|
||||||
|
val dropMulticast: Boolean,
|
||||||
|
|
||||||
|
val allowP2P: Boolean,
|
||||||
|
val mtu: Int,
|
||||||
|
val tos: Int,
|
||||||
|
val registerSuperInterval: Byte,
|
||||||
|
|
||||||
|
val registerTTL: Int,
|
||||||
|
val localPort: Int,
|
||||||
|
|
||||||
|
val supernode: SDLanSock,
|
||||||
|
val stunServer: SDLanSock,
|
||||||
|
val natServer1: java.net.InetSocketAddress,
|
||||||
|
val natServer2: java.net.InetSocketAddress
|
||||||
|
)
|
||||||
|
|
||||||
|
class NodeStats private constructor(
|
||||||
|
// communicate with p2p
|
||||||
|
val txP2P: AtomicLong,
|
||||||
|
val rxP2P: AtomicLong,
|
||||||
|
// communicate with server
|
||||||
|
val txSup: AtomicLong,
|
||||||
|
val rxSup: AtomicLong,
|
||||||
|
// communicate through broadcast
|
||||||
|
val txBroadcast: AtomicLong,
|
||||||
|
val rxBroadcast: AtomicLong,
|
||||||
|
|
||||||
|
// last time communicate with server
|
||||||
|
val lastSup: AtomicLong,
|
||||||
|
// last time communicate with the edge
|
||||||
|
val lastP2P: AtomicLong,
|
||||||
|
) {
|
||||||
|
constructor(): this(
|
||||||
|
AtomicLong(0),
|
||||||
|
AtomicLong(0),
|
||||||
|
AtomicLong(0),
|
||||||
|
AtomicLong(0),
|
||||||
|
AtomicLong(0),
|
||||||
|
AtomicLong(0),
|
||||||
|
AtomicLong(0),
|
||||||
|
AtomicLong(0),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
class NatProbeCookie {
|
||||||
|
val cookieid: AtomicInteger = AtomicInteger(1)
|
||||||
|
val cookiemap = ConcurrentHashMap<Int, SendChannel<SDLStunProbeReply>>()
|
||||||
|
|
||||||
|
fun getChannelWithID(id: Int): SendChannel<SDLStunProbeReply>? {
|
||||||
|
return this.cookiemap.get(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun sendMessage(id: Int, value: SDLStunProbeReply) {
|
||||||
|
cookiemap.remove(id)?.send(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addChannel(channel: SendChannel<SDLStunProbeReply>): Int {
|
||||||
|
val ck = cookieid.getAndAdd(1)
|
||||||
|
cookiemap.set(ck, channel)
|
||||||
|
return ck
|
||||||
|
}
|
||||||
|
|
||||||
|
fun remove(id: Int) {
|
||||||
|
cookiemap.remove(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Node private constructor (
|
||||||
|
// 当前的packet id
|
||||||
|
val packetID: AtomicInteger,
|
||||||
|
// 自身所处的network的id
|
||||||
|
val networkID: AtomicInteger,
|
||||||
|
// 所处网络的token
|
||||||
|
var token: String,
|
||||||
|
|
||||||
|
// tun设备(vpnservice)的配置
|
||||||
|
val deviceConfig: DeviceConfig,
|
||||||
|
// interface的抽象
|
||||||
|
var iface: Iface?,
|
||||||
|
|
||||||
|
// val authorized: AtomicBoolean,
|
||||||
|
|
||||||
|
// rsa info
|
||||||
|
val rsa: RSA,
|
||||||
|
// aes info
|
||||||
|
val aes: AES,
|
||||||
|
// var encryptKey: ByteArray,
|
||||||
|
|
||||||
|
var config: NodeConfig,
|
||||||
|
|
||||||
|
val pendingPeers: PeerMap,
|
||||||
|
val knownPeers: PeerMap,
|
||||||
|
|
||||||
|
// 自身的公网ip
|
||||||
|
val outerIPV4: AtomicInteger,
|
||||||
|
var udpSockV4: SDLanSocket,
|
||||||
|
|
||||||
|
var udpSockMulticast: SDLanSocket?,
|
||||||
|
var multicastSock: SDLanSock,
|
||||||
|
|
||||||
|
val stats: NodeStats,
|
||||||
|
|
||||||
|
// send message to socket, if connected, and authorized
|
||||||
|
var toSocket: SendChannel<ByteArray>,
|
||||||
|
|
||||||
|
var startStopChannel: SendChannel<StartStopChanInfo>,
|
||||||
|
|
||||||
|
val mac: Mac = generateRandomMAC(),
|
||||||
|
var nat_type: NatType = NatType.PortRestricted,
|
||||||
|
val natProbeCookie: NatProbeCookie = NatProbeCookie(),
|
||||||
|
|
||||||
|
var authData: com.jihe.punchnet.api.AuthResponseData? = null,
|
||||||
|
var connectData: com.jihe.punchnet.api.ConnectResponseData? = null,
|
||||||
|
var clientId: String = "",
|
||||||
|
var sessionToken: com.google.protobuf.ByteString = com.google.protobuf.ByteString.EMPTY,
|
||||||
|
val queriedPolicies: ConcurrentHashMap<Int, Int> = ConcurrentHashMap(),
|
||||||
|
val queriedPolicyTargets: ConcurrentHashMap<Int, Int> = ConcurrentHashMap(),
|
||||||
|
val policyRules: ConcurrentHashMap<Int, ByteArray> = ConcurrentHashMap(),
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
private var instance: Node? = null
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun initialize(
|
||||||
|
config: NodeConfig,
|
||||||
|
v4Sock: SDLanSocket,
|
||||||
|
multicastSock: SDLanSocket?,
|
||||||
|
token: String,
|
||||||
|
rsa: RSA,
|
||||||
|
iface: Iface,
|
||||||
|
toSocket: SendChannel<ByteArray>,
|
||||||
|
startStopChannel: SendChannel<StartStopChanInfo>,
|
||||||
|
) : Node{
|
||||||
|
if (instance == null) {
|
||||||
|
instance = Node(
|
||||||
|
config,
|
||||||
|
v4Sock,
|
||||||
|
multicastSock,
|
||||||
|
token,
|
||||||
|
rsa,
|
||||||
|
iface,
|
||||||
|
toSocket,
|
||||||
|
startStopChannel,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return instance!!
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getInstance(): Node {
|
||||||
|
return instance ?: throw IllegalStateException("Node is not initialized")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private constructor(
|
||||||
|
// pubkey: String,
|
||||||
|
config: NodeConfig,
|
||||||
|
v4Sock: SDLanSocket,
|
||||||
|
multicastSock: SDLanSocket?,
|
||||||
|
token: String,
|
||||||
|
rsa: RSA,
|
||||||
|
iface: Iface,
|
||||||
|
toSocket: SendChannel<ByteArray>,
|
||||||
|
startStopChannel: SendChannel<StartStopChanInfo>,
|
||||||
|
// privateKey: ByteArray,
|
||||||
|
// mtu: Int
|
||||||
|
): this (
|
||||||
|
packetID = AtomicInteger(1),
|
||||||
|
networkID = AtomicInteger(0),
|
||||||
|
token = token,
|
||||||
|
deviceConfig = DeviceConfig(config.mtu),
|
||||||
|
iface = iface,
|
||||||
|
rsa = rsa,
|
||||||
|
aes = AES.getAES(),
|
||||||
|
config = config,
|
||||||
|
pendingPeers = PeerMap(),
|
||||||
|
knownPeers = PeerMap(),
|
||||||
|
|
||||||
|
outerIPV4 = AtomicInteger(0),
|
||||||
|
udpSockV4 = v4Sock,
|
||||||
|
udpSockMulticast = multicastSock,
|
||||||
|
|
||||||
|
toSocket = toSocket,
|
||||||
|
startStopChannel = startStopChannel,
|
||||||
|
|
||||||
|
multicastSock = SDLanSock(
|
||||||
|
IPFamily.IPV4,
|
||||||
|
SDLanMulticastConfig.MULTICAST_PORT,
|
||||||
|
SDLanMulticastConfig.MULTICAST_V4,
|
||||||
|
),
|
||||||
|
|
||||||
|
stats = NodeStats(),
|
||||||
|
) {
|
||||||
|
this.deviceConfig.mac = this.mac
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getNextPacketID(): Int {
|
||||||
|
return packetID.getAndAdd(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun sendStunRequest() {
|
||||||
|
if (this.clientId.isEmpty()) {
|
||||||
|
this.clientId = "punchnet_android_client_" + UniqueNodeID.getUUID()
|
||||||
|
}
|
||||||
|
val req = SDLStunRequest.newBuilder()
|
||||||
|
.setClientId(this.clientId)
|
||||||
|
.setNetworkId(this.networkID.get())
|
||||||
|
.setIp(this.deviceConfig.ip.netAddr)
|
||||||
|
.setMac(this.mac)
|
||||||
|
.setNatType(this.nat_type.toByte().toInt())
|
||||||
|
.setV6Info(PunchProto.SDLV6Info.getDefaultInstance())
|
||||||
|
.setSessionToken(this.sessionToken)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val msg = encodeToUDPMessage(req, PacketType.StunRequest)
|
||||||
|
TerminalLogger.debugf {
|
||||||
|
"send STUN_REQUEST to ${config.stunServer}, clientIdTail=${clientId.takeLast(12)}, " +
|
||||||
|
"network=${req.networkId}, ip=${com.jihe.punchnet.sdlan.utils.ipToString(req.ip)}, " +
|
||||||
|
"mac=${com.jihe.punchnet.sdlan.utils.macToString(req.mac)}, natType=${nat_type}, " +
|
||||||
|
"sessionTokenBytes=${sessionToken.size()}"
|
||||||
|
}
|
||||||
|
sendToSock(this, msg, config.stunServer)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
suspend fun ping_to_sn() {
|
||||||
|
val msg = encodeToControlMessage(null, PacketType.Ping).toByteArray()
|
||||||
|
_sendDataToSocket(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun _sendDataToSocket(msg: ByteArray) {
|
||||||
|
|
||||||
|
if (aes.isAuthorized()) {
|
||||||
|
TerminalLogger.debugf{"authorized, ping to sn"}
|
||||||
|
toSocket.send(msg)
|
||||||
|
} else {
|
||||||
|
TerminalLogger.debugf{"unauthorized, not ping to sn"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun probeNatType() {
|
||||||
|
val previousNatType = nat_type
|
||||||
|
val reply1 = this._sendAndWaitForProbeReply(StunProbeAttr.None, config.natServer1)
|
||||||
|
if (reply1 == null) {
|
||||||
|
TerminalLogger.warning { "STUN probe attr=None timed out; keeping natType=${nat_type}" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (reply1.ip == (outerIPV4.get())) {
|
||||||
|
if (this._sendAndWaitForProbeReply(StunProbeAttr.Peer, config.natServer1) == null) {
|
||||||
|
// failed to get with peer, just symmetric
|
||||||
|
nat_type = NatType.Symmetric
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nat_type = NatType.NoNat
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val reply2 = this._sendAndWaitForProbeReply(StunProbeAttr.Peer, config.natServer1)
|
||||||
|
if (reply2 != null) {
|
||||||
|
nat_type = NatType.FullCone
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val reply3 = this._sendAndWaitForProbeReply(StunProbeAttr.None, config.natServer2)
|
||||||
|
if (reply3 == null) {
|
||||||
|
nat_type = if (previousNatType == NatType.Invalid || previousNatType == NatType.Blocked) {
|
||||||
|
NatType.PortRestricted
|
||||||
|
} else {
|
||||||
|
previousNatType
|
||||||
|
}
|
||||||
|
TerminalLogger.warning {
|
||||||
|
"secondary STUN probe timed out after primary reply; keeping natType=${nat_type}"
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((reply1.ip != reply3.ip) || (reply1.port != reply3.port)) {
|
||||||
|
nat_type = NatType.Symmetric
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val reply4 = this._sendAndWaitForProbeReply(StunProbeAttr.Port, config.natServer1)
|
||||||
|
if (reply4 == null) {
|
||||||
|
nat_type = NatType.PortRestricted
|
||||||
|
} else {
|
||||||
|
nat_type = NatType.ConeRestrict
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun _sendAndWaitForProbeReply(attr: StunProbeAttr, toServer: java.net.SocketAddress): SDLStunProbeReply? {
|
||||||
|
val channel = kotlinx.coroutines.channels.Channel<SDLStunProbeReply>(100)
|
||||||
|
val cookie = natProbeCookie.addChannel(channel)
|
||||||
|
val probe = com.jihe.punchnet.protobuf.PunchProto.SDLStunProbe.newBuilder()
|
||||||
|
.setAttr(attr.ordinal.toInt())
|
||||||
|
.setCookie(cookie)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val msg = encodeToUDPMessage(probe, PacketType.StunProbe)
|
||||||
|
TerminalLogger.debugf { "send STUN_PROBE attr=$attr cookie=$cookie to $toServer" }
|
||||||
|
this.udpSockV4.send_to(msg.toByteArray(), toServer)
|
||||||
|
|
||||||
|
val k = kotlinx.coroutines.withTimeoutOrNull(5000) {
|
||||||
|
val response = channel.receive()
|
||||||
|
return@withTimeoutOrNull response
|
||||||
|
}
|
||||||
|
if (k == null) {
|
||||||
|
TerminalLogger.warning { "STUN_PROBE attr=$attr cookie=$cookie to $toServer timed out" }
|
||||||
|
} else {
|
||||||
|
TerminalLogger.debugf { "STUN_PROBE attr=$attr cookie=$cookie reply ip=${k.ip} port=${k.port}" }
|
||||||
|
}
|
||||||
|
natProbeCookie.remove(cookie)
|
||||||
|
return k
|
||||||
|
}
|
||||||
|
}
|
||||||
914
app/src/main/java/com/jihe/punchnet/sdlan/network/helper.kt
Normal file
@ -0,0 +1,914 @@
|
|||||||
|
package com.jihe.punchnet.sdlan.network
|
||||||
|
|
||||||
|
import com.google.protobuf.kotlin.toByteString
|
||||||
|
import com.google.gson.JsonElement
|
||||||
|
import com.jihe.punchnet.protobuf.PunchProto.SDLData
|
||||||
|
import com.jihe.punchnet.protobuf.PunchProto.SDLPeerInfo
|
||||||
|
import com.jihe.punchnet.protobuf.PunchProto.SDLQueryInfo
|
||||||
|
import com.jihe.punchnet.protobuf.PunchProto.SDLRegister
|
||||||
|
import com.jihe.punchnet.protobuf.PunchProto.SDLRegisterAck
|
||||||
|
import com.jihe.punchnet.protobuf.PunchProto.SDLArpRequest
|
||||||
|
import com.jihe.punchnet.protobuf.PunchProto.SDLArpResponse
|
||||||
|
import com.jihe.punchnet.protobuf.PunchProto.SDLExposedServiceRequest
|
||||||
|
import com.jihe.punchnet.protobuf.PunchProto.SDLPolicyRequest
|
||||||
|
|
||||||
|
import com.jihe.punchnet.sdlan.config.EtherType
|
||||||
|
import com.jihe.punchnet.sdlan.config.SDLanConfig
|
||||||
|
import com.jihe.punchnet.sdlan.logs.TerminalLogger
|
||||||
|
import com.jihe.punchnet.sdlan.utils.isMultiBroadcast
|
||||||
|
import com.jihe.punchnet.sdlan.utils.ipStringToInt
|
||||||
|
import com.jihe.punchnet.sdlan.utils.ipToString
|
||||||
|
import com.jihe.punchnet.sdlan.utils.macToString
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.net.URI
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.nio.ByteOrder
|
||||||
|
|
||||||
|
|
||||||
|
suspend fun handlePacketData(
|
||||||
|
scope: CoroutineScope,
|
||||||
|
node: Node,
|
||||||
|
body: ByteBuffer,
|
||||||
|
senderSock: SDLanSock
|
||||||
|
) {
|
||||||
|
val data: SDLData
|
||||||
|
try {
|
||||||
|
data = SDLData.parseFrom(body)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
TerminalLogger.errorf { "failed to decode DATA: $e" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.isP2P) {
|
||||||
|
TerminalLogger.debugf {
|
||||||
|
"[P2P] Rx data from ${senderSock}"
|
||||||
|
}
|
||||||
|
// CoroutineScope(Dispatchers.Default).async {
|
||||||
|
scope.async {
|
||||||
|
checkPeerRegistrationNeeded(
|
||||||
|
node,
|
||||||
|
false,
|
||||||
|
data.srcMac,
|
||||||
|
NatType.NoNat,
|
||||||
|
senderSock,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
TerminalLogger.debugf {
|
||||||
|
"[PsP] Rx data from ${macToString(data.srcMac)} via ${senderSock}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
handleTunPacket(node, senderSock, !data.isP2P, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun handleTunPacket(
|
||||||
|
node: Node,
|
||||||
|
senderSock: SDLanSock,
|
||||||
|
fromSN: Boolean,
|
||||||
|
data: SDLData,
|
||||||
|
) {
|
||||||
|
val payload = data.data
|
||||||
|
val origin = node.aes.decrypt(payload.toByteArray())
|
||||||
|
if (origin == null) {
|
||||||
|
TerminalLogger.errorf { "error to decrypt data" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
TerminalLogger.debugf {"handle data from net"}
|
||||||
|
node.iface?.handleDataFromNet(node, origin)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun handlePacketRegisterACK(
|
||||||
|
node: Node,
|
||||||
|
body: ByteBuffer,
|
||||||
|
senderSock: SDLanSock
|
||||||
|
) {
|
||||||
|
|
||||||
|
val ack: SDLRegisterAck
|
||||||
|
try {
|
||||||
|
ack = SDLRegisterAck.parseFrom(body)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
TerminalLogger.errorf { "failed to decode REGISTERACK: $e" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val viaMulticast = isMultiBroadcast(ack.dstMac)
|
||||||
|
if (viaMulticast && ack.srcMac == node.mac) {
|
||||||
|
TerminalLogger.debugf { "skip register from self" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
TerminalLogger.infof {
|
||||||
|
"Rx REGISTERACK from ${macToString(ack.srcMac)} [${senderSock}] to ${macToString(ack.dstMac)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
peerSetP2PConfirmed(node, ack.srcMac, senderSock)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun peerSetP2PConfirmed(node: Node, mac: Mac, sock: SDLanSock) {
|
||||||
|
val scan = node.pendingPeers.remove(mac)
|
||||||
|
if (scan == null) {
|
||||||
|
TerminalLogger.errorf { "failed to find sender in pending peers: ${sock}" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val now = System.currentTimeMillis()/1000
|
||||||
|
var scan2 = node.knownPeers.get(mac)
|
||||||
|
if (scan2 == null) {
|
||||||
|
node.knownPeers[mac] = scan
|
||||||
|
scan2 = node.knownPeers.get(mac)
|
||||||
|
}
|
||||||
|
scan2?.sock = sock
|
||||||
|
scan2?.lastP2P?.set(now)
|
||||||
|
scan2?.lastSeen?.set(now)
|
||||||
|
|
||||||
|
TerminalLogger.debugf {
|
||||||
|
"P2P connection established: ${macToString(mac)} [${sock}]"
|
||||||
|
}
|
||||||
|
|
||||||
|
TerminalLogger.debugf {
|
||||||
|
"===> new Peer: ${macToString(mac)} -> ${sock}"
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun handlePacketRegister(
|
||||||
|
node: Node,
|
||||||
|
body: ByteBuffer,
|
||||||
|
senderSock: SDLanSock
|
||||||
|
) {
|
||||||
|
if(!node.aes.isAuthorized()) {
|
||||||
|
TerminalLogger.errorf { "drop register due to not authorized"}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val reg: SDLRegister
|
||||||
|
try {
|
||||||
|
reg = SDLRegister.parseFrom(body)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
TerminalLogger.errorf { "failed to parse REGISTER: $e"}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val srcMac = reg.srcMac
|
||||||
|
|
||||||
|
val viaMulticast = isMultiBroadcast(reg.dstMac)
|
||||||
|
if (viaMulticast && reg.srcMac == node.mac) {
|
||||||
|
TerminalLogger.debugf { "skip register from self" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// must be from peer
|
||||||
|
|
||||||
|
TerminalLogger.infof {
|
||||||
|
"[P2P] Rx REGISTER from ${senderSock}, deleting from pending"
|
||||||
|
}
|
||||||
|
val remoteNAT = node.pendingPeers.remove(srcMac)?.natType?:NatType.Invalid
|
||||||
|
|
||||||
|
sendRegisterACK(node,senderSock, reg)
|
||||||
|
|
||||||
|
checkPeerRegistrationNeeded(node, false, reg.srcMac, remoteNAT, senderSock)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun sendRegisterACK(
|
||||||
|
node: Node,
|
||||||
|
sender: SDLanSock,
|
||||||
|
reg: SDLRegister,
|
||||||
|
) {
|
||||||
|
if (!node.config.allowP2P) {
|
||||||
|
TerminalLogger.debugf {
|
||||||
|
"Skipping REGISTER ACK as P2P is not allowed"
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// must be authed
|
||||||
|
val ack = SDLRegisterAck.newBuilder()
|
||||||
|
.setNetworkId(node.networkID.get())
|
||||||
|
.setSrcMac(node.deviceConfig.mac)
|
||||||
|
.setDstMac(reg.srcMac)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val data = encodeToUDPMessage(ack, PacketType.RegisterACK)
|
||||||
|
sendToSock(node, data, sender)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun checkPeerRegistrationNeeded(
|
||||||
|
node: Node,
|
||||||
|
fromSN: Boolean,
|
||||||
|
srcMac: Mac,
|
||||||
|
remoteNat: NatType,
|
||||||
|
peerSock: SDLanSock,
|
||||||
|
) {
|
||||||
|
val peer = node.knownPeers.get(srcMac)
|
||||||
|
if (peer == null) {
|
||||||
|
TerminalLogger.debugf {
|
||||||
|
"not found in known, send REGISTER to peer"
|
||||||
|
}
|
||||||
|
registerWithNewPeer(node,srcMac, remoteNat, peerSock, System.currentTimeMillis()/1000)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val now = System.currentTimeMillis()/1000
|
||||||
|
if (!fromSN) {
|
||||||
|
peer.lastP2P.set(now)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (peerSock.family != peer.sock.family) {
|
||||||
|
TerminalLogger.errorf { "family changed, just ignore it"}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (now - peer.lastSeen.get() > 3) {
|
||||||
|
checkKnownPeerSockChanged(node, fromSN, srcMac, peerSock, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
suspend fun checkKnownPeerSockChanged(
|
||||||
|
node: Node,
|
||||||
|
fromSN: Boolean,
|
||||||
|
srcMac: Mac,
|
||||||
|
peerSock: SDLanSock,
|
||||||
|
now: Long,
|
||||||
|
) {
|
||||||
|
if (isMultiBroadcast(srcMac)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val peer = node.knownPeers.get(srcMac)
|
||||||
|
if (peer == null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!peerSock.is_equal(peer.sock)) {
|
||||||
|
if (!fromSN) {
|
||||||
|
TerminalLogger.infof {
|
||||||
|
"peer changed: ${srcMac}: ${peer.sock} -> $peerSock"
|
||||||
|
}
|
||||||
|
val remoteNAT = peer.natType
|
||||||
|
node.knownPeers.remove(srcMac)
|
||||||
|
|
||||||
|
registerWithNewPeer(node, srcMac, remoteNAT, peerSock, now)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
peer.lastSeen.set(now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun registerWithNewPeer(
|
||||||
|
node: Node,
|
||||||
|
// fromSN: Boolean,
|
||||||
|
mac: Mac,
|
||||||
|
remoteNat: NatType,
|
||||||
|
peerSock: SDLanSock,
|
||||||
|
now: Long,
|
||||||
|
) {
|
||||||
|
var peer = node.pendingPeers.get(mac)
|
||||||
|
if (peer == null) {
|
||||||
|
TerminalLogger.debugf {
|
||||||
|
"===> new pending: ${macToString(mac)} => ${peerSock}"
|
||||||
|
}
|
||||||
|
node.pendingPeers[mac] = EdgePeer(0, node.deviceConfig.ip.netBitLen, peerSock, null, now)
|
||||||
|
peer = node.pendingPeers.get(mac)
|
||||||
|
peer?.lastSeen?.set(now)
|
||||||
|
|
||||||
|
sendRegister(node, remoteNat, peerSock, mac)
|
||||||
|
registerWithLocalPeers(node)
|
||||||
|
} else {
|
||||||
|
peer.sock = peerSock
|
||||||
|
peer.lastSeen.set(now)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun registerWithLocalPeers(node: Node) {
|
||||||
|
if (!node.config.dropMulticast) {
|
||||||
|
sendRegister(node, NatType.NoNat, node.multicastSock, SDLanConfig.BROADCASTMAC)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun sendRegister(
|
||||||
|
node: Node,
|
||||||
|
natType: NatType,
|
||||||
|
sock: SDLanSock,
|
||||||
|
mac: Mac,
|
||||||
|
) {
|
||||||
|
if (!node.config.allowP2P) {
|
||||||
|
TerminalLogger.debugf { "skipping REGISTER as p2p is disabled" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!node.aes.isAuthorized()) {
|
||||||
|
TerminalLogger.debugf { "skipping REGISTER as not authed" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val register = SDLRegister.newBuilder()
|
||||||
|
.setNetworkId(node.networkID.get())
|
||||||
|
.setSrcMac(node.deviceConfig.mac)
|
||||||
|
.setDstMac(mac)
|
||||||
|
.build()
|
||||||
|
val msg = encodeToUDPMessage(register, PacketType.Register)
|
||||||
|
|
||||||
|
TerminalLogger.debugf { "send REGISTER to ${macToString(mac)} at $sock, natType=$natType" }
|
||||||
|
sendToSock(node, msg, sock)
|
||||||
|
|
||||||
|
if (natType == NatType.Symmetric || natType == NatType.PortRestricted) {
|
||||||
|
TerminalLogger.debugf { "guess ports for symmetric/restricted nat" }
|
||||||
|
val guessOffsets = intArrayOf(-1, 1, -2, 2)
|
||||||
|
for (offset in guessOffsets) {
|
||||||
|
val guessPort = sock.port + offset
|
||||||
|
if (guessPort in 1..65535) {
|
||||||
|
val guessSock = SDLanSock(sock.family, guessPort, sock.ip)
|
||||||
|
sendToSock(node, msg, guessSock)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun sendPacketToNet(node: Node, dstmac: Mac, encrypted: ByteArray, size: Long) {
|
||||||
|
var is_p2p: Boolean = false
|
||||||
|
var is_multicast: Boolean = false
|
||||||
|
var destination: SDLanSock
|
||||||
|
|
||||||
|
if (isMultiBroadcast(dstmac)) {
|
||||||
|
node.stats.txSup.addAndGet(size)
|
||||||
|
node.stats.txBroadcast.addAndGet(size)
|
||||||
|
destination = node.config.stunServer
|
||||||
|
is_multicast = true
|
||||||
|
} else {
|
||||||
|
val peer = node.knownPeers.get(dstmac)
|
||||||
|
if (peer == null) {
|
||||||
|
node.stats.txSup.addAndGet(size)
|
||||||
|
destination = node.config.stunServer
|
||||||
|
} else {
|
||||||
|
val now = System.currentTimeMillis() / 1000
|
||||||
|
if (now - peer.lastP2P.get() >= peer.timeout / 2) {
|
||||||
|
TerminalLogger.warning { "last p2p is too old, deleting from known hosts" }
|
||||||
|
node.knownPeers.remove(dstmac)
|
||||||
|
node.stats.txSup.addAndGet(size)
|
||||||
|
destination = node.config.stunServer
|
||||||
|
} else {
|
||||||
|
is_p2p = true
|
||||||
|
node.stats.txP2P.addAndGet(size)
|
||||||
|
destination = peer.sock
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_p2p && !is_multicast) {
|
||||||
|
TerminalLogger.debugf { "check_query_peer_info" }
|
||||||
|
checkQueryPeerInfo(node, dstmac)
|
||||||
|
}
|
||||||
|
|
||||||
|
val transportIdentityId = node.connectData?.identityId ?: 0
|
||||||
|
val data = com.jihe.punchnet.protobuf.PunchProto.SDLData.newBuilder()
|
||||||
|
.setIsP2P(is_p2p)
|
||||||
|
.setNetworkId(node.networkID.get())
|
||||||
|
.setTtl(2)
|
||||||
|
.setSrcMac(node.mac)
|
||||||
|
.setDstMac(dstmac)
|
||||||
|
.setData(com.google.protobuf.ByteString.copyFrom(encrypted))
|
||||||
|
.setSessionToken(node.sessionToken)
|
||||||
|
.setIdentityId(transportIdentityId)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val msg = encodeToUDPMessage(data, PacketType.Data)
|
||||||
|
TerminalLogger.debugf {
|
||||||
|
"send PACKET to ${destination} with isP2P=$is_p2p, ttl=${data.ttl}, " +
|
||||||
|
"src=${macToString(data.srcMac)}, dst=${macToString(data.dstMac)}, " +
|
||||||
|
"network=${data.networkId}, identityId=${data.identityId}, apiIdentity=${node.connectData?.identityId ?: 0}, " +
|
||||||
|
"sessionTokenBytes=${data.sessionToken.size()}, encryptedBytes=${encrypted.size}"
|
||||||
|
}
|
||||||
|
sendToSock(node, msg, destination)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Removed dummy findPeerDestination
|
||||||
|
|
||||||
|
fun describeIpv4Packet(data: ByteArray): String {
|
||||||
|
if (data.size < 20) {
|
||||||
|
return "short-ipv4 bytes=${data.size}"
|
||||||
|
}
|
||||||
|
val version = (data[0].toInt() ushr 4) and 0x0f
|
||||||
|
if (version != 4) {
|
||||||
|
return "non-ipv4 version=$version bytes=${data.size}"
|
||||||
|
}
|
||||||
|
val ihl = (data[0].toInt() and 0x0f) * 4
|
||||||
|
if (ihl < 20 || data.size < ihl) {
|
||||||
|
return "bad-ipv4-header ihl=$ihl bytes=${data.size}"
|
||||||
|
}
|
||||||
|
val proto = data[9].toInt() and 0xff
|
||||||
|
val srcIp = ByteBuffer.wrap(data, 12, 4).order(ByteOrder.BIG_ENDIAN).int
|
||||||
|
val dstIp = ByteBuffer.wrap(data, 16, 4).order(ByteOrder.BIG_ENDIAN).int
|
||||||
|
val protoName = when (proto) {
|
||||||
|
1 -> "ICMP"
|
||||||
|
6 -> "TCP"
|
||||||
|
17 -> "UDP"
|
||||||
|
else -> "proto-$proto"
|
||||||
|
}
|
||||||
|
val portInfo = if ((proto == 6 || proto == 17) && data.size >= ihl + 4) {
|
||||||
|
val srcPort = ByteBuffer.wrap(data, ihl, 2).order(ByteOrder.BIG_ENDIAN).short.toInt() and 0xffff
|
||||||
|
val dstPort = ByteBuffer.wrap(data, ihl + 2, 2).order(ByteOrder.BIG_ENDIAN).short.toInt() and 0xffff
|
||||||
|
" srcPort=$srcPort dstPort=$dstPort"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
return "$protoName ${ipToString(srcIp)} -> ${ipToString(dstIp)}$portInfo bytes=${data.size}"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun describePolicyRules(rules: ByteArray): String {
|
||||||
|
if (rules.isEmpty()) {
|
||||||
|
return "empty"
|
||||||
|
}
|
||||||
|
val result = mutableListOf<String>()
|
||||||
|
var offset = 0
|
||||||
|
while (offset + 3 <= rules.size) {
|
||||||
|
val proto = rules[offset].toInt() and 0xff
|
||||||
|
val port = ByteBuffer.wrap(rules, offset + 1, 2).order(ByteOrder.BIG_ENDIAN).short.toInt() and 0xffff
|
||||||
|
val protoName = when (proto) {
|
||||||
|
1 -> "ICMP"
|
||||||
|
6 -> "TCP"
|
||||||
|
17 -> "UDP"
|
||||||
|
else -> "proto-$proto"
|
||||||
|
}
|
||||||
|
result.add("$protoName/$port")
|
||||||
|
offset += 3
|
||||||
|
}
|
||||||
|
if (offset != rules.size) {
|
||||||
|
result.add("trailingBytes=${rules.size - offset}")
|
||||||
|
}
|
||||||
|
return result.joinToString(",")
|
||||||
|
}
|
||||||
|
|
||||||
|
data class PolicyDecision(
|
||||||
|
val state: PolicyDecisionState,
|
||||||
|
val reason: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class PolicyDecisionState {
|
||||||
|
Allowed,
|
||||||
|
Pending,
|
||||||
|
Denied,
|
||||||
|
}
|
||||||
|
|
||||||
|
fun policyDecisionForIpv4Packet(node: Node, data: ByteArray): PolicyDecision {
|
||||||
|
if (data.size < 20) {
|
||||||
|
return PolicyDecision(PolicyDecisionState.Denied, "too-short")
|
||||||
|
}
|
||||||
|
val version = (data[0].toInt() ushr 4) and 0x0f
|
||||||
|
if (version != 4) {
|
||||||
|
return PolicyDecision(PolicyDecisionState.Denied, "non-ipv4")
|
||||||
|
}
|
||||||
|
val ihl = (data[0].toInt() and 0x0f) * 4
|
||||||
|
if (ihl < 20 || data.size < ihl) {
|
||||||
|
return PolicyDecision(PolicyDecisionState.Denied, "bad-ipv4-header")
|
||||||
|
}
|
||||||
|
val proto = data[9].toInt() and 0xff
|
||||||
|
val targetIp = ByteBuffer.wrap(data, 16, 4).order(ByteOrder.BIG_ENDIAN).int
|
||||||
|
val port = if ((proto == 6 || proto == 17) && data.size >= ihl + 4) {
|
||||||
|
ByteBuffer.wrap(data, ihl + 2, 2).order(ByteOrder.BIG_ENDIAN).short.toInt() and 0xffff
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
val candidates = policyCandidateIdsForTarget(node, targetIp)
|
||||||
|
if (candidates.isEmpty()) {
|
||||||
|
return PolicyDecision(PolicyDecisionState.Denied, "no policy identity for ${ipToString(targetIp)}")
|
||||||
|
}
|
||||||
|
val missing = candidates.filterNot { node.policyRules.containsKey(it) }
|
||||||
|
if (missing.isNotEmpty()) {
|
||||||
|
return PolicyDecision(PolicyDecisionState.Pending, "waiting policy for ${ipToString(targetIp)}, ids=${missing.joinToString(",")}")
|
||||||
|
}
|
||||||
|
for (candidate in candidates) {
|
||||||
|
val rules = node.policyRules[candidate] ?: continue
|
||||||
|
if (policyRulesAllow(rules, proto, port)) {
|
||||||
|
return PolicyDecision(
|
||||||
|
PolicyDecisionState.Allowed,
|
||||||
|
"allowed by policy id=$candidate ${protocolName(proto)}/$port"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return PolicyDecision(
|
||||||
|
PolicyDecisionState.Denied,
|
||||||
|
"no allow rule for ${ipToString(targetIp)} ${protocolName(proto)}/$port, ids=${candidates.joinToString(",")}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun policyCandidateIdsForTarget(node: Node, targetIp: Int): List<Int> {
|
||||||
|
val result = mutableListOf<Int>()
|
||||||
|
node.connectData?.nodeList.orEmpty().firstOrNull {
|
||||||
|
runCatching { ipStringToInt(it.ip) == targetIp }.getOrDefault(false)
|
||||||
|
}?.let { result.add(it.id) }
|
||||||
|
result.addAll(matchingResourceIdsForTarget(node, targetIp))
|
||||||
|
return result.distinct()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun policyRulesAllow(rules: ByteArray, proto: Int, port: Int): Boolean {
|
||||||
|
if (rules.isEmpty()) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var offset = 0
|
||||||
|
while (offset + 3 <= rules.size) {
|
||||||
|
val ruleProto = rules[offset].toInt() and 0xff
|
||||||
|
val rulePort = ByteBuffer.wrap(rules, offset + 1, 2).order(ByteOrder.BIG_ENDIAN).short.toInt() and 0xffff
|
||||||
|
if (ruleProto == proto && (rulePort == port || rulePort == 0)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
offset += 3
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun protocolName(proto: Int): String {
|
||||||
|
return when (proto) {
|
||||||
|
1 -> "ICMP"
|
||||||
|
6 -> "TCP"
|
||||||
|
17 -> "UDP"
|
||||||
|
else -> "proto-$proto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun describeConnectDataForLog(node: Node): String {
|
||||||
|
val connectData = node.connectData ?: return "connect data is null"
|
||||||
|
val localNodeId = localNodeId(node) ?: 0
|
||||||
|
val nodeSummary = connectData.nodeList.orEmpty().joinToString(";") {
|
||||||
|
"id=${it.id},ip=${it.ip},status=${it.connectionStatus}"
|
||||||
|
}.ifEmpty { "none" }
|
||||||
|
val resourceSummary = connectData.resourceList.orEmpty().map { resource ->
|
||||||
|
val uri = runCatching { URI(resource.url) }.getOrNull()
|
||||||
|
val host = uri?.host ?: "invalid-host"
|
||||||
|
val scheme = uri?.scheme ?: "unknown"
|
||||||
|
val port = when {
|
||||||
|
uri == null -> -1
|
||||||
|
uri.port > 0 -> uri.port
|
||||||
|
scheme.equals("http", ignoreCase = true) -> 80
|
||||||
|
scheme.equals("https", ignoreCase = true) -> 443
|
||||||
|
else -> -1
|
||||||
|
}
|
||||||
|
"id=${resource.id},${scheme}://${host}:${port},status=${resource.connectionStatus}"
|
||||||
|
}.joinToString(";").ifEmpty { "none" }
|
||||||
|
return "connect summary: ip=${connectData.ip}/${connectData.maskLen}, identity=${connectData.identityId}, localNodeId=$localNodeId, " +
|
||||||
|
"nodes=${connectData.nodeList.orEmpty().size}[$nodeSummary], " +
|
||||||
|
"resources=${connectData.resourceList.orEmpty().size}[$resourceSummary], " +
|
||||||
|
"acl=${describeAclForLog(connectData.acl)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun localNodeId(node: Node): Int? {
|
||||||
|
val localIp = node.connectData?.ip ?: return null
|
||||||
|
return node.connectData?.nodeList.orEmpty().firstOrNull { it.ip == localIp }?.id
|
||||||
|
}
|
||||||
|
|
||||||
|
fun describeAclForLog(acl: JsonElement?): String {
|
||||||
|
if (acl == null || acl.isJsonNull) {
|
||||||
|
return "null"
|
||||||
|
}
|
||||||
|
if (acl.isJsonArray) {
|
||||||
|
val array = acl.asJsonArray
|
||||||
|
if (array.size() == 0) {
|
||||||
|
return "array(size=0)"
|
||||||
|
}
|
||||||
|
val samples = array.take(3).mapIndexed { index, item ->
|
||||||
|
"[$index]=${describeAclElementForLog(item)}"
|
||||||
|
}
|
||||||
|
return "array(size=${array.size()}, ${samples.joinToString(",")})"
|
||||||
|
}
|
||||||
|
if (acl.isJsonObject) {
|
||||||
|
return describeAclObjectForLog(acl)
|
||||||
|
}
|
||||||
|
if (acl.isJsonPrimitive) {
|
||||||
|
return "primitive"
|
||||||
|
}
|
||||||
|
return acl.javaClass.simpleName
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun describeAclElementForLog(element: JsonElement): String {
|
||||||
|
return when {
|
||||||
|
element.isJsonObject -> describeAclObjectForLog(element)
|
||||||
|
element.isJsonArray -> "array(size=${element.asJsonArray.size()})"
|
||||||
|
element.isJsonNull -> "null"
|
||||||
|
element.isJsonPrimitive -> "primitive"
|
||||||
|
else -> element.javaClass.simpleName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun describeAclObjectForLog(element: JsonElement): String {
|
||||||
|
val obj = element.asJsonObject
|
||||||
|
val fields = obj.entrySet().take(12).joinToString(",") { (key, value) ->
|
||||||
|
"$key=${describeAclValueForLog(value)}"
|
||||||
|
}
|
||||||
|
val suffix = if (obj.entrySet().size > 12) ",..." else ""
|
||||||
|
return "object($fields$suffix)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun describeAclValueForLog(value: JsonElement): String {
|
||||||
|
return when {
|
||||||
|
value.isJsonNull -> "null"
|
||||||
|
value.isJsonArray -> "array(${value.asJsonArray.size()})"
|
||||||
|
value.isJsonObject -> "object(${value.asJsonObject.entrySet().joinToString(",") { it.key }})"
|
||||||
|
value.isJsonPrimitive -> {
|
||||||
|
val primitive = value.asJsonPrimitive
|
||||||
|
when {
|
||||||
|
primitive.isNumber -> primitive.asNumber.toString()
|
||||||
|
primitive.isBoolean -> primitive.asBoolean.toString()
|
||||||
|
primitive.isString -> primitive.asString.take(48)
|
||||||
|
else -> "primitive"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> value.javaClass.simpleName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
suspend fun findPeerDestination(node: Node, dstmac: Mac, size: Long): SDLanSock {
|
||||||
|
var is_p2p: Boolean = false
|
||||||
|
var is_multicast: Boolean = false
|
||||||
|
var result: SDLanSock
|
||||||
|
if (isMultiBroadcast(dstmac)) {
|
||||||
|
node.stats.txSup.addAndGet(size)
|
||||||
|
node.stats.txBroadcast.addAndGet(size)
|
||||||
|
result = node.config.stunServer
|
||||||
|
is_multicast = true
|
||||||
|
} else {
|
||||||
|
val peer = node.knownPeers.get(dstmac)
|
||||||
|
if (peer == null) {
|
||||||
|
node.stats.txSup.addAndGet(size)
|
||||||
|
result = 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)
|
||||||
|
result = node.config.stunServer
|
||||||
|
} else {
|
||||||
|
is_p2p = true
|
||||||
|
node.stats.txP2P.addAndGet(size)
|
||||||
|
result = peer.sock
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!is_p2p && !is_multicast) {
|
||||||
|
TerminalLogger.debugf { "check_query_peer_info" }
|
||||||
|
checkQueryPeerInfo(node, dstmac)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun checkQueryPeerInfo(node: Node, dstmac: Mac) {
|
||||||
|
val now = System.currentTimeMillis()/1000
|
||||||
|
val peer = node.pendingPeers.get(dstmac)
|
||||||
|
|
||||||
|
val needSendQuery: Boolean
|
||||||
|
if (peer != null) {
|
||||||
|
if (now - peer.lastSentQuery.get() > SDLanConfig.RegisterInterval) {
|
||||||
|
needSendQuery = true
|
||||||
|
peer.lastSentQuery.set(now)
|
||||||
|
} else {
|
||||||
|
needSendQuery = false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val sock = SDLanSock(IPFamily.IPV4, 0, ByteArray(4))
|
||||||
|
val peer = EdgePeer(
|
||||||
|
0,
|
||||||
|
node.deviceConfig.ip.netBitLen,
|
||||||
|
sock,
|
||||||
|
null,
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
node.pendingPeers[dstmac] = peer
|
||||||
|
needSendQuery = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needSendQuery) {
|
||||||
|
TerminalLogger.debugf { "send query for ${macToString(dstmac)}" }
|
||||||
|
registerWithLocalPeers(node)
|
||||||
|
sendQueryPeer(node, dstmac)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun sendQueryPeer(node: Node, dstmac: Mac) {
|
||||||
|
if (!node.aes.isAuthorized()) {
|
||||||
|
TerminalLogger.errorf { "not authed for send query" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val query = SDLQueryInfo.newBuilder()
|
||||||
|
.setDstMac(dstmac)
|
||||||
|
.build()
|
||||||
|
val msg = encodeToControlMessage(query, PacketType.QueryInfo)
|
||||||
|
node.toSocket.send(msg.toByteArray())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun formEthernetPacket(srcmac: ByteArray, dstmac: ByteArray, data: ByteArray): ByteBuffer {
|
||||||
|
val buffer = ByteBuffer.allocate(14 + data.size).order(ByteOrder.BIG_ENDIAN)
|
||||||
|
buffer.put(dstmac)
|
||||||
|
buffer.put(srcmac)
|
||||||
|
buffer.putShort(EtherType.IPV4)
|
||||||
|
buffer.put(data)
|
||||||
|
buffer.flip()
|
||||||
|
return buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
// frommac is self's mac
|
||||||
|
// fromip is self ip
|
||||||
|
// queryip is the targetip
|
||||||
|
suspend fun sendArpRequest(node: Node, queryip: Int) {
|
||||||
|
if (!node.aes.isAuthorized()) {
|
||||||
|
TerminalLogger.errorf { "not authed for send arp request" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val arpReq = SDLArpRequest.newBuilder()
|
||||||
|
.setTargetIp(queryip)
|
||||||
|
.setOriginIp(node.deviceConfig.ip.netAddr)
|
||||||
|
.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 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) {
|
||||||
|
val pinfo: SDLPeerInfo
|
||||||
|
try {
|
||||||
|
pinfo = SDLPeerInfo.parseFrom(content)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
TerminalLogger.errorf { "failed to decode peer info: $e"}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pinfo.dstMac == SDLanConfig.BROADCASTMAC) {
|
||||||
|
// pong from sn
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pinfo.hasV4Info() || pinfo.v4Info.v4.size() != 4 || pinfo.v4Info.port == 0) {
|
||||||
|
TerminalLogger.warning { "PeerInfo for ${macToString(pinfo.dstMac)} has no usable IPv4 endpoint" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val remoteNat = NatType.fromUByte(pinfo.v4Info.natType.toUByte())
|
||||||
|
val pending = node.pendingPeers.get(pinfo.dstMac)
|
||||||
|
if (pending == null) {
|
||||||
|
TerminalLogger.debugf { "Rx PEERINFO unknown peer: ${macToString(pinfo.dstMac)}" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pending.sock = SDLanSock(IPFamily.IPV4, pinfo.v4Info.port, pinfo.v4Info.v4.toByteArray())
|
||||||
|
pending.natType = remoteNat
|
||||||
|
TerminalLogger.debugf { "Rx PEERINFO for ${macToString(pinfo.dstMac)} is at ${pending.sock}" }
|
||||||
|
sendRegister(node, remoteNat, pending.sock, pinfo.dstMac)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun handleEvent(node: Node, cmdprotobuf: ByteArray) {
|
||||||
|
val evt: com.jihe.punchnet.protobuf.PunchProto.SDLEvent
|
||||||
|
try {
|
||||||
|
evt = com.jihe.punchnet.protobuf.PunchProto.SDLEvent.parseFrom(cmdprotobuf)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
TerminalLogger.errorf {"failed to decode SDLEvent: $e"}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
when(evt.eventCase) {
|
||||||
|
com.jihe.punchnet.protobuf.PunchProto.SDLEvent.EventCase.SEND_REGISTER -> {
|
||||||
|
val reg = evt.sendRegister
|
||||||
|
val remoteNat = NatType.fromUByte(reg.natType.toUByte())
|
||||||
|
val ip = byteArrayOf(
|
||||||
|
(reg.natIp ushr 24).and(0xff).toByte(),
|
||||||
|
(reg.natIp ushr 16).and(0xff).toByte(),
|
||||||
|
(reg.natIp ushr 8).and(0xff).toByte(),
|
||||||
|
(reg.natIp).and(0xff).toByte(),
|
||||||
|
)
|
||||||
|
checkPeerRegistrationNeeded(node,false, reg.dstMac, remoteNat, SDLanSock(IPFamily.IPV4, reg.natPort, ip))
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
TerminalLogger.warning { "unhandled event: ${evt.eventCase}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ipInt2ByteArray(ip: Int): ByteArray {
|
||||||
|
return byteArrayOf(
|
||||||
|
(ip ushr 24).and(0xff).toByte(),
|
||||||
|
(ip ushr 16).and(0xff).toByte(),
|
||||||
|
(ip ushr 8).and(0xff).toByte(),
|
||||||
|
ip.and(0xff).toByte(),
|
||||||
|
)
|
||||||
|
}
|
||||||
296
app/src/main/java/com/jihe/punchnet/sdlan/network/iface.kt
Normal file
@ -0,0 +1,296 @@
|
|||||||
|
package com.jihe.punchnet.sdlan.network
|
||||||
|
|
||||||
|
import com.google.protobuf.kotlin.toByteString
|
||||||
|
import com.jihe.punchnet.protobuf.PunchProto.SDLData
|
||||||
|
import com.jihe.punchnet.sdlan.config.EtherType
|
||||||
|
import com.jihe.punchnet.sdlan.config.SDLanConfig
|
||||||
|
import com.jihe.punchnet.sdlan.config.toIPV4String
|
||||||
|
import com.jihe.punchnet.sdlan.logs.TerminalLogger
|
||||||
|
import com.jihe.punchnet.sdlan.utils.ArpHdr
|
||||||
|
import com.jihe.punchnet.sdlan.utils.EthHdr
|
||||||
|
import com.jihe.punchnet.sdlan.utils.ipToString
|
||||||
|
import com.jihe.punchnet.sdlan.utils.isMultiBroadcast
|
||||||
|
import com.jihe.punchnet.sdlan.utils.macToString
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.BufferedReader
|
||||||
|
import java.io.DataInputStream
|
||||||
|
import java.io.DataOutputStream
|
||||||
|
import java.io.InputStreamReader
|
||||||
|
import java.net.Inet4Address
|
||||||
|
import java.net.Socket
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.nio.ByteOrder
|
||||||
|
|
||||||
|
interface Iface {
|
||||||
|
suspend fun doInit()
|
||||||
|
suspend fun recv(): ByteArray
|
||||||
|
suspend fun send(content: ByteArray)
|
||||||
|
suspend fun reload_config(config: DeviceConfig)
|
||||||
|
|
||||||
|
fun close() {}
|
||||||
|
suspend fun handleDataFromNet(node: Node, data: ByteArray)
|
||||||
|
suspend fun handleDataFromDevice(node: Node, data: ByteArray)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IfaceTap: Iface {
|
||||||
|
override suspend fun handleDataFromNet(node: Node, data: ByteArray) {
|
||||||
|
TerminalLogger.debugf { "in tap mode" }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun handleDataFromDevice(node: Node, data: ByteArray) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IfaceTun: Iface {
|
||||||
|
/*
|
||||||
|
override suspend fun recv(): ByteArray
|
||||||
|
override suspend fun send(content: ByteArray)
|
||||||
|
override suspend fun reload_config(config: DeviceConfig)
|
||||||
|
*/
|
||||||
|
val arpTable: ARPTable
|
||||||
|
val arpWaitList: ARPWaitList
|
||||||
|
|
||||||
|
override suspend fun doInit() {
|
||||||
|
arpTable.agingARP()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun handleDataFromNet(node: Node, data: ByteArray) {
|
||||||
|
TerminalLogger.debugf { "in tun mode" }
|
||||||
|
var size = data.size
|
||||||
|
if (size < 14) {
|
||||||
|
TerminalLogger.errorf { "packet size error: ${data.size}" }
|
||||||
|
}
|
||||||
|
val buff = ByteBuffer.wrap(data).order(ByteOrder.BIG_ENDIAN)
|
||||||
|
val dstmac = ByteArray(6)
|
||||||
|
val srcmac = ByteArray(6)
|
||||||
|
buff.get(dstmac)
|
||||||
|
buff.get(srcmac)
|
||||||
|
val k = dstmac.toByteString()
|
||||||
|
val etherType = buff.getShort()
|
||||||
|
|
||||||
|
if (k != node.mac && !isMultiBroadcast(k)) {
|
||||||
|
TerminalLogger.errorf { "packet to ${macToString(k)} is not direct to us"}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (etherType == EtherType.ARP) {
|
||||||
|
if (size < 42) {
|
||||||
|
TerminalLogger.errorf { "packet size error: invalid arp length" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val arp = ArpHdr.parseFrom(EthHdr(dstmac, srcmac, etherType), buff)
|
||||||
|
when (arp.opcode) {
|
||||||
|
EtherType.ARP_REQUEST -> {
|
||||||
|
TerminalLogger.debugf { "got arp request: dstmac=${macToString(dstmac.toByteString())}" }
|
||||||
|
|
||||||
|
if (!dstmac.contentEquals(SDLanConfig.BROADCASTMAC_BYTEARRAY)) {
|
||||||
|
TerminalLogger.errorf { "arp request should be broadcast" }
|
||||||
|
}
|
||||||
|
if (arp.dipaddr == node.deviceConfig.ip.netAddr) {
|
||||||
|
val macBytes = node.mac.toByteArray()
|
||||||
|
arpTable.addToARPTable(arp.sipaddr, arp.shwaddr)
|
||||||
|
arp.opcode = EtherType.ARP_REPLY
|
||||||
|
arp.dhwaddr = arp.shwaddr
|
||||||
|
arp.shwaddr = macBytes
|
||||||
|
arp.ethhdr.src = macBytes
|
||||||
|
arp.ethhdr.dest = arp.dhwaddr
|
||||||
|
|
||||||
|
arp.dipaddr = arp.sipaddr
|
||||||
|
arp.sipaddr = node.deviceConfig.ip.netAddr
|
||||||
|
|
||||||
|
val bytesBuf = arp.marshalToBytes()
|
||||||
|
val bytesArr = ByteArray(bytesBuf.remaining())
|
||||||
|
bytesBuf.get(bytesArr)
|
||||||
|
val encrypted = node.aes.encrypt(bytesArr)
|
||||||
|
if (encrypted != null) {
|
||||||
|
val dstmac = arp.dhwaddr.toByteString()
|
||||||
|
TerminalLogger.debugf {
|
||||||
|
"send arp reply to ${macToString(dstmac)}"
|
||||||
|
}
|
||||||
|
sendPacketToNet(node, dstmac, encrypted, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EtherType.ARP_REPLY -> {
|
||||||
|
TerminalLogger.debugf { "got arp shwaddr: ${macToString(arp.shwaddr.toByteString())}" }
|
||||||
|
TerminalLogger.debugf { "got arp dhwaddr: ${macToString(arp.dhwaddr.toByteString())}" }
|
||||||
|
|
||||||
|
TerminalLogger.debugf { "got arp sipaddr: ${ipToString(arp.sipaddr)}" }
|
||||||
|
TerminalLogger.debugf { "got arp dipaddr: ${ipToString(arp.dipaddr)}" }
|
||||||
|
|
||||||
|
println("self_ip = ${ipToString(node.deviceConfig.ip.netAddr)}")
|
||||||
|
if (arp.dipaddr == node.deviceConfig.ip.netAddr) {
|
||||||
|
TerminalLogger.debugf {"arp reply"}
|
||||||
|
arpTable.addToARPTable(arp.sipaddr, arp.shwaddr)
|
||||||
|
arpWaitList.arpArrived(node, arp.sipaddr, arp.shwaddr.toByteString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
TerminalLogger.errorf { "unknown ARP type" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (data.size < 34) {
|
||||||
|
TerminalLogger.errorf { "packet size error: ${data.size}" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val rest = data.sliceArray(14..data.size-1)
|
||||||
|
val buffer = ByteBuffer.wrap(rest, 12, 8)
|
||||||
|
val srcip = buffer.getInt()
|
||||||
|
if (!isMultiBroadcast(srcmac.toByteString())) {
|
||||||
|
arpTable.addToARPTable(srcip, srcmac)
|
||||||
|
}
|
||||||
|
|
||||||
|
send(rest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun handleDataFromDevice(node: Node, data: ByteArray) {
|
||||||
|
if (data.size < 20) {
|
||||||
|
TerminalLogger.errorf { "too short ip packet" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!node.aes.isAuthorized()) {
|
||||||
|
TerminalLogger.infof { "dropping tun packet due to not authed" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val version = (data[0].toInt() ushr 4) and 0x0f
|
||||||
|
if (version != 4) {
|
||||||
|
TerminalLogger.debugf { "dropping non-IPv4 packet from tun: version=$version, bytes=${data.size}" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val buffer = ByteBuffer.wrap(data, 12, 8).order(ByteOrder.BIG_ENDIAN)
|
||||||
|
val srcip = buffer.getInt()
|
||||||
|
val dstip = buffer.getInt()
|
||||||
|
|
||||||
|
TerminalLogger.debugf { "got ${data.size} bytes from tun" }
|
||||||
|
TerminalLogger.debugf { "IPv4 flow from tun: ${describeIpv4Packet(data)}" }
|
||||||
|
|
||||||
|
if (!node.config.allowRouting && (srcip != node.deviceConfig.ip.netAddr)) {
|
||||||
|
TerminalLogger.infof { "dropping routed packet from tun" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.config.dropMulticast && (dstip.toUInt() shr 28) == 14u) {
|
||||||
|
TerminalLogger.debugf { "dropping multicast packet from tun: ${com.jihe.punchnet.sdlan.utils.ipToString(dstip)}" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val (arpinfo, gwip) = arpTable.getMacFromIP(dstip)
|
||||||
|
if (arpinfo == null) {
|
||||||
|
println("arp info is null")
|
||||||
|
// arp not found
|
||||||
|
arpWaitList.addToWaitList(gwip, data)
|
||||||
|
TerminalLogger.debugf { "added to wait list" }
|
||||||
|
sendArpRequest(node, gwip)
|
||||||
|
TerminalLogger.debugf { "sent arp request" }
|
||||||
|
} else {
|
||||||
|
println("mac is ${macToString(arpinfo.toByteString())}")
|
||||||
|
val buffer = arpinfo + node.mac.toByteArray() +
|
||||||
|
byteArrayOf((EtherType.IPV4.toInt() shr 8).toByte(), EtherType.IPV4.toByte()) +
|
||||||
|
data
|
||||||
|
val size = buffer.size
|
||||||
|
val encrypted = node.aes.encrypt(buffer)
|
||||||
|
if (encrypted != null) {
|
||||||
|
val mac = arpinfo.toByteString()
|
||||||
|
sendPacketToNet(node, mac, encrypted, size.toLong())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class IfaceMock: IfaceTun {
|
||||||
|
val deviceName = "dev0"
|
||||||
|
override val arpTable = ARPTable(CoroutineScope(Dispatchers.IO))
|
||||||
|
override val arpWaitList = ARPWaitList()
|
||||||
|
|
||||||
|
val sock = Socket(Inet4Address.getByName("127.0.0.1"), 1234)
|
||||||
|
|
||||||
|
val input: DataInputStream = DataInputStream(sock.getInputStream())
|
||||||
|
val output: DataOutputStream = DataOutputStream(sock.getOutputStream())
|
||||||
|
|
||||||
|
var config: DeviceConfig = DeviceConfig(0)
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun recv(): ByteArray {
|
||||||
|
val result = withContext(Dispatchers.IO) {
|
||||||
|
val size = input.readInt()
|
||||||
|
var result = ByteArray(size)
|
||||||
|
input.read(result)
|
||||||
|
result
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun send(content: ByteArray) {
|
||||||
|
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val size = content.size
|
||||||
|
TerminalLogger.debugf {"sending ${content.size} bytes to tun"}
|
||||||
|
output.writeInt(size)
|
||||||
|
output.write(content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun reload_config(config: DeviceConfig) {
|
||||||
|
this.config = config
|
||||||
|
val ip = ipInt2ByteArray(config.ip.netAddr).toIPV4String()
|
||||||
|
var command = "ifconfig ${deviceName} $ip"
|
||||||
|
command += " netmask ${ipInt2ByteArray(netmaskBit2Int(config.ip.netBitLen)).toIPV4String()}"
|
||||||
|
command += " mtu 1400"
|
||||||
|
command += " up"
|
||||||
|
|
||||||
|
TerminalLogger.debugf { "executing command: ${command}" }
|
||||||
|
runCommand(command)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun String.execute(): Process {
|
||||||
|
val runtime = Runtime.getRuntime()
|
||||||
|
return runtime.exec(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Process.text(): String {
|
||||||
|
val inputStream = this.inputStream
|
||||||
|
val insReader = InputStreamReader(inputStream)
|
||||||
|
val bufReader = BufferedReader(insReader)
|
||||||
|
|
||||||
|
var output = ""
|
||||||
|
var line: String? = ""
|
||||||
|
while(line != null) {
|
||||||
|
line = bufReader.readLine()
|
||||||
|
output += line + "\n"
|
||||||
|
}
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
fun runCommand(command: String): String {
|
||||||
|
val process = command.execute()
|
||||||
|
val exitCode = process.waitFor()
|
||||||
|
val text = process.text()
|
||||||
|
println("exit code: $exitCode")
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
fun netmaskBit2Int(len: Byte): Int {
|
||||||
|
var res = 0
|
||||||
|
for (i in 1..len) {
|
||||||
|
res = res or (1 shl (32 - i))
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
fun macToString(mac: Mac): String {
|
||||||
|
var result = mutableListOf<String>()
|
||||||
|
for (m in mac) {
|
||||||
|
result.add(m.toString(16))
|
||||||
|
}
|
||||||
|
return result.joinToString(":")
|
||||||
|
}
|
||||||
|
*/
|
||||||
502
app/src/main/java/com/jihe/punchnet/sdlan/network/libs.kt
Normal file
@ -0,0 +1,502 @@
|
|||||||
|
package com.jihe.punchnet.sdlan.network
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import com.jihe.punchnet.PunchnetServiceArgument
|
||||||
|
import com.jihe.punchnet.data.ButtonRepository
|
||||||
|
import com.jihe.punchnet.data.ButtonState
|
||||||
|
|
||||||
|
import com.jihe.punchnet.protobuf.PunchProto.SDLRegisterSuper
|
||||||
|
import com.jihe.punchnet.protobuf.PunchProto.SDLRegisterSuperAck
|
||||||
|
import com.jihe.punchnet.protobuf.PunchProto.SDLRegisterSuperNak
|
||||||
|
import com.jihe.punchnet.protobuf.PunchProto.SDLExposedServiceResponse
|
||||||
|
import com.jihe.punchnet.protobuf.PunchProto.SDLPolicyResponse
|
||||||
|
import com.jihe.punchnet.protobuf.PunchProto.SDLStunProbeReply
|
||||||
|
import com.jihe.punchnet.sdlan.config.Arguments
|
||||||
|
import com.jihe.punchnet.sdlan.config.RSAConfig
|
||||||
|
import com.jihe.punchnet.sdlan.config.SDLanConfig
|
||||||
|
import com.jihe.punchnet.sdlan.config.SDLanMulticastConfig
|
||||||
|
import com.jihe.punchnet.sdlan.config.toIPV4String
|
||||||
|
import com.jihe.punchnet.sdlan.logs.TerminalLogger
|
||||||
|
import com.jihe.punchnet.sdlan.utils.RSA
|
||||||
|
import com.jihe.punchnet.sdlan.utils.UniqueNodeID
|
||||||
|
import com.jihe.punchnet.sdlan.utils.ipToString
|
||||||
|
import com.jihe.punchnet.sdlan.utils.macToString
|
||||||
|
import com.jihe.punchnet.sdlan.utils.parseScoketAddressV4FromString
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.NonCancellable
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.cancelAndJoin
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.channels.ReceiveChannel
|
||||||
|
import kotlinx.coroutines.channels.SendChannel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.selects.select
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.DataInputStream
|
||||||
|
import java.io.DataOutputStream
|
||||||
|
import java.net.InetSocketAddress
|
||||||
|
import java.net.Socket
|
||||||
|
import java.net.SocketAddress
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.nio.file.Paths
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
|
import kotlin.io.path.pathString
|
||||||
|
import kotlin.system.exitProcess
|
||||||
|
|
||||||
|
suspend fun onMessage(scope: CoroutineScope, data: SDLanControl) {
|
||||||
|
val node = Node.getInstance()
|
||||||
|
|
||||||
|
TerminalLogger.debugf {"message received"}
|
||||||
|
when(data.packetType) {
|
||||||
|
PacketType.Welcome -> {
|
||||||
|
TerminalLogger.debugf { "got welcome, sending register super" }
|
||||||
|
if (node.clientId.isEmpty()) {
|
||||||
|
node.clientId = "punchnet_android_client_" + UniqueNodeID.getUUID()
|
||||||
|
}
|
||||||
|
val registerIp = com.jihe.punchnet.sdlan.utils.ipStringToInt(node.connectData?.ip ?: "0.0.0.0").toUInt().toInt()
|
||||||
|
|
||||||
|
val registerSuper = SDLRegisterSuper.newBuilder()
|
||||||
|
.setClientId(node.clientId)
|
||||||
|
.setNetworkId(node.authData?.networkId ?: 0)
|
||||||
|
.setMac(com.google.protobuf.ByteString.copyFrom(node.mac.toByteArray()))
|
||||||
|
.setIp(registerIp)
|
||||||
|
.setMaskLen(node.connectData?.maskLen ?: 0)
|
||||||
|
.setHostname(node.connectData?.hostname ?: "")
|
||||||
|
.setPubKey(node.rsa.getPublicKeyString())
|
||||||
|
.setAccessToken(node.authData?.accessToken ?: "")
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val msg = encodeToControlMessage(registerSuper, PacketType.RegisterSuper)
|
||||||
|
node.toSocket.send(msg.toByteArray())
|
||||||
|
TerminalLogger.debugf {
|
||||||
|
"sent RegisterSuper clientIdTail=${node.clientId.takeLast(12)}, network=${registerSuper.networkId}, " +
|
||||||
|
"ip=${ipToString(registerSuper.ip)}, mask=${registerSuper.maskLen}, mac=${macToString(registerSuper.mac)}, " +
|
||||||
|
"identity=${node.connectData?.identityId ?: 0}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PacketType.RegisterSuperACK -> {
|
||||||
|
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) {
|
||||||
|
println("aes key error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
node.aes.setSecret(aeskey, algorithm, ack.regionId.toLong())
|
||||||
|
node.sessionToken = ack.sessionToken
|
||||||
|
|
||||||
|
node.deviceConfig.ip.netAddr = com.jihe.punchnet.sdlan.utils.ipStringToInt(node.connectData?.ip ?: "0.0.0.0").toUInt().toInt()
|
||||||
|
node.deviceConfig.ip.netBitLen = (node.connectData?.maskLen ?: 24).toByte()
|
||||||
|
|
||||||
|
if (node.iface is IfaceTun) {
|
||||||
|
val tun = node.iface as IfaceTun
|
||||||
|
val maskVal = com.jihe.punchnet.sdlan.network.maskDigitToInt(node.deviceConfig.ip.netBitLen.toInt()) ?: 0xffffff00.toInt()
|
||||||
|
tun.arpTable.routeTable.addRoute(
|
||||||
|
com.jihe.punchnet.sdlan.network.RouteDetail(
|
||||||
|
mask = maskVal,
|
||||||
|
gw = 0,
|
||||||
|
maskedAddr = node.deviceConfig.ip.netAddr and maskVal
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
node.iface?.reload_config(node.deviceConfig)
|
||||||
|
node.networkID.set(node.authData?.networkId ?: 0)
|
||||||
|
TerminalLogger.debugf {
|
||||||
|
"control registered: clientIdTail=${node.clientId.takeLast(12)}, network=${node.networkID.get()}, " +
|
||||||
|
"ip=${ipToString(node.deviceConfig.ip.netAddr)}, mask=${node.deviceConfig.ip.netBitLen}, " +
|
||||||
|
"mac=${macToString(node.mac)}, identity=${node.connectData?.identityId ?: 0}"
|
||||||
|
}
|
||||||
|
sendExposedServiceRequest(node)
|
||||||
|
node.sendStunRequest()
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
node.probeNatType()
|
||||||
|
node.sendStunRequest()
|
||||||
|
TerminalLogger.debugf { "updated NAT type after probe: ${node.nat_type}" }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
TerminalLogger.warning { "NAT probe failed: $e" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PacketType.RegisterSuperNAK -> {
|
||||||
|
val nak = SDLRegisterSuperNak.parseFrom(data.currentPacket)
|
||||||
|
TerminalLogger.debugf { "got register super nak: $nak" }
|
||||||
|
val nakcode = NakMsgCode.fromByte(nak.errorCode.toByte())
|
||||||
|
when(nakcode) {
|
||||||
|
NakMsgCode.InvalidToken, NakMsgCode.NodeDisabled-> {
|
||||||
|
node.startStopChannel.send(StartStopChanInfo(StartStopFlag.IsStop, null))
|
||||||
|
ButtonRepository.updateState(ButtonState.ButtonStopped)
|
||||||
|
scope.cancel()
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
ButtonRepository.updateState(ButtonState.ButtonStopped)
|
||||||
|
node.startStopChannel.send(StartStopChanInfo(StartStopFlag.IsStop, null))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
node.aes.setSecret(null)
|
||||||
|
}
|
||||||
|
PacketType.Command -> {
|
||||||
|
TerminalLogger.debugf {"Command received"}
|
||||||
|
if (data.currentPacket.size < 1) {
|
||||||
|
TerminalLogger.errorf { "malformed command received"}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// handleTcpCommand(node, data.currentPacket[0], data.currentPacket.sliceArray(1..data.currentPacket.size-1))
|
||||||
|
}
|
||||||
|
PacketType.PeerInfo -> {
|
||||||
|
TerminalLogger.debugf { "PeerInfo received" }
|
||||||
|
handlePacketPeerInfo(node, data.currentPacket)
|
||||||
|
}
|
||||||
|
PacketType.Event -> {
|
||||||
|
TerminalLogger.debugf { "Event received" }
|
||||||
|
handleEvent(node, data.currentPacket)
|
||||||
|
}
|
||||||
|
PacketType.Pong -> {
|
||||||
|
TerminalLogger.debugf { "Pong Received" }
|
||||||
|
}
|
||||||
|
PacketType.ArpResponse -> {
|
||||||
|
TerminalLogger.debugf { "ArpResponse received" }
|
||||||
|
handlePacketArpResponse(node, data.currentPacket)
|
||||||
|
}
|
||||||
|
PacketType.PolicyResponse -> {
|
||||||
|
val policy = SDLPolicyResponse.parseFrom(data.currentPacket)
|
||||||
|
node.queriedPolicies[policy.dstIdentityId] = policy.version
|
||||||
|
node.policyRules[policy.dstIdentityId] = policy.rules.toByteArray()
|
||||||
|
val ruleSummary = describePolicyRules(policy.rules.toByteArray())
|
||||||
|
val targetIp = node.queriedPolicyTargets[policy.dstIdentityId]
|
||||||
|
val targetText = targetIp?.let { ", dstIp=${ipToString(it)}, ${describeResourcesForTarget(node, it)}" } ?: ""
|
||||||
|
if (policy.rules.isEmpty) {
|
||||||
|
TerminalLogger.warning {
|
||||||
|
"PolicyResponse has no allow rules: srcIdentity=${policy.srcIdentityId}, " +
|
||||||
|
"dstIdentity=${policy.dstIdentityId}, version=${policy.version}$targetText"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
TerminalLogger.debugf {
|
||||||
|
"PolicyResponse received srcIdentity=${policy.srcIdentityId}, dstIdentity=${policy.dstIdentityId}, " +
|
||||||
|
"version=${policy.version}, rules=$ruleSummary$targetText"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PacketType.ExposedServiceResponse -> {
|
||||||
|
val exposed = SDLExposedServiceResponse.parseFrom(data.currentPacket)
|
||||||
|
TerminalLogger.debugf {
|
||||||
|
"ExposedServiceResponse received version=${exposed.version}, " +
|
||||||
|
"tcpPorts=${exposed.tcpPortsList.size}, udpPorts=${exposed.udpPortsList.size}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
println("error packet type: ${data.packetType.toUByte()}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun run_sdlan(scope: CoroutineScope, iface: Iface, argument: Arguments, authData: com.jihe.punchnet.api.AuthResponseData, connectData: com.jihe.punchnet.api.ConnectResponseData) {
|
||||||
|
UniqueNodeID.setBaseDir(argument.baseDir)
|
||||||
|
val edgeUUID = UniqueNodeID.getUUID()
|
||||||
|
val config = parseConfig(edgeUUID, argument)
|
||||||
|
if (config == null) {
|
||||||
|
TerminalLogger.errorf {
|
||||||
|
"parse config failed"
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val toSocket = Channel<ByteArray>(100)
|
||||||
|
val start_stop_channel = Channel<StartStopChanInfo>(100)
|
||||||
|
initEdge(scope, iface, argument.token, config, toSocket, start_stop_channel)
|
||||||
|
|
||||||
|
val snParts = argument.sn.split(":")
|
||||||
|
|
||||||
|
val now = System.currentTimeMillis() / 1000
|
||||||
|
|
||||||
|
val node = Node.getInstance()
|
||||||
|
node.authData = authData
|
||||||
|
node.connectData = connectData
|
||||||
|
TerminalLogger.debugf { "self mac: ${macToString(node.mac)}"}
|
||||||
|
TerminalLogger.debugf { describeConnectDataForLog(node) }
|
||||||
|
|
||||||
|
val onConnected: suspend (tech.kwik.core.QuicStream) -> Unit = { stream ->
|
||||||
|
TerminalLogger.debugf { "QUIC stream connected callback" }
|
||||||
|
// We wait for server's PACKET_WELCOME before sending RegisterSuper
|
||||||
|
}
|
||||||
|
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
while(true) {
|
||||||
|
if (node.iface == null) {
|
||||||
|
delay(1000)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val data = node.iface?.recv()
|
||||||
|
Log.d("SDLAN", "async receive data from iface: ${data?.size} bytes")
|
||||||
|
if (data == null) {
|
||||||
|
delay(1000)
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("iface read is cancelled")
|
||||||
|
node.iface?.close()
|
||||||
|
node.udpSockV4.close()
|
||||||
|
node.udpSockMulticast?.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scope.launch {
|
||||||
|
initQUICConn(
|
||||||
|
scope,
|
||||||
|
snParts[0], snParts[1].toIntOrNull() ?: 1265,
|
||||||
|
start_stop_channel,
|
||||||
|
AtomicLong(now),
|
||||||
|
AtomicBoolean(false),
|
||||||
|
toSocket as ReceiveChannel<ByteArray>,
|
||||||
|
onConnected,
|
||||||
|
::onMessage,
|
||||||
|
suspend {
|
||||||
|
node.aes.setSecret(null)
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
println("sending start")
|
||||||
|
start_stop_channel.send(StartStopChanInfo(StartStopFlag.IsStart, null))
|
||||||
|
println("sent start")
|
||||||
|
|
||||||
|
val cancel = Channel<Boolean>(100)
|
||||||
|
runEdgeLoop(scope, node, cancel)
|
||||||
|
|
||||||
|
while(true) {
|
||||||
|
TerminalLogger.debugf {"ping to sn"}
|
||||||
|
delay(SDLanConfig.TCP_PING_TIME*1000)
|
||||||
|
node.ping_to_sn()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun runEdgeLoop(scope: CoroutineScope, node: Node, cancel: ReceiveChannel<Boolean>) {
|
||||||
|
node.ping_to_sn()
|
||||||
|
scope.launch {
|
||||||
|
loopSocketV4(scope, node, node.udpSockV4, cancel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun loopSocketV4(scope: CoroutineScope, node: Node, sock: SDLanSocket, cancel: ReceiveChannel<Boolean>) {
|
||||||
|
val job_stun_request = scope.async {
|
||||||
|
try {
|
||||||
|
while(true) {
|
||||||
|
delay(10_000)
|
||||||
|
node.probeNatType()
|
||||||
|
node.sendStunRequest()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("Socket", "job stun request exits: $e")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val job_handle_packet = scope.async {
|
||||||
|
try {
|
||||||
|
while(true) {
|
||||||
|
readAndParsePacket(scope, node, sock)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
sock.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
select<Unit> {
|
||||||
|
job_stun_request.onAwait() {
|
||||||
|
|
||||||
|
}
|
||||||
|
cancel.onReceive() {
|
||||||
|
|
||||||
|
}
|
||||||
|
job_handle_packet.onAwait() {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
job_stun_request.cancelAndJoin()
|
||||||
|
job_handle_packet.cancelAndJoin()
|
||||||
|
Log.e("Socket", "loop socket v4 stops")
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun readAndParsePacket(scope: CoroutineScope, node: Node, sock: SDLanSocket) {
|
||||||
|
val packet = sock.receive()
|
||||||
|
val from = packet.socketAddress
|
||||||
|
|
||||||
|
if (packet.length < 1) {
|
||||||
|
TerminalLogger.errorf { "got zero-sized packet"}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val data = ByteBuffer.wrap(packet.data, 0, packet.length)
|
||||||
|
// val data = packet.data.toByteString(0, packet.length)
|
||||||
|
handleAPacket(scope, node, from, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun handleAPacket(scope: CoroutineScope, node: Node, from: SocketAddress, data: ByteBuffer) {
|
||||||
|
val pktType = PacketType.fromValue(data.get().toUByte())
|
||||||
|
if (pktType == null) {
|
||||||
|
TerminalLogger.errorf { "invalid packet type" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// val buffer = ByteBuffer.wrap(data, 1, size-1)
|
||||||
|
|
||||||
|
when (pktType) {
|
||||||
|
PacketType.Data -> {
|
||||||
|
TerminalLogger.debugf { "got DATA" }
|
||||||
|
if (!node.aes.isAuthorized()) {
|
||||||
|
TerminalLogger.errorf { "drop DATA before authed" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (from is InetSocketAddress) {
|
||||||
|
TerminalLogger.debugf {"got data"}
|
||||||
|
val sock = SDLanSock(IPFamily.IPV4, from.port, from.address.address)
|
||||||
|
handlePacketData(scope, node, data, sock)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PacketType.StunProbeReply -> {
|
||||||
|
try {
|
||||||
|
val reply = SDLStunProbeReply.parseFrom(data)
|
||||||
|
node.natProbeCookie.sendMessage(reply.cookie, reply)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
TerminalLogger.errorf { "failed to decode Probe Reply"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PacketType.StunReply -> {
|
||||||
|
TerminalLogger.debugf { "got stun reply" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
PacketType.PolicyResponse -> {
|
||||||
|
TerminalLogger.debugf { "got policy response" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
PacketType.ExposedServiceResponse -> {
|
||||||
|
TerminalLogger.debugf { "got exposed service response" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
PacketType.Register -> {
|
||||||
|
TerminalLogger.debugf { "got REGISTER" }
|
||||||
|
if (from is InetSocketAddress) {
|
||||||
|
val sock = SDLanSock(IPFamily.IPV4, from.port, from.address.address)
|
||||||
|
handlePacketRegister(node, data, sock)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PacketType.RegisterACK -> {
|
||||||
|
TerminalLogger.debugf { "got REGISTERACK" }
|
||||||
|
if (!node.aes.isAuthorized()) {
|
||||||
|
TerminalLogger.errorf { "drop REGISTERACK before authed" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (from is InetSocketAddress) {
|
||||||
|
val sock = SDLanSock(IPFamily.IPV4, from.port, from.address.address)
|
||||||
|
handlePacketRegisterACK(node, data, sock)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
TerminalLogger.debugf {"ignore packet type: $pktType"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fun initEdge(scope: CoroutineScope, iface: Iface, token: String, config: NodeConfig, toSocket: SendChannel<ByteArray>, startStopChannel: SendChannel<StartStopChanInfo>) {
|
||||||
|
val rsa = RSA.getRSA()
|
||||||
|
val pathname = Paths.get(config.baseDir, RSAConfig.BASE_DIR).pathString
|
||||||
|
Log.d("DIR", "pathname = $pathname")
|
||||||
|
rsa.generateKeyPair(pathname)
|
||||||
|
//rsa.generateKeyPair(Path.of(config.baseDir, RSAConfig.BASE_DIR).name)
|
||||||
|
|
||||||
|
val sockV4 = SDLanSocket(scope, "0.0.0.0", config.localPort)
|
||||||
|
|
||||||
|
var sockMulticast: SDLanSocket? = null
|
||||||
|
if (!config.dropMulticast) {
|
||||||
|
sockMulticast = SDLanSocket(scope, SDLanMulticastConfig.MULTICAST_V4.toIPV4String(), SDLanMulticastConfig.MULTICAST_PORT)
|
||||||
|
}
|
||||||
|
|
||||||
|
Node.initialize(
|
||||||
|
config,
|
||||||
|
sockV4,
|
||||||
|
sockMulticast,
|
||||||
|
token,
|
||||||
|
rsa,
|
||||||
|
iface,
|
||||||
|
toSocket,
|
||||||
|
startStopChannel,
|
||||||
|
)
|
||||||
|
val instance = Node.getInstance()
|
||||||
|
instance.token = token
|
||||||
|
instance.udpSockV4 = sockV4
|
||||||
|
instance.udpSockMulticast = sockMulticast
|
||||||
|
instance.config = config
|
||||||
|
instance.toSocket = toSocket
|
||||||
|
instance.startStopChannel = startStopChannel
|
||||||
|
println(instance)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fun parseConfig(nodeuuid: String, argument: Arguments): NodeConfig? {
|
||||||
|
if (argument.sn.length == 0) {
|
||||||
|
println("no sn is specified")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
val sn = parseScoketAddressV4FromString(argument.sn)
|
||||||
|
if (sn == null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val stun = parseScoketAddressV4FromString(argument.stun)
|
||||||
|
if (stun == null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val natServer1 = parseScoketAddressV4FromString(argument.nat_server1)
|
||||||
|
val natServer2 = parseScoketAddressV4FromString(argument.nat_server2)
|
||||||
|
if (natServer1 == null || natServer2 == null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return NodeConfig(
|
||||||
|
baseDir = argument.baseDir,
|
||||||
|
name = argument.name,
|
||||||
|
nodeUUID = nodeuuid,
|
||||||
|
allowRouting = argument.allowRouting,
|
||||||
|
dropMulticast = argument.dropMulticast,
|
||||||
|
allowP2P = argument.allowP2P,
|
||||||
|
mtu = argument.mtu,
|
||||||
|
tos = argument.tos,
|
||||||
|
registerSuperInterval = SDLanConfig.RegisterSuperInterval,
|
||||||
|
registerTTL = argument.registerTTL,
|
||||||
|
localPort = argument.localPort,
|
||||||
|
supernode = SDLanSock(IPFamily.IPV4, sn.port, sn.address.address),
|
||||||
|
stunServer = SDLanSock(IPFamily.IPV4, stun.port, stun.address.address),
|
||||||
|
natServer1 = natServer1,
|
||||||
|
natServer2 = natServer2
|
||||||
|
)
|
||||||
|
}
|
||||||
48
app/src/main/java/com/jihe/punchnet/sdlan/network/message.kt
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
package com.jihe.punchnet.sdlan.network
|
||||||
|
|
||||||
|
enum class NakMsgCode(val code: Byte) {
|
||||||
|
InvalidMsg(0),
|
||||||
|
InvalidToken(1),
|
||||||
|
NodeDisabled(2),
|
||||||
|
NoIPAddress(3),
|
||||||
|
NetworkFault(4),
|
||||||
|
InternalFault(5);
|
||||||
|
|
||||||
|
fun toByte(): Byte {
|
||||||
|
return this.code
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromByte(code: Byte): NakMsgCode {
|
||||||
|
return when(code) {
|
||||||
|
1.toByte() -> InvalidToken
|
||||||
|
2.toByte() -> NodeDisabled
|
||||||
|
3.toByte() -> NoIPAddress
|
||||||
|
4.toByte() -> NetworkFault
|
||||||
|
5.toByte() -> InternalFault
|
||||||
|
else -> InvalidMsg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class EventType(val code: Byte) {
|
||||||
|
KnownIP(1),
|
||||||
|
DropIP(2),
|
||||||
|
NatChanged(3),
|
||||||
|
SendRegister(4),
|
||||||
|
NetworkShutdown(0xff.toByte());
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromByte(code: Byte): EventType {
|
||||||
|
return when(code) {
|
||||||
|
1.toByte() -> KnownIP
|
||||||
|
2.toByte() -> DropIP
|
||||||
|
3.toByte() -> NatChanged
|
||||||
|
4.toByte() -> SendRegister
|
||||||
|
else -> NetworkShutdown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
133
app/src/main/java/com/jihe/punchnet/sdlan/network/packet_info.kt
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
package com.jihe.punchnet.sdlan.network
|
||||||
|
|
||||||
|
import com.google.protobuf.Message
|
||||||
|
import java.net.Inet4Address
|
||||||
|
import java.net.Inet6Address
|
||||||
|
import java.net.InetSocketAddress
|
||||||
|
import java.net.SocketAddress
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.nio.ByteOrder
|
||||||
|
|
||||||
|
enum class StartStopFlag {
|
||||||
|
IsStart,
|
||||||
|
IsStop,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 本地启动或者停止服务的信息
|
||||||
|
class StartStopChanInfo(
|
||||||
|
val flag: StartStopFlag,
|
||||||
|
val packetID: Int?,
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class PacketType(val id: UByte) {
|
||||||
|
Empty(0x00u),
|
||||||
|
RegisterSuper(0x01u),
|
||||||
|
RegisterSuperACK(0x02u),
|
||||||
|
RegisterSuperNAK(0x04u),
|
||||||
|
|
||||||
|
UnRegisterSuper(0x05u),
|
||||||
|
|
||||||
|
QueryInfo(0x06u),
|
||||||
|
PeerInfo(0x07u),
|
||||||
|
|
||||||
|
Ping(0x08u),
|
||||||
|
Pong(0x09u),
|
||||||
|
|
||||||
|
Event(0x10u),
|
||||||
|
|
||||||
|
Command(0x11u),
|
||||||
|
CommandACK(0x12u),
|
||||||
|
|
||||||
|
FlowTracer(0x15u),
|
||||||
|
|
||||||
|
Register(0x20u),
|
||||||
|
RegisterACK(0x21u),
|
||||||
|
|
||||||
|
StunRequest(0x30u),
|
||||||
|
StunReply(0x31u),
|
||||||
|
|
||||||
|
StunProbe(0x32u),
|
||||||
|
StunProbeReply(0x33u),
|
||||||
|
|
||||||
|
Welcome(0x4fu),
|
||||||
|
ArpRequest(0x50u),
|
||||||
|
ArpResponse(0x51u),
|
||||||
|
|
||||||
|
PolicyRequest(0xb0u),
|
||||||
|
PolicyResponse(0xb1u),
|
||||||
|
ExposedServiceRequest(0xb2u),
|
||||||
|
ExposedServiceResponse(0xb3u),
|
||||||
|
|
||||||
|
Data(0xffu);
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val innermap = PacketType.entries.map {it.id to it}.toMap()
|
||||||
|
|
||||||
|
fun fromValue(value: UByte): PacketType? {
|
||||||
|
return innermap[value]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun PacketType.toUByte(): UByte {
|
||||||
|
return this.id
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// tcp/quic发送过来的通道里面的信息
|
||||||
|
class SDLanControl(
|
||||||
|
val packetType: PacketType,
|
||||||
|
val currentPacket: ByteArray,
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class ConnectingState {
|
||||||
|
NotConnected,
|
||||||
|
Connecting,
|
||||||
|
Connected,
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun sendToSock(node: Node, content: ByteArray, sock: SDLanSock) {
|
||||||
|
val target: SocketAddress
|
||||||
|
when (sock.family) {
|
||||||
|
IPFamily.IPV4 -> {
|
||||||
|
target = InetSocketAddress(Inet4Address.getByAddress(sock.ip), sock.port)
|
||||||
|
}
|
||||||
|
IPFamily.IPV6 -> {
|
||||||
|
target = InetSocketAddress(Inet6Address.getByAddress(sock.ip), sock.port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
node.udpSockV4.send_to(content, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun sendToSock(node: Node, content: List<Byte>, sock: SDLanSock) {
|
||||||
|
sendToSock(node, content.toByteArray(), sock)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun encodeToUDPMessage(msg: Message?, packetType: PacketType): List<Byte> {
|
||||||
|
val result: MutableList<Byte> = mutableListOf()
|
||||||
|
val msgByte = msg?.toByteArray()?.toList()?:listOf<Byte>()
|
||||||
|
result.add(packetType.toUByte().toByte())
|
||||||
|
result.addAll(msgByte)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
fun encodeToControlMessage(msg: Message?, packetType: PacketType): List<Byte> {
|
||||||
|
val msgByte = msg?.toByteArray()?.toList()?:listOf<Byte>()
|
||||||
|
val result: MutableList<Byte> = mutableListOf()
|
||||||
|
|
||||||
|
result.addAll(ByteBuffer.allocate(2)
|
||||||
|
.order(ByteOrder.BIG_ENDIAN)
|
||||||
|
.putShort((msgByte.size + 1).toShort())
|
||||||
|
.array().toList())
|
||||||
|
|
||||||
|
result.add(packetType.toUByte().toByte())
|
||||||
|
result.addAll(msgByte)
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class StunProbeAttr {
|
||||||
|
None,
|
||||||
|
Port,
|
||||||
|
Peer,
|
||||||
|
}
|
||||||
107
app/src/main/java/com/jihe/punchnet/sdlan/network/peermap.kt
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
package com.jihe.punchnet.sdlan.network
|
||||||
|
|
||||||
|
import com.google.protobuf.ByteString
|
||||||
|
import com.jihe.punchnet.sdlan.config.SDLanConfig
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.ConcurrentMap
|
||||||
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
|
|
||||||
|
typealias Mac = ByteString
|
||||||
|
// typealias PeerMap = ConcurrentMap<Mac, EdgePeer>
|
||||||
|
|
||||||
|
class PeerMap private constructor (val inner: ConcurrentMap<Mac, EdgePeer>): ConcurrentMap<Mac, EdgePeer> by inner {
|
||||||
|
constructor(): this(ConcurrentHashMap<Mac, EdgePeer>())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class IPSubnet(var netAddr: Int, var netBitLen: Byte) {
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class NatType(val value: UByte) {
|
||||||
|
Blocked(0u),
|
||||||
|
NoNat(1u),
|
||||||
|
FullCone(2u),
|
||||||
|
PortRestricted(3u),
|
||||||
|
ConeRestrict(4u),
|
||||||
|
Symmetric(5u),
|
||||||
|
Invalid(0xffu);
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromUByte(code: UByte): NatType {
|
||||||
|
return when(code) {
|
||||||
|
0.toUByte() -> Blocked
|
||||||
|
1.toUByte() -> NoNat
|
||||||
|
2.toUByte() -> FullCone
|
||||||
|
3.toUByte() -> PortRestricted
|
||||||
|
4.toUByte() -> ConeRestrict
|
||||||
|
5.toUByte() -> Symmetric
|
||||||
|
else -> Invalid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun NatType.toByte(): UByte {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class IPFamily {
|
||||||
|
IPV4, IPV6
|
||||||
|
}
|
||||||
|
|
||||||
|
class SDLanSock constructor(var family: IPFamily, var port: Int, var ip: ByteArray) {
|
||||||
|
override fun toString(): String {
|
||||||
|
when(family) {
|
||||||
|
IPFamily.IPV6 -> {
|
||||||
|
assert(ip.size == 16)
|
||||||
|
val ipstr = ip.map {
|
||||||
|
it.toString(16)
|
||||||
|
}.joinToString(":")
|
||||||
|
return "[$ipstr]:${port}"
|
||||||
|
}
|
||||||
|
IPFamily.IPV4 -> {
|
||||||
|
assert(ip.size == 4)
|
||||||
|
val digit = ip[0]
|
||||||
|
return "${ip[0].toUByte()}.${ip[1].toUByte()}.${ip[2].toUByte()}.${ip[3].toUByte()}:${port}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun is_equal(other: SDLanSock): Boolean {
|
||||||
|
return (family==other.family)
|
||||||
|
&& (port == other.port)
|
||||||
|
&& (ip.contentEquals(other.ip))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class EdgePeer private constructor (
|
||||||
|
var devAddress: IPSubnet,
|
||||||
|
var natType: NatType,
|
||||||
|
var sock: SDLanSock,
|
||||||
|
var ipv6Info: SDLanSock?,
|
||||||
|
// timeout of the family
|
||||||
|
val timeout: Byte,
|
||||||
|
|
||||||
|
// 最近一次与该edge通信
|
||||||
|
val lastSeen: AtomicLong,
|
||||||
|
// 最近一次与该edge有P2P通信
|
||||||
|
val lastP2P: AtomicLong,
|
||||||
|
// 最近一次向服务器查询该edge信息
|
||||||
|
val lastSentQuery: AtomicLong,
|
||||||
|
) {
|
||||||
|
// var dev_addr: IPSubnet = IPSubnet(0, 0)
|
||||||
|
|
||||||
|
constructor(netAddress: Int, netBitLen: Byte, sock: SDLanSock, v6Info: SDLanSock?, now: Long): this(
|
||||||
|
IPSubnet(netAddress, netBitLen),
|
||||||
|
NatType.Blocked,
|
||||||
|
sock,
|
||||||
|
v6Info,
|
||||||
|
SDLanConfig.RegisterSuperInterval,
|
||||||
|
AtomicLong(now),
|
||||||
|
AtomicLong(now),
|
||||||
|
AtomicLong(now),
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
@ -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
|
||||||
|
}
|
||||||
145
app/src/main/java/com/jihe/punchnet/sdlan/network/route.kt
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
package com.jihe.punchnet.sdlan.network
|
||||||
|
|
||||||
|
import com.jihe.punchnet.sdlan.logs.TerminalLogger
|
||||||
|
import org.bouncycastle.util.Strings
|
||||||
|
import java.util.LinkedList
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.locks.ReentrantReadWriteLock
|
||||||
|
|
||||||
|
data class RouteDetail(
|
||||||
|
// Int representation of 255.255.255.0
|
||||||
|
val mask: Int,
|
||||||
|
val gw: Int,
|
||||||
|
val maskedAddr: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun cidrToRouteDetail(cidr: String, gw: String): RouteDetail? {
|
||||||
|
val ipAndMaskDigit = Strings.split(cidr, '/')
|
||||||
|
if (ipAndMaskDigit.size != 2) {
|
||||||
|
TerminalLogger.debugf { "cidr format error: ${cidr}" }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val ip = ipStringToInt(ipAndMaskDigit[0])
|
||||||
|
val mask = maskDigitToInt(ipAndMaskDigit[1].toInt())
|
||||||
|
|
||||||
|
if (ip == null || mask == null) {
|
||||||
|
TerminalLogger.debugf { "cidr format error2: ${cidr}" }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val gateway = ipStringToInt(gw)
|
||||||
|
if (gateway == null) {
|
||||||
|
TerminalLogger.debugf { "gateway format error: ${gateway}" }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((ip and mask) != ip) {
|
||||||
|
TerminalLogger.debugf { "net not masked" }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return RouteDetail(
|
||||||
|
mask = mask,
|
||||||
|
gw = gateway,
|
||||||
|
maskedAddr = ip,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun maskIPToDigit(mask: Int): Int {
|
||||||
|
var count = 0
|
||||||
|
var temp = mask
|
||||||
|
while (temp != 0) {
|
||||||
|
temp = temp shl 1
|
||||||
|
count += 1
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
fun maskDigitToInt(digit: Int): Int? {
|
||||||
|
if (digit <= 0 || digit >= 32) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return ((1 shl (32-digit)) - 1).inv()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ipStringToInt(ip: String): Int? {
|
||||||
|
val ipFiltered = ip.trim { it.isWhitespace() }
|
||||||
|
val ipSeg = Strings.split(ipFiltered, '.')
|
||||||
|
if (ipSeg.size != 4) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return ((ipSeg[0].toInt() and 0xff) shl 24) +
|
||||||
|
((ipSeg[1].toInt() and 0xff) shl 16) +
|
||||||
|
((ipSeg[2].toInt() and 0xff) shl 8) +
|
||||||
|
(ipSeg[3].toInt() and 0xff)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
class RouteTable() {
|
||||||
|
val lock = ReentrantReadWriteLock()
|
||||||
|
|
||||||
|
val routeInfo: MutableList<RouteDetail> = mutableListOf()
|
||||||
|
|
||||||
|
fun addRoute(vararg routes: RouteDetail) {
|
||||||
|
try {
|
||||||
|
lock.writeLock().lock()
|
||||||
|
for (route in routes) {
|
||||||
|
routeInfo.add(route)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
lock.writeLock().unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearRoute() {
|
||||||
|
try {
|
||||||
|
lock.writeLock().lock()
|
||||||
|
routeInfo.clear()
|
||||||
|
} finally {
|
||||||
|
lock.writeLock().unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getGateway(ip: Int): Int? {
|
||||||
|
try {
|
||||||
|
lock.readLock().lock()
|
||||||
|
var bestGw: Int? = null
|
||||||
|
var bestMaskLen = -1
|
||||||
|
for (item in routeInfo) {
|
||||||
|
if ((ip and item.mask) == item.maskedAddr) {
|
||||||
|
val maskLen = Integer.bitCount(item.mask)
|
||||||
|
if (maskLen > bestMaskLen) {
|
||||||
|
bestMaskLen = maskLen
|
||||||
|
bestGw = item.gw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
lock.readLock().unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
class RouteTable2(
|
||||||
|
val initRouteInfo: List<RouteDetail>
|
||||||
|
) {
|
||||||
|
val routeInfo: Array<RouteDetail> = initRouteInfo.toTypedArray()
|
||||||
|
|
||||||
|
fun getGeteway(ip: Int): Int? {
|
||||||
|
var bestGw: Int? = null
|
||||||
|
var bestMaskLen = -1
|
||||||
|
for (item in routeInfo) {
|
||||||
|
if ((ip and item.mask) == item.maskedAddr) {
|
||||||
|
val maskLen = Integer.bitCount(item.mask)
|
||||||
|
if (maskLen > bestMaskLen) {
|
||||||
|
bestMaskLen = maskLen
|
||||||
|
bestGw = item.gw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bestGw
|
||||||
|
}
|
||||||
|
}
|
||||||
65
app/src/main/java/com/jihe/punchnet/sdlan/network/socket.kt
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
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.Job
|
||||||
|
import kotlinx.coroutines.cancelAndJoin
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.channels.ReceiveChannel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.net.DatagramPacket
|
||||||
|
import java.net.DatagramSocket
|
||||||
|
import java.net.Inet4Address
|
||||||
|
import java.net.SocketAddress
|
||||||
|
|
||||||
|
class SDLanSocket(val scope: CoroutineScope, val addr: String, var port: Int, val reuseAddress: Boolean = false) {
|
||||||
|
private val connection: DatagramSocket = run {
|
||||||
|
val sock = DatagramSocket(port, Inet4Address.getByName(addr))
|
||||||
|
if (reuseAddress) {
|
||||||
|
sock.reuseAddress = true
|
||||||
|
}
|
||||||
|
port = sock.localPort
|
||||||
|
Log.d("SDLanSocket", "local port = $port")
|
||||||
|
sock
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loop(): ReceiveChannel<DatagramPacket> {
|
||||||
|
val channel = Channel<DatagramPacket>(100)
|
||||||
|
scope.launch {
|
||||||
|
while (true) {
|
||||||
|
val msg = receive()
|
||||||
|
channel.send(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
return channel
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun send_to(content: ByteArray, target: SocketAddress) {
|
||||||
|
val packet = DatagramPacket(content, content.size, target)
|
||||||
|
try {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
connection.send(packet)
|
||||||
|
}
|
||||||
|
} catch(e: Exception) {
|
||||||
|
TerminalLogger.errorf {"Failed to send to: $e, localport: ${port}"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun receive(): DatagramPacket {
|
||||||
|
val buffer = ByteArray(1500)
|
||||||
|
val packet = DatagramPacket(buffer, buffer.size)
|
||||||
|
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
connection.receive(packet)
|
||||||
|
}
|
||||||
|
return packet
|
||||||
|
}
|
||||||
|
|
||||||
|
fun close() {
|
||||||
|
connection.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
4
app/src/main/java/com/jihe/punchnet/sdlan/sdlan.kt
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
package com.jihe.punchnet.sdlan
|
||||||
|
|
||||||
|
fun test() {
|
||||||
|
}
|
||||||
147
app/src/main/java/com/jihe/punchnet/sdlan/utils/aes.kt
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
package com.jihe.punchnet.sdlan.utils
|
||||||
|
|
||||||
|
import com.jihe.punchnet.sdlan.config.AESConfig
|
||||||
|
import com.jihe.punchnet.sdlan.logs.TerminalLogger
|
||||||
|
import org.bouncycastle.crypto.engines.ChaCha7539Engine
|
||||||
|
import org.bouncycastle.crypto.params.KeyParameter
|
||||||
|
import org.bouncycastle.crypto.params.ParametersWithIV
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.nio.ByteOrder
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
import javax.crypto.spec.IvParameterSpec
|
||||||
|
import javax.crypto.spec.SecretKeySpec
|
||||||
|
|
||||||
|
class AES private constructor () {
|
||||||
|
private var _secret: ByteArray = ByteArray(0)
|
||||||
|
private var _iv: ByteArray = ByteArray(0)
|
||||||
|
private var _algorithm: String = "aes"
|
||||||
|
private var _regionId: Long = 0
|
||||||
|
companion object {
|
||||||
|
private var instance: AES? = null
|
||||||
|
get() {
|
||||||
|
if (field == null) {
|
||||||
|
field = AES()
|
||||||
|
}
|
||||||
|
return field
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun getAES(): AES {
|
||||||
|
return instance!!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isAuthorized(): Boolean {
|
||||||
|
return _secret.size != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSecret(secret: ByteArray?, algorithm: String = "aes", regionId: Long = 0) {
|
||||||
|
if (secret == null) {
|
||||||
|
_secret = ByteArray(0)
|
||||||
|
_iv = ByteArray(0)
|
||||||
|
_algorithm = "aes"
|
||||||
|
_regionId = 0
|
||||||
|
} else {
|
||||||
|
_secret = secret
|
||||||
|
_iv = _secret.sliceArray(0..<16)
|
||||||
|
_algorithm = algorithm.lowercase()
|
||||||
|
_regionId = regionId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun encrypt(content: ByteBuffer): ByteBuffer? {
|
||||||
|
if (!isAuthorized()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
val input = ByteArray(content.remaining())
|
||||||
|
content.get(input)
|
||||||
|
val encrypted = encrypt(input) ?: return null
|
||||||
|
return ByteBuffer.wrap(encrypted)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("failed to encrypt: ${e.toString()}")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun encrypt(content: ByteArray): ByteArray? {
|
||||||
|
if (!isAuthorized()) {
|
||||||
|
TerminalLogger.errorf { "not authed, so not encrypting" }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return when (_algorithm) {
|
||||||
|
"aes" -> aesCrypt(Cipher.ENCRYPT_MODE, content)
|
||||||
|
"chacha20" -> chacha20Crypt(content)
|
||||||
|
else -> {
|
||||||
|
TerminalLogger.errorf { "unsupported encryption algorithm: $_algorithm" }
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
TerminalLogger.errorf {"encrypt failed: $e"}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun decrypt(ciphered: ByteBuffer): ByteBuffer? {
|
||||||
|
if (!isAuthorized()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
val input = ByteArray(ciphered.remaining())
|
||||||
|
ciphered.get(input)
|
||||||
|
val decrypted = decrypt(input) ?: return null
|
||||||
|
return ByteBuffer.wrap(decrypted)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun decrypt(ciphered: ByteArray): ByteArray? {
|
||||||
|
if (!isAuthorized()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return when (_algorithm) {
|
||||||
|
"aes" -> aesCrypt(Cipher.DECRYPT_MODE, ciphered)
|
||||||
|
"chacha20" -> chacha20Crypt(ciphered)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun aesCrypt(mode: Int, content: ByteArray): ByteArray {
|
||||||
|
val cipher = Cipher.getInstance(AESConfig.CIPHER_ALGORITHM)
|
||||||
|
val keyspec = SecretKeySpec(_secret, AESConfig.KEY_SPEC)
|
||||||
|
cipher.init(mode, keyspec, IvParameterSpec(_iv))
|
||||||
|
return cipher.doFinal(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun chacha20Crypt(content: ByteArray): ByteArray {
|
||||||
|
val engine = ChaCha7539Engine()
|
||||||
|
val nonce = chacha20Nonce()
|
||||||
|
engine.init(true, ParametersWithIV(KeyParameter(_secret), nonce))
|
||||||
|
val output = ByteArray(content.size)
|
||||||
|
engine.processBytes(content, 0, content.size, output, 0)
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun chacha20Nonce(): ByteArray {
|
||||||
|
return ByteBuffer.allocate(12)
|
||||||
|
.order(ByteOrder.BIG_ENDIAN)
|
||||||
|
.putLong(0L)
|
||||||
|
.putInt(_regionId.toInt())
|
||||||
|
.array()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun byteArray2Hex(array: ByteArray): String {
|
||||||
|
var result = mutableListOf<String>()
|
||||||
|
for (item in array) {
|
||||||
|
result.add(String.format("0x%02x", item.toInt() and 0xff))
|
||||||
|
}
|
||||||
|
return result.joinToString(" ")
|
||||||
|
}
|
||||||
166
app/src/main/java/com/jihe/punchnet/sdlan/utils/rsa.kt
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
package com.jihe.punchnet.sdlan.utils
|
||||||
|
|
||||||
|
import com.jihe.punchnet.sdlan.config.RSAConfig
|
||||||
|
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo
|
||||||
|
import org.bouncycastle.jce.provider.BouncyCastleProvider
|
||||||
|
import org.bouncycastle.openssl.PEMParser
|
||||||
|
import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter
|
||||||
|
import org.bouncycastle.openssl.jcajce.JcaPEMWriter
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileReader
|
||||||
|
import java.io.FileWriter
|
||||||
|
import java.security.KeyPair
|
||||||
|
import java.security.KeyPairGenerator
|
||||||
|
import java.security.PrivateKey
|
||||||
|
import java.security.PublicKey
|
||||||
|
import java.security.Security
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
|
||||||
|
// 添加BouncyCastle提供者
|
||||||
|
fun setupBouncyCastle() {
|
||||||
|
Security.addProvider(BouncyCastleProvider())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun savePublicKeyToPem(publicKey: PublicKey, fileName: String) {
|
||||||
|
FileWriter(fileName).use { fileWriter ->
|
||||||
|
JcaPEMWriter(fileWriter).use { pemWriter ->
|
||||||
|
pemWriter.writeObject(publicKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun savePrivateKeyToPem(privateKey: PrivateKey, fileName: String) {
|
||||||
|
FileWriter(fileName).use { fileWriter ->
|
||||||
|
JcaPEMWriter(fileWriter).use { pemWriter ->
|
||||||
|
pemWriter.writeObject(privateKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadPublicKeyFromPem(fileName: String): PublicKey {
|
||||||
|
FileReader(fileName).use { fileReader ->
|
||||||
|
PEMParser(fileReader).use { pemParser ->
|
||||||
|
val converter = JcaPEMKeyConverter()
|
||||||
|
val keypair = pemParser.readObject()
|
||||||
|
return converter.getPublicKey(keypair as SubjectPublicKeyInfo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadPrivateKeyFromPem(fileName: String): PrivateKey {
|
||||||
|
FileReader(fileName).use { fileReader ->
|
||||||
|
PEMParser(fileReader).use { pemParser ->
|
||||||
|
val converter = JcaPEMKeyConverter()
|
||||||
|
val keypair = pemParser.readObject() as org.bouncycastle.openssl.PEMKeyPair
|
||||||
|
return converter.getPrivateKey(keypair.privateKeyInfo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成RSA密钥对
|
||||||
|
fun generateRSAKeyPair(): KeyPair {
|
||||||
|
val keyPairGenerator = KeyPairGenerator.getInstance("RSA", "BC")
|
||||||
|
keyPairGenerator.initialize(2048)
|
||||||
|
return keyPairGenerator.generateKeyPair()
|
||||||
|
}
|
||||||
|
|
||||||
|
class RSA private constructor () {
|
||||||
|
private var pubkey: PublicKey? = null
|
||||||
|
private var prikey: PrivateKey? = null
|
||||||
|
private var pubkeyStr: String? = null
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private var instance: RSA? = null
|
||||||
|
get() {
|
||||||
|
if (field == null) {
|
||||||
|
field = RSA()
|
||||||
|
}
|
||||||
|
return field
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun getRSA(): RSA {
|
||||||
|
return instance!!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun generateKeyPair(dirname: String) {
|
||||||
|
setupBouncyCastle()
|
||||||
|
val pubFile = File(dirname, RSAConfig.PUBLIC_FILE_NAME)
|
||||||
|
val priFile = File(dirname, RSAConfig.PRIVATE_FILE_NAME)
|
||||||
|
|
||||||
|
val dirpath = File(dirname)
|
||||||
|
dirpath.mkdirs()
|
||||||
|
|
||||||
|
if (pubFile.exists() && priFile.exists()) {
|
||||||
|
pubkey = loadPublicKeyFromPem(pubFile.path)
|
||||||
|
prikey = loadPrivateKeyFromPem(priFile.path)
|
||||||
|
} else {
|
||||||
|
// generate public and private file
|
||||||
|
val keypair = generateRSAKeyPair()
|
||||||
|
|
||||||
|
savePublicKeyToPem(keypair.public, pubFile.path)
|
||||||
|
savePrivateKeyToPem(keypair.private, priFile.path)
|
||||||
|
|
||||||
|
pubkey = keypair.public
|
||||||
|
prikey = keypair.private
|
||||||
|
}
|
||||||
|
|
||||||
|
pubkeyStr = pubFile.readText()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun encrypt(input: ByteArray, use_private_key: Boolean = true): ByteArray {
|
||||||
|
val cipher = Cipher.getInstance(RSAConfig.RSAMODE)
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, if (use_private_key) prikey else pubkey)
|
||||||
|
|
||||||
|
var temp: ByteArray? = null
|
||||||
|
var offset = 0
|
||||||
|
val outputStream = ByteArrayOutputStream()
|
||||||
|
|
||||||
|
while(input.size - offset > 0) {
|
||||||
|
if (input.size - offset >= RSAConfig.ENCRYPT_MAX_SIZE) {
|
||||||
|
temp = cipher.doFinal(input, offset, RSAConfig.ENCRYPT_MAX_SIZE)
|
||||||
|
offset += RSAConfig.ENCRYPT_MAX_SIZE
|
||||||
|
} else {
|
||||||
|
temp = cipher.doFinal(input, offset, input.size - offset)
|
||||||
|
offset = input.size
|
||||||
|
}
|
||||||
|
outputStream.write(temp!!)
|
||||||
|
}
|
||||||
|
outputStream.close()
|
||||||
|
return outputStream.toByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun decrypt(ciphered: ByteArray, use_pub_key: Boolean = false): ByteArray? {
|
||||||
|
val cipher = Cipher.getInstance(RSAConfig.RSAMODE)
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, if (use_pub_key) pubkey else prikey)
|
||||||
|
|
||||||
|
var temp: ByteArray? = null
|
||||||
|
var offset = 0
|
||||||
|
val outputStream = ByteArrayOutputStream()
|
||||||
|
|
||||||
|
try {
|
||||||
|
while(ciphered.size - offset > 0) {
|
||||||
|
if (ciphered.size - offset >= RSAConfig.DECRYPT_MAX_SIZE) {
|
||||||
|
temp = cipher.doFinal(ciphered, offset, RSAConfig.DECRYPT_MAX_SIZE)
|
||||||
|
offset += RSAConfig.DECRYPT_MAX_SIZE
|
||||||
|
} else {
|
||||||
|
temp = cipher.doFinal(ciphered, offset, ciphered.size - offset)
|
||||||
|
offset = ciphered.size
|
||||||
|
}
|
||||||
|
outputStream.write(temp!!)
|
||||||
|
}
|
||||||
|
outputStream.close()
|
||||||
|
return outputStream.toByteArray()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getPublicKeyString(): String {
|
||||||
|
return pubkeyStr!!
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
187
app/src/main/java/com/jihe/punchnet/sdlan/utils/utils.kt
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
package com.jihe.punchnet.sdlan.utils
|
||||||
|
|
||||||
|
import android.os.Environment
|
||||||
|
import com.google.protobuf.ByteString
|
||||||
|
import com.google.protobuf.kotlin.toByteString
|
||||||
|
import com.jihe.punchnet.sdlan.config.SDLanConfig
|
||||||
|
import com.jihe.punchnet.sdlan.logs.TerminalLogger
|
||||||
|
import com.jihe.punchnet.sdlan.network.Mac
|
||||||
|
import java.io.File
|
||||||
|
import java.net.Inet4Address
|
||||||
|
import java.net.InetSocketAddress
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.nio.ByteOrder
|
||||||
|
import java.util.UUID
|
||||||
|
import kotlin.experimental.and
|
||||||
|
import kotlin.experimental.inv
|
||||||
|
import kotlin.experimental.or
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
|
||||||
|
object UniqueNodeID {
|
||||||
|
var id: String = ""
|
||||||
|
private var baseDir: String = ""
|
||||||
|
|
||||||
|
fun setBaseDir(basedir: String) {
|
||||||
|
baseDir = basedir
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getUUID(context: Context? = null): String {
|
||||||
|
if (id.isEmpty() && context != null) {
|
||||||
|
val androidId = android.provider.Settings.Secure.getString(context.contentResolver, android.provider.Settings.Secure.ANDROID_ID)
|
||||||
|
if (!androidId.isNullOrEmpty() && androidId != "9774d56d682e549c") { // 9774d56d682e549c is a known bugged ID on old emulators
|
||||||
|
id = androidId
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (id.isEmpty()) {
|
||||||
|
if (baseDir.isEmpty()) {
|
||||||
|
baseDir = Environment.getExternalStorageDirectory().absolutePath
|
||||||
|
}
|
||||||
|
val dirpath = File(baseDir)
|
||||||
|
dirpath.mkdirs()
|
||||||
|
val f = File(baseDir, SDLanConfig.ID_FILENAME)
|
||||||
|
if (f.exists()) {
|
||||||
|
val value = f.readText()
|
||||||
|
id = value
|
||||||
|
} else {
|
||||||
|
// file not exists
|
||||||
|
val uuid = UUID.randomUUID()
|
||||||
|
id = uuid.toString().replace("-", "")
|
||||||
|
f.writeText(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parseScoketAddressV4FromString(data: String): InetSocketAddress? {
|
||||||
|
val pieces = data.split(":")
|
||||||
|
if (pieces.size != 2) {
|
||||||
|
TerminalLogger.errorf {"socker format error: $data"}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val host = Inet4Address.getByName(pieces[0])
|
||||||
|
val port = pieces[1].toIntOrNull()
|
||||||
|
|
||||||
|
if (port != null) {
|
||||||
|
return InetSocketAddress(host, port)
|
||||||
|
}
|
||||||
|
TerminalLogger.errorf {"invalid port: ${port}"}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ipToString(ip: Int): String {
|
||||||
|
val d1 = ip.ushr(24).and(0x000000ff).toUByte()
|
||||||
|
val d2 = ip.ushr(16).and(0x000000ff).toUByte()
|
||||||
|
val d3 = ip.ushr(8).and(0x000000ff).toUByte()
|
||||||
|
val d4 = ip.and(0x000000ff).toUByte()
|
||||||
|
return "$d1.$d2.$d3.$d4"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ipStringToInt(ipString: String): Int {
|
||||||
|
val parts = ipString.split(".")
|
||||||
|
if (parts.size != 4) return 0
|
||||||
|
return (parts[0].toInt() shl 24) or
|
||||||
|
(parts[1].toInt() shl 16) or
|
||||||
|
(parts[2].toInt() shl 8) or
|
||||||
|
(parts[3].toInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun macToString(mac: Mac): String {
|
||||||
|
return mac.joinToString(separator = ":") { it.toUByte().toString(16) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun generateRandomMAC(): Mac {
|
||||||
|
var result = Random.nextBytes(6)
|
||||||
|
val k: Byte = 0x01
|
||||||
|
|
||||||
|
result[0] = result[0].and(k.inv())
|
||||||
|
result[0] = result[0].or(0x02)
|
||||||
|
return result.toByteString()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isMultiBroadcast(mac: ByteString): Boolean {
|
||||||
|
return isBroadcast(mac) || isMulticast(mac) || isIP6Multicast(mac)
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun isBroadcast(mac: ByteString): Boolean {
|
||||||
|
return mac.all {it == 0xff.toByte()}
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun isMulticast(mac: ByteString): Boolean {
|
||||||
|
return mac.size()==6 && mac.byteAt(0) == 0x01.toByte()
|
||||||
|
&& mac.byteAt(1) == 0x00.toByte()
|
||||||
|
&& mac.byteAt(2) == 0x5e.toByte()
|
||||||
|
&& (mac.byteAt(3).and(0x80.toByte()) == 0.toByte())
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun isIP6Multicast(mac: ByteString): Boolean {
|
||||||
|
return mac.size() == 6 && mac.byteAt(0) == 0x33.toByte()
|
||||||
|
&& mac.byteAt(1) == 0x33.toByte()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class EthHdr(
|
||||||
|
var dest: ByteArray,
|
||||||
|
var src: ByteArray,
|
||||||
|
var etherType: Short,
|
||||||
|
)
|
||||||
|
|
||||||
|
class ArpHdr(
|
||||||
|
val ethhdr: EthHdr,
|
||||||
|
val hwtype: Short,
|
||||||
|
val protocol: Short,
|
||||||
|
val hwlen: Byte,
|
||||||
|
val protolen: Byte,
|
||||||
|
var opcode: Short,
|
||||||
|
|
||||||
|
var shwaddr: ByteArray,
|
||||||
|
var sipaddr: Int,
|
||||||
|
var dhwaddr: ByteArray,
|
||||||
|
var dipaddr: Int,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
// 需要保证,长度够
|
||||||
|
fun parseFrom(ethhdr: EthHdr, data: ByteBuffer): ArpHdr {
|
||||||
|
val hwtype = data.getShort()
|
||||||
|
val protocol = data.getShort()
|
||||||
|
val hwlen = data.get()
|
||||||
|
val protolen = data.get()
|
||||||
|
val opcode = data.getShort()
|
||||||
|
|
||||||
|
val shwaddr = ByteArray(6)
|
||||||
|
data.get(shwaddr)
|
||||||
|
val sipaddr = data.getInt()
|
||||||
|
|
||||||
|
val dhwaddr = ByteArray(6)
|
||||||
|
data.get(dhwaddr)
|
||||||
|
val dipaddr = data.getInt()
|
||||||
|
|
||||||
|
return ArpHdr(ethhdr, hwtype, protocol, hwlen, protolen, opcode, shwaddr, sipaddr, dhwaddr, dipaddr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun marshalToBytes(): ByteBuffer {
|
||||||
|
val buff = ByteBuffer.allocate(64).order(ByteOrder.BIG_ENDIAN)
|
||||||
|
buff.put(ethhdr.dest)
|
||||||
|
buff.put(ethhdr.src)
|
||||||
|
buff.putShort(ethhdr.etherType)
|
||||||
|
|
||||||
|
buff.putShort(hwtype)
|
||||||
|
buff.putShort(protocol)
|
||||||
|
buff.put(hwlen)
|
||||||
|
buff.put(protolen)
|
||||||
|
buff.putShort(opcode)
|
||||||
|
|
||||||
|
buff.put(shwaddr)
|
||||||
|
buff.putInt(sipaddr)
|
||||||
|
|
||||||
|
buff.put(dhwaddr)
|
||||||
|
buff.putInt(dipaddr)
|
||||||
|
|
||||||
|
buff.flip()
|
||||||
|
return buff
|
||||||
|
}
|
||||||
|
}
|
||||||
11
app/src/main/java/com/jihe/punchnet/ui/theme/Color.kt
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
package com.jihe.punchnet.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
val Purple80 = Color(0xFFD0BCFF)
|
||||||
|
val PurpleGrey80 = Color(0xFFCCC2DC)
|
||||||
|
val Pink80 = Color(0xFFEFB8C8)
|
||||||
|
|
||||||
|
val Purple40 = Color(0xFF6650a4)
|
||||||
|
val PurpleGrey40 = Color(0xFF625b71)
|
||||||
|
val Pink40 = Color(0xFF7D5260)
|
||||||
57
app/src/main/java/com/jihe/punchnet/ui/theme/Theme.kt
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
package com.jihe.punchnet.ui.theme
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.material3.dynamicDarkColorScheme
|
||||||
|
import androidx.compose.material3.dynamicLightColorScheme
|
||||||
|
import androidx.compose.material3.lightColorScheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
|
||||||
|
private val DarkColorScheme = darkColorScheme(
|
||||||
|
primary = Purple80,
|
||||||
|
secondary = PurpleGrey80,
|
||||||
|
tertiary = Pink80
|
||||||
|
)
|
||||||
|
|
||||||
|
private val LightColorScheme = lightColorScheme(
|
||||||
|
primary = Purple40,
|
||||||
|
secondary = PurpleGrey40,
|
||||||
|
tertiary = Pink40
|
||||||
|
|
||||||
|
/* Other default colors to override
|
||||||
|
background = Color(0xFFFFFBFE),
|
||||||
|
surface = Color(0xFFFFFBFE),
|
||||||
|
onPrimary = Color.White,
|
||||||
|
onSecondary = Color.White,
|
||||||
|
onTertiary = Color.White,
|
||||||
|
onBackground = Color(0xFF1C1B1F),
|
||||||
|
onSurface = Color(0xFF1C1B1F),
|
||||||
|
*/
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PunchnetTheme(
|
||||||
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
|
// Dynamic color is available on Android 12+
|
||||||
|
dynamicColor: Boolean = true,
|
||||||
|
content: @Composable () -> Unit
|
||||||
|
) {
|
||||||
|
val colorScheme = when {
|
||||||
|
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||||
|
val context = LocalContext.current
|
||||||
|
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
darkTheme -> DarkColorScheme
|
||||||
|
else -> LightColorScheme
|
||||||
|
}
|
||||||
|
|
||||||
|
MaterialTheme(
|
||||||
|
colorScheme = colorScheme,
|
||||||
|
typography = Typography,
|
||||||
|
content = content
|
||||||
|
)
|
||||||
|
}
|
||||||
34
app/src/main/java/com/jihe/punchnet/ui/theme/Type.kt
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
package com.jihe.punchnet.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.material3.Typography
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
// Set of Material typography styles to start with
|
||||||
|
val Typography = Typography(
|
||||||
|
bodyLarge = TextStyle(
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
lineHeight = 24.sp,
|
||||||
|
letterSpacing = 0.5.sp
|
||||||
|
)
|
||||||
|
/* Other default text styles to override
|
||||||
|
titleLarge = TextStyle(
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 22.sp,
|
||||||
|
lineHeight = 28.sp,
|
||||||
|
letterSpacing = 0.sp
|
||||||
|
),
|
||||||
|
labelSmall = TextStyle(
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
lineHeight = 16.sp,
|
||||||
|
letterSpacing = 0.5.sp
|
||||||
|
)
|
||||||
|
*/
|
||||||
|
)
|
||||||
196
app/src/main/proto/message.proto
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
option java_package = "com.jihe.punchnet.protobuf";
|
||||||
|
option java_outer_classname = "PunchProto";
|
||||||
|
|
||||||
|
// 基础公共类型定义
|
||||||
|
message SDLV4Info {
|
||||||
|
uint32 port = 1;
|
||||||
|
bytes v4 = 2;
|
||||||
|
uint32 nat_type = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SDLV6Info {
|
||||||
|
uint32 port = 1;
|
||||||
|
bytes v6 = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SDLWelcome {
|
||||||
|
uint32 version = 1;
|
||||||
|
uint32 max_bidi_streams = 2;
|
||||||
|
uint32 max_packet_size = 3;
|
||||||
|
uint32 heartbeat_sec = 4;
|
||||||
|
SDLV6Info ipv6_assist = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SDLRegisterSuperAck {
|
||||||
|
string algorithm = 1;
|
||||||
|
bytes key = 2;
|
||||||
|
uint32 region_id = 3;
|
||||||
|
bytes session_token = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SDLRegisterSuperNak {
|
||||||
|
uint32 error_code = 1;
|
||||||
|
string error_message = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 网络地址查询
|
||||||
|
message SDLQueryInfo {
|
||||||
|
bytes dst_mac = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SDLPeerInfo {
|
||||||
|
bytes dst_mac = 1;
|
||||||
|
optional SDLV4Info v4_info = 2;
|
||||||
|
optional SDLV6Info v6_info = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SDLArpRequest {
|
||||||
|
uint32 target_ip = 1;
|
||||||
|
uint32 origin_ip = 2;
|
||||||
|
bytes context = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SDLArpResponse {
|
||||||
|
uint32 target_ip = 1;
|
||||||
|
bytes target_mac = 2;
|
||||||
|
uint32 origin_ip = 3;
|
||||||
|
bytes context = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SDLPolicyRequest {
|
||||||
|
uint32 src_identity_id = 1;
|
||||||
|
uint32 dst_identity_id = 2;
|
||||||
|
uint32 version = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SDLPolicyResponse {
|
||||||
|
uint32 src_identity_id = 1;
|
||||||
|
uint32 dst_identity_id = 2;
|
||||||
|
uint32 version = 3;
|
||||||
|
bytes rules = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SDLExposedServiceRequest {
|
||||||
|
uint32 version = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SDLExposedServiceResponse {
|
||||||
|
uint32 version = 1;
|
||||||
|
repeated uint32 tcp_ports = 2;
|
||||||
|
repeated uint32 udp_ports = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 事件定义
|
||||||
|
message SDLEvent {
|
||||||
|
message NatChanged {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 命令定义
|
||||||
|
message SDLCommand {
|
||||||
|
uint32 pkt_id = 1;
|
||||||
|
|
||||||
|
message ExitNodeControl {
|
||||||
|
int32 action = 1;
|
||||||
|
string remark = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
oneof command {
|
||||||
|
ExitNodeControl exit_node = 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message SDLCommandAck {
|
||||||
|
uint32 pkt_id = 1;
|
||||||
|
int32 code = 2;
|
||||||
|
string message = 3;
|
||||||
|
bytes data = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// UDP通讯消息
|
||||||
|
message SDLStunRequest {
|
||||||
|
string client_id = 1;
|
||||||
|
uint32 network_id = 2;
|
||||||
|
bytes mac = 3;
|
||||||
|
uint32 ip = 4;
|
||||||
|
uint32 nat_type = 5;
|
||||||
|
optional SDLV6Info v6_info = 6;
|
||||||
|
bytes session_token = 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SDLStunReply {
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SDLRegister {
|
||||||
|
uint32 network_id = 1;
|
||||||
|
bytes src_mac = 2;
|
||||||
|
bytes dst_mac = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SDLRegisterAck {
|
||||||
|
uint32 network_id = 1;
|
||||||
|
bytes src_mac = 2;
|
||||||
|
bytes dst_mac = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 网络类型探测
|
||||||
|
message SDLStunProbe {
|
||||||
|
uint32 cookie = 1;
|
||||||
|
uint32 attr = 2;
|
||||||
|
uint32 step = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SDLStunProbeReply {
|
||||||
|
uint32 cookie = 1;
|
||||||
|
uint32 step = 2;
|
||||||
|
uint32 port = 3;
|
||||||
|
uint32 ip = 4;
|
||||||
|
}
|
||||||
170
app/src/main/res/drawable/ic_launcher_background.xml
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="#3DDC84"
|
||||||
|
android:pathData="M0,0h108v108h-108z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M9,0L9,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,0L19,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M29,0L29,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M39,0L39,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M49,0L49,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M59,0L59,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M69,0L69,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M79,0L79,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M89,0L89,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M99,0L99,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,9L108,9"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,19L108,19"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,29L108,29"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,39L108,39"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,49L108,49"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,59L108,59"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,69L108,69"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,79L108,79"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,89L108,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,99L108,99"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,29L89,29"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,39L89,39"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,49L89,49"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,59L89,59"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,69L89,69"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,79L89,79"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M29,19L29,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M39,19L39,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M49,19L49,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M59,19L59,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M69,19L69,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M79,19L79,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
</vector>
|
||||||
30
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
||||||
|
<aapt:attr name="android:fillColor">
|
||||||
|
<gradient
|
||||||
|
android:endX="85.84757"
|
||||||
|
android:endY="92.4963"
|
||||||
|
android:startX="42.9492"
|
||||||
|
android:startY="49.59793"
|
||||||
|
android:type="linear">
|
||||||
|
<item
|
||||||
|
android:color="#44000000"
|
||||||
|
android:offset="0.0" />
|
||||||
|
<item
|
||||||
|
android:color="#00000000"
|
||||||
|
android:offset="1.0" />
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:fillType="nonZero"
|
||||||
|
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
||||||
|
android:strokeWidth="1"
|
||||||
|
android:strokeColor="#00000000" />
|
||||||
|
</vector>
|
||||||
30
app/src/main/res/drawable/ic_vpn.xml
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Copyright (C) 2017 The Android Open Source Project
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
-->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:height="24dp"
|
||||||
|
android:width="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:pathData="M19.35,10.04 C18.67,6.59,15.64,4,12,4 C9.11,4,6.6,5.64,5.35,8.04
|
||||||
|
C2.34,8.36,0,10.91,0,14 C0,17.31,2.69,20,6,20 L19,20 C21.76,20,24,17.76,24,15
|
||||||
|
C24,12.36,21.95,10.22,19.35,10.04 Z M19,18 L6,18 C3.79,18,2,16.21,2,14
|
||||||
|
S3.79,10,6,10 L6.71,10 C7.37,7.69,9.48,6,12,6 C15.04,6,17.5,8.46,17.5,11.5
|
||||||
|
L17.5,12 L19,12 C20.66,12,22,13.34,22,15 S20.66,18,19,18 Z" />
|
||||||
|
<path
|
||||||
|
android:strokeColor="#000000"
|
||||||
|
android:strokeWidth="2"
|
||||||
|
android:pathData="M6.58994,13.1803 C6.58994,13.1803,8.59173,15.8724,12.011,15.8726
|
||||||
|
C15.2788,15.8728,17.3696,13.2502,17.3696,13.2502" />
|
||||||
|
</vector>
|
||||||
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>
|
||||||
648
app/src/main/res/drawable/punchnet_log.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>
|
||||||
6
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
6
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 982 B |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
10
app/src/main/res/values/colors.xml
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="purple_200">#FFBB86FC</color>
|
||||||
|
<color name="purple_500">#FF6200EE</color>
|
||||||
|
<color name="purple_700">#FF3700B3</color>
|
||||||
|
<color name="teal_200">#FF03DAC5</color>
|
||||||
|
<color name="teal_700">#FF018786</color>
|
||||||
|
<color name="black">#FF000000</color>
|
||||||
|
<color name="white">#FFFFFFFF</color>
|
||||||
|
</resources>
|
||||||
6
app/src/main/res/values/strings.xml
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">punchnet</string>
|
||||||
|
|
||||||
|
<string name="start_vpn">开启</string>
|
||||||
|
<string name="stop_vpn">关闭</string>
|
||||||
|
</resources>
|
||||||
5
app/src/main/res/values/themes.xml
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
|
||||||
|
<style name="Theme.Punchnet" parent="android:Theme.Material.Light.NoActionBar" />
|
||||||
|
</resources>
|
||||||
13
app/src/main/res/xml/backup_rules.xml
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!--
|
||||||
|
Sample backup rules file; uncomment and customize as necessary.
|
||||||
|
See https://developer.android.com/guide/topics/data/autobackup
|
||||||
|
for details.
|
||||||
|
Note: This file is ignored for devices older that API 31
|
||||||
|
See https://developer.android.com/about/versions/12/backup-restore
|
||||||
|
-->
|
||||||
|
<full-backup-content>
|
||||||
|
<!--
|
||||||
|
<include domain="sharedpref" path="."/>
|
||||||
|
<exclude domain="sharedpref" path="device.xml"/>
|
||||||
|
-->
|
||||||
|
</full-backup-content>
|
||||||
19
app/src/main/res/xml/data_extraction_rules.xml
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!--
|
||||||
|
Sample data extraction rules file; uncomment and customize as necessary.
|
||||||
|
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
|
||||||
|
for details.
|
||||||
|
-->
|
||||||
|
<data-extraction-rules>
|
||||||
|
<cloud-backup>
|
||||||
|
<!-- TODO: Use <include> and <exclude> to control what is backed up.
|
||||||
|
<include .../>
|
||||||
|
<exclude .../>
|
||||||
|
-->
|
||||||
|
</cloud-backup>
|
||||||
|
<!--
|
||||||
|
<device-transfer>
|
||||||
|
<include .../>
|
||||||
|
<exclude .../>
|
||||||
|
</device-transfer>
|
||||||
|
-->
|
||||||
|
</data-extraction-rules>
|
||||||
16
app/src/test/java/com/jihe/punchnet/ExampleUnitTest.kt
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
package com.jihe.punchnet
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example local unit test, which will execute on the development machine (host).
|
||||||
|
*
|
||||||
|
* See [testing documentation](http://d.android.com/tools/testing).
|
||||||
|
*/
|
||||||
|
class ExampleUnitTest {
|
||||||
|
@Test
|
||||||
|
fun addition_isCorrect() {
|
||||||
|
assertEquals(4, 2 + 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
6
build.gradle.kts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application) apply false
|
||||||
|
alias(libs.plugins.kotlin.android) apply false
|
||||||
|
alias(libs.plugins.kotlin.compose) apply false
|
||||||
|
}
|
||||||
24
gradle.properties
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# Project-wide Gradle settings.
|
||||||
|
# IDE (e.g. Android Studio) users:
|
||||||
|
# Gradle settings configured through the IDE *will override*
|
||||||
|
# any settings specified in this file.
|
||||||
|
# For more details on how to configure your build environment visit
|
||||||
|
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||||
|
# Specifies the JVM arguments used for the daemon process.
|
||||||
|
# The setting is particularly useful for tweaking memory settings.
|
||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
# When configured, Gradle will run in incubating parallel mode.
|
||||||
|
# This option should only be used with decoupled projects. For more details, visit
|
||||||
|
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
|
||||||
|
# org.gradle.parallel=true
|
||||||
|
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||||
|
# Android operating system, and which are packaged with your app's APK
|
||||||
|
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||||
|
android.useAndroidX=true
|
||||||
|
# Kotlin code style for this project: "official" or "obsolete":
|
||||||
|
kotlin.code.style=official
|
||||||
|
# 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,
|
||||||
|
# thereby reducing the size of the R class for that library
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
|
org.gradle.java.home=/Users/stavid/Library/Java/JavaVirtualMachines/corretto-17.0.17/Contents/Home
|
||||||