Compare commits

...

No commits in common. "main" and "master" have entirely different histories.
main ... master

108 changed files with 9934 additions and 37 deletions

48
.gitignore vendored
View File

@ -1,35 +1,15 @@
# ---> Android
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Log/OS Files
*.log
# Android Studio generated files and folders
captures/
.externalNativeBuild/
.cxx/
*.apk
output.json
# IntelliJ
*.iml *.iml
.idea/ .gradle
misc.xml /local.properties
deploymentTargetDropDown.xml /.idea/caches
render.experimental.xml /.idea/libraries
/.idea/modules.xml
# Keystore files /.idea/workspace.xml
*.jks /.idea/navEditor.xml
*.keystore /.idea/assetWizardSettings.xml
.DS_Store
# Google Services (e.g. APIs or Firebase) /build
google-services.json /captures
.externalNativeBuild
# Android Profiling .cxx
*.hprof local.properties

3
.idea/.gitignore generated vendored Normal file
View File

@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml

1
.idea/.name generated Normal file
View File

@ -0,0 +1 @@
punchnet

6
.idea/AndroidProjectSystem.xml generated Normal file
View File

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

6
.idea/compiler.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" />
</component>
</project>

18
.idea/deploymentTargetSelector.xml generated Normal file
View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-05-30T07:22:02.241059Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="LocalEmulator" identifier="path=/Users/stavid/.android/avd/Medium_Phone.avd" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState>
</selectionStates>
</component>
</project>

13
.idea/deviceManager.xml generated Normal file
View File

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

20
.idea/gradle.xml generated Normal file
View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
</set>
</option>
<option name="resolveExternalAnnotations" value="false" />
</GradleProjectSettings>
</option>
</component>
</project>

View File

@ -0,0 +1,61 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDeviceShouldUseNewSpec" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
</profile>
</component>

6
.idea/kotlinc.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="version" value="2.0.0" />
</component>
</project>

8
.idea/markdown.xml generated Normal file
View File

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

10
.idea/migrations.xml generated Normal file
View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectMigrations">
<option name="MigrateToGradleLocalJavaHome">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
</component>
</project>

9
.idea/misc.xml generated Normal file
View File

@ -0,0 +1,9 @@
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>

17
.idea/runConfigurations.xml generated Normal file
View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RunConfigurationProducerService">
<option name="ignoredProducers">
<set>
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
</set>
</option>
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

517
API.md Normal file
View File

@ -0,0 +1,517 @@
## HTTP API 接口列表
域名 https://root.punchsky.com/api
### 1.密钥登录接口
**URL**`/auth/token`
**Method**`POST`
#### 请求参数
| 参数名 | 类型 | 必填 | 说明 |
|-----------|------|------|--------------------------|
| client_id | string | ✅ | 客户端唯一标识符 |
| token | string | ✅ | 密钥 |
| mac | string | | MAC地址 |
| system | string | | 系统信息(window11/macos 15.8) |
| version | string | | 软件版本 |
| hostname | string | | 主机名 |
#### 响应参数
| 字段 | 类型 | 说明 |
|------|--------|-----------------------|
| code | int | 0 成功,其他失败 |
| message | string | 说明 |
| data | object | |
| access_token | string | |
| username | string | 用户名 |
| user_type | string | token密钥登录、account账号登录 |
| audit | int | 0 未审核1审核通过 |
| network_id | int | 网络ID |
| network_name | string | 网络名称 |
| network_domain | string | 网络域名 |
| domain | string | 主域名 |
| my_network_list | array | 我的网络列表 |
#### 示例响应
```json
{
"code": 0,
"message": "success",
"data": {
"access_token": "MjZ8NDB8MGZhMWE3OGI2NzVkZDEwOWIyYjg2MmI4ZDQ5MTY1MmV8ZTg4MzgzMThhZDBhMjU5MDI5MzQ4ZGQ5ZTc3ZWExZDVhYmQ2YjQwM2M3NDE1YmE4MWQ5YzEzZTEzOTM3MzYzMQ",
"username": "测试clinet_id_123456",
"user_type": "token",
"audit": 1,
"network_id": 3,
"network_name": "test的网络",
"network_domain": "punchsky8.com",
"domain": "punchsky.com",
"my_network_list": []
}
}
```
#### 错误
```json
{
"code": 400,
"message": "密钥已失效",
"data": null
}
```
### 2.账号登录接口
**URL**`/auth/login`
**Method**`POST`
#### 请求参数
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|--------------------------|
| client_id | string | ✅ | 客户端唯一标识符 |
| username | string | ✅ | 用户名 |
| password | string | ✅ | 密码 |
| mac | string | | MAC地址 |
| system | string | | 系统信息(window11/macos 15.8) |
| version | string | | 软件版本 |
| hostname | string | | 主机名 |
#### 响应参数 - 跟auth/token一致 ,多了一个我的网络列表
```json
{
"code": 0,
"message": "success",
"data": {
"access_token": "MzF8NDV8MzM2MzMyMTQ1MTc3YjlkMjYyZGU3OTNkMWFhYjQ4OTB8YmE3ODRiZDE1MmFlMjQ3MDk2NzIyZTRiY2EyOWQzMGZmNWMyMWRhMmJkYzM3Zjk1N2NkNjg2NGRlNjQ1ZDc1MQ",
"username": "test3",
"user_type": "account",
"audit": 1,
"network_id": 3,
"network_name": "test\u7684\u7f51\u7edc",
"network_domain": "lfn1.punchlink.cn",
"my_network_list": [
{
"network_id": 3,
"network_name": "test\u7684\u7f51\u7edc"
}
]
}
}
```
### 3.连接网络
**URL**`/connect`
**Method**`POST`
#### 请求参数
| 参数名 | 类型 | 必填 | 说明 |
|--------|--------|------|------|
| client_id | string | ✅ | 客户端唯一标识符 |
| access_token | string | ✅ | |
| version | string | | 软件版本 |
#### 响应参数
| 字段 | 类型 | 说明 |
|------|--------|-------------------|
| code | int | 0 成功,其他失败 |
| message | string | 说明 |
| data | object | |
| ip | string | IP地址 |
| mask_len | int | mask |
| hostname | string | hostname |
| identity_id | int | 目前返回单个数字,多组概念稍后解决 |
| resource_list | array | 资源列表 |
| node_list | string | 节点列表 |
| acl | array | 访问规则 |
| exit_node | array | 出口节点列表 (可为空) |
#### 示例响应
```json
{
"code": 0,
"message": "success",
"data": {
"ip": "10.10.2.3",
"mask_len": 24,
"hostname": "lfmw",
"identity_id": 26,
"resource_list": [
{
"id": 0,
"name": "文档中心",
"url": "http://10.10.2.6:88",
"connection_status": "connected"
},
{
"id": 1,
"name": "文档中心",
"url": "http://10.10.2.6:80",
"connection_status": "connected"
},
{
"id": 2,
"name": "数据中心",
"url": "http://10.10.2.6:88",
"connection_status": "connected"
},
{
"id": 3,
"name": "数据中心",
"url": "http://10.10.2.6:80",
"connection_status": "connected"
},
{
"id": 4,
"name": "数据中心",
"url": "http://10.10.2.6:3306",
"connection_status": "connected"
}
],
"node_list": [
{
"id": 13,
"name": "key_MTE1",
"ip": "10.10.2.3",
"system": "Macos 1.0.0",
"connection_status": "connected"
},
{
"id": 14,
"name": "test3",
"ip": "10.10.2.4",
"system": "Window 1.0.0",
"connection_status": "connected"
},
{
"id": 23,
"name": "alc",
"ip": "10.10.2.6",
"system": "",
"connection_status": "connected"
},
{
"id": 25,
"name": "maliu-window",
"ip": "10.10.2.7",
"system": "OS: Windows, Host name: DESKTOP-VLMVV81",
"connection_status": "connected"
},
{
"id": 26,
"name": "key_MTE4",
"ip": "10.10.2.8",
"system": "OS: Windows, Host name: DESKTOP-VLMVV81",
"connection_status": "connected"
},
{
"id": 27,
"name": "company-window",
"ip": "10.10.2.9",
"system": "OS: Windows, Host name: DESKTOP-VLMVV81",
"connection_status": "disconnected"
},
{
"id": 28,
"name": "maliu-macos",
"ip": "10.10.2.10",
"system": "",
"connection_status": "connected"
},
{
"id": 29,
"name": "key_MTE4",
"ip": "10.10.2.5",
"system": "linux",
"connection_status": "connected"
},
{
"id": 39,
"name": "maliu-ubuntu",
"ip": "10.10.2.12",
"system": "linux",
"connection_status": "disconnected"
},
{
"id": 40,
"name": "测试clinet_id_123456",
"ip": "10.10.2.3",
"system": "Macos 1.0.0",
"connection_status": "connected"
},
{
"id": 41,
"name": "test3",
"ip": "10.10.2.5",
"system": "linux",
"connection_status": "connected"
},
{
"id": 42,
"name": "test3",
"ip": "10.10.2.6",
"system": "",
"connection_status": "connected"
},
{
"id": 43,
"name": "user_MTQw",
"ip": "10.10.2.13",
"system": "linux",
"connection_status": "connected"
},
{
"id": 45,
"name": "test3",
"ip": "10.10.2.3",
"system": "Macos 1.0.0",
"connection_status": "connected"
},
{
"id": 47,
"name": "compay-usa",
"ip": "10.10.2.14",
"system": "linux",
"connection_status": "connected"
},
{
"id": 48,
"name": "test3",
"ip": "10.10.2.10",
"system": "",
"connection_status": "disconnected"
},
{
"id": 49,
"name": "test3",
"ip": "10.10.2.7",
"system": "OS: Windows, Host name: DESKTOP-VLMVV81",
"connection_status": "connected"
},
{
"id": 50,
"name": "test3",
"ip": "10.10.2.9",
"system": "OS: Windows, Host name: DESKTOP-VLMVV81",
"connection_status": "disconnected"
},
{
"id": 51,
"name": "test3",
"ip": "10.10.2.15",
"system": "OS: Windows, Host name: MALIU3CA0",
"connection_status": "disconnected"
},
{
"id": 52,
"name": "test3",
"ip": "10.10.2.16",
"system": "OS: Ubuntu, Host name: alex-ubuntu",
"connection_status": "disconnected"
}
],
"acl": [],
"exit_node": [
{
"node_id": 41,
"node_name": "test3",
"gateway": "10.10.2.5",
"target_network": "0.0.0.0/24"
}
]
}
}
```
#### 错误
```json
{
"code": 400,
"message": "操作失败",
"data": null
}
```
### 4.断开网络
**URL**`/disconnect`
**Method**`POST`
#### 请求参数
| 参数名 | 类型 | 必填 | 说明 |
|--------|--------|------|------|
| client_id | string | ✅ | 客户端唯一标识符 |
| access_token | string | ✅ | |
#### 响应参数
| 字段 | 类型 | 说明 |
|------|------|------|
| code | int | 0 成功,其他失败 |
| message | string | 说明 |
| data | | |
#### 示例响应
```json
{
"code": 0,
"message": "success",
"data": null
}
```
#### 错误
```json
{
"code": 400,
"message": "操作失败",
"data": null
}
```
### 5.设备详情
**URL**`/get_node_resources`
**Method**`POST`
#### 请求参数
| 参数名 | 类型 | 必填 | 说明 |
|----|--------|------|----------|
| client_id | string | ✅ | 客户端唯一标识符 |
| access_token | string | ✅ | |
| id | int | ✅ | 设备id |
#### 响应参数
| 字段 | 类型 | 说明 |
|------|------|------|
| code | int | 0 成功,其他失败 |
| message | string | 说明 |
| data | | |
#### 示例响应
```json
{
"code": 0,
"message": "success",
"data": {
"id": 15,
"name": "key_MTE1",
"ip": "10.10.2.5",
"system": "Macos 1.0.0",
"connection_status": "connected",
"resource_list": [
{
"id": 15,
"name": "数据中心",
"url": "http://10.10.2.5:8080",
"connection_status": "connected"
},
{
"id": 15,
"name": "数据中心",
"url": "http://10.10.2.5:90",
"connection_status": "connected"
},
{
"id": 15,
"name": "数据中心",
"url": "http://10.10.2.5:10",
"connection_status": "connected"
}
]
}
}
```
### 6.验证access_token 服务端使用
**URL**`auth/access_token`
**Method**`POST`
#### 请求参数
| 参数名 | 类型 | 必填 | 说明 |
|--------------|--------|------|----------|
| client_id | string | ✅ | 客户端唯一标识符 |
| access_token | string | ✅ | |
| ip | string | ✅ | |
| mask_len | int | ✅ | |
| hostname | string | ✅ | |
| mac | string | ✅ | |
| network_id | int | ✅ | |
#### 响应参数
#### 示例响应
```json
{
"result": "ok"
}
```
#### 错误
```json
{
"error": {
"code": 1,
"message": "缺少必填参数client_id"
}
}
```
### 7.set_node_status 服务端使用
**URL**`set_node_status`
**Method**`POST`
#### 请求参数
| 参数名 | 类型 | 必填 | 说明 |
|--------------|--------|------|----------|
| client_id | string | ✅ | 客户端唯一标识符 |
| access_token | string | ✅ | |
| status | int | ✅ | |
| network_id | int | ✅ | |
#### 响应参数
#### 示例响应
```json
{
"result": "ok"
}
```
#### 错误
```json
{
"error": {
"code": 1,
"message": "缺少必填参数client_id"
}
}
```
### 8.获取节点开放端口
**URL**`/acl`
**Method**`POST`
#### 请求参数
| 参数名 | 类型 | 必填 | 说明 |
|--------------|--------|------|---------------------------|
| client_id | string | ✅ | 客户端唯一标识符 |
| access_token | string | ✅ | |
| network_id | int | | 网络ID |
#### 响应参数
| 字段 | 类型 | 说明 |
|------|--------|-----------------------|
| code | int | 0 成功,其他失败 |
| message | string | 说明 |
| data | object | |
#### 示例响应
```json
{
"code": 0,
"message": "success",
"data": {
"tcp": [
80
],
"udp": []
}
}
```

777
Protocol.md Normal file
View File

