commit 4ffbc3bffe39cf6598fead8234ca24b83726861d Author: Meghdad Fadaee Date: Sun May 31 15:36:07 2026 +0330 init diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b3de894 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +name: CI + +on: + pull_request: + push: + branches: [main, master] + +jobs: + rust: + name: Rust + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + - name: Format + run: cargo fmt --all -- --check + - name: Clippy + run: cargo clippy --workspace --all-targets -- -D warnings + - name: Test + run: cargo test --workspace + + android: + name: Android + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + - uses: android-actions/setup-android@v3 + - uses: gradle/actions/setup-gradle@v4 + with: + gradle-version: current + - name: Assemble debug + run: gradle :apps:android:app:assembleDebug + - name: Lint debug + run: gradle :apps:android:app:lintDebug diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c9a4a80 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +.gradle/ +build/ +**/build/ +.idea/ +*.iml +local.properties +.DS_Store +target/ +Cargo.lock +captures/ +*.apk +*.aab +*.keystore diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..9a618d3 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,31 @@ +# Contributing + +VPN Share is designed as a serious networking project. Contributions should +preserve the core product constraints: + +- No root requirement. +- No manual proxy or routing setup for users. +- Existing VPN apps on the phone remain the upstream. +- Companion clients configure their own local virtual interfaces. +- No traffic-content logging. + +## Development Checks + +```bash +cargo fmt --check +cargo test --workspace +``` + +Android checks require a working Gradle/Android SDK setup: + +```bash +gradle :apps:android:app:lintDebug +gradle :apps:android:app:assembleDebug +``` + +## Code Style + +- Keep packet/protocol logic in Rust crates where possible. +- Keep Android platform glue thin and testable. +- Avoid adding dependencies to security-sensitive parsing paths without review. +- Public protocol changes require docs and compatibility tests. diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..2955b01 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,16 @@ +[workspace] +members = [ + "crates/vpnshare-core", + "crates/vpnshare-proto", + "crates/vpnshare-transport", + "crates/vpnshare-ffi", + "clients/desktop", +] +resolver = "2" + +[workspace.package] +edition = "2021" +license = "Apache-2.0" +repository = "https://github.com/vpnshare/vpn-share" +rust-version = "1.80" +version = "0.1.0" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b1277fe --- /dev/null +++ b/LICENSE @@ -0,0 +1,153 @@ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and +distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright +owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities +that control, are controlled by, or are under common control with that entity. +For the purposes of this definition, "control" means (i) the power, direct or +indirect, to cause the direction or management of such entity, whether by +contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the +outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising +permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including +but not limited to software source code, documentation source, and configuration +files. + +"Object" form shall mean any form resulting from mechanical transformation or +translation of a Source form, including but not limited to compiled object code, +generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, +made available under the License, as indicated by a copyright notice that is +included in or attached to the work. + +"Derivative Works" shall mean any work, whether in Source or Object form, that +is based on (or derived from) the Work and for which the editorial revisions, +annotations, elaborations, or other modifications represent, as a whole, an +original work of authorship. For the purposes of this License, Derivative Works +shall not include works that remain separable from, or merely link (or bind by +name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version +of the Work and any modifications or additions to that Work or Derivative Works +thereof, that is intentionally submitted to Licensor for inclusion in the Work +by the copyright owner or by an individual or Legal Entity authorized to submit +on behalf of the copyright owner. For the purposes of this definition, +"submitted" means any form of electronic, verbal, or written communication sent +to the Licensor or its representatives, including but not limited to +communication on electronic mailing lists, source code control systems, and +issue tracking systems that are managed by, or on behalf of, the Licensor for +the purpose of discussing and improving the Work, but excluding communication +that is conspicuously marked or otherwise designated in writing by the copyright +owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf +of whom a Contribution has been received by Licensor and subsequently +incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this +License, each Contributor hereby grants to You a perpetual, worldwide, +non-exclusive, no-charge, royalty-free, irrevocable copyright license to +reproduce, prepare Derivative Works of, publicly display, publicly perform, +sublicense, and distribute the Work and such Derivative Works in Source or +Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, +each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, +no-charge, royalty-free, irrevocable patent license to make, have made, use, +offer to sell, sell, import, and otherwise transfer the Work, where such license +applies only to those patent claims licensable by such Contributor that are +necessarily infringed by their Contribution alone or by combination of their +Contribution with the Work to which such Contribution was submitted. If You +institute patent litigation against any entity alleging that the Work or a +Contribution incorporated within the Work constitutes direct or contributory +patent infringement, then any patent licenses granted to You under this License +for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or +Derivative Works thereof in any medium, with or without modifications, and in +Source or Object form, provided that You meet the following conditions: + +(a) You must give any other recipients of the Work or Derivative Works a copy of +this License; and + +(b) You must cause any modified files to carry prominent notices stating that +You changed the files; and + +(c) You must retain, in the Source form of any Derivative Works that You +distribute, all copyright, patent, trademark, and attribution notices from the +Source form of the Work, excluding those notices that do not pertain to any part +of the Derivative Works; and + +(d) If the Work includes a "NOTICE" text file as part of its distribution, then +any Derivative Works that You distribute must include a readable copy of the +attribution notices contained within such NOTICE file, excluding those notices +that do not pertain to any part of the Derivative Works, in at least one of the +following places: within a NOTICE text file distributed as part of the +Derivative Works; within the Source form or documentation, if provided along +with the Derivative Works; or, within a display generated by the Derivative +Works, if and wherever such third-party notices normally appear. The contents of +the NOTICE file are for informational purposes only and do not modify the +License. You may add Your own attribution notices within Derivative Works that +You distribute, alongside or as an addendum to the NOTICE text from the Work, +provided that such additional attribution notices cannot be construed as +modifying the License. + +You may add Your own copyright statement to Your modifications and may provide +additional or different license terms and conditions for use, reproduction, or +distribution of Your modifications, or for any such Derivative Works as a whole, +provided Your use, reproduction, and distribution of the Work otherwise complies +with the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any +Contribution intentionally submitted for inclusion in the Work by You to the +Licensor shall be under the terms and conditions of this License, without any +additional terms or conditions. Notwithstanding the above, nothing herein shall +supersede or modify the terms of any separate license agreement you may have +executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, +trademarks, service marks, or product names of the Licensor, except as required +for reasonable and customary use in describing the origin of the Work and +reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in +writing, Licensor provides the Work on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied, including, without +limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, +MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible +for determining the appropriateness of using or redistributing the Work and +assume any risks associated with Your exercise of permissions under this +License. + +8. Limitation of Liability. In no event and under no legal theory, whether in +tort (including negligence), contract, or otherwise, unless required by +applicable law (such as deliberate and grossly negligent acts) or agreed to in +writing, shall any Contributor be liable to You for damages, including any +direct, indirect, special, incidental, or consequential damages of any character +arising as a result of this License or out of the use or inability to use the +Work, even if such Contributor has been advised of the possibility of such +damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or +Derivative Works thereof, You may choose to offer, and charge a fee for, +acceptance of support, warranty, indemnity, or other liability obligations +and/or rights consistent with this License. However, in accepting such +obligations, You may act only on Your own behalf and on Your sole +responsibility, not on behalf of any other Contributor, and only if You agree to +indemnify, defend, and hold each Contributor harmless for any liability incurred +by, or claims asserted against, such Contributor by reason of your accepting any +such warranty or additional liability. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..0193de6 --- /dev/null +++ b/NOTICE @@ -0,0 +1,4 @@ +VPN Share +Copyright 2026 VPN Share contributors + +Licensed under the Apache License, Version 2.0. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0829f55 --- /dev/null +++ b/README.md @@ -0,0 +1,67 @@ +# VPN Share + +VPN Share is an open-source Android-first project for sharing an Android phone's +active VPN connection with nearby computers, tablets, and phones. + +The product goal is simple for users: start the VPN they already trust on their +phone, tap **Share**, pair the receiving device, and let the client route traffic +through the phone automatically. + +## Current Status + +This repository contains the production architecture, protocol specification, +Android project scaffold, Rust core scaffold, and the first USB-first engine +interfaces. It is not yet a complete packet-forwarding release. + +The first shippable milestone is: + +- Android gateway app, Kotlin, minSdk 26. +- Rust `vpnshare-core` packet engine library. +- USB companion-client transport. +- Desktop client foundation for Windows, Linux, and macOS. +- Encrypted VSHP tunnel with QR/code pairing. + +## Important Platform Constraint + +VPN Share uses companion clients on receiving devices. This is required because +stock Android only allows one prepared `VpnService` owner at a time. The phone +gateway must preserve the already-running VPN app, so it does not create a +second phone-side VPN. + +The Android app opens normal network sockets from the gateway process. When an +existing VPN app is the device default network, those sockets are routed through +that VPN by Android. The gateway must not call `VpnService.protect()` for +forwarded traffic, because protected sockets bypass VPN routing. + +## Repository Layout + +```text +apps/android/ Android application and feature modules +clients/desktop/ Desktop client CLI/service foundation +crates/vpnshare-core/ Packet, NAT, DNS, MTU, and gateway domain engine +crates/vpnshare-proto/ VSHP frame and pairing protocol primitives +crates/vpnshare-transport/ Transport abstraction shared by clients/gateway +crates/vpnshare-ffi/ C ABI surface for Android JNI integration +docs/ Architecture, protocol, security, testing, roadmap +``` + +## Build Notes + +Rust validation: + +```bash +cargo test --workspace +``` + +Android validation, once a healthy Gradle installation or wrapper is available: + +```bash +gradle :apps:android:app:assembleDebug +``` + +The local environment used to create this scaffold had a broken system Gradle +native-platform installation, so Android compilation was not executed locally. + +## License + +Apache-2.0. See [LICENSE](LICENSE). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..449f0a3 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,30 @@ +# Security Policy + +VPN Share handles user network traffic. Security issues should be treated as +high priority even before the first stable release. + +## Reporting + +Report vulnerabilities privately to the maintainers. Do not open public issues +for exploitable bugs until a fix is available. + +## Scope + +In scope: + +- Authentication bypass. +- Traffic decryption or tampering. +- DNS or traffic leaks. +- Peer isolation failures. +- Unsafe packet parser behavior. +- Sensitive data logging. + +Out of scope: + +- Denial of service requiring physical access and no persistence. +- Bugs in third-party VPN applications. + +## Disclosure Target + +Maintainers should acknowledge reports within 7 days and publish a fix timeline +based on severity. diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts new file mode 100644 index 0000000..56790e4 --- /dev/null +++ b/apps/android/app/build.gradle.kts @@ -0,0 +1,36 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "org.vpnshare.app" + compileSdk = 36 + + defaultConfig { + applicationId = "org.vpnshare" + minSdk = 26 + targetSdk = 36 + versionCode = 1 + versionName = "0.1.0" + } + + buildFeatures { + compose = true + } +} + +dependencies { + implementation(project(":apps:android:core:domain")) + implementation(project(":apps:android:feature:share")) + implementation(project(":apps:android:service:gateway")) + + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.core.ktx) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.runtime) + implementation(libs.androidx.compose.ui) + debugImplementation(libs.androidx.compose.ui.tooling) +} diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..44eb49f --- /dev/null +++ b/apps/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/android/app/src/main/kotlin/org/vpnshare/app/MainActivity.kt b/apps/android/app/src/main/kotlin/org/vpnshare/app/MainActivity.kt new file mode 100644 index 0000000..58dc7b1 --- /dev/null +++ b/apps/android/app/src/main/kotlin/org/vpnshare/app/MainActivity.kt @@ -0,0 +1,31 @@ +package org.vpnshare.app + +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import org.vpnshare.feature.share.ShareScreen +import org.vpnshare.gateway.VpnShareGatewayService + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + ShareScreen( + onStartShare = { + startForegroundService( + Intent(this, VpnShareGatewayService::class.java) + .setAction(VpnShareGatewayService.ACTION_START) + ) + }, + onStopShare = { + startService( + Intent(this, VpnShareGatewayService::class.java) + .setAction(VpnShareGatewayService.ACTION_STOP) + ) + } + ) + } + } +} diff --git a/apps/android/app/src/main/kotlin/org/vpnshare/app/VpnShareApplication.kt b/apps/android/app/src/main/kotlin/org/vpnshare/app/VpnShareApplication.kt new file mode 100644 index 0000000..b014407 --- /dev/null +++ b/apps/android/app/src/main/kotlin/org/vpnshare/app/VpnShareApplication.kt @@ -0,0 +1,5 @@ +package org.vpnshare.app + +import android.app.Application + +class VpnShareApplication : Application() diff --git a/apps/android/app/src/main/res/drawable/ic_launcher.xml b/apps/android/app/src/main/res/drawable/ic_launcher.xml new file mode 100644 index 0000000..d42cd6e --- /dev/null +++ b/apps/android/app/src/main/res/drawable/ic_launcher.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..a089670 --- /dev/null +++ b/apps/android/app/src/main/res/values/strings.xml @@ -0,0 +1,6 @@ + + VPN Share + VPN Share sessions + VPN Share is active + Sharing through the phone VPN + diff --git a/apps/android/app/src/main/res/values/styles.xml b/apps/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..dd28c9a --- /dev/null +++ b/apps/android/app/src/main/res/values/styles.xml @@ -0,0 +1,7 @@ + + + diff --git a/apps/android/core/domain/build.gradle.kts b/apps/android/core/domain/build.gradle.kts new file mode 100644 index 0000000..240ec71 --- /dev/null +++ b/apps/android/core/domain/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) +} + +android { + namespace = "org.vpnshare.domain" + compileSdk = 36 + + defaultConfig { + minSdk = 26 + } +} diff --git a/apps/android/core/domain/src/main/AndroidManifest.xml b/apps/android/core/domain/src/main/AndroidManifest.xml new file mode 100644 index 0000000..cc947c5 --- /dev/null +++ b/apps/android/core/domain/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/apps/android/core/domain/src/main/kotlin/org/vpnshare/domain/model/Models.kt b/apps/android/core/domain/src/main/kotlin/org/vpnshare/domain/model/Models.kt new file mode 100644 index 0000000..32524bc --- /dev/null +++ b/apps/android/core/domain/src/main/kotlin/org/vpnshare/domain/model/Models.kt @@ -0,0 +1,83 @@ +package org.vpnshare.domain.model + +import java.time.Instant + +@JvmInline +value class PeerId(val value: String) + +enum class ShareTransport { + Usb, + Wifi, + Hotspot +} + +enum class ClientPlatform { + Windows, + Linux, + MacOs, + Android, + Ios, + Unknown +} + +data class GatewayConfig( + val preferredTransport: ShareTransport = ShareTransport.Usb, + val allowWifiFallback: Boolean = true, + val allowHotspot: Boolean = false, + val maxPeers: Int = 4, + val defaultMtu: Int = 1280 +) + +data class VpnStatus( + val active: Boolean, + val networkName: String?, + val supportsIpv4: Boolean, + val supportsIpv6: Boolean +) + +data class PairingRequest( + val requestId: String, + val displayName: String, + val platform: ClientPlatform, + val transport: ShareTransport, + val createdAt: Instant, + val expiresAt: Instant +) { + fun isExpired(now: Instant): Boolean = !expiresAt.isAfter(now) +} + +data class PeerDevice( + val id: PeerId, + val displayName: String, + val platform: ClientPlatform, + val trustedAt: Instant, + val lastSeenAt: Instant?, + val revoked: Boolean = false +) + +data class TunnelLease( + val peerId: PeerId, + val ipv4Address: String, + val ipv6Address: String?, + val dnsGateway: String, + val mtu: Int, + val routes: List, + val expiresAt: Instant +) + +data class GatewayStats( + val connectedPeers: Int = 0, + val bytesFromClients: Long = 0, + val bytesToClients: Long = 0 +) + +sealed interface ShareState { + data object Stopped : ShareState + data class Starting(val config: GatewayConfig) : ShareState + data class Running( + val config: GatewayConfig, + val vpnStatus: VpnStatus, + val stats: GatewayStats + ) : ShareState + data class Failed(val reason: String) : ShareState +} diff --git a/apps/android/core/engine/build.gradle.kts b/apps/android/core/engine/build.gradle.kts new file mode 100644 index 0000000..3c1b3be --- /dev/null +++ b/apps/android/core/engine/build.gradle.kts @@ -0,0 +1,19 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) +} + +android { + namespace = "org.vpnshare.engine" + compileSdk = 36 + + defaultConfig { + minSdk = 26 + } +} + +dependencies { + implementation(project(":apps:android:core:domain")) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.coroutines.core) +} diff --git a/apps/android/core/engine/src/main/AndroidManifest.xml b/apps/android/core/engine/src/main/AndroidManifest.xml new file mode 100644 index 0000000..cc947c5 --- /dev/null +++ b/apps/android/core/engine/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/apps/android/core/engine/src/main/kotlin/org/vpnshare/engine/GatewayEvent.kt b/apps/android/core/engine/src/main/kotlin/org/vpnshare/engine/GatewayEvent.kt new file mode 100644 index 0000000..bff5329 --- /dev/null +++ b/apps/android/core/engine/src/main/kotlin/org/vpnshare/engine/GatewayEvent.kt @@ -0,0 +1,13 @@ +package org.vpnshare.engine + +import org.vpnshare.domain.model.PairingRequest +import org.vpnshare.domain.model.PeerId +import org.vpnshare.domain.model.ShareState + +sealed interface GatewayEvent { + data class StateChanged(val state: ShareState) : GatewayEvent + data class PairingRequested(val request: PairingRequest) : GatewayEvent + data class PeerConnected(val peerId: PeerId) : GatewayEvent + data class PeerDisconnected(val peerId: PeerId, val reason: String) : GatewayEvent + data class Warning(val message: String) : GatewayEvent +} diff --git a/apps/android/core/engine/src/main/kotlin/org/vpnshare/engine/RustVpnShareEngine.kt b/apps/android/core/engine/src/main/kotlin/org/vpnshare/engine/RustVpnShareEngine.kt new file mode 100644 index 0000000..6fa9247 --- /dev/null +++ b/apps/android/core/engine/src/main/kotlin/org/vpnshare/engine/RustVpnShareEngine.kt @@ -0,0 +1,67 @@ +package org.vpnshare.engine + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import org.vpnshare.domain.model.GatewayConfig +import org.vpnshare.domain.model.GatewayStats +import org.vpnshare.domain.model.PairingRequest +import org.vpnshare.domain.model.PeerId +import org.vpnshare.domain.model.ShareState +import org.vpnshare.domain.model.VpnStatus + +class RustVpnShareEngine( + private val versionProvider: EngineVersionProvider = JniEngineVersionProvider() +) : VpnShareEngine { + private val mutableEvents = MutableSharedFlow(extraBufferCapacity = 64) + + override val events: Flow = mutableEvents.asSharedFlow() + + override suspend fun startGateway(config: GatewayConfig) { + mutableEvents.emit(GatewayEvent.StateChanged(ShareState.Starting(config))) + mutableEvents.emit( + GatewayEvent.StateChanged( + ShareState.Running( + config = config, + vpnStatus = VpnStatus( + active = false, + networkName = null, + supportsIpv4 = true, + supportsIpv6 = false + ), + stats = GatewayStats() + ) + ) + ) + mutableEvents.emit(GatewayEvent.Warning("Rust core linked as ${versionProvider.version()}")) + } + + override suspend fun stopGateway() { + mutableEvents.emit(GatewayEvent.StateChanged(ShareState.Stopped)) + } + + override suspend fun approvePairing(request: PairingRequest): PeerId { + val peerId = PeerId("peer-${request.requestId}") + mutableEvents.emit(GatewayEvent.PeerConnected(peerId)) + return peerId + } + + override suspend fun rejectPairing(request: PairingRequest) { + mutableEvents.emit(GatewayEvent.Warning("Rejected pairing request ${request.requestId}")) + } +} + +interface EngineVersionProvider { + fun version(): String +} + +class JniEngineVersionProvider : EngineVersionProvider { + override fun version(): String { + return runCatching { + System.loadLibrary("vpnshare_ffi") + nativeVersion() + }.getOrDefault("unlinked") + } + + private external fun nativeVersion(): String +} diff --git a/apps/android/core/engine/src/main/kotlin/org/vpnshare/engine/VpnShareEngine.kt b/apps/android/core/engine/src/main/kotlin/org/vpnshare/engine/VpnShareEngine.kt new file mode 100644 index 0000000..117b0f4 --- /dev/null +++ b/apps/android/core/engine/src/main/kotlin/org/vpnshare/engine/VpnShareEngine.kt @@ -0,0 +1,18 @@ +package org.vpnshare.engine + +import kotlinx.coroutines.flow.Flow +import org.vpnshare.domain.model.GatewayConfig +import org.vpnshare.domain.model.PairingRequest +import org.vpnshare.domain.model.PeerId + +interface VpnShareEngine { + val events: Flow + + suspend fun startGateway(config: GatewayConfig) + + suspend fun stopGateway() + + suspend fun approvePairing(request: PairingRequest): PeerId + + suspend fun rejectPairing(request: PairingRequest) +} diff --git a/apps/android/feature/share/build.gradle.kts b/apps/android/feature/share/build.gradle.kts new file mode 100644 index 0000000..c2aad51 --- /dev/null +++ b/apps/android/feature/share/build.gradle.kts @@ -0,0 +1,28 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "org.vpnshare.feature.share" + compileSdk = 36 + + defaultConfig { + minSdk = 26 + } + + buildFeatures { + compose = true + } +} + +dependencies { + implementation(project(":apps:android:core:domain")) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.foundation) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.runtime) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.tooling.preview) +} diff --git a/apps/android/feature/share/src/main/AndroidManifest.xml b/apps/android/feature/share/src/main/AndroidManifest.xml new file mode 100644 index 0000000..cc947c5 --- /dev/null +++ b/apps/android/feature/share/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/apps/android/feature/share/src/main/kotlin/org/vpnshare/feature/share/ShareScreen.kt b/apps/android/feature/share/src/main/kotlin/org/vpnshare/feature/share/ShareScreen.kt new file mode 100644 index 0000000..1b08249 --- /dev/null +++ b/apps/android/feature/share/src/main/kotlin/org/vpnshare/feature/share/ShareScreen.kt @@ -0,0 +1,100 @@ +package org.vpnshare.feature.share + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp + +@Composable +fun ShareScreen( + onStartShare: () -> Unit, + onStopShare: () -> Unit +) { + MaterialTheme { + Surface( + color = Color(0xFFF8FAFC), + modifier = Modifier.fillMaxSize() + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + verticalArrangement = Arrangement.SpaceBetween + ) { + Column { + Text( + text = "VPN Share", + style = MaterialTheme.typography.headlineLarge, + fontWeight = FontWeight.SemiBold, + color = Color(0xFF0F172A) + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "Share this phone's active VPN with a paired device.", + style = MaterialTheme.typography.bodyLarge, + color = Color(0xFF475569) + ) + } + + Surface( + shape = RoundedCornerShape(8.dp), + color = Color.White, + shadowElevation = 1.dp, + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = "USB first", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + color = Color(0xFF0F172A) + ) + Text( + text = "Connect a computer with USB, approve pairing on this phone, and the client configures routing automatically.", + style = MaterialTheme.typography.bodyMedium, + color = Color(0xFF475569) + ) + } + } + + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Button( + onClick = onStartShare, + modifier = Modifier.weight(1f) + ) { + Text("Share") + } + OutlinedButton( + onClick = onStopShare, + modifier = Modifier.weight(1f) + ) { + Text("Stop") + } + } + } + } + } +} diff --git a/apps/android/service/gateway/build.gradle.kts b/apps/android/service/gateway/build.gradle.kts new file mode 100644 index 0000000..af23535 --- /dev/null +++ b/apps/android/service/gateway/build.gradle.kts @@ -0,0 +1,20 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) +} + +android { + namespace = "org.vpnshare.gateway" + compileSdk = 36 + + defaultConfig { + minSdk = 26 + } +} + +dependencies { + implementation(project(":apps:android:core:domain")) + implementation(project(":apps:android:core:engine")) + implementation(libs.androidx.core.ktx) + implementation(libs.kotlinx.coroutines.android) +} diff --git a/apps/android/service/gateway/src/main/AndroidManifest.xml b/apps/android/service/gateway/src/main/AndroidManifest.xml new file mode 100644 index 0000000..cc947c5 --- /dev/null +++ b/apps/android/service/gateway/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/apps/android/service/gateway/src/main/kotlin/org/vpnshare/gateway/VpnDetector.kt b/apps/android/service/gateway/src/main/kotlin/org/vpnshare/gateway/VpnDetector.kt new file mode 100644 index 0000000..b7a173f --- /dev/null +++ b/apps/android/service/gateway/src/main/kotlin/org/vpnshare/gateway/VpnDetector.kt @@ -0,0 +1,38 @@ +package org.vpnshare.gateway + +import android.content.Context +import android.net.ConnectivityManager +import android.net.LinkProperties +import android.net.NetworkCapabilities +import org.vpnshare.domain.model.VpnStatus + +class VpnDetector(context: Context) { + private val connectivityManager = + context.getSystemService(ConnectivityManager::class.java) + + fun snapshot(): VpnStatus { + val activeNetwork = connectivityManager.activeNetwork + val activeCapabilities = activeNetwork?.let(connectivityManager::getNetworkCapabilities) + val vpnNetwork = connectivityManager.allNetworks.firstOrNull { network -> + connectivityManager.getNetworkCapabilities(network) + ?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) == true + } + val linkProperties: LinkProperties? = (vpnNetwork ?: activeNetwork) + ?.let(connectivityManager::getLinkProperties) + + return VpnStatus( + active = activeCapabilities?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) == true || + vpnNetwork != null, + networkName = linkProperties?.interfaceName, + supportsIpv4 = linkProperties?.linkAddresses + ?.any { it.address.address.size == IPV4_BYTES } ?: false, + supportsIpv6 = linkProperties?.linkAddresses + ?.any { it.address.address.size == IPV6_BYTES } ?: false + ) + } + + private companion object { + const val IPV4_BYTES = 4 + const val IPV6_BYTES = 16 + } +} diff --git a/apps/android/service/gateway/src/main/kotlin/org/vpnshare/gateway/VpnShareGatewayService.kt b/apps/android/service/gateway/src/main/kotlin/org/vpnshare/gateway/VpnShareGatewayService.kt new file mode 100644 index 0000000..ef9c5cc --- /dev/null +++ b/apps/android/service/gateway/src/main/kotlin/org/vpnshare/gateway/VpnShareGatewayService.kt @@ -0,0 +1,109 @@ +package org.vpnshare.gateway + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.IBinder +import androidx.core.app.NotificationCompat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import org.vpnshare.domain.model.GatewayConfig +import org.vpnshare.engine.RustVpnShareEngine + +class VpnShareGatewayService : Service() { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val engine = RustVpnShareEngine() + private lateinit var vpnDetector: VpnDetector + + override fun onCreate() { + super.onCreate() + vpnDetector = VpnDetector(this) + ensureNotificationChannel() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + when (intent?.action) { + ACTION_STOP -> { + scope.launch { engine.stopGateway() } + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + return START_NOT_STICKY + } + else -> startGateway() + } + return START_STICKY + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onDestroy() { + scope.cancel() + super.onDestroy() + } + + private fun startGateway() { + val notification = buildNotification() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground( + NOTIFICATION_ID, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE + ) + } else { + startForeground(NOTIFICATION_ID, notification) + } + + scope.launch { + val vpn = vpnDetector.snapshot() + engine.startGateway(GatewayConfig()) + if (!vpn.active) { + // The UI layer will render this event once event collection is wired. + // The gateway still accepts local pairing so users can start their VPN. + } + } + } + + private fun buildNotification(): Notification { + val stopIntent = Intent(this, VpnShareGatewayService::class.java).setAction(ACTION_STOP) + val stopPendingIntent = PendingIntent.getService( + this, + 0, + stopIntent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + return NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(android.R.drawable.stat_sys_upload) + .setContentTitle("VPN Share is active") + .setContentText("Sharing through the phone VPN") + .setOngoing(true) + .addAction(android.R.drawable.ic_menu_close_clear_cancel, "Stop", stopPendingIntent) + .build() + } + + private fun ensureNotificationChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val manager = getSystemService(NotificationManager::class.java) + manager.createNotificationChannel( + NotificationChannel( + CHANNEL_ID, + "VPN Share sessions", + NotificationManager.IMPORTANCE_LOW + ) + ) + } + + companion object { + const val ACTION_START = "org.vpnshare.action.START" + const val ACTION_STOP = "org.vpnshare.action.STOP" + private const val CHANNEL_ID = "vpnshare.gateway" + private const val NOTIFICATION_ID = 1001 + } +} diff --git a/apps/android/service/gateway/src/main/kotlin/org/vpnshare/gateway/discovery/NsdDiscoveryPublisher.kt b/apps/android/service/gateway/src/main/kotlin/org/vpnshare/gateway/discovery/NsdDiscoveryPublisher.kt new file mode 100644 index 0000000..f4d0a36 --- /dev/null +++ b/apps/android/service/gateway/src/main/kotlin/org/vpnshare/gateway/discovery/NsdDiscoveryPublisher.kt @@ -0,0 +1,40 @@ +package org.vpnshare.gateway.discovery + +import android.content.Context +import android.net.nsd.NsdManager +import android.net.nsd.NsdServiceInfo + +class NsdDiscoveryPublisher(context: Context) { + private val nsdManager = context.getSystemService(NsdManager::class.java) + private var listener: NsdManager.RegistrationListener? = null + + fun publish(instanceName: String, port: Int) { + stop() + val serviceInfo = NsdServiceInfo().apply { + serviceName = instanceName + serviceType = SERVICE_TYPE + setPort(port) + setAttribute("v", "1") + setAttribute("caps", "vshp,qr,resume") + } + val registrationListener = object : NsdManager.RegistrationListener { + override fun onServiceRegistered(serviceInfo: NsdServiceInfo) = Unit + override fun onRegistrationFailed(serviceInfo: NsdServiceInfo, errorCode: Int) = Unit + override fun onServiceUnregistered(serviceInfo: NsdServiceInfo) = Unit + override fun onUnregistrationFailed(serviceInfo: NsdServiceInfo, errorCode: Int) = Unit + } + listener = registrationListener + nsdManager.registerService(serviceInfo, NsdManager.PROTOCOL_DNS_SD, registrationListener) + } + + fun stop() { + listener?.let { + runCatching { nsdManager.unregisterService(it) } + } + listener = null + } + + companion object { + const val SERVICE_TYPE = "_vpnshare._udp." + } +} diff --git a/apps/android/service/gateway/src/main/kotlin/org/vpnshare/gateway/transport/LocalOnlyHotspotController.kt b/apps/android/service/gateway/src/main/kotlin/org/vpnshare/gateway/transport/LocalOnlyHotspotController.kt new file mode 100644 index 0000000..5440e68 --- /dev/null +++ b/apps/android/service/gateway/src/main/kotlin/org/vpnshare/gateway/transport/LocalOnlyHotspotController.kt @@ -0,0 +1,54 @@ +package org.vpnshare.gateway.transport + +import android.net.wifi.WifiManager +import android.os.Build +import android.os.Handler +import android.os.Looper + +class LocalOnlyHotspotController( + private val wifiManager: WifiManager +) { + private var reservation: WifiManager.LocalOnlyHotspotReservation? = null + + fun start(callback: Callback) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + callback.onFailed("Local-only hotspot requires Android 8.0 or newer") + return + } + wifiManager.startLocalOnlyHotspot( + object : WifiManager.LocalOnlyHotspotCallback() { + override fun onStarted(reservation: WifiManager.LocalOnlyHotspotReservation) { + this@LocalOnlyHotspotController.reservation = reservation + val config = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + reservation.softApConfiguration?.ssid.orEmpty() + } else { + @Suppress("DEPRECATION") + reservation.wifiConfiguration?.SSID.orEmpty() + } + callback.onStarted(config) + } + + override fun onStopped() { + this@LocalOnlyHotspotController.reservation = null + callback.onStopped() + } + + override fun onFailed(reason: Int) { + callback.onFailed("Local-only hotspot failed: $reason") + } + }, + Handler(Looper.getMainLooper()) + ) + } + + fun stop() { + reservation?.close() + reservation = null + } + + interface Callback { + fun onStarted(ssid: String) + fun onStopped() + fun onFailed(reason: String) + } +} diff --git a/apps/android/service/gateway/src/main/kotlin/org/vpnshare/gateway/transport/UsbAccessoryTransport.kt b/apps/android/service/gateway/src/main/kotlin/org/vpnshare/gateway/transport/UsbAccessoryTransport.kt new file mode 100644 index 0000000..c9283d4 --- /dev/null +++ b/apps/android/service/gateway/src/main/kotlin/org/vpnshare/gateway/transport/UsbAccessoryTransport.kt @@ -0,0 +1,30 @@ +package org.vpnshare.gateway.transport + +import android.hardware.usb.UsbAccessory +import android.hardware.usb.UsbManager +import android.os.ParcelFileDescriptor +import java.io.Closeable +import java.io.FileInputStream +import java.io.FileOutputStream + +class UsbAccessoryTransport( + private val usbManager: UsbManager +) { + fun open(accessory: UsbAccessory): Session? { + val descriptor = usbManager.openAccessory(accessory) ?: return null + return Session(descriptor) + } + + class Session( + private val descriptor: ParcelFileDescriptor + ) : Closeable { + val input = FileInputStream(descriptor.fileDescriptor) + val output = FileOutputStream(descriptor.fileDescriptor) + + override fun close() { + runCatching { input.close() } + runCatching { output.close() } + descriptor.close() + } + } +} diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..81f6a88 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,6 @@ +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.compose) apply false +} diff --git a/clients/desktop/Cargo.toml b/clients/desktop/Cargo.toml new file mode 100644 index 0000000..8f1dd47 --- /dev/null +++ b/clients/desktop/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "vpnshare-desktop" +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +version.workspace = true + +[[bin]] +name = "vpnshare-desktop" +path = "src/main.rs" + +[dependencies] +vpnshare-core = { path = "../../crates/vpnshare-core" } +vpnshare-proto = { path = "../../crates/vpnshare-proto" } +vpnshare-transport = { path = "../../crates/vpnshare-transport" } diff --git a/clients/desktop/src/main.rs b/clients/desktop/src/main.rs new file mode 100644 index 0000000..27bf9ca --- /dev/null +++ b/clients/desktop/src/main.rs @@ -0,0 +1,14 @@ +use vpnshare_core::{GatewayConfig, MtuPolicy}; +use vpnshare_proto::{encode_frame, FrameType, PROTOCOL_VERSION}; + +fn main() { + let config = GatewayConfig::default(); + let mtu = MtuPolicy::default(); + let hello = encode_frame(FrameType::Hello, 0, 1, b"vpnshare-desktop").expect("static hello frame"); + + println!("VPN Share desktop client skeleton"); + println!("protocol=VSHP/{PROTOCOL_VERSION}"); + println!("default_gateway_mtu={}", config.default_mtu); + println!("effective_tunnel_mtu={}", mtu.effective_tunnel_mtu()); + println!("hello_frame_bytes={}", hello.len()); +} diff --git a/crates/vpnshare-core/Cargo.toml b/crates/vpnshare-core/Cargo.toml new file mode 100644 index 0000000..0593496 --- /dev/null +++ b/crates/vpnshare-core/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "vpnshare-core" +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +version.workspace = true + +[lib] +path = "src/lib.rs" + +[dependencies] +vpnshare-proto = { path = "../vpnshare-proto" } diff --git a/crates/vpnshare-core/src/dns.rs b/crates/vpnshare-core/src/dns.rs new file mode 100644 index 0000000..701dfc6 --- /dev/null +++ b/crates/vpnshare-core/src/dns.rs @@ -0,0 +1,42 @@ +use std::net::IpAddr; +use std::time::Duration; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DnsPolicy { + pub gateway_listen: IpAddr, + pub min_ttl: Duration, + pub max_ttl: Duration, + pub enable_tcp_fallback: bool, +} + +impl DnsPolicy { + pub fn clamp_ttl(&self, upstream_ttl: Duration) -> Duration { + upstream_ttl.max(self.min_ttl).min(self.max_ttl) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DnsRequest { + pub transaction_id: u16, + pub question_name: String, + pub question_type: u16, +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::{IpAddr, Ipv4Addr}; + + #[test] + fn ttl_is_clamped() { + let policy = DnsPolicy { + gateway_listen: IpAddr::V4(Ipv4Addr::new(10, 241, 0, 1)), + min_ttl: Duration::from_secs(10), + max_ttl: Duration::from_secs(300), + enable_tcp_fallback: true, + }; + + assert_eq!(policy.clamp_ttl(Duration::from_secs(1)), Duration::from_secs(10)); + assert_eq!(policy.clamp_ttl(Duration::from_secs(600)), Duration::from_secs(300)); + } +} diff --git a/crates/vpnshare-core/src/lease.rs b/crates/vpnshare-core/src/lease.rs new file mode 100644 index 0000000..bea65dc --- /dev/null +++ b/crates/vpnshare-core/src/lease.rs @@ -0,0 +1,99 @@ +use std::collections::HashMap; +use std::net::{Ipv4Addr, Ipv6Addr}; +use std::time::{Duration, Instant}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct PeerId([u8; 16]); + +impl PeerId { + pub fn from_u128(value: u128) -> Self { + Self(value.to_be_bytes()) + } + + pub fn as_bytes(&self) -> [u8; 16] { + self.0 + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Lease { + pub peer_id: PeerId, + pub ipv4: Ipv4Addr, + pub ipv6: Option, + pub dns_gateway: Ipv4Addr, + pub mtu: u16, + pub expires_at: Instant, +} + +#[derive(Debug)] +pub struct LeaseAllocator { + next_host: u8, + default_mtu: u16, + leases: HashMap, +} + +impl LeaseAllocator { + pub fn new(default_mtu: u16) -> Self { + Self { + next_host: 2, + default_mtu, + leases: HashMap::new(), + } + } + + pub fn allocate(&mut self, peer_id: PeerId) -> Result { + if let Some(existing) = self.leases.get(&peer_id) { + return Ok(existing.clone()); + } + if self.next_host == u8::MAX { + return Err(LeaseError::PoolExhausted); + } + + let lease = Lease { + peer_id, + ipv4: Ipv4Addr::new(10, 241, 0, self.next_host), + ipv6: None, + dns_gateway: Ipv4Addr::new(10, 241, 0, 1), + mtu: self.default_mtu, + expires_at: Instant::now() + Duration::from_secs(12 * 60 * 60), + }; + self.next_host += 1; + self.leases.insert(peer_id, lease.clone()); + Ok(lease) + } + + pub fn revoke(&mut self, peer_id: PeerId) -> Option { + self.leases.remove(&peer_id) + } + + pub fn clear(&mut self) { + self.next_host = 2; + self.leases.clear(); + } + + pub fn active_count(&self) -> usize { + self.leases.len() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LeaseError { + PoolExhausted, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn allocates_stable_peer_lease() { + let mut allocator = LeaseAllocator::new(1280); + let peer = PeerId::from_u128(42); + + let first = allocator.allocate(peer).unwrap(); + let second = allocator.allocate(peer).unwrap(); + + assert_eq!(first.ipv4, Ipv4Addr::new(10, 241, 0, 2)); + assert_eq!(first, second); + } +} diff --git a/crates/vpnshare-core/src/lib.rs b/crates/vpnshare-core/src/lib.rs new file mode 100644 index 0000000..b354a01 --- /dev/null +++ b/crates/vpnshare-core/src/lib.rs @@ -0,0 +1,117 @@ +//! Core VPN Share engine primitives. + +pub mod dns; +pub mod lease; +pub mod mtu; +pub mod nat; +pub mod packet; + +use std::time::Instant; + +pub use dns::{DnsPolicy, DnsRequest}; +pub use lease::{Lease, LeaseAllocator, PeerId}; +pub use mtu::MtuPolicy; +pub use nat::{FlowKey, FlowTable, TransportProtocol}; +pub use packet::{IpPacket, PacketError}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GatewayState { + Stopped, + Starting, + Running, + Failed, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GatewayConfig { + pub max_peers: usize, + pub default_mtu: u16, + pub ipv6_enabled: bool, +} + +impl Default for GatewayConfig { + fn default() -> Self { + Self { + max_peers: 4, + default_mtu: 1280, + ipv6_enabled: false, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VpnStatus { + pub active: bool, + pub interface_name: Option, + pub supports_ipv4: bool, + pub supports_ipv6: bool, +} + +#[derive(Debug)] +pub struct GatewayEngine { + state: GatewayState, + config: GatewayConfig, + leases: LeaseAllocator, + flows: FlowTable, + started_at: Option, +} + +impl GatewayEngine { + pub fn new(config: GatewayConfig) -> Self { + Self { + state: GatewayState::Stopped, + leases: LeaseAllocator::new(config.default_mtu), + flows: FlowTable::default(), + config, + started_at: None, + } + } + + pub fn start(&mut self, now: Instant) { + self.state = GatewayState::Running; + self.started_at = Some(now); + } + + pub fn stop(&mut self) { + self.state = GatewayState::Stopped; + self.started_at = None; + self.flows.clear(); + self.leases.clear(); + } + + pub fn state(&self) -> GatewayState { + self.state + } + + pub fn config(&self) -> &GatewayConfig { + &self.config + } + + pub fn leases(&mut self) -> &mut LeaseAllocator { + &mut self.leases + } + + pub fn flows(&mut self) -> &mut FlowTable { + &mut self.flows + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn gateway_start_stop_clears_runtime_state() { + let mut engine = GatewayEngine::new(GatewayConfig::default()); + engine.start(Instant::now()); + assert_eq!(engine.state(), GatewayState::Running); + + let peer = PeerId::from_u128(1); + engine.leases().allocate(peer).unwrap(); + assert_eq!(engine.leases().active_count(), 1); + + engine.stop(); + assert_eq!(engine.state(), GatewayState::Stopped); + assert_eq!(engine.leases().active_count(), 0); + } +} diff --git a/crates/vpnshare-core/src/mtu.rs b/crates/vpnshare-core/src/mtu.rs new file mode 100644 index 0000000..6ac9640 --- /dev/null +++ b/crates/vpnshare-core/src/mtu.rs @@ -0,0 +1,50 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MtuPolicy { + pub link_mtu: u16, + pub protocol_overhead: u16, + pub min_ipv6_mtu: u16, +} + +impl MtuPolicy { + pub fn effective_tunnel_mtu(&self) -> u16 { + self.link_mtu + .saturating_sub(self.protocol_overhead) + .max(self.min_ipv6_mtu) + } + + pub fn tcp_mss_ipv4(&self) -> u16 { + self.effective_tunnel_mtu().saturating_sub(40) + } + + pub fn tcp_mss_ipv6(&self) -> u16 { + self.effective_tunnel_mtu().saturating_sub(60) + } +} + +impl Default for MtuPolicy { + fn default() -> Self { + Self { + link_mtu: 1420, + protocol_overhead: 96, + min_ipv6_mtu: 1280, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mtu_never_below_ipv6_minimum() { + let policy = MtuPolicy { + link_mtu: 1000, + protocol_overhead: 100, + min_ipv6_mtu: 1280, + }; + + assert_eq!(policy.effective_tunnel_mtu(), 1280); + assert_eq!(policy.tcp_mss_ipv4(), 1240); + assert_eq!(policy.tcp_mss_ipv6(), 1220); + } +} diff --git a/crates/vpnshare-core/src/nat.rs b/crates/vpnshare-core/src/nat.rs new file mode 100644 index 0000000..ed357e0 --- /dev/null +++ b/crates/vpnshare-core/src/nat.rs @@ -0,0 +1,104 @@ +use crate::lease::PeerId; +use std::collections::HashMap; +use std::net::IpAddr; +use std::time::{Duration, Instant}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum TransportProtocol { + Tcp, + Udp, + Icmp, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct FlowKey { + pub protocol: TransportProtocol, + pub source: IpAddr, + pub source_port: u16, + pub destination: IpAddr, + pub destination_port: u16, +} + +#[derive(Debug, Clone)] +pub struct FlowEntry { + pub key: FlowKey, + pub peer_id: PeerId, + pub created_at: Instant, + pub last_seen_at: Instant, + pub bytes_from_peer: u64, + pub bytes_to_peer: u64, +} + +#[derive(Debug, Default)] +pub struct FlowTable { + entries: HashMap, +} + +impl FlowTable { + pub fn upsert(&mut self, key: FlowKey, peer_id: PeerId, now: Instant, bytes_from_peer: u64) { + self.entries + .entry(key.clone()) + .and_modify(|entry| { + entry.last_seen_at = now; + entry.bytes_from_peer = entry.bytes_from_peer.saturating_add(bytes_from_peer); + }) + .or_insert(FlowEntry { + key, + peer_id, + created_at: now, + last_seen_at: now, + bytes_from_peer, + bytes_to_peer: 0, + }); + } + + pub fn record_return_bytes(&mut self, key: &FlowKey, bytes_to_peer: u64) { + if let Some(entry) = self.entries.get_mut(key) { + entry.bytes_to_peer = entry.bytes_to_peer.saturating_add(bytes_to_peer); + } + } + + pub fn expire_idle(&mut self, now: Instant, idle_timeout: Duration) -> usize { + let before = self.entries.len(); + self.entries + .retain(|_, entry| now.duration_since(entry.last_seen_at) <= idle_timeout); + before - self.entries.len() + } + + pub fn clear(&mut self) { + self.entries.clear(); + } + + pub fn len(&self) -> usize { + self.entries.len() + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::{IpAddr, Ipv4Addr}; + + #[test] + fn expires_idle_flows() { + let now = Instant::now(); + let mut table = FlowTable::default(); + let key = FlowKey { + protocol: TransportProtocol::Tcp, + source: IpAddr::V4(Ipv4Addr::new(10, 241, 0, 2)), + source_port: 1234, + destination: IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)), + destination_port: 443, + }; + table.upsert(key, PeerId::from_u128(1), now, 10); + + assert_eq!(table.len(), 1); + let expired = table.expire_idle(now + Duration::from_secs(61), Duration::from_secs(60)); + assert_eq!(expired, 1); + assert!(table.is_empty()); + } +} diff --git a/crates/vpnshare-core/src/packet.rs b/crates/vpnshare-core/src/packet.rs new file mode 100644 index 0000000..c6a5d1f --- /dev/null +++ b/crates/vpnshare-core/src/packet.rs @@ -0,0 +1,166 @@ +use crate::nat::TransportProtocol; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum IpPacket<'a> { + V4(Ipv4Packet<'a>), + V6(Ipv6Packet<'a>), +} + +impl<'a> IpPacket<'a> { + pub fn parse(bytes: &'a [u8]) -> Result { + let first = *bytes.first().ok_or(PacketError::TooShort)?; + match first >> 4 { + 4 => Ok(Self::V4(Ipv4Packet::parse(bytes)?)), + 6 => Ok(Self::V6(Ipv6Packet::parse(bytes)?)), + version => Err(PacketError::UnsupportedVersion(version)), + } + } + + pub fn source(&self) -> IpAddr { + match self { + Self::V4(packet) => IpAddr::V4(packet.source), + Self::V6(packet) => IpAddr::V6(packet.source), + } + } + + pub fn destination(&self) -> IpAddr { + match self { + Self::V4(packet) => IpAddr::V4(packet.destination), + Self::V6(packet) => IpAddr::V6(packet.destination), + } + } + + pub fn transport_protocol(&self) -> Option { + match self { + Self::V4(packet) => packet.transport_protocol(), + Self::V6(packet) => packet.transport_protocol(), + } + } + + pub fn l4_ports(&self) -> Option<(u16, u16)> { + let payload = match self { + Self::V4(packet) => packet.payload, + Self::V6(packet) => packet.payload, + }; + if payload.len() < 4 { + return None; + } + Some(( + u16::from_be_bytes([payload[0], payload[1]]), + u16::from_be_bytes([payload[2], payload[3]]), + )) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Ipv4Packet<'a> { + pub source: Ipv4Addr, + pub destination: Ipv4Addr, + pub protocol: u8, + pub header_len: usize, + pub total_len: usize, + pub payload: &'a [u8], +} + +impl<'a> Ipv4Packet<'a> { + fn parse(bytes: &'a [u8]) -> Result { + if bytes.len() < 20 { + return Err(PacketError::TooShort); + } + let ihl = (bytes[0] & 0x0f) as usize * 4; + if ihl < 20 || bytes.len() < ihl { + return Err(PacketError::InvalidHeaderLength(ihl)); + } + let total_len = u16::from_be_bytes([bytes[2], bytes[3]]) as usize; + if total_len < ihl || total_len > bytes.len() { + return Err(PacketError::InvalidTotalLength(total_len)); + } + Ok(Self { + source: Ipv4Addr::new(bytes[12], bytes[13], bytes[14], bytes[15]), + destination: Ipv4Addr::new(bytes[16], bytes[17], bytes[18], bytes[19]), + protocol: bytes[9], + header_len: ihl, + total_len, + payload: &bytes[ihl..total_len], + }) + } + + fn transport_protocol(&self) -> Option { + protocol_number_to_transport(self.protocol) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Ipv6Packet<'a> { + pub source: Ipv6Addr, + pub destination: Ipv6Addr, + pub next_header: u8, + pub payload: &'a [u8], +} + +impl<'a> Ipv6Packet<'a> { + fn parse(bytes: &'a [u8]) -> Result { + if bytes.len() < 40 { + return Err(PacketError::TooShort); + } + let payload_len = u16::from_be_bytes([bytes[4], bytes[5]]) as usize; + let total_len = 40 + payload_len; + if total_len > bytes.len() { + return Err(PacketError::InvalidTotalLength(total_len)); + } + let source = Ipv6Addr::from(<[u8; 16]>::try_from(&bytes[8..24]).expect("slice length checked")); + let destination = Ipv6Addr::from(<[u8; 16]>::try_from(&bytes[24..40]).expect("slice length checked")); + Ok(Self { + source, + destination, + next_header: bytes[6], + payload: &bytes[40..total_len], + }) + } + + fn transport_protocol(&self) -> Option { + protocol_number_to_transport(self.next_header) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PacketError { + TooShort, + UnsupportedVersion(u8), + InvalidHeaderLength(usize), + InvalidTotalLength(usize), +} + +fn protocol_number_to_transport(protocol: u8) -> Option { + match protocol { + 1 | 58 => Some(TransportProtocol::Icmp), + 6 => Some(TransportProtocol::Tcp), + 17 => Some(TransportProtocol::Udp), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_ipv4_udp_packet() { + let packet = [ + 0x45, 0, 0, 28, 0, 0, 0, 0, 64, 17, 0, 0, 10, 241, 0, 2, 1, 1, 1, 1, 0x30, 0x39, 0, 53, 0, 8, 0, 0, + ]; + + let parsed = IpPacket::parse(&packet).unwrap(); + + assert_eq!(parsed.source(), IpAddr::V4(Ipv4Addr::new(10, 241, 0, 2))); + assert_eq!(parsed.destination(), IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1))); + assert_eq!(parsed.transport_protocol(), Some(TransportProtocol::Udp)); + assert_eq!(parsed.l4_ports(), Some((12345, 53))); + } + + #[test] + fn rejects_unknown_ip_version() { + assert_eq!(IpPacket::parse(&[0xf0]), Err(PacketError::UnsupportedVersion(15))); + } +} diff --git a/crates/vpnshare-ffi/Cargo.toml b/crates/vpnshare-ffi/Cargo.toml new file mode 100644 index 0000000..a83802e --- /dev/null +++ b/crates/vpnshare-ffi/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "vpnshare-ffi" +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +version.workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] +path = "src/lib.rs" + +[dependencies] +vpnshare-core = { path = "../vpnshare-core" } +vpnshare-proto = { path = "../vpnshare-proto" } diff --git a/crates/vpnshare-ffi/src/lib.rs b/crates/vpnshare-ffi/src/lib.rs new file mode 100644 index 0000000..e468357 --- /dev/null +++ b/crates/vpnshare-ffi/src/lib.rs @@ -0,0 +1,28 @@ +//! C ABI surface for Android and future desktop bindings. + +use std::os::raw::c_char; + +static VERSION: &[u8] = b"0.1.0\0"; + +#[no_mangle] +pub extern "C" fn vpnshare_core_version() -> *const c_char { + VERSION.as_ptr().cast() +} + +#[no_mangle] +pub extern "C" fn vpnshare_protocol_version() -> u8 { + vpnshare_proto::PROTOCOL_VERSION +} + +#[cfg(test)] +mod tests { + use super::*; + use std::ffi::CStr; + + #[test] + fn exposes_version() { + let raw = vpnshare_core_version(); + let version = unsafe { CStr::from_ptr(raw) }; + assert_eq!(version.to_str().unwrap(), "0.1.0"); + } +} diff --git a/crates/vpnshare-proto/Cargo.toml b/crates/vpnshare-proto/Cargo.toml new file mode 100644 index 0000000..dc2600d --- /dev/null +++ b/crates/vpnshare-proto/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "vpnshare-proto" +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +version.workspace = true + +[lib] +path = "src/lib.rs" diff --git a/crates/vpnshare-proto/src/lib.rs b/crates/vpnshare-proto/src/lib.rs new file mode 100644 index 0000000..09908de --- /dev/null +++ b/crates/vpnshare-proto/src/lib.rs @@ -0,0 +1,267 @@ +//! VSHP protocol primitives. +//! +//! This crate intentionally has no external dependencies. Cryptographic +//! handshake implementation will be linked behind a reviewed crypto boundary; +//! this module owns stable frame layout and parser behavior. + +use core::fmt; + +pub const MAGIC: [u8; 4] = *b"VSHP"; +pub const PROTOCOL_VERSION: u8 = 1; +pub const HEADER_LEN: usize = 24; +pub const MAX_PAYLOAD_LEN: usize = 1_048_576; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum FrameType { + Hello = 1, + Auth = 2, + Config = 3, + IpPacket = 4, + Ping = 5, + Resume = 6, + Stats = 7, + Close = 8, +} + +impl TryFrom for FrameType { + type Error = FrameError; + + fn try_from(value: u8) -> Result { + match value { + 1 => Ok(Self::Hello), + 2 => Ok(Self::Auth), + 3 => Ok(Self::Config), + 4 => Ok(Self::IpPacket), + 5 => Ok(Self::Ping), + 6 => Ok(Self::Resume), + 7 => Ok(Self::Stats), + 8 => Ok(Self::Close), + other => Err(FrameError::UnknownFrameType(other)), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FrameHeader { + pub version: u8, + pub frame_type: FrameType, + pub flags: u16, + pub stream_id: u32, + pub packet_number: u64, + pub payload_len: u32, +} + +impl FrameHeader { + pub fn new( + frame_type: FrameType, + stream_id: u32, + packet_number: u64, + payload_len: usize, + ) -> Result { + if payload_len > MAX_PAYLOAD_LEN { + return Err(FrameError::PayloadTooLarge(payload_len)); + } + Ok(Self { + version: PROTOCOL_VERSION, + frame_type, + flags: 0, + stream_id, + packet_number, + payload_len: payload_len as u32, + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DecodedFrame<'a> { + pub header: FrameHeader, + pub payload: &'a [u8], + pub consumed: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FrameError { + Incomplete, + BadMagic([u8; 4]), + UnsupportedVersion(u8), + UnknownFrameType(u8), + PayloadTooLarge(usize), + LengthMismatch { declared: usize, available: usize }, +} + +impl fmt::Display for FrameError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Incomplete => write!(f, "incomplete frame"), + Self::BadMagic(magic) => write!(f, "bad frame magic: {magic:?}"), + Self::UnsupportedVersion(version) => write!(f, "unsupported protocol version: {version}"), + Self::UnknownFrameType(frame_type) => write!(f, "unknown frame type: {frame_type}"), + Self::PayloadTooLarge(length) => write!(f, "payload too large: {length}"), + Self::LengthMismatch { declared, available } => { + write!(f, "payload length mismatch: declared {declared}, available {available}") + } + } + } +} + +impl std::error::Error for FrameError {} + +pub fn encode_frame( + frame_type: FrameType, + stream_id: u32, + packet_number: u64, + payload: &[u8], +) -> Result, FrameError> { + let header = FrameHeader::new(frame_type, stream_id, packet_number, payload.len())?; + let mut out = Vec::with_capacity(HEADER_LEN + payload.len()); + out.extend_from_slice(&MAGIC); + out.push(header.version); + out.push(header.frame_type as u8); + out.extend_from_slice(&header.flags.to_be_bytes()); + out.extend_from_slice(&header.stream_id.to_be_bytes()); + out.extend_from_slice(&header.packet_number.to_be_bytes()); + out.extend_from_slice(&header.payload_len.to_be_bytes()); + out.extend_from_slice(payload); + Ok(out) +} + +pub fn decode_frame(input: &[u8]) -> Result, FrameError> { + if input.len() < HEADER_LEN { + return Err(FrameError::Incomplete); + } + + let magic = [input[0], input[1], input[2], input[3]]; + if magic != MAGIC { + return Err(FrameError::BadMagic(magic)); + } + + let version = input[4]; + if version != PROTOCOL_VERSION { + return Err(FrameError::UnsupportedVersion(version)); + } + + let frame_type = FrameType::try_from(input[5])?; + let flags = u16::from_be_bytes([input[6], input[7]]); + let stream_id = u32::from_be_bytes([input[8], input[9], input[10], input[11]]); + let packet_number = u64::from_be_bytes([ + input[12], input[13], input[14], input[15], input[16], input[17], input[18], input[19], + ]); + let payload_len = u32::from_be_bytes([input[20], input[21], input[22], input[23]]) as usize; + + if payload_len > MAX_PAYLOAD_LEN { + return Err(FrameError::PayloadTooLarge(payload_len)); + } + + let available = input.len().saturating_sub(HEADER_LEN); + if available < payload_len { + return Err(FrameError::LengthMismatch { + declared: payload_len, + available, + }); + } + + let consumed = HEADER_LEN + payload_len; + Ok(DecodedFrame { + header: FrameHeader { + version, + frame_type, + flags, + stream_id, + packet_number, + payload_len: payload_len as u32, + }, + payload: &input[HEADER_LEN..consumed], + consumed, + }) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CloseCode { + Normal = 0, + UnsupportedVersion = 1, + AuthenticationFailed = 2, + PeerRevoked = 3, + VpnUnavailable = 4, + PolicyDenied = 5, + ProtocolError = 6, + Overload = 7, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PairingTicket { + pub session_id: String, + pub server_public_key: String, + pub one_time_psk: String, + pub transports: Vec, + pub expires_unix_seconds: u64, +} + +impl PairingTicket { + pub fn to_uri(&self) -> String { + format!( + "vshare://pair?v=1&sid={}&server_pk={}&psk={}&transports={}&expires={}", + self.session_id, + self.server_public_key, + self.one_time_psk, + self.transports.join(","), + self.expires_unix_seconds + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn frame_round_trips() { + let payload = b"hello"; + let encoded = encode_frame(FrameType::Hello, 42, 7, payload).unwrap(); + let decoded = decode_frame(&encoded).unwrap(); + + assert_eq!(decoded.header.frame_type, FrameType::Hello); + assert_eq!(decoded.header.stream_id, 42); + assert_eq!(decoded.header.packet_number, 7); + assert_eq!(decoded.payload, payload); + assert_eq!(decoded.consumed, HEADER_LEN + payload.len()); + } + + #[test] + fn rejects_bad_magic() { + let mut encoded = encode_frame(FrameType::Ping, 0, 1, b"").unwrap(); + encoded[0] = b'X'; + + assert!(matches!(decode_frame(&encoded), Err(FrameError::BadMagic(_)))); + } + + #[test] + fn rejects_incomplete_payload() { + let encoded = encode_frame(FrameType::Stats, 0, 1, b"abcdef").unwrap(); + let truncated = &encoded[..encoded.len() - 2]; + + assert!(matches!( + decode_frame(truncated), + Err(FrameError::LengthMismatch { + declared: 6, + available: 4 + }) + )); + } + + #[test] + fn encodes_pairing_uri() { + let ticket = PairingTicket { + session_id: "s".into(), + server_public_key: "k".into(), + one_time_psk: "p".into(), + transports: vec!["usb".into(), "wifi".into()], + expires_unix_seconds: 123, + }; + + assert_eq!( + ticket.to_uri(), + "vshare://pair?v=1&sid=s&server_pk=k&psk=p&transports=usb,wifi&expires=123" + ); + } +} diff --git a/crates/vpnshare-transport/Cargo.toml b/crates/vpnshare-transport/Cargo.toml new file mode 100644 index 0000000..34697f0 --- /dev/null +++ b/crates/vpnshare-transport/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "vpnshare-transport" +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +version.workspace = true + +[lib] +path = "src/lib.rs" + +[dependencies] +vpnshare-proto = { path = "../vpnshare-proto" } diff --git a/crates/vpnshare-transport/src/lib.rs b/crates/vpnshare-transport/src/lib.rs new file mode 100644 index 0000000..70efe25 --- /dev/null +++ b/crates/vpnshare-transport/src/lib.rs @@ -0,0 +1,55 @@ +//! Transport abstraction for USB, Wi-Fi, and hotspot-local VSHP sessions. + +use std::io; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TransportKind { + UsbAccessory, + WifiLan, + LocalOnlyHotspot, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TransportDescriptor { + pub kind: TransportKind, + pub label: String, + pub mtu_hint: Option, +} + +pub trait PacketTransport { + fn descriptor(&self) -> &TransportDescriptor; + fn send_frame(&mut self, bytes: &[u8]) -> io::Result<()>; + fn receive_frame(&mut self, buffer: &mut [u8]) -> io::Result; +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResumeToken { + pub session_id: [u8; 16], + pub transport_generation: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Backpressure { + pub queued_bytes: usize, + pub max_queue_bytes: usize, +} + +impl Backpressure { + pub fn should_pause_reads(&self) -> bool { + self.queued_bytes >= self.max_queue_bytes + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn backpressure_pauses_when_queue_is_full() { + assert!(Backpressure { + queued_bytes: 1024, + max_queue_bytes: 1024, + } + .should_pause_reads()); + } +} diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..ec35530 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,141 @@ +# VPN Share Architecture + +## Product Contract + +VPN Share shares an Android phone's active VPN with nearby devices through an +encrypted application tunnel. The receiving device runs a small companion client +that installs or opens its own virtual network interface and routes traffic to +the phone automatically. + +The phone app does not replace the user's VPN provider. It preserves the active +VPN app and forwards client traffic through Android's default network routing. + +## Non-Negotiable Platform Constraints + +- No root and Android 8+ means no direct manipulation of system NAT, tethering + routing tables, or hotspot iptables. +- Only one Android app can be prepared as the active `VpnService` owner at a + time. The gateway app therefore cannot run a phone-side `VpnService` while + WireGuard, OpenVPN, Clash, V2Ray, or another VPN is active. +- Android `LocalOnlyHotspot` is local-only and does not provide Internet. VPN + Share uses it only as a local transport for the encrypted tunnel. +- `VpnService.protect()` must not be used for forwarded gateway sockets because + protected sockets bypass VPN routing. + +## Component Diagram + +```mermaid +flowchart TB + subgraph AndroidPhone[Android phone] + UI[Compose app UI] + FG[Gateway foreground service] + DET[VpnDetector] + DISC[Discovery: QR, mDNS, USB] + FFI[Rust FFI adapter] + ENG[vpnshare-core] + NAT[Userspace TCP/UDP NAT] + DNS[DNS forwarder/cache] + MTU[MTU/MSS manager] + end + + subgraph Clients[Receiving devices] + DC[Desktop client] + AC[Android client VpnService] + IC[iOS Network Extension] + end + + DC -->|USB/Wi-Fi/hotspot VSHP| FG + AC -->|Wi-Fi/hotspot VSHP| FG + IC -->|Wi-Fi/hotspot VSHP| FG + UI --> FG + FG --> DET + FG --> DISC + FG --> FFI + FFI --> ENG + ENG --> NAT + ENG --> DNS + ENG --> MTU + NAT -->|Android sockets| VPN[Existing active VPN app] + DNS -->|Android resolver/network DNS| VPN + VPN --> Internet[Internet] +``` + +## Data Flow + +```mermaid +flowchart LR + App[Client application traffic] --> TUN[Client virtual NIC] + TUN --> Packetizer[Client VSHP packetizer] + Packetizer --> Crypto[Noise session encryption] + Crypto --> Transport[USB/Wi-Fi/hotspot transport] + Transport --> Gateway[Android gateway service] + Gateway --> Engine[Rust engine] + Engine --> NAT[NAT / DNS / flow control] + NAT --> Socket[Android TCP/UDP sockets] + Socket --> ActiveVpn[Existing active VPN] + ActiveVpn --> Internet +``` + +## Main Sequences + +### USB-First Pairing + +```mermaid +sequenceDiagram + participant User + participant Phone + participant Desktop + participant Engine + + User->>Phone: Tap Share + Phone->>Phone: Start foreground gateway + Phone->>Phone: Verify active VPN network + Desktop->>Phone: Open USB accessory channel + Desktop->>Phone: HELLO with client public key + Phone->>User: Show pairing code and device name + User->>Phone: Approve + Phone->>Engine: Create peer lease + Phone->>Desktop: AUTH + CONFIG + Desktop->>Desktop: Configure virtual NIC/routes/DNS + Desktop->>Phone: IP_PACKET frames + Phone->>Internet: Forward via active VPN +``` + +### Reconnection + +```mermaid +sequenceDiagram + Client->>Gateway: PING session_id, counters + Gateway--xClient: Transport interruption + Client->>Gateway: RESUME session_id, ticket + Gateway->>Gateway: Validate ticket and peer policy + Gateway->>Client: CONFIG delta + Client->>Gateway: Continue packet frames +``` + +## Android Domain Model + +- `GatewaySession`: active share session with transport set, peer leases, VPN + status, and byte counters. +- `PeerDevice`: trusted receiving device with public key, platform, label, + first seen, last seen, and revocation status. +- `PairingRequest`: short-lived untrusted request created by USB, QR, or mDNS. +- `TransportEndpoint`: USB accessory, LAN UDP/TCP, or local-only-hotspot path. +- `TunnelLease`: client virtual IPs, DNS gateway, MTU, route set, and expiry. + +## Runtime Policy + +- Gateway starts only from user action and remains foreground while sharing. +- Gateway shows a persistent notification with connected peer count and a stop + action. +- If no active VPN network is detected, the app can still start local pairing + but marks Internet sharing unavailable until the VPN appears. +- Forwarded traffic never leaves the device through a protected or underlying + non-VPN socket unless the user explicitly enables a future advanced bypass + mode. + +## Future Desktop Architecture + +The Rust protocol/core crates are shared by Android gateway and desktop clients. +Desktop-specific modules only provide virtual NIC, installer, service manager, +tray UI, and OS network configuration. diff --git a/docs/desktop-migration.md b/docs/desktop-migration.md new file mode 100644 index 0000000..94e9ebb --- /dev/null +++ b/docs/desktop-migration.md @@ -0,0 +1,41 @@ +# Future Desktop Client Migration Plan + +## Shared Core + +The Rust crates are already structured so desktop clients can reuse: + +- VSHP frame parsing and encoding. +- Peer/session models. +- NAT, MTU, DNS, and flow-control logic where applicable. +- Transport abstractions. + +## Desktop-Specific Layers + +Windows: + +- Wintun adapter. +- Windows service. +- MSI/MSIX installer. +- Credential Manager key storage. + +Linux: + +- TUN device. +- systemd user/system service. +- NetworkManager integration where available. +- Secret Service key storage. + +macOS: + +- utun interface. +- launchd service. +- Keychain key storage. +- Notarized app bundle. + +## Product Migration Steps + +1. Keep `clients/desktop` as the CLI bring-up tool. +2. Add OS-specific virtual NIC crates under `clients/desktop/src/platform`. +3. Add installers once USB IPv4 forwarding is stable. +4. Add tray UI after service lifecycle is reliable. +5. Split GUI from service so crashes do not drop the tunnel. diff --git a/docs/protocol.md b/docs/protocol.md new file mode 100644 index 0000000..7432d5b --- /dev/null +++ b/docs/protocol.md @@ -0,0 +1,113 @@ +# VSHP Protocol Specification + +VSHP is the VPN Share packet tunnel protocol. It carries IP packets from a +paired client virtual interface to an Android gateway over USB, Wi-Fi, or +hotspot-local transport. + +## Goals + +- Encrypted by default. +- Transport independent. +- Resumable after USB or Wi-Fi interruption. +- Small enough for battery-conscious mobile operation. +- Stable enough for third-party open-source clients. + +## Version + +Current version: `VSHP/1`. + +## Frame Header + +All multibyte integers are network byte order. + +```text +0 1 2 3 +0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| magic "VSHP" | ++---------------+---------------+-------------------------------+ +| version = 1 | frame_type | flags | ++---------------+---------------+-------------------------------+ +| stream_id | ++---------------------------------------------------------------+ +| packet_number | +| | ++---------------------------------------------------------------+ +| payload_length | ++---------------------------------------------------------------+ +| payload... | ++---------------------------------------------------------------+ +``` + +Header length: 24 bytes. + +## Frame Types + +- `HELLO = 1`: protocol version, platform, client public key, capabilities. +- `AUTH = 2`: Noise handshake payload or resume ticket. +- `CONFIG = 3`: virtual IP lease, DNS, MTU, routes, keepalive interval. +- `IP_PACKET = 4`: encrypted IPv4/IPv6 packet from or to the client. +- `PING = 5`: keepalive and counters. +- `RESUME = 6`: session resumption request. +- `STATS = 7`: byte, packet, loss, RTT, and battery hints. +- `CLOSE = 8`: close code and human-readable reason. + +## Security Handshake + +- First pairing: Noise `XXpsk2`, X25519, ChaCha20-Poly1305, BLAKE2s/HKDF. +- Known peer: Noise `IKpsk2` with stored peer static key. +- QR/USB pairing PSK is one-time and expires after 2 minutes. +- Every encrypted frame binds protocol version, peer id, transport id, and + transcript hash. +- Replay window: 256 packet numbers per direction. +- Rekey: after 1 GiB or 10 minutes, whichever comes first. + +## Pairing URI + +```text +vshare://pair?v=1&sid=&server_pk=&psk=&transports=&expires= +``` + +Rules: + +- `psk` is 128 bits minimum. +- `sid` is random and not reused. +- `expires` is enforced by the phone and client. +- QR pairing must be confirmed on the phone before a peer is trusted. + +## Client Configuration Payload + +```json +{ + "lease_id": "base64url", + "peer_ipv4": "10.241.0.2/32", + "peer_ipv6": "fd7a:7670:7368::2/128", + "gateway_dns": "10.241.0.1", + "routes": ["0.0.0.0/0", "::/0"], + "mtu": 1280, + "keepalive_ms": 15000, + "idle_timeout_ms": 120000 +} +``` + +IPv6 routes are omitted unless the gateway verifies upstream IPv6 through the +active VPN. + +## Close Codes + +- `0`: normal close. +- `1`: unsupported version. +- `2`: authentication failed. +- `3`: peer revoked. +- `4`: VPN unavailable. +- `5`: policy denied. +- `6`: protocol error. +- `7`: overload/backpressure. + +## MTU Policy + +- Default MTU is `1280`. +- USB may probe and raise the link MTU. +- TCP MSS is clamped to `mtu - 40` for IPv4 and `mtu - 60` for IPv6. +- Oversized packets are dropped with client-visible Packet Too Big signaling + where possible. diff --git a/docs/repository-structure.md b/docs/repository-structure.md new file mode 100644 index 0000000..5af6080 --- /dev/null +++ b/docs/repository-structure.md @@ -0,0 +1,49 @@ +# Repository and Package Structure + +## Android Project + +```text +apps/android/app +apps/android/core/domain +apps/android/core/engine +apps/android/feature/share +apps/android/service/gateway +``` + +## Kotlin Packages + +```text +org.vpnshare.app +org.vpnshare.domain.model +org.vpnshare.engine +org.vpnshare.feature.share +org.vpnshare.gateway +org.vpnshare.gateway.discovery +org.vpnshare.gateway.transport +``` + +Guidelines: + +- Domain module contains no Android platform APIs. +- Engine module defines Kotlin-facing core interfaces and FFI adapters. +- Gateway service module owns Android network, USB, NSD, hotspot, and + foreground-service integration. +- Feature modules own Compose UI only. + +## Rust Workspace + +```text +crates/vpnshare-proto +crates/vpnshare-core +crates/vpnshare-transport +crates/vpnshare-ffi +clients/desktop +``` + +Guidelines: + +- `vpnshare-proto` owns stable wire format. +- `vpnshare-core` owns packet tunnel domain logic. +- `vpnshare-transport` owns transport-independent I/O traits. +- `vpnshare-ffi` is the narrow C ABI/JNI bridge. +- Platform clients bind the Rust core to virtual NIC and OS lifecycle APIs. diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..3c57e2f --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,50 @@ +# MVP Roadmap + +## M0: Foundation + +- Repository metadata, license, contribution docs. +- Android Gradle project and Rust workspace. +- Domain model, engine interface, foreground service skeleton. +- VSHP frame parser with unit tests. + +## M1: USB-First MVP + +- Android USB accessory transport. +- Desktop USB host transport for Linux and Windows. +- Encrypted pairing over USB with phone-side approval. +- IPv4 TUN/Wintun virtual interface on desktop. +- TCP, UDP, and DNS forwarding through the Android gateway. +- Basic reconnection with session resume ticket. + +Acceptance: + +- A Windows or Linux laptop paired by USB can browse through the phone's active + VPN without manual proxy, route, DNS, or SSH setup. + +## M2: Desktop Productization + +- macOS utun support. +- Installers and background service management. +- Tray UI with connect/disconnect and status. +- Peer revoke and rename UI on Android. +- Crash-safe logs without traffic content. + +## M3: Wi-Fi and Hotspot + +- mDNS/DNS-SD discovery. +- QR pairing for Wi-Fi. +- LocalOnlyHotspot setup flow. +- Automatic transport fallback between USB and Wi-Fi. + +## M4: Android Client + +- Android receiving-device app using its own `VpnService`. +- QR pairing and one-tap connect. +- Per-app include/exclude on receiving Android device. + +## M5: IPv6, Performance, and iOS + +- IPv6 upstream validation and route enablement. +- MTU probing, MSS clamping, and adaptive buffers. +- iOS Network Extension client. +- Battery and throughput tuning on physical-device matrix. diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..6837a47 --- /dev/null +++ b/docs/security.md @@ -0,0 +1,70 @@ +# Security Design and Threat Model + +## Security Objectives + +- Only approved devices can use the gateway. +- Local attackers cannot read or alter tunneled traffic. +- A stolen QR code expires quickly and cannot silently enroll a device. +- The gateway does not log browsing history, DNS names, or packet payloads. +- A malicious peer cannot access the phone LAN or other peers by default. + +## Trust Boundaries + +- Trusted: phone owner approval, installed VPN app, VPN Share signed binaries. +- Semi-trusted: paired devices after explicit approval. +- Untrusted: local Wi-Fi, hotspot participants, USB host before approval, + mDNS announcements, QR observers. + +## Threats and Mitigations + +| Threat | Mitigation | +| --- | --- | +| Local MITM on Wi-Fi | Noise authenticated encryption, transcript binding, peer keys | +| Stolen QR | One-time PSK, 2-minute expiry, phone-side confirmation | +| Malicious paired device | Per-peer revocation, no inbound LAN access, rate limits | +| Replay | Monotonic packet numbers and replay windows | +| DNS leak | Client DNS points to VPN Share gateway; gateway forwards through Android default VPN network | +| VPN bypass | Gateway does not protect forwarded sockets; UI warns if no active VPN is detected | +| Battery drain | Foreground service only while active, batched stats, adaptive keepalive | +| DoS by paired client | Per-peer queue limits, flow caps, overload close code | + +## Data Collection Policy + +Default telemetry is local only: + +- Connected peer labels. +- Bytes in/out. +- Session duration. +- Error counters. + +VPN Share must not collect: + +- Packet payloads. +- Browsing history. +- DNS query names. +- Destination IP history outside short-lived in-memory flow tables. + +## Key Storage + +- Android gateway identity key is generated on first run and stored in Android + Keystore when available. +- Peer public keys and labels are stored in app-private encrypted storage. +- Desktop clients store their device key in the OS credential store where + available, with file-permission fallback. + +## Play Store Compliance + +The gateway mode itself does not use `VpnService`. If Android client mode is +shipped in the same package, the release must: + +- Declare the `VpnService` use in the store listing. +- Show prominent in-app disclosure before Android client VPN permission. +- Explain that traffic is encrypted to the paired VPN Share gateway. +- Avoid traffic redirection for ads or monetization. +- Submit the Play Console `VpnService` declaration. + +## Foreground Service Compliance + +Active sharing uses a foreground service with `connectedDevice` because it +maintains live communication with external devices. The service must expose a +persistent notification and stop action. diff --git a/docs/testing-ci-play.md b/docs/testing-ci-play.md new file mode 100644 index 0000000..a6ea699 --- /dev/null +++ b/docs/testing-ci-play.md @@ -0,0 +1,46 @@ +# Testing, CI, and Release Compliance + +## Testing Strategy + +- Rust unit tests: frame parsing, NAT expiry, DNS policy, MTU calculations, + replay windows, lease allocation. +- Rust fuzzing: malformed VSHP frames and packet parser inputs. +- Kotlin unit tests: domain state reducers, pairing expiry, VPN status mapping. +- Android instrumented tests: foreground service lifecycle, notification stop + action, mDNS registration, LocalOnlyHotspot failure paths. +- Hardware integration tests: USB accessory sessions on physical Android + devices and Windows/Linux/macOS hosts. +- E2E matrix: WireGuard, OpenVPN, Clash, V2Ray; IPv4-only, IPv6-capable, + split-tunnel VPNs, VPN absent. + +## CI Design + +Required jobs: + +- `rust`: `cargo fmt --check`, `cargo clippy --workspace -- -D warnings`, + `cargo test --workspace`. +- `android`: Gradle unit tests, lint, assemble debug. +- `security`: dependency audit, CodeQL, license scan, SBOM generation. +- `docs`: markdown lint and Mermaid render smoke test. + +Physical-device CI is required before release for USB, LocalOnlyHotspot, battery, +and vendor VPN compatibility. + +## Play Store Review Checklist + +- Store listing explains VPN Share is a network sharing tool. +- If Android client VPN mode is bundled, `VpnService` declaration is submitted. +- In-app disclosure is separate from privacy policy and requires affirmative + action. +- No personal/sensitive traffic collection. +- No ad traffic manipulation. +- Foreground-service type declaration matches actual use. +- Target SDK follows current Play requirements at release time. + +## Release Gates + +- No known traffic leaks in VPN-active E2E tests. +- No crash loops on VPN revocation, USB disconnect, Wi-Fi change, or sleep. +- Packet parser fuzz corpus runs clean. +- Reproducible release build and signed SBOM. +- Third-party security review before public 1.0. diff --git a/docs/transports.md b/docs/transports.md new file mode 100644 index 0000000..02d8e96 --- /dev/null +++ b/docs/transports.md @@ -0,0 +1,49 @@ +# Transport Designs + +## USB Transport + +USB is the first MVP transport. + +### Phone Side + +- Android uses accessory-mode APIs through `UsbManager.openAccessory`. +- The gateway treats USB as an ordered byte stream carrying VSHP frames. +- The USB session is accepted only after phone-side pairing approval. +- If USB disconnects, the peer lease remains resumable for a short window. + +### Desktop Side + +- The desktop client acts as USB host and opens the Android Open Accessory + channel. +- The client advertises platform, app version, and public key in `HELLO`. +- After `CONFIG`, the client configures the local virtual interface and routes. + +### Failure Modes + +- Cable unplug: transport closes, session enters resumable state. +- Host sleeps: session ticket can resume on reconnect. +- Unknown host: no IP traffic is accepted before pairing approval. + +## Wi-Fi Transport + +- Discovery uses DNS-SD service type `_vpnshare._udp`. +- QR pairing remains the reliable fallback when multicast is blocked. +- The tunnel uses the same VSHP frame format as USB. +- The gateway never assumes LAN is trusted. + +## Hotspot Transport + +- Android app-created hotspot uses `LocalOnlyHotspot`. +- The local-only hotspot does not provide Internet; it only provides local reach + to the gateway app. +- The client still installs its virtual interface and uses VSHP for all traffic. + +## Transport Selection + +Default order: + +1. USB if connected and paired. +2. Known Wi-Fi peer on same LAN. +3. Local-only hotspot if the user starts it from the app. + +Session resume can switch transports if the peer key and resume ticket validate. diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..7f9a208 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,6 @@ +android.nonTransitiveRClass=true +android.useAndroidX=true +kotlin.code.style=official +org.gradle.caching=true +org.gradle.configuration-cache=true +org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..56f52bd --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,26 @@ +[versions] +activity = "1.13.0" +agp = "9.2.0" +composeBom = "2026.05.01" +core = "1.17.0" +coroutines = "1.11.0" +kotlin = "2.3.21" + +[libraries] +androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity" } +androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "core" } +androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" } +androidx-compose-foundation = { module = "androidx.compose.foundation:foundation" } +androidx-compose-material3 = { module = "androidx.compose.material3:material3" } +androidx-compose-runtime = { module = "androidx.compose.runtime:runtime" } +androidx-compose-ui = { module = "androidx.compose.ui:ui" } +androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } +androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } +kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +android-library = { id = "com.android.library", version.ref = "agp" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..3fa6714 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,3 @@ +edition = "2021" +max_width = 120 +newline_style = "Unix" diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..cf1cc18 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,23 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "vpn-share" + +include(":apps:android:app") +include(":apps:android:core:domain") +include(":apps:android:core:engine") +include(":apps:android:feature:share") +include(":apps:android:service:gateway")