init
This commit is contained in:
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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user