@ -0,0 +1,777 @@
# SDLAN 协议说明
本文档描述 Client 端和当前服务端的交互协议。当前控制面支持两种接入方式:
- QUIC 接入:实现位于 `src/quic`,主入口为 `sdlan_quic_server``sdlan_quic_transport`
- SSL/TLS 接入:实现位于 `src/ssl`,主入口为 `sdlan_ssl_server``sdlan_ssl_transport`
两种接入方式在传输层不同,但进入 `sdlan_session` 后使用同一套应用层包类型和 protobuf 消息。Client 完成控制面注册后,还需要通过 UDP STUN 服务上报 NAT 映射并承载节点之间的数据转发。
## 1. 加密说明
服务端在网络启动时为每个网络生成独立密钥Client 通过 `RegisterSuper` 响应拿到当前网络的加密参数。
```text
algorithm:
aes AES-256
chacha20 ChaCha20
key:
长度 32 字节。
RegisterSuperAck.key 会使用客户端 RegisterSuper.pub_key 中的 RSA 公钥加密。
AES 参数:
blockMode: cbc
padding: pkcs7Padding
iv: key 的前 16 字节
ChaCha20 参数:
key: RegisterSuperAck.key 解密后的 32 字节密钥
region_id: RegisterSuperAck.region_id用于客户端侧逻辑分区/nonce 生成
```
`SDLData.data` 字段承载隧道中的原始二层/三层数据Client 需要按 `RegisterSuperAck.algorithm``key` 做加解密;包头中的 `network_id``src_mac``dst_mac``ttl``session_token``identity_id` 不参与该字段加密。
## 2. 通用应用层帧
QUIC 和 SSL/TLS 接入最终交给 `sdlan_session:handle_frame/2` 的应用层帧格式一致:
```text
Frame = <<PacketType:8, ProtobufPayload/binary>>
```
- `PacketType`1 字节包类型,取值见第 3 节。
- `ProtobufPayload`protobuf 编码后的消息体。没有消息体的包只包含 `PacketType`
- Event 和 Command 当前已经改为 protobuf `oneof` 结构,不再使用旧文档中的二级编码字节。
## 3. 包类型定义
包类型以 `include/sdlan.hrl` 为准。
| 宏 | 值 | 方向 | 消息 | 说明 |
| --- | --- | --- | --- | --- |
| `PACKET_EMPTY` | `0x00` | 双向 | 无 | 空包,当前主流程未使用。 |
| `PACKET_REGISTER_SUPER` | `0x01` | Client -> Server | `SDLRegisterSuper` | 控制连接注册,必须在收到 Welcome 后发送。 |
| `PACKET_REGISTER_SUPER_ACK` | `0x02` | Server -> Client | `SDLRegisterSuperAck` | 注册成功,返回网络加密参数和 `session_token`。 |
| `PACKET_REGISTER_SUPER_NAK` | `0x04` | Server -> Client | `SDLRegisterSuperNak` | 注册失败,返回错误码和错误消息,随后连接关闭。 |
| `PACKET_UNREGISTER` | `0x05` | Client -> Server | 无 | 主动注销当前控制连接,服务端清理网络绑定后关闭连接。 |
| `PACKET_QUERY_INFO` | `0x06` | Client -> Server | `SDLQueryInfo` | 查询目标 MAC 的 NAT/IPv6 信息,用于发起 P2P 打洞。 |
| `PACKET_PEER_INFO` | `0x07` | Server -> Client | `SDLPeerInfo` | `QUERY_INFO` 的响应。找不到目标时返回空 `v4_info/v6_info`。 |
| `PACKET_PING` | `0x08` | Client -> Server | 无 | 控制连接心跳。 |
| `PACKET_PONG` | `0x09` | Server -> Client | 无 | 心跳响应。 |
| `PACKET_EVENT` | `0x10` | Server -> Client | `SDLEvent` | 服务端主动事件推送Client 不需要 Ack。 |
| `PACKET_COMMAND` | `0x11` | Server -> Client | `SDLCommand` | 服务端主动命令推送,需要 Client 回 `COMMAND_ACK`。 |
| `PACKET_COMMAND_ACK` | `0x12` | Client -> Server | `SDLCommandAck` | 命令处理结果,`pkt_id` 必须等于 `SDLCommand.pkt_id`。 |
| `PACKET_FLOW_TRACER` | `0x15` | Client -> Server | 历史保留 | `sdlan_session` 当前未处理该包。 |
| `PACKET_REGISTER` | `0x20` | Client <-> Client | `SDLRegister` | 节点之间 UDP 打洞握手请求。 |
| `PACKET_REGISTER_ACK` | `0x21` | Client <-> Client | `SDLRegisterAck` | 节点之间 UDP 打洞握手响应。 |
| `PACKET_STUN_REQUEST` | `0x30` | Client -> STUN | `SDLStunRequest` | 周期上报 NAT 映射和 IPv6 信息,同时维持 NAT 映射。 |
| `PACKET_STUN_REPLY` | `0x31` | STUN -> Client | `SDLStunReply` | STUN 心跳响应。 |
| `PACKET_STUN_PROBE` | `0x32` | Client -> STUN | `SDLStunProbe` | NAT 类型探测请求。 |
| `PACKET_STUN_PROBE_REPLY` | `0x33` | STUN -> Client | `SDLStunProbeReply` | NAT 类型探测响应,返回服务端看到的公网 IP/端口。 |
| `PACKET_STUN_PROBE_RELAY` | `0x3a` | STUN 内部 | 内部二进制 | STUN 辅助节点内部转发探测响应Client 不直接使用。 |
| `PACKET_WELCOME` | `0x4f` | Server -> Client | `SDLWelcome` | 控制连接建立后服务端首先下发的欢迎包。 |
| `PACKET_ARP_REQUEST` | `0x50` | Client -> Server | `SDLArpRequest` | 查询虚拟网络 IP 对应的 MAC。 |
| `PACKET_ARP_RESPONSE` | `0x51` | Server -> Client | `SDLArpResponse` | ARP 查询响应。 |
| `PACKET_POLICY_REQUEST` | `0xb0` | Client -> Server | `SDLPolicyRequest` | 查询源身份到目标身份的访问规则。 |
| `PACKET_POLICY_REPLY` | `0xb1` | Server -> Client | `SDLPolicyResponse` | 权限规则响应。 |
| `PACKET_EXPOSED_SERVICE_REQUEST` | `0xb2` | Client -> Server | `SDLExposedServiceRequest` | 查询当前节点暴露服务端口列表。 |
| `PACKET_EXPOSED_SERVICE_RESPONSE` | `0xb3` | Server -> Client | `SDLExposedServiceResponse` | 暴露服务端口响应。 |
| `PACKET_STUN_DATA` | `0xff` | Client <-> STUN/Client | `SDLData` | UDP 数据包,支持服务端转发和 P2P 直连。 |
STUN 探测属性同样定义在 `include/sdlan.hrl`
```text
STUN_ATTR_CHANGE_NONE = 0 使用收到请求的同一个 socket 响应
STUN_ATTR_CHANGE_PORT = 1 通过 stun_peer_assist 路径转发响应,通常用于测试变化 IP/端口路径
STUN_ATTR_CHANGE_PEER = 2 通过 stun_port_assist 路径响应,通常用于测试变化端口路径
```
## 4. Protobuf 消息
protobuf 定义以 `proto/sdlan.proto` 为准Erlang 生成文件为 `src/sdlan_pb.erl``include/sdlan_pb.hrl`
### 4.1 公共类型
```protobuf
message SDLV4Info {
uint32 port = 1;
bytes v4 = 2;
uint32 nat_type = 3;
}
message SDLV6Info {
uint32 port = 1;
bytes v6 = 2;
}
```
- `SDLV4Info.port`:服务端观察到的客户端 NAT 端口。
- `SDLV4Info.v4`4 字节 IPv4 地址,网络字节序。
- `SDLV4Info.nat_type`Client 上报的 NAT 类型。
- `SDLV6Info.port`IPv6 辅助通道端口。
- `SDLV6Info.v6`16 字节 IPv6 地址。
### 4.2 NAT 类型约定
```text
0 blocked 网络不可达
1 noNat 当前设备在公网地址下
2 fullCone Full Cone NAT
3 portRestricted Port Restricted Cone NAT
4 coneRestricted Restricted Cone NAT
5 symmetric Symmetric NAT
```
## 5. QUIC 接入
QUIC 接入代码位于 `src/quic`。服务端监听配置来自 `quic_server`
- 默认端口:`443`
- ALPN`punchnet/1.0`
- 服务端允许 1 条双向 stream`peer_bidi_stream_count => 1`
- 默认最大应用包:`max_packet_size = 16384`
- 默认心跳间隔:`heartbeat_sec = 15`
### 5.1 QUIC 传输帧
QUIC 在单条双向 stream 上承载控制面消息。stream 上的实际传输格式为:
```text
QuicStreamData = <<Len:16, Frame:Len/binary>>
Frame = <<PacketType:8, ProtobufPayload/binary>>
```
- `Len`2 字节无符号长度,表示后续 `Frame` 字节数,不包含 `Len` 自身。
- `Frame`:第 2 节定义的通用应用层帧。
- 服务端接收时支持一次 QUIC data 中包含多个完整 frame也支持半包累积。
- 当 `Len > max_packet_size` 时,服务端关闭连接,原因是 `frame_too_large`
### 5.2 QUIC 连接建立流程
1. Client 与服务端完成 QUIC/TLS 握手ALPN 必须匹配 `punchnet/1.0`
2. Client 打开一条双向 stream。
3. 服务端接受第一条 stream 后立即发送 `PACKET_WELCOME`
4. Client 收到 Welcome 后发送 `PACKET_REGISTER_SUPER`
5. 注册成功后服务端返回 `PACKET_REGISTER_SUPER_ACK` 并进入 `registered` 状态。
6. 注册失败时服务端返回 `PACKET_REGISTER_SUPER_NAK`,随后关闭连接。
7. 进入 `registered`Client 必须按 Welcome 中的 `heartbeat_sec` 周期发送 `PACKET_PING`
### 5.3 QUIC Welcome
```text
Server -> Client
<<Len:16, 0x4f:8, SDLWelcome/binary>>
```
```protobuf
message SDLWelcome {
uint32 version = 1;
uint32 max_bidi_streams = 2;
uint32 max_packet_size = 3;
uint32 heartbeat_sec = 4;
SDLV6Info ipv6_assist = 5;
}
```
- `version`:协议版本,当前为 `1`
- `max_bidi_streams`:服务端允许的双向 stream 数,当前为 `1`
- `max_packet_size`:单个应用层 frame 最大长度。
- `heartbeat_sec`心跳间隔Client 应以小于或等于该值的周期发送 Ping。
- `ipv6_assist`IPv6 辅助器地址。未配置时为空。
### 5.4 QUIC RegisterSuper
```text
Client -> Server
<<Len:16, 0x01:8, SDLRegisterSuper/binary>>
```
```protobuf
message SDLRegisterSuper {
string client_id = 1;
uint32 network_id = 2;
bytes mac = 3;
uint32 ip = 4;
uint32 mask_len = 5;
string hostname = 6;
string pub_key = 7;
string access_token = 8;
}
```
- `client_id`:客户端节点 ID不能为空。
- `network_id`:客户端要加入的网络 ID。
- `mac`:虚拟网卡 MAC不能为空且不能是广播或组播 MAC。
- `ip`HTTP 接口已分配的虚拟网络 IPv4按 32 位整数传输。
- `mask_len`:虚拟网络掩码长度。
- `hostname`:客户端主机名,服务端用于维护域名映射。
- `pub_key`:客户端 RSA 公钥 PEM服务端用它加密网络密钥。
- `access_token`:客户端通过 HTTP 登录或令牌换取的访问凭证,服务端注册时调用 API 校验。
注册成功:
```text
Server -> Client
<<Len:16, 0x02:8, SDLRegisterSuperAck/binary>>
```
```protobuf
message SDLRegisterSuperAck {
string algorithm = 1;
bytes key = 2;
uint32 region_id = 3;
bytes session_token = 4;
}
```
- `algorithm`:当前网络加密算法,取值为 `aes``chacha20`
- `key`:使用 `pub_key` 加密后的网络密钥Client 需要用私钥解密。
- `region_id`:服务端根据虚拟 IP 生成的逻辑分区 ID。
- `session_token`:本次控制会话 token。后续 UDP `SDLStunRequest``SDLData` 必须携带,用于校验端和网络绑定关系。
注册失败:
```text
Server -> Client
<<Len:16, 0x04:8, SDLRegisterSuperNak/binary>>
```
```protobuf
message SDLRegisterSuperNak {
uint32 error_code = 1;
string error_message = 2;
}
```
- `error_code`:错误码。网络/API 错误当前使用 `4`,服务内部错误当前使用 `5`API 返回业务错误时透传 API code。
- `error_message`:错误描述。服务端发送 NAK 后关闭连接。
### 5.5 QUIC 心跳
```text
Client -> Server
<<Len:16, 0x08:8>>
Server -> Client
<<Len:16, 0x09:8>>
```
- Client 只在注册成功后发送 Ping。
- 服务端收到 Ping 后立即回复 Pong并记录本轮心跳已收到。
- 服务端按 `heartbeat_sec` 检查上一周期是否收到过 Ping如果没有收到关闭连接。
### 5.6 QUIC 查询 PeerInfo
```text
Client -> Server
<<Len:16, 0x06:8, SDLQueryInfo/binary>>
```
```protobuf
message SDLQueryInfo {
bytes dst_mac = 1;
}
```
- `dst_mac`:目标节点 MAC。
- 服务端查找目标节点的 NAT 映射和 IPv6 信息。
- 如果目标存在,服务端同时会向目标节点推送 `SDLEvent.SendRegister`,让目标主动向查询方发送打洞包,提高 P2P 成功率。
响应:
```text
Server -> Client
<<Len:16, 0x07:8, SDLPeerInfo/binary>>
```
```protobuf
message SDLPeerInfo {
bytes dst_mac = 1;
optional SDLV4Info v4_info = 2;
optional SDLV6Info v6_info = 3;
}
```
- `dst_mac`:原样返回查询的目标 MAC。
- `v4_info`:目标节点最近一次 `STUN_REQUEST` 上报形成的公网 IPv4/端口和 NAT 类型。
- `v6_info`:目标节点上报的 IPv6 辅助信息。没有时为空。
- 找不到目标或目标没有可用 NAT 信息时,服务端仍返回 `SDLPeerInfo`,但 `v4_info``v6_info` 为空。
### 5.7 QUIC ARP 查询
```text
Client -> Server
<<Len:16, 0x50:8, SDLArpRequest/binary>>
```
```protobuf
message SDLArpRequest {
uint32 target_ip = 1;
uint32 origin_ip = 2;
bytes context = 3;
}
```
- `target_ip`:要查询 MAC 的虚拟网络 IPv4。
- `origin_ip`:发起查询的虚拟网络 IPv4。
- `context`Client 自定义上下文,服务端原样回写,方便客户端匹配本地 ARP 请求。
响应:
```text
Server -> Client
<<Len:16, 0x51:8, SDLArpResponse/binary>>
```
```protobuf
message SDLArpResponse {
uint32 target_ip = 1;
bytes target_mac = 2;
uint32 origin_ip = 3;
bytes context = 4;
}
```
- `target_mac`:查询成功时为目标 MAC查询失败时为空字节串。
- 其它字段与请求一致或对应请求上下文。
### 5.8 QUIC Policy 查询
```text
Client -> Server
<<Len:16, 0xb0:8, SDLPolicyRequest/binary>>
```
```protobuf
message SDLPolicyRequest {
uint32 src_identity_id = 1;
uint32 dst_identity_id = 2;
uint32 version = 3;
}
```
- `src_identity_id`:源端身份 ID。
- `dst_identity_id`:目标端身份 ID。
- `version`Client 侧规则版本服务端原样回写Client 用于判断是否覆盖本地缓存。
响应:
```text
Server -> Client
<<Len:16, 0xb1:8, SDLPolicyResponse/binary>>
```
```protobuf
message SDLPolicyResponse {
uint32 src_identity_id = 1;
uint32 dst_identity_id = 2;
uint32 version = 3;
bytes rules = 4;
}
```
- `rules`:稀疏序列化规则列表,每条规则格式为 `<<Proto:8, Port:16>>`
- `Proto`:协议号,例如 TCP/UDP 对应的协议编号。
- `Port`:允许访问的目标端口。
- 服务端只下发 allow 规则deny 规则在服务端侧已过滤。
- 如果请求解析或规则查询失败,当前服务端可能不返回响应包。
### 5.9 QUIC ExposedService 查询
```text
Client -> Server
<<Len:16, 0xb2:8, SDLExposedServiceRequest/binary>>
```
```protobuf
message SDLExposedServiceRequest {
uint32 version = 1;
}
```
- `version`Client 当前缓存版本,服务端响应中原样返回。
响应:
```text
Server -> Client
<<Len:16, 0xb3:8, SDLExposedServiceResponse/binary>>
```
```protobuf
message SDLExposedServiceResponse {
uint32 version = 1;
repeated uint32 tcp_ports = 2;
repeated uint32 udp_ports = 3;
}
```
- `tcp_ports`:当前节点允许暴露的 TCP 端口列表。
- `udp_ports`:当前节点允许暴露的 UDP 端口列表。
- 服务端只返回 `1..65534` 范围内的端口。
- API 查询失败时当前服务端可能不返回响应包。
### 5.10 QUIC Event 推送
```text
Server -> Client
<<Len:16, 0x10:8, SDLEvent/binary>>
```
```protobuf
message SDLEvent {
message NatChanged {
bytes mac = 1;
uint32 ip = 2;
}
message SendRegister {
bytes dst_mac = 1;
uint32 nat_ip = 2;
uint32 nat_port = 3;
uint32 nat_type = 4;
optional SDLV6Info v6_info = 5;
}
message ExposedServiceChanged {
}
message NetworkShutdown {
string message = 1;
}
oneof event {
NatChanged nat_changed = 1;
SendRegister send_register = 2;
NetworkShutdown shutdown = 3;
ExposedServiceChanged exposed_service_changed = 4;
}
}
```
- `nat_changed`:某个 MAC 的虚拟 IP 或 NAT 映射发生变化。Client 应清理对应 peer 缓存、ARP 缓存或重新查询 peer 信息。
- `send_register`:服务端要求当前 Client 向 `dst_mac` 对应节点发送 UDP `PACKET_REGISTER` 打洞请求。`nat_ip/nat_port/nat_type/v6_info` 是目标可达地址信息。
- `exposed_service_changed`当前节点暴露服务配置变化。Client 应重新发送 `PACKET_EXPOSED_SERVICE_REQUEST` 获取端口列表。
- `shutdown`网络关闭。Client 应停止当前网络会话并释放本地资源。
- Event 不需要 Client 回复 Ack。
### 5.11 QUIC Command 下发和 ACK
```text
Server -> Client
<<Len:16, 0x11:8, SDLCommand/binary>>
```
```protobuf
message SDLCommand {
uint32 pkt_id = 1;
message ExitNodeControl {
int32 action = 1;
string remark = 2;
}
oneof command {
ExitNodeControl exit_node = 2;
}
}
```
- `pkt_id`服务端生成的命令序号Client 回复时必须原样带回。
- `exit_node.action`:出口节点控制动作,具体动作值由业务层约定。
- `exit_node.remark`:操作备注或调试说明。
Client 处理完成后回复:
```text
Client -> Server
<<Len:16, 0x12:8, SDLCommandAck/binary>>
```
```protobuf
message SDLCommandAck {
uint32 pkt_id = 1;
int32 code = 2;
string message = 3;
bytes data = 4;
}
```
- `pkt_id`:必须与收到的 `SDLCommand.pkt_id` 一致。
- `code`:处理结果码,建议 `0` 表示成功,非 `0` 表示失败。
- `message`:处理结果说明。
- `data`:可选业务数据。
- 服务端收到 ACK 后,会把结果转发给等待该命令响应的内部调用方。
### 5.12 QUIC Unregister
```text
Client -> Server
<<Len:16, 0x05:8>>
```
- Client 主动退出网络时发送。
- 服务端收到后调用 `sdlan_network:unregister/3` 清理当前 `client_id + mac` 的绑定,并关闭控制连接。
## 6. SSL/TLS 接入
SSL/TLS 接入代码位于 `src/ssl`。服务端通过 Ranch SSL 监听,配置来自 `ssl_server`
- 默认端口:`1443`
- ALPN`punchnet/1.0`
- TLS 版本:`tlsv1.3``tlsv1.2`
- 默认最大应用包:`max_packet_size = 16384`
- 默认心跳间隔:`heartbeat_sec = 15`
### 6.1 SSL/TLS 传输帧
SSL/TLS 接入使用 Erlang socket `{packet, 2}` 分包。对非 Erlang Client 来说,线上格式仍然是:
```text
TlsStreamData = <<Len:16, Frame:Len/binary>>
Frame = <<PacketType:8, ProtobufPayload/binary>>
```
- `Len`2 字节无符号长度,表示后续 `Frame` 字节数,不包含 `Len` 自身。
- 服务端 `ssl` 层会自动剥离 `Len` 后把 `Frame` 交给 `sdlan_session`
- 服务端发送时也依赖 `{packet, 2}` 自动加长度前缀。
- 单个发送包长度必须不超过 `65535`,业务上应遵守 Welcome 中的 `max_packet_size`
### 6.2 SSL/TLS 连接建立流程
1. Client 与服务端建立 TCP 连接。
2. Client 完成 TLS 握手ALPN 使用 `punchnet/1.0`
3. 服务端 TLS 握手成功后立即发送 `PACKET_WELCOME`
4. Client 收到 Welcome 后发送 `PACKET_REGISTER_SUPER`
5. 注册成功后服务端返回 `PACKET_REGISTER_SUPER_ACK` 并进入 `registered` 状态。
6. 注册失败时服务端返回 `PACKET_REGISTER_SUPER_NAK`,随后关闭连接。
7. 进入 `registered`Client 必须按 Welcome 中的 `heartbeat_sec` 周期发送 `PACKET_PING`
### 6.3 SSL/TLS 消息交互
SSL/TLS 接入的应用层消息与 QUIC 完全一致,区别只在传输层:
- Welcome`5.3`,包类型 `0x4f`
- RegisterSuper / RegisterSuperAck / RegisterSuperNak`5.4`,包类型 `0x01/0x02/0x04`
- Ping / Pong`5.5`,包类型 `0x08/0x09`
- QueryInfo / PeerInfo`5.6`,包类型 `0x06/0x07`
- ARP 查询:见 `5.7`,包类型 `0x50/0x51`
- Policy 查询:见 `5.8`,包类型 `0xb0/0xb1`
- ExposedService 查询:见 `5.9`,包类型 `0xb2/0xb3`
- Event 推送:见 `5.10`,包类型 `0x10`
- Command / CommandAck`5.11`,包类型 `0x11/0x12`
- Unregister`5.12`,包类型 `0x05`
Client 如果同时支持 QUIC 和 SSL/TLS建议抽象出同一个应用层 `Frame` 编解码模块,仅替换底层连接、握手和 stream/socket 读写逻辑。
## 7. UDP STUN 和数据转发
UDP 逻辑不属于 `src/quic``src/ssl`,但它依赖控制面注册返回的 `session_token` 和网络密钥,是 Client 完整接入流程的一部分。相关服务端实现位于:
- `src/sdlan_stun.erl`
- `src/sdlan_stun_port_assist.erl`
- `src/sdlan_stun_peer_assist.erl`
UDP 包没有 2 字节长度前缀:
```text
UdpPacket = <<PacketType:8, ProtobufPayload/binary>>
```
### 7.1 STUN Request
```text
Client -> STUN
<<0x30:8, SDLStunRequest/binary>>
```
```protobuf
message SDLStunRequest {
string client_id = 1;
uint32 network_id = 2;
bytes mac = 3;
uint32 ip = 4;
uint32 nat_type = 5;
optional SDLV6Info v6_info = 6;
bytes session_token = 7;
}
```
- `client_id/network_id/mac/ip`:必须与控制面 `RegisterSuper` 一致。
- `nat_type`Client 通过 `STUN_PROBE` 判断出的 NAT 类型。
- `v6_info`Client 可用的 IPv6 辅助信息,没有时为空。
- `session_token`:控制面 `RegisterSuperAck.session_token`,服务端用它校验当前 Client 的 NAT 上报。
- 服务端以 UDP 源地址和源端口作为该节点的最新 NAT 映射,并通知其它节点 `nat_changed`
响应:
```text
STUN -> Client
<<0x31:8, SDLStunReply/binary>>
```
```protobuf
message SDLStunReply {
}
```
- 空消息体,仅表示服务端已收到并处理 STUN 心跳。
- Client 需要周期性发送该包维持 NAT 映射,建议周期小于 NAT 超时时间。
### 7.2 STUN Probe
```text
Client -> STUN
<<0x32:8, SDLStunProbe/binary>>
```
```protobuf
message SDLStunProbe {
uint32 cookie = 1;
uint32 attr = 2;
uint32 step = 3;
}
```
- `cookie`Client 生成的随机值,用于匹配响应。
- `attr`:探测属性,取值见第 3 节 `STUN_ATTR_*`
- `step`Client 侧探测步骤编号。当前 `sdlan_stun` 主响应代码未写回该字段,响应里通常为 protobuf 默认值 `0`Client 需要兼容。
响应:
```text
STUN -> Client
<<0x33:8, SDLStunProbeReply/binary>>
```
```protobuf
message SDLStunProbeReply {
uint32 cookie = 1;
uint32 step = 2;
uint32 port = 3;
uint32 ip = 4;
}
```
- `cookie`:原样返回请求中的 `cookie`
- `step`:当前实现通常为默认值 `0`
- `port`:服务端看到的 Client 公网 UDP 源端口。
- `ip`:服务端看到的 Client 公网 IPv4按 32 位整数传输。
典型 NAT 判断流程:
1. 向主 STUN 地址发送 `attr = 0`,拿到公网地址 A。
2. 如果公网地址 A 等于本地 UDP 地址,则为 `noNat`
3. 向另一组 STUN 地址发送 `attr = 0`,拿到公网地址 B。
4. 如果 A 和 B 的 IP 不同,则倾向判断为 `symmetric`
5. 向主 STUN 地址发送 `attr = 1`,如果能收到辅助 peer 路径响应,则说明 NAT 对来源 IP/端口限制较少。
6. 向主 STUN 地址发送 `attr = 2`,如果能收到辅助端口路径响应,则说明 NAT 对来源端口限制较少;如果收不到则按端口限制型或降级策略处理。
7. 任何关键步骤无响应时,可按 `blocked` 或降级策略处理。
### 7.3 Client 间 UDP 打洞
当 Client 通过 `QUERY_INFO``send_register` 事件拿到对端 NAT 地址后,向对端 UDP 地址发送:
```text
Client A -> Client B
<<0x20:8, SDLRegister/binary>>
```
```protobuf
message SDLRegister {
uint32 network_id = 1;
bytes src_mac = 2;
bytes dst_mac = 3;
}
```
- `network_id`:当前虚拟网络 ID。
- `src_mac`:发送方 MAC。
- `dst_mac`:目标方 MAC。
- 该包用于在双方 NAT 设备上建立映射,不经过控制连接。
对端收到后回复:
```text
Client B -> Client A
<<0x21:8, SDLRegisterAck/binary>>
```
```protobuf
message SDLRegisterAck {
uint32 network_id = 1;
bytes src_mac = 2;
bytes dst_mac = 3;
}
```
- `src_mac`ACK 发送方 MAC。
- `dst_mac`ACK 目标方 MAC。
- 收到 ACK 后Client 可以优先使用 P2P 直连发送 `SDLData`
### 7.4 UDP 数据包
```text
Client -> STUN/Peer
<<0xff:8, SDLData/binary>>
```
```protobuf
message SDLData {
uint32 network_id = 1;
bytes src_mac = 2;
bytes dst_mac = 3;
bool is_p2p = 4;
uint32 ttl = 5;
bytes data = 6;
bytes session_token = 7;
uint32 identity_id = 8;
}
```
- `network_id`:当前虚拟网络 ID。
- `src_mac`:发送方 MAC。
- `dst_mac`:目标 MAC。广播或组播 MAC 会触发服务端向网络内其它在线节点广播转发。
- `is_p2p`Client 直连发送时为 `true`;服务端转发时会改写为 `false`
- `ttl`转发跳数。STUN 服务端转发前会执行 `ttl - 1`
- `data`:加密后的真实业务数据。
- `session_token`:控制面注册返回的会话 token。
- `identity_id`:源端身份 ID对端用于本地权限判断。
服务端转发逻辑:
1. STUN 服务端收到 `PACKET_STUN_DATA` 后解析 `SDLData`
2. 按 `network_id` 找到当前网络 ETS 表。
3. 校验 `src_mac` 当前在线且存在 endpoint。
4. 如果 `dst_mac` 是广播或组播,转发给除 `src_mac` 外所有有 NAT hole 的 endpoint。
5. 如果 `dst_mac` 是单播,查找目标 endpoint 的 NAT hole 并转发。
6. 转发前将 `ttl` 减 1并把 `is_p2p` 改为 `false`
7. 服务端按网络带宽限制做限流;限流或找不到目标时丢弃。
## 8. 推荐 Client 完整接入流程
1. 通过 HTTP/API 完成登录和网络/IP 分配,拿到 `access_token``network_id`、虚拟 IP、掩码、身份 ID 等信息。
2. 优先尝试 QUIC 接入;如果网络环境不支持 QUIC可降级到 SSL/TLS 接入。
3. 完成控制连接握手并接收 `PACKET_WELCOME`
4. 发送 `PACKET_REGISTER_SUPER`
5. 收到 `PACKET_REGISTER_SUPER_ACK` 后,用私钥解密 `key`,保存 `algorithm``region_id``session_token`
6. 启动控制连接心跳,按 `heartbeat_sec` 周期发送 `PACKET_PING` 并处理 `PACKET_PONG`
7. 使用 `PACKET_STUN_PROBE` 判断 NAT 类型。
8. 周期性发送 `PACKET_STUN_REQUEST`,上报 NAT 类型、IPv6 信息和 `session_token`
9. 有目标通信需求时,先使用 `PACKET_ARP_REQUEST` 或本地缓存确定目标 MAC。
10. 通过 `PACKET_POLICY_REQUEST` 查询访问策略,按 `rules` 在本地做放行判断。
11. 通过 `PACKET_QUERY_INFO` 查询目标 NAT 信息,并根据 `SDLEvent.SendRegister` 做双向 UDP 打洞。
12. P2P 打洞成功后优先直连发送 `PACKET_STUN_DATA`;失败时通过 STUN 服务端转发。
13. 处理服务端推送的 `SDLEvent``SDLCommand`。Command 必须回复 `SDLCommandAck`
14. Client 主动退出时发送 `PACKET_UNREGISTER` 并关闭本地控制连接和 UDP 资源。
===端口说明====
组件 默认端口/配置 」 说明 」
QUIC | 443 | ALPN `punchnet/1.0` |控制面主入口,允许 1条双向 stream。
SSL/TLS | 1443 | ALPN `punchnet/1.0` |控制面备用入口,与 QUIC 共用`sdlan session`
STUN | 1365 | `acceptor_nums=5` |UDP_NAT 上报、探测和服务端转发
STUN port assist | 1366 |同节点变端 probe reply
STUN peer assist |配置的 `punchnet.aioe.tech:1366`|转发到另一辅助节点后回复。
IPv6 assist | 1367 |IPv6 可达性探测。

