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
|
||||
.idea/
|
||||
misc.xml
|
||||
deploymentTargetDropDown.xml
|
||||
render.experimental.xml
|
||||
|
||||
# Keystore files
|
||||
*.jks
|
||||
*.keystore
|
||||
|
||||
# Google Services (e.g. APIs or Firebase)
|
||||
google-services.json
|
||||
|
||||
# Android Profiling
|
||||
*.hprof
|
||||
|
||||
.gradle
|
||||
/local.properties
|
||||
/.idea/caches
|
||||
/.idea/libraries
|
||||
/.idea/modules.xml
|
||||
/.idea/workspace.xml
|
||||
/.idea/navEditor.xml
|
||||
/.idea/assetWizardSettings.xml
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
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
|
||||