From 4ffbc3bffe39cf6598fead8234ca24b83726861d Mon Sep 17 00:00:00 2001 From: Meghdad Fadaee Date: Sun, 31 May 2026 15:36:07 +0330 Subject: [PATCH] init --- .github/workflows/ci.yml | 40 +++ .gitignore | 13 + CONTRIBUTING.md | 31 ++ Cargo.toml | 16 ++ LICENSE | 153 ++++++++++ NOTICE | 4 + README.md | 67 +++++ SECURITY.md | 30 ++ apps/android/app/build.gradle.kts | 36 +++ apps/android/app/src/main/AndroidManifest.xml | 38 +++ .../kotlin/org/vpnshare/app/MainActivity.kt | 31 ++ .../org/vpnshare/app/VpnShareApplication.kt | 5 + .../app/src/main/res/drawable/ic_launcher.xml | 15 + .../app/src/main/res/values/strings.xml | 6 + .../app/src/main/res/values/styles.xml | 7 + apps/android/core/domain/build.gradle.kts | 13 + .../core/domain/src/main/AndroidManifest.xml | 1 + .../org/vpnshare/domain/model/Models.kt | 83 ++++++ apps/android/core/engine/build.gradle.kts | 19 ++ .../core/engine/src/main/AndroidManifest.xml | 1 + .../org/vpnshare/engine/GatewayEvent.kt | 13 + .../org/vpnshare/engine/RustVpnShareEngine.kt | 67 +++++ .../org/vpnshare/engine/VpnShareEngine.kt | 18 ++ apps/android/feature/share/build.gradle.kts | 28 ++ .../share/src/main/AndroidManifest.xml | 1 + .../org/vpnshare/feature/share/ShareScreen.kt | 100 +++++++ apps/android/service/gateway/build.gradle.kts | 20 ++ .../gateway/src/main/AndroidManifest.xml | 1 + .../org/vpnshare/gateway/VpnDetector.kt | 38 +++ .../gateway/VpnShareGatewayService.kt | 109 +++++++ .../discovery/NsdDiscoveryPublisher.kt | 40 +++ .../transport/LocalOnlyHotspotController.kt | 54 ++++ .../transport/UsbAccessoryTransport.kt | 30 ++ build.gradle.kts | 6 + clients/desktop/Cargo.toml | 16 ++ clients/desktop/src/main.rs | 14 + crates/vpnshare-core/Cargo.toml | 13 + crates/vpnshare-core/src/dns.rs | 42 +++ crates/vpnshare-core/src/lease.rs | 99 +++++++ crates/vpnshare-core/src/lib.rs | 117 ++++++++ crates/vpnshare-core/src/mtu.rs | 50 ++++ crates/vpnshare-core/src/nat.rs | 104 +++++++ crates/vpnshare-core/src/packet.rs | 166 +++++++++++ crates/vpnshare-ffi/Cargo.toml | 15 + crates/vpnshare-ffi/src/lib.rs | 28 ++ crates/vpnshare-proto/Cargo.toml | 10 + crates/vpnshare-proto/src/lib.rs | 267 ++++++++++++++++++ crates/vpnshare-transport/Cargo.toml | 13 + crates/vpnshare-transport/src/lib.rs | 55 ++++ docs/architecture.md | 141 +++++++++ docs/desktop-migration.md | 41 +++ docs/protocol.md | 113 ++++++++ docs/repository-structure.md | 49 ++++ docs/roadmap.md | 50 ++++ docs/security.md | 70 +++++ docs/testing-ci-play.md | 46 +++ docs/transports.md | 49 ++++ gradle.properties | 6 + gradle/libs.versions.toml | 26 ++ rustfmt.toml | 3 + settings.gradle.kts | 23 ++ 61 files changed, 2760 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 CONTRIBUTING.md create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 NOTICE create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 apps/android/app/build.gradle.kts create mode 100644 apps/android/app/src/main/AndroidManifest.xml create mode 100644 apps/android/app/src/main/kotlin/org/vpnshare/app/MainActivity.kt create mode 100644 apps/android/app/src/main/kotlin/org/vpnshare/app/VpnShareApplication.kt create mode 100644 apps/android/app/src/main/res/drawable/ic_launcher.xml create mode 100644 apps/android/app/src/main/res/values/strings.xml create mode 100644 apps/android/app/src/main/res/values/styles.xml create mode 100644 apps/android/core/domain/build.gradle.kts create mode 100644 apps/android/core/domain/src/main/AndroidManifest.xml create mode 100644 apps/android/core/domain/src/main/kotlin/org/vpnshare/domain/model/Models.kt create mode 100644 apps/android/core/engine/build.gradle.kts create mode 100644 apps/android/core/engine/src/main/AndroidManifest.xml create mode 100644 apps/android/core/engine/src/main/kotlin/org/vpnshare/engine/GatewayEvent.kt create mode 100644 apps/android/core/engine/src/main/kotlin/org/vpnshare/engine/RustVpnShareEngine.kt create mode 100644 apps/android/core/engine/src/main/kotlin/org/vpnshare/engine/VpnShareEngine.kt create mode 100644 apps/android/feature/share/build.gradle.kts create mode 100644 apps/android/feature/share/src/main/AndroidManifest.xml create mode 100644 apps/android/feature/share/src/main/kotlin/org/vpnshare/feature/share/ShareScreen.kt create mode 100644 apps/android/service/gateway/build.gradle.kts create mode 100644 apps/android/service/gateway/src/main/AndroidManifest.xml create mode 100644 apps/android/service/gateway/src/main/kotlin/org/vpnshare/gateway/VpnDetector.kt create mode 100644 apps/android/service/gateway/src/main/kotlin/org/vpnshare/gateway/VpnShareGatewayService.kt create mode 100644 apps/android/service/gateway/src/main/kotlin/org/vpnshare/gateway/discovery/NsdDiscoveryPublisher.kt create mode 100644 apps/android/service/gateway/src/main/kotlin/org/vpnshare/gateway/transport/LocalOnlyHotspotController.kt create mode 100644 apps/android/service/gateway/src/main/kotlin/org/vpnshare/gateway/transport/UsbAccessoryTransport.kt create mode 100644 build.gradle.kts create mode 100644 clients/desktop/Cargo.toml create mode 100644 clients/desktop/src/main.rs create mode 100644 crates/vpnshare-core/Cargo.toml create mode 100644 crates/vpnshare-core/src/dns.rs create mode 100644 crates/vpnshare-core/src/lease.rs create mode 100644 crates/vpnshare-core/src/lib.rs create mode 100644 crates/vpnshare-core/src/mtu.rs create mode 100644 crates/vpnshare-core/src/nat.rs create mode 100644 crates/vpnshare-core/src/packet.rs create mode 100644 crates/vpnshare-ffi/Cargo.toml create mode 100644 crates/vpnshare-ffi/src/lib.rs create mode 100644 crates/vpnshare-proto/Cargo.toml create mode 100644 crates/vpnshare-proto/src/lib.rs create mode 100644 crates/vpnshare-transport/Cargo.toml create mode 100644 crates/vpnshare-transport/src/lib.rs create mode 100644 docs/architecture.md create mode 100644 docs/desktop-migration.md create mode 100644 docs/protocol.md create mode 100644 docs/repository-structure.md create mode 100644 docs/roadmap.md create mode 100644 docs/security.md create mode 100644 docs/testing-ci-play.md create mode 100644 docs/transports.md create mode 100644 gradle.properties create mode 100644 gradle/libs.versions.toml create mode 100644 rustfmt.toml create mode 100644 settings.gradle.kts 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")