View File

@ -1,3 +0,0 @@
# punchnet-android
android client of punch net

112
Sign.md Normal file
View File

@ -0,0 +1,112 @@
# 接口签名算法说明
## 概述
本接口使用 **HMAC-MD5** 算法对请求参数进行签名,以确保请求的合法性与完整性,防止请求被篡改或伪造。
生成的sign值放在header中key=X-signvalue=加密值。
---
## 签名生成流程
```
请求参数
① 参数递归排序ksort
② 构造签名字符串key=value&key=value...
│ 数组/对象类型 → JSON 字符串
③ HMAC-MD5(query_string, secret_key)
sign签名结果
```
---
## 详细步骤
### 第一步:参数递归排序
对所有请求参数按照**键名字典序ksort进行递归排序**,嵌套数组也需同样处理,以保证双方构造字符串的顺序一致。
### 第二步:构造签名字符串
遍历排序后的参数,按以下规则拼接为字符串:
| 参数类型 | 处理方式 |
| ------------------------ | -------------------------------------------------------- |
| 普通值(字符串、数字等) | 直接使用原始值 |
| 数组 / 对象 | 转为 JSON 字符串(中文不转义,`JSON_UNESCAPED_UNICODE` |
拼接格式:
```
key1=value1&key2=value2&key3=value3
```
> ⚠️ **注意**:参数值**不做 URL encode**,与 `http_build_query` 的行为不同。数组参数也**不展开**为 `key[0]=...` 形式,而是整体转为 JSON 字符串。
### 第三步:计算签名
使用 `secret_key` 对上一步得到的字符串执行 **HMAC-MD5** 计算:
```
sign = HMAC-MD5(query_string, secret_key)
```
---
## 示例
### 原始请求参数
```json
{
"uid": 1001,
"amount": 100,
"currency": "CNY",
"items": [
{ "id": 1, "name": "商品A" }
]
}
```
### 第一步:排序后的参数顺序
```
amount, currency, items, uid
```
### 第二步:构造签名字符串
```
amount=100&currency=CNY&items=[{"id":1,"name":"商品A"}]&uid=1001
```
### 第三步:计算签名
```
sign = HMAC-MD5("amount=100&currency=CNY&items=[{\"id\":1,\"name\":\"商品A\"}]&uid=1001", "your_secret_key")
```
---
## 注意事项
| 项目 | 说明 |
| -------- | -------------------------------------------------------- |
| 排序方式 | 字典序递归排序,嵌套数组也要排序 |
| 数组参数 | 转为 JSON 字符串,中文不转义(`JSON_UNESCAPED_UNICODE` |
| URL 编码 | 参数值**不做** URL encode |
| 数组展开 | **不展开**为 `key[0]=...` 形式 |
| 密钥管理 | `secret_key` 需双方提前约定,**严禁在请求中传输** |
| 算法 | HMAC-MD5输出为 32 位十六进制小写字符串 |
---
密钥: H6p*2RfEu4ITcL

1
app/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

107
app/build.gradle.kts Normal file
View File

@ -0,0 +1,107 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
id("com.google.protobuf") version "0.9.4"
id("com.google.devtools.ksp") version "2.0.0-1.0.22"
}
android {
namespace = "com.jihe.punchnet"
compileSdk = 35
packagingOptions {
exclude("META-INF/versions/9/OSGI-INF/MANIFEST.MF")
}
defaultConfig {
applicationId = "com.jihe.punchnet"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = "11"
}
buildFeatures {
compose = true
}
}
protobuf {
protoc {
artifact = "com.google.protobuf:protoc:4.29.3"
}
generateProtoTasks {
all().forEach {
task -> task.builtins {
create("kotlin")
create("java")
}
}
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
implementation("com.google.protobuf:protobuf-java:4.29.3")
implementation("com.google.protobuf:protobuf-kotlin:4.29.3")
implementation("androidx.navigation:navigation-compose:2.9.0")
implementation("io.coil-kt:coil-compose:2.6.0")
implementation("androidx.room:room-runtime:2.7.2")
ksp("androidx.room:room-compiler:2.7.2")
implementation("androidx.room:room-ktx:2.7.2")
// HTTP API Control Plane
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.google.code.gson:gson:2.10.1")
// QUIC Data Plane (Kwik)
implementation("tech.kwik:kwik:0.10.3")
implementation(libs.bcprov.jdk18on)
implementation(libs.bcpkix.jdk18on)
// implementation(files("libs/org.asxalex.sdlan/sdlan-1.0-SNAPSHOT.jar"))
// implementation(files("libs/org.asxalex.sdlan/1.0.0/sdlan-1.0.0.jar"))
// Security Crypto
implementation("androidx.security:security-crypto:1.1.0-alpha06")
}

21
app/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

BIN
app/release/app-release.apk Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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

View File

@ -0,0 +1,22 @@
package com.jihe.punchnet
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.jihe.punchnet", appContext.packageName)
}
}

View File

@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"/>
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@drawable/punchnet_log"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Punchnet"
android:usesCleartextTraffic="true"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.Punchnet">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service android:name=".PunchnetService"
android:exported="true"
android:foregroundServiceType="specialUse"
android:permission="android.permission.BIND_VPN_SERVICE">
<intent-filter>
<action android:name="android.net.VpnService"/>
</intent-filter>
</service>
</application>
</manifest>

View File

@ -0,0 +1,200 @@
package com.jihe.punchnet
import android.app.Application
import android.content.Context
import android.content.Intent
import android.net.VpnService
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.compose.rememberNavController
import com.jihe.punchnet.data.ButtonState
import com.jihe.punchnet.data.ButtonViewModel
import com.jihe.punchnet.data.RouteViewModel
import com.jihe.punchnet.data.ServiceViewModel
import com.jihe.punchnet.screen.MainApp
import com.jihe.punchnet.ui.theme.PunchnetTheme
import kotlinx.coroutines.flow.onEach
class PunchnetApp: Application() {
val buttonModel: ButtonViewModel by lazy {
ViewModelProvider.AndroidViewModelFactory.getInstance(this)
.create(ButtonViewModel::class.java)
}
}
class MainActivity : ComponentActivity() {
private val TAG = "MainActivity"
private val viewModel: RouteViewModel by viewModels {
object: ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun<T: ViewModel> create(modelClass: Class<T>): T {
return RouteViewModel(application) as T
}
}
}
private val serviceModel: ServiceViewModel by viewModels {
object: ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun<T: ViewModel> create(modelClass: Class<T>): T {
return ServiceViewModel(application) as T
}
}
}
/*
val buttonModel: ButtonViewModel by viewModels {
object: ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun<T: ViewModel> create(modelClass: Class<T>): T {
return ButtonViewModel(application) as T
}
}
}
*/
/*
fun prepareAndStartVPN() {
val intent = VpnService.prepare(this)
if (intent != null) {
vpnPermissionLauncher.launch(intent)
} else {
startVpnService(this)
}
}
*/
/*
private fun startVpnService(context: Context) {
val intent = Intent(context, PunchnetService::class.java)
intent.putExtra("argument", PunchnetServiceArgument(
"",
arrayOf(
RouteInfo(
"192.168.80.0/24", "10.211.188.2"
)
)
))
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(intent)
} else {
startService(intent)
}
Toast.makeText(this, "VPN service started", Toast.LENGTH_SHORT).show()
}
*/
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Initialize AppAuthRepository for Split Tunneling
com.jihe.punchnet.data.AppAuthRepository.init(this)
enableEdgeToEdge()
Log.d("DIR", "filesdir = ${this.filesDir}")
val buttonModel = ButtonViewModel()
buttonModel.updateState(ButtonState.ButtonStopped)
setContent {
PunchnetTheme {
MainApp(buttonModel, serviceModel, viewModel, rememberNavController())
/*
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
StartStop(
R.string.start_vpn,
R.string.stop_vpn,
onStart = {
prepareAndStartVPN()
},
onStop = {
// stopVpnService()
},
modifier = Modifier.padding(innerPadding)
)
}
*/
}
}
}
}
@Composable
fun StartStop(
@StringRes start_name_id: Int,
@StringRes stop_name_id: Int,
onStart: ()->Unit,
onStop: ()->Unit,
modifier: Modifier = Modifier,
) {
val start_text = stringResource(start_name_id)
val stop_text = stringResource(stop_name_id)
var button_state by rememberSaveable { mutableStateOf(false) }
var button_text by rememberSaveable() { mutableStateOf(start_text) }
Button(
modifier = modifier,
onClick = {
button_state = !button_state
if (button_state) {
onStart()
button_text = stop_text
} else {
Log.d("STOP", "call on stop")
onStop()
button_text = start_text
}
}
) {
Text (
text = button_text
)
}
}
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
Text(
text = "Hello $name!",
modifier = modifier
)
}
@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
PunchnetTheme {
Greeting("Android")
}
}

View File

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

View File

@ -0,0 +1,58 @@
package com.jihe.punchnet
import android.os.Parcel
import android.os.Parcelable
import com.jihe.punchnet.data.RouteItem
data class RouteInfo(
// 192.168.80.0/24
val targetNetCIDR: String,
// 10.167.69.2
val gateway: String,
)
data class PunchnetServiceArgument(
val token: String,
val routes: Array<RouteItem>,
): Parcelable {
override fun writeToParcel(dest: Parcel, flags: Int) {
dest.writeString(token)
dest.writeInt(routes.size)
routes.forEach { item ->
dest.writeLong(item.id)
dest.writeInt(item.net_ip)
dest.writeInt(item.mask_ip)
dest.writeInt(item.gateway)
}
}
override fun describeContents(): Int {
return 0
}
companion object CREATOR: Parcelable.Creator<PunchnetServiceArgument> {
override fun createFromParcel(source: Parcel): PunchnetServiceArgument {
val token = source.readString()!!
val size = source.readInt()
var routes = mutableListOf<RouteItem>()
for (i in 0..<size) {
val id = source.readLong()!!
val netIP = source.readInt()!!
val maskIP = source.readInt()!!
val gateway = source.readInt()!!
routes.add(RouteItem(
id = id,
net_ip = netIP,
mask_ip = maskIP,
gateway = gateway,
))
}
return PunchnetServiceArgument(token, routes.toTypedArray())
}
override fun newArray(size: Int): Array<PunchnetServiceArgument?> {
return arrayOfNulls(size)
}
}
}

View File

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

View File

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

View File

@ -0,0 +1,91 @@
package com.jihe.punchnet.api
import com.google.gson.GsonBuilder
import com.google.gson.JsonParser
import okhttp3.Interceptor
import okhttp3.Response
import okio.Buffer
import java.nio.charset.StandardCharsets
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
class SignInterceptor : Interceptor {
private val secretKey = "H6p*2RfEu4ITcL"
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
val body = originalRequest.body
if (originalRequest.method == "POST" && body != null && body.contentType()?.subtype == "json") {
try {
// Read original JSON body
val buffer = Buffer()
body.writeTo(buffer)
val jsonStr = buffer.readUtf8()
// Parse into JsonObject
val jsonElement = JsonParser.parseString(jsonStr)
if (jsonElement.isJsonObject) {
val jsonObject = jsonElement.asJsonObject
// Sort keys
val sortedKeys = jsonObject.keySet().sorted()
// Build query string
val queryStringBuilder = StringBuilder()
val gson = GsonBuilder().disableHtmlEscaping().create()
for (key in sortedKeys) {
val element = jsonObject.get(key)
if (element.isJsonNull) continue
if (queryStringBuilder.isNotEmpty()) {
queryStringBuilder.append("&")
}
queryStringBuilder.append(key).append("=")
if (element.isJsonPrimitive) {
val primitive = element.asJsonPrimitive
if (primitive.isString) {
queryStringBuilder.append(primitive.asString)
} else {
// numbers and booleans
queryStringBuilder.append(primitive.asString)
}
} else {
// Object or Array - convert to JSON string (JSON_UNESCAPED_UNICODE is default in Gson if disableHtmlEscaping is used)
queryStringBuilder.append(gson.toJson(element))
}
}
val queryString = queryStringBuilder.toString()
// Calculate HMAC-MD5
val sign = calculateHmacMD5(queryString, secretKey)
// Inject sign into HTTP Header X-sign
val newRequest = originalRequest.newBuilder()
.addHeader("X-sign", sign)
.post(body)
.build()
return chain.proceed(newRequest)
}
} catch (e: Exception) {
e.printStackTrace()
}
}
return chain.proceed(originalRequest)
}
private fun calculateHmacMD5(data: String, key: String): String {
val algorithm = "HmacMD5"
val mac = Mac.getInstance(algorithm)
val secretKeySpec = SecretKeySpec(key.toByteArray(StandardCharsets.UTF_8), algorithm)
mac.init(secretKeySpec)
val hashBytes = mac.doFinal(data.toByteArray(StandardCharsets.UTF_8))
return hashBytes.joinToString("") { "%02x".format(it) }
}
}

View File

@ -0,0 +1,35 @@
package com.jihe.punchnet.data
import android.content.Context
import androidx.core.content.edit
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
object AppAuthRepository {
private const val PREFS_NAME = "punchnet_app_auth"
private const val KEY_ALLOWED_APPS = "allowed_apps"
private val _allowedApps = MutableStateFlow<Set<String>>(emptySet())
val allowedApps: StateFlow<Set<String>> = _allowedApps
fun init(context: Context) {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val savedApps = prefs.getStringSet(KEY_ALLOWED_APPS, emptySet()) ?: emptySet()
_allowedApps.value = savedApps
}
fun toggleApp(context: Context, packageName: String, isAllowed: Boolean) {
val current = _allowedApps.value.toMutableSet()
if (isAllowed) {
current.add(packageName)
} else {
current.remove(packageName)
}
_allowedApps.value = current
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit {
putStringSet(KEY_ALLOWED_APPS, current)
}
}
}

View File

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

View File

@ -0,0 +1,56 @@
package com.jihe.punchnet.data
import com.jihe.punchnet.api.NodeItem
import com.jihe.punchnet.api.ResourceItem
import com.jihe.punchnet.api.ExitNodeItem
import com.jihe.punchnet.api.ConnectResponseData
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
data class LocalProfileInfo(
val ip: String,
val maskLen: Int,
val hostname: String,
val identityId: Int
)
object NodeRepository {
private val _nodes = MutableStateFlow<List<NodeItem>>(emptyList())
val nodes: StateFlow<List<NodeItem>> = _nodes
private val _resources = MutableStateFlow<List<ResourceItem>>(emptyList())
val resources: StateFlow<List<ResourceItem>> = _resources
private val _exitNodes = MutableStateFlow<List<ExitNodeItem>>(emptyList())
val exitNodes: StateFlow<List<ExitNodeItem>> = _exitNodes
private val _localProfile = MutableStateFlow<LocalProfileInfo?>(null)
val localProfile: StateFlow<LocalProfileInfo?> = _localProfile
fun updateAll(data: ConnectResponseData?) {
if (data == null) {
clear()
return
}
_nodes.value = data.nodeList ?: emptyList()
_resources.value = data.resourceList ?: emptyList()
_exitNodes.value = data.exitNode ?: emptyList()
_localProfile.value = LocalProfileInfo(
ip = data.ip,
maskLen = data.maskLen,
hostname = data.hostname,
identityId = data.identityId
)
}
fun updateNodes(newNodes: List<NodeItem>?) {
_nodes.value = newNodes ?: emptyList()
}
fun clear() {
_nodes.value = emptyList()
_resources.value = emptyList()
_exitNodes.value = emptyList()
_localProfile.value = null
}
}

View File

@ -0,0 +1,111 @@
package com.jihe.punchnet.data
import android.app.Application
import android.content.Context
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import androidx.room.ColumnInfo
import androidx.room.Dao
import androidx.room.Database
import androidx.room.Delete
import androidx.room.Entity
import androidx.room.Insert
import androidx.room.PrimaryKey
import androidx.room.Query
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.Update
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
@Entity(tableName = "routes")
data class RouteItem (
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
@ColumnInfo(name = "net_ip")
val net_ip: Int,
// mask int, Intof(255,255,255,0
@ColumnInfo(name = "mask_ip")
val mask_ip: Int,
@ColumnInfo(name = "gateway")
val gateway: Int,
)
@Dao
interface RouteDAO {
@Insert
suspend fun insert(item: RouteItem): Long
@Update
suspend fun update(item: RouteItem)
@Query("DELETE FROM routes WHERE id = :id")
suspend fun deleteById(id: Long)
@Query("SELECT * FROM routes")
fun getAll(): Flow<List<RouteItem>>
}
@Database(entities = [RouteItem::class], version = 1)
abstract class AppDatabase: RoomDatabase() {
abstract fun routeDao(): RouteDAO
companion object {
@Volatile
private var INSTANCE: AppDatabase? = null
fun getDatabase(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"app_database"
)
.fallbackToDestructiveMigration()
.build()
INSTANCE = instance
instance
}
}
}
}
class RouteRepository(private val routeDao: RouteDAO) {
val all_routes: Flow<List<RouteItem>> = routeDao.getAll()
suspend fun insert(item: RouteItem) {
routeDao.insert(item)
}
suspend fun update(item: RouteItem) {
routeDao.update(item)
}
suspend fun deleteById(id: Long) {
routeDao.deleteById(id)
}
}
class RouteViewModel(application: Application): AndroidViewModel(application) {
private val repository: RouteRepository
init {
val routeDao = AppDatabase.getDatabase(application).routeDao()
repository = RouteRepository(routeDao)
}
val allRoutes: Flow<List<RouteItem>> = repository.all_routes
fun insert(item: RouteItem) = viewModelScope.launch {
repository.insert(item)
}
fun update(item: RouteItem) = viewModelScope.launch {
repository.update(item)
}
fun deleteById(id: Long) = viewModelScope.launch {
repository.deleteById(id)
}
}

View File

@ -0,0 +1,69 @@
package com.jihe.punchnet.data
import android.app.Activity.RESULT_CANCELED
import android.app.Activity.RESULT_OK
import android.app.Application
import android.content.Context
import android.content.Intent
import android.net.VpnService
import android.os.Build
import android.util.Log
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.core.content.ContextCompat.startForegroundService
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.jihe.punchnet.PunchnetService
import com.jihe.punchnet.PunchnetServiceArgument
import com.jihe.punchnet.RouteInfo
import com.jihe.punchnet.sdlan.utils.ipToString
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.forEach
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
class ServiceViewModel(application: Application): AndroidViewModel(application) {
/*
private val _isRunning = mutableStateOf(false)
val isRunning: State<Boolean> = _isRunning
*/
fun startService(context: Context, routes: Array<RouteItem>, token: String) {
// should has the permission
startVpnService(context, routes, token)
// ButtonRepository.updateState(ButtonState.ButtonStarting)
// _isRunning.value = true
}
private fun startVpnService(context: Context, routes: Array<RouteItem>, token: String) {
val intent = Intent(context, PunchnetService::class.java)
intent.putExtra("argument", PunchnetServiceArgument(
token,
routes,
))
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
Toast.makeText(context, "VPN service started", Toast.LENGTH_SHORT).show()
}
fun stopVpnService(context: Context) {
Log.d("STOP PUNCHNET", "stopping PUNCHNET")
context.startService(Intent(context, PunchnetService::class.java).also { it.action=
PunchnetService.ACTION_DISCONNECT })
// stopService(Intent(this, PunchnetService::class.java))
Log.d("STOPPED PUNCHNET", "stopping PUNCHNET")
// ButtonRepository.updateState(ButtonState.ButtonStopping)
// Toast.makeText(this, "VPN service stopped", Toast.LENGTH_SHORT).show()
}
}

View File

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

View File

