fix some warning

This commit is contained in:
asxalex 2025-07-29 14:10:04 +08:00 committed by stavid
parent f86069d165
commit 4b1bae0e55
26 changed files with 1438 additions and 906 deletions

1
.idea/.name generated Normal file
View File

@ -0,0 +1 @@
punchnet

View File

@ -4,6 +4,14 @@
<selectionStates> <selectionStates>
<SelectionState runConfigName="app"> <SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" /> <option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-05-30T07:22:02.241059Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="LocalEmulator" identifier="path=/Users/stavid/.android/avd/Medium_Phone.avd" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState> </SelectionState>
</selectionStates> </selectionStates>
</component> </component>

13
.idea/deviceManager.xml generated Normal file
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>

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>

View File

@ -90,12 +90,18 @@ dependencies {
ksp("androidx.room:room-compiler:2.7.2") ksp("androidx.room:room-compiler:2.7.2")
implementation("androidx.room:room-ktx:2.7.2") implementation("androidx.room:room-ktx:2.7.2")
// implementation(libs.bcprov.jdk18on) // HTTP API Control Plane
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.google.code.gson:gson:2.10.1")
// QUIC Data Plane (Kwik)
implementation("tech.kwik:kwik:0.10.3")
implementation(libs.bcprov.jdk18on)
implementation(libs.bcpkix.jdk18on) implementation(libs.bcpkix.jdk18on)
// implementation(files("libs/org.asxalex.sdlan/sdlan-1.0-SNAPSHOT.jar")) // implementation(files("libs/org.asxalex.sdlan/sdlan-1.0-SNAPSHOT.jar"))
// implementation(files("libs/org.asxalex.sdlan/1.0.0/sdlan-1.0.0.jar")) // implementation(files("libs/org.asxalex.sdlan/1.0.0/sdlan-1.0.0.jar"))
//implementation("org.jetbrains.kotlin:kotlin-stdlib:2.1.0") {
//version { strictly("2.1.0") }
// }
} // Security Crypto
implementation("androidx.security:security-crypto:1.1.0-alpha06")
}

View File

@ -5,6 +5,8 @@
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/> <uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"/> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"/>
<application <application
@ -16,6 +18,7 @@
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.Punchnet" android:theme="@style/Theme.Punchnet"
android:usesCleartextTraffic="true"
tools:targetApi="31"> tools:targetApi="31">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
@ -30,7 +33,7 @@
</activity> </activity>
<service android:name=".PunchnetService" <service android:name=".PunchnetService"
android:exported="false" android:exported="true"
android:foregroundServiceType="specialUse" android:foregroundServiceType="specialUse"
android:permission="android.permission.BIND_VPN_SERVICE"> android:permission="android.permission.BIND_VPN_SERVICE">

View File

@ -114,6 +114,10 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// Initialize AppAuthRepository for Split Tunneling
com.jihe.punchnet.data.AppAuthRepository.init(this)
enableEdgeToEdge() enableEdgeToEdge()
Log.d("DIR", "filesdir = ${this.filesDir}") Log.d("DIR", "filesdir = ${this.filesDir}")

View File

