init
This commit is contained in:
40
.github/workflows/ci.yml
vendored
Normal file
40
.github/workflows/ci.yml
vendored
Normal 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
13
.gitignore
vendored
Normal 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
31
CONTRIBUTING.md
Normal 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
16
Cargo.toml
Normal 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
153
LICENSE
Normal 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
4
NOTICE
Normal 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
67
README.md
Normal 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
30
SECURITY.md
Normal 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.
|
||||||
36
apps/android/app/build.gradle.kts
Normal file
36
apps/android/app/build.gradle.kts
Normal 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)
|
||||||
|
}
|
||||||
38
apps/android/app/src/main/AndroidManifest.xml
Normal file
38
apps/android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||||
@@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package org.vpnshare.app
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
|
||||||
|
class VpnShareApplication : Application()
|
||||||
15
apps/android/app/src/main/res/drawable/ic_launcher.xml
Normal file
15
apps/android/app/src/main/res/drawable/ic_launcher.xml
Normal 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>
|
||||||
6
apps/android/app/src/main/res/values/strings.xml
Normal file
6
apps/android/app/src/main/res/values/strings.xml
Normal 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>
|
||||||
7
apps/android/app/src/main/res/values/styles.xml
Normal file
7
apps/android/app/src/main/res/values/styles.xml
Normal 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>
|
||||||
13
apps/android/core/domain/build.gradle.kts
Normal file
13
apps/android/core/domain/build.gradle.kts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
1
apps/android/core/domain/src/main/AndroidManifest.xml
Normal file
1
apps/android/core/domain/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<manifest />
|
||||||
@@ -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
|
||||||
|
}
|
||||||
19
apps/android/core/engine/build.gradle.kts
Normal file
19
apps/android/core/engine/build.gradle.kts
Normal 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)
|
||||||
|
}
|
||||||
1
apps/android/core/engine/src/main/AndroidManifest.xml
Normal file
1
apps/android/core/engine/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<manifest />
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
28
apps/android/feature/share/build.gradle.kts
Normal file
28
apps/android/feature/share/build.gradle.kts
Normal 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)
|
||||||
|
}
|
||||||
1
apps/android/feature/share/src/main/AndroidManifest.xml
Normal file
1
apps/android/feature/share/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<manifest />
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
apps/android/service/gateway/build.gradle.kts
Normal file
20
apps/android/service/gateway/build.gradle.kts
Normal 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)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<manifest />
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
6
build.gradle.kts
Normal 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
|
||||||
|
}
|
||||||
16
clients/desktop/Cargo.toml
Normal file
16
clients/desktop/Cargo.toml
Normal 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" }
|
||||||
14
clients/desktop/src/main.rs
Normal file
14
clients/desktop/src/main.rs
Normal 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());
|
||||||
|
}
|
||||||
13
crates/vpnshare-core/Cargo.toml
Normal file
13
crates/vpnshare-core/Cargo.toml
Normal 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" }
|
||||||
42
crates/vpnshare-core/src/dns.rs
Normal file
42
crates/vpnshare-core/src/dns.rs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
99
crates/vpnshare-core/src/lease.rs
Normal file
99
crates/vpnshare-core/src/lease.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
117
crates/vpnshare-core/src/lib.rs
Normal file
117
crates/vpnshare-core/src/lib.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
50
crates/vpnshare-core/src/mtu.rs
Normal file
50
crates/vpnshare-core/src/mtu.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
104
crates/vpnshare-core/src/nat.rs
Normal file
104
crates/vpnshare-core/src/nat.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
166
crates/vpnshare-core/src/packet.rs
Normal file
166
crates/vpnshare-core/src/packet.rs
Normal 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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
15
crates/vpnshare-ffi/Cargo.toml
Normal file
15
crates/vpnshare-ffi/Cargo.toml
Normal 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" }
|
||||||
28
crates/vpnshare-ffi/src/lib.rs
Normal file
28
crates/vpnshare-ffi/src/lib.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
10
crates/vpnshare-proto/Cargo.toml
Normal file
10
crates/vpnshare-proto/Cargo.toml
Normal 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"
|
||||||
267
crates/vpnshare-proto/src/lib.rs
Normal file
267
crates/vpnshare-proto/src/lib.rs
Normal 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
crates/vpnshare-transport/Cargo.toml
Normal file
13
crates/vpnshare-transport/Cargo.toml
Normal 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" }
|
||||||
55
crates/vpnshare-transport/src/lib.rs
Normal file
55
crates/vpnshare-transport/src/lib.rs
Normal 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
141
docs/architecture.md
Normal 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
41
docs/desktop-migration.md
Normal 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
113
docs/protocol.md
Normal 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.
|
||||||
49
docs/repository-structure.md
Normal file
49
docs/repository-structure.md
Normal 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
50
docs/roadmap.md
Normal 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
70
docs/security.md
Normal 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
46
docs/testing-ci-play.md
Normal 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
49
docs/transports.md
Normal 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
6
gradle.properties
Normal 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
26
gradle/libs.versions.toml
Normal 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
3
rustfmt.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
edition = "2021"
|
||||||
|
max_width = 120
|
||||||
|
newline_style = "Unix"
|
||||||
23
settings.gradle.kts
Normal file
23
settings.gradle.kts
Normal 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")
|
||||||
Reference in New Issue
Block a user