@ -0,0 +1,190 @@
package com.jihe.punchnet.screen
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
import android.graphics.drawable.Drawable
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Add
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.graphics.drawable.toBitmap
import androidx.navigation.NavHostController
import com.jihe.punchnet.data.AppAuthRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
data class AppInfo(
val name: String,
val packageName: String,
val icon: Drawable
)
@Composable
fun AppAuthScreen(navController: NavHostController) {
val context = LocalContext.current
val allowedApps by AppAuthRepository.allowedApps.collectAsState()
var installedApps by remember { mutableStateOf<List<AppInfo>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) }
var showAppSelector by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
withContext(Dispatchers.IO) {
val pm = context.packageManager
val intent = android.content.Intent(android.content.Intent.ACTION_MAIN, null).apply {
addCategory(android.content.Intent.CATEGORY_LAUNCHER)
}
// Use queryIntentActivities to reliably find all user-facing apps
val resolveInfos = pm.queryIntentActivities(intent, 0)
val apps = resolveInfos.mapNotNull {
try {
val appInfo = it.activityInfo.applicationInfo
if (appInfo.packageName != context.packageName) {
AppInfo(
name = it.loadLabel(pm).toString(),
packageName = appInfo.packageName,
icon = it.loadIcon(pm)
)
} else null
} catch (e: Exception) {
null // Skip apps that cause PackageManager exceptions
}
}.distinctBy { it.packageName }.sortedBy { it.name }
installedApps = apps
isLoading = false
}
}
if (showAppSelector) {
// App Selection View
Column(modifier = Modifier.fillMaxSize()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
androidx.compose.material3.IconButton(onClick = { showAppSelector = false }) {
androidx.compose.material3.Icon(
imageVector = Icons.Default.ArrowBack,
contentDescription = "返回"
)
}
Text("选择要代理的应用", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
}
if (isLoading) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("正在加载应用列表...")
}
} else {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(installedApps) { app ->
val isAllowed = allowedApps.contains(app.packageName)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Image(
bitmap = app.icon.toBitmap().asImageBitmap(),
contentDescription = app.name,
modifier = Modifier.size(40.dp)
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(text = app.name, fontWeight = FontWeight.Bold, fontSize = 16.sp)
Text(text = app.packageName, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Switch(
checked = isAllowed,
onCheckedChange = { checked ->
AppAuthRepository.toggleApp(context, app.packageName, checked)
}
)
}
}
}
}
}
} else {
// Main View
Box(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize()) {
Text(
text = "只有在列表中的应用,其网络请求才会通过 PunchNet 代理。若列表为空,则默认代理所有应用。",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(16.dp)
)
val selectedApps = installedApps.filter { allowedApps.contains(it.packageName) }
if (selectedApps.isEmpty() && !isLoading) {
Box(modifier = Modifier.fillMaxSize().padding(bottom = 80.dp), contentAlignment = Alignment.Center) {
Text("当前未选择任何应用\n(将代理全局流量)", textAlign = androidx.compose.ui.text.style.TextAlign.Center, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
} else {
LazyColumn(modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(bottom = 80.dp)) {
items(selectedApps) { app ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Image(
bitmap = app.icon.toBitmap().asImageBitmap(),
contentDescription = app.name,
modifier = Modifier.size(40.dp)
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(text = app.name, fontWeight = FontWeight.Bold, fontSize = 16.sp)
Text(text = app.packageName, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
androidx.compose.material3.IconButton(
onClick = { AppAuthRepository.toggleApp(context, app.packageName, false) }
) {
androidx.compose.material3.Icon(
imageVector = Icons.Default.Clear,
contentDescription = "移除"
)
}
}
}
}
}
}
// Add Button (FAB)
androidx.compose.material3.FloatingActionButton(
onClick = { showAppSelector = true },
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(16.dp)
) {
androidx.compose.material3.Icon(
imageVector = Icons.Default.Add,
contentDescription = "添加应用"
)
}
}
}
}

View File

@ -0,0 +1,95 @@
package com.jihe.punchnet.screen
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import com.jihe.punchnet.R
import com.jihe.punchnet.RouteInfo
import com.jihe.punchnet.data.ButtonRepository
import com.jihe.punchnet.data.ButtonState
import com.jihe.punchnet.data.ButtonViewModel
import com.jihe.punchnet.data.RouteViewModel
import com.jihe.punchnet.data.ServiceViewModel
@Composable
fun AppNavHost2(
buttonViewModel: ButtonViewModel,
serviceViewModel: ServiceViewModel,
routeViewModel: RouteViewModel = viewModel(),
navController: NavHostController,
paddingValues: PaddingValues
) {
val context = androidx.compose.ui.platform.LocalContext.current
val token = com.jihe.punchnet.helper.getPreferenceString(context, com.jihe.punchnet.helper.PreferenceName.PreferenceToken)
val startDest = if (token.isNullOrEmpty()) Screen.LoginScreen.route else Screen.MainScreen.route
NavHost(
navController = navController,
startDestination = startDest,
modifier = Modifier.padding(paddingValues)
) {
composable(Screen.LoginScreen.route) {
LoginScreen(navController)
}
composable(Screen.MainScreen.route) {
HomeScreen(buttonViewModel, serviceViewModel, routeViewModel, navController)
}
composable(Screen.DeviceScreen.route) {
CustomHeaderScreen("设备") {
DeviceScreen(navController)
}
}
composable(Screen.AppAuthScreen.route) {
CustomHeaderScreen("应用授权") {
AppAuthScreen(navController)
}
}
composable(Screen.SettingsScreen.route) {
CustomHeaderScreen("设置") {
SettingsScreen(ButtonRepository.buttonState.collectAsState().value != ButtonState.ButtonStarted, routeViewModel)
}
}
composable(
route = Screen.WebViewScreen.route,
arguments = listOf(navArgument("url") { type = androidx.navigation.NavType.StringType })
) { backStackEntry ->
val url = backStackEntry.arguments?.getString("url") ?: ""
WebViewScreen(
url = url,
onBack = { navController.popBackStack() }
)
}
}
}

View File

@ -0,0 +1,70 @@
package com.jihe.punchnet.screen
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavHostController
import androidx.navigation.compose.currentBackStackEntryAsState
@Composable
fun AppBottomNavigation(
navController: NavHostController
) {
// val currentRoute = navController.currentBackStackEntry?.destination?.route
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute by remember {
derivedStateOf {
navBackStackEntry?.destination?.route
}
}
NavigationBar {
bottomNavItems.forEach { screen ->
NavigationBarItem(
icon = {
Icon(
imageVector = when (screen) {
Screen.MainScreen -> Icons.Default.Home
Screen.DeviceScreen -> Icons.Default.Menu
Screen.AppAuthScreen -> Icons.Default.Person
else -> Icons.Default.Home
},
contentDescription = screen.route
)
},
label = {
val labelText = when (screen) {
Screen.MainScreen -> "首页"
Screen.DeviceScreen -> "设备"
Screen.AppAuthScreen -> "应用授权"
else -> screen.route
}
Text(text = labelText)
},
selected = currentRoute == screen.route,
onClick = {
navController.navigate(screen.route) {
launchSingleTop = true
restoreState= true
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
}
println("navigate to ${screen.route}")
}
)
}
}
}

View File

@ -0,0 +1,57 @@
package com.jihe.punchnet.screen
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Phone
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavHostController
@Composable
fun DeviceScreen(navController: NavHostController) {
val nodeList by com.jihe.punchnet.data.NodeRepository.nodes.collectAsState()
LazyColumn(
modifier = Modifier.fillMaxWidth().fillMaxHeight(),
horizontalAlignment = Alignment.CenterHorizontally
) {
if (nodeList.isNotEmpty()) {
item {
Text(
text = "网络节点 (${nodeList.count { it.connectionStatus == "connected" }}/${nodeList.size})",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 16.dp, start = 24.dp, top = 24.dp).fillMaxWidth()
)
}
items(nodeList) { node ->
Box(modifier = Modifier.padding(horizontal = 24.dp)) {
NodeItemRow(node)
}
}
} else {
item {
Box(modifier = Modifier.fillMaxSize().padding(top = 100.dp), contentAlignment = Alignment.Center) {
Text("暂无设备,请先连接网络", color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
}

View File

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

View File

@ -0,0 +1,213 @@
package com.jihe.punchnet.screen
import android.widget.Toast
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavHostController
import com.jihe.punchnet.R
import com.jihe.punchnet.api.ApiClient
import com.jihe.punchnet.api.AuthLoginRequest
import com.jihe.punchnet.api.AuthTokenRequest
import com.jihe.punchnet.helper.PreferenceName
import com.jihe.punchnet.helper.setPreferenceString
import com.jihe.punchnet.sdlan.utils.UniqueNodeID
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Composable
fun LoginScreen(navController: NavHostController) {
var selectedTabIndex by remember { mutableStateOf(0) }
val tabs = listOf("账号密码登录", "Token直连")
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var token by remember { mutableStateOf("") }
var isLoading by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope()
val context = LocalContext.current
val apiClient = remember { ApiClient() }
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Image(
painter = painterResource(R.drawable.punchnet_log),
contentDescription = "logo",
modifier = Modifier.size(100.dp)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Welcome to PunchNet",
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(32.dp))
TabRow(selectedTabIndex = selectedTabIndex) {
tabs.forEachIndexed { index, title ->
Tab(
selected = selectedTabIndex == index,
onClick = { selectedTabIndex = index },
text = { Text(title) }
)
}
}
Spacer(modifier = Modifier.height(24.dp))
if (selectedTabIndex == 0) {
// Account Login
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text("用户名") },
leadingIcon = { Icon(Icons.Default.Person, contentDescription = null) },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("密码") },
leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null) },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
} else {
// Token Login
OutlinedTextField(
value = token,
onValueChange = { token = it },
label = { Text("连接 Token") },
leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null) },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
}
Spacer(modifier = Modifier.height(32.dp))
Button(
onClick = {
if (selectedTabIndex == 0 && (username.isEmpty() || password.isEmpty())) {
Toast.makeText(context, "请输入用户名和密码", Toast.LENGTH_SHORT).show()
return@Button
}
if (selectedTabIndex == 1 && token.isEmpty()) {
Toast.makeText(context, "请输入Token", Toast.LENGTH_SHORT).show()
return@Button
}
isLoading = true
coroutineScope.launch {
UniqueNodeID.setBaseDir(context.filesDir.absolutePath)
val clientId = "punchnet_android_client_" + UniqueNodeID.getUUID(context)
try {
val response = if (selectedTabIndex == 0) {
val request = AuthLoginRequest(
clientId = clientId,
username = username,
password = password,
version = "1.0.0",
system = "Android " + android.os.Build.VERSION.RELEASE,
hostname = android.os.Build.MODEL
)
ApiClient().loginWithAccount(request)
} else {
val request = AuthTokenRequest(
clientId = clientId,
token = token,
version = "1.0.0",
system = "Android " + android.os.Build.VERSION.RELEASE,
hostname = android.os.Build.MODEL
)
ApiClient().loginWithToken(request)
}
withContext(Dispatchers.Main) {
isLoading = false
if (response != null && response.code == 0 && response.data != null) {
// Success! Save auth data as JSON and navigate
val authDataJson = com.google.gson.Gson().toJson(response.data)
setPreferenceString(context, PreferenceName.PreferenceToken, authDataJson)
Toast.makeText(context, "登录成功", Toast.LENGTH_SHORT).show()
navController.navigate(Screen.MainScreen.route) {
popUpTo(Screen.LoginScreen.route) { inclusive = true }
}
} else {
Toast.makeText(context, "登录失败: ${response?.message ?: "网络错误"}", Toast.LENGTH_LONG).show()
}
}
} catch (e: Exception) {
e.printStackTrace()
withContext(Dispatchers.Main) {
isLoading = false
Toast.makeText(context, "登录异常: ${e.message}", Toast.LENGTH_LONG).show()
}
}
}
},
modifier = Modifier
.fillMaxWidth()
.height(50.dp),
shape = RoundedCornerShape(8.dp),
enabled = !isLoading
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp
)
} else {
Text("登录", fontSize = 16.sp)
}
}
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
TextButton(
onClick = {
Toast.makeText(context, "请前往官网注册账号", Toast.LENGTH_SHORT).show()
}
) {
Text("没有账号?去注册")
}
}
}
}

View File

@ -0,0 +1,136 @@
package com.jihe.punchnet.screen
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
import com.jihe.punchnet.data.ButtonViewModel
import com.jihe.punchnet.data.RouteViewModel
import com.jihe.punchnet.data.ServiceViewModel
sealed class Screen(val route: String) {
object MainScreen: Screen("home") // 首页
object DeviceScreen: Screen("devices") // 设备 (取代原先的 Route)
object AppAuthScreen: Screen("appAuth") // 应用授权 (取代原先的 Profile)
object SettingsScreen: Screen("settings") // 设置
object WebViewScreen: Screen("webview/{url}") {
fun createRoute(url: String) = "webview/${android.net.Uri.encode(url)}"
}
object LoginScreen: Screen("login")
}
val bottomNavItems = listOf(
Screen.MainScreen,
Screen.DeviceScreen,
Screen.AppAuthScreen,
)
@Composable
fun MainApp(
buttonViewModel: ButtonViewModel = viewModel(),
serviceViewModel: ServiceViewModel = viewModel(),
routeModel: RouteViewModel = viewModel(),
navController: NavHostController,
) {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute by remember { derivedStateOf { navBackStackEntry?.destination?.route } }
val showBottomBar = currentRoute != Screen.LoginScreen.route
Scaffold(
bottomBar = {
if (showBottomBar) {
AppBottomNavigation(navController = navController)
}
}
){ paddingValues ->
/*
Image(
painter = painterResource(R.drawable.punchnet_log),
contentDescription = null,
modifier = Modifier.padding(paddingValues).size(200.dp)
)
*/
AppNavHost2(buttonViewModel, serviceViewModel, routeModel, navController, paddingValues)
}
}
@Composable
fun CustomHeaderScreen(
text: String,
onBack: @Composable (() -> Unit)? = null,
onMenu: @Composable (() -> Unit)? = null,
body: @Composable () -> Unit,
) {
Column(
modifier = Modifier.fillMaxWidth(),
) {
Box (
modifier = Modifier
.fillMaxWidth()
.height(56.dp)
.background(MaterialTheme.colorScheme.background),
//.padding(horizontal = 16.dp),
contentAlignment = Alignment.CenterStart
) {
Row (
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Box(
modifier = Modifier.fillMaxHeight()
.padding(horizontal = 8.dp)
.width(35.dp)
) {
if (onBack != null) {
onBack()
}
}
Text(
text,
// color = MaterialTheme.colorScheme.onPrimary,
style = MaterialTheme.typography.titleLarge,
)
Box(
modifier = Modifier.fillMaxHeight()
.padding(horizontal = 8.dp)
.width(35.dp)
) {
if (onMenu != null) {
onMenu()
}
}
}
}
Box(modifier = Modifier.fillMaxSize()) {
body()
}
}
}

View File

@ -0,0 +1,189 @@
package com.jihe.punchnet.screen
import android.widget.Toast
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.jihe.punchnet.data.RouteItem
import com.jihe.punchnet.data.RouteViewModel
import com.jihe.punchnet.helper.IntToIPString
import com.jihe.punchnet.helper.parseCIDRAndGW
import com.jihe.punchnet.sdlan.network.maskIPToDigit
import com.jihe.punchnet.data.NodeRepository
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.rememberScrollState
@Composable
fun SettingsScreen(enabled: Boolean, routeViewModel: RouteViewModel) {
val localProfile by NodeRepository.localProfile.collectAsState()
val exitNodes by NodeRepository.exitNodes.collectAsState()
val routeItems by routeViewModel.allRoutes.collectAsState(initial = emptyList())
val showAddRoute = remember { mutableStateOf<RouteItem?>(null) }
val shouldShow = remember { mutableStateOf(false) }
AddOrModifyRouteDialog(routeViewModel, showAddRoute, shouldShow, exitNodes)
LazyColumn(modifier = Modifier.fillMaxSize().padding(16.dp)) {
// Section 1: Local Profile
item {
Text("本机信息", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(8.dp))
if (localProfile != null) {
Card(modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp)) {
Column(modifier = Modifier.padding(16.dp)) {
Text("Hostname: ${localProfile!!.hostname}")
Text("IP: ${localProfile!!.ip}/${localProfile!!.maskLen}")
Text("Identity ID: ${localProfile!!.identityId}")
}
}
} else {
Text("暂无本机网络信息", color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(bottom = 16.dp))
}
}
// Section 2: Exit Nodes
item {
Text("出口节点", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(8.dp))
if (exitNodes.isEmpty()) {
Text("当前网络无可用出口节点", color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(bottom = 16.dp))
}
}
items(exitNodes) { exitNode ->
Card(modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
Column(modifier = Modifier.padding(16.dp)) {
Text("名称: ${exitNode.nodeName}", fontWeight = FontWeight.Bold)
Text("网关 IP: ${exitNode.gateway}")
Text("代理网段: ${exitNode.targetNetwork}")
}
}
}
item { Spacer(modifier = Modifier.height(8.dp)) }
// Section 3: Routes
item {
Text("本地路由配置", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(8.dp))
}
items(routeItems) { item ->
RouteCard(routeViewModel, item, enabled)
}
item {
Spacer(modifier = Modifier.height(8.dp))
Button(
enabled = enabled,
onClick = { shouldShow.value = true },
modifier = Modifier.fillMaxWidth()
) {
Text("添加路由")
}
}
}
}
@Composable
fun AddOrModifyRouteDialog(
routeViewModel: RouteViewModel,
initRoute: MutableState<RouteItem?>,
shouldShowRoute: MutableState<Boolean>,
exitNodes: List<com.jihe.punchnet.api.ExitNodeItem>
) {
if (shouldShowRoute.value) {
val context = LocalContext.current
var cidr by remember { mutableStateOf("") }
var gw by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = { shouldShowRoute.value = false },
confirmButton = {
Button(
onClick = {
val item = parseCIDRAndGW(cidr, gw)
if (item == null) {
Toast.makeText(context, "数据解析出错", Toast.LENGTH_SHORT).show()
} else {
routeViewModel.insert(item)
shouldShowRoute.value = false
}
}
) {
Text("确定")
}
},
title = { Text("添加新路由") },
text = {
Column {
TextField(
value = cidr,
onValueChange = { cidr = it },
label = { Text("CIDR") },
placeholder = { Text("例如 0.0.0.0/0") }
)
Spacer(modifier = Modifier.height(8.dp))
TextField(
value = gw,
onValueChange = { gw = it },
label = { Text("Gateway") },
placeholder = { Text("例如 10.10.4.5") }
)
if (exitNodes.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
Text("快捷选择出口节点网关:", fontSize = 12.sp)
Row(
modifier = Modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
exitNodes.forEach { en ->
SuggestionChip(
onClick = {
gw = en.gateway
if (cidr.isEmpty()) cidr = en.targetNetwork
},
label = { Text(en.nodeName) }
)
}
}
}
}
}
)
}
}
@Composable
fun RouteCard(
routeViewModel: RouteViewModel,
item: RouteItem,
enabled: Boolean,
) {
Card(modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.height(48.dp).padding(horizontal = 16.dp)
) {
Icon(
Icons.Default.Clear,
contentDescription = "delete route",
modifier = Modifier.size(24.dp).clickable(enabled = enabled) {
routeViewModel.deleteById(item.id)
}
)
Spacer(modifier = Modifier.width(16.dp))
Text("${IntToIPString(item.net_ip)}/${maskIPToDigit(item.mask_ip)}")
Spacer(modifier = Modifier.width(16.dp))
Text("-> ${IntToIPString(item.gateway)}")
}
}
}

View File

@ -0,0 +1,94 @@
package com.jihe.punchnet.screen
import android.annotation.SuppressLint
import android.webkit.WebChromeClient
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
@OptIn(ExperimentalMaterial3Api::class)
@SuppressLint("SetJavaScriptEnabled")
@Composable
fun WebViewScreen(url: String, onBack: () -> Unit) {
var webViewRef by remember { mutableStateOf<WebView?>(null) }
var loadingProgress by remember { mutableStateOf(0f) }
var isLoading by remember { mutableStateOf(true) }
var currentTitle by remember { mutableStateOf("资源查看") }
Column(modifier = Modifier.fillMaxSize()) {
TopAppBar(
title = { Text(currentTitle) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.Close, contentDescription = "关闭")
}
},
actions = {
IconButton(onClick = { webViewRef?.reload() }) {
Icon(Icons.Default.Refresh, contentDescription = "刷新")
}
}
)
if (isLoading) {
LinearProgressIndicator(
progress = { loadingProgress },
modifier = Modifier.fillMaxWidth(),
)
}
AndroidView(
factory = { context ->
WebView(context).apply {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
return false // Let WebView load the URL
}
}
webChromeClient = object : WebChromeClient() {
override fun onProgressChanged(view: WebView?, newProgress: Int) {
loadingProgress = newProgress / 100f
isLoading = newProgress < 100
}
override fun onReceivedTitle(view: WebView?, title: String?) {
if (!title.isNullOrEmpty()) {
currentTitle = title
}
}
}
webViewRef = this
loadUrl(url)
}
},
update = { webView ->
// Don't loadUrl here again, it causes infinite reloading on recomposition
},
modifier = Modifier.fillMaxSize()
)
}
}

View File

@ -0,0 +1,30 @@
package com.jihe.punchnet.sdlan.config
data class Arguments(
val baseDir: String,
// udp info of the super node
val sn: String,
// tcp info of the super node
// udp info of the STUN server
val stun: String,
val nat_server1: String,
val nat_server2: String,
val mtu: Int = 1400,
val token: String,
val allowRouting: Boolean = true,
val dropMulticast: Boolean = true,
val registerTTL: Int = 1,
val name: String,
// type of service
val tos: Int = 0,
// local udp port, default use 0, for random
val localPort: Int = 0,
val allowP2P: Boolean = true,
)

View File

@ -0,0 +1,44 @@
package com.jihe.punchnet.sdlan.config
import com.google.protobuf.ByteString
object SDLanConfig {
const val RegisterSuperInterval: Byte = 20
const val RegisterInterval: Int = 5
const val ID_FILENAME: String = ".id"
const val TCP_PING_TIME: Long = 7
val BROADCASTMAC: ByteString = ByteString.copyFrom(byteArrayOf(0xff.toByte(), 0xff.toByte(), 0xff.toByte(), 0xff.toByte(), 0xff.toByte(), 0xff.toByte()))
var BROADCASTMAC_BYTEARRAY = BROADCASTMAC.toByteArray()
}
object SDLanMulticastConfig {
const val MULTICAST_PORT: Int = 1070
val MULTICAST_V4: ByteArray = byteArrayOf(224.toByte(),0u.toByte(),0.toByte(),70.toByte())
}
fun ByteArray.toIPV4String(): String {
if (this.size != 4) {
return "0.0.0.0"
}
return "${this[0].toUByte()}.${this[1].toUByte()}.${this[2].toUByte()}.${this[3].toUByte()}"
}
object RSAConfig {
const val BASE_DIR: String = ".keys"
const val RSAMODE: String = "RSA/ECB/PKCS1Padding"
const val PUBLIC_FILE_NAME: String = "id_rsa.pub"
const val PRIVATE_FILE_NAME: String = "id_rsa"
const val ENCRYPT_MAX_SIZE: Int = 245
const val DECRYPT_MAX_SIZE: Int = 256
}
object AESConfig {
const val CIPHER_ALGORITHM: String = "AES/CBC/PKCS7Padding"
const val KEY_SPEC: String = "AES"
}

