mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-09-03 01:25:37 +00:00
refactor(core): separate portable core from native runtime (#2451)
Create easytier-core as the portable owner of configuration, connectivity, tunnels, peer and routing state, gateways, management, the data plane, and instance lifecycle. Keep operating-system integration, native protocol engines, process startup, and presentation in easytier behind explicit Host capability adapters. Create easytier-proto to own schemas, generated RPC types, descriptors, and feature-scoped protocol slices. Remove runtime protobuf reflection from core while preserving unknown route-peer fields across forwarding. Normalize instance construction through CoreInstance, CoreHostAdapters, CoreProcessRuntime, and InstanceManager. Make the runtime config store the only authoritative mutable configuration after startup. Move the portable TCP/UDP data plane into core and extract a generic OperationBroker for completion, cancellation, disposal, and capacity accounting. Expose the session-based FFI v2 completion API and keep the WASI guest ABI, wire schemas, and adapters with core. Migrate CLI, GUI, web, FFI, Android JNI, OHOS, uptime, and mobile consumers to the shared manager and core state. Add explicit user/web config ownership and revision-aware web reconciliation. Preserve configuration, wire, and management behavior while fixing regressions discovered by the full platform and integration matrix: - inherit advertised relay capabilities in foreign networks; - refresh OSPF peer state immediately after runtime config changes; - restore CLI GlobalCtx event output without forcing GUI logging; - retain legacy encryption names and standalone RPC tunnel metadata; - restore ICMP host composition and fragmented UDP handling; - use portable 64-bit atomics on 32-bit MIPS targets; and - retain discarded operations until late cancellation completes. Validate the refactor across 45 GitHub checks, including Linux, macOS, Windows, FreeBSD, web, GUI, Android, OHOS, feature profiles, and three-node and subnet-proxy integration tests. BREAKING CHANGE: internal Rust module paths are not preserved. Legacy native data-plane APIs are replaced by the session-based FFI v2 API. The dedicated Android data-plane wrapper is removed.
This commit is contained in:
@@ -1,65 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{
|
||||
instance_manager::NetworkInstanceManager,
|
||||
proto::{rpc_impl::service_registry::ServiceRegistry, web::DeviceOsInfo},
|
||||
rpc_service::api::register_api_rpc_service,
|
||||
web_client::WebClientHooks,
|
||||
};
|
||||
|
||||
pub struct Controller {
|
||||
token: String,
|
||||
machine_id: uuid::Uuid,
|
||||
hostname: String,
|
||||
device_os: DeviceOsInfo,
|
||||
manager: Arc<NetworkInstanceManager>,
|
||||
hooks: Arc<dyn WebClientHooks>,
|
||||
}
|
||||
|
||||
impl Controller {
|
||||
pub fn new(
|
||||
token: String,
|
||||
machine_id: uuid::Uuid,
|
||||
hostname: String,
|
||||
device_os: DeviceOsInfo,
|
||||
manager: Arc<NetworkInstanceManager>,
|
||||
hooks: Arc<dyn WebClientHooks>,
|
||||
) -> Self {
|
||||
Controller {
|
||||
token,
|
||||
machine_id,
|
||||
hostname,
|
||||
device_os,
|
||||
manager,
|
||||
hooks,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_network_instance_ids(&self) -> Vec<uuid::Uuid> {
|
||||
self.manager.list_network_instance_ids()
|
||||
}
|
||||
|
||||
pub fn token(&self) -> String {
|
||||
self.token.clone()
|
||||
}
|
||||
|
||||
pub fn hostname(&self) -> String {
|
||||
self.hostname.clone()
|
||||
}
|
||||
|
||||
pub fn machine_id(&self) -> uuid::Uuid {
|
||||
self.machine_id
|
||||
}
|
||||
|
||||
pub fn device_os(&self) -> DeviceOsInfo {
|
||||
self.device_os.clone()
|
||||
}
|
||||
|
||||
pub fn register_api_rpc_service(&self, registry: &ServiceRegistry) {
|
||||
register_api_rpc_service(&self.manager, registry, Some(self.hooks.clone()));
|
||||
}
|
||||
|
||||
pub(super) fn notify_manager_stopping(&self) {
|
||||
self.manager.notify_stop_check();
|
||||
}
|
||||
}
|
||||
+84
-261
@@ -1,42 +1,72 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use async_trait::async_trait;
|
||||
use easytier_core::{
|
||||
connectivity::{manual::ManualTunnelConnector, protocol::raw::TunnelDialer},
|
||||
management::{ConfigServerEndpoint, WebClientConfig},
|
||||
socket::IpVersion,
|
||||
tunnel::Tunnel,
|
||||
};
|
||||
use url::Url;
|
||||
|
||||
use crate::{
|
||||
common::{
|
||||
MachineIdOptions,
|
||||
config::TomlConfigLoader,
|
||||
global_ctx::{ArcGlobalCtx, GlobalCtx},
|
||||
log,
|
||||
os_info::collect_device_os_info,
|
||||
resolve_machine_id,
|
||||
stun::MockStunInfoCollector,
|
||||
MachineIdOptions, config::TomlConfigLoader, constants::EASYTIER_VERSION,
|
||||
global_ctx::GlobalCtx, os_info::collect_device_os_info, resolve_machine_id,
|
||||
},
|
||||
connector::create_connector_by_url,
|
||||
instance_manager::{DaemonGuard, NetworkInstanceManager},
|
||||
proto::common::NatType,
|
||||
tunnel::{IpVersion, Tunnel, TunnelConnector, TunnelError, TunnelScheme},
|
||||
instance::{
|
||||
composition::runtime_one_shot_manual_connector,
|
||||
config_storage::NativeConfigFileStorage,
|
||||
factory::{NativeInstanceFactory, NativeInstanceManager},
|
||||
host::NativeInstanceHost,
|
||||
},
|
||||
rpc_service::logger::NativeLoggerControl,
|
||||
tunnel::TunnelScheme,
|
||||
};
|
||||
use anyhow::{Context as _, Result};
|
||||
use async_trait::async_trait;
|
||||
use tokio_util::task::AbortOnDropHandle;
|
||||
use url::Url;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[async_trait]
|
||||
pub trait WebClientHooks: Send + Sync {
|
||||
fn manages_remote_config_instances(&self) -> bool {
|
||||
false
|
||||
pub use easytier_core::management::InstanceMutationHooks as WebClientHooks;
|
||||
|
||||
pub struct WebClient {
|
||||
inner: easytier_core::management::WebClient<NativeInstanceFactory>,
|
||||
}
|
||||
|
||||
impl WebClient {
|
||||
pub fn new<T, S, H>(
|
||||
connector: T,
|
||||
token: S,
|
||||
machine_id: uuid::Uuid,
|
||||
hostname: H,
|
||||
secure_mode: bool,
|
||||
manager: Arc<NativeInstanceManager>,
|
||||
hooks: Option<Arc<dyn WebClientHooks>>,
|
||||
) -> Self
|
||||
where
|
||||
T: TunnelDialer + 'static,
|
||||
S: ToString,
|
||||
H: ToString,
|
||||
{
|
||||
Self {
|
||||
inner: easytier_core::management::WebClient::new(
|
||||
connector,
|
||||
WebClientConfig {
|
||||
token: token.to_string(),
|
||||
machine_id,
|
||||
hostname: hostname.to_string(),
|
||||
device_os: collect_device_os_info(),
|
||||
easytier_version: EASYTIER_VERSION.to_owned(),
|
||||
secure_mode,
|
||||
},
|
||||
manager,
|
||||
hooks.unwrap_or_else(|| Arc::new(DefaultHooks)),
|
||||
Arc::new(NativeConfigFileStorage),
|
||||
Arc::new(NativeLoggerControl),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
async fn pre_run_network_instance(&self, _cfg: &TomlConfigLoader) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn post_run_network_instance(&self, _id: &Uuid) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn post_remove_network_instances(&self, _ids: &[Uuid]) -> Result<(), String> {
|
||||
Ok(())
|
||||
pub fn is_connected(&self) -> bool {
|
||||
self.inner.is_connected()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,36 +75,17 @@ pub struct DefaultHooks;
|
||||
#[async_trait]
|
||||
impl WebClientHooks for DefaultHooks {}
|
||||
|
||||
pub mod controller;
|
||||
pub mod security;
|
||||
pub mod session;
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
pub struct WebClient {
|
||||
controller: Arc<controller::Controller>,
|
||||
tasks: AbortOnDropHandle<()>,
|
||||
manager_guard: DaemonGuard,
|
||||
connected: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
struct ConfigServerConnector {
|
||||
url: Url,
|
||||
global_ctx: ArcGlobalCtx,
|
||||
connector: ManualTunnelConnector<NativeInstanceHost>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TunnelConnector for ConfigServerConnector {
|
||||
async fn connect(&mut self) -> std::result::Result<Box<dyn Tunnel>, TunnelError> {
|
||||
let mut connector =
|
||||
create_connector_by_url(self.url.as_str(), &self.global_ctx, IpVersion::Both)
|
||||
.await
|
||||
.map_err(|err| match err {
|
||||
crate::common::error::Error::TunnelError(err) => err,
|
||||
err => TunnelError::Anyhow(err.into()),
|
||||
})?;
|
||||
|
||||
connector.connect().await
|
||||
impl TunnelDialer for ConfigServerConnector {
|
||||
async fn connect(&self) -> anyhow::Result<Box<dyn Tunnel>> {
|
||||
self.connector
|
||||
.connect(self.url.clone(), IpVersion::Both)
|
||||
.await
|
||||
}
|
||||
|
||||
fn remote_url(&self) -> Url {
|
||||
@@ -82,221 +93,38 @@ impl TunnelConnector for ConfigServerConnector {
|
||||
}
|
||||
}
|
||||
|
||||
impl WebClient {
|
||||
pub fn new<T: TunnelConnector + 'static, S: ToString, H: ToString>(
|
||||
connector: T,
|
||||
token: S,
|
||||
machine_id: Uuid,
|
||||
hostname: H,
|
||||
secure_mode: bool,
|
||||
manager: Arc<NetworkInstanceManager>,
|
||||
hooks: Option<Arc<dyn WebClientHooks>>,
|
||||
) -> Self {
|
||||
let manager_guard = manager.register_daemon();
|
||||
let hooks = hooks.unwrap_or_else(|| Arc::new(DefaultHooks));
|
||||
let controller = Arc::new(controller::Controller::new(
|
||||
token.to_string(),
|
||||
machine_id,
|
||||
hostname.to_string(),
|
||||
collect_device_os_info(),
|
||||
manager,
|
||||
hooks,
|
||||
));
|
||||
let connected = Arc::new(AtomicBool::new(false));
|
||||
|
||||
let controller_clone = controller.clone();
|
||||
let connected_clone = connected.clone();
|
||||
let tasks = AbortOnDropHandle::new(tokio::spawn(async move {
|
||||
Self::routine(
|
||||
controller_clone,
|
||||
connected_clone,
|
||||
secure_mode,
|
||||
Box::new(connector),
|
||||
)
|
||||
.await;
|
||||
}));
|
||||
|
||||
WebClient {
|
||||
controller,
|
||||
tasks,
|
||||
manager_guard,
|
||||
connected,
|
||||
}
|
||||
}
|
||||
|
||||
async fn routine(
|
||||
controller: Arc<controller::Controller>,
|
||||
connected: Arc<AtomicBool>,
|
||||
secure_mode: bool,
|
||||
mut connector: Box<dyn TunnelConnector>,
|
||||
) {
|
||||
loop {
|
||||
let conn = match connector.connect().await {
|
||||
Ok(conn) => conn,
|
||||
Err(error) => {
|
||||
let wait = 1;
|
||||
log::warn!(%error, "Failed to connect to the server, retrying in {} seconds...", wait);
|
||||
tokio::time::sleep(std::time::Duration::from_secs(wait)).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
connected.store(true, Ordering::Release);
|
||||
log::info!("Successfully connected to {:?}", conn.info());
|
||||
|
||||
let mut session = session::Session::new(conn, controller.clone());
|
||||
let support_encryption = match tokio::time::timeout(
|
||||
std::time::Duration::from_secs(3),
|
||||
session.get_feature(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(feature)) => feature.support_encryption,
|
||||
Ok(Err(error)) => {
|
||||
log::warn!(%error, "GetFeature rpc failed, fallback to legacy tunnel");
|
||||
false
|
||||
}
|
||||
Err(_) => {
|
||||
log::warn!("GetFeature rpc timeout, fallback to legacy tunnel");
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
if support_encryption && security::web_secure_tunnel_supported() {
|
||||
log::info!("Server supports encryption, reconnecting with secure tunnel");
|
||||
drop(session);
|
||||
|
||||
let conn = match connector.connect().await {
|
||||
Ok(conn) => conn,
|
||||
Err(error) => {
|
||||
connected.store(false, Ordering::Release);
|
||||
let wait = 1;
|
||||
log::warn!(%error, "Failed to reconnect secure tunnel, retrying in {} seconds...", wait);
|
||||
tokio::time::sleep(std::time::Duration::from_secs(wait)).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let conn = match security::upgrade_client_tunnel(conn).await {
|
||||
Ok(conn) => conn,
|
||||
Err(error) => {
|
||||
connected.store(false, Ordering::Release);
|
||||
let wait = 1;
|
||||
log::warn!(%error, "Noise handshake failed, retrying in {} seconds...", wait);
|
||||
tokio::time::sleep(std::time::Duration::from_secs(wait)).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let mut session = session::Session::new(conn, controller.clone());
|
||||
session.start_heartbeat().await;
|
||||
session.wait().await;
|
||||
connected.store(false, Ordering::Release);
|
||||
continue;
|
||||
}
|
||||
|
||||
if support_encryption {
|
||||
if secure_mode {
|
||||
connected.store(false, Ordering::Release);
|
||||
let wait = 1;
|
||||
log::warn!(
|
||||
"secure-mode enabled but local build lacks aes-gcm support for web secure tunnel, retrying in {} seconds...",
|
||||
wait
|
||||
);
|
||||
tokio::time::sleep(std::time::Duration::from_secs(wait)).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
log::warn!(
|
||||
"Server supports encryption but local build lacks aes-gcm support for web secure tunnel, falling back to legacy tunnel"
|
||||
);
|
||||
}
|
||||
|
||||
if secure_mode {
|
||||
connected.store(false, Ordering::Release);
|
||||
let wait = 1;
|
||||
log::warn!(
|
||||
"secure-mode enabled but server does not support encryption, retrying in {} seconds...",
|
||||
wait
|
||||
);
|
||||
tokio::time::sleep(std::time::Duration::from_secs(wait)).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
session.start_heartbeat().await;
|
||||
session.wait().await;
|
||||
connected.store(false, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_connected(&self) -> bool {
|
||||
self.connected.load(Ordering::Acquire)
|
||||
}
|
||||
pub fn parse_config_server_endpoint(input: &str) -> anyhow::Result<ConfigServerEndpoint> {
|
||||
ConfigServerEndpoint::parse(input, |url| TunnelScheme::try_from(url).is_ok())
|
||||
}
|
||||
|
||||
pub async fn run_web_client(
|
||||
config_server_url_s: &str,
|
||||
machine_id_opts: MachineIdOptions,
|
||||
config_server_url: &str,
|
||||
machine_id_options: MachineIdOptions,
|
||||
hostname: Option<String>,
|
||||
secure_mode: bool,
|
||||
manager: Arc<NetworkInstanceManager>,
|
||||
manager: Arc<NativeInstanceManager>,
|
||||
hooks: Option<Arc<dyn WebClientHooks>>,
|
||||
) -> Result<WebClient> {
|
||||
let machine_id = resolve_machine_id(&machine_id_opts)
|
||||
let machine_id = resolve_machine_id(&machine_id_options)
|
||||
.with_context(|| "failed to resolve machine id for web client")?;
|
||||
let config_server_url = match Url::parse(config_server_url_s) {
|
||||
Ok(u) => u,
|
||||
Err(_) => format!(
|
||||
"udp://config-server.easytier.cn:22020/{}",
|
||||
config_server_url_s
|
||||
)
|
||||
.parse()
|
||||
.with_context(|| "failed to parse config server URL")?,
|
||||
};
|
||||
|
||||
TunnelScheme::try_from(&config_server_url).map_err(|_| {
|
||||
anyhow::anyhow!(
|
||||
"unsupported config server scheme: {}",
|
||||
config_server_url.scheme()
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut c_url = config_server_url.clone();
|
||||
if !matches!(c_url.scheme(), "ws" | "wss") {
|
||||
c_url.set_path("");
|
||||
}
|
||||
let token = config_server_url
|
||||
.path_segments()
|
||||
.and_then(|mut x| x.next_back())
|
||||
.map(|x| percent_encoding::percent_decode_str(x).decode_utf8())
|
||||
.transpose()
|
||||
.with_context(|| "failed to decode config server token")?
|
||||
.map(|x| x.to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
if token.is_empty() {
|
||||
return Err(anyhow::anyhow!("empty token"));
|
||||
}
|
||||
let endpoint = parse_config_server_endpoint(config_server_url)?;
|
||||
|
||||
let config = TomlConfigLoader::default();
|
||||
let global_ctx = Arc::new(GlobalCtx::new(config));
|
||||
global_ctx.replace_stun_info_collector(Box::new(MockStunInfoCollector {
|
||||
udp_nat_type: NatType::Unknown,
|
||||
}));
|
||||
let global_ctx = Arc::new(GlobalCtx::new(config.clone()));
|
||||
let mut flags = global_ctx.get_flags();
|
||||
flags.bind_device = false;
|
||||
global_ctx.set_flags(flags);
|
||||
let hostname =
|
||||
hostname.unwrap_or_else(|| gethostname::gethostname().to_string_lossy().to_string());
|
||||
let connector =
|
||||
runtime_one_shot_manual_connector(global_ctx, &config, manager.process_runtime())?;
|
||||
|
||||
let hostname = match hostname {
|
||||
None => gethostname::gethostname().to_string_lossy().to_string(),
|
||||
Some(hostname) => hostname,
|
||||
};
|
||||
Ok(WebClient::new(
|
||||
ConfigServerConnector {
|
||||
url: c_url,
|
||||
global_ctx,
|
||||
url: endpoint.connect_url().clone(),
|
||||
connector,
|
||||
},
|
||||
token.to_string(),
|
||||
endpoint.token(),
|
||||
machine_id,
|
||||
hostname,
|
||||
secure_mode,
|
||||
@@ -309,11 +137,11 @@ pub async fn run_web_client(
|
||||
mod tests {
|
||||
use std::sync::{Arc, atomic::AtomicBool};
|
||||
|
||||
use crate::{common::MachineIdOptions, instance_manager::NetworkInstanceManager};
|
||||
use crate::{common::MachineIdOptions, instance::factory::native_instance_manager};
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_manager_wait() {
|
||||
let manager = Arc::new(NetworkInstanceManager::new());
|
||||
let manager = Arc::new(native_instance_manager());
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let client = super::run_web_client(
|
||||
format!("ring://{}/test", uuid::Uuid::new_v4()).as_str(),
|
||||
@@ -333,21 +161,17 @@ mod tests {
|
||||
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
|
||||
println!("Dropping client...");
|
||||
sleep_finish_clone.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
drop(client);
|
||||
println!("Client dropped.");
|
||||
});
|
||||
|
||||
println!("Waiting for manager...");
|
||||
manager.wait().await;
|
||||
assert!(sleep_finish.load(std::sync::atomic::Ordering::Relaxed));
|
||||
println!("Manager stopped.");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_run_web_client_with_unreachable_config_server() {
|
||||
let manager = Arc::new(NetworkInstanceManager::new());
|
||||
let manager = Arc::new(native_instance_manager());
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let client = super::run_web_client(
|
||||
"udp://config-server.invalid:22020/test",
|
||||
@@ -365,6 +189,5 @@ mod tests {
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
assert!(!client.is_connected());
|
||||
drop(client);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,398 +0,0 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use snow::{Builder, params::NoiseParams};
|
||||
|
||||
use crate::{
|
||||
common::config::EncryptionAlgorithm,
|
||||
peers::secure_datagram::{SecureDatagramDirection, SecureDatagramSession},
|
||||
proto::common::TunnelInfo,
|
||||
tunnel::{
|
||||
SplitTunnel, StreamItem, Tunnel, TunnelError, ZCPacketSink, ZCPacketStream,
|
||||
filter::{TunnelFilter, TunnelWithFilter},
|
||||
packet_def::{PacketType, ZCPacket, ZCPacketType},
|
||||
},
|
||||
};
|
||||
|
||||
const NOISE_MAGIC: &[u8] = b"ET_WEB_NOISE_V1:";
|
||||
const NOISE_PROLOGUE: &[u8] = b"easytier-webclient-noise-v1";
|
||||
const NOISE_PATTERN: &str = "Noise_NN_25519_ChaChaPoly_SHA256";
|
||||
const WEB_SECURE_CIPHER_ALGORITHM: &str = "aes-gcm";
|
||||
const WEB_SESSION_GENERATION: u32 = 1;
|
||||
const WEB_INITIAL_EPOCH: u32 = 0;
|
||||
const WEB_SECURE_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
const WEB_SECURE_ACCEPT_TIMEOUT: Duration = WEB_SECURE_HANDSHAKE_TIMEOUT;
|
||||
|
||||
struct RawSplitTunnel {
|
||||
info: Option<TunnelInfo>,
|
||||
split: Mutex<Option<SplitTunnel>>,
|
||||
}
|
||||
|
||||
impl RawSplitTunnel {
|
||||
fn new(
|
||||
info: Option<TunnelInfo>,
|
||||
stream: std::pin::Pin<Box<dyn ZCPacketStream>>,
|
||||
sink: std::pin::Pin<Box<dyn ZCPacketSink>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
info,
|
||||
split: Mutex::new(Some((stream, sink))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Tunnel for RawSplitTunnel {
|
||||
fn split(&self) -> SplitTunnel {
|
||||
self.split
|
||||
.lock()
|
||||
.unwrap()
|
||||
.take()
|
||||
.expect("split can only be called once")
|
||||
}
|
||||
|
||||
fn info(&self) -> Option<TunnelInfo> {
|
||||
self.info.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum SecureTunnelRole {
|
||||
Initiator,
|
||||
Responder,
|
||||
}
|
||||
|
||||
impl SecureTunnelRole {
|
||||
fn send_dir(self) -> SecureDatagramDirection {
|
||||
match self {
|
||||
Self::Initiator => SecureDatagramDirection::AToB,
|
||||
Self::Responder => SecureDatagramDirection::BToA,
|
||||
}
|
||||
}
|
||||
|
||||
fn recv_dir(self) -> SecureDatagramDirection {
|
||||
match self {
|
||||
Self::Initiator => SecureDatagramDirection::BToA,
|
||||
Self::Responder => SecureDatagramDirection::AToB,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SecureDatagramTunnelFilter {
|
||||
session: Arc<SecureDatagramSession>,
|
||||
role: SecureTunnelRole,
|
||||
}
|
||||
|
||||
impl TunnelFilter for SecureDatagramTunnelFilter {
|
||||
type FilterOutput = ();
|
||||
|
||||
fn before_send(&self, data: ZCPacket) -> Option<ZCPacket> {
|
||||
let mut packet = ZCPacket::new_with_payload(data.tunnel_payload());
|
||||
packet.fill_peer_manager_hdr(0, 0, PacketType::Data as u8);
|
||||
self.session
|
||||
.encrypt_payload(self.role.send_dir(), &mut packet)
|
||||
.ok()?;
|
||||
Some(packet)
|
||||
}
|
||||
|
||||
fn after_received(&self, data: StreamItem) -> Option<StreamItem> {
|
||||
let packet = match data {
|
||||
Ok(v) => v,
|
||||
Err(e) => return Some(Err(e)),
|
||||
};
|
||||
|
||||
let payload = match checked_payload(&packet, "secure packet") {
|
||||
Ok(v) => v,
|
||||
Err(e) => return Some(Err(e)),
|
||||
};
|
||||
let mut cipher = ZCPacket::new_with_payload(payload);
|
||||
cipher.fill_peer_manager_hdr(0, 0, PacketType::Data as u8);
|
||||
cipher
|
||||
.mut_peer_manager_header()
|
||||
.unwrap()
|
||||
.set_encrypted(true);
|
||||
if let Err(e) = self
|
||||
.session
|
||||
.decrypt_payload(self.role.recv_dir(), &mut cipher)
|
||||
{
|
||||
return Some(Err(TunnelError::InvalidPacket(format!(
|
||||
"secure datagram decrypt failed: {e}"
|
||||
))));
|
||||
}
|
||||
|
||||
let packet = ZCPacket::new_from_buf(cipher.payload_bytes(), ZCPacketType::DummyTunnel);
|
||||
if packet.peer_manager_header().is_none() {
|
||||
return Some(Err(TunnelError::InvalidPacket(
|
||||
"decrypted secure packet too short".to_string(),
|
||||
)));
|
||||
}
|
||||
|
||||
Some(Ok(packet))
|
||||
}
|
||||
|
||||
fn filter_output(&self) {}
|
||||
}
|
||||
|
||||
fn checked_payload<'a>(packet: &'a ZCPacket, context: &str) -> Result<&'a [u8], TunnelError> {
|
||||
if packet.peer_manager_header().is_none() {
|
||||
return Err(TunnelError::InvalidPacket(format!("{context} too short")));
|
||||
}
|
||||
|
||||
Ok(packet.payload())
|
||||
}
|
||||
|
||||
fn pack_control_packet(payload: &[u8]) -> ZCPacket {
|
||||
let mut packet = ZCPacket::new_with_payload(payload);
|
||||
packet.fill_peer_manager_hdr(0, 0, PacketType::Data as u8);
|
||||
packet
|
||||
}
|
||||
|
||||
fn encode_noise_payload(buf: &[u8]) -> Vec<u8> {
|
||||
let mut payload = Vec::with_capacity(NOISE_MAGIC.len() + buf.len());
|
||||
payload.extend_from_slice(NOISE_MAGIC);
|
||||
payload.extend_from_slice(buf);
|
||||
payload
|
||||
}
|
||||
|
||||
fn decode_noise_payload(payload: &[u8]) -> Option<&[u8]> {
|
||||
payload.strip_prefix(NOISE_MAGIC)
|
||||
}
|
||||
|
||||
pub fn web_secure_tunnel_supported() -> bool {
|
||||
WEB_SECURE_CIPHER_ALGORITHM
|
||||
.parse::<EncryptionAlgorithm>()
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
fn web_secure_cipher_algorithm() -> Result<&'static str, TunnelError> {
|
||||
if !web_secure_tunnel_supported() {
|
||||
return Err(TunnelError::InternalError(format!(
|
||||
"web secure tunnel requires {WEB_SECURE_CIPHER_ALGORITHM} support"
|
||||
)));
|
||||
}
|
||||
Ok(WEB_SECURE_CIPHER_ALGORITHM)
|
||||
}
|
||||
|
||||
fn new_web_secure_session(root_key: [u8; 32], algorithm: &str) -> Arc<SecureDatagramSession> {
|
||||
let algo = algorithm.to_string();
|
||||
Arc::new(SecureDatagramSession::new(
|
||||
root_key,
|
||||
WEB_SESSION_GENERATION,
|
||||
WEB_INITIAL_EPOCH,
|
||||
algo.clone(),
|
||||
algo,
|
||||
))
|
||||
}
|
||||
|
||||
fn wrap_secure_tunnel(
|
||||
info: Option<TunnelInfo>,
|
||||
stream: std::pin::Pin<Box<dyn ZCPacketStream>>,
|
||||
sink: std::pin::Pin<Box<dyn ZCPacketSink>>,
|
||||
session: Arc<SecureDatagramSession>,
|
||||
role: SecureTunnelRole,
|
||||
) -> Box<dyn Tunnel> {
|
||||
let raw = RawSplitTunnel::new(info, stream, sink);
|
||||
Box::new(TunnelWithFilter::new(
|
||||
raw,
|
||||
SecureDatagramTunnelFilter { session, role },
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn upgrade_client_tunnel(
|
||||
tunnel: Box<dyn Tunnel>,
|
||||
) -> Result<Box<dyn Tunnel>, TunnelError> {
|
||||
let web_cipher_algorithm = web_secure_cipher_algorithm()?;
|
||||
let info = tunnel.info();
|
||||
let (mut stream, mut sink) = tunnel.split();
|
||||
|
||||
let params: NoiseParams = NOISE_PATTERN
|
||||
.parse()
|
||||
.map_err(|e| TunnelError::InternalError(format!("parse noise params failed: {e}")))?;
|
||||
let mut state = Builder::new(params)
|
||||
.prologue(NOISE_PROLOGUE)
|
||||
.map_err(|e| TunnelError::InternalError(format!("set prologue failed: {e}")))?
|
||||
.build_initiator()
|
||||
.map_err(|e| TunnelError::InternalError(format!("build initiator failed: {e}")))?;
|
||||
|
||||
let mut msg1 = vec![0u8; 1024];
|
||||
let msg1_len = state
|
||||
.write_message(&[], &mut msg1)
|
||||
.map_err(|e| TunnelError::InternalError(format!("write noise msg1 failed: {e}")))?;
|
||||
sink.send(pack_control_packet(&encode_noise_payload(
|
||||
&msg1[..msg1_len],
|
||||
)))
|
||||
.await?;
|
||||
|
||||
let msg2_packet = match tokio::time::timeout(WEB_SECURE_HANDSHAKE_TIMEOUT, stream.next()).await
|
||||
{
|
||||
Ok(Some(Ok(packet))) => packet,
|
||||
Ok(Some(Err(error))) => return Err(error),
|
||||
Ok(None) => return Err(TunnelError::Shutdown),
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
let msg2_payload = checked_payload(&msg2_packet, "noise msg2 packet")?;
|
||||
let msg2_cipher = decode_noise_payload(msg2_payload)
|
||||
.ok_or_else(|| TunnelError::InvalidPacket("invalid noise msg2 magic".to_string()))?;
|
||||
let mut root_key_buf = [0u8; 32];
|
||||
let root_key_len = state
|
||||
.read_message(msg2_cipher, &mut root_key_buf)
|
||||
.map_err(|e| TunnelError::InvalidPacket(format!("read noise msg2 failed: {e}")))?;
|
||||
if root_key_len != root_key_buf.len() {
|
||||
return Err(TunnelError::InvalidPacket(format!(
|
||||
"invalid web secure root key len: {root_key_len}"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(wrap_secure_tunnel(
|
||||
info,
|
||||
stream,
|
||||
sink,
|
||||
new_web_secure_session(root_key_buf, web_cipher_algorithm),
|
||||
SecureTunnelRole::Initiator,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn accept_or_upgrade_server_tunnel(
|
||||
tunnel: Box<dyn Tunnel>,
|
||||
) -> Result<(Box<dyn Tunnel>, bool), TunnelError> {
|
||||
let info = tunnel.info();
|
||||
let (stream, sink) = tunnel.split();
|
||||
let mut stream = stream;
|
||||
let mut sink = sink;
|
||||
|
||||
let first_packet = match tokio::time::timeout(WEB_SECURE_ACCEPT_TIMEOUT, stream.next()).await {
|
||||
Ok(Some(Ok(packet))) => packet,
|
||||
Ok(Some(Err(error))) => return Err(error),
|
||||
Ok(None) => return Err(TunnelError::Shutdown),
|
||||
Err(_) => {
|
||||
return Ok((
|
||||
Box::new(RawSplitTunnel::new(info, stream, sink)) as Box<dyn Tunnel>,
|
||||
false,
|
||||
));
|
||||
}
|
||||
};
|
||||
let first_payload = checked_payload(&first_packet, "first packet")?;
|
||||
let Some(msg1_cipher) = decode_noise_payload(first_payload) else {
|
||||
let stream = Box::pin(futures::stream::once(async move { Ok(first_packet) }).chain(stream));
|
||||
return Ok((
|
||||
Box::new(RawSplitTunnel::new(info, stream, sink)) as Box<dyn Tunnel>,
|
||||
false,
|
||||
));
|
||||
};
|
||||
let web_cipher_algorithm = web_secure_cipher_algorithm()?;
|
||||
|
||||
let params: NoiseParams = NOISE_PATTERN
|
||||
.parse()
|
||||
.map_err(|e| TunnelError::InternalError(format!("parse noise params failed: {e}")))?;
|
||||
let mut state = Builder::new(params)
|
||||
.prologue(NOISE_PROLOGUE)
|
||||
.map_err(|e| TunnelError::InternalError(format!("set prologue failed: {e}")))?
|
||||
.build_responder()
|
||||
.map_err(|e| TunnelError::InternalError(format!("build responder failed: {e}")))?;
|
||||
|
||||
let mut msg1 = vec![0u8; 1024];
|
||||
state
|
||||
.read_message(msg1_cipher, &mut msg1)
|
||||
.map_err(|e| TunnelError::InvalidPacket(format!("read noise msg1 failed: {e}")))?;
|
||||
|
||||
let root_key = SecureDatagramSession::new_root_key();
|
||||
let mut msg2 = vec![0u8; 1024];
|
||||
let msg2_len = state
|
||||
.write_message(&root_key, &mut msg2)
|
||||
.map_err(|e| TunnelError::InternalError(format!("write noise msg2 failed: {e}")))?;
|
||||
sink.send(pack_control_packet(&encode_noise_payload(
|
||||
&msg2[..msg2_len],
|
||||
)))
|
||||
.await?;
|
||||
|
||||
Ok((
|
||||
wrap_secure_tunnel(
|
||||
info,
|
||||
stream,
|
||||
sink,
|
||||
new_web_secure_session(root_key, web_cipher_algorithm),
|
||||
SecureTunnelRole::Responder,
|
||||
),
|
||||
true,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::tunnel::ring::create_ring_tunnel_pair;
|
||||
use bytes::BytesMut;
|
||||
|
||||
#[test]
|
||||
fn web_secure_cipher_algorithm_matches_support_flag() {
|
||||
let result = web_secure_cipher_algorithm();
|
||||
if web_secure_tunnel_supported() {
|
||||
assert_eq!(result.unwrap(), WEB_SECURE_CIPHER_ALGORITHM);
|
||||
} else {
|
||||
assert!(matches!(result, Err(TunnelError::InternalError(_))));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_secure_session_uses_pinned_cipher_algorithm() {
|
||||
if !web_secure_tunnel_supported() {
|
||||
return;
|
||||
}
|
||||
|
||||
let session = new_web_secure_session(
|
||||
SecureDatagramSession::new_root_key(),
|
||||
web_secure_cipher_algorithm().unwrap(),
|
||||
);
|
||||
session
|
||||
.check_encrypt_algo_same(WEB_SECURE_CIPHER_ALGORITHM, WEB_SECURE_CIPHER_ALGORITHM)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upgrade_client_tunnel_times_out_when_server_never_replies() {
|
||||
let (server_tunnel, client_tunnel) = create_ring_tunnel_pair();
|
||||
let _server_tunnel = server_tunnel;
|
||||
|
||||
let err = upgrade_client_tunnel(client_tunnel).await.unwrap_err();
|
||||
assert!(matches!(err, TunnelError::Timeout(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accept_secure_tunnel_rejects_short_first_packet() {
|
||||
let (server_tunnel, client_tunnel) = create_ring_tunnel_pair();
|
||||
|
||||
let server_task =
|
||||
tokio::spawn(async move { accept_or_upgrade_server_tunnel(server_tunnel).await });
|
||||
|
||||
let (_stream, mut sink) = client_tunnel.split();
|
||||
sink.send(ZCPacket::new_from_buf(
|
||||
BytesMut::from(&b"\x01"[..]),
|
||||
ZCPacketType::TCP,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let err = server_task.await.unwrap().unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
TunnelError::InvalidPacket(msg) if msg == "first packet too short"
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accept_secure_tunnel_after_short_client_delay() {
|
||||
let (server_tunnel, client_tunnel) = create_ring_tunnel_pair();
|
||||
|
||||
let server_task =
|
||||
tokio::spawn(async move { accept_or_upgrade_server_tunnel(server_tunnel).await });
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(1500)).await;
|
||||
|
||||
let client_task = tokio::spawn(async move { upgrade_client_tunnel(client_tunnel).await });
|
||||
|
||||
let (server_res, client_res) = tokio::join!(server_task, client_task);
|
||||
let (_, secure) = server_res.unwrap().unwrap();
|
||||
assert!(secure);
|
||||
assert!(client_res.unwrap().is_ok());
|
||||
}
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
use std::sync::{Arc, Weak};
|
||||
|
||||
use tokio::{
|
||||
sync::{Mutex, broadcast},
|
||||
task::JoinSet,
|
||||
time::interval,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
common::constants::EASYTIER_VERSION,
|
||||
proto::{
|
||||
rpc_impl::bidirect::BidirectRpcManager,
|
||||
rpc_types::controller::BaseController,
|
||||
web::{
|
||||
GetFeatureRequest, GetFeatureResponse, HeartbeatRequest, HeartbeatResponse,
|
||||
WebServerServiceClientFactory,
|
||||
},
|
||||
},
|
||||
tunnel::Tunnel,
|
||||
};
|
||||
|
||||
use super::controller::Controller;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct HeartbeatCtx {
|
||||
notifier: Arc<broadcast::Sender<HeartbeatResponse>>,
|
||||
resp: Arc<Mutex<Option<HeartbeatResponse>>>,
|
||||
}
|
||||
|
||||
pub struct Session {
|
||||
rpc_mgr: BidirectRpcManager,
|
||||
controller: Arc<Controller>,
|
||||
|
||||
heartbeat_ctx: HeartbeatCtx,
|
||||
heartbeat_started: std::sync::atomic::AtomicBool,
|
||||
|
||||
tasks: Mutex<JoinSet<()>>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn new(tunnel: Box<dyn Tunnel>, controller: Arc<Controller>) -> Self {
|
||||
let rpc_mgr = BidirectRpcManager::new();
|
||||
rpc_mgr.run_with_tunnel(tunnel);
|
||||
|
||||
controller.register_api_rpc_service(rpc_mgr.rpc_server().registry());
|
||||
|
||||
let (tx, _rx1) = broadcast::channel(2);
|
||||
let heartbeat_ctx = HeartbeatCtx {
|
||||
notifier: Arc::new(tx),
|
||||
resp: Arc::new(Mutex::new(None)),
|
||||
};
|
||||
|
||||
Session {
|
||||
rpc_mgr,
|
||||
controller,
|
||||
heartbeat_ctx,
|
||||
heartbeat_started: std::sync::atomic::AtomicBool::new(false),
|
||||
tasks: Mutex::new(JoinSet::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn heartbeat_routine(
|
||||
rpc_mgr: &BidirectRpcManager,
|
||||
controller: Weak<Controller>,
|
||||
tasks: &mut JoinSet<()>,
|
||||
ctx: HeartbeatCtx,
|
||||
) {
|
||||
let controller = controller.upgrade().unwrap();
|
||||
let mid = controller.machine_id();
|
||||
let inst_id = uuid::Uuid::new_v4();
|
||||
let token = controller.token();
|
||||
let hostname = controller.hostname();
|
||||
let device_os = controller.device_os();
|
||||
let controller = Arc::downgrade(&controller);
|
||||
|
||||
let ctx_clone = ctx.clone();
|
||||
let mut tick = interval(std::time::Duration::from_secs(1));
|
||||
let client = rpc_mgr
|
||||
.rpc_client()
|
||||
.scoped_client::<WebServerServiceClientFactory<BaseController>>(1, 1, "".to_string());
|
||||
tasks.spawn(async move {
|
||||
loop {
|
||||
tick.tick().await;
|
||||
|
||||
let Some(controller) = controller.upgrade() else {
|
||||
break;
|
||||
};
|
||||
|
||||
let req = HeartbeatRequest {
|
||||
machine_id: Some(mid.into()),
|
||||
inst_id: Some(inst_id.into()),
|
||||
user_token: token.to_string(),
|
||||
|
||||
easytier_version: EASYTIER_VERSION.to_string(),
|
||||
hostname: hostname.clone(),
|
||||
report_time: chrono::Local::now().to_rfc3339(),
|
||||
device_os: Some(device_os.clone()),
|
||||
support_config_source: true,
|
||||
|
||||
running_network_instances: controller
|
||||
.list_network_instance_ids()
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.collect(),
|
||||
};
|
||||
|
||||
match client
|
||||
.heartbeat(BaseController::default(), req.clone())
|
||||
.await
|
||||
{
|
||||
Err(e) => {
|
||||
tracing::error!("heartbeat failed: {:?}", e);
|
||||
break;
|
||||
}
|
||||
Ok(resp) => {
|
||||
tracing::debug!("heartbeat response: {:?}", resp);
|
||||
let _ = ctx_clone.notifier.send(resp);
|
||||
ctx_clone.resp.lock().await.replace(resp);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub async fn start_heartbeat(&self) {
|
||||
if self
|
||||
.heartbeat_started
|
||||
.swap(true, std::sync::atomic::Ordering::AcqRel)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let mut tasks = self.tasks.lock().await;
|
||||
Self::heartbeat_routine(
|
||||
&self.rpc_mgr,
|
||||
Arc::downgrade(&self.controller),
|
||||
&mut tasks,
|
||||
self.heartbeat_ctx.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
async fn wait_routines(&self) {
|
||||
self.tasks.lock().await.join_next().await;
|
||||
// if any task failed, we should abort all tasks
|
||||
self.tasks.lock().await.abort_all();
|
||||
}
|
||||
|
||||
pub async fn wait(&mut self) {
|
||||
tokio::select! {
|
||||
_ = self.rpc_mgr.wait() => {}
|
||||
_ = self.wait_routines() => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_feature(
|
||||
&self,
|
||||
) -> Result<GetFeatureResponse, crate::proto::rpc_types::error::Error> {
|
||||
let client = self
|
||||
.rpc_mgr
|
||||
.rpc_client()
|
||||
.scoped_client::<WebServerServiceClientFactory<BaseController>>(1, 1, "".to_string());
|
||||
client
|
||||
.get_feature(BaseController::default(), GetFeatureRequest {})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn wait_next_heartbeat(&self) -> Option<HeartbeatResponse> {
|
||||
let mut rx = self.heartbeat_ctx.notifier.subscribe();
|
||||
rx.recv().await.ok()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user