init
Some checks failed
CI / Rust (push) Successful in 20s
CI / Android (push) Failing after 8m35s

This commit is contained in:
2026-05-31 15:36:07 +03:30
commit 4ffbc3bffe
61 changed files with 2760 additions and 0 deletions

40
.github/workflows/ci.yml vendored Normal file
View File

@@ -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

13
.gitignore vendored Normal file
View File

@@ -0,0 +1,13 @@
.gradle/
build/
**/build/
.idea/
*.iml
local.properties
.DS_Store
target/
Cargo.lock
captures/
*.apk
*.aab
*.keystore

31
CONTRIBUTING.md Normal file
View File

@@ -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.

16
Cargo.toml Normal file
View File

@@ -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"

153
LICENSE Normal file
View File

@@ -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.

4
NOTICE Normal file
View File

@@ -0,0 +1,4 @@
VPN Share
Copyright 2026 VPN Share contributors
Licensed under the Apache License, Version 2.0.

67
README.md Normal file
View File

@@ -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).

30
SECURITY.md Normal file
View File

@@ -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.

View File

@@ -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)
}

View File

@@ -0,0 +1,38 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES"
android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-feature android:name="android.hardware.usb.accessory" android:required="false" />
<uses-feature android:name="android.hardware.wifi" android:required="false" />
<application
android:name=".VpnShareApplication"
android:allowBackup="false"
android:icon="@drawable/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.VpnShare">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name="org.vpnshare.gateway.VpnShareGatewayService"
android:exported="false"
android:foregroundServiceType="connectedDevice" />
</application>
</manifest>

View File

@@ -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)
)
}
)
}
}
}

View File

@@ -0,0 +1,5 @@
package org.vpnshare.app
import android.app.Application
class VpnShareApplication : Application()

View File

@@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="48"
android:viewportHeight="48">
<path
android:fillColor="#0F172A"
android:pathData="M24,4L40,10V22C40,32 33.4,40.7 24,44C14.6,40.7 8,32 8,22V10L24,4Z" />
<path
android:fillColor="#38BDF8"
android:pathData="M16,23C16,18.6 19.6,15 24,15C28.4,15 32,18.6 32,23C32,27.4 28.4,31 24,31C19.6,31 16,27.4 16,23Z" />
<path
android:fillColor="#F8FAFC"
android:pathData="M22,20H26V34H22V20Z" />
</vector>

View File

@@ -0,0 +1,6 @@
<resources>
<string name="app_name">VPN Share</string>
<string name="gateway_channel_name">VPN Share sessions</string>
<string name="gateway_notification_title">VPN Share is active</string>
<string name="gateway_notification_text">Sharing through the phone VPN</string>
</resources>

View File

@@ -0,0 +1,7 @@
<resources>
<style name="Theme.VpnShare" parent="android:style/Theme.Material.Light.NoActionBar">
<item name="android:windowLightStatusBar">true</item>
<item name="android:navigationBarColor">#F8FAFC</item>
<item name="android:statusBarColor">#F8FAFC</item>
</style>
</resources>

View File

@@ -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
}
}

View File

@@ -0,0 +1 @@
<manifest />

View File

@@ -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<String>,
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
}

View File

@@ -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)
}

View File

@@ -0,0 +1 @@
<manifest />

View File

@@ -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
}

View File

@@ -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<GatewayEvent>(extraBufferCapacity = 64)
override val events: Flow<GatewayEvent> = 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
}

View File

@@ -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<GatewayEvent>
suspend fun startGateway(config: GatewayConfig)
suspend fun stopGateway()
suspend fun approvePairing(request: PairingRequest): PeerId
suspend fun rejectPairing(request: PairingRequest)
}

View File

@@ -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)
}

View File

@@ -0,0 +1 @@
<manifest />

View File

@@ -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")
}
}
}
}
}
}

View File

@@ -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)
}

View File

@@ -0,0 +1 @@
<manifest />

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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."
}
}

View File

@@ -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)
}
}

View File

@@ -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()
}
}
}

6
build.gradle.kts Normal file
View File

@@ -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
}

View File

@@ -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" }

View File

@@ -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());
}

View File

@@ -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" }

View File

@@ -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));
}
}

View File

@@ -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<Ipv6Addr>,
pub dns_gateway: Ipv4Addr,
pub mtu: u16,
pub expires_at: Instant,
}
#[derive(Debug)]
pub struct LeaseAllocator {
next_host: u8,
default_mtu: u16,
leases: HashMap<PeerId, Lease>,
}
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<Lease, LeaseError> {
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<Lease> {
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);
}
}

View File

@@ -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<String>,
pub supports_ipv4: bool,
pub supports_ipv6: bool,
}
#[derive(Debug)]
pub struct GatewayEngine {
state: GatewayState,
config: GatewayConfig,
leases: LeaseAllocator,
flows: FlowTable,
started_at: Option<Instant>,
}
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);
}
}

View File

@@ -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);
}
}

View File

@@ -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<FlowKey, FlowEntry>,
}
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());
}
}

View File

@@ -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<Self, PacketError> {
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<TransportProtocol> {
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<Self, PacketError> {
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<TransportProtocol> {
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<Self, PacketError> {
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<TransportProtocol> {
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<TransportProtocol> {
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)));
}
}

View File

@@ -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" }

View File

@@ -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");
}
}

View File

@@ -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"

View File

@@ -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<u8> for FrameType {
type Error = FrameError;
fn try_from(value: u8) -> Result<Self, Self::Error> {
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<Self, FrameError> {
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<Vec<u8>, 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<DecodedFrame<'_>, 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<String>,
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"
);
}
}

View File

@@ -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" }

View File

@@ -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<u16>,
}
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<usize>;
}
#[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());
}
}

141
docs/architecture.md Normal file
View File

@@ -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.

41
docs/desktop-migration.md Normal file
View File

@@ -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.

113
docs/protocol.md Normal file
View File

@@ -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=<session>&server_pk=<base64url>&psk=<base64url>&transports=<csv>&expires=<unix>
```
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.

View File

@@ -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.

50
docs/roadmap.md Normal file
View File

@@ -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.

70
docs/security.md Normal file
View File

@@ -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.

46
docs/testing-ci-play.md Normal file
View File

@@ -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.

49
docs/transports.md Normal file
View File

@@ -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.

6
gradle.properties Normal file
View File

@@ -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

26
gradle/libs.versions.toml Normal file
View File

@@ -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" }

3
rustfmt.toml Normal file
View File

@@ -0,0 +1,3 @@
edition = "2021"
max_width = 120
newline_style = "Unix"

23
settings.gradle.kts Normal file
View File

@@ -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")