View File

@ -0,0 +1,17 @@
package com.jihe.punchnet.sdlan.config
object EtherType {
const val IPV4: Short = 0x0800.toShort()
const val IPV6: Short = 0x86dd.toShort()
const val ARP: Short = 0x0806.toShort()
const val HWTYPE_ETH: Short = 1
const val ARP_REQUEST: Short = 1
const val ARP_REPLY: Short = 2
const val ARP_TABLE_SIZE: Int = 100
const val ARP_EXPIRE_TIME: Int = 60
const val ARP_DETECT_TIMES: Int = 3
}

View File

@ -0,0 +1,39 @@
package com.jihe.punchnet.sdlan.logs
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
const val ColorPrefix = "\u001b"
const val GreenColor = "${ColorPrefix}[0;32m"
const val BlueColor = "${ColorPrefix}[0;34m"
const val YellowColor = "${ColorPrefix}[0;33m"
const val RedColor = "${ColorPrefix}[0;31m"
const val ColorReset = "${ColorPrefix}[0;0m"
interface Logger {
val format: DateTimeFormatter
fun debugf(formatter: ()->String) {
val now = LocalDateTime.now().format(format)
println("${GreenColor}DEBU:${ColorReset}[$now] ${formatter()}")
}
fun infof(formatter: ()->String) {
val now = LocalDateTime.now().format(format)
println("${BlueColor}INFO:${ColorReset}[$now] ${formatter()}")
}
fun warning(formatter: ()->String) {
val now = LocalDateTime.now().format(format)
println("${YellowColor}WARN:${ColorReset}[$now] ${formatter()}")
}
fun errorf(formatter: ()->String) {
val now = LocalDateTime.now().format(format)
println("${RedColor}ERRO:${ColorReset}[$now] ${formatter()}")
}
fun criticalf(formatter: ()->String) {
val now = LocalDateTime.now().format(format)
println("${RedColor}CRIT:${ColorReset}[$now] ${formatter()}")
}
}
object TerminalLogger: Logger {
override val format: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm::s")
}

View File

@ -0,0 +1,146 @@
package com.jihe.punchnet.sdlan.network
import com.google.protobuf.kotlin.toByteString
import com.jihe.punchnet.protobuf.PunchProto.SDLData
import com.jihe.punchnet.sdlan.config.EtherType
import com.jihe.punchnet.sdlan.logs.TerminalLogger
import com.jihe.punchnet.sdlan.utils.ipToString
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import java.util.concurrent.ConcurrentHashMap
const val BroadcastIP = 0xFFFFFFFF
@OptIn(ExperimentalUnsignedTypes::class)
val BroadcastMac = ubyteArrayOf(0xffu, 0xffu, 0xffu, 0xffu, 0xffu, 0xffu).toByteArray()
class ARPTable(
val scope: CoroutineScope,
) {
val routeTable: RouteTable = RouteTable()
val content = ConcurrentHashMap<Int, ARPInfo>()
suspend fun agingARP() {
// CoroutineScope(Dispatchers.Default).async {
scope.async {
while(true) {
delay(20_000)
val now = System.currentTimeMillis()/1000
val toRemove = mutableListOf<Int>()
for ((k, v) in content) {
if (now-v.timestamp > EtherType.ARP_EXPIRE_TIME) {
toRemove.add(k)
}
}
for (item in toRemove) {
content.remove(item)
}
}
}
}
fun getMacFromIP(ip: Int): Pair<ByteArray?, Int> {
val gw = routeTable.getGateway(ip)
if (gw == null || gw == 0) {
// not found in route table, just use the ip
TerminalLogger.debugf { "ARP: IP=${ipToString(ip)} has no gateway (gw=$gw), querying IP directly." }
val value = content.get(ip)
return Pair(value?.mac, ip)
} else {
// gw not null, try find the gw's mac
TerminalLogger.debugf { "ARP: IP=${ipToString(ip)} uses gateway=${ipToString(gw)}." }
val value = content.get(gw)
return Pair(value?.mac, gw)
}
}
fun addToARPTable(ip: Int, mac: ByteArray) {
val origin = content.get(ip)
val now = System.currentTimeMillis()/1000
if (origin != null) {
TerminalLogger.debugf { "adding ${ipToString(ip)} to arptable" }
origin.timestamp = now
origin.mac = mac
return
}
if (content.size >= EtherType.ARP_TABLE_SIZE) {
// loop and clear the old aged item, or if no one expires,
// remove the oldest one
var maxage = -1L
val toRemove = mutableListOf<Int>()
var maxindex = -1
val expireage = EtherType.ARP_EXPIRE_TIME + EtherType.ARP_DETECT_TIMES*5
for ((k, v) in content) {
if (now-v.timestamp > expireage) {
toRemove.add(k)
continue
}
if (now-v.timestamp > maxage) {
maxage = now-v.timestamp
maxindex = k
}
}
if (toRemove.size == 0 && maxindex != -1) {
toRemove.add(maxindex)
}
for (item in toRemove) {
content.remove(item)
}
println("adding ${ipToString(ip)}")
}
content[ip] = ARPInfo(now, mac)
}
}
class ARPInfo(var timestamp: Long, var mac: ByteArray)
class ARPWaitList {
val content = ConcurrentHashMap<Int, MutableList<ARPWaitInfo>>()
fun addToWaitList(ip: Int, originData: ByteArray) {
val origin = content.getOrPut(ip, {mutableListOf<ARPWaitInfo>()})
val now = System.currentTimeMillis()/1000
if (origin.size > 5) {
origin.removeAt(0)
// origin.removeFirst()
}
origin.add(ARPWaitInfo(now, originData))
}
suspend fun arpArrived(node: Node, ip: Int, mac: Mac) {
val waitlist = content.remove(ip)
if (waitlist == null) {
return
}
if (!node.aes.isAuthorized()) {
return
}
val now = System.currentTimeMillis()/1000
val networkid = node.networkID.get()
for (item in waitlist) {
if (now - item.timestamp > 5) {
// just skip the packet
continue
}
TerminalLogger.debugf { "ARP resumed flow: ${describeIpv4Packet(item.originData)}" }
val packet = formEthernetPacket(node.mac.toByteArray(), mac.toByteArray(), item.originData)
val size = packet.remaining()
val packetBytes = ByteArray(size)
packet.get(packetBytes)
val encrypted = node.aes.encrypt(packetBytes)
if (encrypted != null) {
sendPacketToNet(node, mac, encrypted, size.toLong())
}
}
}
}
class ARPWaitInfo(val timestamp: Long, val originData: ByteArray)

View File

@ -0,0 +1,358 @@
package com.jihe.punchnet.sdlan.network
// import sdlanproto.Message.*
import android.util.Log
import com.google.protobuf.kotlin.toByteString
import com.jihe.punchnet.protobuf.PunchProto
import com.jihe.punchnet.protobuf.PunchProto.SDLStunProbe
import com.jihe.punchnet.protobuf.PunchProto.SDLStunProbeReply
import com.jihe.punchnet.protobuf.PunchProto.SDLStunRequest
import com.jihe.punchnet.sdlan.config.SDLanMulticastConfig
import com.jihe.punchnet.sdlan.logs.TerminalLogger
import com.jihe.punchnet.sdlan.utils.AES
import com.jihe.punchnet.sdlan.utils.RSA
import com.jihe.punchnet.sdlan.utils.UniqueNodeID
import com.jihe.punchnet.sdlan.utils.generateRandomMAC
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.SendChannel
import kotlinx.coroutines.withTimeoutOrNull
import java.net.InetSocketAddress
import java.net.SocketAddress
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicLong
class DeviceConfig (
var mtu: Int,
var mac: Mac,
var ip: IPSubnet,
) {
constructor(mtu: Int): this(
mtu,
ByteArray(6).toByteString(),
IPSubnet(0, 0),
)
}
class NodeConfig (
val baseDir: String,
val name: String,
val nodeUUID: String,
val allowRouting: Boolean,
val dropMulticast: Boolean,
val allowP2P: Boolean,
val mtu: Int,
val tos: Int,
val registerSuperInterval: Byte,
val registerTTL: Int,
val localPort: Int,
val supernode: SDLanSock,
val stunServer: SDLanSock,
val natServer1: java.net.InetSocketAddress,
val natServer2: java.net.InetSocketAddress
)
class NodeStats private constructor(
// communicate with p2p
val txP2P: AtomicLong,
val rxP2P: AtomicLong,
// communicate with server
val txSup: AtomicLong,
val rxSup: AtomicLong,
// communicate through broadcast
val txBroadcast: AtomicLong,
val rxBroadcast: AtomicLong,
// last time communicate with server
val lastSup: AtomicLong,
// last time communicate with the edge
val lastP2P: AtomicLong,
) {
constructor(): this(
AtomicLong(0),
AtomicLong(0),
AtomicLong(0),
AtomicLong(0),
AtomicLong(0),
AtomicLong(0),
AtomicLong(0),
AtomicLong(0),
)
}
class NatProbeCookie {
val cookieid: AtomicInteger = AtomicInteger(1)
val cookiemap = ConcurrentHashMap<Int, SendChannel<SDLStunProbeReply>>()
fun getChannelWithID(id: Int): SendChannel<SDLStunProbeReply>? {
return this.cookiemap.get(id)
}
suspend fun sendMessage(id: Int, value: SDLStunProbeReply) {
cookiemap.remove(id)?.send(value)
}
fun addChannel(channel: SendChannel<SDLStunProbeReply>): Int {
val ck = cookieid.getAndAdd(1)
cookiemap.set(ck, channel)
return ck
}
fun remove(id: Int) {
cookiemap.remove(id)
}
}
class Node private constructor (
// 当前的packet id
val packetID: AtomicInteger,
// 自身所处的network的id
val networkID: AtomicInteger,
// 所处网络的token
var token: String,
// tun设备vpnservice的配置
val deviceConfig: DeviceConfig,
// interface的抽象
var iface: Iface?,
// val authorized: AtomicBoolean,
// rsa info
val rsa: RSA,
// aes info
val aes: AES,
// var encryptKey: ByteArray,
var config: NodeConfig,
val pendingPeers: PeerMap,
val knownPeers: PeerMap,
// 自身的公网ip
val outerIPV4: AtomicInteger,
var udpSockV4: SDLanSocket,
var udpSockMulticast: SDLanSocket?,
var multicastSock: SDLanSock,
val stats: NodeStats,
// send message to socket, if connected, and authorized
var toSocket: SendChannel<ByteArray>,
var startStopChannel: SendChannel<StartStopChanInfo>,
val mac: Mac = generateRandomMAC(),
var nat_type: NatType = NatType.PortRestricted,
val natProbeCookie: NatProbeCookie = NatProbeCookie(),
var authData: com.jihe.punchnet.api.AuthResponseData? = null,
var connectData: com.jihe.punchnet.api.ConnectResponseData? = null,
var clientId: String = "",
var sessionToken: com.google.protobuf.ByteString = com.google.protobuf.ByteString.EMPTY,
val queriedPolicies: ConcurrentHashMap<Int, Int> = ConcurrentHashMap(),
val queriedPolicyTargets: ConcurrentHashMap<Int, Int> = ConcurrentHashMap(),
val policyRules: ConcurrentHashMap<Int, ByteArray> = ConcurrentHashMap(),
) {
companion object {
private var instance: Node? = null
@Synchronized
fun initialize(
config: NodeConfig,
v4Sock: SDLanSocket,
multicastSock: SDLanSocket?,
token: String,
rsa: RSA,
iface: Iface,
toSocket: SendChannel<ByteArray>,
startStopChannel: SendChannel<StartStopChanInfo>,
) : Node{
if (instance == null) {
instance = Node(
config,
v4Sock,
multicastSock,
token,
rsa,
iface,
toSocket,
startStopChannel,
)
}
return instance!!
}
fun getInstance(): Node {
return instance ?: throw IllegalStateException("Node is not initialized")
}
}
private constructor(
// pubkey: String,
config: NodeConfig,
v4Sock: SDLanSocket,
multicastSock: SDLanSocket?,
token: String,
rsa: RSA,
iface: Iface,
toSocket: SendChannel<ByteArray>,
startStopChannel: SendChannel<StartStopChanInfo>,
// privateKey: ByteArray,
// mtu: Int
): this (
packetID = AtomicInteger(1),
networkID = AtomicInteger(0),
token = token,
deviceConfig = DeviceConfig(config.mtu),
iface = iface,
rsa = rsa,
aes = AES.getAES(),
config = config,
pendingPeers = PeerMap(),
knownPeers = PeerMap(),
outerIPV4 = AtomicInteger(0),
udpSockV4 = v4Sock,
udpSockMulticast = multicastSock,
toSocket = toSocket,
startStopChannel = startStopChannel,
multicastSock = SDLanSock(
IPFamily.IPV4,
SDLanMulticastConfig.MULTICAST_PORT,
SDLanMulticastConfig.MULTICAST_V4,
),
stats = NodeStats(),
) {
this.deviceConfig.mac = this.mac
}
fun getNextPacketID(): Int {
return packetID.getAndAdd(1)
}
suspend fun sendStunRequest() {
if (this.clientId.isEmpty()) {
this.clientId = "punchnet_android_client_" + UniqueNodeID.getUUID()
}
val req = SDLStunRequest.newBuilder()
.setClientId(this.clientId)
.setNetworkId(this.networkID.get())
.setIp(this.deviceConfig.ip.netAddr)
.setMac(this.mac)
.setNatType(this.nat_type.toByte().toInt())
.setV6Info(PunchProto.SDLV6Info.getDefaultInstance())
.setSessionToken(this.sessionToken)
.build()
val msg = encodeToUDPMessage(req, PacketType.StunRequest)
TerminalLogger.debugf {
"send STUN_REQUEST to ${config.stunServer}, clientIdTail=${clientId.takeLast(12)}, " +
"network=${req.networkId}, ip=${com.jihe.punchnet.sdlan.utils.ipToString(req.ip)}, " +
"mac=${com.jihe.punchnet.sdlan.utils.macToString(req.mac)}, natType=${nat_type}, " +
"sessionTokenBytes=${sessionToken.size()}"
}
sendToSock(this, msg, config.stunServer)
}
suspend fun ping_to_sn() {
val msg = encodeToControlMessage(null, PacketType.Ping).toByteArray()
_sendDataToSocket(msg)
}
suspend fun _sendDataToSocket(msg: ByteArray) {
if (aes.isAuthorized()) {
TerminalLogger.debugf{"authorized, ping to sn"}
toSocket.send(msg)
} else {
TerminalLogger.debugf{"unauthorized, not ping to sn"}
}
}
suspend fun probeNatType() {
val previousNatType = nat_type
val reply1 = this._sendAndWaitForProbeReply(StunProbeAttr.None, config.natServer1)
if (reply1 == null) {
TerminalLogger.warning { "STUN probe attr=None timed out; keeping natType=${nat_type}" }
return
}
if (reply1.ip == (outerIPV4.get())) {
if (this._sendAndWaitForProbeReply(StunProbeAttr.Peer, config.natServer1) == null) {
// failed to get with peer, just symmetric
nat_type = NatType.Symmetric
return
}
nat_type = NatType.NoNat
return
}
val reply2 = this._sendAndWaitForProbeReply(StunProbeAttr.Peer, config.natServer1)
if (reply2 != null) {
nat_type = NatType.FullCone
return
}
val reply3 = this._sendAndWaitForProbeReply(StunProbeAttr.None, config.natServer2)
if (reply3 == null) {
nat_type = if (previousNatType == NatType.Invalid || previousNatType == NatType.Blocked) {
NatType.PortRestricted
} else {
previousNatType
}
TerminalLogger.warning {
"secondary STUN probe timed out after primary reply; keeping natType=${nat_type}"
}
return
}
if ((reply1.ip != reply3.ip) || (reply1.port != reply3.port)) {
nat_type = NatType.Symmetric
return
}
val reply4 = this._sendAndWaitForProbeReply(StunProbeAttr.Port, config.natServer1)
if (reply4 == null) {
nat_type = NatType.PortRestricted
} else {
nat_type = NatType.ConeRestrict
}
}
suspend fun _sendAndWaitForProbeReply(attr: StunProbeAttr, toServer: java.net.SocketAddress): SDLStunProbeReply? {
val channel = kotlinx.coroutines.channels.Channel<SDLStunProbeReply>(100)
val cookie = natProbeCookie.addChannel(channel)
val probe = com.jihe.punchnet.protobuf.PunchProto.SDLStunProbe.newBuilder()
.setAttr(attr.ordinal.toInt())
.setCookie(cookie)
.build()
val msg = encodeToUDPMessage(probe, PacketType.StunProbe)
TerminalLogger.debugf { "send STUN_PROBE attr=$attr cookie=$cookie to $toServer" }
this.udpSockV4.send_to(msg.toByteArray(), toServer)
val k = kotlinx.coroutines.withTimeoutOrNull(5000) {
val response = channel.receive()
return@withTimeoutOrNull response
}
if (k == null) {
TerminalLogger.warning { "STUN_PROBE attr=$attr cookie=$cookie to $toServer timed out" }
} else {
TerminalLogger.debugf { "STUN_PROBE attr=$attr cookie=$cookie reply ip=${k.ip} port=${k.port}" }
}
natProbeCookie.remove(cookie)
return k
}
}

View File

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

View File

