Compare commits
No commits in common. "master" and "main" have entirely different histories.
48
.gitignore
vendored
@ -1,15 +1,35 @@
|
|||||||
*.iml
|
# ---> Android
|
||||||
.gradle
|
# Gradle files
|
||||||
/local.properties
|
.gradle/
|
||||||
/.idea/caches
|
build/
|
||||||
/.idea/libraries
|
|
||||||
/.idea/modules.xml
|
# Local configuration file (sdk path, etc)
|
||||||
/.idea/workspace.xml
|
|
||||||
/.idea/navEditor.xml
|
|
||||||
/.idea/assetWizardSettings.xml
|
|
||||||
.DS_Store
|
|
||||||
/build
|
|
||||||
/captures
|
|
||||||
.externalNativeBuild
|
|
||||||
.cxx
|
|
||||||
local.properties
|
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
|
||||||
|
|
||||||
|
|||||||
3
.idea/.gitignore
generated
vendored
@ -1,3 +0,0 @@
|
|||||||
# Default ignored files
|
|
||||||
/shelf/
|
|
||||||
/workspace.xml
|
|
||||||
1
.idea/.name
generated
@ -1 +0,0 @@
|
|||||||
punchnet
|
|
||||||
6
.idea/AndroidProjectSystem.xml
generated
@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="AndroidProjectSystem">
|
|
||||||
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
6
.idea/compiler.xml
generated
@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="CompilerConfiguration">
|
|
||||||
<bytecodeTargetLevel target="21" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
18
.idea/deploymentTargetSelector.xml
generated
@ -1,18 +0,0 @@
|
|||||||
<?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
@ -1,13 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="DeviceTable">
|
|
||||||
<option name="columnSorters">
|
|
||||||
<list>
|
|
||||||
<ColumnSorterState>
|
|
||||||
<option name="column" value="Name" />
|
|
||||||
<option name="order" value="ASCENDING" />
|
|
||||||
</ColumnSorterState>
|
|
||||||
</list>
|
|
||||||
</option>
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
20
.idea/gradle.xml
generated
@ -1,20 +0,0 @@
|
|||||||
<?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
@ -1,61 +0,0 @@
|
|||||||
<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
@ -1,6 +0,0 @@
|
|||||||
<?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
@ -1,8 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="MarkdownSettings">
|
|
||||||
<option name="previewPanelProviderInfo">
|
|
||||||
<ProviderInfo name="Compose (experimental)" className="com.intellij.markdown.compose.preview.ComposePanelProvider" />
|
|
||||||
</option>
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
10
.idea/migrations.xml
generated
@ -1,10 +0,0 @@
|
|||||||
<?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
@ -1,9 +0,0 @@
|
|||||||
<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
@ -1,17 +0,0 @@
|
|||||||
<?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
@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="VcsDirectoryMappings">
|
|
||||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
517
API.md
@ -1,517 +0,0 @@
|
|||||||
## HTTP API 接口列表
|
|
||||||
|
|
||||||
域名 https://root.punchsky.com/api
|
|
||||||
### 1.密钥登录接口
|
|
||||||
|
|
||||||
**URL**:`/auth/token`
|
|
||||||
**Method**:`POST`
|
|
||||||
|
|
||||||
#### 请求参数
|
|
||||||
| 参数名 | 类型 | 必填 | 说明 |
|
|
||||||
|-----------|------|------|--------------------------|
|
|
||||||
| client_id | string | ✅ | 客户端唯一标识符 |
|
|
||||||
| token | string | ✅ | 密钥 |
|
|
||||||
| mac | string | | MAC地址 |
|
|
||||||
| system | string | | 系统信息(window11/macos 15.8) |
|
|
||||||
| version | string | | 软件版本 |
|
|
||||||
| hostname | string | | 主机名 |
|
|
||||||
|
|
||||||
#### 响应参数
|
|
||||||
| 字段 | 类型 | 说明 |
|
|
||||||
|------|--------|-----------------------|
|
|
||||||
| code | int | 0 成功,其他失败 |
|
|
||||||
| message | string | 说明 |
|
|
||||||
| data | object | |
|
|
||||||
| access_token | string | |
|
|
||||||
| username | string | 用户名 |
|
|
||||||
| user_type | string | token密钥登录、account账号登录 |
|
|
||||||
| audit | int | 0 未审核,1审核通过 |
|
|
||||||
| network_id | int | 网络ID |
|
|
||||||
| network_name | string | 网络名称 |
|
|
||||||
| network_domain | string | 网络域名 |
|
|
||||||
| domain | string | 主域名 |
|
|
||||||
| my_network_list | array | 我的网络列表 |
|
|
||||||
#### 示例响应
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"code": 0,
|
|
||||||
"message": "success",
|
|
||||||
"data": {
|
|
||||||
"access_token": "MjZ8NDB8MGZhMWE3OGI2NzVkZDEwOWIyYjg2MmI4ZDQ5MTY1MmV8ZTg4MzgzMThhZDBhMjU5MDI5MzQ4ZGQ5ZTc3ZWExZDVhYmQ2YjQwM2M3NDE1YmE4MWQ5YzEzZTEzOTM3MzYzMQ",
|
|
||||||
"username": "测试clinet_id_123456",
|
|
||||||
"user_type": "token",
|
|
||||||
"audit": 1,
|
|
||||||
"network_id": 3,
|
|
||||||
"network_name": "test的网络",
|
|
||||||
"network_domain": "punchsky8.com",
|
|
||||||
"domain": "punchsky.com",
|
|
||||||
"my_network_list": []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
#### 错误
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"code": 400,
|
|
||||||
"message": "密钥已失效",
|
|
||||||
"data": null
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
### 2.账号登录接口
|
|
||||||
|
|
||||||
**URL**:`/auth/login`
|
|
||||||
**Method**:`POST`
|
|
||||||
|
|
||||||
#### 请求参数
|
|
||||||
| 参数名 | 类型 | 必填 | 说明 |
|
|
||||||
|--------|------|------|--------------------------|
|
|
||||||
| client_id | string | ✅ | 客户端唯一标识符 |
|
|
||||||
| username | string | ✅ | 用户名 |
|
|
||||||
| password | string | ✅ | 密码 |
|
|
||||||
| mac | string | | MAC地址 |
|
|
||||||
| system | string | | 系统信息(window11/macos 15.8) |
|
|
||||||
| version | string | | 软件版本 |
|
|
||||||
| hostname | string | | 主机名 |
|
|
||||||
|
|
||||||
#### 响应参数 - 跟auth/token一致 ,多了一个我的网络列表
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"code": 0,
|
|
||||||
"message": "success",
|
|
||||||
"data": {
|
|
||||||
"access_token": "MzF8NDV8MzM2MzMyMTQ1MTc3YjlkMjYyZGU3OTNkMWFhYjQ4OTB8YmE3ODRiZDE1MmFlMjQ3MDk2NzIyZTRiY2EyOWQzMGZmNWMyMWRhMmJkYzM3Zjk1N2NkNjg2NGRlNjQ1ZDc1MQ",
|
|
||||||
"username": "test3",
|
|
||||||
"user_type": "account",
|
|
||||||
"audit": 1,
|
|
||||||
"network_id": 3,
|
|
||||||
"network_name": "test\u7684\u7f51\u7edc",
|
|
||||||
"network_domain": "lfn1.punchlink.cn",
|
|
||||||
"my_network_list": [
|
|
||||||
{
|
|
||||||
"network_id": 3,
|
|
||||||
"network_name": "test\u7684\u7f51\u7edc"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.连接网络
|
|
||||||
|
|
||||||
**URL**:`/connect`
|
|
||||||
**Method**:`POST`
|
|
||||||
|
|
||||||
#### 请求参数
|
|
||||||
| 参数名 | 类型 | 必填 | 说明 |
|
|
||||||
|--------|--------|------|------|
|
|
||||||
| client_id | string | ✅ | 客户端唯一标识符 |
|
|
||||||
| access_token | string | ✅ | |
|
|
||||||
| version | string | | 软件版本 |
|
|
||||||
#### 响应参数
|
|
||||||
| 字段 | 类型 | 说明 |
|
|
||||||
|------|--------|-------------------|
|
|
||||||
| code | int | 0 成功,其他失败 |
|
|
||||||
| message | string | 说明 |
|
|
||||||
| data | object | |
|
|
||||||
| ip | string | IP地址 |
|
|
||||||
| mask_len | int | mask |
|
|
||||||
| hostname | string | hostname |
|
|
||||||
| identity_id | int | 目前返回单个数字,多组概念稍后解决 |
|
|
||||||
| resource_list | array | 资源列表 |
|
|
||||||
| node_list | string | 节点列表 |
|
|
||||||
| acl | array | 访问规则 |
|
|
||||||
| exit_node | array | 出口节点列表 (可为空) |
|
|
||||||
|
|
||||||
#### 示例响应
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"code": 0,
|
|
||||||
"message": "success",
|
|
||||||
"data": {
|
|
||||||
"ip": "10.10.2.3",
|
|
||||||
"mask_len": 24,
|
|
||||||
"hostname": "lfmw",
|
|
||||||
"identity_id": 26,
|
|
||||||
"resource_list": [
|
|
||||||
{
|
|
||||||
"id": 0,
|
|
||||||
"name": "文档中心",
|
|
||||||
"url": "http://10.10.2.6:88",
|
|
||||||
"connection_status": "connected"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"name": "文档中心",
|
|
||||||
"url": "http://10.10.2.6:80",
|
|
||||||
"connection_status": "connected"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 2,
|
|
||||||
"name": "数据中心",
|
|
||||||
"url": "http://10.10.2.6:88",
|
|
||||||
"connection_status": "connected"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 3,
|
|
||||||
"name": "数据中心",
|
|
||||||
"url": "http://10.10.2.6:80",
|
|
||||||
"connection_status": "connected"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 4,
|
|
||||||
"name": "数据中心",
|
|
||||||
"url": "http://10.10.2.6:3306",
|
|
||||||
"connection_status": "connected"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"node_list": [
|
|
||||||
{
|
|
||||||
"id": 13,
|
|
||||||
"name": "key_MTE1",
|
|
||||||
"ip": "10.10.2.3",
|
|
||||||
"system": "Macos 1.0.0",
|
|
||||||
"connection_status": "connected"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 14,
|
|
||||||
"name": "test3",
|
|
||||||
"ip": "10.10.2.4",
|
|
||||||
"system": "Window 1.0.0",
|
|
||||||
"connection_status": "connected"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 23,
|
|
||||||
"name": "alc",
|
|
||||||
"ip": "10.10.2.6",
|
|
||||||
"system": "",
|
|
||||||
"connection_status": "connected"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 25,
|
|
||||||
"name": "maliu-window",
|
|
||||||
"ip": "10.10.2.7",
|
|
||||||
"system": "OS: Windows, Host name: DESKTOP-VLMVV81",
|
|
||||||
"connection_status": "connected"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 26,
|
|
||||||
"name": "key_MTE4",
|
|
||||||
"ip": "10.10.2.8",
|
|
||||||
"system": "OS: Windows, Host name: DESKTOP-VLMVV81",
|
|
||||||
"connection_status": "connected"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 27,
|
|
||||||
"name": "company-window",
|
|
||||||
"ip": "10.10.2.9",
|
|
||||||
"system": "OS: Windows, Host name: DESKTOP-VLMVV81",
|
|
||||||
"connection_status": "disconnected"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 28,
|
|
||||||
"name": "maliu-macos",
|
|
||||||
"ip": "10.10.2.10",
|
|
||||||
"system": "",
|
|
||||||
"connection_status": "connected"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 29,
|
|
||||||
"name": "key_MTE4",
|
|
||||||
"ip": "10.10.2.5",
|
|
||||||
"system": "linux",
|
|
||||||
"connection_status": "connected"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 39,
|
|
||||||
"name": "maliu-ubuntu",
|
|
||||||
"ip": "10.10.2.12",
|
|
||||||
"system": "linux",
|
|
||||||
"connection_status": "disconnected"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 40,
|
|
||||||
"name": "测试clinet_id_123456",
|
|
||||||
"ip": "10.10.2.3",
|
|
||||||
"system": "Macos 1.0.0",
|
|
||||||
"connection_status": "connected"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 41,
|
|
||||||
"name": "test3",
|
|
||||||
"ip": "10.10.2.5",
|
|
||||||
"system": "linux",
|
|
||||||
"connection_status": "connected"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 42,
|
|
||||||
"name": "test3",
|
|
||||||
"ip": "10.10.2.6",
|
|
||||||
"system": "",
|
|
||||||
"connection_status": "connected"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 43,
|
|
||||||
"name": "user_MTQw",
|
|
||||||
"ip": "10.10.2.13",
|
|
||||||
"system": "linux",
|
|
||||||
"connection_status": "connected"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 45,
|
|
||||||
"name": "test3",
|
|
||||||
"ip": "10.10.2.3",
|
|
||||||
"system": "Macos 1.0.0",
|
|
||||||
"connection_status": "connected"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 47,
|
|
||||||
"name": "compay-usa",
|
|
||||||
"ip": "10.10.2.14",
|
|
||||||
"system": "linux",
|
|
||||||
"connection_status": "connected"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 48,
|
|
||||||
"name": "test3",
|
|
||||||
"ip": "10.10.2.10",
|
|
||||||
"system": "",
|
|
||||||
"connection_status": "disconnected"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 49,
|
|
||||||
"name": "test3",
|
|
||||||
"ip": "10.10.2.7",
|
|
||||||
"system": "OS: Windows, Host name: DESKTOP-VLMVV81",
|
|
||||||
"connection_status": "connected"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 50,
|
|
||||||
"name": "test3",
|
|
||||||
"ip": "10.10.2.9",
|
|
||||||
"system": "OS: Windows, Host name: DESKTOP-VLMVV81",
|
|
||||||
"connection_status": "disconnected"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 51,
|
|
||||||
"name": "test3",
|
|
||||||
"ip": "10.10.2.15",
|
|
||||||
"system": "OS: Windows, Host name: MALIU3CA0",
|
|
||||||
"connection_status": "disconnected"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 52,
|
|
||||||
"name": "test3",
|
|
||||||
"ip": "10.10.2.16",
|
|
||||||
"system": "OS: Ubuntu, Host name: alex-ubuntu",
|
|
||||||
"connection_status": "disconnected"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"acl": [],
|
|
||||||
"exit_node": [
|
|
||||||
{
|
|
||||||
"node_id": 41,
|
|
||||||
"node_name": "test3",
|
|
||||||
"gateway": "10.10.2.5",
|
|
||||||
"target_network": "0.0.0.0/24"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 错误
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"code": 400,
|
|
||||||
"message": "操作失败",
|
|
||||||
"data": null
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.断开网络
|
|
||||||
|
|
||||||
**URL**:`/disconnect`
|
|
||||||
**Method**:`POST`
|
|
||||||
|
|
||||||
#### 请求参数
|
|
||||||
| 参数名 | 类型 | 必填 | 说明 |
|
|
||||||
|--------|--------|------|------|
|
|
||||||
| client_id | string | ✅ | 客户端唯一标识符 |
|
|
||||||
| access_token | string | ✅ | |
|
|
||||||
|
|
||||||
#### 响应参数
|
|
||||||
| 字段 | 类型 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| code | int | 0 成功,其他失败 |
|
|
||||||
| message | string | 说明 |
|
|
||||||
| data | | |
|
|
||||||
|
|
||||||
#### 示例响应
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"code": 0,
|
|
||||||
"message": "success",
|
|
||||||
"data": null
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 错误
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"code": 400,
|
|
||||||
"message": "操作失败",
|
|
||||||
"data": null
|
|
||||||
}
|
|
||||||
```
|
|
||||||
### 5.设备详情
|
|
||||||
|
|
||||||
**URL**:`/get_node_resources`
|
|
||||||
**Method**:`POST`
|
|
||||||
|
|
||||||
#### 请求参数
|
|
||||||
| 参数名 | 类型 | 必填 | 说明 |
|
|
||||||
|----|--------|------|----------|
|
|
||||||
| client_id | string | ✅ | 客户端唯一标识符 |
|
|
||||||
| access_token | string | ✅ | |
|
|
||||||
| id | int | ✅ | 设备id |
|
|
||||||
|
|
||||||
#### 响应参数
|
|
||||||
| 字段 | 类型 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| code | int | 0 成功,其他失败 |
|
|
||||||
| message | string | 说明 |
|
|
||||||
| data | | |
|
|
||||||
|
|
||||||
#### 示例响应
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"code": 0,
|
|
||||||
"message": "success",
|
|
||||||
"data": {
|
|
||||||
"id": 15,
|
|
||||||
"name": "key_MTE1",
|
|
||||||
"ip": "10.10.2.5",
|
|
||||||
"system": "Macos 1.0.0",
|
|
||||||
"connection_status": "connected",
|
|
||||||
"resource_list": [
|
|
||||||
{
|
|
||||||
"id": 15,
|
|
||||||
"name": "数据中心",
|
|
||||||
"url": "http://10.10.2.5:8080",
|
|
||||||
"connection_status": "connected"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 15,
|
|
||||||
"name": "数据中心",
|
|
||||||
"url": "http://10.10.2.5:90",
|
|
||||||
"connection_status": "connected"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 15,
|
|
||||||
"name": "数据中心",
|
|
||||||
"url": "http://10.10.2.5:10",
|
|
||||||
"connection_status": "connected"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.验证access_token 服务端使用
|
|
||||||
|
|
||||||
**URL**:`auth/access_token`
|
|
||||||
**Method**:`POST`
|
|
||||||
|
|
||||||
#### 请求参数
|
|
||||||
| 参数名 | 类型 | 必填 | 说明 |
|
|
||||||
|--------------|--------|------|----------|
|
|
||||||
| client_id | string | ✅ | 客户端唯一标识符 |
|
|
||||||
| access_token | string | ✅ | |
|
|
||||||
| ip | string | ✅ | |
|
|
||||||
| mask_len | int | ✅ | |
|
|
||||||
| hostname | string | ✅ | |
|
|
||||||
| mac | string | ✅ | |
|
|
||||||
| network_id | int | ✅ | |
|
|
||||||
|
|
||||||
#### 响应参数
|
|
||||||
|
|
||||||
#### 示例响应
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"result": "ok"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
#### 错误
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": {
|
|
||||||
"code": 1,
|
|
||||||
"message": "缺少必填参数:client_id"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.set_node_status 服务端使用
|
|
||||||
|
|
||||||
**URL**:`set_node_status`
|
|
||||||
**Method**:`POST`
|
|
||||||
|
|
||||||
#### 请求参数
|
|
||||||
| 参数名 | 类型 | 必填 | 说明 |
|
|
||||||
|--------------|--------|------|----------|
|
|
||||||
| client_id | string | ✅ | 客户端唯一标识符 |
|
|
||||||
| access_token | string | ✅ | |
|
|
||||||
| status | int | ✅ | |
|
|
||||||
| network_id | int | ✅ | |
|
|
||||||
|
|
||||||
#### 响应参数
|
|
||||||
|
|
||||||
#### 示例响应
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"result": "ok"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
#### 错误
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": {
|
|
||||||
"code": 1,
|
|
||||||
"message": "缺少必填参数:client_id"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8.获取节点开放端口
|
|
||||||
|
|
||||||
**URL**:`/acl`
|
|
||||||
**Method**:`POST`
|
|
||||||
|
|
||||||
#### 请求参数
|
|
||||||
| 参数名 | 类型 | 必填 | 说明 |
|
|
||||||
|--------------|--------|------|---------------------------|
|
|
||||||
| client_id | string | ✅ | 客户端唯一标识符 |
|
|
||||||
| access_token | string | ✅ | |
|
|
||||||
| network_id | int | | 网络ID |
|
|
||||||
|
|
||||||
#### 响应参数
|
|
||||||
| 字段 | 类型 | 说明 |
|
|
||||||
|------|--------|-----------------------|
|
|
||||||
| code | int | 0 成功,其他失败 |
|
|
||||||
| message | string | 说明 |
|
|
||||||
| data | object | |
|
|
||||||
#### 示例响应
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"code": 0,
|
|
||||||
"message": "success",
|
|
||||||
"data": {
|
|
||||||
"tcp": [
|
|
||||||
80
|
|
||||||
],
|
|
||||||
"udp": []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
777
Protocol.md
@ -1,777 +0,0 @@
|
|||||||
# SDLAN 协议说明
|
|
||||||
|
|
||||||
本文档描述 Client 端和当前服务端的交互协议。当前控制面支持两种接入方式:
|
|
||||||
|
|
||||||
- QUIC 接入:实现位于 `src/quic`,主入口为 `sdlan_quic_server` 和 `sdlan_quic_transport`。
|
|
||||||
- SSL/TLS 接入:实现位于 `src/ssl`,主入口为 `sdlan_ssl_server` 和 `sdlan_ssl_transport`。
|
|
||||||
|
|
||||||
两种接入方式在传输层不同,但进入 `sdlan_session` 后使用同一套应用层包类型和 protobuf 消息。Client 完成控制面注册后,还需要通过 UDP STUN 服务上报 NAT 映射并承载节点之间的数据转发。
|
|
||||||
|
|
||||||
## 1. 加密说明
|
|
||||||
|
|
||||||
服务端在网络启动时为每个网络生成独立密钥,Client 通过 `RegisterSuper` 响应拿到当前网络的加密参数。
|
|
||||||
|
|
||||||
```text
|
|
||||||
algorithm:
|
|
||||||
aes AES-256
|
|
||||||
chacha20 ChaCha20
|
|
||||||
|
|
||||||
key:
|
|
||||||
长度 32 字节。
|
|
||||||
RegisterSuperAck.key 会使用客户端 RegisterSuper.pub_key 中的 RSA 公钥加密。
|
|
||||||
|
|
||||||
AES 参数:
|
|
||||||
blockMode: cbc
|
|
||||||
padding: pkcs7Padding
|
|
||||||
iv: key 的前 16 字节
|
|
||||||
|
|
||||||
ChaCha20 参数:
|
|
||||||
key: RegisterSuperAck.key 解密后的 32 字节密钥
|
|
||||||
region_id: RegisterSuperAck.region_id,用于客户端侧逻辑分区/nonce 生成
|
|
||||||
```
|
|
||||||
|
|
||||||
`SDLData.data` 字段承载隧道中的原始二层/三层数据,Client 需要按 `RegisterSuperAck.algorithm` 和 `key` 做加解密;包头中的 `network_id`、`src_mac`、`dst_mac`、`ttl`、`session_token`、`identity_id` 不参与该字段加密。
|
|
||||||
|
|
||||||
## 2. 通用应用层帧
|
|
||||||
|
|
||||||
QUIC 和 SSL/TLS 接入最终交给 `sdlan_session:handle_frame/2` 的应用层帧格式一致:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Frame = <<PacketType:8, ProtobufPayload/binary>>
|
|
||||||
```
|
|
||||||
|
|
||||||
- `PacketType`:1 字节包类型,取值见第 3 节。
|
|
||||||
- `ProtobufPayload`:protobuf 编码后的消息体。没有消息体的包只包含 `PacketType`。
|
|
||||||
- Event 和 Command 当前已经改为 protobuf `oneof` 结构,不再使用旧文档中的二级编码字节。
|
|
||||||
|
|
||||||
## 3. 包类型定义
|
|
||||||
|
|
||||||
包类型以 `include/sdlan.hrl` 为准。
|
|
||||||
|
|
||||||
| 宏 | 值 | 方向 | 消息 | 说明 |
|
|
||||||
| --- | --- | --- | --- | --- |
|
|
||||||
| `PACKET_EMPTY` | `0x00` | 双向 | 无 | 空包,当前主流程未使用。 |
|
|
||||||
| `PACKET_REGISTER_SUPER` | `0x01` | Client -> Server | `SDLRegisterSuper` | 控制连接注册,必须在收到 Welcome 后发送。 |
|
|
||||||
| `PACKET_REGISTER_SUPER_ACK` | `0x02` | Server -> Client | `SDLRegisterSuperAck` | 注册成功,返回网络加密参数和 `session_token`。 |
|
|
||||||
| `PACKET_REGISTER_SUPER_NAK` | `0x04` | Server -> Client | `SDLRegisterSuperNak` | 注册失败,返回错误码和错误消息,随后连接关闭。 |
|
|
||||||
| `PACKET_UNREGISTER` | `0x05` | Client -> Server | 无 | 主动注销当前控制连接,服务端清理网络绑定后关闭连接。 |
|
|
||||||
| `PACKET_QUERY_INFO` | `0x06` | Client -> Server | `SDLQueryInfo` | 查询目标 MAC 的 NAT/IPv6 信息,用于发起 P2P 打洞。 |
|
|
||||||
| `PACKET_PEER_INFO` | `0x07` | Server -> Client | `SDLPeerInfo` | `QUERY_INFO` 的响应。找不到目标时返回空 `v4_info/v6_info`。 |
|
|
||||||
| `PACKET_PING` | `0x08` | Client -> Server | 无 | 控制连接心跳。 |
|
|
||||||
| `PACKET_PONG` | `0x09` | Server -> Client | 无 | 心跳响应。 |
|
|
||||||
| `PACKET_EVENT` | `0x10` | Server -> Client | `SDLEvent` | 服务端主动事件推送,Client 不需要 Ack。 |
|
|
||||||
| `PACKET_COMMAND` | `0x11` | Server -> Client | `SDLCommand` | 服务端主动命令推送,需要 Client 回 `COMMAND_ACK`。 |
|
|
||||||
| `PACKET_COMMAND_ACK` | `0x12` | Client -> Server | `SDLCommandAck` | 命令处理结果,`pkt_id` 必须等于 `SDLCommand.pkt_id`。 |
|
|
||||||
| `PACKET_FLOW_TRACER` | `0x15` | Client -> Server | 历史保留 | `sdlan_session` 当前未处理该包。 |
|
|
||||||
| `PACKET_REGISTER` | `0x20` | Client <-> Client | `SDLRegister` | 节点之间 UDP 打洞握手请求。 |
|
|
||||||
| `PACKET_REGISTER_ACK` | `0x21` | Client <-> Client | `SDLRegisterAck` | 节点之间 UDP 打洞握手响应。 |
|
|
||||||
| `PACKET_STUN_REQUEST` | `0x30` | Client -> STUN | `SDLStunRequest` | 周期上报 NAT 映射和 IPv6 信息,同时维持 NAT 映射。 |
|
|
||||||
| `PACKET_STUN_REPLY` | `0x31` | STUN -> Client | `SDLStunReply` | STUN 心跳响应。 |
|
|
||||||
| `PACKET_STUN_PROBE` | `0x32` | Client -> STUN | `SDLStunProbe` | NAT 类型探测请求。 |
|
|
||||||
| `PACKET_STUN_PROBE_REPLY` | `0x33` | STUN -> Client | `SDLStunProbeReply` | NAT 类型探测响应,返回服务端看到的公网 IP/端口。 |
|
|
||||||
| `PACKET_STUN_PROBE_RELAY` | `0x3a` | STUN 内部 | 内部二进制 | STUN 辅助节点内部转发探测响应,Client 不直接使用。 |
|
|
||||||
| `PACKET_WELCOME` | `0x4f` | Server -> Client | `SDLWelcome` | 控制连接建立后服务端首先下发的欢迎包。 |
|
|
||||||
| `PACKET_ARP_REQUEST` | `0x50` | Client -> Server | `SDLArpRequest` | 查询虚拟网络 IP 对应的 MAC。 |
|
|
||||||
| `PACKET_ARP_RESPONSE` | `0x51` | Server -> Client | `SDLArpResponse` | ARP 查询响应。 |
|
|
||||||
| `PACKET_POLICY_REQUEST` | `0xb0` | Client -> Server | `SDLPolicyRequest` | 查询源身份到目标身份的访问规则。 |
|
|
||||||
| `PACKET_POLICY_REPLY` | `0xb1` | Server -> Client | `SDLPolicyResponse` | 权限规则响应。 |
|
|
||||||
| `PACKET_EXPOSED_SERVICE_REQUEST` | `0xb2` | Client -> Server | `SDLExposedServiceRequest` | 查询当前节点暴露服务端口列表。 |
|
|
||||||
| `PACKET_EXPOSED_SERVICE_RESPONSE` | `0xb3` | Server -> Client | `SDLExposedServiceResponse` | 暴露服务端口响应。 |
|
|
||||||
| `PACKET_STUN_DATA` | `0xff` | Client <-> STUN/Client | `SDLData` | UDP 数据包,支持服务端转发和 P2P 直连。 |
|
|
||||||
|
|
||||||
STUN 探测属性同样定义在 `include/sdlan.hrl`:
|
|
||||||
|
|
||||||
```text
|
|
||||||
STUN_ATTR_CHANGE_NONE = 0 使用收到请求的同一个 socket 响应
|
|
||||||
STUN_ATTR_CHANGE_PORT = 1 通过 stun_peer_assist 路径转发响应,通常用于测试变化 IP/端口路径
|
|
||||||
STUN_ATTR_CHANGE_PEER = 2 通过 stun_port_assist 路径响应,通常用于测试变化端口路径
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. Protobuf 消息
|
|
||||||
|
|
||||||
protobuf 定义以 `proto/sdlan.proto` 为准,Erlang 生成文件为 `src/sdlan_pb.erl` 和 `include/sdlan_pb.hrl`。
|
|
||||||
|
|
||||||
### 4.1 公共类型
|
|
||||||
|
|
||||||
```protobuf
|
|
||||||
message SDLV4Info {
|
|
||||||
uint32 port = 1;
|
|
||||||
bytes v4 = 2;
|
|
||||||
uint32 nat_type = 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
message SDLV6Info {
|
|
||||||
uint32 port = 1;
|
|
||||||
bytes v6 = 2;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `SDLV4Info.port`:服务端观察到的客户端 NAT 端口。
|
|
||||||
- `SDLV4Info.v4`:4 字节 IPv4 地址,网络字节序。
|
|
||||||
- `SDLV4Info.nat_type`:Client 上报的 NAT 类型。
|
|
||||||
- `SDLV6Info.port`:IPv6 辅助通道端口。
|
|
||||||
- `SDLV6Info.v6`:16 字节 IPv6 地址。
|
|
||||||
|
|
||||||
### 4.2 NAT 类型约定
|
|
||||||
|
|
||||||
```text
|
|
||||||
0 blocked 网络不可达
|
|
||||||
1 noNat 当前设备在公网地址下
|
|
||||||
2 fullCone Full Cone NAT
|
|
||||||
3 portRestricted Port Restricted Cone NAT
|
|
||||||
4 coneRestricted Restricted Cone NAT
|
|
||||||
5 symmetric Symmetric NAT
|
|
||||||
```
|
|
||||||
|
|
||||||
## 5. QUIC 接入
|
|
||||||
|
|
||||||
QUIC 接入代码位于 `src/quic`。服务端监听配置来自 `quic_server`:
|
|
||||||
|
|
||||||
- 默认端口:`443`
|
|
||||||
- ALPN:`punchnet/1.0`
|
|
||||||
- 服务端允许 1 条双向 stream:`peer_bidi_stream_count => 1`
|
|
||||||
- 默认最大应用包:`max_packet_size = 16384`
|
|
||||||
- 默认心跳间隔:`heartbeat_sec = 15`
|
|
||||||
|
|
||||||
### 5.1 QUIC 传输帧
|
|
||||||
|
|
||||||
QUIC 在单条双向 stream 上承载控制面消息。stream 上的实际传输格式为:
|
|
||||||
|
|
||||||
```text
|
|
||||||
QuicStreamData = <<Len:16, Frame:Len/binary>>
|
|
||||||
Frame = <<PacketType:8, ProtobufPayload/binary>>
|
|
||||||
```
|
|
||||||
|
|
||||||
- `Len`:2 字节无符号长度,表示后续 `Frame` 字节数,不包含 `Len` 自身。
|
|
||||||
- `Frame`:第 2 节定义的通用应用层帧。
|
|
||||||
- 服务端接收时支持一次 QUIC data 中包含多个完整 frame,也支持半包累积。
|
|
||||||
- 当 `Len > max_packet_size` 时,服务端关闭连接,原因是 `frame_too_large`。
|
|
||||||
|
|
||||||
### 5.2 QUIC 连接建立流程
|
|
||||||
|
|
||||||
1. Client 与服务端完成 QUIC/TLS 握手,ALPN 必须匹配 `punchnet/1.0`。
|
|
||||||
2. Client 打开一条双向 stream。
|
|
||||||
3. 服务端接受第一条 stream 后立即发送 `PACKET_WELCOME`。
|
|
||||||
4. Client 收到 Welcome 后发送 `PACKET_REGISTER_SUPER`。
|
|
||||||
5. 注册成功后服务端返回 `PACKET_REGISTER_SUPER_ACK` 并进入 `registered` 状态。
|
|
||||||
6. 注册失败时服务端返回 `PACKET_REGISTER_SUPER_NAK`,随后关闭连接。
|
|
||||||
7. 进入 `registered` 后,Client 必须按 Welcome 中的 `heartbeat_sec` 周期发送 `PACKET_PING`。
|
|
||||||
|
|
||||||
### 5.3 QUIC Welcome
|
|
||||||
|
|
||||||
```text
|
|
||||||
Server -> Client
|
|
||||||
<<Len:16, 0x4f:8, SDLWelcome/binary>>
|
|
||||||
```
|
|
||||||
|
|
||||||
```protobuf
|
|
||||||
message SDLWelcome {
|
|
||||||
uint32 version = 1;
|
|
||||||
uint32 max_bidi_streams = 2;
|
|
||||||
uint32 max_packet_size = 3;
|
|
||||||
uint32 heartbeat_sec = 4;
|
|
||||||
SDLV6Info ipv6_assist = 5;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `version`:协议版本,当前为 `1`。
|
|
||||||
- `max_bidi_streams`:服务端允许的双向 stream 数,当前为 `1`。
|
|
||||||
- `max_packet_size`:单个应用层 frame 最大长度。
|
|
||||||
- `heartbeat_sec`:心跳间隔,Client 应以小于或等于该值的周期发送 Ping。
|
|
||||||
- `ipv6_assist`:IPv6 辅助器地址。未配置时为空。
|
|
||||||
|
|
||||||
### 5.4 QUIC RegisterSuper
|
|
||||||
|
|
||||||
```text
|
|
||||||
Client -> Server
|
|
||||||
<<Len:16, 0x01:8, SDLRegisterSuper/binary>>
|
|
||||||
```
|
|
||||||
|
|
||||||
```protobuf
|
|
||||||
message SDLRegisterSuper {
|
|
||||||
string client_id = 1;
|
|
||||||
uint32 network_id = 2;
|
|
||||||
bytes mac = 3;
|
|
||||||
uint32 ip = 4;
|
|
||||||
uint32 mask_len = 5;
|
|
||||||
string hostname = 6;
|
|
||||||
string pub_key = 7;
|
|
||||||
string access_token = 8;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `client_id`:客户端节点 ID,不能为空。
|
|
||||||
- `network_id`:客户端要加入的网络 ID。
|
|
||||||
- `mac`:虚拟网卡 MAC,不能为空,且不能是广播或组播 MAC。
|
|
||||||
- `ip`:HTTP 接口已分配的虚拟网络 IPv4,按 32 位整数传输。
|
|
||||||
- `mask_len`:虚拟网络掩码长度。
|
|
||||||
- `hostname`:客户端主机名,服务端用于维护域名映射。
|
|
||||||
- `pub_key`:客户端 RSA 公钥 PEM,服务端用它加密网络密钥。
|
|
||||||
- `access_token`:客户端通过 HTTP 登录或令牌换取的访问凭证,服务端注册时调用 API 校验。
|
|
||||||
|
|
||||||
注册成功:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Server -> Client
|
|
||||||
<<Len:16, 0x02:8, SDLRegisterSuperAck/binary>>
|
|
||||||
```
|
|
||||||
|
|
||||||
```protobuf
|
|
||||||
message SDLRegisterSuperAck {
|
|
||||||
string algorithm = 1;
|
|
||||||
bytes key = 2;
|
|
||||||
uint32 region_id = 3;
|
|
||||||
bytes session_token = 4;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `algorithm`:当前网络加密算法,取值为 `aes` 或 `chacha20`。
|
|
||||||
- `key`:使用 `pub_key` 加密后的网络密钥,Client 需要用私钥解密。
|
|
||||||
- `region_id`:服务端根据虚拟 IP 生成的逻辑分区 ID。
|
|
||||||
- `session_token`:本次控制会话 token。后续 UDP `SDLStunRequest` 和 `SDLData` 必须携带,用于校验端和网络绑定关系。
|
|
||||||
|
|
||||||
注册失败:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Server -> Client
|
|
||||||
<<Len:16, 0x04:8, SDLRegisterSuperNak/binary>>
|
|
||||||
```
|
|
||||||
|
|
||||||
```protobuf
|
|
||||||
message SDLRegisterSuperNak {
|
|
||||||
uint32 error_code = 1;
|
|
||||||
string error_message = 2;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `error_code`:错误码。网络/API 错误当前使用 `4`,服务内部错误当前使用 `5`,API 返回业务错误时透传 API code。
|
|
||||||
- `error_message`:错误描述。服务端发送 NAK 后关闭连接。
|
|
||||||
|
|
||||||
### 5.5 QUIC 心跳
|
|
||||||
|
|
||||||
```text
|
|
||||||
Client -> Server
|
|
||||||
<<Len:16, 0x08:8>>
|
|
||||||
|
|
||||||
Server -> Client
|
|
||||||
<<Len:16, 0x09:8>>
|
|
||||||
```
|
|
||||||
|
|
||||||
- Client 只在注册成功后发送 Ping。
|
|
||||||
- 服务端收到 Ping 后立即回复 Pong,并记录本轮心跳已收到。
|
|
||||||
- 服务端按 `heartbeat_sec` 检查上一周期是否收到过 Ping;如果没有收到,关闭连接。
|
|
||||||
|
|
||||||
### 5.6 QUIC 查询 PeerInfo
|
|
||||||
|
|
||||||
```text
|
|
||||||
Client -> Server
|
|
||||||
<<Len:16, 0x06:8, SDLQueryInfo/binary>>
|
|
||||||
```
|
|
||||||
|
|
||||||
```protobuf
|
|
||||||
message SDLQueryInfo {
|
|
||||||
bytes dst_mac = 1;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `dst_mac`:目标节点 MAC。
|
|
||||||
- 服务端查找目标节点的 NAT 映射和 IPv6 信息。
|
|
||||||
- 如果目标存在,服务端同时会向目标节点推送 `SDLEvent.SendRegister`,让目标主动向查询方发送打洞包,提高 P2P 成功率。
|
|
||||||
|
|
||||||
响应:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Server -> Client
|
|
||||||
<<Len:16, 0x07:8, SDLPeerInfo/binary>>
|
|
||||||
```
|
|
||||||
|
|
||||||
```protobuf
|
|
||||||
message SDLPeerInfo {
|
|
||||||
bytes dst_mac = 1;
|
|
||||||
optional SDLV4Info v4_info = 2;
|
|
||||||
optional SDLV6Info v6_info = 3;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `dst_mac`:原样返回查询的目标 MAC。
|
|
||||||
- `v4_info`:目标节点最近一次 `STUN_REQUEST` 上报形成的公网 IPv4/端口和 NAT 类型。
|
|
||||||
- `v6_info`:目标节点上报的 IPv6 辅助信息。没有时为空。
|
|
||||||
- 找不到目标或目标没有可用 NAT 信息时,服务端仍返回 `SDLPeerInfo`,但 `v4_info` 和 `v6_info` 为空。
|
|
||||||
|
|
||||||
### 5.7 QUIC ARP 查询
|
|
||||||
|
|
||||||
```text
|
|
||||||
Client -> Server
|
|
||||||
<<Len:16, 0x50:8, SDLArpRequest/binary>>
|
|
||||||
```
|
|
||||||
|
|
||||||
```protobuf
|
|
||||||
message SDLArpRequest {
|
|
||||||
uint32 target_ip = 1;
|
|
||||||
uint32 origin_ip = 2;
|
|
||||||
bytes context = 3;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `target_ip`:要查询 MAC 的虚拟网络 IPv4。
|
|
||||||
- `origin_ip`:发起查询的虚拟网络 IPv4。
|
|
||||||
- `context`:Client 自定义上下文,服务端原样回写,方便客户端匹配本地 ARP 请求。
|
|
||||||
|
|
||||||
响应:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Server -> Client
|
|
||||||
<<Len:16, 0x51:8, SDLArpResponse/binary>>
|
|
||||||
```
|
|
||||||
|
|
||||||
```protobuf
|
|
||||||
message SDLArpResponse {
|
|
||||||
uint32 target_ip = 1;
|
|
||||||
bytes target_mac = 2;
|
|
||||||
uint32 origin_ip = 3;
|
|
||||||
bytes context = 4;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `target_mac`:查询成功时为目标 MAC;查询失败时为空字节串。
|
|
||||||
- 其它字段与请求一致或对应请求上下文。
|
|
||||||
|
|
||||||
### 5.8 QUIC Policy 查询
|
|
||||||
|
|
||||||
```text
|
|
||||||
Client -> Server
|
|
||||||
<<Len:16, 0xb0:8, SDLPolicyRequest/binary>>
|
|
||||||
```
|
|
||||||
|
|
||||||
```protobuf
|
|
||||||
message SDLPolicyRequest {
|
|
||||||
uint32 src_identity_id = 1;
|
|
||||||
uint32 dst_identity_id = 2;
|
|
||||||
uint32 version = 3;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `src_identity_id`:源端身份 ID。
|
|
||||||
- `dst_identity_id`:目标端身份 ID。
|
|
||||||
- `version`:Client 侧规则版本,服务端原样回写,Client 用于判断是否覆盖本地缓存。
|
|
||||||
|
|
||||||
响应:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Server -> Client
|
|
||||||
<<Len:16, 0xb1:8, SDLPolicyResponse/binary>>
|
|
||||||
```
|
|
||||||
|
|
||||||
```protobuf
|
|
||||||
message SDLPolicyResponse {
|
|
||||||
uint32 src_identity_id = 1;
|
|
||||||
uint32 dst_identity_id = 2;
|
|
||||||
uint32 version = 3;
|
|
||||||
bytes rules = 4;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `rules`:稀疏序列化规则列表,每条规则格式为 `<<Proto:8, Port:16>>`。
|
|
||||||
- `Proto`:协议号,例如 TCP/UDP 对应的协议编号。
|
|
||||||
- `Port`:允许访问的目标端口。
|
|
||||||
- 服务端只下发 allow 规则,deny 规则在服务端侧已过滤。
|
|
||||||
- 如果请求解析或规则查询失败,当前服务端可能不返回响应包。
|
|
||||||
|
|
||||||
### 5.9 QUIC ExposedService 查询
|
|
||||||
|
|
||||||
```text
|
|
||||||
Client -> Server
|
|
||||||
<<Len:16, 0xb2:8, SDLExposedServiceRequest/binary>>
|
|
||||||
```
|
|
||||||
|
|
||||||
```protobuf
|
|
||||||
message SDLExposedServiceRequest {
|
|
||||||
uint32 version = 1;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `version`:Client 当前缓存版本,服务端响应中原样返回。
|
|
||||||
|
|
||||||
响应:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Server -> Client
|
|
||||||
<<Len:16, 0xb3:8, SDLExposedServiceResponse/binary>>
|
|
||||||
```
|
|
||||||
|
|
||||||
```protobuf
|
|
||||||
message SDLExposedServiceResponse {
|
|
||||||
uint32 version = 1;
|
|
||||||
repeated uint32 tcp_ports = 2;
|
|
||||||
repeated uint32 udp_ports = 3;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `tcp_ports`:当前节点允许暴露的 TCP 端口列表。
|
|
||||||
- `udp_ports`:当前节点允许暴露的 UDP 端口列表。
|
|
||||||
- 服务端只返回 `1..65534` 范围内的端口。
|
|
||||||
- API 查询失败时当前服务端可能不返回响应包。
|
|
||||||
|
|
||||||
### 5.10 QUIC Event 推送
|
|
||||||
|
|
||||||
```text
|
|
||||||
Server -> Client
|
|
||||||
<<Len:16, 0x10:8, SDLEvent/binary>>
|
|
||||||
```
|
|
||||||
|
|
||||||
```protobuf
|
|
||||||
message SDLEvent {
|
|
||||||
message NatChanged {
|
|
||||||
bytes mac = 1;
|
|
||||||
uint32 ip = 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
message SendRegister {
|
|
||||||
bytes dst_mac = 1;
|
|
||||||
uint32 nat_ip = 2;
|
|
||||||
uint32 nat_port = 3;
|
|
||||||
uint32 nat_type = 4;
|
|
||||||
optional SDLV6Info v6_info = 5;
|
|
||||||
}
|
|
||||||
|
|
||||||
message ExposedServiceChanged {
|
|
||||||
}
|
|
||||||
|
|
||||||
message NetworkShutdown {
|
|
||||||
string message = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
oneof event {
|
|
||||||
NatChanged nat_changed = 1;
|
|
||||||
SendRegister send_register = 2;
|
|
||||||
NetworkShutdown shutdown = 3;
|
|
||||||
ExposedServiceChanged exposed_service_changed = 4;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `nat_changed`:某个 MAC 的虚拟 IP 或 NAT 映射发生变化。Client 应清理对应 peer 缓存、ARP 缓存或重新查询 peer 信息。
|
|
||||||
- `send_register`:服务端要求当前 Client 向 `dst_mac` 对应节点发送 UDP `PACKET_REGISTER` 打洞请求。`nat_ip/nat_port/nat_type/v6_info` 是目标可达地址信息。
|
|
||||||
- `exposed_service_changed`:当前节点暴露服务配置变化。Client 应重新发送 `PACKET_EXPOSED_SERVICE_REQUEST` 获取端口列表。
|
|
||||||
- `shutdown`:网络关闭。Client 应停止当前网络会话并释放本地资源。
|
|
||||||
- Event 不需要 Client 回复 Ack。
|
|
||||||
|
|
||||||
### 5.11 QUIC Command 下发和 ACK
|
|
||||||
|
|
||||||
```text
|
|
||||||
Server -> Client
|
|
||||||
<<Len:16, 0x11:8, SDLCommand/binary>>
|
|
||||||
```
|
|
||||||
|
|
||||||
```protobuf
|
|
||||||
message SDLCommand {
|
|
||||||
uint32 pkt_id = 1;
|
|
||||||
|
|
||||||
message ExitNodeControl {
|
|
||||||
int32 action = 1;
|
|
||||||
string remark = 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
oneof command {
|
|
||||||
ExitNodeControl exit_node = 2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `pkt_id`:服务端生成的命令序号,Client 回复时必须原样带回。
|
|
||||||
- `exit_node.action`:出口节点控制动作,具体动作值由业务层约定。
|
|
||||||
- `exit_node.remark`:操作备注或调试说明。
|
|
||||||
|
|
||||||
Client 处理完成后回复:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Client -> Server
|
|
||||||
<<Len:16, 0x12:8, SDLCommandAck/binary>>
|
|
||||||
```
|
|
||||||
|
|
||||||
```protobuf
|
|
||||||
message SDLCommandAck {
|
|
||||||
uint32 pkt_id = 1;
|
|
||||||
int32 code = 2;
|
|
||||||
string message = 3;
|
|
||||||
bytes data = 4;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `pkt_id`:必须与收到的 `SDLCommand.pkt_id` 一致。
|
|
||||||
- `code`:处理结果码,建议 `0` 表示成功,非 `0` 表示失败。
|
|
||||||
- `message`:处理结果说明。
|
|
||||||
- `data`:可选业务数据。
|
|
||||||
- 服务端收到 ACK 后,会把结果转发给等待该命令响应的内部调用方。
|
|
||||||
|
|
||||||
### 5.12 QUIC Unregister
|
|
||||||
|
|
||||||
```text
|
|
||||||
Client -> Server
|
|
||||||
<<Len:16, 0x05:8>>
|
|
||||||
```
|
|
||||||
|
|
||||||
- Client 主动退出网络时发送。
|
|
||||||
- 服务端收到后调用 `sdlan_network:unregister/3` 清理当前 `client_id + mac` 的绑定,并关闭控制连接。
|
|
||||||
|
|
||||||
## 6. SSL/TLS 接入
|
|
||||||
|
|
||||||
SSL/TLS 接入代码位于 `src/ssl`。服务端通过 Ranch SSL 监听,配置来自 `ssl_server`:
|
|
||||||
|
|
||||||
- 默认端口:`1443`
|
|
||||||
- ALPN:`punchnet/1.0`
|
|
||||||
- TLS 版本:`tlsv1.3`、`tlsv1.2`
|
|
||||||
- 默认最大应用包:`max_packet_size = 16384`
|
|
||||||
- 默认心跳间隔:`heartbeat_sec = 15`
|
|
||||||
|
|
||||||
### 6.1 SSL/TLS 传输帧
|
|
||||||
|
|
||||||
SSL/TLS 接入使用 Erlang socket `{packet, 2}` 分包。对非 Erlang Client 来说,线上格式仍然是:
|
|
||||||
|
|
||||||
```text
|
|
||||||
TlsStreamData = <<Len:16, Frame:Len/binary>>
|
|
||||||
Frame = <<PacketType:8, ProtobufPayload/binary>>
|
|
||||||
```
|
|
||||||
|
|
||||||
- `Len`:2 字节无符号长度,表示后续 `Frame` 字节数,不包含 `Len` 自身。
|
|
||||||
- 服务端 `ssl` 层会自动剥离 `Len` 后把 `Frame` 交给 `sdlan_session`。
|
|
||||||
- 服务端发送时也依赖 `{packet, 2}` 自动加长度前缀。
|
|
||||||
- 单个发送包长度必须不超过 `65535`,业务上应遵守 Welcome 中的 `max_packet_size`。
|
|
||||||
|
|
||||||
### 6.2 SSL/TLS 连接建立流程
|
|
||||||
|
|
||||||
1. Client 与服务端建立 TCP 连接。
|
|
||||||
2. Client 完成 TLS 握手,ALPN 使用 `punchnet/1.0`。
|
|
||||||
3. 服务端 TLS 握手成功后立即发送 `PACKET_WELCOME`。
|
|
||||||
4. Client 收到 Welcome 后发送 `PACKET_REGISTER_SUPER`。
|
|
||||||
5. 注册成功后服务端返回 `PACKET_REGISTER_SUPER_ACK` 并进入 `registered` 状态。
|
|
||||||
6. 注册失败时服务端返回 `PACKET_REGISTER_SUPER_NAK`,随后关闭连接。
|
|
||||||
7. 进入 `registered` 后,Client 必须按 Welcome 中的 `heartbeat_sec` 周期发送 `PACKET_PING`。
|
|
||||||
|
|
||||||
### 6.3 SSL/TLS 消息交互
|
|
||||||
|
|
||||||
SSL/TLS 接入的应用层消息与 QUIC 完全一致,区别只在传输层:
|
|
||||||
|
|
||||||
- Welcome:见 `5.3`,包类型 `0x4f`。
|
|
||||||
- RegisterSuper / RegisterSuperAck / RegisterSuperNak:见 `5.4`,包类型 `0x01/0x02/0x04`。
|
|
||||||
- Ping / Pong:见 `5.5`,包类型 `0x08/0x09`。
|
|
||||||
- QueryInfo / PeerInfo:见 `5.6`,包类型 `0x06/0x07`。
|
|
||||||
- ARP 查询:见 `5.7`,包类型 `0x50/0x51`。
|
|
||||||
- Policy 查询:见 `5.8`,包类型 `0xb0/0xb1`。
|
|
||||||
- ExposedService 查询:见 `5.9`,包类型 `0xb2/0xb3`。
|
|
||||||
- Event 推送:见 `5.10`,包类型 `0x10`。
|
|
||||||
- Command / CommandAck:见 `5.11`,包类型 `0x11/0x12`。
|
|
||||||
- Unregister:见 `5.12`,包类型 `0x05`。
|
|
||||||
|
|
||||||
Client 如果同时支持 QUIC 和 SSL/TLS,建议抽象出同一个应用层 `Frame` 编解码模块,仅替换底层连接、握手和 stream/socket 读写逻辑。
|
|
||||||
|
|
||||||
## 7. UDP STUN 和数据转发
|
|
||||||
|
|
||||||
UDP 逻辑不属于 `src/quic` 或 `src/ssl`,但它依赖控制面注册返回的 `session_token` 和网络密钥,是 Client 完整接入流程的一部分。相关服务端实现位于:
|
|
||||||
|
|
||||||
- `src/sdlan_stun.erl`
|
|
||||||
- `src/sdlan_stun_port_assist.erl`
|
|
||||||
- `src/sdlan_stun_peer_assist.erl`
|
|
||||||
|
|
||||||
UDP 包没有 2 字节长度前缀:
|
|
||||||
|
|
||||||
```text
|
|
||||||
UdpPacket = <<PacketType:8, ProtobufPayload/binary>>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.1 STUN Request
|
|
||||||
|
|
||||||
```text
|
|
||||||
Client -> STUN
|
|
||||||
<<0x30:8, SDLStunRequest/binary>>
|
|
||||||
```
|
|
||||||
|
|
||||||
```protobuf
|
|
||||||
message SDLStunRequest {
|
|
||||||
string client_id = 1;
|
|
||||||
uint32 network_id = 2;
|
|
||||||
bytes mac = 3;
|
|
||||||
uint32 ip = 4;
|
|
||||||
uint32 nat_type = 5;
|
|
||||||
optional SDLV6Info v6_info = 6;
|
|
||||||
bytes session_token = 7;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `client_id/network_id/mac/ip`:必须与控制面 `RegisterSuper` 一致。
|
|
||||||
- `nat_type`:Client 通过 `STUN_PROBE` 判断出的 NAT 类型。
|
|
||||||
- `v6_info`:Client 可用的 IPv6 辅助信息,没有时为空。
|
|
||||||
- `session_token`:控制面 `RegisterSuperAck.session_token`,服务端用它校验当前 Client 的 NAT 上报。
|
|
||||||
- 服务端以 UDP 源地址和源端口作为该节点的最新 NAT 映射,并通知其它节点 `nat_changed`。
|
|
||||||
|
|
||||||
响应:
|
|
||||||
|
|
||||||
```text
|
|
||||||
STUN -> Client
|
|
||||||
<<0x31:8, SDLStunReply/binary>>
|
|
||||||
```
|
|
||||||
|
|
||||||
```protobuf
|
|
||||||
message SDLStunReply {
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- 空消息体,仅表示服务端已收到并处理 STUN 心跳。
|
|
||||||
- Client 需要周期性发送该包维持 NAT 映射,建议周期小于 NAT 超时时间。
|
|
||||||
|
|
||||||
### 7.2 STUN Probe
|
|
||||||
|
|
||||||
```text
|
|
||||||
Client -> STUN
|
|
||||||
<<0x32:8, SDLStunProbe/binary>>
|
|
||||||
```
|
|
||||||
|
|
||||||
```protobuf
|
|
||||||
message SDLStunProbe {
|
|
||||||
uint32 cookie = 1;
|
|
||||||
uint32 attr = 2;
|
|
||||||
uint32 step = 3;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `cookie`:Client 生成的随机值,用于匹配响应。
|
|
||||||
- `attr`:探测属性,取值见第 3 节 `STUN_ATTR_*`。
|
|
||||||
- `step`:Client 侧探测步骤编号。当前 `sdlan_stun` 主响应代码未写回该字段,响应里通常为 protobuf 默认值 `0`,Client 需要兼容。
|
|
||||||
|
|
||||||
响应:
|
|
||||||
|
|
||||||
```text
|
|
||||||
STUN -> Client
|
|
||||||
<<0x33:8, SDLStunProbeReply/binary>>
|
|
||||||
```
|
|
||||||
|
|
||||||
```protobuf
|
|
||||||
message SDLStunProbeReply {
|
|
||||||
uint32 cookie = 1;
|
|
||||||
uint32 step = 2;
|
|
||||||
uint32 port = 3;
|
|
||||||
uint32 ip = 4;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `cookie`:原样返回请求中的 `cookie`。
|
|
||||||
- `step`:当前实现通常为默认值 `0`。
|
|
||||||
- `port`:服务端看到的 Client 公网 UDP 源端口。
|
|
||||||
- `ip`:服务端看到的 Client 公网 IPv4,按 32 位整数传输。
|
|
||||||
|
|
||||||
典型 NAT 判断流程:
|
|
||||||
|
|
||||||
1. 向主 STUN 地址发送 `attr = 0`,拿到公网地址 A。
|
|
||||||
2. 如果公网地址 A 等于本地 UDP 地址,则为 `noNat`。
|
|
||||||
3. 向另一组 STUN 地址发送 `attr = 0`,拿到公网地址 B。
|
|
||||||
4. 如果 A 和 B 的 IP 不同,则倾向判断为 `symmetric`。
|
|
||||||
5. 向主 STUN 地址发送 `attr = 1`,如果能收到辅助 peer 路径响应,则说明 NAT 对来源 IP/端口限制较少。
|
|
||||||
6. 向主 STUN 地址发送 `attr = 2`,如果能收到辅助端口路径响应,则说明 NAT 对来源端口限制较少;如果收不到则按端口限制型或降级策略处理。
|
|
||||||
7. 任何关键步骤无响应时,可按 `blocked` 或降级策略处理。
|
|
||||||
|
|
||||||
### 7.3 Client 间 UDP 打洞
|
|
||||||
|
|
||||||
当 Client 通过 `QUERY_INFO` 或 `send_register` 事件拿到对端 NAT 地址后,向对端 UDP 地址发送:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Client A -> Client B
|
|
||||||
<<0x20:8, SDLRegister/binary>>
|
|
||||||
```
|
|
||||||
|
|
||||||
```protobuf
|
|
||||||
message SDLRegister {
|
|
||||||
uint32 network_id = 1;
|
|
||||||
bytes src_mac = 2;
|
|
||||||
bytes dst_mac = 3;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `network_id`:当前虚拟网络 ID。
|
|
||||||
- `src_mac`:发送方 MAC。
|
|
||||||
- `dst_mac`:目标方 MAC。
|
|
||||||
- 该包用于在双方 NAT 设备上建立映射,不经过控制连接。
|
|
||||||
|
|
||||||
对端收到后回复:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Client B -> Client A
|
|
||||||
<<0x21:8, SDLRegisterAck/binary>>
|
|
||||||
```
|
|
||||||
|
|
||||||
```protobuf
|
|
||||||
message SDLRegisterAck {
|
|
||||||
uint32 network_id = 1;
|
|
||||||
bytes src_mac = 2;
|
|
||||||
bytes dst_mac = 3;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `src_mac`:ACK 发送方 MAC。
|
|
||||||
- `dst_mac`:ACK 目标方 MAC。
|
|
||||||
- 收到 ACK 后,Client 可以优先使用 P2P 直连发送 `SDLData`。
|
|
||||||
|
|
||||||
### 7.4 UDP 数据包
|
|
||||||
|
|
||||||
```text
|
|
||||||
Client -> STUN/Peer
|
|
||||||
<<0xff:8, SDLData/binary>>
|
|
||||||
```
|
|
||||||
|
|
||||||
```protobuf
|
|
||||||
message SDLData {
|
|
||||||
uint32 network_id = 1;
|
|
||||||
bytes src_mac = 2;
|
|
||||||
bytes dst_mac = 3;
|
|
||||||
bool is_p2p = 4;
|
|
||||||
uint32 ttl = 5;
|
|
||||||
bytes data = 6;
|
|
||||||
bytes session_token = 7;
|
|
||||||
uint32 identity_id = 8;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `network_id`:当前虚拟网络 ID。
|
|
||||||
- `src_mac`:发送方 MAC。
|
|
||||||
- `dst_mac`:目标 MAC。广播或组播 MAC 会触发服务端向网络内其它在线节点广播转发。
|
|
||||||
- `is_p2p`:Client 直连发送时为 `true`;服务端转发时会改写为 `false`。
|
|
||||||
- `ttl`:转发跳数。STUN 服务端转发前会执行 `ttl - 1`。
|
|
||||||
- `data`:加密后的真实业务数据。
|
|
||||||
- `session_token`:控制面注册返回的会话 token。
|
|
||||||
- `identity_id`:源端身份 ID,对端用于本地权限判断。
|
|
||||||
|
|
||||||
服务端转发逻辑:
|
|
||||||
|
|
||||||
1. STUN 服务端收到 `PACKET_STUN_DATA` 后解析 `SDLData`。
|
|
||||||
2. 按 `network_id` 找到当前网络 ETS 表。
|
|
||||||
3. 校验 `src_mac` 当前在线且存在 endpoint。
|
|
||||||
4. 如果 `dst_mac` 是广播或组播,转发给除 `src_mac` 外所有有 NAT hole 的 endpoint。
|
|
||||||
5. 如果 `dst_mac` 是单播,查找目标 endpoint 的 NAT hole 并转发。
|
|
||||||
6. 转发前将 `ttl` 减 1,并把 `is_p2p` 改为 `false`。
|
|
||||||
7. 服务端按网络带宽限制做限流;限流或找不到目标时丢弃。
|
|
||||||
|
|
||||||
## 8. 推荐 Client 完整接入流程
|
|
||||||
|
|
||||||
1. 通过 HTTP/API 完成登录和网络/IP 分配,拿到 `access_token`、`network_id`、虚拟 IP、掩码、身份 ID 等信息。
|
|
||||||
2. 优先尝试 QUIC 接入;如果网络环境不支持 QUIC,可降级到 SSL/TLS 接入。
|
|
||||||
3. 完成控制连接握手并接收 `PACKET_WELCOME`。
|
|
||||||
4. 发送 `PACKET_REGISTER_SUPER`。
|
|
||||||
5. 收到 `PACKET_REGISTER_SUPER_ACK` 后,用私钥解密 `key`,保存 `algorithm`、`region_id`、`session_token`。
|
|
||||||
6. 启动控制连接心跳,按 `heartbeat_sec` 周期发送 `PACKET_PING` 并处理 `PACKET_PONG`。
|
|
||||||
7. 使用 `PACKET_STUN_PROBE` 判断 NAT 类型。
|
|
||||||
8. 周期性发送 `PACKET_STUN_REQUEST`,上报 NAT 类型、IPv6 信息和 `session_token`。
|
|
||||||
9. 有目标通信需求时,先使用 `PACKET_ARP_REQUEST` 或本地缓存确定目标 MAC。
|
|
||||||
10. 通过 `PACKET_POLICY_REQUEST` 查询访问策略,按 `rules` 在本地做放行判断。
|
|
||||||
11. 通过 `PACKET_QUERY_INFO` 查询目标 NAT 信息,并根据 `SDLEvent.SendRegister` 做双向 UDP 打洞。
|
|
||||||
12. P2P 打洞成功后优先直连发送 `PACKET_STUN_DATA`;失败时通过 STUN 服务端转发。
|
|
||||||
13. 处理服务端推送的 `SDLEvent` 和 `SDLCommand`。Command 必须回复 `SDLCommandAck`。
|
|
||||||
14. Client 主动退出时发送 `PACKET_UNREGISTER` 并关闭本地控制连接和 UDP 资源。
|
|
||||||
|
|
||||||
|
|
||||||
===端口说明====
|
|
||||||
组件 默认端口/配置 」 说明 」
|
|
||||||
QUIC | 443 | ALPN `punchnet/1.0` |控制面主入口,允许 1条双向 stream。
|
|
||||||
SSL/TLS | 1443 | ALPN `punchnet/1.0` |控制面备用入口,与 QUIC 共用`sdlan session`
|
|
||||||
STUN | 1365 | `acceptor_nums=5` |UDP_NAT 上报、探测和服务端转发
|
|
||||||
STUN port assist | 1366 |同节点变端 probe reply
|
|
||||||
STUN peer assist |配置的 `punchnet.aioe.tech:1366`|转发到另一辅助节点后回复。
|
|
||||||
IPv6 assist | 1367 |IPv6 可达性探测。
|
|
||||||
112
Sign.md
@ -1,112 +0,0 @@
|
|||||||
# 接口签名算法说明
|
|
||||||
|
|
||||||
## 概述
|
|
||||||
|
|
||||||
本接口使用 **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
@ -1 +0,0 @@
|
|||||||
/build
|
|
||||||
@ -1,107 +0,0 @@
|
|||||||
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
@ -1,21 +0,0 @@
|
|||||||
# 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
|
|
||||||
@ -1,37 +0,0 @@
|
|||||||
{
|
|
||||||
"version": 3,
|
|
||||||
"artifactType": {
|
|
||||||
"type": "APK",
|
|
||||||
"kind": "Directory"
|
|
||||||
},
|
|
||||||
"applicationId": "com.jihe.punchnet",
|
|
||||||
"variantName": "release",
|
|
||||||
"elements": [
|
|
||||||
{
|
|
||||||
"type": "SINGLE",
|
|
||||||
"filters": [],
|
|
||||||
"attributes": [],
|
|
||||||
"versionCode": 1,
|
|
||||||
"versionName": "1.0",
|
|
||||||
"outputFile": "app-release.apk"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"elementType": "File",
|
|
||||||
"baselineProfiles": [
|
|
||||||
{
|
|
||||||
"minApi": 28,
|
|
||||||
"maxApi": 30,
|
|
||||||
"baselineProfiles": [
|
|
||||||
"baselineProfiles/1/app-release.dm"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"minApi": 31,
|
|
||||||
"maxApi": 2147483647,
|
|
||||||
"baselineProfiles": [
|
|
||||||
"baselineProfiles/0/app-release.dm"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"minSdkVersionForDexing": 26
|
|
||||||
}
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,46 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@ -1,200 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,334 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,58 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,81 +0,0 @@
|
|||||||
package com.jihe.punchnet.api
|
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import com.google.gson.Gson
|
|
||||||
import com.google.gson.reflect.TypeToken
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import okhttp3.Request
|
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
|
|
||||||
class ApiClient {
|
|
||||||
private val client = OkHttpClient.Builder()
|
|
||||||
.connectTimeout(15, TimeUnit.SECONDS)
|
|
||||||
.readTimeout(15, TimeUnit.SECONDS)
|
|
||||||
.writeTimeout(15, TimeUnit.SECONDS)
|
|
||||||
.addInterceptor(SignInterceptor())
|
|
||||||
.build()
|
|
||||||
|
|
||||||
private val gson = Gson()
|
|
||||||
private val JSON = "application/json; charset=utf-8".toMediaType()
|
|
||||||
|
|
||||||
private val BASE_URL = "https://root.punchsky.com/api"
|
|
||||||
|
|
||||||
suspend fun loginWithToken(request: AuthTokenRequest): BaseResponse<AuthResponseData>? {
|
|
||||||
return post("/auth/token", request)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun connect(request: ConnectRequest): BaseResponse<ConnectResponseData>? {
|
|
||||||
return post("/connect", request)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun disconnect(request: DisconnectRequest): BaseResponse<Any>? {
|
|
||||||
return post("/disconnect", request)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun loginWithAccount(request: AuthLoginRequest): BaseResponse<AuthResponseData>? {
|
|
||||||
return post("/auth/login", request)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getNodeResources(request: GetNodeResourcesRequest): BaseResponse<GetNodeResourcesResponseData>? {
|
|
||||||
return post("/get_node_resources", request)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getAcl(request: AclRequest): BaseResponse<AclResponseData>? {
|
|
||||||
return post("/acl", request)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend inline fun <reified T, reified R> post(path: String, requestBody: T): BaseResponse<R>? {
|
|
||||||
return withContext(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
val jsonBody = gson.toJson(requestBody)
|
|
||||||
Log.i("apiClient", "post [${BASE_URL + path}]")
|
|
||||||
val body = jsonBody.toRequestBody(JSON)
|
|
||||||
val request = Request.Builder()
|
|
||||||
.url(BASE_URL + path)
|
|
||||||
.post(body)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
client.newCall(request).execute().use { response ->
|
|
||||||
if (!response.isSuccessful) {
|
|
||||||
response.body?.close()
|
|
||||||
Log.e("apiClient", "response error [${BASE_URL + path}]: HTTP ${response.code}")
|
|
||||||
return@withContext BaseResponse<R>(code = -1, message = "HTTP ${response.code}: ${response.message}", data = null)
|
|
||||||
}
|
|
||||||
val responseStr = response.body?.string() ?: return@withContext BaseResponse<R>(code = -1, message = "Empty response body", data = null)
|
|
||||||
Log.i("apiClient", "response [${BASE_URL + path}]: HTTP ${response.code}")
|
|
||||||
|
|
||||||
val type = object : TypeToken<BaseResponse<R>>() {}.type
|
|
||||||
gson.fromJson<BaseResponse<R>>(responseStr, type)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
Log.e("apiClient", "response exception [${BASE_URL + path}]: ${e.message}")
|
|
||||||
BaseResponse<R>(code = -1, message = e.message ?: "Unknown error", data = null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,115 +0,0 @@
|
|||||||
package com.jihe.punchnet.api
|
|
||||||
|
|
||||||
import com.google.gson.annotations.SerializedName
|
|
||||||
|
|
||||||
data class AuthTokenRequest(
|
|
||||||
@SerializedName("client_id") val clientId: String,
|
|
||||||
@SerializedName("token") val token: String,
|
|
||||||
@SerializedName("mac") val mac: String? = null,
|
|
||||||
@SerializedName("system") val system: String? = null,
|
|
||||||
@SerializedName("version") val version: String? = null,
|
|
||||||
@SerializedName("hostname") val hostname: String? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
data class NetworkItem(
|
|
||||||
@SerializedName("network_id") val networkId: Int,
|
|
||||||
@SerializedName("network_name") val networkName: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class AuthResponseData(
|
|
||||||
@SerializedName("access_token") val accessToken: String,
|
|
||||||
@SerializedName("username") val username: String,
|
|
||||||
@SerializedName("user_type") val userType: String,
|
|
||||||
@SerializedName("audit") val audit: Int,
|
|
||||||
@SerializedName("network_id") val networkId: Int,
|
|
||||||
@SerializedName("network_name") val networkName: String,
|
|
||||||
@SerializedName("network_domain") val networkDomain: String,
|
|
||||||
@SerializedName("domain") val domain: String?,
|
|
||||||
@SerializedName("my_network_list") val myNetworkList: List<NetworkItem>?
|
|
||||||
)
|
|
||||||
|
|
||||||
data class BaseResponse<T>(
|
|
||||||
@SerializedName("code") val code: Int,
|
|
||||||
@SerializedName("message") val message: String,
|
|
||||||
@SerializedName("data") val data: T?
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ConnectRequest(
|
|
||||||
@SerializedName("client_id") val clientId: String,
|
|
||||||
@SerializedName("access_token") val accessToken: String,
|
|
||||||
@SerializedName("version") val version: String? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ResourceItem(
|
|
||||||
@SerializedName("id") val id: Int,
|
|
||||||
@SerializedName("name") val name: String,
|
|
||||||
@SerializedName("url") val url: String,
|
|
||||||
@SerializedName("connection_status") val connectionStatus: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class NodeItem(
|
|
||||||
@SerializedName("id") val id: Int,
|
|
||||||
@SerializedName("name") val name: String,
|
|
||||||
@SerializedName("ip") val ip: String,
|
|
||||||
@SerializedName("system") val system: String?,
|
|
||||||
@SerializedName("connection_status") val connectionStatus: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ExitNodeItem(
|
|
||||||
@SerializedName("node_id") val nodeId: Int,
|
|
||||||
@SerializedName("node_name") val nodeName: String,
|
|
||||||
@SerializedName("gateway") val gateway: String,
|
|
||||||
@SerializedName("target_network") val targetNetwork: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ConnectResponseData(
|
|
||||||
@SerializedName("ip") val ip: String,
|
|
||||||
@SerializedName("mask_len") val maskLen: Int,
|
|
||||||
@SerializedName("hostname") val hostname: String,
|
|
||||||
@SerializedName("identity_id") val identityId: Int,
|
|
||||||
@SerializedName("resource_list") val resourceList: List<ResourceItem>?,
|
|
||||||
@SerializedName("node_list") val nodeList: List<NodeItem>?,
|
|
||||||
@SerializedName("acl") val acl: com.google.gson.JsonElement?,
|
|
||||||
@SerializedName("exit_node") val exitNode: List<ExitNodeItem>?
|
|
||||||
)
|
|
||||||
|
|
||||||
data class AuthLoginRequest(
|
|
||||||
@SerializedName("client_id") val clientId: String,
|
|
||||||
@SerializedName("username") val username: String,
|
|
||||||
@SerializedName("password") val password: String,
|
|
||||||
@SerializedName("mac") val mac: String? = null,
|
|
||||||
@SerializedName("system") val system: String? = null,
|
|
||||||
@SerializedName("version") val version: String? = null,
|
|
||||||
@SerializedName("hostname") val hostname: String? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
data class GetNodeResourcesRequest(
|
|
||||||
@SerializedName("client_id") val clientId: String,
|
|
||||||
@SerializedName("access_token") val accessToken: String,
|
|
||||||
@SerializedName("id") val id: Int
|
|
||||||
)
|
|
||||||
|
|
||||||
data class GetNodeResourcesResponseData(
|
|
||||||
@SerializedName("id") val id: Int,
|
|
||||||
@SerializedName("name") val name: String,
|
|
||||||
@SerializedName("ip") val ip: String,
|
|
||||||
@SerializedName("system") val system: String?,
|
|
||||||
@SerializedName("connection_status") val connectionStatus: String,
|
|
||||||
@SerializedName("resource_list") val resourceList: List<ResourceItem>?
|
|
||||||
)
|
|
||||||
|
|
||||||
data class AclRequest(
|
|
||||||
@SerializedName("client_id") val clientId: String,
|
|
||||||
@SerializedName("access_token") val accessToken: String,
|
|
||||||
@SerializedName("network_id") val networkId: Int? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
data class AclResponseData(
|
|
||||||
@SerializedName("tcp") val tcp: List<Int>?,
|
|
||||||
@SerializedName("udp") val udp: List<Int>?
|
|
||||||
)
|
|
||||||
|
|
||||||
data class DisconnectRequest(
|
|
||||||
@SerializedName("client_id") val clientId: String,
|
|
||||||
@SerializedName("access_token") val accessToken: String
|
|
||||||
)
|
|
||||||
@ -1,91 +0,0 @@
|
|||||||
package com.jihe.punchnet.api
|
|
||||||
|
|
||||||
import com.google.gson.GsonBuilder
|
|
||||||
import com.google.gson.JsonParser
|
|
||||||
import okhttp3.Interceptor
|
|
||||||
import okhttp3.Response
|
|
||||||
import okio.Buffer
|
|
||||||
import java.nio.charset.StandardCharsets
|
|
||||||
import javax.crypto.Mac
|
|
||||||
import javax.crypto.spec.SecretKeySpec
|
|
||||||
|
|
||||||
class SignInterceptor : Interceptor {
|
|
||||||
|
|
||||||
private val secretKey = "H6p*2RfEu4ITcL"
|
|
||||||
|
|
||||||
override fun intercept(chain: Interceptor.Chain): Response {
|
|
||||||
val originalRequest = chain.request()
|
|
||||||
val body = originalRequest.body
|
|
||||||
|
|
||||||
if (originalRequest.method == "POST" && body != null && body.contentType()?.subtype == "json") {
|
|
||||||
try {
|
|
||||||
// Read original JSON body
|
|
||||||
val buffer = Buffer()
|
|
||||||
body.writeTo(buffer)
|
|
||||||
val jsonStr = buffer.readUtf8()
|
|
||||||
|
|
||||||
// Parse into JsonObject
|
|
||||||
val jsonElement = JsonParser.parseString(jsonStr)
|
|
||||||
if (jsonElement.isJsonObject) {
|
|
||||||
val jsonObject = jsonElement.asJsonObject
|
|
||||||
|
|
||||||
// Sort keys
|
|
||||||
val sortedKeys = jsonObject.keySet().sorted()
|
|
||||||
|
|
||||||
// Build query string
|
|
||||||
val queryStringBuilder = StringBuilder()
|
|
||||||
val gson = GsonBuilder().disableHtmlEscaping().create()
|
|
||||||
|
|
||||||
for (key in sortedKeys) {
|
|
||||||
val element = jsonObject.get(key)
|
|
||||||
if (element.isJsonNull) continue
|
|
||||||
|
|
||||||
if (queryStringBuilder.isNotEmpty()) {
|
|
||||||
queryStringBuilder.append("&")
|
|
||||||
}
|
|
||||||
|
|
||||||
queryStringBuilder.append(key).append("=")
|
|
||||||
|
|
||||||
if (element.isJsonPrimitive) {
|
|
||||||
val primitive = element.asJsonPrimitive
|
|
||||||
if (primitive.isString) {
|
|
||||||
queryStringBuilder.append(primitive.asString)
|
|
||||||
} else {
|
|
||||||
// numbers and booleans
|
|
||||||
queryStringBuilder.append(primitive.asString)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Object or Array - convert to JSON string (JSON_UNESCAPED_UNICODE is default in Gson if disableHtmlEscaping is used)
|
|
||||||
queryStringBuilder.append(gson.toJson(element))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val queryString = queryStringBuilder.toString()
|
|
||||||
|
|
||||||
// Calculate HMAC-MD5
|
|
||||||
val sign = calculateHmacMD5(queryString, secretKey)
|
|
||||||
// Inject sign into HTTP Header X-sign
|
|
||||||
val newRequest = originalRequest.newBuilder()
|
|
||||||
.addHeader("X-sign", sign)
|
|
||||||
.post(body)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
return chain.proceed(newRequest)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return chain.proceed(originalRequest)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun calculateHmacMD5(data: String, key: String): String {
|
|
||||||
val algorithm = "HmacMD5"
|
|
||||||
val mac = Mac.getInstance(algorithm)
|
|
||||||
val secretKeySpec = SecretKeySpec(key.toByteArray(StandardCharsets.UTF_8), algorithm)
|
|
||||||
mac.init(secretKeySpec)
|
|
||||||
val hashBytes = mac.doFinal(data.toByteArray(StandardCharsets.UTF_8))
|
|
||||||
return hashBytes.joinToString("") { "%02x".format(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
package com.jihe.punchnet.data
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.core.content.edit
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
|
|
||||||
object AppAuthRepository {
|
|
||||||
private const val PREFS_NAME = "punchnet_app_auth"
|
|
||||||
private const val KEY_ALLOWED_APPS = "allowed_apps"
|
|
||||||
|
|
||||||
private val _allowedApps = MutableStateFlow<Set<String>>(emptySet())
|
|
||||||
val allowedApps: StateFlow<Set<String>> = _allowedApps
|
|
||||||
|
|
||||||
fun init(context: Context) {
|
|
||||||
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
|
||||||
val savedApps = prefs.getStringSet(KEY_ALLOWED_APPS, emptySet()) ?: emptySet()
|
|
||||||
_allowedApps.value = savedApps
|
|
||||||
}
|
|
||||||
|
|
||||||
fun toggleApp(context: Context, packageName: String, isAllowed: Boolean) {
|
|
||||||
val current = _allowedApps.value.toMutableSet()
|
|
||||||
if (isAllowed) {
|
|
||||||
current.add(packageName)
|
|
||||||
} else {
|
|
||||||
current.remove(packageName)
|
|
||||||
}
|
|
||||||
_allowedApps.value = current
|
|
||||||
|
|
||||||
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
|
||||||
prefs.edit {
|
|
||||||
putStringSet(KEY_ALLOWED_APPS, current)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
package com.jihe.punchnet.data
|
|
||||||
|
|
||||||
import android.app.Application
|
|
||||||
import androidx.compose.runtime.MutableState
|
|
||||||
import androidx.compose.runtime.State
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.lifecycle.AndroidViewModel
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
|
|
||||||
object ButtonRepository {
|
|
||||||
private val _buttonState: MutableStateFlow<ButtonState> = MutableStateFlow(ButtonState.ButtonStopped)
|
|
||||||
val buttonState: StateFlow<ButtonState> = _buttonState
|
|
||||||
|
|
||||||
fun updateState(state: ButtonState) {
|
|
||||||
_buttonState.value = state
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sealed class ButtonState(val text: String, val enabled: Boolean) {
|
|
||||||
object ButtonStarted: ButtonState("停止", true)
|
|
||||||
object ButtonStarting: ButtonState("启动中", false)
|
|
||||||
object ButtonStopped: ButtonState("启动", true)
|
|
||||||
object ButtonStopping: ButtonState("停止中", false)
|
|
||||||
}
|
|
||||||
|
|
||||||
class ButtonViewModel(): ViewModel() {
|
|
||||||
val buttonState = ButtonRepository.buttonState
|
|
||||||
|
|
||||||
fun updateState(state: ButtonState) {
|
|
||||||
ButtonRepository.updateState(state)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,56 +0,0 @@
|
|||||||
package com.jihe.punchnet.data
|
|
||||||
|
|
||||||
import com.jihe.punchnet.api.NodeItem
|
|
||||||
import com.jihe.punchnet.api.ResourceItem
|
|
||||||
import com.jihe.punchnet.api.ExitNodeItem
|
|
||||||
import com.jihe.punchnet.api.ConnectResponseData
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
|
|
||||||
data class LocalProfileInfo(
|
|
||||||
val ip: String,
|
|
||||||
val maskLen: Int,
|
|
||||||
val hostname: String,
|
|
||||||
val identityId: Int
|
|
||||||
)
|
|
||||||
|
|
||||||
object NodeRepository {
|
|
||||||
private val _nodes = MutableStateFlow<List<NodeItem>>(emptyList())
|
|
||||||
val nodes: StateFlow<List<NodeItem>> = _nodes
|
|
||||||
|
|
||||||
private val _resources = MutableStateFlow<List<ResourceItem>>(emptyList())
|
|
||||||
val resources: StateFlow<List<ResourceItem>> = _resources
|
|
||||||
|
|
||||||
private val _exitNodes = MutableStateFlow<List<ExitNodeItem>>(emptyList())
|
|
||||||
val exitNodes: StateFlow<List<ExitNodeItem>> = _exitNodes
|
|
||||||
|
|
||||||
private val _localProfile = MutableStateFlow<LocalProfileInfo?>(null)
|
|
||||||
val localProfile: StateFlow<LocalProfileInfo?> = _localProfile
|
|
||||||
|
|
||||||
fun updateAll(data: ConnectResponseData?) {
|
|
||||||
if (data == null) {
|
|
||||||
clear()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_nodes.value = data.nodeList ?: emptyList()
|
|
||||||
_resources.value = data.resourceList ?: emptyList()
|
|
||||||
_exitNodes.value = data.exitNode ?: emptyList()
|
|
||||||
_localProfile.value = LocalProfileInfo(
|
|
||||||
ip = data.ip,
|
|
||||||
maskLen = data.maskLen,
|
|
||||||
hostname = data.hostname,
|
|
||||||
identityId = data.identityId
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updateNodes(newNodes: List<NodeItem>?) {
|
|
||||||
_nodes.value = newNodes ?: emptyList()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun clear() {
|
|
||||||
_nodes.value = emptyList()
|
|
||||||
_resources.value = emptyList()
|
|
||||||
_exitNodes.value = emptyList()
|
|
||||||
_localProfile.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,111 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,69 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,158 +0,0 @@
|
|||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,190 +0,0 @@
|
|||||||
package com.jihe.punchnet.screen
|
|
||||||
|
|
||||||
import android.content.pm.ApplicationInfo
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.graphics.drawable.Drawable
|
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.layout.*
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Switch
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.*
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.ArrowBack
|
|
||||||
import androidx.compose.material.icons.filled.Clear
|
|
||||||
import androidx.compose.material.icons.filled.Add
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.core.graphics.drawable.toBitmap
|
|
||||||
import androidx.navigation.NavHostController
|
|
||||||
import com.jihe.punchnet.data.AppAuthRepository
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
|
|
||||||
data class AppInfo(
|
|
||||||
val name: String,
|
|
||||||
val packageName: String,
|
|
||||||
val icon: Drawable
|
|
||||||
)
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun AppAuthScreen(navController: NavHostController) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val allowedApps by AppAuthRepository.allowedApps.collectAsState()
|
|
||||||
|
|
||||||
var installedApps by remember { mutableStateOf<List<AppInfo>>(emptyList()) }
|
|
||||||
var isLoading by remember { mutableStateOf(true) }
|
|
||||||
var showAppSelector by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
val pm = context.packageManager
|
|
||||||
val intent = android.content.Intent(android.content.Intent.ACTION_MAIN, null).apply {
|
|
||||||
addCategory(android.content.Intent.CATEGORY_LAUNCHER)
|
|
||||||
}
|
|
||||||
// Use queryIntentActivities to reliably find all user-facing apps
|
|
||||||
val resolveInfos = pm.queryIntentActivities(intent, 0)
|
|
||||||
val apps = resolveInfos.mapNotNull {
|
|
||||||
try {
|
|
||||||
val appInfo = it.activityInfo.applicationInfo
|
|
||||||
if (appInfo.packageName != context.packageName) {
|
|
||||||
AppInfo(
|
|
||||||
name = it.loadLabel(pm).toString(),
|
|
||||||
packageName = appInfo.packageName,
|
|
||||||
icon = it.loadIcon(pm)
|
|
||||||
)
|
|
||||||
} else null
|
|
||||||
} catch (e: Exception) {
|
|
||||||
null // Skip apps that cause PackageManager exceptions
|
|
||||||
}
|
|
||||||
}.distinctBy { it.packageName }.sortedBy { it.name }
|
|
||||||
installedApps = apps
|
|
||||||
isLoading = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showAppSelector) {
|
|
||||||
// App Selection View
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(16.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
androidx.compose.material3.IconButton(onClick = { showAppSelector = false }) {
|
|
||||||
androidx.compose.material3.Icon(
|
|
||||||
imageVector = Icons.Default.ArrowBack,
|
|
||||||
contentDescription = "返回"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Text("选择要代理的应用", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
|
||||||
Text("正在加载应用列表...")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
|
||||||
items(installedApps) { app ->
|
|
||||||
val isAllowed = allowedApps.contains(app.packageName)
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Image(
|
|
||||||
bitmap = app.icon.toBitmap().asImageBitmap(),
|
|
||||||
contentDescription = app.name,
|
|
||||||
modifier = Modifier.size(40.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(16.dp))
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(text = app.name, fontWeight = FontWeight.Bold, fontSize = 16.sp)
|
|
||||||
Text(text = app.packageName, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
||||||
}
|
|
||||||
Switch(
|
|
||||||
checked = isAllowed,
|
|
||||||
onCheckedChange = { checked ->
|
|
||||||
AppAuthRepository.toggleApp(context, app.packageName, checked)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Main View
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
|
||||||
Text(
|
|
||||||
text = "只有在列表中的应用,其网络请求才会通过 PunchNet 代理。若列表为空,则默认代理所有应用。",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(16.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
val selectedApps = installedApps.filter { allowedApps.contains(it.packageName) }
|
|
||||||
|
|
||||||
if (selectedApps.isEmpty() && !isLoading) {
|
|
||||||
Box(modifier = Modifier.fillMaxSize().padding(bottom = 80.dp), contentAlignment = Alignment.Center) {
|
|
||||||
Text("当前未选择任何应用\n(将代理全局流量)", textAlign = androidx.compose.ui.text.style.TextAlign.Center, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
LazyColumn(modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(bottom = 80.dp)) {
|
|
||||||
items(selectedApps) { app ->
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Image(
|
|
||||||
bitmap = app.icon.toBitmap().asImageBitmap(),
|
|
||||||
contentDescription = app.name,
|
|
||||||
modifier = Modifier.size(40.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(16.dp))
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(text = app.name, fontWeight = FontWeight.Bold, fontSize = 16.sp)
|
|
||||||
Text(text = app.packageName, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
||||||
}
|
|
||||||
androidx.compose.material3.IconButton(
|
|
||||||
onClick = { AppAuthRepository.toggleApp(context, app.packageName, false) }
|
|
||||||
) {
|
|
||||||
androidx.compose.material3.Icon(
|
|
||||||
imageVector = Icons.Default.Clear,
|
|
||||||
contentDescription = "移除"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add Button (FAB)
|
|
||||||
androidx.compose.material3.FloatingActionButton(
|
|
||||||
onClick = { showAppSelector = true },
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.BottomEnd)
|
|
||||||
.padding(16.dp)
|
|
||||||
) {
|
|
||||||
androidx.compose.material3.Icon(
|
|
||||||
imageVector = Icons.Default.Add,
|
|
||||||
contentDescription = "添加应用"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,95 +0,0 @@
|
|||||||
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() }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,70 +0,0 @@
|
|||||||
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}")
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,57 +0,0 @@
|
|||||||
package com.jihe.punchnet.screen
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.*
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Person
|
|
||||||
import androidx.compose.material.icons.filled.Phone
|
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.CardDefaults
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.navigation.NavHostController
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun DeviceScreen(navController: NavHostController) {
|
|
||||||
val nodeList by com.jihe.punchnet.data.NodeRepository.nodes.collectAsState()
|
|
||||||
|
|
||||||
LazyColumn(
|
|
||||||
modifier = Modifier.fillMaxWidth().fillMaxHeight(),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
if (nodeList.isNotEmpty()) {
|
|
||||||
item {
|
|
||||||
Text(
|
|
||||||
text = "网络节点 (${nodeList.count { it.connectionStatus == "connected" }}/${nodeList.size})",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
modifier = Modifier.padding(bottom = 16.dp, start = 24.dp, top = 24.dp).fillMaxWidth()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
items(nodeList) { node ->
|
|
||||||
Box(modifier = Modifier.padding(horizontal = 24.dp)) {
|
|
||||||
NodeItemRow(node)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
item {
|
|
||||||
Box(modifier = Modifier.fillMaxSize().padding(top = 100.dp), contentAlignment = Alignment.Center) {
|
|
||||||
Text("暂无设备,请先连接网络", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,369 +0,0 @@
|
|||||||
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
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,213 +0,0 @@
|
|||||||
package com.jihe.punchnet.screen
|
|
||||||
|
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.layout.*
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Lock
|
|
||||||
import androidx.compose.material.icons.filled.Person
|
|
||||||
import androidx.compose.material3.*
|
|
||||||
import androidx.compose.runtime.*
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.navigation.NavHostController
|
|
||||||
import com.jihe.punchnet.R
|
|
||||||
import com.jihe.punchnet.api.ApiClient
|
|
||||||
import com.jihe.punchnet.api.AuthLoginRequest
|
|
||||||
import com.jihe.punchnet.api.AuthTokenRequest
|
|
||||||
import com.jihe.punchnet.helper.PreferenceName
|
|
||||||
import com.jihe.punchnet.helper.setPreferenceString
|
|
||||||
import com.jihe.punchnet.sdlan.utils.UniqueNodeID
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun LoginScreen(navController: NavHostController) {
|
|
||||||
var selectedTabIndex by remember { mutableStateOf(0) }
|
|
||||||
val tabs = listOf("账号密码登录", "Token直连")
|
|
||||||
|
|
||||||
var username by remember { mutableStateOf("") }
|
|
||||||
var password by remember { mutableStateOf("") }
|
|
||||||
var token by remember { mutableStateOf("") }
|
|
||||||
|
|
||||||
var isLoading by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
val coroutineScope = rememberCoroutineScope()
|
|
||||||
val context = LocalContext.current
|
|
||||||
val apiClient = remember { ApiClient() }
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(24.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.Center
|
|
||||||
) {
|
|
||||||
Image(
|
|
||||||
painter = painterResource(R.drawable.punchnet_log),
|
|
||||||
contentDescription = "logo",
|
|
||||||
modifier = Modifier.size(100.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = "Welcome to PunchNet",
|
|
||||||
fontSize = 24.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(32.dp))
|
|
||||||
|
|
||||||
TabRow(selectedTabIndex = selectedTabIndex) {
|
|
||||||
tabs.forEachIndexed { index, title ->
|
|
||||||
Tab(
|
|
||||||
selected = selectedTabIndex == index,
|
|
||||||
onClick = { selectedTabIndex = index },
|
|
||||||
text = { Text(title) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
|
||||||
|
|
||||||
if (selectedTabIndex == 0) {
|
|
||||||
// Account Login
|
|
||||||
OutlinedTextField(
|
|
||||||
value = username,
|
|
||||||
onValueChange = { username = it },
|
|
||||||
label = { Text("用户名") },
|
|
||||||
leadingIcon = { Icon(Icons.Default.Person, contentDescription = null) },
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
singleLine = true
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
OutlinedTextField(
|
|
||||||
value = password,
|
|
||||||
onValueChange = { password = it },
|
|
||||||
label = { Text("密码") },
|
|
||||||
leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null) },
|
|
||||||
visualTransformation = PasswordVisualTransformation(),
|
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
singleLine = true
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
// Token Login
|
|
||||||
OutlinedTextField(
|
|
||||||
value = token,
|
|
||||||
onValueChange = { token = it },
|
|
||||||
label = { Text("连接 Token") },
|
|
||||||
leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null) },
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
singleLine = true
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(32.dp))
|
|
||||||
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
if (selectedTabIndex == 0 && (username.isEmpty() || password.isEmpty())) {
|
|
||||||
Toast.makeText(context, "请输入用户名和密码", Toast.LENGTH_SHORT).show()
|
|
||||||
return@Button
|
|
||||||
}
|
|
||||||
if (selectedTabIndex == 1 && token.isEmpty()) {
|
|
||||||
Toast.makeText(context, "请输入Token", Toast.LENGTH_SHORT).show()
|
|
||||||
return@Button
|
|
||||||
}
|
|
||||||
|
|
||||||
isLoading = true
|
|
||||||
coroutineScope.launch {
|
|
||||||
UniqueNodeID.setBaseDir(context.filesDir.absolutePath)
|
|
||||||
val clientId = "punchnet_android_client_" + UniqueNodeID.getUUID(context)
|
|
||||||
|
|
||||||
try {
|
|
||||||
val response = if (selectedTabIndex == 0) {
|
|
||||||
val request = AuthLoginRequest(
|
|
||||||
clientId = clientId,
|
|
||||||
username = username,
|
|
||||||
password = password,
|
|
||||||
version = "1.0.0",
|
|
||||||
system = "Android " + android.os.Build.VERSION.RELEASE,
|
|
||||||
hostname = android.os.Build.MODEL
|
|
||||||
)
|
|
||||||
ApiClient().loginWithAccount(request)
|
|
||||||
} else {
|
|
||||||
val request = AuthTokenRequest(
|
|
||||||
clientId = clientId,
|
|
||||||
token = token,
|
|
||||||
version = "1.0.0",
|
|
||||||
system = "Android " + android.os.Build.VERSION.RELEASE,
|
|
||||||
hostname = android.os.Build.MODEL
|
|
||||||
)
|
|
||||||
ApiClient().loginWithToken(request)
|
|
||||||
}
|
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
isLoading = false
|
|
||||||
if (response != null && response.code == 0 && response.data != null) {
|
|
||||||
// Success! Save auth data as JSON and navigate
|
|
||||||
val authDataJson = com.google.gson.Gson().toJson(response.data)
|
|
||||||
setPreferenceString(context, PreferenceName.PreferenceToken, authDataJson)
|
|
||||||
Toast.makeText(context, "登录成功", Toast.LENGTH_SHORT).show()
|
|
||||||
navController.navigate(Screen.MainScreen.route) {
|
|
||||||
popUpTo(Screen.LoginScreen.route) { inclusive = true }
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Toast.makeText(context, "登录失败: ${response?.message ?: "网络错误"}", Toast.LENGTH_LONG).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
isLoading = false
|
|
||||||
Toast.makeText(context, "登录异常: ${e.message}", Toast.LENGTH_LONG).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(50.dp),
|
|
||||||
shape = RoundedCornerShape(8.dp),
|
|
||||||
enabled = !isLoading
|
|
||||||
) {
|
|
||||||
if (isLoading) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.size(24.dp),
|
|
||||||
color = MaterialTheme.colorScheme.onPrimary,
|
|
||||||
strokeWidth = 2.dp
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Text("登录", fontSize = 16.sp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.Center
|
|
||||||
) {
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
Toast.makeText(context, "请前往官网注册账号", Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Text("没有账号?去注册")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,136 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,189 +0,0 @@
|
|||||||
package com.jihe.punchnet.screen
|
|
||||||
|
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.*
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Clear
|
|
||||||
import androidx.compose.material3.*
|
|
||||||
import androidx.compose.runtime.*
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import com.jihe.punchnet.data.RouteItem
|
|
||||||
import com.jihe.punchnet.data.RouteViewModel
|
|
||||||
import com.jihe.punchnet.helper.IntToIPString
|
|
||||||
import com.jihe.punchnet.helper.parseCIDRAndGW
|
|
||||||
import com.jihe.punchnet.sdlan.network.maskIPToDigit
|
|
||||||
import com.jihe.punchnet.data.NodeRepository
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.compose.foundation.horizontalScroll
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun SettingsScreen(enabled: Boolean, routeViewModel: RouteViewModel) {
|
|
||||||
val localProfile by NodeRepository.localProfile.collectAsState()
|
|
||||||
val exitNodes by NodeRepository.exitNodes.collectAsState()
|
|
||||||
val routeItems by routeViewModel.allRoutes.collectAsState(initial = emptyList())
|
|
||||||
|
|
||||||
val showAddRoute = remember { mutableStateOf<RouteItem?>(null) }
|
|
||||||
val shouldShow = remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
AddOrModifyRouteDialog(routeViewModel, showAddRoute, shouldShow, exitNodes)
|
|
||||||
|
|
||||||
LazyColumn(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
|
||||||
// Section 1: Local Profile
|
|
||||||
item {
|
|
||||||
Text("本机信息", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
if (localProfile != null) {
|
|
||||||
Card(modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp)) {
|
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
|
||||||
Text("Hostname: ${localProfile!!.hostname}")
|
|
||||||
Text("IP: ${localProfile!!.ip}/${localProfile!!.maskLen}")
|
|
||||||
Text("Identity ID: ${localProfile!!.identityId}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Text("暂无本机网络信息", color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(bottom = 16.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Section 2: Exit Nodes
|
|
||||||
item {
|
|
||||||
Text("出口节点", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
if (exitNodes.isEmpty()) {
|
|
||||||
Text("当前网络无可用出口节点", color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(bottom = 16.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
items(exitNodes) { exitNode ->
|
|
||||||
Card(modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
|
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
|
||||||
Text("名称: ${exitNode.nodeName}", fontWeight = FontWeight.Bold)
|
|
||||||
Text("网关 IP: ${exitNode.gateway}")
|
|
||||||
Text("代理网段: ${exitNode.targetNetwork}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
item { Spacer(modifier = Modifier.height(8.dp)) }
|
|
||||||
|
|
||||||
// Section 3: Routes
|
|
||||||
item {
|
|
||||||
Text("本地路由配置", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
}
|
|
||||||
items(routeItems) { item ->
|
|
||||||
RouteCard(routeViewModel, item, enabled)
|
|
||||||
}
|
|
||||||
item {
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
Button(
|
|
||||||
enabled = enabled,
|
|
||||||
onClick = { shouldShow.value = true },
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Text("添加路由")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun AddOrModifyRouteDialog(
|
|
||||||
routeViewModel: RouteViewModel,
|
|
||||||
initRoute: MutableState<RouteItem?>,
|
|
||||||
shouldShowRoute: MutableState<Boolean>,
|
|
||||||
exitNodes: List<com.jihe.punchnet.api.ExitNodeItem>
|
|
||||||
) {
|
|
||||||
if (shouldShowRoute.value) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
var cidr by remember { mutableStateOf("") }
|
|
||||||
var gw by remember { mutableStateOf("") }
|
|
||||||
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { shouldShowRoute.value = false },
|
|
||||||
confirmButton = {
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
val item = parseCIDRAndGW(cidr, gw)
|
|
||||||
if (item == null) {
|
|
||||||
Toast.makeText(context, "数据解析出错", Toast.LENGTH_SHORT).show()
|
|
||||||
} else {
|
|
||||||
routeViewModel.insert(item)
|
|
||||||
shouldShowRoute.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Text("确定")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
title = { Text("添加新路由") },
|
|
||||||
text = {
|
|
||||||
Column {
|
|
||||||
TextField(
|
|
||||||
value = cidr,
|
|
||||||
onValueChange = { cidr = it },
|
|
||||||
label = { Text("CIDR") },
|
|
||||||
placeholder = { Text("例如 0.0.0.0/0") }
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
TextField(
|
|
||||||
value = gw,
|
|
||||||
onValueChange = { gw = it },
|
|
||||||
label = { Text("Gateway") },
|
|
||||||
placeholder = { Text("例如 10.10.4.5") }
|
|
||||||
)
|
|
||||||
if (exitNodes.isNotEmpty()) {
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
Text("快捷选择出口节点网关:", fontSize = 12.sp)
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
|
||||||
exitNodes.forEach { en ->
|
|
||||||
SuggestionChip(
|
|
||||||
onClick = {
|
|
||||||
gw = en.gateway
|
|
||||||
if (cidr.isEmpty()) cidr = en.targetNetwork
|
|
||||||
},
|
|
||||||
label = { Text(en.nodeName) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun RouteCard(
|
|
||||||
routeViewModel: RouteViewModel,
|
|
||||||
item: RouteItem,
|
|
||||||
enabled: Boolean,
|
|
||||||
) {
|
|
||||||
Card(modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.height(48.dp).padding(horizontal = 16.dp)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Clear,
|
|
||||||
contentDescription = "delete route",
|
|
||||||
modifier = Modifier.size(24.dp).clickable(enabled = enabled) {
|
|
||||||
routeViewModel.deleteById(item.id)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(16.dp))
|
|
||||||
Text("${IntToIPString(item.net_ip)}/${maskIPToDigit(item.mask_ip)}")
|
|
||||||
Spacer(modifier = Modifier.width(16.dp))
|
|
||||||
Text("-> ${IntToIPString(item.gateway)}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,94 +0,0 @@
|
|||||||
package com.jihe.punchnet.screen
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.webkit.WebChromeClient
|
|
||||||
import android.webkit.WebResourceRequest
|
|
||||||
import android.webkit.WebView
|
|
||||||
import android.webkit.WebViewClient
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Close
|
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@SuppressLint("SetJavaScriptEnabled")
|
|
||||||
@Composable
|
|
||||||
fun WebViewScreen(url: String, onBack: () -> Unit) {
|
|
||||||
var webViewRef by remember { mutableStateOf<WebView?>(null) }
|
|
||||||
var loadingProgress by remember { mutableStateOf(0f) }
|
|
||||||
var isLoading by remember { mutableStateOf(true) }
|
|
||||||
var currentTitle by remember { mutableStateOf("资源查看") }
|
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
|
||||||
TopAppBar(
|
|
||||||
title = { Text(currentTitle) },
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(onClick = onBack) {
|
|
||||||
Icon(Icons.Default.Close, contentDescription = "关闭")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
actions = {
|
|
||||||
IconButton(onClick = { webViewRef?.reload() }) {
|
|
||||||
Icon(Icons.Default.Refresh, contentDescription = "刷新")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
LinearProgressIndicator(
|
|
||||||
progress = { loadingProgress },
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
AndroidView(
|
|
||||||
factory = { context ->
|
|
||||||
WebView(context).apply {
|
|
||||||
settings.javaScriptEnabled = true
|
|
||||||
settings.domStorageEnabled = true
|
|
||||||
|
|
||||||
webViewClient = object : WebViewClient() {
|
|
||||||
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
|
|
||||||
return false // Let WebView load the URL
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
webChromeClient = object : WebChromeClient() {
|
|
||||||
override fun onProgressChanged(view: WebView?, newProgress: Int) {
|
|
||||||
loadingProgress = newProgress / 100f
|
|
||||||
isLoading = newProgress < 100
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onReceivedTitle(view: WebView?, title: String?) {
|
|
||||||
if (!title.isNullOrEmpty()) {
|
|
||||||
currentTitle = title
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
webViewRef = this
|
|
||||||
loadUrl(url)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
update = { webView ->
|
|
||||||
// Don't loadUrl here again, it causes infinite reloading on recomposition
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxSize()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
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,
|
|
||||||
)
|
|
||||||
@ -1,44 +0,0 @@
|
|||||||
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"
|
|
||||||
}
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,39 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
@ -1,146 +0,0 @@
|
|||||||
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)
|
|
||||||
@ -1,358 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,914 +0,0 @@
|
|||||||
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(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,296 +0,0 @@
|
|||||||
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(":")
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
@ -1,502 +0,0 @@
|
|||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -1,133 +0,0 @@
|
|||||||
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,
|
|
||||||
}
|
|
||||||
@ -1,107 +0,0 @@
|
|||||||
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),
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,214 +0,0 @@
|
|||||||
package com.jihe.punchnet.sdlan.network
|
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import com.jihe.punchnet.sdlan.logs.TerminalLogger
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.cancelAndJoin
|
|
||||||
import kotlinx.coroutines.channels.Channel
|
|
||||||
import kotlinx.coroutines.channels.ReceiveChannel
|
|
||||||
import kotlinx.coroutines.channels.SendChannel
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.isActive
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.selects.select
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import tech.kwik.core.QuicClientConnection
|
|
||||||
import tech.kwik.core.QuicStream
|
|
||||||
import java.net.URI
|
|
||||||
import java.nio.ByteBuffer
|
|
||||||
import java.io.InputStream
|
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
|
||||||
import java.util.concurrent.atomic.AtomicLong
|
|
||||||
|
|
||||||
suspend fun initQUICConn(
|
|
||||||
scope: CoroutineScope,
|
|
||||||
quicHost: String,
|
|
||||||
quicPort: Int,
|
|
||||||
start_stop: Channel<StartStopChanInfo>,
|
|
||||||
pong_time: AtomicLong,
|
|
||||||
connected: AtomicBoolean,
|
|
||||||
toSocket: ReceiveChannel<ByteArray>,
|
|
||||||
onConnected: suspend (stream: QuicStream)->Unit,
|
|
||||||
onMessage: suspend (CoroutineScope, SDLanControl)->Unit,
|
|
||||||
onDisconnected: suspend ()->Unit,
|
|
||||||
connectingChan: SendChannel<ConnectingState>?
|
|
||||||
) {
|
|
||||||
val started = AtomicBoolean(false)
|
|
||||||
val node = Node.getInstance()
|
|
||||||
|
|
||||||
while (scope.isActive) {
|
|
||||||
connectingChan?.send(ConnectingState.NotConnected)
|
|
||||||
|
|
||||||
if (!started.get()) {
|
|
||||||
while (true) {
|
|
||||||
val startStopInfo = start_stop.receive()
|
|
||||||
if (startStopInfo.flag == StartStopFlag.IsStart) {
|
|
||||||
started.set(true)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
connectingChan?.send(ConnectingState.Connecting)
|
|
||||||
TerminalLogger.debugf { "try connecting quic..." }
|
|
||||||
|
|
||||||
var connection: QuicClientConnection? = null
|
|
||||||
var stream: QuicStream? = null
|
|
||||||
|
|
||||||
try {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
TerminalLogger.debugf { "connecting to QUIC $quicHost:$quicPort" }
|
|
||||||
val builder = QuicClientConnection.newBuilder()
|
|
||||||
.uri(URI("https://$quicHost:$quicPort"))
|
|
||||||
.applicationProtocol("punchnet/1.0")
|
|
||||||
.noServerCertificateCheck()
|
|
||||||
connection = builder.build()
|
|
||||||
connection!!.connect()
|
|
||||||
|
|
||||||
// Open bidirectional stream
|
|
||||||
stream = connection!!.createStream(true)
|
|
||||||
|
|
||||||
// In QUIC, streams are created implicitly when the first data is sent.
|
|
||||||
// We send a PACKET_EMPTY (0x00) so the server can accept the stream and send Welcome.
|
|
||||||
val emptyPacket = ByteArray(3)
|
|
||||||
emptyPacket[0] = 0 // Len high
|
|
||||||
emptyPacket[1] = 1 // Len low (1 byte for PacketType)
|
|
||||||
emptyPacket[2] = 0 // PacketType.Empty (0x00)
|
|
||||||
TerminalLogger.debugf { "Sending QUIC application packet (PACKET_EMPTY): [00 01 00]" }
|
|
||||||
stream!!.outputStream.write(emptyPacket)
|
|
||||||
stream!!.outputStream.flush()
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
TerminalLogger.errorf { "failed to connect to QUIC ${quicHost}:${quicPort}: $e" }
|
|
||||||
delay(3000)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
val job_read_packet = scope.launch(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
while (isActive) {
|
|
||||||
val controlPacket = readQUICPacket(stream!!)
|
|
||||||
if (controlPacket == null) {
|
|
||||||
TerminalLogger.errorf { "QUIC Stream EOF or error" }
|
|
||||||
break
|
|
||||||
}
|
|
||||||
onMessage(scope, controlPacket)
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
TerminalLogger.errorf { "QUIC read loop closing" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val job_write_to_packet = scope.launch(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
while (isActive) {
|
|
||||||
val msg = toSocket.receive()
|
|
||||||
val packetType = if (msg.size >= 3) PacketType.fromValue(msg[2].toUByte()) else null
|
|
||||||
TerminalLogger.debugf { "Sending QUIC application packet: type=${packetType ?: "unknown"}, bytes=${msg.size}" }
|
|
||||||
stream!!.outputStream.write(msg)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
TerminalLogger.errorf { "failed to write to QUIC stream: $e" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val job_check_pong = scope.launch(Dispatchers.IO) {
|
|
||||||
while (isActive) {
|
|
||||||
delay(10_000)
|
|
||||||
val now = System.currentTimeMillis() / 1000
|
|
||||||
if (connected.get() && (now - pong_time.get() > com.jihe.punchnet.sdlan.config.SDLanConfig.TCP_PING_TIME * 2)) {
|
|
||||||
TerminalLogger.errorf { "QUIC pong check expired" }
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val job_check_stop = scope.launch(Dispatchers.IO) {
|
|
||||||
while (isActive) {
|
|
||||||
try {
|
|
||||||
val v = start_stop.receive()
|
|
||||||
if (v.flag == StartStopFlag.IsStop) {
|
|
||||||
started.set(false)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
TerminalLogger.debugf { "QUIC Stream connected" }
|
|
||||||
onConnected(stream!!)
|
|
||||||
connectingChan?.send(ConnectingState.Connected)
|
|
||||||
|
|
||||||
select<Unit> {
|
|
||||||
job_read_packet.onJoin { }
|
|
||||||
job_write_to_packet.onJoin { }
|
|
||||||
job_check_pong.onJoin { }
|
|
||||||
job_check_stop.onJoin { }
|
|
||||||
}
|
|
||||||
|
|
||||||
job_read_packet.cancelAndJoin()
|
|
||||||
job_write_to_packet.cancelAndJoin()
|
|
||||||
job_check_pong.cancelAndJoin()
|
|
||||||
job_check_stop.cancelAndJoin()
|
|
||||||
|
|
||||||
delay(1000)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
onDisconnected()
|
|
||||||
} finally {
|
|
||||||
try {
|
|
||||||
connection?.close()
|
|
||||||
} catch (e: Exception) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun readQUICPacket(stream: QuicStream): SDLanControl? = withContext(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
// <<Len:16, PacketType:8, ProtobufPayload>>
|
|
||||||
val lenBytes = ByteArray(2)
|
|
||||||
if (!stream.inputStream.readFullyOrEof(lenBytes)) return@withContext null
|
|
||||||
|
|
||||||
val len = ByteBuffer.wrap(lenBytes).short.toInt() and 0xFFFF
|
|
||||||
if (len < 1) return@withContext null
|
|
||||||
|
|
||||||
val typeByte = ByteArray(1)
|
|
||||||
if (!stream.inputStream.readFullyOrEof(typeByte)) return@withContext null
|
|
||||||
|
|
||||||
val packetTypeUByte = typeByte[0].toUByte()
|
|
||||||
val packetType = PacketType.fromValue(packetTypeUByte)
|
|
||||||
if (packetType == null) {
|
|
||||||
TerminalLogger.errorf { "packet type not found: $packetTypeUByte" }
|
|
||||||
return@withContext null
|
|
||||||
}
|
|
||||||
|
|
||||||
val payloadLen = len - 1
|
|
||||||
val payload = ByteArray(payloadLen)
|
|
||||||
var toread = payloadLen
|
|
||||||
var offset = 0
|
|
||||||
while (toread > 0) {
|
|
||||||
val sizeGot = stream.inputStream.read(payload, offset, toread)
|
|
||||||
if (sizeGot <= 0) return@withContext null
|
|
||||||
toread -= sizeGot
|
|
||||||
offset += sizeGot
|
|
||||||
}
|
|
||||||
|
|
||||||
return@withContext SDLanControl(packetType, payload)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
TerminalLogger.errorf { "failed to read QUIC stream: $e" }
|
|
||||||
return@withContext null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun InputStream.readFullyOrEof(buffer: ByteArray): Boolean {
|
|
||||||
var offset = 0
|
|
||||||
while (offset < buffer.size) {
|
|
||||||
val readSize = read(buffer, offset, buffer.size - offset)
|
|
||||||
if (readSize <= 0) return false
|
|
||||||
offset += readSize
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
@ -1,106 +0,0 @@
|
|||||||
package com.jihe.punchnet.sdlan.network
|
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import com.jihe.punchnet.PunchnetServiceArgument
|
|
||||||
import com.jihe.punchnet.api.ConnectResponseData
|
|
||||||
import com.jihe.punchnet.sdlan.config.Arguments
|
|
||||||
import com.jihe.punchnet.sdlan.logs.TerminalLogger
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.isActive
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import tech.kwik.core.QuicClientConnection
|
|
||||||
import java.net.InetSocketAddress
|
|
||||||
import java.nio.ByteBuffer
|
|
||||||
|
|
||||||
suspend fun run_quic_data_plane(
|
|
||||||
scope: CoroutineScope,
|
|
||||||
service: com.jihe.punchnet.PunchnetService,
|
|
||||||
argument: Arguments,
|
|
||||||
connectData: ConnectResponseData
|
|
||||||
) {
|
|
||||||
TerminalLogger.infof { "Starting QUIC Data Plane..." }
|
|
||||||
|
|
||||||
// Parse the server address from argument.sn (e.g. root.punchsky.com:1265)
|
|
||||||
val snParts = argument.sn.split(":")
|
|
||||||
val host = snParts[0]
|
|
||||||
val port = snParts[1].toIntOrNull() ?: 1265
|
|
||||||
|
|
||||||
while (scope.isActive) {
|
|
||||||
var connection: QuicClientConnection? = null
|
|
||||||
try {
|
|
||||||
TerminalLogger.debugf { "Connecting to QUIC server at $host:$port..." }
|
|
||||||
|
|
||||||
// In Kwik, QuicClientConnection is used to connect to a server.
|
|
||||||
// Note: Kwik API details may require a specific Builder or config.
|
|
||||||
// We use standard connection parameters.
|
|
||||||
val serverAddress = InetSocketAddress(host, port)
|
|
||||||
|
|
||||||
val builder = QuicClientConnection.newBuilder()
|
|
||||||
.uri(java.net.URI("https://$host:$port"))
|
|
||||||
// TODO: configure certificates if self-signed, else default is fine.
|
|
||||||
|
|
||||||
connection = builder.build()
|
|
||||||
connection.connect()
|
|
||||||
|
|
||||||
TerminalLogger.infof { "QUIC connection established!" }
|
|
||||||
|
|
||||||
// Start reading from the TUN interface and sending as QUIC Datagrams
|
|
||||||
val jobWrite = scope.launch(Dispatchers.IO) {
|
|
||||||
while (isActive) {
|
|
||||||
val data = service.recv()
|
|
||||||
if (data.isEmpty()) {
|
|
||||||
delay(1000)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send over QUIC Datagram
|
|
||||||
// Note: Kwik Datagram API might be on the connection object.
|
|
||||||
try {
|
|
||||||
// Using datagram sending API from Kwik
|
|
||||||
// If kwik doesn't support datagram directly, we fallback to streams
|
|
||||||
// For RFC 9221 datagrams:
|
|
||||||
// connection.sendDatagram(data)
|
|
||||||
// Note: Datagram API in Kwik is still experimental in some versions.
|
|
||||||
// We will use stream for fallback if datagram fails compile.
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start reading QUIC Datagrams from server and write to TUN
|
|
||||||
val jobRead = scope.launch(Dispatchers.IO) {
|
|
||||||
while (isActive) {
|
|
||||||
// Receive Datagram
|
|
||||||
// val buf = connection.receiveDatagram()
|
|
||||||
// service.send(buf)
|
|
||||||
delay(100)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
jobWrite.join()
|
|
||||||
jobRead.join()
|
|
||||||
|
|
||||||
} catch (e: Exception) {
|
|
||||||
TerminalLogger.errorf { "QUIC connection error: ${e.message}" }
|
|
||||||
e.printStackTrace()
|
|
||||||
delay(3000)
|
|
||||||
} finally {
|
|
||||||
try {
|
|
||||||
connection?.close()
|
|
||||||
} catch (e: Exception) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun ipToInt(ipAddress: String): Int {
|
|
||||||
val parts = ipAddress.split(".")
|
|
||||||
if (parts.size != 4) return 0
|
|
||||||
var result = 0
|
|
||||||
for (i in 0..3) {
|
|
||||||
result = result shl 8 or (parts[i].toInt() and 0xFF)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
@ -1,145 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,65 +0,0 @@
|
|||||||
package com.jihe.punchnet.sdlan.network
|
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import com.jihe.punchnet.sdlan.logs.TerminalLogger
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
package com.jihe.punchnet.sdlan
|
|
||||||
|
|
||||||
fun test() {
|
|
||||||
}
|
|
||||||
@ -1,147 +0,0 @@
|
|||||||
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(" ")
|
|
||||||
}
|
|
||||||
@ -1,166 +0,0 @@
|
|||||||
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!!
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -1,187 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
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)
|
|
||||||
@ -1,57 +0,0 @@
|
|||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
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
|
|
||||||
)
|
|
||||||
*/
|
|
||||||
)
|
|
||||||
@ -1,196 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -1,170 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="200dp"
|
|
||||||
android:height="200dp"
|
|
||||||
android:viewportWidth="1024"
|
|
||||||
android:viewportHeight="1024">
|
|
||||||
<path
|
|
||||||
android:fillColor="#FF000000"
|
|
||||||
android:pathData="M802.1,102.4L222.2,102.4C156.3,102.4 102.4,156 102.4,221.9v579.9A120.1,120.1 0,0 0,222.2 921.6L802.1,921.6c65.9,0 119.5,-53.9 119.5,-119.8L921.6,221.9C921.6,156 868,102.4 802.1,102.4zM751.3,767.7h-297v11.6c0,19.1 -15.4,34.1 -34.1,34.1s-34.1,-15 -34.1,-34.1v-11.6h-112.6c-19.1,0 -34.1,-15.4 -34.1,-34.1s15,-34.1 34.1,-34.1h112.6v-11.6c0,-18.8 15.4,-34.1 34.1,-34.1s34.1,15.4 34.1,34.1v11.6h297c18.8,0 34.1,15.4 34.1,34.1s-15.4,34.1 -34.1,34.1zM751.3,543.4h-123.9v11.6c0,18.8 -15.4,34.1 -34.1,34.1s-34.1,-15.4 -34.1,-34.1v-11.6L273.4,543.4c-19.1,0 -34.1,-15.4 -34.1,-34.1 0,-19.1 15,-34.1 34.1,-34.1h285.7v-11.9c0,-18.8 15.4,-34.1 34.1,-34.1s34.1,15.4 34.1,34.1v11.9h123.9c18.8,0 34.1,15 34.1,34.1 0,18.8 -15.4,34.1 -34.1,34.1zM751.3,323.9L405.2,323.9v6.8c0,18.8 -15.4,34.1 -34.1,34.1s-34.1,-15.4 -34.1,-34.1v-6.8L273.4,323.9c-19.1,0 -34.1,-15.4 -34.1,-34.1s15,-34.1 34.1,-34.1h63.5L336.9,238.9c0,-18.8 15.4,-34.1 34.1,-34.1s34.1,15.4 34.1,34.1v16.7h346.1c18.8,0 34.1,15.4 34.1,34.1s-15.4,34.1 -34.1,34.1z"/>
|
|
||||||
</vector>
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="200dp"
|
|
||||||
android:height="200dp"
|
|
||||||
android:viewportWidth="1024"
|
|
||||||
android:viewportHeight="1024">
|
|
||||||
<path
|
|
||||||
android:pathData="M818.2,268.3c0.2,-34.9 -28.7,-64.3 -63.7,-64.6 -34.2,-0.3 -64.3,29.5 -64.3,64 0,34.3 28.9,63.6 63.3,64C788.2,332.1 818,302.9 818.2,268.3M128,640 L128,595.1c0,-0.4 1,-0.8 1,-1.1C132.3,570.2 144.4,551.5 161.2,534.8c125.6,-125.3 251,-250.8 376.5,-376.2C542.1,154.2 546.8,150 551.7,146.2 564,136.3 578.8,131.8 593.7,128l220.8,0c0.6,0.4 1.2,1 1.8,1.2 43.5,6.2 80.3,46.9 79.6,96.1 -0.8,63.2 -0.3,126.6 -0.1,189.9 0.1,26.6 -9.3,49.2 -28,67.9 -127.9,128 -255.7,256.1 -383.8,383.8 -39.2,39.1 -97.5,38.8 -136.8,-0.1 -63.6,-63.2 -126.7,-126.7 -190.1,-190 -11.9,-11.9 -20.6,-25.5 -25.3,-41.9C130.4,630.1 128,625.3 128,620.6"
|
|
||||||
android:fillColor="#2c2c2c"/>
|
|
||||||
</vector>
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
<?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>
|
|
||||||
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 982 B |
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 7.6 KiB |
@ -1,10 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
<resources>
|
|
||||||
<string name="app_name">punchnet</string>
|
|
||||||
|
|
||||||
<string name="start_vpn">开启</string>
|
|
||||||
<string name="stop_vpn">关闭</string>
|
|
||||||
</resources>
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
|
|
||||||
<style name="Theme.Punchnet" parent="android:Theme.Material.Light.NoActionBar" />
|
|
||||||
</resources>
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
package com.jihe.punchnet
|
|
||||||
|
|
||||||
import org.junit.Test
|
|
||||||
import tech.kwik.core.QuicClientConnection
|
|
||||||
import java.net.URI
|
|
||||||
|
|
||||||
class TestQuic {
|
|
||||||
@Test
|
|
||||||
fun testQuic() {
|
|
||||||
try {
|
|
||||||
val builder = QuicClientConnection.newBuilder()
|
|
||||||
.uri(URI("https://127.0.0.1:1365"))
|
|
||||||
.applicationProtocol("punchnet/1.0")
|
|
||||||
.noServerCertificateCheck()
|
|
||||||
val connection = builder.build()
|
|
||||||
connection.connect()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
# 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
|
|
||||||