@ -12,12 +12,9 @@ import android.os.ParcelFileDescriptor
import android.util.Log import android.util.Log
import android.widget.Toast import android.widget.Toast
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.lifecycle.ViewModelProvider
import com.jihe.punchnet.data.ButtonRepository import com.jihe.punchnet.data.ButtonRepository
import com.jihe.punchnet.data.ButtonState import com.jihe.punchnet.data.ButtonState
import com.jihe.punchnet.data.ButtonViewModel
import com.jihe.punchnet.data.RouteItem import com.jihe.punchnet.data.RouteItem
import com.jihe.punchnet.data.RouteViewModel
import com.jihe.punchnet.sdlan.config.Arguments import com.jihe.punchnet.sdlan.config.Arguments
import com.jihe.punchnet.sdlan.config.toIPV4String import com.jihe.punchnet.sdlan.config.toIPV4String
import com.jihe.punchnet.sdlan.logs.TerminalLogger import com.jihe.punchnet.sdlan.logs.TerminalLogger
@ -26,23 +23,17 @@ import com.jihe.punchnet.sdlan.network.ARPWaitList
import com.jihe.punchnet.sdlan.network.DeviceConfig import com.jihe.punchnet.sdlan.network.DeviceConfig
import com.jihe.punchnet.sdlan.network.IfaceTun import com.jihe.punchnet.sdlan.network.IfaceTun
import com.jihe.punchnet.sdlan.network.RouteDetail import com.jihe.punchnet.sdlan.network.RouteDetail
import com.jihe.punchnet.sdlan.network.RouteTable
import com.jihe.punchnet.sdlan.network.StartStopChanInfo
import com.jihe.punchnet.sdlan.network.cidrToRouteDetail
import com.jihe.punchnet.sdlan.network.ipInt2ByteArray import com.jihe.punchnet.sdlan.network.ipInt2ByteArray
import com.jihe.punchnet.sdlan.network.maskIPToDigit import com.jihe.punchnet.sdlan.network.maskIPToDigit
import com.jihe.punchnet.sdlan.network.run_sdlan import com.jihe.punchnet.sdlan.network.run_sdlan
import com.jihe.punchnet.sdlan.utils.ipToString import com.jihe.punchnet.sdlan.utils.ipToString
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.FileInputStream import java.io.FileInputStream
import java.io.FileOutputStream import java.io.FileOutputStream
import java.util.concurrent.atomic.AtomicBoolean
class PunchnetService : VpnService() , IfaceTun { class PunchnetService : VpnService() , IfaceTun {
@ -79,7 +70,6 @@ class PunchnetService : VpnService() , IfaceTun {
try { try {
var size = input?.read(result) var size = input?.read(result)
if (size == null) { if (size == null) {
println("xxx failed to read")
size = 0 size = 0
} else { } else {
println("xxx got $size bytes") println("xxx got $size bytes")
@ -90,6 +80,8 @@ class PunchnetService : VpnService() , IfaceTun {
result.copyOf(size) result.copyOf(size)
// result.slice(0..<size).toByteArray() // result.slice(0..<size).toByteArray()
} catch (e: Exception) { } catch (e: Exception) {
println("xxx read exception: ${e.message}")
e.printStackTrace()
ByteArray(0) ByteArray(0)
} }
} }
@ -126,10 +118,45 @@ class PunchnetService : VpnService() , IfaceTun {
.addAddress(ip, config.ip.netBitLen.toInt()) .addAddress(ip, config.ip.netBitLen.toInt())
.setBlocking(true) .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) { for (route in routes) {
tempVpnDescriptor = tempVpnDescriptor.addRoute(ipToString(route.net_ip), maskIPToDigit(route.mask_ip)) 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 vpnDescriptor = tempVpnDescriptor
.establish()?.apply { .establish()?.apply {
input = FileInputStream(fileDescriptor) input = FileInputStream(fileDescriptor)
@ -198,11 +225,10 @@ class PunchnetService : VpnService() , IfaceTun {
val argument = Arguments( val argument = Arguments(
baseDir = this.filesDir.path, baseDir = this.filesDir.path,
sn = "$server:1265", sn = "$server:443",
tcp = "$server:18083", stun = "$server:1365",
nat_server1 = "$server:1365",
nat_server1 = "$server:1265", nat_server2 = "$server:1366",
nat_server2 = "47.98.178.3:1265",
token = startArg?.token?:"", token = startArg?.token?:"",
name = "tau", name = "tau",
) )
@ -210,8 +236,43 @@ class PunchnetService : VpnService() , IfaceTun {
val notification = createNotification() val notification = createNotification()
startForeground(1, notification) startForeground(1, notification)
// Initialize UniqueNodeID baseDir to app's files directory
com.jihe.punchnet.sdlan.utils.UniqueNodeID.setBaseDir(this.filesDir.absolutePath)
scope.launch { scope.launch {
run_sdlan(scope, iface, argument, startArg) 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) // isRunning.set(true)
@ -236,7 +297,7 @@ class PunchnetService : VpnService() , IfaceTun {
} else { } else {
intent?.getParcelableExtra("argument") intent?.getParcelableExtra("argument")
} }
println("argument = ${argument}") TerminalLogger.debugf { "service argument received: routes=${argument?.routes?.size ?: 0}, hasToken=${!argument?.token.isNullOrEmpty()}" }
connect(argument) connect(argument)
START_STICKY START_STICKY
} }
@ -265,8 +326,9 @@ class PunchnetService : VpnService() , IfaceTun {
// disconnect() // disconnect()
scope.cancel() scope.cancel()
disconnect() disconnect()
com.jihe.punchnet.data.NodeRepository.clear()
Log.e(TAG, "onDestroy is called") Log.e(TAG, "onDestroy is called")
// stopForeground(STOP_FOREGROUND_REMOVE) // stopForeground(STOP_FOREGROUND_REMOVE)
super.onDestroy() super.onDestroy()
} }
} }

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

@ -61,7 +61,9 @@ abstract class AppDatabase: RoomDatabase() {
context.applicationContext, context.applicationContext,
AppDatabase::class.java, AppDatabase::class.java,
"app_database" "app_database"
).build() )
.fallbackToDestructiveMigration()
.build()
INSTANCE = instance INSTANCE = instance
instance instance
} }

View File

@ -2,6 +2,7 @@ package com.jihe.punchnet.helper
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
import android.content.SharedPreferences
import com.jihe.punchnet.data.RouteItem import com.jihe.punchnet.data.RouteItem
import com.jihe.punchnet.sdlan.network.maskDigitToInt import com.jihe.punchnet.sdlan.network.maskDigitToInt
@ -10,9 +11,41 @@ sealed class PreferenceName(val name: String) {
object PreferenceToken: PreferenceName("token") 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? { fun getPreferenceString(context: Context, name: PreferenceName): String? {
val preference = context.getSharedPreferences(PreferenceRepositoryName, Activity.MODE_PRIVATE)
try { try {
val preference = getEncryptedSharedPrefs(context)
migratePlainPreference(context, name, preference)
return preference.getString(name.name, "") return preference.getString(name.name, "")
} catch (e: Exception) { } catch (e: Exception) {
return null return null
@ -20,8 +53,9 @@ fun getPreferenceString(context: Context, name: PreferenceName): String? {
} }
fun getPreferenceInt(context: Context, name: PreferenceName): Int? { fun getPreferenceInt(context: Context, name: PreferenceName): Int? {
val preference = context.getSharedPreferences(PreferenceRepositoryName, Activity.MODE_PRIVATE)
try { try {
val preference = getEncryptedSharedPrefs(context)
migratePlainPreference(context, name, preference)
return preference.getInt(name.name, 0) return preference.getInt(name.name, 0)
} catch (e: Exception) { } catch (e: Exception) {
return null return null
@ -29,17 +63,21 @@ fun getPreferenceInt(context: Context, name: PreferenceName): Int? {
} }
fun setPreferenceString(context: Context, name: PreferenceName, value: String) { fun setPreferenceString(context: Context, name: PreferenceName, value: String) {
val preference = context.getSharedPreferences(PreferenceRepositoryName, Activity.MODE_PRIVATE) try {
val editor = preference.edit() val preference = getEncryptedSharedPrefs(context)
editor.putString(name.name, value) preference.edit().putString(name.name, value).apply()
editor.apply() } catch (e: Exception) {
return
}
} }
fun setPreferenceInt(context: Context, name: PreferenceName, value: Int) { fun setPreferenceInt(context: Context, name: PreferenceName, value: Int) {
val preference = context.getSharedPreferences(PreferenceRepositoryName, Activity.MODE_PRIVATE) try {
val editor = preference.edit() val preference = getEncryptedSharedPrefs(context)
editor.putInt(name.name, value) preference.edit().putInt(name.name, value).apply()
editor.apply() } catch (e: Exception) {
return
}
} }
@ -117,4 +155,4 @@ fun parseCIDRAndGW(cidr: String, gw: String): RouteItem? {
gateway = gateway, gateway = gateway,
mask_ip = mask, mask_ip = mask,
) )
} }

View File

@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@ -50,33 +51,45 @@ fun AppNavHost2(
paddingValues: PaddingValues paddingValues: PaddingValues
) { ) {
val context = androidx.compose.ui.platform.LocalContext.current
val token = com.jihe.punchnet.helper.getPreferenceString(context, com.jihe.punchnet.helper.PreferenceName.PreferenceToken)
val startDest = if (token.isNullOrEmpty()) Screen.LoginScreen.route else Screen.MainScreen.route
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = Screen.MainScreen.route, startDestination = startDest,
modifier = Modifier.padding(paddingValues) modifier = Modifier.padding(paddingValues)
) { ) {
composable(Screen.LoginScreen.route) {
LoginScreen(navController)
}
composable(Screen.MainScreen.route) { composable(Screen.MainScreen.route) {
HomeScreen(buttonViewModel, serviceViewModel, routeViewModel) HomeScreen(buttonViewModel, serviceViewModel, routeViewModel, navController)
} }
composable(Screen.RouteScreen.route) { composable(Screen.DeviceScreen.route) {
CustomHeaderScreen( CustomHeaderScreen("设备") {
"路由设置" DeviceScreen(navController)
) {
RouteScreen(ButtonRepository.buttonState.collectAsState().value != ButtonState.ButtonStarted, routeViewModel)
// LightScreen()
} }
} }
composable(Screen.ProfileScreen.route) { composable(Screen.AppAuthScreen.route) {
Column( CustomHeaderScreen("应用授权") {
horizontalAlignment = Alignment.CenterHorizontally, AppAuthScreen(navController)
modifier = Modifier.fillMaxWidth()
) {
Text(
"TODO",
style = MaterialTheme.typography.titleLarge,
)
} }
// Profile(dbdao, 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

@ -37,17 +37,21 @@ fun AppBottomNavigation(
Icon( Icon(
imageVector = when (screen) { imageVector = when (screen) {
Screen.MainScreen -> Icons.Default.Home Screen.MainScreen -> Icons.Default.Home
Screen.RouteScreen -> Icons.Default.Menu Screen.DeviceScreen -> Icons.Default.Menu
// Screen.Search -> Icons.Default.Search Screen.AppAuthScreen -> Icons.Default.Person
Screen.ProfileScreen -> Icons.Default.Person else -> Icons.Default.Home
}, },
contentDescription = screen.route contentDescription = screen.route
) )
}, },
label = { label = {
Text( val labelText = when (screen) {
text = screen.route.replaceFirstChar { it.uppercase() }, Screen.MainScreen -> "首页"
) Screen.DeviceScreen -> "设备"
Screen.AppAuthScreen -> "应用授权"
else -> screen.route
}
Text(text = labelText)
}, },
selected = currentRoute == screen.route, selected = currentRoute == screen.route,
onClick = { onClick = {

View File

@ -6,12 +6,14 @@ import android.app.Activity.RESULT_OK
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.VpnService import android.net.VpnService
import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.widget.Toast import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.core.content.ContextCompat
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.indication import androidx.compose.foundation.indication
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
@ -64,6 +66,12 @@ import com.jihe.punchnet.helper.PreferenceName
import com.jihe.punchnet.helper.getPreferenceString import com.jihe.punchnet.helper.getPreferenceString
import com.jihe.punchnet.helper.setPreferenceString import com.jihe.punchnet.helper.setPreferenceString
import kotlin.math.exp 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( data class HomeDropDownInfo(
@DrawableRes val icon: Int, @DrawableRes val icon: Int,
@ -72,32 +80,30 @@ data class HomeDropDownInfo(
) )
@Composable @Composable
fun HomeDropdownMenu( fun HomeDropdownMenu(navController: androidx.navigation.NavHostController) {
showTokenDialog: MutableState<Boolean>,
) {
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
// var showTokenDialog by remember { mutableStateOf(false) }
// val showSettingDialog by remember { mutableStateOf(false) }
val context = LocalContext.current val context = LocalContext.current
val homeItems = arrayOf( val homeItems = arrayOf(
HomeDropDownInfo( HomeDropDownInfo(
R.drawable.tag, R.drawable.tag, // TODO: Use settings icon if available, or just standard icon
"修改token", "设置",
{ {
showTokenDialog.value = true navController.navigate(Screen.SettingsScreen.route)
} }
), ),
/*
HomeDropDownInfo( HomeDropDownInfo(
R.drawable.preferences, 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() { Box() {
IconButton( IconButton(
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
@ -125,7 +131,6 @@ fun HomeDropdownMenu(
item.callback?.invoke() item.callback?.invoke()
}, },
text = { text = {
Row ( Row (
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
){ ){
@ -142,12 +147,10 @@ fun HomeDropdownMenu(
Text(text=item.name) Text(text=item.name)
} }
} }
) )
} }
} }
} }
} }
@ -156,15 +159,13 @@ fun HomeScreen(
buttonViewModel: ButtonViewModel, buttonViewModel: ButtonViewModel,
serviceViewModel: ServiceViewModel, serviceViewModel: ServiceViewModel,
routeViewModel: RouteViewModel, routeViewModel: RouteViewModel,
// started: MutableState<Boolean>, navController: androidx.navigation.NavHostController,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val tkPref = getPreferenceString(context, PreferenceName.PreferenceToken) val tkPref = getPreferenceString(context, PreferenceName.PreferenceToken)
var tokenvalue by remember {mutableStateOf(tkPref?:"")}
val allroutes = routeViewModel.allRoutes.collectAsState(emptyList()) val allroutes = routeViewModel.allRoutes.collectAsState(emptyList())
val vpnLauncher = rememberLauncherForActivityResult( val vpnLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult() contract = ActivityResultContracts.StartActivityForResult()
@ -174,7 +175,6 @@ fun HomeScreen(
// serviceViewModel.startService(context) // serviceViewModel.startService(context)
// serviceViewModel.setVPNPermission(true) // serviceViewModel.setVPNPermission(true)
val tk = getPreferenceString(context, PreferenceName.PreferenceToken)?:"" val tk = getPreferenceString(context, PreferenceName.PreferenceToken)?:""
println("now token value2 = '${tokenvalue}'")
serviceViewModel.startService(context, allroutes.value.toTypedArray(), tk) serviceViewModel.startService(context, allroutes.value.toTypedArray(), tk)
} }
RESULT_CANCELED -> { RESULT_CANCELED -> {
@ -183,118 +183,187 @@ fun HomeScreen(
} }
} }
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 buttonState = buttonViewModel.buttonState.collectAsState()
val showTokenDialog = remember { mutableStateOf(false) } val resourceList by com.jihe.punchnet.data.NodeRepository.resources.collectAsState()
CustomHeaderScreen( CustomHeaderScreen(
"", "",
onBack = null, onBack = null,
onMenu = { onMenu = {
HomeDropdownMenu(showTokenDialog) HomeDropdownMenu(navController)
} }
) { ) {
if (showTokenDialog.value) { LazyColumn(
AlertDialog( modifier = Modifier.fillMaxWidth().fillMaxHeight(),
onDismissRequest = {showTokenDialog.value=false}, horizontalAlignment = Alignment.CenterHorizontally
confirmButton = { ) {
Button( item {
onClick = { Spacer(modifier = Modifier.height(50.dp))
setPreferenceString(context, PreferenceName.PreferenceToken, tokenvalue)
println("now token value = '${tokenvalue}'") Image(
showTokenDialog.value = false 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)
}
}
} }
) { },
Text("确定") enabled = buttonState.value.enabled,
} shape = RoundedCornerShape(10.dp),
}, modifier = Modifier.padding(top=48.dp)
title = { .width(120.dp)
Text("修改token") .height(40.dp)
}, ) {
text = { Text(buttonState.value.text)
TextField( }
value = tokenvalue,
onValueChange = { newValue -> Spacer(modifier = Modifier.height(48.dp))
tokenvalue = newValue.trim() }
},
leadingIcon = { if (resourceList.isNotEmpty()) {
Icon( item {
painter = painterResource(R.drawable.tag), Text(
contentDescription = "token", text = "企业资源 (${resourceList.size})",
modifier = Modifier.size(24.dp) 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)
Column(
modifier = Modifier.fillMaxWidth()
.padding(top=50.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
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) {
// if (serviceViewModel.isRunning.value) {
// if is running, should stop service
serviceViewModel.stopVpnService(context)
} else if (buttonViewModel.buttonState.value == ButtonState.ButtonStopped){
val intent = VpnService.prepare(context)
if (intent != null) {
vpnLauncher.launch(intent)
} else {
val tk = getPreferenceString(context, PreferenceName.PreferenceToken)?:""
println("now token value1 = '${tokenvalue}'")
serviceViewModel.startService(context, allroutes.value.toTypedArray(), tk)
}
} }
// started.value = !started.value }
},
enabled = buttonState.value.enabled,
shape = RoundedCornerShape(10.dp),
modifier = Modifier.padding(top=48.dp)
.width(120.dp)
.height(40.dp)
) {
Text(
buttonState.value.text
// buttonViewModel.buttonText.value
/*
if (serviceViewModel.isRunning.value) {
"停止"
} else {
"启动"
}
*/
)
} }
} }
// BasicCardList(dbdao = dbdao, 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

@ -15,6 +15,10 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -25,15 +29,20 @@ import com.jihe.punchnet.data.RouteViewModel
import com.jihe.punchnet.data.ServiceViewModel import com.jihe.punchnet.data.ServiceViewModel
sealed class Screen(val route: String) { sealed class Screen(val route: String) {
object MainScreen: Screen("main"); object MainScreen: Screen("home") // 首页
object RouteScreen: Screen("routes") object DeviceScreen: Screen("devices") // 设备 (取代原先的 Route)
object ProfileScreen: Screen("profile") object AppAuthScreen: Screen("appAuth") // 应用授权 (取代原先的 Profile)
object SettingsScreen: Screen("settings") // 设置
object WebViewScreen: Screen("webview/{url}") {
fun createRoute(url: String) = "webview/${android.net.Uri.encode(url)}"
}
object LoginScreen: Screen("login")
} }
val bottomNavItems = listOf( val bottomNavItems = listOf(
Screen.MainScreen, Screen.MainScreen,
Screen.RouteScreen, Screen.DeviceScreen,
Screen.ProfileScreen, Screen.AppAuthScreen,
) )
@Composable @Composable
@ -44,9 +53,15 @@ fun MainApp(
navController: NavHostController, navController: NavHostController,
) { ) {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute by remember { derivedStateOf { navBackStackEntry?.destination?.route } }
val showBottomBar = currentRoute != Screen.LoginScreen.route
Scaffold( Scaffold(
bottomBar = { bottomBar = {
AppBottomNavigation(navController = navController) if (showBottomBar) {
AppBottomNavigation(navController = navController)
}
} }
){ paddingValues -> ){ paddingValues ->
/* /*

View File

@ -1,211 +0,0 @@
package com.jihe.punchnet.screen
import android.widget.Toast
import androidx.collection.emptyLongSet
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.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.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
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.graphics.RectangleShape
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.room.util.TableInfo
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
@Composable
fun AddOrModifyRoute(
routeViewModel: RouteViewModel,
initRoute: MutableState<RouteItem?>,
shouldShowRoute: MutableState<Boolean>,
) {
if (shouldShowRoute.value) {
val context = LocalContext.current
var cidr by remember {
mutableStateOf(
if (initRoute.value == null)
""
else
""
)
}
var gw by remember {
mutableStateOf(
if (initRoute.value == null)
""
else
""
)
}
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 = { newValue ->
cidr = newValue
},
placeholder = {
Text(
"192.168.0.0/24",
color = MaterialTheme.colorScheme.onBackground.copy(.5f)
)
},
label = {
Text("CIDR")
},
modifier = Modifier.padding(start = 16.dp)
)
TextField(
value = gw,
onValueChange = { newValue ->
gw = newValue
},
placeholder = {
Text(
"10.167.69.13",
color = MaterialTheme.colorScheme.onBackground.copy(.5f)
)
},
label = {
Text("gateway")
},
modifier = Modifier.padding(start = 16.dp, top = 8.dp)
)
}
}
)
}
}
@Composable
fun RouteScreen(
enabled: Boolean,
routeViewModel: RouteViewModel,
modifier: Modifier = Modifier,
) {
val showAddRoute: MutableState<RouteItem?> = remember { mutableStateOf(null) }
val shouldShow: MutableState<Boolean> = remember { mutableStateOf(false) }
AddOrModifyRoute(routeViewModel, showAddRoute, shouldShow)
val routeItems by routeViewModel.allRoutes.collectAsState(initial = emptyList())
LazyColumn {
item {
Row(
) {
}
}
items(routeItems) {item ->
RouteCard(routeViewModel, item, enabled)
}
item {
Button(
enabled = enabled,
shape = RectangleShape,
onClick = {
shouldShow.value = true
},
modifier = Modifier.fillMaxWidth()
) {
Text("添加路由")
}
}
}
}
@Composable
fun RouteCard(
routeViewModel: RouteViewModel,
item: RouteItem,
enabled: Boolean,
modifier: Modifier = Modifier,
) {
Card (
modifier = Modifier.fillMaxWidth()
){
Row (
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.height(36.dp)
){
Icon(
Icons.Default.Clear,
contentDescription = "delete route",
modifier = Modifier.size(30.dp).clickable(
enabled = enabled,
) {
routeViewModel.deleteById(item.id)
}
)
Text(
"${IntToIPString(item.net_ip)}/${maskIPToDigit(item.mask_ip)}",
modifier = Modifier.padding(start = 8.dp)
)
Text(
IntToIPString(item.gateway),
modifier = Modifier.padding(start = 8.dp)
)
}
}
}

View File

@ -5,7 +5,9 @@ data class Arguments(
// udp info of the super node // udp info of the super node
val sn: String, val sn: String,
// tcp info of the super node // tcp info of the super node
val tcp: String,
// udp info of the STUN server
val stun: String,
val nat_server1: String, val nat_server1: String,
val nat_server2: String, val nat_server2: String,

View File

@ -52,8 +52,9 @@ class NodeConfig (
val localPort: Int, val localPort: Int,
val supernode: SDLanSock, val supernode: SDLanSock,
val natServer1: InetSocketAddress, val stunServer: SDLanSock,
val natServer2: InetSocketAddress val natServer1: java.net.InetSocketAddress,
val natServer2: java.net.InetSocketAddress
) )
class NodeStats private constructor( class NodeStats private constructor(
@ -148,8 +149,16 @@ class Node private constructor (
var startStopChannel: SendChannel<StartStopChanInfo>, var startStopChannel: SendChannel<StartStopChanInfo>,
val mac: Mac = generateRandomMAC(), val mac: Mac = generateRandomMAC(),
var nat_type: NatType = NatType.Invalid, var nat_type: NatType = NatType.PortRestricted,
val natProbeCookie: NatProbeCookie = NatProbeCookie(), val natProbeCookie: NatProbeCookie = NatProbeCookie(),
var authData: com.jihe.punchnet.api.AuthResponseData? = null,
var connectData: com.jihe.punchnet.api.ConnectResponseData? = null,
var clientId: String = "",
var sessionToken: com.google.protobuf.ByteString = com.google.protobuf.ByteString.EMPTY,
val queriedPolicies: ConcurrentHashMap<Int, Int> = ConcurrentHashMap(),
val queriedPolicyTargets: ConcurrentHashMap<Int, Int> = ConcurrentHashMap(),
val policyRules: ConcurrentHashMap<Int, ByteArray> = ConcurrentHashMap(),
) { ) {
companion object { companion object {
private var instance: Node? = null private var instance: Node? = null
@ -176,11 +185,6 @@ class Node private constructor (
toSocket, toSocket,
startStopChannel, startStopChannel,
) )
} else {
instance?.config = config
instance?.udpSockV4 = v4Sock
instance?.token = token
Log.e("Initialize", "not initialize instance")
} }
return instance!! return instance!!
} }
@ -237,24 +241,51 @@ class Node private constructor (
} }
suspend fun sendStunRequest() { suspend fun sendStunRequest() {
if (this.clientId.isEmpty()) {
this.clientId = "punchnet_android_client_" + UniqueNodeID.getUUID()
}
val req = SDLStunRequest.newBuilder() val req = SDLStunRequest.newBuilder()
.setCookie(0) .setClientId(this.clientId)
.setClientId(UniqueNodeID.getUUID())
.setNetworkId(this.networkID.get()) .setNetworkId(this.networkID.get())
.setIp(this.deviceConfig.ip.netAddr) .setIp(this.deviceConfig.ip.netAddr)
.setMac(this.mac) .setMac(this.mac)
.setNatType(this.nat_type.toByte().toInt()) .setNatType(this.nat_type.toByte().toInt())
.setV6Info(PunchProto.SDLV6Info.getDefaultInstance()) .setV6Info(PunchProto.SDLV6Info.getDefaultInstance())
.setSessionToken(this.sessionToken)
.build() .build()
val msg = encodeToUDPMessage(req, PacketType.StunRequest) val msg = encodeToUDPMessage(req, PacketType.StunRequest)
sendToSock(this, msg, config.supernode) TerminalLogger.debugf {
"send STUN_REQUEST to ${config.stunServer}, clientIdTail=${clientId.takeLast(12)}, " +
"network=${req.networkId}, ip=${com.jihe.punchnet.sdlan.utils.ipToString(req.ip)}, " +
"mac=${com.jihe.punchnet.sdlan.utils.macToString(req.mac)}, natType=${nat_type}, " +
"sessionTokenBytes=${sessionToken.size()}"
}
sendToSock(this, msg, config.stunServer)
}
suspend fun ping_to_sn() {
val msg = encodeToControlMessage(null, PacketType.Ping).toByteArray()
_sendDataToSocket(msg)
}
suspend fun _sendDataToSocket(msg: ByteArray) {
if (aes.isAuthorized()) {
TerminalLogger.debugf{"authorized, ping to sn"}
toSocket.send(msg)
} else {
TerminalLogger.debugf{"unauthorized, not ping to sn"}
}
} }
suspend fun probeNatType() { suspend fun probeNatType() {
val previousNatType = nat_type
val reply1 = this._sendAndWaitForProbeReply(StunProbeAttr.None, config.natServer1) val reply1 = this._sendAndWaitForProbeReply(StunProbeAttr.None, config.natServer1)
if (reply1 == null) { if (reply1 == null) {
nat_type = NatType.Blocked TerminalLogger.warning { "STUN probe attr=None timed out; keeping natType=${nat_type}" }
return return
} }
if (reply1.ip == (outerIPV4.get())) { if (reply1.ip == (outerIPV4.get())) {
@ -275,7 +306,14 @@ class Node private constructor (
val reply3 = this._sendAndWaitForProbeReply(StunProbeAttr.None, config.natServer2) val reply3 = this._sendAndWaitForProbeReply(StunProbeAttr.None, config.natServer2)
if (reply3 == null) { if (reply3 == null) {
nat_type = NatType.Blocked nat_type = if (previousNatType == NatType.Invalid || previousNatType == NatType.Blocked) {
NatType.PortRestricted
} else {
previousNatType
}
TerminalLogger.warning {
"secondary STUN probe timed out after primary reply; keeping natType=${nat_type}"
}
return return
} }
@ -293,38 +331,28 @@ class Node private constructor (
} }
suspend fun _sendAndWaitForProbeReply(attr: StunProbeAttr, toServer: SocketAddress): SDLStunProbeReply? { suspend fun _sendAndWaitForProbeReply(attr: StunProbeAttr, toServer: java.net.SocketAddress): SDLStunProbeReply? {
val channel = Channel<SDLStunProbeReply>(100) val channel = kotlinx.coroutines.channels.Channel<SDLStunProbeReply>(100)
val cookie = natProbeCookie.addChannel(channel) val cookie = natProbeCookie.addChannel(channel)
val probe = SDLStunProbe.newBuilder() val probe = com.jihe.punchnet.protobuf.PunchProto.SDLStunProbe.newBuilder()
.setAttr(attr.ordinal.toInt()) .setAttr(attr.ordinal.toInt())
.setCookie(cookie) .setCookie(cookie)
.build() .build()
val msg = encodeToUDPMessage(probe, PacketType.StunProbe) val msg = encodeToUDPMessage(probe, PacketType.StunProbe)
TerminalLogger.debugf { "send STUN_PROBE attr=$attr cookie=$cookie to $toServer" }
this.udpSockV4.send_to(msg.toByteArray(), toServer) this.udpSockV4.send_to(msg.toByteArray(), toServer)
val k = withTimeoutOrNull(5000) { val k = kotlinx.coroutines.withTimeoutOrNull(5000) {
val response = channel.receive() val response = channel.receive()
return@withTimeoutOrNull response return@withTimeoutOrNull response
} }
if (k == null) {
TerminalLogger.warning { "STUN_PROBE attr=$attr cookie=$cookie to $toServer timed out" }
} else {
TerminalLogger.debugf { "STUN_PROBE attr=$attr cookie=$cookie reply ip=${k.ip} port=${k.port}" }
}
natProbeCookie.remove(cookie) natProbeCookie.remove(cookie)
return k return k
} }
suspend fun ping_to_sn() {
val msg = encodeToTcpMessage(null, 0, PacketType.Ping).toByteArray()
_sendDataToSocket(msg)
}
suspend fun _sendDataToSocket(msg: ByteArray) {
if (aes.isAuthorized()) {
TerminalLogger.debugf{"authorized, ping to sn"}
toSocket.send(msg)
} else {
TerminalLogger.debugf{"unauthorized, not ping to sn"}
}
}
} }

View File

@ -1,21 +1,29 @@
package com.jihe.punchnet.sdlan.network package com.jihe.punchnet.sdlan.network
import com.google.protobuf.kotlin.toByteString import com.google.protobuf.kotlin.toByteString
import com.google.gson.JsonElement
import com.jihe.punchnet.protobuf.PunchProto.SDLData import com.jihe.punchnet.protobuf.PunchProto.SDLData
import com.jihe.punchnet.protobuf.PunchProto.SDLPeerInfo import com.jihe.punchnet.protobuf.PunchProto.SDLPeerInfo
import com.jihe.punchnet.protobuf.PunchProto.SDLQueryInfo import com.jihe.punchnet.protobuf.PunchProto.SDLQueryInfo
import com.jihe.punchnet.protobuf.PunchProto.SDLRegister import com.jihe.punchnet.protobuf.PunchProto.SDLRegister
import com.jihe.punchnet.protobuf.PunchProto.SDLRegisterAck import com.jihe.punchnet.protobuf.PunchProto.SDLRegisterAck
import com.jihe.punchnet.protobuf.PunchProto.SDLSendRegisterEvent import com.jihe.punchnet.protobuf.PunchProto.SDLArpRequest
import com.jihe.punchnet.protobuf.PunchProto.SDLArpResponse
import com.jihe.punchnet.protobuf.PunchProto.SDLExposedServiceRequest
import com.jihe.punchnet.protobuf.PunchProto.SDLPolicyRequest
import com.jihe.punchnet.sdlan.config.EtherType import com.jihe.punchnet.sdlan.config.EtherType
import com.jihe.punchnet.sdlan.config.SDLanConfig import com.jihe.punchnet.sdlan.config.SDLanConfig
import com.jihe.punchnet.sdlan.logs.TerminalLogger import com.jihe.punchnet.sdlan.logs.TerminalLogger
import com.jihe.punchnet.sdlan.utils.isMultiBroadcast import com.jihe.punchnet.sdlan.utils.isMultiBroadcast
import com.jihe.punchnet.sdlan.utils.ipStringToInt
import com.jihe.punchnet.sdlan.utils.ipToString
import com.jihe.punchnet.sdlan.utils.macToString import com.jihe.punchnet.sdlan.utils.macToString
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.net.URI
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.ByteOrder import java.nio.ByteOrder
@ -299,15 +307,311 @@ suspend fun sendRegister(
.build() .build()
val msg = encodeToUDPMessage(register, PacketType.Register) val msg = encodeToUDPMessage(register, PacketType.Register)
TerminalLogger.debugf { "send REGISTER to ${macToString(mac)} at $sock, natType=$natType" }
sendToSock(node, msg, sock) sendToSock(node, msg, sock)
// TODO: need guess port if (natType == NatType.Symmetric || natType == NatType.PortRestricted) {
TerminalLogger.debugf { "guess ports for symmetric/restricted nat" }
val guessOffsets = intArrayOf(-1, 1, -2, 2)
for (offset in guessOffsets) {
val guessPort = sock.port + offset
if (guessPort in 1..65535) {
val guessSock = SDLanSock(sock.family, guessPort, sock.ip)
sendToSock(node, msg, guessSock)
}
}
}
} }
suspend fun sendPacketToNet(node: Node, dstmac: Mac, content: List<Byte>, size: Long) { suspend fun sendPacketToNet(node: Node, dstmac: Mac, encrypted: ByteArray, size: Long) {
val destination = findPeerDestination(node, dstmac, size) var is_p2p: Boolean = false
TerminalLogger.debugf { "send PACKET to ${destination}" } var is_multicast: Boolean = false
sendToSock(node, content, destination) var destination: SDLanSock
if (isMultiBroadcast(dstmac)) {
node.stats.txSup.addAndGet(size)
node.stats.txBroadcast.addAndGet(size)
destination = node.config.stunServer
is_multicast = true
} else {
val peer = node.knownPeers.get(dstmac)
if (peer == null) {
node.stats.txSup.addAndGet(size)
destination = node.config.stunServer
} else {
val now = System.currentTimeMillis() / 1000
if (now - peer.lastP2P.get() >= peer.timeout / 2) {
TerminalLogger.warning { "last p2p is too old, deleting from known hosts" }
node.knownPeers.remove(dstmac)
node.stats.txSup.addAndGet(size)
destination = node.config.stunServer
} else {
is_p2p = true
node.stats.txP2P.addAndGet(size)
destination = peer.sock
}
}
}
if (!is_p2p && !is_multicast) {
TerminalLogger.debugf { "check_query_peer_info" }
checkQueryPeerInfo(node, dstmac)
}
val transportIdentityId = node.connectData?.identityId ?: 0
val data = com.jihe.punchnet.protobuf.PunchProto.SDLData.newBuilder()
.setIsP2P(is_p2p)
.setNetworkId(node.networkID.get())
.setTtl(2)
.setSrcMac(node.mac)
.setDstMac(dstmac)
.setData(com.google.protobuf.ByteString.copyFrom(encrypted))
.setSessionToken(node.sessionToken)
.setIdentityId(transportIdentityId)
.build()
val msg = encodeToUDPMessage(data, PacketType.Data)
TerminalLogger.debugf {
"send PACKET to ${destination} with isP2P=$is_p2p, ttl=${data.ttl}, " +
"src=${macToString(data.srcMac)}, dst=${macToString(data.dstMac)}, " +
"network=${data.networkId}, identityId=${data.identityId}, apiIdentity=${node.connectData?.identityId ?: 0}, " +
"sessionTokenBytes=${data.sessionToken.size()}, encryptedBytes=${encrypted.size}"
}
sendToSock(node, msg, destination)
}
// Removed dummy findPeerDestination
fun describeIpv4Packet(data: ByteArray): String {
if (data.size < 20) {
return "short-ipv4 bytes=${data.size}"
}
val version = (data[0].toInt() ushr 4) and 0x0f
if (version != 4) {
return "non-ipv4 version=$version bytes=${data.size}"
}
val ihl = (data[0].toInt() and 0x0f) * 4
if (ihl < 20 || data.size < ihl) {
return "bad-ipv4-header ihl=$ihl bytes=${data.size}"
}
val proto = data[9].toInt() and 0xff
val srcIp = ByteBuffer.wrap(data, 12, 4).order(ByteOrder.BIG_ENDIAN).int
val dstIp = ByteBuffer.wrap(data, 16, 4).order(ByteOrder.BIG_ENDIAN).int
val protoName = when (proto) {
1 -> "ICMP"
6 -> "TCP"
17 -> "UDP"
else -> "proto-$proto"
}
val portInfo = if ((proto == 6 || proto == 17) && data.size >= ihl + 4) {
val srcPort = ByteBuffer.wrap(data, ihl, 2).order(ByteOrder.BIG_ENDIAN).short.toInt() and 0xffff
val dstPort = ByteBuffer.wrap(data, ihl + 2, 2).order(ByteOrder.BIG_ENDIAN).short.toInt() and 0xffff
" srcPort=$srcPort dstPort=$dstPort"
} else {
""
}
return "$protoName ${ipToString(srcIp)} -> ${ipToString(dstIp)}$portInfo bytes=${data.size}"
}
fun describePolicyRules(rules: ByteArray): String {
if (rules.isEmpty()) {
return "empty"
}
val result = mutableListOf<String>()
var offset = 0
while (offset + 3 <= rules.size) {
val proto = rules[offset].toInt() and 0xff
val port = ByteBuffer.wrap(rules, offset + 1, 2).order(ByteOrder.BIG_ENDIAN).short.toInt() and 0xffff
val protoName = when (proto) {
1 -> "ICMP"
6 -> "TCP"
17 -> "UDP"
else -> "proto-$proto"
}
result.add("$protoName/$port")
offset += 3
}
if (offset != rules.size) {
result.add("trailingBytes=${rules.size - offset}")
}
return result.joinToString(",")
}
data class PolicyDecision(
val state: PolicyDecisionState,
val reason: String,
)
enum class PolicyDecisionState {
Allowed,
Pending,
Denied,
}
fun policyDecisionForIpv4Packet(node: Node, data: ByteArray): PolicyDecision {
if (data.size < 20) {
return PolicyDecision(PolicyDecisionState.Denied, "too-short")
}
val version = (data[0].toInt() ushr 4) and 0x0f
if (version != 4) {
return PolicyDecision(PolicyDecisionState.Denied, "non-ipv4")
}
val ihl = (data[0].toInt() and 0x0f) * 4
if (ihl < 20 || data.size < ihl) {
return PolicyDecision(PolicyDecisionState.Denied, "bad-ipv4-header")
}
val proto = data[9].toInt() and 0xff
val targetIp = ByteBuffer.wrap(data, 16, 4).order(ByteOrder.BIG_ENDIAN).int
val port = if ((proto == 6 || proto == 17) && data.size >= ihl + 4) {
ByteBuffer.wrap(data, ihl + 2, 2).order(ByteOrder.BIG_ENDIAN).short.toInt() and 0xffff
} else {
0
}
val candidates = policyCandidateIdsForTarget(node, targetIp)
if (candidates.isEmpty()) {
return PolicyDecision(PolicyDecisionState.Denied, "no policy identity for ${ipToString(targetIp)}")
}
val missing = candidates.filterNot { node.policyRules.containsKey(it) }
if (missing.isNotEmpty()) {
return PolicyDecision(PolicyDecisionState.Pending, "waiting policy for ${ipToString(targetIp)}, ids=${missing.joinToString(",")}")
}
for (candidate in candidates) {
val rules = node.policyRules[candidate] ?: continue
if (policyRulesAllow(rules, proto, port)) {
return PolicyDecision(
PolicyDecisionState.Allowed,
"allowed by policy id=$candidate ${protocolName(proto)}/$port"
)
}
}
return PolicyDecision(
PolicyDecisionState.Denied,
"no allow rule for ${ipToString(targetIp)} ${protocolName(proto)}/$port, ids=${candidates.joinToString(",")}"
)
}
fun policyCandidateIdsForTarget(node: Node, targetIp: Int): List<Int> {
val result = mutableListOf<Int>()
node.connectData?.nodeList.orEmpty().firstOrNull {
runCatching { ipStringToInt(it.ip) == targetIp }.getOrDefault(false)
}?.let { result.add(it.id) }
result.addAll(matchingResourceIdsForTarget(node, targetIp))
return result.distinct()
}
fun policyRulesAllow(rules: ByteArray, proto: Int, port: Int): Boolean {
if (rules.isEmpty()) {
return false
}
var offset = 0
while (offset + 3 <= rules.size) {
val ruleProto = rules[offset].toInt() and 0xff
val rulePort = ByteBuffer.wrap(rules, offset + 1, 2).order(ByteOrder.BIG_ENDIAN).short.toInt() and 0xffff
if (ruleProto == proto && (rulePort == port || rulePort == 0)) {
return true
}
offset += 3
}
return false
}
fun protocolName(proto: Int): String {
return when (proto) {
1 -> "ICMP"
6 -> "TCP"
17 -> "UDP"
else -> "proto-$proto"
}
}
fun describeConnectDataForLog(node: Node): String {
val connectData = node.connectData ?: return "connect data is null"
val localNodeId = localNodeId(node) ?: 0
val nodeSummary = connectData.nodeList.orEmpty().joinToString(";") {
"id=${it.id},ip=${it.ip},status=${it.connectionStatus}"
}.ifEmpty { "none" }
val resourceSummary = connectData.resourceList.orEmpty().map { resource ->
val uri = runCatching { URI(resource.url) }.getOrNull()
val host = uri?.host ?: "invalid-host"
val scheme = uri?.scheme ?: "unknown"
val port = when {
uri == null -> -1
uri.port > 0 -> uri.port
scheme.equals("http", ignoreCase = true) -> 80
scheme.equals("https", ignoreCase = true) -> 443
else -> -1
}
"id=${resource.id},${scheme}://${host}:${port},status=${resource.connectionStatus}"
}.joinToString(";").ifEmpty { "none" }
return "connect summary: ip=${connectData.ip}/${connectData.maskLen}, identity=${connectData.identityId}, localNodeId=$localNodeId, " +
"nodes=${connectData.nodeList.orEmpty().size}[$nodeSummary], " +
"resources=${connectData.resourceList.orEmpty().size}[$resourceSummary], " +
"acl=${describeAclForLog(connectData.acl)}"
}
fun localNodeId(node: Node): Int? {
val localIp = node.connectData?.ip ?: return null
return node.connectData?.nodeList.orEmpty().firstOrNull { it.ip == localIp }?.id
}
fun describeAclForLog(acl: JsonElement?): String {
if (acl == null || acl.isJsonNull) {
return "null"
}
if (acl.isJsonArray) {
val array = acl.asJsonArray
if (array.size() == 0) {
return "array(size=0)"
}
val samples = array.take(3).mapIndexed { index, item ->
"[$index]=${describeAclElementForLog(item)}"
}
return "array(size=${array.size()}, ${samples.joinToString(",")})"
}
if (acl.isJsonObject) {
return describeAclObjectForLog(acl)
}
if (acl.isJsonPrimitive) {
return "primitive"
}
return acl.javaClass.simpleName
}
private fun describeAclElementForLog(element: JsonElement): String {
return when {
element.isJsonObject -> describeAclObjectForLog(element)
element.isJsonArray -> "array(size=${element.asJsonArray.size()})"
element.isJsonNull -> "null"
element.isJsonPrimitive -> "primitive"
else -> element.javaClass.simpleName
}
}
private fun describeAclObjectForLog(element: JsonElement): String {
val obj = element.asJsonObject
val fields = obj.entrySet().take(12).joinToString(",") { (key, value) ->
"$key=${describeAclValueForLog(value)}"
}
val suffix = if (obj.entrySet().size > 12) ",..." else ""
return "object($fields$suffix)"
}
private fun describeAclValueForLog(value: JsonElement): String {
return when {
value.isJsonNull -> "null"
value.isJsonArray -> "array(${value.asJsonArray.size()})"
value.isJsonObject -> "object(${value.asJsonObject.entrySet().joinToString(",") { it.key }})"
value.isJsonPrimitive -> {
val primitive = value.asJsonPrimitive
when {
primitive.isNumber -> primitive.asNumber.toString()
primitive.isBoolean -> primitive.asBoolean.toString()
primitive.isString -> primitive.asString.take(48)
else -> "primitive"
}
}
else -> value.javaClass.simpleName
}
} }
@ -318,20 +622,20 @@ suspend fun findPeerDestination(node: Node, dstmac: Mac, size: Long): SDLanSock
if (isMultiBroadcast(dstmac)) { if (isMultiBroadcast(dstmac)) {
node.stats.txSup.addAndGet(size) node.stats.txSup.addAndGet(size)
node.stats.txBroadcast.addAndGet(size) node.stats.txBroadcast.addAndGet(size)
result = node.config.supernode result = node.config.stunServer
is_multicast = true is_multicast = true
} else { } else {
val peer = node.knownPeers.get(dstmac) val peer = node.knownPeers.get(dstmac)
if (peer == null) { if (peer == null) {
node.stats.txSup.addAndGet(size) node.stats.txSup.addAndGet(size)
result = node.config.supernode result = node.config.stunServer
} else { } else {
val now = System.currentTimeMillis()/1000 val now = System.currentTimeMillis()/1000
if (now - peer.lastP2P.get() >= peer.timeout/2) { if (now - peer.lastP2P.get() >= peer.timeout/2) {
TerminalLogger.warning { "last p2p is too old, deleting from known hosts" } TerminalLogger.warning { "last p2p is too old, deleting from known hosts" }
node.knownPeers.remove(dstmac) node.knownPeers.remove(dstmac)
node.stats.txSup.addAndGet(size) node.stats.txSup.addAndGet(size)
result = node.config.supernode result = node.config.stunServer
} else { } else {
is_p2p = true is_p2p = true
node.stats.txP2P.addAndGet(size) node.stats.txP2P.addAndGet(size)
@ -389,12 +693,12 @@ suspend fun sendQueryPeer(node: Node, dstmac: Mac) {
val query = SDLQueryInfo.newBuilder() val query = SDLQueryInfo.newBuilder()
.setDstMac(dstmac) .setDstMac(dstmac)
.build() .build()
val msg = encodeToTcpMessage(query, node.getNextPacketID(), PacketType.QueryInfo) val msg = encodeToControlMessage(query, PacketType.QueryInfo)
node.toSocket.send(msg.toByteArray()) node.toSocket.send(msg.toByteArray())
} }
fun formEthernetPacket(srcmac: ByteArray, dstmac: ByteArray, data: ByteArray): ByteBuffer { fun formEthernetPacket(srcmac: ByteArray, dstmac: ByteArray, data: ByteArray): ByteBuffer {
val buffer = ByteBuffer.allocate(14 + data.size + 4).order(ByteOrder.BIG_ENDIAN) val buffer = ByteBuffer.allocate(14 + data.size).order(ByteOrder.BIG_ENDIAN)
buffer.put(dstmac) buffer.put(dstmac)
buffer.put(srcmac) buffer.put(srcmac)
buffer.putShort(EtherType.IPV4) buffer.putShort(EtherType.IPV4)
@ -407,54 +711,140 @@ fun formEthernetPacket(srcmac: ByteArray, dstmac: ByteArray, data: ByteArray): B
// fromip is self ip // fromip is self ip
// queryip is the targetip // queryip is the targetip
suspend fun sendArpRequest(node: Node, queryip: Int) { suspend fun sendArpRequest(node: Node, queryip: Int) {
println(1) if (!node.aes.isAuthorized()) {
val frommac = node.mac.toByteArray() TerminalLogger.errorf { "not authed for send arp request" }
val fromip = node.deviceConfig.ip.netAddr return
val buffer = ByteBuffer.allocate(64).order(ByteOrder.BIG_ENDIAN)
buffer.put(ByteArray(6){0xff.toByte()})
buffer.put(frommac)
println(2)
// println("src mac: ${macToString(node.mac)}")
buffer.putShort(EtherType.ARP)
buffer.putShort(EtherType.HWTYPE_ETH)
buffer.putShort(EtherType.IPV4)
buffer.put(6)
buffer.put(4)
buffer.putShort(EtherType.ARP_REQUEST)
buffer.put(frommac)
buffer.putInt(fromip)
buffer.put(ByteArray(6))
buffer.putInt(queryip)
buffer.flip()
println(3)
val size = buffer.remaining()
println(3.1)
val output = node.aes.encrypt(buffer)
println(3.2)
println(4)
if (output != null) {
val d = output.toByteString()
val data = SDLData.newBuilder()
.setIsP2P(true)
.setNetworkId(node.networkID.get())
.setTtl(2)
.setSrcMac(frommac.toByteString())
.setDstMac(SDLanConfig.BROADCASTMAC)
.setData(d)
.build()
println(5)
val msg = encodeToUDPMessage(data, PacketType.Data)
sendPacketToNet(node, SDLanConfig.BROADCASTMAC, msg, size.toLong())
} }
println(6) val arpReq = SDLArpRequest.newBuilder()
.setTargetIp(queryip)
.setOriginIp(node.deviceConfig.ip.netAddr)
.build()
val msg = encodeToControlMessage(arpReq, PacketType.ArpRequest)
node.toSocket.send(msg.toByteArray())
TerminalLogger.debugf { "sent QUIC ARP request for ip: ${com.jihe.punchnet.sdlan.utils.ipToString(queryip)}" }
} }
suspend fun handleTcpCommand(node: Node, cmdtype: Byte, cmdprotobuf: ByteArray) {} suspend fun handlePacketArpResponse(node: Node, content: ByteArray) {
val arpRes: SDLArpResponse
try {
arpRes = SDLArpResponse.parseFrom(content)
} catch (e: Exception) {
TerminalLogger.errorf { "failed to decode arp response: $e"}
return
}
val targetIp = arpRes.targetIp
val targetMac = arpRes.targetMac
if (targetMac.isEmpty) {
TerminalLogger.debugf { "ARP response returned empty MAC for IP: ${com.jihe.punchnet.sdlan.utils.ipToString(targetIp)}" }
return
}
TerminalLogger.debugf { "Rx QUIC ARP response for IP: ${com.jihe.punchnet.sdlan.utils.ipToString(targetIp)}, MAC: ${macToString(targetMac)}" }
sendPolicyRequestForTargetIp(node, targetIp)
if (node.iface is IfaceTun) {
val tun = node.iface as IfaceTun
tun.arpTable.addToARPTable(targetIp, targetMac.toByteArray())
tun.arpWaitList.arpArrived(node, targetIp, targetMac)
}
}
suspend fun sendExposedServiceRequest(node: Node) {
if (!node.aes.isAuthorized()) {
return
}
val req = SDLExposedServiceRequest.newBuilder()
.setVersion(0)
.build()
node.toSocket.send(encodeToControlMessage(req, PacketType.ExposedServiceRequest).toByteArray())
TerminalLogger.debugf { "sent ExposedServiceRequest version=0" }
}
suspend fun sendPolicyRequestForTargetIp(node: Node, targetIp: Int) {
if (!node.aes.isAuthorized()) {
return
}
val srcIdentityId = node.connectData?.identityId ?: 0
if (srcIdentityId == 0) {
TerminalLogger.warning { "skip PolicyRequest for ${ipToString(targetIp)}: missing src identity" }
return
}
val dstNode = node.connectData?.nodeList?.firstOrNull {
runCatching { ipStringToInt(it.ip) == targetIp }.getOrDefault(false)
}
if (dstNode == null) {
TerminalLogger.warning { "skip PolicyRequest for ${ipToString(targetIp)}: target not in node_list" }
return
}
TerminalLogger.debugf {
"policy target ${ipToString(targetIp)}: srcIdentity=$srcIdentityId, apiIdentity=${node.connectData?.identityId ?: 0}, " +
"nodeId=${dstNode.id}, nodeStatus=${dstNode.connectionStatus}, " +
describeResourcesForTarget(node, targetIp) + ", acl=${describeAclForLog(node.connectData?.acl)}"
}
sendPolicyRequest(node, srcIdentityId, dstNode.id, targetIp, "node:${dstNode.name}/${dstNode.connectionStatus}")
matchingResourceIdsForTarget(node, targetIp)
.filter { it != dstNode.id }
.forEach { resourceId ->
sendPolicyRequest(node, srcIdentityId, resourceId, targetIp, "resource")
}
}
suspend fun sendPolicyRequest(
node: Node,
srcIdentityId: Int,
dstIdentityId: Int,
targetIp: Int,
source: String,
) {
if (node.queriedPolicies.putIfAbsent(dstIdentityId, 0) != null) {
return
}
node.queriedPolicyTargets[dstIdentityId] = targetIp
val req = SDLPolicyRequest.newBuilder()
.setSrcIdentityId(srcIdentityId)
.setDstIdentityId(dstIdentityId)
.setVersion(0)
.build()
node.toSocket.send(encodeToControlMessage(req, PacketType.PolicyRequest).toByteArray())
TerminalLogger.debugf {
"sent PolicyRequest srcIdentity=$srcIdentityId, dstIdentity=$dstIdentityId, dstIp=${ipToString(targetIp)}, source=$source, version=0"
}
}
fun describeResourcesForTarget(node: Node, targetIp: Int): String {
val targetIpText = ipToString(targetIp)
val matches = node.connectData?.resourceList.orEmpty().mapNotNull { resource ->
val uri = runCatching { URI(resource.url) }.getOrNull() ?: return@mapNotNull null
if (uri.host != targetIpText) {
return@mapNotNull null
}
val port = when {
uri.port > 0 -> uri.port
uri.scheme.equals("http", ignoreCase = true) -> 80
uri.scheme.equals("https", ignoreCase = true) -> 443
else -> -1
}
"id=${resource.id}:${uri.scheme ?: "unknown"}/$port"
}.distinct()
return if (matches.isEmpty()) {
"no matching resource in connect resource_list for $targetIpText"
} else {
"matching resources for $targetIpText: ${matches.joinToString(",")}"
}
}
fun matchingResourceIdsForTarget(node: Node, targetIp: Int): List<Int> {
val targetIpText = ipToString(targetIp)
return node.connectData?.resourceList.orEmpty().mapNotNull { resource ->
val uri = runCatching { URI(resource.url) }.getOrNull() ?: return@mapNotNull null
if (uri.host == targetIpText) resource.id else null
}.distinct()
}
suspend fun handlePacketPeerInfo(node: Node, content: ByteArray) { suspend fun handlePacketPeerInfo(node: Node, content: ByteArray) {
val pinfo: SDLPeerInfo val pinfo: SDLPeerInfo
@ -470,6 +860,11 @@ suspend fun handlePacketPeerInfo(node: Node, content: ByteArray) {
return return
} }
if (!pinfo.hasV4Info() || pinfo.v4Info.v4.size() != 4 || pinfo.v4Info.port == 0) {
TerminalLogger.warning { "PeerInfo for ${macToString(pinfo.dstMac)} has no usable IPv4 endpoint" }
return
}
val remoteNat = NatType.fromUByte(pinfo.v4Info.natType.toUByte()) val remoteNat = NatType.fromUByte(pinfo.v4Info.natType.toUByte())
val pending = node.pendingPeers.get(pinfo.dstMac) val pending = node.pendingPeers.get(pinfo.dstMac)
if (pending == null) { if (pending == null) {
@ -482,16 +877,18 @@ suspend fun handlePacketPeerInfo(node: Node, content: ByteArray) {
sendRegister(node, remoteNat, pending.sock, pinfo.dstMac) sendRegister(node, remoteNat, pending.sock, pinfo.dstMac)
} }
suspend fun handleTcpEvent(node: Node, event: EventType, cmdprotobuf: ByteArray) { suspend fun handleEvent(node: Node, cmdprotobuf: ByteArray) {
when(event) { val evt: com.jihe.punchnet.protobuf.PunchProto.SDLEvent
EventType.SendRegister -> { try {
val reg: SDLSendRegisterEvent evt = com.jihe.punchnet.protobuf.PunchProto.SDLEvent.parseFrom(cmdprotobuf)
try { } catch (e: Exception) {
reg = SDLSendRegisterEvent.parseFrom(cmdprotobuf) TerminalLogger.errorf {"failed to decode SDLEvent: $e"}
} catch (e: Exception) { return
TerminalLogger.errorf {"failed to decode SendRegisterEvent: $e"} }
return
} when(evt.eventCase) {
com.jihe.punchnet.protobuf.PunchProto.SDLEvent.EventCase.SEND_REGISTER -> {
val reg = evt.sendRegister
val remoteNat = NatType.fromUByte(reg.natType.toUByte()) val remoteNat = NatType.fromUByte(reg.natType.toUByte())
val ip = byteArrayOf( val ip = byteArrayOf(
(reg.natIp ushr 24).and(0xff).toByte(), (reg.natIp ushr 24).and(0xff).toByte(),
@ -502,7 +899,7 @@ suspend fun handleTcpEvent(node: Node, event: EventType, cmdprotobuf: ByteArray)
checkPeerRegistrationNeeded(node,false, reg.dstMac, remoteNat, SDLanSock(IPFamily.IPV4, reg.natPort, ip)) checkPeerRegistrationNeeded(node,false, reg.dstMac, remoteNat, SDLanSock(IPFamily.IPV4, reg.natPort, ip))
} }
else -> { else -> {
TerminalLogger.warning { "unhandled event: $event" } TerminalLogger.warning { "unhandled event: ${evt.eventCase}" }
} }
} }
} }
@ -514,4 +911,4 @@ fun ipInt2ByteArray(ip: Int): ByteArray {
(ip ushr 8).and(0xff).toByte(), (ip ushr 8).and(0xff).toByte(),
ip.and(0xff).toByte(), ip.and(0xff).toByte(),
) )
} }

View File

@ -4,10 +4,12 @@ import android.util.Log
import com.jihe.punchnet.PunchnetServiceArgument import com.jihe.punchnet.PunchnetServiceArgument
import com.jihe.punchnet.data.ButtonRepository import com.jihe.punchnet.data.ButtonRepository
import com.jihe.punchnet.data.ButtonState import com.jihe.punchnet.data.ButtonState
import com.jihe.punchnet.protobuf.PunchProto.SDLDevAddr
import com.jihe.punchnet.protobuf.PunchProto.SDLRegisterSuper import com.jihe.punchnet.protobuf.PunchProto.SDLRegisterSuper
import com.jihe.punchnet.protobuf.PunchProto.SDLRegisterSuperAck import com.jihe.punchnet.protobuf.PunchProto.SDLRegisterSuperAck
import com.jihe.punchnet.protobuf.PunchProto.SDLRegisterSuperNak import com.jihe.punchnet.protobuf.PunchProto.SDLRegisterSuperNak
import com.jihe.punchnet.protobuf.PunchProto.SDLExposedServiceResponse
import com.jihe.punchnet.protobuf.PunchProto.SDLPolicyResponse
import com.jihe.punchnet.protobuf.PunchProto.SDLStunProbeReply import com.jihe.punchnet.protobuf.PunchProto.SDLStunProbeReply
import com.jihe.punchnet.sdlan.config.Arguments import com.jihe.punchnet.sdlan.config.Arguments
import com.jihe.punchnet.sdlan.config.RSAConfig import com.jihe.punchnet.sdlan.config.RSAConfig
@ -47,52 +49,92 @@ import java.util.concurrent.atomic.AtomicLong
import kotlin.io.path.pathString import kotlin.io.path.pathString
import kotlin.system.exitProcess import kotlin.system.exitProcess
suspend fun onMessage(scope: CoroutineScope, data: SDLanTCP) { suspend fun onMessage(scope: CoroutineScope, data: SDLanControl) {
val node = Node.getInstance() val node = Node.getInstance()
TerminalLogger.debugf {"message received"} TerminalLogger.debugf {"message received"}
when(data.packetType) { when(data.packetType) {
PacketType.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 -> { PacketType.RegisterSuperACK -> {
val ack = SDLRegisterSuperAck.parseFrom(data.currentPacket) val ack = SDLRegisterSuperAck.parseFrom(data.currentPacket)
// TerminalLogger.debugf { "got register super ack: $ack" } val algorithm = ack.algorithm.ifEmpty { "aes" }.lowercase()
println("ack is ${ack}") TerminalLogger.debugf {
println("ack's key is ${ack.aesKey.size()}") "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.aesKey.toByteArray()) val aeskey = node.rsa.decrypt(ack.key.toByteArray())
if (aeskey == null) { if (aeskey == null) {
println("aes key error") println("aes key error")
return return
} }
println("decrypted aes is: ${aeskey.contentToString()}") node.aes.setSecret(aeskey, algorithm, ack.regionId.toLong())
node.aes.setSecret(aeskey) node.sessionToken = ack.sessionToken
val ipStr = ipToString(ack.devAddr.netAddr)
TerminalLogger.debugf { "assigned ip: $ipStr" } node.deviceConfig.ip.netAddr = com.jihe.punchnet.sdlan.utils.ipStringToInt(node.connectData?.ip ?: "0.0.0.0").toUInt().toInt()
node.deviceConfig.ip.netAddr = ack.devAddr.netAddr node.deviceConfig.ip.netBitLen = (node.connectData?.maskLen ?: 24).toByte()
node.deviceConfig.ip.netBitLen = ack.devAddr.netBitLen.toByte()
if (node.iface is IfaceTun) {
val tun = node.iface as IfaceTun
val maskVal = com.jihe.punchnet.sdlan.network.maskDigitToInt(node.deviceConfig.ip.netBitLen.toInt()) ?: 0xffffff00.toInt()
tun.arpTable.routeTable.addRoute(
com.jihe.punchnet.sdlan.network.RouteDetail(
mask = maskVal,
gw = 0,
maskedAddr = node.deviceConfig.ip.netAddr and maskVal
)
)
}
println("reloading config: node.iface = ${node.iface}")
node.iface?.reload_config(node.deviceConfig) node.iface?.reload_config(node.deviceConfig)
println("reloading config ok: node.iface = ${node.iface}") node.networkID.set(node.authData?.networkId ?: 0)
println("reloading config ok: node.udp = ${node.udpSockV4}") TerminalLogger.debugf {
"control registered: clientIdTail=${node.clientId.takeLast(12)}, network=${node.networkID.get()}, " +
node.networkID.set(ack.devAddr.networkId) "ip=${ipToString(node.deviceConfig.ip.netAddr)}, mask=${node.deviceConfig.ip.netBitLen}, " +
// println("got aes key: ${aeskey.toList()}, length is ${aeskey.size}") "mac=${macToString(node.mac)}, identity=${node.connectData?.identityId ?: 0}"
}
sendExposedServiceRequest(node)
node.sendStunRequest() node.sendStunRequest()
scope.launch { scope.launch {
try { try {
node.probeNatType() node.probeNatType()
TerminalLogger.debugf { "nat type is ${node.nat_type}"} node.sendStunRequest()
TerminalLogger.debugf { "updated NAT type after probe: ${node.nat_type}" }
} catch (e: Exception) { } catch (e: Exception) {
Log.e("NAT", "probe nat exited: $e") TerminalLogger.warning { "NAT probe failed: $e" }
} }
} }
/*
CoroutineScope(Dispatchers.Default).async {
node.probeNatType()
TerminalLogger.debugf { "nat type is ${node.nat_type}"}
}
*/
} }
PacketType.RegisterSuperNAK -> { PacketType.RegisterSuperNAK -> {
val nak = SDLRegisterSuperNak.parseFrom(data.currentPacket) val nak = SDLRegisterSuperNak.parseFrom(data.currentPacket)
@ -117,7 +159,7 @@ suspend fun onMessage(scope: CoroutineScope, data: SDLanTCP) {
TerminalLogger.errorf { "malformed command received"} TerminalLogger.errorf { "malformed command received"}
return return
} }
handleTcpCommand(node, data.currentPacket[0], data.currentPacket.sliceArray(1..data.currentPacket.size-1)) // handleTcpCommand(node, data.currentPacket[0], data.currentPacket.sliceArray(1..data.currentPacket.size-1))
} }
PacketType.PeerInfo -> { PacketType.PeerInfo -> {
TerminalLogger.debugf { "PeerInfo received" } TerminalLogger.debugf { "PeerInfo received" }
@ -125,19 +167,40 @@ suspend fun onMessage(scope: CoroutineScope, data: SDLanTCP) {
} }
PacketType.Event -> { PacketType.Event -> {
TerminalLogger.debugf { "Event received" } TerminalLogger.debugf { "Event received" }
if (data.currentPacket.size < 1) { handleEvent(node, data.currentPacket)
TerminalLogger.errorf { "malformed event received"}
return
}
val eventType = EventType.fromByte(data.currentPacket[0])
val size = data.currentPacket.size
val content = data.currentPacket.sliceArray(1..size-1)
handleTcpEvent(node, eventType, content)
} }
PacketType.Pong -> { PacketType.Pong -> {
TerminalLogger.debugf { "tcp Pong Received" } TerminalLogger.debugf { "Pong Received" }
// node.ping_to_sn() }
PacketType.ArpResponse -> {
TerminalLogger.debugf { "ArpResponse received" }
handlePacketArpResponse(node, data.currentPacket)
}
PacketType.PolicyResponse -> {
val policy = SDLPolicyResponse.parseFrom(data.currentPacket)
node.queriedPolicies[policy.dstIdentityId] = policy.version
node.policyRules[policy.dstIdentityId] = policy.rules.toByteArray()
val ruleSummary = describePolicyRules(policy.rules.toByteArray())
val targetIp = node.queriedPolicyTargets[policy.dstIdentityId]
val targetText = targetIp?.let { ", dstIp=${ipToString(it)}, ${describeResourcesForTarget(node, it)}" } ?: ""
if (policy.rules.isEmpty) {
TerminalLogger.warning {
"PolicyResponse has no allow rules: srcIdentity=${policy.srcIdentityId}, " +
"dstIdentity=${policy.dstIdentityId}, version=${policy.version}$targetText"
}
} else {
TerminalLogger.debugf {
"PolicyResponse received srcIdentity=${policy.srcIdentityId}, dstIdentity=${policy.dstIdentityId}, " +
"version=${policy.version}, rules=$ruleSummary$targetText"
}
}
}
PacketType.ExposedServiceResponse -> {
val exposed = SDLExposedServiceResponse.parseFrom(data.currentPacket)
TerminalLogger.debugf {
"ExposedServiceResponse received version=${exposed.version}, " +
"tcpPorts=${exposed.tcpPortsList.size}, udpPorts=${exposed.udpPortsList.size}"
}
} }
else -> { else -> {
println("error packet type: ${data.packetType.toUByte()}") println("error packet type: ${data.packetType.toUByte()}")
@ -145,7 +208,7 @@ suspend fun onMessage(scope: CoroutineScope, data: SDLanTCP) {
} }
} }
suspend fun run_sdlan(scope: CoroutineScope, iface: Iface, argument: Arguments, routeinfo: PunchnetServiceArgument?) { suspend fun run_sdlan(scope: CoroutineScope, iface: Iface, argument: Arguments, authData: com.jihe.punchnet.api.AuthResponseData, connectData: com.jihe.punchnet.api.ConnectResponseData) {
UniqueNodeID.setBaseDir(argument.baseDir) UniqueNodeID.setBaseDir(argument.baseDir)
val edgeUUID = UniqueNodeID.getUUID() val edgeUUID = UniqueNodeID.getUUID()
val config = parseConfig(edgeUUID, argument) val config = parseConfig(edgeUUID, argument)
@ -156,58 +219,24 @@ suspend fun run_sdlan(scope: CoroutineScope, iface: Iface, argument: Arguments,
return return
} }
val toSocket = Channel<ByteArray>(100) val toSocket = Channel<ByteArray>(100)
val start_stop_channel = Channel<StartStopChanInfo>(100) val start_stop_channel = Channel<StartStopChanInfo>(100)
initEdge(scope, iface, argument.token, config, toSocket, start_stop_channel) initEdge(scope, iface, argument.token, config, toSocket, start_stop_channel)
val tcp = argument.tcp.split(":") val snParts = argument.sn.split(":")
val now = System.currentTimeMillis() / 1000 val now = System.currentTimeMillis() / 1000
val node = Node.getInstance() val node = Node.getInstance()
node.authData = authData
val before = ubyteArrayOf(126u,162u,25u,63u,148u,147u,198u,41u,69u,165u,149u,101u,153u,82u,190u,21u,48u,120u,26u,64u,142u,103u,159u,60u,47u,129u,176u,17u,232u,210u,36u,56u) node.connectData = connectData
val encrypted = node.rsa.encrypt(before.toByteArray(), use_private_key = false)
// println("encrypted: ${encrypted.contentToString()}")
val origin = node.rsa.decrypt(encrypted, use_pub_key = false)
// TerminalLogger.debugf { "got encrypted ${encrypted.size}"}
// TerminalLogger.debugf { "got origin ${origin.contentToString()}"}
TerminalLogger.debugf { "self mac: ${macToString(node.mac)}"} TerminalLogger.debugf { "self mac: ${macToString(node.mac)}"}
TerminalLogger.debugf { describeConnectDataForLog(node) }
val onConnected: suspend (tech.kwik.core.QuicStream) -> Unit = { stream ->
val onConnected: suspend (Socket, Int?) -> Unit = { socket, pktID -> TerminalLogger.debugf { "QUIC stream connected callback" }
println("connected callback") // We wait for server's PACKET_WELCOME before sending RegisterSuper
val clientid = UniqueNodeID.getUUID()
println("client id is $clientid")
val registerSuper = SDLRegisterSuper.newBuilder()
.setVersion(1)
.setToken(node.token)
.setInstalledChannel("kotlin")
.setClientId(clientid)
.setDevAddr(
SDLDevAddr.newBuilder().setMac(node.mac)
.setNetAddr(0)
.setNetBitLen(0)
.setNetworkId(0)
.build()
)
.setPubKey(node.rsa.getPublicKeyString())
.build()
val packetID = pktID ?: node.getNextPacketID()
val msg = encodeToTcpMessage(registerSuper, packetID, PacketType.RegisterSuper)
// TerminalLogger.debugf{"sent register super: $registerSuper"}
toSocket.send(msg.toByteArray())
TerminalLogger.debugf{"sent register super!"}
} }
/*
val onMessage: suspend (SDLanTCP) -> Unit = { data ->
}
*/
scope.launch { scope.launch {
try { try {
@ -219,15 +248,11 @@ suspend fun run_sdlan(scope: CoroutineScope, iface: Iface, argument: Arguments,
val data = node.iface?.recv() val data = node.iface?.recv()
Log.d("SDLAN", "async receive data from iface: ${data?.size} bytes") Log.d("SDLAN", "async receive data from iface: ${data?.size} bytes")
if (data == null) { if (data == null) {
//delay(1000)
delay(1000) delay(1000)
// println("got data is null")
continue continue
} }
if (data.isEmpty()) { if (data.isEmpty()) {
delay(1000) delay(1000)
// println("got data size 0")
// delay(1000)
continue continue
} }
Log.d("SDLAN", "handle data form device starts") Log.d("SDLAN", "handle data form device starts")
@ -241,33 +266,11 @@ suspend fun run_sdlan(scope: CoroutineScope, iface: Iface, argument: Arguments,
node.udpSockMulticast?.close() node.udpSockMulticast?.close()
} }
} }
/*
CoroutineScope((Dispatchers.IO)).async {
while(true) {
val data = node.iface?.recv()
Log.d("SDLAN", "async receive data from iface: ${data?.size} bytes")
if (data == null) {
//delay(1000)
println("got data is null")
continue
}
if (data.isEmpty()) {
println("got data size 0")
// delay(1000)
continue
}
Log.d("SDLAN", "handle data form device starts")
node.iface?.handleDataFromDevice(node, data)
Log.d("SDLAN", "handle data form device stops")
}
}
*/
// CoroutineScope(Dispatchers.IO).async {
scope.launch { scope.launch {
initTCPConn( initQUICConn(
scope, scope,
tcp[0], tcp[1].toInt(), snParts[0], snParts[1].toIntOrNull() ?: 1265,
start_stop_channel, start_stop_channel,
AtomicLong(now), AtomicLong(now),
AtomicBoolean(false), AtomicBoolean(false),
@ -281,7 +284,6 @@ suspend fun run_sdlan(scope: CoroutineScope, iface: Iface, argument: Arguments,
) )
} }
println("sending start") println("sending start")
start_stop_channel.send(StartStopChanInfo(StartStopFlag.IsStart, null)) start_stop_channel.send(StartStopChanInfo(StartStopFlag.IsStart, null))
println("sent start") println("sent start")
@ -308,6 +310,7 @@ suspend fun loopSocketV4(scope: CoroutineScope, node: Node, sock: SDLanSocket, c
try { try {
while(true) { while(true) {
delay(10_000) delay(10_000)
node.probeNatType()
node.sendStunRequest() node.sendStunRequest()
} }
} catch (e: Exception) { } catch (e: Exception) {
@ -362,7 +365,7 @@ suspend fun handleAPacket(scope: CoroutineScope, node: Node, from: SocketAddress
} }
// val buffer = ByteBuffer.wrap(data, 1, size-1) // val buffer = ByteBuffer.wrap(data, 1, size-1)
when (pktType) { when (pktType) {
PacketType.Data -> { PacketType.Data -> {
TerminalLogger.debugf { "got DATA" } TerminalLogger.debugf { "got DATA" }
if (!node.aes.isAuthorized()) { if (!node.aes.isAuthorized()) {
@ -387,6 +390,14 @@ suspend fun handleAPacket(scope: CoroutineScope, node: Node, from: SocketAddress
TerminalLogger.debugf { "got stun reply" } TerminalLogger.debugf { "got stun reply" }
return return
} }
PacketType.PolicyResponse -> {
TerminalLogger.debugf { "got policy response" }
return
}
PacketType.ExposedServiceResponse -> {
TerminalLogger.debugf { "got exposed service response" }
return
}
PacketType.Register -> { PacketType.Register -> {
TerminalLogger.debugf { "got REGISTER" } TerminalLogger.debugf { "got REGISTER" }
if (from is InetSocketAddress) { if (from is InetSocketAddress) {
@ -447,213 +458,6 @@ fun initEdge(scope: CoroutineScope, iface: Iface, token: String, config: NodeCon
println(instance) println(instance)
} }
suspend fun initTCPConn(
scope: CoroutineScope,
tcpHost: String,
tcpPort: Int,
start_stop: Channel<StartStopChanInfo>,
pong_time: AtomicLong,
connected: AtomicBoolean,
toSocket: ReceiveChannel<ByteArray>,
onConnected: suspend (stream: Socket, pktID: Int?)->Unit,
onMessage: suspend (CoroutineScope, SDLanTCP)->Unit,
onDisconnected: suspend ()->Unit,
connectingChan: SendChannel<ConnectingState>?
) {
val started = AtomicBoolean(false)
// var started: Boolean = false
var startPktID: Int? = null
val node = Node.getInstance()
while (true) {
connectingChan?.send(ConnectingState.NotConnected)
if (!started.get()) {
while (true) {
println("waiting for start_stop")
val startStopInfo = start_stop.receive()
if (startStopInfo.flag == StartStopFlag.IsStart) {
started.set(true)
// started = true
startPktID = startStopInfo.packetID
break
}
TerminalLogger.debugf {
"start stop chan received ${startStopInfo}"
}
}
}
connectingChan?.send(ConnectingState.Connecting)
TerminalLogger.debugf { "try connecting tcp..." }
val socket: Socket
try {
withContext(Dispatchers.IO) {
TerminalLogger.debugf { "connecting to $tcpHost:$tcpPort" }
socket = Socket(tcpHost, tcpPort)
}
}catch(e: CancellationException) {
withContext(NonCancellable) {
node.iface?.close()
}
return
} catch (e: Exception) {
TerminalLogger.errorf { "failed to connect to ${tcpHost}:${tcpPort}: $e" }
delay(3000)
continue
}
try {
val node = Node.getInstance()
val outIP = ByteBuffer.wrap(socket.localAddress.address).getInt()
node.outerIPV4.set(outIP)
// val job_read_packet = CoroutineScope(Dispatchers.IO).async {
val job_read_packet = scope.async {
val input = DataInputStream(socket.getInputStream())
try {
println("job read packet starts")
while (true) {
val tcpPacket = readPacket(input)
if (tcpPacket == null) {
TerminalLogger.errorf { "tcp Packet is null" }
break
}
onMessage(scope, tcpPacket)
}
} finally {
input.close()
TerminalLogger.errorf { "input closing" }
}
}
// val job_write_to_packet = CoroutineScope(Dispatchers.IO).async {
val job_write_to_packet = scope.async {
val output = DataOutputStream(socket.getOutputStream())
try {
TerminalLogger.debugf { "job write to packet starts" }
while (true) {
try {
val msg = toSocket.receive()
TerminalLogger.debugf { "received message" }
output.write(msg)
} catch (e: Exception) {
output.close()
TerminalLogger.errorf { "failed to receive message to tcp: $e" }
break
}
}
} finally {
output.close()
}
}
// val job_check_pong = CoroutineScope(Dispatchers.IO).async {
val job_check_pong = scope.async {
println("job check pong starts")
while (true) {
delay(10_000)
val now = System.currentTimeMillis() / 1000
if (connected.get() && (now - pong_time.get() > SDLanConfig.TCP_PING_TIME * 2)) {
TerminalLogger.errorf { "tcp pong check expired" }
break
}
}
}
// val job_check_stop = CoroutineScope(Dispatchers.IO).async {
val job_check_stop = scope.async {
println("job check stop starts")
while (true) {
try {
val v = start_stop.receive()
if (v.flag == StartStopFlag.IsStop) {
started.set(false)
break
}
} catch (e: Exception) {
// started.set(false)
break
}
}
}
TerminalLogger.debugf { "connected" }
onConnected(socket, startPktID)
connectingChan?.send(ConnectingState.Connected)
var cancelled: Boolean = false
select<Unit> {
job_read_packet.onAwait() {
println("job read packet exited")
}
job_write_to_packet.onAwait() {
println("job write to packet exited")
}
job_check_pong.onAwait() {
println("job check pong exited")
}
job_check_stop.onAwait() {
println("job check stop exited")
}
}
println("m1")
job_read_packet.cancelAndJoin()
println("m2")
job_write_to_packet.cancelAndJoin()
println("m3")
job_check_pong.cancelAndJoin()
println("m4")
job_check_stop.cancelAndJoin()
println("m5")
delay(1000)
} catch (e: Exception) {
withContext(NonCancellable) {
node.iface?.close()
socket.close()
}
}
}
}
fun readPacket(input: DataInputStream): SDLanTCP? {
try {
val size = input.readShort()
val packetID = input.readInt().toUInt()
val packetTypeUByte = input.readByte().toUByte()
val packetType = PacketType.fromValue(packetTypeUByte)
if (packetType == null) {
TerminalLogger.errorf{"packet type not found: $packetTypeUByte"}
return null
}
if (size < 5) {
TerminalLogger.errorf {"got input stream size error: ${size}"}
return null
}
var buffsize = size - 5
val data = ByteArray(buffsize)
var toread = buffsize
while(toread > 0) {
val sizeGot = input.read(data, (buffsize-toread), toread)
if (sizeGot <= 0) {
TerminalLogger.errorf {"failed to read further: got ${sizeGot}"}
return null
}
toread -= sizeGot
}
return SDLanTCP(packetID, packetType, data)
} catch (e: Exception) {
TerminalLogger.errorf { "failed to read: $e" }
return null
}
}
fun parseConfig(nodeuuid: String, argument: Arguments): NodeConfig? { fun parseConfig(nodeuuid: String, argument: Arguments): NodeConfig? {
if (argument.sn.length == 0) { if (argument.sn.length == 0) {
@ -661,18 +465,23 @@ fun parseConfig(nodeuuid: String, argument: Arguments): NodeConfig? {
return null return null
} }
val natServer1 = parseScoketAddressV4FromString(argument.nat_server1)
val natServer2 = parseScoketAddressV4FromString(argument.nat_server2)
if (natServer1 == null || natServer2 == null) {
return null
}
val sn = parseScoketAddressV4FromString(argument.sn) val sn = parseScoketAddressV4FromString(argument.sn)
if (sn == null) { if (sn == null) {
return null return null
} }
val stun = parseScoketAddressV4FromString(argument.stun)
if (stun == null) {
return null
}
val natServer1 = parseScoketAddressV4FromString(argument.nat_server1)
val natServer2 = parseScoketAddressV4FromString(argument.nat_server2)
if (natServer1 == null || natServer2 == null) {
return null
}
return NodeConfig( return NodeConfig(
baseDir = argument.baseDir, baseDir = argument.baseDir,
name = argument.name, name = argument.name,
@ -686,7 +495,8 @@ fun parseConfig(nodeuuid: String, argument: Arguments): NodeConfig? {
registerTTL = argument.registerTTL, registerTTL = argument.registerTTL,
localPort = argument.localPort, localPort = argument.localPort,
supernode = SDLanSock(IPFamily.IPV4, sn.port, sn.address.address), supernode = SDLanSock(IPFamily.IPV4, sn.port, sn.address.address),
stunServer = SDLanSock(IPFamily.IPV4, stun.port, stun.address.address),
natServer1 = natServer1, natServer1 = natServer1,
natServer2 = natServer2, natServer2 = natServer2
) )
} }

View File

@ -49,6 +49,15 @@ enum class PacketType(val id: UByte) {
StunProbe(0x32u), StunProbe(0x32u),
StunProbeReply(0x33u), StunProbeReply(0x33u),
Welcome(0x4fu),
ArpRequest(0x50u),
ArpResponse(0x51u),
PolicyRequest(0xb0u),
PolicyResponse(0xb1u),
ExposedServiceRequest(0xb2u),
ExposedServiceResponse(0xb3u),
Data(0xffu); Data(0xffu);
companion object { companion object {
@ -65,9 +74,8 @@ fun PacketType.toUByte(): UByte {
} }
// tcp发送过来的通道里面的信息 // tcp/quic发送过来的通道里面的信息
class SDLanTCP( class SDLanControl(
val packetID: UInt,
val packetType: PacketType, val packetType: PacketType,
val currentPacket: ByteArray, val currentPacket: ByteArray,
) )
@ -103,18 +111,13 @@ fun encodeToUDPMessage(msg: Message?, packetType: PacketType): List<Byte> {
return result return result
} }
fun encodeToTcpMessage(msg: Message?, packetID: Int, packetType: PacketType): List<Byte> { fun encodeToControlMessage(msg: Message?, packetType: PacketType): List<Byte> {
val msgByte = msg?.toByteArray()?.toList()?:listOf<Byte>() val msgByte = msg?.toByteArray()?.toList()?:listOf<Byte>()
val result: MutableList<Byte> = mutableListOf() val result: MutableList<Byte> = mutableListOf()
result.addAll(ByteBuffer.allocate(2) result.addAll(ByteBuffer.allocate(2)
.order(ByteOrder.BIG_ENDIAN) .order(ByteOrder.BIG_ENDIAN)
.putShort((msgByte.size + 5).toShort()) .putShort((msgByte.size + 1).toShort())
.array().toList())
result.addAll(ByteBuffer.allocate(4)
.order(ByteOrder.BIG_ENDIAN)
.putInt(packetID)
.array().toList()) .array().toList())
result.add(packetType.toUByte().toByte()) result.add(packetType.toUByte().toByte())
@ -127,4 +130,4 @@ enum class StunProbeAttr {
None, None,
Port, Port,
Peer, Peer,
} }

View File

@ -17,6 +17,8 @@ import kotlin.experimental.inv
import kotlin.experimental.or import kotlin.experimental.or
import kotlin.random.Random import kotlin.random.Random
import android.content.Context
object UniqueNodeID { object UniqueNodeID {
var id: String = "" var id: String = ""
private var baseDir: String = "" private var baseDir: String = ""
@ -25,11 +27,18 @@ object UniqueNodeID {
baseDir = basedir baseDir = basedir
} }
fun getUUID(): String { fun getUUID(context: Context? = null): String {
if (baseDir.length == 0) { if (id.isEmpty() && context != null) {
baseDir = Environment.getExternalStorageDirectory().name val androidId = android.provider.Settings.Secure.getString(context.contentResolver, android.provider.Settings.Secure.ANDROID_ID)
if (!androidId.isNullOrEmpty() && androidId != "9774d56d682e549c") { // 9774d56d682e549c is a known bugged ID on old emulators
id = androidId
return id
}
} }
if (id.length == 0) { if (id.isEmpty()) {
if (baseDir.isEmpty()) {
baseDir = Environment.getExternalStorageDirectory().absolutePath
}
val dirpath = File(baseDir) val dirpath = File(baseDir)
dirpath.mkdirs() dirpath.mkdirs()
val f = File(baseDir, SDLanConfig.ID_FILENAME) val f = File(baseDir, SDLanConfig.ID_FILENAME)
@ -71,6 +80,15 @@ fun ipToString(ip: Int): String {
return "$d1.$d2.$d3.$d4" return "$d1.$d2.$d3.$d4"
} }
fun ipStringToInt(ipString: String): Int {
val parts = ipString.split(".")
if (parts.size != 4) return 0
return (parts[0].toInt() shl 24) or
(parts[1].toInt() shl 16) or
(parts[2].toInt() shl 8) or
(parts[3].toInt())
}
fun macToString(mac: Mac): String { fun macToString(mac: Mac): String {
return mac.joinToString(separator = ":") { it.toUByte().toString(16) } return mac.joinToString(separator = ":") { it.toUByte().toString(16) }
} }

View File

@ -2,8 +2,8 @@ syntax = "proto3";
option java_package = "com.jihe.punchnet.protobuf"; option java_package = "com.jihe.punchnet.protobuf";
option java_outer_classname = "PunchProto"; option java_outer_classname = "PunchProto";
//
//
message SDLV4Info { message SDLV4Info {
uint32 port = 1; uint32 port = 1;
bytes v4 = 2; bytes v4 = 2;
@ -15,34 +15,30 @@ message SDLV6Info {
bytes v6 = 2; bytes v6 = 2;
} }
// message SDLWelcome {
message SDLDevAddr { uint32 version = 1;
uint32 network_id = 1; uint32 max_bidi_streams = 2;
bytes mac = 2; uint32 max_packet_size = 3;
uint32 net_addr = 3; uint32 heartbeat_sec = 4;
uint32 net_bit_len = 4; SDLV6Info ipv6_assist = 5;
}
// tcp通讯消息
message SDLEmpty {
} }
message SDLRegisterSuper { message SDLRegisterSuper {
uint32 version = 1; string client_id = 1;
string installed_channel = 2; uint32 network_id = 2;
string client_id = 3; bytes mac = 3;
SDLDevAddr dev_addr = 4; uint32 ip = 4;
string pub_key = 5; uint32 mask_len = 5;
string token = 6; string hostname = 6;
string pub_key = 7;
string access_token = 8;
} }
message SDLRegisterSuperAck { message SDLRegisterSuperAck {
SDLDevAddr dev_addr = 1; string algorithm = 1;
bytes aes_key = 2; bytes key = 2;
uint32 upgrade_type = 3; uint32 region_id = 3;
optional string upgrade_prompt = 4; bytes session_token = 4;
optional string upgrade_address = 5;
} }
message SDLRegisterSuperNak { message SDLRegisterSuperNak {
@ -51,72 +47,115 @@ message SDLRegisterSuperNak {
} }
// //
message SDLQueryInfo { message SDLQueryInfo {
bytes dst_mac = 1; bytes dst_mac = 1;
} }
message SDLPeerInfo { message SDLPeerInfo {
bytes dst_mac = 1; bytes dst_mac = 1;
SDLV4Info v4_info = 2; optional SDLV4Info v4_info = 2;
optional SDLV6Info v6_info = 3; optional SDLV6Info v6_info = 3;
} }
message SDLArpRequest {
uint32 target_ip = 1;
uint32 origin_ip = 2;
bytes context = 3;
}
message SDLArpResponse {
uint32 target_ip = 1;
bytes target_mac = 2;
uint32 origin_ip = 3;
bytes context = 4;
}
message SDLPolicyRequest {
uint32 src_identity_id = 1;
uint32 dst_identity_id = 2;
uint32 version = 3;
}
message SDLPolicyResponse {
uint32 src_identity_id = 1;
uint32 dst_identity_id = 2;
uint32 version = 3;
bytes rules = 4;
}
message SDLExposedServiceRequest {
uint32 version = 1;
}
message SDLExposedServiceResponse {
uint32 version = 1;
repeated uint32 tcp_ports = 2;
repeated uint32 udp_ports = 3;
}
// //
message SDLEvent {
message NatChanged {
bytes mac = 1;
uint32 ip = 2;
}
message SDLNatChangedEvent { message SendRegister {
bytes mac = 1; bytes dst_mac = 1;
uint32 ip = 2; uint32 nat_ip = 2;
} uint32 nat_port = 3;
uint32 nat_type = 4;
optional SDLV6Info v6_info = 5;
}
message SDLSendRegisterEvent { message ExposedServiceChanged {
bytes dst_mac = 1; }
uint32 nat_ip = 2;
uint32 nat_port = 3;
uint32 nat_type = 4;
optional SDLV6Info v6_info = 5;
}
message SDLNetworkShutdownEvent { message NetworkShutdown {
string message = 1; string message = 1;
}
oneof event {
NatChanged nat_changed = 1;
SendRegister send_register = 2;
NetworkShutdown shutdown = 3;
ExposedServiceChanged exposed_service_changed = 4;
}
} }
// //
message SDLCommand {
uint32 pkt_id = 1;
message SDLChangeNetworkCommand { message ExitNodeControl {
SDLDevAddr dev_addr = 1; int32 action = 1;
bytes aes_key = 2; string remark = 2;
}
oneof command {
ExitNodeControl exit_node = 2;
}
} }
message SDLCommandAck { message SDLCommandAck {
// status = true, status = false message是失败原因描述 uint32 pkt_id = 1;
bool status = 1; int32 code = 2;
optional string message = 2; string message = 3;
} bytes data = 4;
message SDLFlows {
//
uint32 forward_num = 1;
// p2p直接流量
uint32 p2p_num = 2;
//
uint32 inbound_num = 3;
} }
// UDP通讯消息 // UDP通讯消息
message SDLStunRequest { message SDLStunRequest {
uint32 cookie = 1; string client_id = 1;
string client_id = 2; uint32 network_id = 2;
uint32 network_id = 3; bytes mac = 3;
bytes mac = 4; uint32 ip = 4;
uint32 ip = 5; uint32 nat_type = 5;
uint32 nat_type = 6; optional SDLV6Info v6_info = 6;
optional SDLV6Info v6_info = 7; bytes session_token = 7;
} }
message SDLStunReply { message SDLStunReply {
uint32 cookie = 1;
} }
message SDLData { message SDLData {
@ -126,6 +165,8 @@ message SDLData {
bool is_p2p = 4; bool is_p2p = 4;
uint32 ttl = 5; uint32 ttl = 5;
bytes data = 6; bytes data = 6;
bytes session_token = 7;
uint32 identity_id = 8;
} }
message SDLRegister { message SDLRegister {
@ -141,14 +182,15 @@ message SDLRegisterAck {
} }
// //
message SDLStunProbe { message SDLStunProbe {
uint32 cookie = 1; uint32 cookie = 1;
uint32 attr = 2; uint32 attr = 2;
uint32 step = 3;
} }
message SDLStunProbeReply { message SDLStunProbeReply {
uint32 cookie = 1; uint32 cookie = 1;
uint32 port = 2; uint32 step = 2;
uint32 ip = 3; uint32 port = 3;
uint32 ip = 4;
} }

View File

@ -20,4 +20,5 @@ kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the # Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies, # resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library # thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true android.nonTransitiveRClass=true
org.gradle.java.home=/Users/stavid/Library/Java/JavaVirtualMachines/corretto-17.0.17/Contents/Home

0
gradlew vendored Normal file → Executable file
View File