@ -0,0 +1,296 @@
package com.jihe.punchnet.sdlan.network
import com.google.protobuf.kotlin.toByteString
import com.jihe.punchnet.protobuf.PunchProto.SDLData
import com.jihe.punchnet.sdlan.config.EtherType
import com.jihe.punchnet.sdlan.config.SDLanConfig
import com.jihe.punchnet.sdlan.config.toIPV4String
import com.jihe.punchnet.sdlan.logs.TerminalLogger
import com.jihe.punchnet.sdlan.utils.ArpHdr
import com.jihe.punchnet.sdlan.utils.EthHdr
import com.jihe.punchnet.sdlan.utils.ipToString
import com.jihe.punchnet.sdlan.utils.isMultiBroadcast
import com.jihe.punchnet.sdlan.utils.macToString
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.BufferedReader
import java.io.DataInputStream
import java.io.DataOutputStream
import java.io.InputStreamReader
import java.net.Inet4Address
import java.net.Socket
import java.nio.ByteBuffer
import java.nio.ByteOrder
interface Iface {
suspend fun doInit()
suspend fun recv(): ByteArray
suspend fun send(content: ByteArray)
suspend fun reload_config(config: DeviceConfig)
fun close() {}
suspend fun handleDataFromNet(node: Node, data: ByteArray)
suspend fun handleDataFromDevice(node: Node, data: ByteArray)
}
interface IfaceTap: Iface {
override suspend fun handleDataFromNet(node: Node, data: ByteArray) {
TerminalLogger.debugf { "in tap mode" }
}
override suspend fun handleDataFromDevice(node: Node, data: ByteArray) {
}
}
interface IfaceTun: Iface {
/*
override suspend fun recv(): ByteArray
override suspend fun send(content: ByteArray)
override suspend fun reload_config(config: DeviceConfig)
*/
val arpTable: ARPTable
val arpWaitList: ARPWaitList
override suspend fun doInit() {
arpTable.agingARP()
}
override suspend fun handleDataFromNet(node: Node, data: ByteArray) {
TerminalLogger.debugf { "in tun mode" }
var size = data.size
if (size < 14) {
TerminalLogger.errorf { "packet size error: ${data.size}" }
}
val buff = ByteBuffer.wrap(data).order(ByteOrder.BIG_ENDIAN)
val dstmac = ByteArray(6)
val srcmac = ByteArray(6)
buff.get(dstmac)
buff.get(srcmac)
val k = dstmac.toByteString()
val etherType = buff.getShort()
if (k != node.mac && !isMultiBroadcast(k)) {
TerminalLogger.errorf { "packet to ${macToString(k)} is not direct to us"}
return
}
if (etherType == EtherType.ARP) {
if (size < 42) {
TerminalLogger.errorf { "packet size error: invalid arp length" }
return
}
val arp = ArpHdr.parseFrom(EthHdr(dstmac, srcmac, etherType), buff)
when (arp.opcode) {
EtherType.ARP_REQUEST -> {
TerminalLogger.debugf { "got arp request: dstmac=${macToString(dstmac.toByteString())}" }
if (!dstmac.contentEquals(SDLanConfig.BROADCASTMAC_BYTEARRAY)) {
TerminalLogger.errorf { "arp request should be broadcast" }
}
if (arp.dipaddr == node.deviceConfig.ip.netAddr) {
val macBytes = node.mac.toByteArray()
arpTable.addToARPTable(arp.sipaddr, arp.shwaddr)
arp.opcode = EtherType.ARP_REPLY
arp.dhwaddr = arp.shwaddr
arp.shwaddr = macBytes
arp.ethhdr.src = macBytes
arp.ethhdr.dest = arp.dhwaddr
arp.dipaddr = arp.sipaddr
arp.sipaddr = node.deviceConfig.ip.netAddr
val bytesBuf = arp.marshalToBytes()
val bytesArr = ByteArray(bytesBuf.remaining())
bytesBuf.get(bytesArr)
val encrypted = node.aes.encrypt(bytesArr)
if (encrypted != null) {
val dstmac = arp.dhwaddr.toByteString()
TerminalLogger.debugf {
"send arp reply to ${macToString(dstmac)}"
}
sendPacketToNet(node, dstmac, encrypted, 0)
}
}
}
EtherType.ARP_REPLY -> {
TerminalLogger.debugf { "got arp shwaddr: ${macToString(arp.shwaddr.toByteString())}" }
TerminalLogger.debugf { "got arp dhwaddr: ${macToString(arp.dhwaddr.toByteString())}" }
TerminalLogger.debugf { "got arp sipaddr: ${ipToString(arp.sipaddr)}" }
TerminalLogger.debugf { "got arp dipaddr: ${ipToString(arp.dipaddr)}" }
println("self_ip = ${ipToString(node.deviceConfig.ip.netAddr)}")
if (arp.dipaddr == node.deviceConfig.ip.netAddr) {
TerminalLogger.debugf {"arp reply"}
arpTable.addToARPTable(arp.sipaddr, arp.shwaddr)
arpWaitList.arpArrived(node, arp.sipaddr, arp.shwaddr.toByteString())
}
}
else -> {
TerminalLogger.errorf { "unknown ARP type" }
}
}
} else {
if (data.size < 34) {
TerminalLogger.errorf { "packet size error: ${data.size}" }
return
}
val rest = data.sliceArray(14..data.size-1)
val buffer = ByteBuffer.wrap(rest, 12, 8)
val srcip = buffer.getInt()
if (!isMultiBroadcast(srcmac.toByteString())) {
arpTable.addToARPTable(srcip, srcmac)
}
send(rest)
}
}
override suspend fun handleDataFromDevice(node: Node, data: ByteArray) {
if (data.size < 20) {
TerminalLogger.errorf { "too short ip packet" }
return
}
if (!node.aes.isAuthorized()) {
TerminalLogger.infof { "dropping tun packet due to not authed" }
return
}
val version = (data[0].toInt() ushr 4) and 0x0f
if (version != 4) {
TerminalLogger.debugf { "dropping non-IPv4 packet from tun: version=$version, bytes=${data.size}" }
return
}
val buffer = ByteBuffer.wrap(data, 12, 8).order(ByteOrder.BIG_ENDIAN)
val srcip = buffer.getInt()
val dstip = buffer.getInt()
TerminalLogger.debugf { "got ${data.size} bytes from tun" }
TerminalLogger.debugf { "IPv4 flow from tun: ${describeIpv4Packet(data)}" }
if (!node.config.allowRouting && (srcip != node.deviceConfig.ip.netAddr)) {
TerminalLogger.infof { "dropping routed packet from tun" }
return
}
if (node.config.dropMulticast && (dstip.toUInt() shr 28) == 14u) {
TerminalLogger.debugf { "dropping multicast packet from tun: ${com.jihe.punchnet.sdlan.utils.ipToString(dstip)}" }
return
}
val (arpinfo, gwip) = arpTable.getMacFromIP(dstip)
if (arpinfo == null) {
println("arp info is null")
// arp not found
arpWaitList.addToWaitList(gwip, data)
TerminalLogger.debugf { "added to wait list" }
sendArpRequest(node, gwip)
TerminalLogger.debugf { "sent arp request" }
} else {
println("mac is ${macToString(arpinfo.toByteString())}")
val buffer = arpinfo + node.mac.toByteArray() +
byteArrayOf((EtherType.IPV4.toInt() shr 8).toByte(), EtherType.IPV4.toByte()) +
data
val size = buffer.size
val encrypted = node.aes.encrypt(buffer)
if (encrypted != null) {
val mac = arpinfo.toByteString()
sendPacketToNet(node, mac, encrypted, size.toLong())
}
}
}
}
class IfaceMock: IfaceTun {
val deviceName = "dev0"
override val arpTable = ARPTable(CoroutineScope(Dispatchers.IO))
override val arpWaitList = ARPWaitList()
val sock = Socket(Inet4Address.getByName("127.0.0.1"), 1234)
val input: DataInputStream = DataInputStream(sock.getInputStream())
val output: DataOutputStream = DataOutputStream(sock.getOutputStream())
var config: DeviceConfig = DeviceConfig(0)
override fun close() {
}
override suspend fun recv(): ByteArray {
val result = withContext(Dispatchers.IO) {
val size = input.readInt()
var result = ByteArray(size)
input.read(result)
result
}
return result
}
override suspend fun send(content: ByteArray) {
withContext(Dispatchers.IO) {
val size = content.size
TerminalLogger.debugf {"sending ${content.size} bytes to tun"}
output.writeInt(size)
output.write(content)
}
}
override suspend fun reload_config(config: DeviceConfig) {
this.config = config
val ip = ipInt2ByteArray(config.ip.netAddr).toIPV4String()
var command = "ifconfig ${deviceName} $ip"
command += " netmask ${ipInt2ByteArray(netmaskBit2Int(config.ip.netBitLen)).toIPV4String()}"
command += " mtu 1400"
command += " up"
TerminalLogger.debugf { "executing command: ${command}" }
runCommand(command)
}
}
fun String.execute(): Process {
val runtime = Runtime.getRuntime()
return runtime.exec(this)
}
fun Process.text(): String {
val inputStream = this.inputStream
val insReader = InputStreamReader(inputStream)
val bufReader = BufferedReader(insReader)
var output = ""
var line: String? = ""
while(line != null) {
line = bufReader.readLine()
output += line + "\n"
}
return output
}
fun runCommand(command: String): String {
val process = command.execute()
val exitCode = process.waitFor()
val text = process.text()
println("exit code: $exitCode")
return text
}
fun netmaskBit2Int(len: Byte): Int {
var res = 0
for (i in 1..len) {
res = res or (1 shl (32 - i))
}
return res
}
/*
fun macToString(mac: Mac): String {
var result = mutableListOf<String>()
for (m in mac) {
result.add(m.toString(16))
}
return result.joinToString(":")
}
*/

View File

@ -0,0 +1,502 @@
package com.jihe.punchnet.sdlan.network
import android.util.Log
import com.jihe.punchnet.PunchnetServiceArgument
import com.jihe.punchnet.data.ButtonRepository
import com.jihe.punchnet.data.ButtonState
import com.jihe.punchnet.protobuf.PunchProto.SDLRegisterSuper
import com.jihe.punchnet.protobuf.PunchProto.SDLRegisterSuperAck
import com.jihe.punchnet.protobuf.PunchProto.SDLRegisterSuperNak
import com.jihe.punchnet.protobuf.PunchProto.SDLExposedServiceResponse
import com.jihe.punchnet.protobuf.PunchProto.SDLPolicyResponse
import com.jihe.punchnet.protobuf.PunchProto.SDLStunProbeReply
import com.jihe.punchnet.sdlan.config.Arguments
import com.jihe.punchnet.sdlan.config.RSAConfig
import com.jihe.punchnet.sdlan.config.SDLanConfig
import com.jihe.punchnet.sdlan.config.SDLanMulticastConfig
import com.jihe.punchnet.sdlan.config.toIPV4String
import com.jihe.punchnet.sdlan.logs.TerminalLogger
import com.jihe.punchnet.sdlan.utils.RSA
import com.jihe.punchnet.sdlan.utils.UniqueNodeID
import com.jihe.punchnet.sdlan.utils.ipToString
import com.jihe.punchnet.sdlan.utils.macToString
import com.jihe.punchnet.sdlan.utils.parseScoketAddressV4FromString
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.channels.SendChannel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.selects.select
import kotlinx.coroutines.withContext
import java.io.DataInputStream
import java.io.DataOutputStream
import java.net.InetSocketAddress
import java.net.Socket
import java.net.SocketAddress
import java.nio.ByteBuffer
import java.nio.file.Paths
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
import kotlin.io.path.pathString
import kotlin.system.exitProcess
suspend fun onMessage(scope: CoroutineScope, data: SDLanControl) {
val node = Node.getInstance()
TerminalLogger.debugf {"message received"}
when(data.packetType) {
PacketType.Welcome -> {
TerminalLogger.debugf { "got welcome, sending register super" }
if (node.clientId.isEmpty()) {
node.clientId = "punchnet_android_client_" + UniqueNodeID.getUUID()
}
val registerIp = com.jihe.punchnet.sdlan.utils.ipStringToInt(node.connectData?.ip ?: "0.0.0.0").toUInt().toInt()
val registerSuper = SDLRegisterSuper.newBuilder()
.setClientId(node.clientId)
.setNetworkId(node.authData?.networkId ?: 0)
.setMac(com.google.protobuf.ByteString.copyFrom(node.mac.toByteArray()))
.setIp(registerIp)
.setMaskLen(node.connectData?.maskLen ?: 0)
.setHostname(node.connectData?.hostname ?: "")
.setPubKey(node.rsa.getPublicKeyString())
.setAccessToken(node.authData?.accessToken ?: "")
.build()
val msg = encodeToControlMessage(registerSuper, PacketType.RegisterSuper)
node.toSocket.send(msg.toByteArray())
TerminalLogger.debugf {
"sent RegisterSuper clientIdTail=${node.clientId.takeLast(12)}, network=${registerSuper.networkId}, " +
"ip=${ipToString(registerSuper.ip)}, mask=${registerSuper.maskLen}, mac=${macToString(registerSuper.mac)}, " +
"identity=${node.connectData?.identityId ?: 0}"
}
}
PacketType.RegisterSuperACK -> {
val ack = SDLRegisterSuperAck.parseFrom(data.currentPacket)
val algorithm = ack.algorithm.ifEmpty { "aes" }.lowercase()
TerminalLogger.debugf {
"got register super ack: algorithm=$algorithm, region=${ack.regionId}, sessionTokenBytes=${ack.sessionToken.size()}"
}
if (algorithm != "aes" && algorithm != "chacha20") {
TerminalLogger.errorf { "unsupported encryption algorithm from server: ${ack.algorithm}" }
node.startStopChannel.send(StartStopChanInfo(StartStopFlag.IsStop, null))
ButtonRepository.updateState(ButtonState.ButtonStopped)
scope.cancel()
return
}
val aeskey = node.rsa.decrypt(ack.key.toByteArray())
if (aeskey == null) {
println("aes key error")
return
}
node.aes.setSecret(aeskey, algorithm, ack.regionId.toLong())
node.sessionToken = ack.sessionToken
node.deviceConfig.ip.netAddr = com.jihe.punchnet.sdlan.utils.ipStringToInt(node.connectData?.ip ?: "0.0.0.0").toUInt().toInt()
node.deviceConfig.ip.netBitLen = (node.connectData?.maskLen ?: 24).toByte()
if (node.iface is IfaceTun) {
val tun = node.iface as IfaceTun
val maskVal = com.jihe.punchnet.sdlan.network.maskDigitToInt(node.deviceConfig.ip.netBitLen.toInt()) ?: 0xffffff00.toInt()
tun.arpTable.routeTable.addRoute(
com.jihe.punchnet.sdlan.network.RouteDetail(
mask = maskVal,
gw = 0,
maskedAddr = node.deviceConfig.ip.netAddr and maskVal
)
)
}
node.iface?.reload_config(node.deviceConfig)
node.networkID.set(node.authData?.networkId ?: 0)
TerminalLogger.debugf {
"control registered: clientIdTail=${node.clientId.takeLast(12)}, network=${node.networkID.get()}, " +
"ip=${ipToString(node.deviceConfig.ip.netAddr)}, mask=${node.deviceConfig.ip.netBitLen}, " +
"mac=${macToString(node.mac)}, identity=${node.connectData?.identityId ?: 0}"
}
sendExposedServiceRequest(node)
node.sendStunRequest()
scope.launch {
try {
node.probeNatType()
node.sendStunRequest()
TerminalLogger.debugf { "updated NAT type after probe: ${node.nat_type}" }
} catch (e: Exception) {
TerminalLogger.warning { "NAT probe failed: $e" }
}
}
}
PacketType.RegisterSuperNAK -> {
val nak = SDLRegisterSuperNak.parseFrom(data.currentPacket)
TerminalLogger.debugf { "got register super nak: $nak" }
val nakcode = NakMsgCode.fromByte(nak.errorCode.toByte())
when(nakcode) {
NakMsgCode.InvalidToken, NakMsgCode.NodeDisabled-> {
node.startStopChannel.send(StartStopChanInfo(StartStopFlag.IsStop, null))
ButtonRepository.updateState(ButtonState.ButtonStopped)
scope.cancel()
}
else -> {
ButtonRepository.updateState(ButtonState.ButtonStopped)
node.startStopChannel.send(StartStopChanInfo(StartStopFlag.IsStop, null))
}
}
node.aes.setSecret(null)
}
PacketType.Command -> {
TerminalLogger.debugf {"Command received"}
if (data.currentPacket.size < 1) {
TerminalLogger.errorf { "malformed command received"}
return
}
// handleTcpCommand(node, data.currentPacket[0], data.currentPacket.sliceArray(1..data.currentPacket.size-1))
}
PacketType.PeerInfo -> {
TerminalLogger.debugf { "PeerInfo received" }
handlePacketPeerInfo(node, data.currentPacket)
}
PacketType.Event -> {
TerminalLogger.debugf { "Event received" }
handleEvent(node, data.currentPacket)
}
PacketType.Pong -> {
TerminalLogger.debugf { "Pong Received" }
}
PacketType.ArpResponse -> {
TerminalLogger.debugf { "ArpResponse received" }
handlePacketArpResponse(node, data.currentPacket)
}
PacketType.PolicyResponse -> {
val policy = SDLPolicyResponse.parseFrom(data.currentPacket)
node.queriedPolicies[policy.dstIdentityId] = policy.version
node.policyRules[policy.dstIdentityId] = policy.rules.toByteArray()
val ruleSummary = describePolicyRules(policy.rules.toByteArray())
val targetIp = node.queriedPolicyTargets[policy.dstIdentityId]
val targetText = targetIp?.let { ", dstIp=${ipToString(it)}, ${describeResourcesForTarget(node, it)}" } ?: ""
if (policy.rules.isEmpty) {
TerminalLogger.warning {
"PolicyResponse has no allow rules: srcIdentity=${policy.srcIdentityId}, " +
"dstIdentity=${policy.dstIdentityId}, version=${policy.version}$targetText"
}
} else {
TerminalLogger.debugf {
"PolicyResponse received srcIdentity=${policy.srcIdentityId}, dstIdentity=${policy.dstIdentityId}, " +
"version=${policy.version}, rules=$ruleSummary$targetText"
}
}
}
PacketType.ExposedServiceResponse -> {
val exposed = SDLExposedServiceResponse.parseFrom(data.currentPacket)
TerminalLogger.debugf {
"ExposedServiceResponse received version=${exposed.version}, " +
"tcpPorts=${exposed.tcpPortsList.size}, udpPorts=${exposed.udpPortsList.size}"
}
}
else -> {
println("error packet type: ${data.packetType.toUByte()}")
}
}
}
suspend fun run_sdlan(scope: CoroutineScope, iface: Iface, argument: Arguments, authData: com.jihe.punchnet.api.AuthResponseData, connectData: com.jihe.punchnet.api.ConnectResponseData) {
UniqueNodeID.setBaseDir(argument.baseDir)
val edgeUUID = UniqueNodeID.getUUID()
val config = parseConfig(edgeUUID, argument)
if (config == null) {
TerminalLogger.errorf {
"parse config failed"
}
return
}
val toSocket = Channel<ByteArray>(100)
val start_stop_channel = Channel<StartStopChanInfo>(100)
initEdge(scope, iface, argument.token, config, toSocket, start_stop_channel)
val snParts = argument.sn.split(":")
val now = System.currentTimeMillis() / 1000
val node = Node.getInstance()
node.authData = authData
node.connectData = connectData
TerminalLogger.debugf { "self mac: ${macToString(node.mac)}"}
TerminalLogger.debugf { describeConnectDataForLog(node) }
val onConnected: suspend (tech.kwik.core.QuicStream) -> Unit = { stream ->
TerminalLogger.debugf { "QUIC stream connected callback" }
// We wait for server's PACKET_WELCOME before sending RegisterSuper
}
scope.launch {
try {
while(true) {
if (node.iface == null) {
delay(1000)
continue
}
val data = node.iface?.recv()
Log.d("SDLAN", "async receive data from iface: ${data?.size} bytes")
if (data == null) {
delay(1000)
continue
}
if (data.isEmpty()) {
delay(1000)
continue
}
Log.d("SDLAN", "handle data form device starts")
node.iface?.handleDataFromDevice(node, data)
Log.d("SDLAN", "handle data form device stops")
}
} catch (e: Exception) {
println("iface read is cancelled")
node.iface?.close()
node.udpSockV4.close()
node.udpSockMulticast?.close()
}
}
scope.launch {
initQUICConn(
scope,
snParts[0], snParts[1].toIntOrNull() ?: 1265,
start_stop_channel,
AtomicLong(now),
AtomicBoolean(false),
toSocket as ReceiveChannel<ByteArray>,
onConnected,
::onMessage,
suspend {
node.aes.setSecret(null)
},
null,
)
}
println("sending start")
start_stop_channel.send(StartStopChanInfo(StartStopFlag.IsStart, null))
println("sent start")
val cancel = Channel<Boolean>(100)
runEdgeLoop(scope, node, cancel)
while(true) {
TerminalLogger.debugf {"ping to sn"}
delay(SDLanConfig.TCP_PING_TIME*1000)
node.ping_to_sn()
}
}
suspend fun runEdgeLoop(scope: CoroutineScope, node: Node, cancel: ReceiveChannel<Boolean>) {
node.ping_to_sn()
scope.launch {
loopSocketV4(scope, node, node.udpSockV4, cancel)
}
}
suspend fun loopSocketV4(scope: CoroutineScope, node: Node, sock: SDLanSocket, cancel: ReceiveChannel<Boolean>) {
val job_stun_request = scope.async {
try {
while(true) {
delay(10_000)
node.probeNatType()
node.sendStunRequest()
}
} catch (e: Exception) {
Log.e("Socket", "job stun request exits: $e")
}
}
val job_handle_packet = scope.async {
try {
while(true) {
readAndParsePacket(scope, node, sock)
}
} catch (e: Exception) {
sock.close()
}
}
select<Unit> {
job_stun_request.onAwait() {
}
cancel.onReceive() {
}
job_handle_packet.onAwait() {
}
}
job_stun_request.cancelAndJoin()
job_handle_packet.cancelAndJoin()
Log.e("Socket", "loop socket v4 stops")
}
suspend fun readAndParsePacket(scope: CoroutineScope, node: Node, sock: SDLanSocket) {
val packet = sock.receive()
val from = packet.socketAddress
if (packet.length < 1) {
TerminalLogger.errorf { "got zero-sized packet"}
return
}
val data = ByteBuffer.wrap(packet.data, 0, packet.length)
// val data = packet.data.toByteString(0, packet.length)
handleAPacket(scope, node, from, data)
}
suspend fun handleAPacket(scope: CoroutineScope, node: Node, from: SocketAddress, data: ByteBuffer) {
val pktType = PacketType.fromValue(data.get().toUByte())
if (pktType == null) {
TerminalLogger.errorf { "invalid packet type" }
return
}
// val buffer = ByteBuffer.wrap(data, 1, size-1)
when (pktType) {
PacketType.Data -> {
TerminalLogger.debugf { "got DATA" }
if (!node.aes.isAuthorized()) {
TerminalLogger.errorf { "drop DATA before authed" }
return
}
if (from is InetSocketAddress) {
TerminalLogger.debugf {"got data"}
val sock = SDLanSock(IPFamily.IPV4, from.port, from.address.address)
handlePacketData(scope, node, data, sock)
}
}
PacketType.StunProbeReply -> {
try {
val reply = SDLStunProbeReply.parseFrom(data)
node.natProbeCookie.sendMessage(reply.cookie, reply)
} catch (e: Exception) {
TerminalLogger.errorf { "failed to decode Probe Reply"}
}
}
PacketType.StunReply -> {
TerminalLogger.debugf { "got stun reply" }
return
}
PacketType.PolicyResponse -> {
TerminalLogger.debugf { "got policy response" }
return
}
PacketType.ExposedServiceResponse -> {
TerminalLogger.debugf { "got exposed service response" }
return
}
PacketType.Register -> {
TerminalLogger.debugf { "got REGISTER" }
if (from is InetSocketAddress) {
val sock = SDLanSock(IPFamily.IPV4, from.port, from.address.address)
handlePacketRegister(node, data, sock)
}
}
PacketType.RegisterACK -> {
TerminalLogger.debugf { "got REGISTERACK" }
if (!node.aes.isAuthorized()) {
TerminalLogger.errorf { "drop REGISTERACK before authed" }
return
}
if (from is InetSocketAddress) {
val sock = SDLanSock(IPFamily.IPV4, from.port, from.address.address)
handlePacketRegisterACK(node, data, sock)
}
}
else -> {
TerminalLogger.debugf {"ignore packet type: $pktType"}
}
}
}
fun initEdge(scope: CoroutineScope, iface: Iface, token: String, config: NodeConfig, toSocket: SendChannel<ByteArray>, startStopChannel: SendChannel<StartStopChanInfo>) {
val rsa = RSA.getRSA()
val pathname = Paths.get(config.baseDir, RSAConfig.BASE_DIR).pathString
Log.d("DIR", "pathname = $pathname")
rsa.generateKeyPair(pathname)
//rsa.generateKeyPair(Path.of(config.baseDir, RSAConfig.BASE_DIR).name)
val sockV4 = SDLanSocket(scope, "0.0.0.0", config.localPort)
var sockMulticast: SDLanSocket? = null
if (!config.dropMulticast) {
sockMulticast = SDLanSocket(scope, SDLanMulticastConfig.MULTICAST_V4.toIPV4String(), SDLanMulticastConfig.MULTICAST_PORT)
}
Node.initialize(
config,
sockV4,
sockMulticast,
token,
rsa,
iface,
toSocket,
startStopChannel,
)
val instance = Node.getInstance()
instance.token = token
instance.udpSockV4 = sockV4
instance.udpSockMulticast = sockMulticast
instance.config = config
instance.toSocket = toSocket
instance.startStopChannel = startStopChannel
println(instance)
}
fun parseConfig(nodeuuid: String, argument: Arguments): NodeConfig? {
if (argument.sn.length == 0) {
println("no sn is specified")
return null
}
val sn = parseScoketAddressV4FromString(argument.sn)
if (sn == null) {
return null
}
val stun = parseScoketAddressV4FromString(argument.stun)
if (stun == null) {
return null
}
val natServer1 = parseScoketAddressV4FromString(argument.nat_server1)
val natServer2 = parseScoketAddressV4FromString(argument.nat_server2)
if (natServer1 == null || natServer2 == null) {
return null
}
return NodeConfig(
baseDir = argument.baseDir,
name = argument.name,
nodeUUID = nodeuuid,
allowRouting = argument.allowRouting,
dropMulticast = argument.dropMulticast,
allowP2P = argument.allowP2P,
mtu = argument.mtu,
tos = argument.tos,
registerSuperInterval = SDLanConfig.RegisterSuperInterval,
registerTTL = argument.registerTTL,
localPort = argument.localPort,
supernode = SDLanSock(IPFamily.IPV4, sn.port, sn.address.address),
stunServer = SDLanSock(IPFamily.IPV4, stun.port, stun.address.address),
natServer1 = natServer1,
natServer2 = natServer2
)
}

View File

@ -0,0 +1,48 @@
package com.jihe.punchnet.sdlan.network
enum class NakMsgCode(val code: Byte) {
InvalidMsg(0),
InvalidToken(1),
NodeDisabled(2),
NoIPAddress(3),
NetworkFault(4),
InternalFault(5);
fun toByte(): Byte {
return this.code
}
companion object {
fun fromByte(code: Byte): NakMsgCode {
return when(code) {
1.toByte() -> InvalidToken
2.toByte() -> NodeDisabled
3.toByte() -> NoIPAddress
4.toByte() -> NetworkFault
5.toByte() -> InternalFault
else -> InvalidMsg
}
}
}
}
enum class EventType(val code: Byte) {
KnownIP(1),
DropIP(2),
NatChanged(3),
SendRegister(4),
NetworkShutdown(0xff.toByte());
companion object {
fun fromByte(code: Byte): EventType {
return when(code) {
1.toByte() -> KnownIP
2.toByte() -> DropIP
3.toByte() -> NatChanged
4.toByte() -> SendRegister
else -> NetworkShutdown
}
}
}
}

View File

@ -0,0 +1,133 @@
package com.jihe.punchnet.sdlan.network
import com.google.protobuf.Message
import java.net.Inet4Address
import java.net.Inet6Address
import java.net.InetSocketAddress
import java.net.SocketAddress
import java.nio.ByteBuffer
import java.nio.ByteOrder
enum class StartStopFlag {
IsStart,
IsStop,
}
// 本地启动或者停止服务的信息
class StartStopChanInfo(
val flag: StartStopFlag,
val packetID: Int?,
)
enum class PacketType(val id: UByte) {
Empty(0x00u),
RegisterSuper(0x01u),
RegisterSuperACK(0x02u),
RegisterSuperNAK(0x04u),
UnRegisterSuper(0x05u),
QueryInfo(0x06u),
PeerInfo(0x07u),
Ping(0x08u),
Pong(0x09u),
Event(0x10u),
Command(0x11u),
CommandACK(0x12u),
FlowTracer(0x15u),
Register(0x20u),
RegisterACK(0x21u),
StunRequest(0x30u),
StunReply(0x31u),
StunProbe(0x32u),
StunProbeReply(0x33u),
Welcome(0x4fu),
ArpRequest(0x50u),
ArpResponse(0x51u),
PolicyRequest(0xb0u),
PolicyResponse(0xb1u),
ExposedServiceRequest(0xb2u),
ExposedServiceResponse(0xb3u),
Data(0xffu);
companion object {
private val innermap = PacketType.entries.map {it.id to it}.toMap()
fun fromValue(value: UByte): PacketType? {
return innermap[value]
}
}
}
fun PacketType.toUByte(): UByte {
return this.id
}
// tcp/quic发送过来的通道里面的信息
class SDLanControl(
val packetType: PacketType,
val currentPacket: ByteArray,
)
enum class ConnectingState {
NotConnected,
Connecting,
Connected,
}
suspend fun sendToSock(node: Node, content: ByteArray, sock: SDLanSock) {
val target: SocketAddress
when (sock.family) {
IPFamily.IPV4 -> {
target = InetSocketAddress(Inet4Address.getByAddress(sock.ip), sock.port)
}
IPFamily.IPV6 -> {
target = InetSocketAddress(Inet6Address.getByAddress(sock.ip), sock.port)
}
}
node.udpSockV4.send_to(content, target)
}
suspend fun sendToSock(node: Node, content: List<Byte>, sock: SDLanSock) {
sendToSock(node, content.toByteArray(), sock)
}
fun encodeToUDPMessage(msg: Message?, packetType: PacketType): List<Byte> {
val result: MutableList<Byte> = mutableListOf()
val msgByte = msg?.toByteArray()?.toList()?:listOf<Byte>()
result.add(packetType.toUByte().toByte())
result.addAll(msgByte)
return result
}
fun encodeToControlMessage(msg: Message?, packetType: PacketType): List<Byte> {
val msgByte = msg?.toByteArray()?.toList()?:listOf<Byte>()
val result: MutableList<Byte> = mutableListOf()
result.addAll(ByteBuffer.allocate(2)
.order(ByteOrder.BIG_ENDIAN)
.putShort((msgByte.size + 1).toShort())
.array().toList())
result.add(packetType.toUByte().toByte())
result.addAll(msgByte)
return result
}
enum class StunProbeAttr {
None,
Port,
Peer,
}

View File

@ -0,0 +1,107 @@
package com.jihe.punchnet.sdlan.network
import com.google.protobuf.ByteString
import com.jihe.punchnet.sdlan.config.SDLanConfig
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentMap
import java.util.concurrent.atomic.AtomicLong
typealias Mac = ByteString
// typealias PeerMap = ConcurrentMap<Mac, EdgePeer>
class PeerMap private constructor (val inner: ConcurrentMap<Mac, EdgePeer>): ConcurrentMap<Mac, EdgePeer> by inner {
constructor(): this(ConcurrentHashMap<Mac, EdgePeer>())
}
class IPSubnet(var netAddr: Int, var netBitLen: Byte) {
}
enum class NatType(val value: UByte) {
Blocked(0u),
NoNat(1u),
FullCone(2u),
PortRestricted(3u),
ConeRestrict(4u),
Symmetric(5u),
Invalid(0xffu);
companion object {
fun fromUByte(code: UByte): NatType {
return when(code) {
0.toUByte() -> Blocked
1.toUByte() -> NoNat
2.toUByte() -> FullCone
3.toUByte() -> PortRestricted
4.toUByte() -> ConeRestrict
5.toUByte() -> Symmetric
else -> Invalid
}
}
}
}
fun NatType.toByte(): UByte {
return value
}
enum class IPFamily {
IPV4, IPV6
}
class SDLanSock constructor(var family: IPFamily, var port: Int, var ip: ByteArray) {
override fun toString(): String {
when(family) {
IPFamily.IPV6 -> {
assert(ip.size == 16)
val ipstr = ip.map {
it.toString(16)
}.joinToString(":")
return "[$ipstr]:${port}"
}
IPFamily.IPV4 -> {
assert(ip.size == 4)
val digit = ip[0]
return "${ip[0].toUByte()}.${ip[1].toUByte()}.${ip[2].toUByte()}.${ip[3].toUByte()}:${port}"
}
}
}
fun is_equal(other: SDLanSock): Boolean {
return (family==other.family)
&& (port == other.port)
&& (ip.contentEquals(other.ip))
}
}
class EdgePeer private constructor (
var devAddress: IPSubnet,
var natType: NatType,
var sock: SDLanSock,
var ipv6Info: SDLanSock?,
// timeout of the family
val timeout: Byte,
// 最近一次与该edge通信
val lastSeen: AtomicLong,
// 最近一次与该edge有P2P通信
val lastP2P: AtomicLong,
// 最近一次向服务器查询该edge信息
val lastSentQuery: AtomicLong,
) {
// var dev_addr: IPSubnet = IPSubnet(0, 0)
constructor(netAddress: Int, netBitLen: Byte, sock: SDLanSock, v6Info: SDLanSock?, now: Long): this(
IPSubnet(netAddress, netBitLen),
NatType.Blocked,
sock,
v6Info,
SDLanConfig.RegisterSuperInterval,
AtomicLong(now),
AtomicLong(now),
AtomicLong(now),
) {
}
}

View File

@ -0,0 +1,214 @@
package com.jihe.punchnet.sdlan.network
import android.util.Log
import com.jihe.punchnet.sdlan.logs.TerminalLogger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.channels.SendChannel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.selects.select
import kotlinx.coroutines.withContext
import tech.kwik.core.QuicClientConnection
import tech.kwik.core.QuicStream
import java.net.URI
import java.nio.ByteBuffer
import java.io.InputStream
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
suspend fun initQUICConn(
scope: CoroutineScope,
quicHost: String,
quicPort: Int,
start_stop: Channel<StartStopChanInfo>,
pong_time: AtomicLong,
connected: AtomicBoolean,
toSocket: ReceiveChannel<ByteArray>,
onConnected: suspend (stream: QuicStream)->Unit,
onMessage: suspend (CoroutineScope, SDLanControl)->Unit,
onDisconnected: suspend ()->Unit,
connectingChan: SendChannel<ConnectingState>?
) {
val started = AtomicBoolean(false)
val node = Node.getInstance()
while (scope.isActive) {
connectingChan?.send(ConnectingState.NotConnected)
if (!started.get()) {
while (true) {
val startStopInfo = start_stop.receive()
if (startStopInfo.flag == StartStopFlag.IsStart) {
started.set(true)
break
}
}
}
connectingChan?.send(ConnectingState.Connecting)
TerminalLogger.debugf { "try connecting quic..." }
var connection: QuicClientConnection? = null
var stream: QuicStream? = null
try {
withContext(Dispatchers.IO) {
TerminalLogger.debugf { "connecting to QUIC $quicHost:$quicPort" }
val builder = QuicClientConnection.newBuilder()
.uri(URI("https://$quicHost:$quicPort"))
.applicationProtocol("punchnet/1.0")
.noServerCertificateCheck()
connection = builder.build()
connection!!.connect()
// Open bidirectional stream
stream = connection!!.createStream(true)
// In QUIC, streams are created implicitly when the first data is sent.
// We send a PACKET_EMPTY (0x00) so the server can accept the stream and send Welcome.
val emptyPacket = ByteArray(3)
emptyPacket[0] = 0 // Len high
emptyPacket[1] = 1 // Len low (1 byte for PacketType)
emptyPacket[2] = 0 // PacketType.Empty (0x00)
TerminalLogger.debugf { "Sending QUIC application packet (PACKET_EMPTY): [00 01 00]" }
stream!!.outputStream.write(emptyPacket)
stream!!.outputStream.flush()
}
} catch (e: Exception) {
TerminalLogger.errorf { "failed to connect to QUIC ${quicHost}:${quicPort}: $e" }
delay(3000)
continue
}
try {
val job_read_packet = scope.launch(Dispatchers.IO) {
try {
while (isActive) {
val controlPacket = readQUICPacket(stream!!)
if (controlPacket == null) {
TerminalLogger.errorf { "QUIC Stream EOF or error" }
break
}
onMessage(scope, controlPacket)
}
} finally {
TerminalLogger.errorf { "QUIC read loop closing" }
}
}
val job_write_to_packet = scope.launch(Dispatchers.IO) {
try {
while (isActive) {
val msg = toSocket.receive()
val packetType = if (msg.size >= 3) PacketType.fromValue(msg[2].toUByte()) else null
TerminalLogger.debugf { "Sending QUIC application packet: type=${packetType ?: "unknown"}, bytes=${msg.size}" }
stream!!.outputStream.write(msg)
}
} catch (e: Exception) {
TerminalLogger.errorf { "failed to write to QUIC stream: $e" }
}
}
val job_check_pong = scope.launch(Dispatchers.IO) {
while (isActive) {
delay(10_000)
val now = System.currentTimeMillis() / 1000
if (connected.get() && (now - pong_time.get() > com.jihe.punchnet.sdlan.config.SDLanConfig.TCP_PING_TIME * 2)) {
TerminalLogger.errorf { "QUIC pong check expired" }
break
}
}
}
val job_check_stop = scope.launch(Dispatchers.IO) {
while (isActive) {
try {
val v = start_stop.receive()
if (v.flag == StartStopFlag.IsStop) {
started.set(false)
break
}
} catch (e: Exception) {
break
}
}
}
TerminalLogger.debugf { "QUIC Stream connected" }
onConnected(stream!!)
connectingChan?.send(ConnectingState.Connected)
select<Unit> {
job_read_packet.onJoin { }
job_write_to_packet.onJoin { }
job_check_pong.onJoin { }
job_check_stop.onJoin { }
}
job_read_packet.cancelAndJoin()
job_write_to_packet.cancelAndJoin()
job_check_pong.cancelAndJoin()
job_check_stop.cancelAndJoin()
delay(1000)
} catch (e: Exception) {
onDisconnected()
} finally {
try {
connection?.close()
} catch (e: Exception) {}
}
}
}
suspend fun readQUICPacket(stream: QuicStream): SDLanControl? = withContext(Dispatchers.IO) {
try {
// <<Len:16, PacketType:8, ProtobufPayload>>
val lenBytes = ByteArray(2)
if (!stream.inputStream.readFullyOrEof(lenBytes)) return@withContext null
val len = ByteBuffer.wrap(lenBytes).short.toInt() and 0xFFFF
if (len < 1) return@withContext null
val typeByte = ByteArray(1)
if (!stream.inputStream.readFullyOrEof(typeByte)) return@withContext null
val packetTypeUByte = typeByte[0].toUByte()
val packetType = PacketType.fromValue(packetTypeUByte)
if (packetType == null) {
TerminalLogger.errorf { "packet type not found: $packetTypeUByte" }
return@withContext null
}
val payloadLen = len - 1
val payload = ByteArray(payloadLen)
var toread = payloadLen
var offset = 0
while (toread > 0) {
val sizeGot = stream.inputStream.read(payload, offset, toread)
if (sizeGot <= 0) return@withContext null
toread -= sizeGot
offset += sizeGot
}
return@withContext SDLanControl(packetType, payload)
} catch (e: Exception) {
TerminalLogger.errorf { "failed to read QUIC stream: $e" }
return@withContext null
}
}
private fun InputStream.readFullyOrEof(buffer: ByteArray): Boolean {
var offset = 0
while (offset < buffer.size) {
val readSize = read(buffer, offset, buffer.size - offset)
if (readSize <= 0) return false
offset += readSize
}
return true
}

View File

@ -0,0 +1,106 @@
package com.jihe.punchnet.sdlan.network
import android.util.Log
import com.jihe.punchnet.PunchnetServiceArgument
import com.jihe.punchnet.api.ConnectResponseData
import com.jihe.punchnet.sdlan.config.Arguments
import com.jihe.punchnet.sdlan.logs.TerminalLogger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import tech.kwik.core.QuicClientConnection
import java.net.InetSocketAddress
import java.nio.ByteBuffer
suspend fun run_quic_data_plane(
scope: CoroutineScope,
service: com.jihe.punchnet.PunchnetService,
argument: Arguments,
connectData: ConnectResponseData
) {
TerminalLogger.infof { "Starting QUIC Data Plane..." }
// Parse the server address from argument.sn (e.g. root.punchsky.com:1265)
val snParts = argument.sn.split(":")
val host = snParts[0]
val port = snParts[1].toIntOrNull() ?: 1265
while (scope.isActive) {
var connection: QuicClientConnection? = null
try {
TerminalLogger.debugf { "Connecting to QUIC server at $host:$port..." }
// In Kwik, QuicClientConnection is used to connect to a server.
// Note: Kwik API details may require a specific Builder or config.
// We use standard connection parameters.
val serverAddress = InetSocketAddress(host, port)
val builder = QuicClientConnection.newBuilder()
.uri(java.net.URI("https://$host:$port"))
// TODO: configure certificates if self-signed, else default is fine.
connection = builder.build()
connection.connect()
TerminalLogger.infof { "QUIC connection established!" }
// Start reading from the TUN interface and sending as QUIC Datagrams
val jobWrite = scope.launch(Dispatchers.IO) {
while (isActive) {
val data = service.recv()
if (data.isEmpty()) {
delay(1000)
continue
}
// Send over QUIC Datagram
// Note: Kwik Datagram API might be on the connection object.
try {
// Using datagram sending API from Kwik
// If kwik doesn't support datagram directly, we fallback to streams
// For RFC 9221 datagrams:
// connection.sendDatagram(data)
// Note: Datagram API in Kwik is still experimental in some versions.
// We will use stream for fallback if datagram fails compile.
} catch (e: Exception) {
e.printStackTrace()
}
}
}
// Start reading QUIC Datagrams from server and write to TUN
val jobRead = scope.launch(Dispatchers.IO) {
while (isActive) {
// Receive Datagram
// val buf = connection.receiveDatagram()
// service.send(buf)
delay(100)
}
}
jobWrite.join()
jobRead.join()
} catch (e: Exception) {
TerminalLogger.errorf { "QUIC connection error: ${e.message}" }
e.printStackTrace()
delay(3000)
} finally {
try {
connection?.close()
} catch (e: Exception) {}
}
}
}
fun ipToInt(ipAddress: String): Int {
val parts = ipAddress.split(".")
if (parts.size != 4) return 0
var result = 0
for (i in 0..3) {
result = result shl 8 or (parts[i].toInt() and 0xFF)
}
return result
}

View File

@ -0,0 +1,145 @@
package com.jihe.punchnet.sdlan.network
import com.jihe.punchnet.sdlan.logs.TerminalLogger
import org.bouncycastle.util.Strings
import java.util.LinkedList
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.locks.ReentrantReadWriteLock
data class RouteDetail(
// Int representation of 255.255.255.0
val mask: Int,
val gw: Int,
val maskedAddr: Int,
)
fun cidrToRouteDetail(cidr: String, gw: String): RouteDetail? {
val ipAndMaskDigit = Strings.split(cidr, '/')
if (ipAndMaskDigit.size != 2) {
TerminalLogger.debugf { "cidr format error: ${cidr}" }
return null
}
val ip = ipStringToInt(ipAndMaskDigit[0])
val mask = maskDigitToInt(ipAndMaskDigit[1].toInt())
if (ip == null || mask == null) {
TerminalLogger.debugf { "cidr format error2: ${cidr}" }
return null
}
val gateway = ipStringToInt(gw)
if (gateway == null) {
TerminalLogger.debugf { "gateway format error: ${gateway}" }
return null
}
if ((ip and mask) != ip) {
TerminalLogger.debugf { "net not masked" }
return null
}
return RouteDetail(
mask = mask,
gw = gateway,
maskedAddr = ip,
)
}
fun maskIPToDigit(mask: Int): Int {
var count = 0
var temp = mask
while (temp != 0) {
temp = temp shl 1
count += 1
}
return count
}
fun maskDigitToInt(digit: Int): Int? {
if (digit <= 0 || digit >= 32) {
return null
}
return ((1 shl (32-digit)) - 1).inv()
}
fun ipStringToInt(ip: String): Int? {
val ipFiltered = ip.trim { it.isWhitespace() }
val ipSeg = Strings.split(ipFiltered, '.')
if (ipSeg.size != 4) {
return null
}
return ((ipSeg[0].toInt() and 0xff) shl 24) +
((ipSeg[1].toInt() and 0xff) shl 16) +
((ipSeg[2].toInt() and 0xff) shl 8) +
(ipSeg[3].toInt() and 0xff)
}
class RouteTable() {
val lock = ReentrantReadWriteLock()
val routeInfo: MutableList<RouteDetail> = mutableListOf()
fun addRoute(vararg routes: RouteDetail) {
try {
lock.writeLock().lock()
for (route in routes) {
routeInfo.add(route)
}
} finally {
lock.writeLock().unlock()
}
}
fun clearRoute() {
try {
lock.writeLock().lock()
routeInfo.clear()
} finally {
lock.writeLock().unlock()
}
}
fun getGateway(ip: Int): Int? {
try {
lock.readLock().lock()
var bestGw: Int? = null
var bestMaskLen = -1
for (item in routeInfo) {
if ((ip and item.mask) == item.maskedAddr) {
val maskLen = Integer.bitCount(item.mask)
if (maskLen > bestMaskLen) {
bestMaskLen = maskLen
bestGw = item.gw
}
}
}
TerminalLogger.debugf { "RouteTable LPM: ip=${com.jihe.punchnet.sdlan.utils.ipToString(ip)} -> bestGw=${bestGw?.let { com.jihe.punchnet.sdlan.utils.ipToString(it) } ?: "null"} (maskLen=$bestMaskLen)" }
return bestGw
} finally {
lock.readLock().unlock()
}
}
}
class RouteTable2(
val initRouteInfo: List<RouteDetail>
) {
val routeInfo: Array<RouteDetail> = initRouteInfo.toTypedArray()
fun getGeteway(ip: Int): Int? {
var bestGw: Int? = null
var bestMaskLen = -1
for (item in routeInfo) {
if ((ip and item.mask) == item.maskedAddr) {
val maskLen = Integer.bitCount(item.mask)
if (maskLen > bestMaskLen) {
bestMaskLen = maskLen
bestGw = item.gw
}
}
}
return bestGw
}
}

View File

@ -0,0 +1,65 @@
package com.jihe.punchnet.sdlan.network
import android.util.Log
import com.jihe.punchnet.sdlan.logs.TerminalLogger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.Inet4Address
import java.net.SocketAddress
class SDLanSocket(val scope: CoroutineScope, val addr: String, var port: Int, val reuseAddress: Boolean = false) {
private val connection: DatagramSocket = run {
val sock = DatagramSocket(port, Inet4Address.getByName(addr))
if (reuseAddress) {
sock.reuseAddress = true
}
port = sock.localPort
Log.d("SDLanSocket", "local port = $port")
sock
}
fun loop(): ReceiveChannel<DatagramPacket> {
val channel = Channel<DatagramPacket>(100)
scope.launch {
while (true) {
val msg = receive()
channel.send(msg)
}
}
return channel
}
suspend fun send_to(content: ByteArray, target: SocketAddress) {
val packet = DatagramPacket(content, content.size, target)
try {
withContext(Dispatchers.IO) {
connection.send(packet)
}
} catch(e: Exception) {
TerminalLogger.errorf {"Failed to send to: $e, localport: ${port}"}
}
}
suspend fun receive(): DatagramPacket {
val buffer = ByteArray(1500)
val packet = DatagramPacket(buffer, buffer.size)
withContext(Dispatchers.IO) {
connection.receive(packet)
}
return packet
}
fun close() {
connection.close()
}
}

View File

@ -0,0 +1,4 @@
package com.jihe.punchnet.sdlan
fun test() {
}

View File

@ -0,0 +1,147 @@
package com.jihe.punchnet.sdlan.utils
import com.jihe.punchnet.sdlan.config.AESConfig
import com.jihe.punchnet.sdlan.logs.TerminalLogger
import org.bouncycastle.crypto.engines.ChaCha7539Engine
import org.bouncycastle.crypto.params.KeyParameter
import org.bouncycastle.crypto.params.ParametersWithIV
import java.nio.ByteBuffer
import java.nio.ByteOrder
import javax.crypto.Cipher
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
class AES private constructor () {
private var _secret: ByteArray = ByteArray(0)
private var _iv: ByteArray = ByteArray(0)
private var _algorithm: String = "aes"
private var _regionId: Long = 0
companion object {
private var instance: AES? = null
get() {
if (field == null) {
field = AES()
}
return field
}
@Synchronized
fun getAES(): AES {
return instance!!
}
}
fun isAuthorized(): Boolean {
return _secret.size != 0
}
fun setSecret(secret: ByteArray?, algorithm: String = "aes", regionId: Long = 0) {
if (secret == null) {
_secret = ByteArray(0)
_iv = ByteArray(0)
_algorithm = "aes"
_regionId = 0
} else {
_secret = secret
_iv = _secret.sliceArray(0..<16)
_algorithm = algorithm.lowercase()
_regionId = regionId
}
}
fun encrypt(content: ByteBuffer): ByteBuffer? {
if (!isAuthorized()) {
return null
}
try {
val input = ByteArray(content.remaining())
content.get(input)
val encrypted = encrypt(input) ?: return null
return ByteBuffer.wrap(encrypted)
} catch (e: Exception) {
println("failed to encrypt: ${e.toString()}")
return null
}
}
fun encrypt(content: ByteArray): ByteArray? {
if (!isAuthorized()) {
TerminalLogger.errorf { "not authed, so not encrypting" }
return null
}
try {
return when (_algorithm) {
"aes" -> aesCrypt(Cipher.ENCRYPT_MODE, content)
"chacha20" -> chacha20Crypt(content)
else -> {
TerminalLogger.errorf { "unsupported encryption algorithm: $_algorithm" }
null
}
}
} catch (e: Exception) {
TerminalLogger.errorf {"encrypt failed: $e"}
return null
}
}
fun decrypt(ciphered: ByteBuffer): ByteBuffer? {
if (!isAuthorized()) {
return null
}
try {
val input = ByteArray(ciphered.remaining())
ciphered.get(input)
val decrypted = decrypt(input) ?: return null
return ByteBuffer.wrap(decrypted)
} catch (e: Exception) {
return null
}
}
fun decrypt(ciphered: ByteArray): ByteArray? {
if (!isAuthorized()) {
return null
}
try {
return when (_algorithm) {
"aes" -> aesCrypt(Cipher.DECRYPT_MODE, ciphered)
"chacha20" -> chacha20Crypt(ciphered)
else -> null
}
} catch (e: Exception) {
return null
}
}
private fun aesCrypt(mode: Int, content: ByteArray): ByteArray {
val cipher = Cipher.getInstance(AESConfig.CIPHER_ALGORITHM)
val keyspec = SecretKeySpec(_secret, AESConfig.KEY_SPEC)
cipher.init(mode, keyspec, IvParameterSpec(_iv))
return cipher.doFinal(content)
}
private fun chacha20Crypt(content: ByteArray): ByteArray {
val engine = ChaCha7539Engine()
val nonce = chacha20Nonce()
engine.init(true, ParametersWithIV(KeyParameter(_secret), nonce))
val output = ByteArray(content.size)
engine.processBytes(content, 0, content.size, output, 0)
return output
}
private fun chacha20Nonce(): ByteArray {
return ByteBuffer.allocate(12)
.order(ByteOrder.BIG_ENDIAN)
.putLong(0L)
.putInt(_regionId.toInt())
.array()
}
}
fun byteArray2Hex(array: ByteArray): String {
var result = mutableListOf<String>()
for (item in array) {
result.add(String.format("0x%02x", item.toInt() and 0xff))
}
return result.joinToString(" ")
}

View File

@ -0,0 +1,166 @@
package com.jihe.punchnet.sdlan.utils
import com.jihe.punchnet.sdlan.config.RSAConfig
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo
import org.bouncycastle.jce.provider.BouncyCastleProvider
import org.bouncycastle.openssl.PEMParser
import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter
import org.bouncycastle.openssl.jcajce.JcaPEMWriter
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileReader
import java.io.FileWriter
import java.security.KeyPair
import java.security.KeyPairGenerator
import java.security.PrivateKey
import java.security.PublicKey
import java.security.Security
import javax.crypto.Cipher
// 添加BouncyCastle提供者
fun setupBouncyCastle() {
Security.addProvider(BouncyCastleProvider())
}
fun savePublicKeyToPem(publicKey: PublicKey, fileName: String) {
FileWriter(fileName).use { fileWriter ->
JcaPEMWriter(fileWriter).use { pemWriter ->
pemWriter.writeObject(publicKey)
}
}
}
fun savePrivateKeyToPem(privateKey: PrivateKey, fileName: String) {
FileWriter(fileName).use { fileWriter ->
JcaPEMWriter(fileWriter).use { pemWriter ->
pemWriter.writeObject(privateKey)
}
}
}
fun loadPublicKeyFromPem(fileName: String): PublicKey {
FileReader(fileName).use { fileReader ->
PEMParser(fileReader).use { pemParser ->
val converter = JcaPEMKeyConverter()
val keypair = pemParser.readObject()
return converter.getPublicKey(keypair as SubjectPublicKeyInfo)
}
}
}
fun loadPrivateKeyFromPem(fileName: String): PrivateKey {
FileReader(fileName).use { fileReader ->
PEMParser(fileReader).use { pemParser ->
val converter = JcaPEMKeyConverter()
val keypair = pemParser.readObject() as org.bouncycastle.openssl.PEMKeyPair
return converter.getPrivateKey(keypair.privateKeyInfo)
}
}
}
// 生成RSA密钥对
fun generateRSAKeyPair(): KeyPair {
val keyPairGenerator = KeyPairGenerator.getInstance("RSA", "BC")
keyPairGenerator.initialize(2048)
return keyPairGenerator.generateKeyPair()
}
class RSA private constructor () {
private var pubkey: PublicKey? = null
private var prikey: PrivateKey? = null
private var pubkeyStr: String? = null
companion object {
private var instance: RSA? = null
get() {
if (field == null) {
field = RSA()
}
return field
}
@Synchronized
fun getRSA(): RSA {
return instance!!
}
}
fun generateKeyPair(dirname: String) {
setupBouncyCastle()
val pubFile = File(dirname, RSAConfig.PUBLIC_FILE_NAME)
val priFile = File(dirname, RSAConfig.PRIVATE_FILE_NAME)
val dirpath = File(dirname)
dirpath.mkdirs()
if (pubFile.exists() && priFile.exists()) {
pubkey = loadPublicKeyFromPem(pubFile.path)
prikey = loadPrivateKeyFromPem(priFile.path)
} else {
// generate public and private file
val keypair = generateRSAKeyPair()
savePublicKeyToPem(keypair.public, pubFile.path)
savePrivateKeyToPem(keypair.private, priFile.path)
pubkey = keypair.public
prikey = keypair.private
}
pubkeyStr = pubFile.readText()
}
fun encrypt(input: ByteArray, use_private_key: Boolean = true): ByteArray {
val cipher = Cipher.getInstance(RSAConfig.RSAMODE)
cipher.init(Cipher.ENCRYPT_MODE, if (use_private_key) prikey else pubkey)
var temp: ByteArray? = null
var offset = 0
val outputStream = ByteArrayOutputStream()
while(input.size - offset > 0) {
if (input.size - offset >= RSAConfig.ENCRYPT_MAX_SIZE) {
temp = cipher.doFinal(input, offset, RSAConfig.ENCRYPT_MAX_SIZE)
offset += RSAConfig.ENCRYPT_MAX_SIZE
} else {
temp = cipher.doFinal(input, offset, input.size - offset)
offset = input.size
}
outputStream.write(temp!!)
}
outputStream.close()
return outputStream.toByteArray()
}
fun decrypt(ciphered: ByteArray, use_pub_key: Boolean = false): ByteArray? {
val cipher = Cipher.getInstance(RSAConfig.RSAMODE)
cipher.init(Cipher.DECRYPT_MODE, if (use_pub_key) pubkey else prikey)
var temp: ByteArray? = null
var offset = 0
val outputStream = ByteArrayOutputStream()
try {
while(ciphered.size - offset > 0) {
if (ciphered.size - offset >= RSAConfig.DECRYPT_MAX_SIZE) {
temp = cipher.doFinal(ciphered, offset, RSAConfig.DECRYPT_MAX_SIZE)
offset += RSAConfig.DECRYPT_MAX_SIZE
} else {
temp = cipher.doFinal(ciphered, offset, ciphered.size - offset)
offset = ciphered.size
}
outputStream.write(temp!!)
}
outputStream.close()
return outputStream.toByteArray()
} catch (e: Exception) {
return null
}
}
fun getPublicKeyString(): String {
return pubkeyStr!!
}
}

View File

@ -0,0 +1,187 @@
package com.jihe.punchnet.sdlan.utils
import android.os.Environment
import com.google.protobuf.ByteString
import com.google.protobuf.kotlin.toByteString
import com.jihe.punchnet.sdlan.config.SDLanConfig
import com.jihe.punchnet.sdlan.logs.TerminalLogger
import com.jihe.punchnet.sdlan.network.Mac
import java.io.File
import java.net.Inet4Address
import java.net.InetSocketAddress
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.UUID
import kotlin.experimental.and
import kotlin.experimental.inv
import kotlin.experimental.or
import kotlin.random.Random
import android.content.Context
object UniqueNodeID {
var id: String = ""
private var baseDir: String = ""
fun setBaseDir(basedir: String) {
baseDir = basedir
}
fun getUUID(context: Context? = null): String {
if (id.isEmpty() && context != null) {
val androidId = android.provider.Settings.Secure.getString(context.contentResolver, android.provider.Settings.Secure.ANDROID_ID)
if (!androidId.isNullOrEmpty() && androidId != "9774d56d682e549c") { // 9774d56d682e549c is a known bugged ID on old emulators
id = androidId
return id
}
}
if (id.isEmpty()) {
if (baseDir.isEmpty()) {
baseDir = Environment.getExternalStorageDirectory().absolutePath
}
val dirpath = File(baseDir)
dirpath.mkdirs()
val f = File(baseDir, SDLanConfig.ID_FILENAME)
if (f.exists()) {
val value = f.readText()
id = value
} else {
// file not exists
val uuid = UUID.randomUUID()
id = uuid.toString().replace("-", "")
f.writeText(id)
}
}
return id
}
}
fun parseScoketAddressV4FromString(data: String): InetSocketAddress? {
val pieces = data.split(":")
if (pieces.size != 2) {
TerminalLogger.errorf {"socker format error: $data"}
return null
}
val host = Inet4Address.getByName(pieces[0])
val port = pieces[1].toIntOrNull()
if (port != null) {
return InetSocketAddress(host, port)
}
TerminalLogger.errorf {"invalid port: ${port}"}
return null
}
fun ipToString(ip: Int): String {
val d1 = ip.ushr(24).and(0x000000ff).toUByte()
val d2 = ip.ushr(16).and(0x000000ff).toUByte()
val d3 = ip.ushr(8).and(0x000000ff).toUByte()
val d4 = ip.and(0x000000ff).toUByte()
return "$d1.$d2.$d3.$d4"
}
fun ipStringToInt(ipString: String): Int {
val parts = ipString.split(".")
if (parts.size != 4) return 0
return (parts[0].toInt() shl 24) or
(parts[1].toInt() shl 16) or
(parts[2].toInt() shl 8) or
(parts[3].toInt())
}
fun macToString(mac: Mac): String {
return mac.joinToString(separator = ":") { it.toUByte().toString(16) }
}
fun generateRandomMAC(): Mac {
var result = Random.nextBytes(6)
val k: Byte = 0x01
result[0] = result[0].and(k.inv())
result[0] = result[0].or(0x02)
return result.toByteString()
}
fun isMultiBroadcast(mac: ByteString): Boolean {
return isBroadcast(mac) || isMulticast(mac) || isIP6Multicast(mac)
}
inline fun isBroadcast(mac: ByteString): Boolean {
return mac.all {it == 0xff.toByte()}
}
inline fun isMulticast(mac: ByteString): Boolean {
return mac.size()==6 && mac.byteAt(0) == 0x01.toByte()
&& mac.byteAt(1) == 0x00.toByte()
&& mac.byteAt(2) == 0x5e.toByte()
&& (mac.byteAt(3).and(0x80.toByte()) == 0.toByte())
}
inline fun isIP6Multicast(mac: ByteString): Boolean {
return mac.size() == 6 && mac.byteAt(0) == 0x33.toByte()
&& mac.byteAt(1) == 0x33.toByte()
}
class EthHdr(
var dest: ByteArray,
var src: ByteArray,
var etherType: Short,
)
class ArpHdr(
val ethhdr: EthHdr,
val hwtype: Short,
val protocol: Short,
val hwlen: Byte,
val protolen: Byte,
var opcode: Short,
var shwaddr: ByteArray,
var sipaddr: Int,
var dhwaddr: ByteArray,
var dipaddr: Int,
) {
companion object {
// 需要保证,长度够
fun parseFrom(ethhdr: EthHdr, data: ByteBuffer): ArpHdr {
val hwtype = data.getShort()
val protocol = data.getShort()
val hwlen = data.get()
val protolen = data.get()
val opcode = data.getShort()
val shwaddr = ByteArray(6)
data.get(shwaddr)
val sipaddr = data.getInt()
val dhwaddr = ByteArray(6)
data.get(dhwaddr)
val dipaddr = data.getInt()
return ArpHdr(ethhdr, hwtype, protocol, hwlen, protolen, opcode, shwaddr, sipaddr, dhwaddr, dipaddr)
}
}
fun marshalToBytes(): ByteBuffer {
val buff = ByteBuffer.allocate(64).order(ByteOrder.BIG_ENDIAN)
buff.put(ethhdr.dest)
buff.put(ethhdr.src)
buff.putShort(ethhdr.etherType)
buff.putShort(hwtype)
buff.putShort(protocol)
buff.put(hwlen)
buff.put(protolen)
buff.putShort(opcode)
buff.put(shwaddr)
buff.putInt(sipaddr)
buff.put(dhwaddr)
buff.putInt(dipaddr)
buff.flip()
return buff
}
}

View File

@ -0,0 +1,11 @@
package com.jihe.punchnet.ui.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)

View File

@ -0,0 +1,57 @@
package com.jihe.punchnet.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80
)
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40
/* Other default colors to override
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color.White,
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F),
*/
)
@Composable
fun PunchnetTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}

View File

@ -0,0 +1,34 @@
package com.jihe.punchnet.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
)
/* Other default text styles to override
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
*/
)

View File

@ -0,0 +1,196 @@
syntax = "proto3";
option java_package = "com.jihe.punchnet.protobuf";
option java_outer_classname = "PunchProto";
//
message SDLV4Info {
uint32 port = 1;
bytes v4 = 2;
uint32 nat_type = 3;
}
message SDLV6Info {
uint32 port = 1;
bytes v6 = 2;
}
message SDLWelcome {
uint32 version = 1;
uint32 max_bidi_streams = 2;
uint32 max_packet_size = 3;
uint32 heartbeat_sec = 4;
SDLV6Info ipv6_assist = 5;
}
message SDLRegisterSuper {
string client_id = 1;
uint32 network_id = 2;
bytes mac = 3;
uint32 ip = 4;
uint32 mask_len = 5;
string hostname = 6;
string pub_key = 7;
string access_token = 8;
}
message SDLRegisterSuperAck {
string algorithm = 1;
bytes key = 2;
uint32 region_id = 3;
bytes session_token = 4;
}
message SDLRegisterSuperNak {
uint32 error_code = 1;
string error_message = 2;
}
//
message SDLQueryInfo {
bytes dst_mac = 1;
}
message SDLPeerInfo {
bytes dst_mac = 1;
optional SDLV4Info v4_info = 2;
optional SDLV6Info v6_info = 3;
}
message SDLArpRequest {
uint32 target_ip = 1;
uint32 origin_ip = 2;
bytes context = 3;
}
message SDLArpResponse {
uint32 target_ip = 1;
bytes target_mac = 2;
uint32 origin_ip = 3;
bytes context = 4;
}
message SDLPolicyRequest {
uint32 src_identity_id = 1;
uint32 dst_identity_id = 2;
uint32 version = 3;
}
message SDLPolicyResponse {
uint32 src_identity_id = 1;
uint32 dst_identity_id = 2;
uint32 version = 3;
bytes rules = 4;
}
message SDLExposedServiceRequest {
uint32 version = 1;
}
message SDLExposedServiceResponse {
uint32 version = 1;
repeated uint32 tcp_ports = 2;
repeated uint32 udp_ports = 3;
}
//
message SDLEvent {
message NatChanged {
bytes mac = 1;
uint32 ip = 2;
}
message SendRegister {
bytes dst_mac = 1;
uint32 nat_ip = 2;
uint32 nat_port = 3;
uint32 nat_type = 4;
optional SDLV6Info v6_info = 5;
}
message ExposedServiceChanged {
}
message NetworkShutdown {
string message = 1;
}
oneof event {
NatChanged nat_changed = 1;
SendRegister send_register = 2;
NetworkShutdown shutdown = 3;
ExposedServiceChanged exposed_service_changed = 4;
}
}
//
message SDLCommand {
uint32 pkt_id = 1;
message ExitNodeControl {
int32 action = 1;
string remark = 2;
}
oneof command {
ExitNodeControl exit_node = 2;
}
}
message SDLCommandAck {
uint32 pkt_id = 1;
int32 code = 2;
string message = 3;
bytes data = 4;
}
// UDP通讯消息
message SDLStunRequest {
string client_id = 1;
uint32 network_id = 2;
bytes mac = 3;
uint32 ip = 4;
uint32 nat_type = 5;
optional SDLV6Info v6_info = 6;
bytes session_token = 7;
}
message SDLStunReply {
}
message SDLData {
uint32 network_id = 1;
bytes src_mac = 2;
bytes dst_mac = 3;
bool is_p2p = 4;
uint32 ttl = 5;
bytes data = 6;
bytes session_token = 7;
uint32 identity_id = 8;
}
message SDLRegister {
uint32 network_id = 1;
bytes src_mac = 2;
bytes dst_mac = 3;
}
message SDLRegisterAck {
uint32 network_id = 1;
bytes src_mac = 2;
bytes dst_mac = 3;
}
//
message SDLStunProbe {
uint32 cookie = 1;
uint32 attr = 2;
uint32 step = 3;
}
message SDLStunProbeReply {
uint32 cookie = 1;
uint32 step = 2;
uint32 port = 3;
uint32 ip = 4;
}

View File

@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

View File

@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

View File

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright (C) 2017 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="24dp"
android:width="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#000000"
android:pathData="M19.35,10.04 C18.67,6.59,15.64,4,12,4 C9.11,4,6.6,5.64,5.35,8.04
C2.34,8.36,0,10.91,0,14 C0,17.31,2.69,20,6,20 L19,20 C21.76,20,24,17.76,24,15
C24,12.36,21.95,10.22,19.35,10.04 Z M19,18 L6,18 C3.79,18,2,16.21,2,14
S3.79,10,6,10 L6.71,10 C7.37,7.69,9.48,6,12,6 C15.04,6,17.5,8.46,17.5,11.5
L17.5,12 L19,12 C20.66,12,22,13.34,22,15 S20.66,18,19,18 Z" />
<path
android:strokeColor="#000000"
android:strokeWidth="2"
android:pathData="M6.58994,13.1803 C6.58994,13.1803,8.59173,15.8724,12.011,15.8726
C15.2788,15.8728,17.3696,13.2502,17.3696,13.2502" />
</vector>

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

View File

@ -0,0 +1,6 @@
<resources>
<string name="app_name">punchnet</string>
<string name="start_vpn">开启</string>
<string name="stop_vpn">关闭</string>
</resources>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Punchnet" parent="android:Theme.Material.Light.NoActionBar" />
</resources>

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older that API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>

View File

@ -0,0 +1,16 @@
package com.jihe.punchnet
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}

View File

@ -0,0 +1,20 @@
package com.jihe.punchnet
import org.junit.Test
import tech.kwik.core.QuicClientConnection
import java.net.URI
class TestQuic {
@Test
fun testQuic() {
try {
val builder = QuicClientConnection.newBuilder()
.uri(URI("https://127.0.0.1:1365"))
.applicationProtocol("punchnet/1.0")
.noServerCertificateCheck()
val connection = builder.build()
connection.connect()
} catch (e: Exception) {
}
}
}

6
build.gradle.kts Normal file
View File

@ -0,0 +1,6 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
}

24
gradle.properties Normal file
View File

@ -0,0 +1,24 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. For more details, visit
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true
org.gradle.java.home=/Users/stavid/Library/Java/JavaVirtualMachines/corretto-17.0.17/Contents/Home

Some files were not shown because too many files have changed in this diff Show More