feat(wasi): expose protobuf RPC request ABI (#2477)

* feat(wasi): expose protobuf RPC request ABI

Add an instance-scoped asynchronous RPC session backed by the shared
operation broker. Reuse the existing dispatcher and management handlers.
WASI hosts can call PeerManageRpc and ConnectorManageRpc with the same
protobuf payloads as easytier-cli.

Export ABI version, submit, take, and free functions. Bind selectors to
the WASM instance handle and keep method errors in RpcResponse. Enable
management RPC explicitly in the Go-host WASM build.

* fix(gateway): serialize UDP client eviction

Serialize UDP client admission across forwarding rules so only one
eviction can claim and wait for a released semaphore permit. Retry
when cleanup concurrently removes the selected client.

Add a multithreaded regression test for the permit handoff while the
evicted client is still referenced.

* fix(gateway): publish UDP client admission atomically

Hold the admission guard through client and response-task publication
so a concurrent eviction cannot leave an orphan task holding the slot
permit.

Open the data-plane flow before entering the critical section and extend
the multithreaded regression test across the publication window.
This commit is contained in:
KKRainbow
2026-08-07 18:34:53 +08:00
committed by GitHub
parent e31bde1836
commit 1e40350c89
33 changed files with 2479 additions and 183 deletions
+44
View File
@@ -29,9 +29,21 @@ pub const HOST_CRYPTO_AUTH_FAILED: i32 = -10;
/// Version of the JSON document accepted by `easytier_instance_create`.
pub const CORE_INSTANCE_CONFIG_VERSION: u32 = 14;
/// Version of the JSON document accepted by `easytier_web_client_create`.
#[cfg(feature = "management")]
pub const WEB_CLIENT_CONFIG_VERSION: u32 = 1;
/// Version of the public data-plane guest export contract.
pub const DATA_PLANE_ABI_VERSION: u32 = 3;
/// Version of the protobuf RPC guest export contract.
#[cfg(feature = "management-rpc")]
pub const RPC_ABI_VERSION: u32 = 2;
/// `easytier_rpc_response_take` has not completed yet.
#[cfg(feature = "management-rpc")]
pub const RPC_STATUS_PENDING: i32 = -6;
/// The guest exposes an instance-scoped data-plane operation broker.
pub const DATA_PLANE_CAPABILITY: u64 = 1 << 0;
/// The guest data plane supports TCP streams and listeners.
@@ -68,6 +80,38 @@ pub const GUEST_EXPORTS: &[&str] = &[
"easytier_instance_error_copy",
];
/// Guest exports present when process-level WebClient management is enabled.
#[cfg(feature = "management")]
pub const WEB_CLIENT_GUEST_EXPORTS: &[&str] = &[
"easytier_web_client_create",
"easytier_web_client_drive",
"easytier_web_client_notify_completions",
"easytier_web_client_next_deadline_millis",
"easytier_web_client_is_connected",
"easytier_web_client_drop",
];
/// Guest exports present when protobuf management RPC is enabled.
///
/// `easytier_rpc_request_submit` accepts a serialized
/// `common.DirectRpcRequest`. Its `full_method_name` uses the canonical
/// protobuf reflection name, and `request` contains the serialized protobuf
/// input. An absent `timeout_ms` means the dispatcher has no timeout.
/// `easytier_rpc_response_take` returns a serialized `common.RpcResponse`,
/// including any method error.
/// Submit writes an opaque big-endian `u64` operation ID. A response take
/// returns [`RPC_STATUS_PENDING`] while running; `(output, capacity) == (0,
/// 0)` probes the size, and an undersized buffer returns the required size
/// without consuming the response. Free cancels pending work and discards
/// any retained result.
#[cfg(feature = "management-rpc")]
pub const RPC_GUEST_EXPORTS: &[&str] = &[
"easytier_rpc_abi_version",
"easytier_rpc_request_submit",
"easytier_rpc_response_take",
"easytier_rpc_operation_free",
];
/// Guest exports present when the core is built with the smoltcp data plane.
#[cfg(feature = "proxy-smoltcp-stack")]
pub const DATA_PLANE_GUEST_EXPORTS: &[&str] = &[
@@ -0,0 +1,66 @@
use std::{io, task::Poll};
use crate::{
host::{management::HostManagementIo, socket::HostOperationId},
wasi::{
imports::{HOST_PENDING, cancel_operation, start_management_call, take_management_call},
wire::common::{host_error, status},
},
};
const MAX_MANAGEMENT_RESULT_LEN: usize = 16 * 1024 * 1024;
#[derive(Clone, Default)]
pub struct WasiHostManagementIo;
impl HostManagementIo for WasiHostManagementIo {
fn submit_call(&self, operation: HostOperationId, request: &[u8]) -> io::Result<()> {
let length = u32::try_from(request.len()).map_err(|_| {
io::Error::new(
io::ErrorKind::InvalidInput,
"management request is too long",
)
})?;
status("start_management_call", unsafe {
start_management_call(operation.0, request.as_ptr() as u32, length)
})
}
fn take_call(&self, operation: HostOperationId) -> Poll<io::Result<Vec<u8>>> {
let required = unsafe { take_management_call(operation.0, 0, 0) };
if required == HOST_PENDING {
return Poll::Pending;
}
if required <= 0 {
return Poll::Ready(Err(host_error("take_management_call", required)));
}
let required = usize::try_from(required).expect("positive i32 fits usize");
if required > MAX_MANAGEMENT_RESULT_LEN {
let _ = unsafe { cancel_operation(operation.0) };
return Poll::Ready(Err(io::Error::new(
io::ErrorKind::InvalidData,
"host management response is too long",
)));
}
let mut response = vec![0; required];
let copied = unsafe {
take_management_call(
operation.0,
response.as_mut_ptr() as u32,
u32::try_from(required).expect("management result limit fits u32"),
)
};
if copied != i32::try_from(required).expect("management result length fits i32") {
let _ = unsafe { cancel_operation(operation.0) };
return Poll::Ready(Err(io::Error::new(
io::ErrorKind::InvalidData,
"host management response length changed",
)));
}
Poll::Ready(Ok(response))
}
fn cancel_operation(&self, operation: HostOperationId) -> io::Result<()> {
status("cancel_operation", unsafe { cancel_operation(operation.0) })
}
}
+2
View File
@@ -3,5 +3,7 @@
pub mod dns;
pub mod environment;
pub mod event;
#[cfg(feature = "management")]
pub mod management;
pub mod packet;
pub mod socket;
+8
View File
@@ -168,6 +168,14 @@ unsafe extern "C" {
/// Copies the resolved local socket address into the fixed-size `result` buffer.
pub(crate) fn take_local_addr_for_remote(operation: u64, result: u32, result_len: u32) -> i32;
/// Starts one process-level WebClient management call after copying its request.
#[cfg(feature = "management")]
pub(crate) fn start_management_call(operation: u64, request: u32, request_len: u32) -> i32;
/// Probes or copies the process-level management response for `operation`.
#[cfg(feature = "management")]
pub(crate) fn take_management_call(operation: u64, result: u32, result_capacity: u32) -> i32;
/// Attempts to deliver one raw IP packet to a host packet sink.
///
/// On success the host owns a complete copy. [`HOST_WOULD_BLOCK`] leaves
+2
View File
@@ -20,5 +20,7 @@ pub(crate) mod runtime_driver;
pub(crate) mod schema;
#[cfg(any(test, target_os = "wasi"))]
pub(crate) mod time;
#[cfg(all(target_os = "wasi", feature = "management"))]
pub(crate) mod web_client;
#[cfg(any(test, target_os = "wasi"))]
pub(crate) mod wire;
+65 -1
View File
@@ -1,9 +1,41 @@
//! Runtime implementation and lifecycle exports for a WASI core instance.
use crate::{
config::toml::TomlConfig, connectivity::connector_host::HostConnectorEnvironmentSnapshot,
config::toml::TomlConfig,
connectivity::connector_host::HostConnectorEnvironmentSnapshot,
gateway::dhcp::{DhcpIpv4ApplyOutcome, DhcpIpv4Host},
instance::{CorePacketPlane, InstanceRuntimeHost},
};
struct WasiInstanceRuntimeHost;
#[async_trait::async_trait]
impl DhcpIpv4Host for WasiInstanceRuntimeHost {
fn take_interface_closed(&self) -> bool {
false
}
async fn apply_dhcp_ipv4(
&self,
_previous: Option<cidr::Ipv4Inet>,
next: Option<cidr::Ipv4Inet>,
) -> DhcpIpv4ApplyOutcome {
DhcpIpv4ApplyOutcome::applied(next)
}
}
#[async_trait::async_trait]
impl InstanceRuntimeHost for WasiInstanceRuntimeHost {
async fn prepare(
&self,
_packet_plane: std::sync::Arc<CorePacketPlane>,
) -> anyhow::Result<Option<std::sync::Arc<dyn DhcpIpv4Host>>> {
Ok(Some(std::sync::Arc::new(WasiInstanceRuntimeHost)))
}
async fn shutdown(&self) {}
}
pub(super) type WasiCore = crate::instance::CoreInstance<
crate::connectivity::connector_host::ConnectorHost<
crate::wasi::adapter::socket::backend::WasiHostSocketBackend,
@@ -63,6 +95,7 @@ pub(super) fn new_wasi_core_runtime(
packet_sink,
));
let mut adapters = CoreHostAdapters::new(host, dns, packet_sink, process_runtime);
adapters.instance_runtime = Arc::new(WasiInstanceRuntimeHost);
adapters.events = Arc::new(WasiHostEventSink::new(event_sink));
let core = CoreInstance::from_toml(config, adapters)?;
@@ -98,9 +131,17 @@ mod abi {
#[cfg(feature = "proxy-smoltcp-stack")]
mod data_plane;
#[cfg(feature = "management-rpc")]
mod rpc;
#[cfg(feature = "management")]
mod web_client;
const MAX_CREATE_CONFIG_LEN: usize = 16 * 1024 * 1024;
const MAX_GUEST_BUFFER_LEN: usize = MAX_CREATE_CONFIG_LEN;
#[cfg(feature = "management-rpc")]
const MAX_RPC_MESSAGE_LEN: usize = 16 * 1024 * 1024;
#[cfg(feature = "management-rpc")]
const MAX_RPC_OPERATIONS: usize = 256;
const INVALID_HANDLE: i32 = -1;
const INVALID_STATE: i32 = -2;
const INVALID_INPUT: i32 = -3;
@@ -135,6 +176,8 @@ mod abi {
struct WasiContext {
factory: WasiInstanceFactory,
instances: RefCell<BTreeMap<uuid::Uuid, Arc<WasiInstance>>>,
#[cfg(feature = "management")]
web_client: RefCell<Option<crate::wasi::web_client::WasiWebClientRuntime>>,
abi: RefCell<WasiAbiState>,
}
@@ -146,6 +189,8 @@ mod abi {
Self {
factory,
instances: RefCell::new(BTreeMap::new()),
#[cfg(feature = "management")]
web_client: RefCell::new(None),
abi: RefCell::new(WasiAbiState::default()),
}
}
@@ -171,6 +216,8 @@ mod abi {
domain: u64,
core: WasiCoreRuntime,
execution: Mutex<WasiExecution>,
#[cfg(feature = "management-rpc")]
rpc_operations: crate::rpc::operation::RpcOperationSession,
_protected_tcp_port_leases: Vec<ProtectedTcpPortLease>,
}
@@ -223,6 +270,19 @@ mod abi {
context.event_sink,
)?
};
#[cfg(feature = "management-rpc")]
let rpc_operations = {
let registry = Arc::new(crate::rpc::service_registry::ServiceRegistry::new());
crate::management::register_bound_management_rpc(
core.core().clone(),
registry.as_ref(),
);
crate::rpc::operation::RpcOperationSession::new(
registry,
MAX_RPC_OPERATIONS,
MAX_RPC_MESSAGE_LEN,
)
};
Ok(Arc::new(WasiInstance {
instance_id,
@@ -235,6 +295,8 @@ mod abi {
start_task: None,
stop_task: None,
}),
#[cfg(feature = "management-rpc")]
rpc_operations,
_protected_tcp_port_leases: protected_tcp_ports,
}))
}
@@ -749,6 +811,8 @@ mod abi {
return INVALID_STATE;
};
let domain = instance.domain;
#[cfg(feature = "management-rpc")]
instance.rpc_operations.discard_all();
{
let _domain = enter_domain(domain);
drop(instance);
+214
View File
@@ -0,0 +1,214 @@
//! Protobuf RPC guest exports bound to one WASI core instance.
use crate::{
rpc::operation::{RpcAccessError, RpcOperationId, RpcSubmitError},
wasi::abi::{RPC_ABI_VERSION, RPC_STATUS_PENDING},
};
use super::{
ASYNC_ERROR, BUSY, INVALID_INPUT, MAX_RPC_MESSAGE_LEN, WasiInstance, read_guest_buffer,
set_instance_error, with_abi_state, with_abi_state_mut, with_instance,
};
const OPERATION_ID_LEN: usize = 8;
impl WasiInstance {
fn submit_rpc(&self, encoded_request: &[u8]) -> Result<RpcOperationId, RpcSubmitError> {
let execution = self.execution.lock().unwrap();
let _domain = crate::foundation::time::enter_domain(self.domain);
let _runtime = execution.runtime.enter();
self.rpc_operations.submit_encoded(encoded_request)
}
}
fn operation_id(raw: u64) -> anyhow::Result<RpcOperationId> {
RpcOperationId::from_raw(raw).ok_or_else(|| anyhow::anyhow!("invalid RPC operation ID"))
}
fn validate_output(pointer: u32, capacity: usize) -> anyhow::Result<()> {
if pointer == 0 {
anyhow::bail!("guest RPC output buffer pointer is zero");
}
with_abi_state(|state| {
let buffer = state
.buffers
.get(&pointer)
.ok_or_else(|| anyhow::anyhow!("unknown guest buffer: {pointer}"))?;
if capacity > buffer.len() {
anyhow::bail!(
"guest RPC output capacity {capacity} exceeds allocation {}",
buffer.len()
);
}
Ok(())
})
}
fn write_output(pointer: u32, bytes: &[u8]) -> anyhow::Result<()> {
with_abi_state_mut(|state| {
let buffer = state
.buffers
.get_mut(&pointer)
.ok_or_else(|| anyhow::anyhow!("unknown guest buffer: {pointer}"))?;
if bytes.len() > buffer.len() {
anyhow::bail!(
"RPC response requires {} bytes, allocation has {}",
bytes.len(),
buffer.len()
);
}
buffer[..bytes.len()].copy_from_slice(bytes);
Ok(())
})
}
fn submit_status(error: &RpcSubmitError) -> i32 {
match error {
RpcSubmitError::AtCapacity | RpcSubmitError::IdExhausted => BUSY,
RpcSubmitError::ExecutorUnavailable => ASYNC_ERROR,
RpcSubmitError::Decode(_) | RpcSubmitError::MissingMethod => INVALID_INPUT,
}
}
#[unsafe(no_mangle)]
pub extern "C" fn easytier_rpc_abi_version() -> u32 {
RPC_ABI_VERSION
}
/// Submits one serialized `common.DirectRpcRequest`.
///
/// The full protobuf method name is mandatory. On success, writes the opaque
/// operation ID as one big-endian `u64` to `output_operation`.
#[unsafe(no_mangle)]
pub extern "C" fn easytier_rpc_request_submit(
handle: u64,
request_pointer: u32,
request_length: u32,
output_operation: u32,
) -> i32 {
with_instance(handle, |instance| {
if let Err(error) = validate_output(output_operation, OPERATION_ID_LEN) {
set_instance_error(handle, error);
return Ok(INVALID_INPUT);
}
let request = match read_guest_buffer(request_pointer, request_length, MAX_RPC_MESSAGE_LEN)
{
Ok(request) => request,
Err(error) => {
set_instance_error(handle, error);
return Ok(INVALID_INPUT);
}
};
let operation = match instance.submit_rpc(&request) {
Ok(operation) => operation,
Err(error) => {
let status = submit_status(&error);
set_instance_error(handle, error);
return Ok(status);
}
};
if let Err(error) = write_output(output_operation, &operation.get().to_be_bytes()) {
instance.rpc_operations.free(operation);
set_instance_error(handle, error);
return Ok(INVALID_INPUT);
}
Ok(0)
})
}
/// Probes or consumes one serialized `common.RpcResponse`.
///
/// Returns [`RPC_STATUS_PENDING`] while the operation is running. Passing
/// `(output, capacity) == (0, 0)` returns the required byte length without
/// consuming the response. If `capacity` is too small, the required length is
/// returned and the response remains available. Otherwise the response is
/// copied, consumed, and its byte length is returned.
#[unsafe(no_mangle)]
pub extern "C" fn easytier_rpc_response_take(
handle: u64,
operation: u64,
output: u32,
capacity: u32,
) -> i32 {
with_instance(handle, |instance| {
let operation = match operation_id(operation) {
Ok(operation) => operation,
Err(error) => {
set_instance_error(handle, error);
return Ok(INVALID_INPUT);
}
};
let required = match instance.rpc_operations.response_len(operation) {
Ok(required) => required,
Err(RpcAccessError::Pending) => return Ok(RPC_STATUS_PENDING),
Err(error) => {
set_instance_error(handle, error);
return Ok(INVALID_INPUT);
}
};
let required_i32 = match i32::try_from(required) {
Ok(required) => required,
Err(_) => {
set_instance_error(handle, "RPC response length exceeds i32");
return Ok(ASYNC_ERROR);
}
};
if output == 0 && capacity == 0 {
return Ok(required_i32);
}
let capacity = usize::try_from(capacity).expect("u32 fits usize on wasm32");
if let Err(error) = validate_output(output, capacity) {
set_instance_error(handle, error);
return Ok(INVALID_INPUT);
}
if capacity < required {
return Ok(required_i32);
}
let mut write_error = None;
let taken = instance
.rpc_operations
.take_response_with(operation, |response| {
if let Err(error) = write_output(output, response) {
write_error = Some(error);
return None;
}
Some(())
});
if let Some(error) = write_error {
set_instance_error(handle, error);
return Ok(INVALID_INPUT);
}
match taken {
Ok(Some(())) => Ok(required_i32),
Ok(None) => {
set_instance_error(handle, "RPC response could not be consumed");
Ok(ASYNC_ERROR)
}
Err(RpcAccessError::Pending) => Ok(RPC_STATUS_PENDING),
Err(error) => {
set_instance_error(handle, error);
Ok(INVALID_INPUT)
}
}
})
}
/// Cancels and discards a pending operation, or discards an untaken response.
#[unsafe(no_mangle)]
pub extern "C" fn easytier_rpc_operation_free(handle: u64, operation: u64) -> i32 {
with_instance(handle, |instance| {
let operation = match operation_id(operation) {
Ok(operation) => operation,
Err(error) => {
set_instance_error(handle, error);
return Ok(INVALID_INPUT);
}
};
if !instance.rpc_operations.free(operation) {
set_instance_error(handle, "unknown RPC operation");
return Ok(INVALID_INPUT);
}
Ok(0)
})
}
@@ -0,0 +1,121 @@
use crate::{
foundation::time::{clear_domain, enter_domain},
wasi::{
schema::WasiWebClientCreateConfig,
web_client::{WEB_CLIENT_DOMAIN, WasiWebClientRuntime},
},
};
use super::{
ASYNC_ERROR, CONTEXT, INVALID_INPUT, INVALID_STATE, MAX_CREATE_CONFIG_LEN, read_guest_buffer,
set_abi_error,
};
fn with_web_client(operation: impl FnOnce(&WasiWebClientRuntime) -> i32) -> i32 {
CONTEXT.with(|context| {
let web_client = context.web_client.borrow();
match web_client.as_ref() {
Some(web_client) => operation(web_client),
None => {
set_abi_error("WebClient is not running");
INVALID_STATE
}
}
})
}
#[unsafe(no_mangle)]
pub extern "C" fn easytier_web_client_create(config_pointer: u32, config_length: u32) -> i32 {
let encoded = match read_guest_buffer(config_pointer, config_length, MAX_CREATE_CONFIG_LEN) {
Ok(encoded) => encoded,
Err(error) => {
set_abi_error(error);
return INVALID_INPUT;
}
};
let config: WasiWebClientCreateConfig = match serde_json::from_slice(&encoded) {
Ok(config) => config,
Err(error) => {
set_abi_error(error);
return INVALID_INPUT;
}
};
if let Err(error) = config.validate() {
set_abi_error(error);
return INVALID_INPUT;
}
let process_runtime = CONTEXT.with(|context| {
if context.web_client.borrow().is_some() {
return None;
}
Some(context.factory.process_runtime.clone())
});
let Some(process_runtime) = process_runtime else {
set_abi_error("WebClient is already running");
return INVALID_STATE;
};
let web_client = match WasiWebClientRuntime::new(config, process_runtime) {
Ok(web_client) => web_client,
Err(error) => {
set_abi_error(error);
return ASYNC_ERROR;
}
};
CONTEXT.with(|context| {
*context.web_client.borrow_mut() = Some(web_client);
});
0
}
#[unsafe(no_mangle)]
pub extern "C" fn easytier_web_client_drive() -> i32 {
with_web_client(|web_client| {
web_client.drive();
0
})
}
#[unsafe(no_mangle)]
pub extern "C" fn easytier_web_client_notify_completions() -> i32 {
with_web_client(|web_client| {
web_client.notify_host_completions();
0
})
}
#[unsafe(no_mangle)]
pub extern "C" fn easytier_web_client_next_deadline_millis() -> i64 {
let mut result = i64::from(INVALID_STATE);
let status = with_web_client(|web_client| {
result = web_client
.next_wait_millis()
.map(|millis| i64::try_from(millis).unwrap_or(i64::MAX))
.unwrap_or(i64::MAX);
0
});
if status == 0 {
result
} else {
i64::from(status)
}
}
#[unsafe(no_mangle)]
pub extern "C" fn easytier_web_client_is_connected() -> i32 {
with_web_client(|web_client| i32::from(web_client.is_connected()))
}
#[unsafe(no_mangle)]
pub extern "C" fn easytier_web_client_drop() -> i32 {
let web_client = CONTEXT.with(|context| context.web_client.borrow_mut().take());
let Some(web_client) = web_client else {
set_abi_error("WebClient is not running");
return INVALID_STATE;
};
{
let _domain = enter_domain(WEB_CLIENT_DOMAIN);
drop(web_client);
}
clear_domain(WEB_CLIENT_DOMAIN);
0
}
+28
View File
@@ -8,6 +8,8 @@ use crate::{
pub(crate) const WASI_CORE_INSTANCE_CONFIG_VERSION: u32 =
crate::wasi::abi::CORE_INSTANCE_CONFIG_VERSION;
#[cfg(all(target_os = "wasi", feature = "management"))]
pub(crate) const WASI_WEB_CLIENT_CONFIG_VERSION: u32 = crate::wasi::abi::WEB_CLIENT_CONFIG_VERSION;
/// Versioned payload accepted by host-driven instance frontends.
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -32,3 +34,29 @@ impl WasiCoreInstanceCreateConfig {
TomlConfig::new_from_str_with_source("WASI create config", &self.config)
}
}
#[cfg(all(target_os = "wasi", feature = "management"))]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct WasiWebClientCreateConfig {
pub version: u32,
pub endpoint: String,
pub machine_id: String,
pub hostname: String,
pub secure_mode: bool,
pub os_type: String,
pub environment: HostConnectorEnvironmentSnapshot,
}
#[cfg(all(target_os = "wasi", feature = "management"))]
impl WasiWebClientCreateConfig {
pub fn validate(&self) -> anyhow::Result<()> {
if self.version != WASI_WEB_CLIENT_CONFIG_VERSION {
anyhow::bail!(
"unsupported host WebClient config version: {}",
self.version
);
}
uuid::Uuid::parse_str(&self.machine_id)?;
Ok(())
}
}
+474
View File
@@ -0,0 +1,474 @@
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use bytes::Bytes;
use prost::Message;
use tokio::runtime::Builder;
use url::Url;
use crate::{
config::{api_input::NetworkConfigExt, toml::ConfigLoader},
connectivity::{
connector_host::{ConnectorHost, new_connector_host},
manual::{
ManualConnectorOptions, ManualTunnelConnector, discovery::ManualEndpointDiscoveryConfig,
},
protocol::{CoreClientProtocolConfig, CoreClientProtocolUpgrader, raw::TunnelDialer},
},
foundation::time::{enter_domain, next_deadline_millis},
host::{dns::HostDnsResolver, management::HostManagementClient, socket::HostSocketRuntime},
management::{
ConfigServerEndpoint, ManagementRpcForwarder, WebClient, WebClientBackend, WebClientConfig,
config_source_from_rpc, register_forwarded_instance_management_rpc,
},
process_runtime::CoreProcessRuntime,
proto::{
api::manage::{
ListNetworkInstanceRequest, NetworkConfig, NetworkingMethod, RunNetworkInstanceRequest,
ValidateConfigRequest, ValidateConfigResponse, WebClientService,
WebClientServiceClient, WebClientServiceDescriptor, WebClientServiceMethodDescriptor,
},
common::{DirectRpcRequest, HostManagementRequest, RpcResponse},
rpc_types::{
controller::BaseController,
descriptor::{MethodDescriptor, ServiceDescriptor},
error,
handler::Handler,
},
web::DeviceOsInfo,
},
rpc::service_registry::ServiceRegistry,
socket::IpVersion,
tunnel::Tunnel,
wasi::{
adapter::{
dns::WasiHostDnsIo, environment::WasiHostConnectorEnvironmentIo,
management::WasiHostManagementIo, socket::backend::WasiHostSocketBackend,
},
runtime_driver::{RuntimeDriveOutcome, RuntimeDriver},
schema::WasiWebClientCreateConfig,
},
};
pub(super) const WEB_CLIENT_DOMAIN: u64 = u64::MAX;
type WasiConnectorHost = ConnectorHost<WasiHostSocketBackend, WasiHostConnectorEnvironmentIo>;
fn supports_hosted_tunnel_url(value: &str) -> bool {
Url::parse(value).is_ok_and(|url| matches!(url.scheme(), "tcp" | "udp"))
}
fn hosted_network_config(config: &NetworkConfig) -> NetworkConfig {
let public_server_url = config
.public_server_url
.as_ref()
.filter(|url| supports_hosted_tunnel_url(url))
.cloned();
let peers = config
.peers
.iter()
.filter(|peer| supports_hosted_tunnel_url(&peer.uri))
.cloned()
.collect::<Vec<_>>();
let networking_method =
match NetworkingMethod::try_from(config.networking_method.unwrap_or_default()) {
Ok(NetworkingMethod::PublicServer)
if public_server_url.is_none() && peers.is_empty() =>
{
NetworkingMethod::Standalone
}
Ok(method) => method,
Err(_) => NetworkingMethod::Standalone,
};
NetworkConfig {
instance_id: config.instance_id.clone(),
dhcp: config.dhcp,
virtual_ipv4: config.virtual_ipv4.clone(),
network_length: config.network_length,
hostname: config.hostname.clone(),
network_name: config.network_name.clone(),
network_secret: config.network_secret.clone(),
networking_method: Some(networking_method as i32),
public_server_url,
peer_urls: config
.peer_urls
.iter()
.filter(|url| supports_hosted_tunnel_url(url))
.cloned()
.collect(),
proxy_cidrs: config.proxy_cidrs.clone(),
listener_urls: config
.listener_urls
.iter()
.filter(|url| supports_hosted_tunnel_url(url))
.cloned()
.collect(),
latency_first: config.latency_first,
disable_ipv6: config.disable_ipv6,
disable_p2p: config.disable_p2p,
no_tun: config.no_tun,
relay_all_peer_rpc: config.relay_all_peer_rpc,
enable_relay_network_whitelist: config.enable_relay_network_whitelist,
relay_network_whitelist: config.relay_network_whitelist.clone(),
disable_encryption: config.disable_encryption,
disable_udp_hole_punching: config.disable_udp_hole_punching,
mtu: config.mtu,
enable_private_mode: config.enable_private_mode,
disable_sym_hole_punching: config.disable_sym_hole_punching,
p2p_only: config.p2p_only,
disable_tcp_hole_punching: config.disable_tcp_hole_punching,
secure_mode: config.secure_mode.clone(),
acl: config.acl.clone(),
port_forwards: config.port_forwards.clone(),
lazy_p2p: config.lazy_p2p,
need_p2p: config.need_p2p,
instance_recv_bps_limit: config.instance_recv_bps_limit,
disable_upnp: config.disable_upnp,
disable_relay_data: config.disable_relay_data,
enable_udp_broadcast_relay: config.enable_udp_broadcast_relay,
peers,
..Default::default()
}
}
struct WasiConfigServerConnector {
url: Url,
connector: ManualTunnelConnector<WasiConnectorHost>,
}
#[async_trait]
impl TunnelDialer for WasiConfigServerConnector {
async fn connect(&self) -> anyhow::Result<Box<dyn Tunnel>> {
self.connector
.connect(self.url.clone(), IpVersion::Both)
.await
}
fn remote_url(&self) -> Url {
self.url.clone()
}
}
#[derive(Clone)]
struct HostManagementHandler {
client: HostManagementClient<WasiHostManagementIo>,
}
impl HostManagementHandler {
async fn forward(
&self,
full_method_name: String,
request: Bytes,
prepared_config: Option<String>,
prepared_instance_id: Option<uuid::Uuid>,
) -> error::Result<Bytes> {
let request = HostManagementRequest {
rpc: Some(DirectRpcRequest {
full_method_name,
request: request.into(),
timeout_ms: None,
}),
prepared_config,
prepared_instance_id: prepared_instance_id.map(Into::into),
};
let response = self
.client
.call(&request.encode_to_vec())
.await
.map_err(|error| error::Error::ExecutionError(error.into()))?;
let response = RpcResponse::decode(response.as_slice())?;
if let Some(error) = response.error {
return Err((&error).into());
}
Ok(response.response.into())
}
}
#[async_trait]
impl ManagementRpcForwarder for HostManagementHandler {
async fn forward(&self, full_method_name: String, input: Bytes) -> error::Result<Bytes> {
HostManagementHandler::forward(self, full_method_name, input, None, None).await
}
}
#[async_trait]
impl Handler for HostManagementHandler {
type Descriptor = WebClientServiceDescriptor;
type Controller = BaseController;
async fn call(
&self,
_: Self::Controller,
method: WebClientServiceMethodDescriptor,
input: Bytes,
) -> error::Result<Bytes> {
let full_method_name = format!(
"{}.{}.{}",
WebClientServiceDescriptor.package(),
WebClientServiceDescriptor.proto_name(),
method.proto_name()
);
match method {
WebClientServiceMethodDescriptor::ValidateConfig => {
let request = ValidateConfigRequest::decode(input)?;
let network_config = request.config.unwrap_or_default();
let config = hosted_network_config(&network_config).gen_config()?;
Ok(ValidateConfigResponse {
toml_config: config.dump(),
}
.encode_to_vec()
.into())
}
WebClientServiceMethodDescriptor::RunNetworkInstance => {
let request = RunNetworkInstanceRequest::decode(input.clone())?;
let network_config = request
.config
.as_ref()
.ok_or_else(|| anyhow::anyhow!("config is required"))?;
let config = hosted_network_config(&network_config).gen_config()?;
let instance_id = request
.inst_id
.map(Into::into)
.unwrap_or_else(|| config.get_id());
config.set_id(instance_id);
config.set_network_config_source(config_source_from_rpc(request.source));
self.forward(
full_method_name,
input,
Some(config.dump()),
Some(instance_id),
)
.await
}
_ => self.forward(full_method_name, input, None, None).await,
}
}
}
struct WasiWebClientBackend {
handler: HostManagementHandler,
}
#[async_trait]
impl WebClientBackend for WasiWebClientBackend {
fn register(&self, registry: &ServiceRegistry) {
registry.register(self.handler.clone(), "");
register_forwarded_instance_management_rpc(self.handler.clone(), registry);
}
async fn instance_ids(&self) -> anyhow::Result<Vec<uuid::Uuid>> {
let response = WebClientServiceClient::new(self.handler.clone())
.list_network_instance(BaseController::default(), ListNetworkInstanceRequest {})
.await?;
Ok(response.inst_ids.into_iter().map(Into::into).collect())
}
}
pub(super) struct WasiWebClientRuntime {
socket_runtime: HostSocketRuntime,
client: WebClient<()>,
execution: Mutex<WasiWebClientExecution>,
}
struct WasiWebClientExecution {
runtime: tokio::runtime::Runtime,
runtime_driver: RuntimeDriver,
drive_again: bool,
}
impl WasiWebClientRuntime {
pub(super) fn new(
config: WasiWebClientCreateConfig,
process_runtime: Arc<CoreProcessRuntime>,
) -> anyhow::Result<Self> {
let endpoint = ConfigServerEndpoint::parse(&config.endpoint, |url| {
matches!(url.scheme(), "tcp" | "udp")
})?;
let machine_id = uuid::Uuid::parse_str(&config.machine_id)?;
let runtime_driver = RuntimeDriver::default();
let park_driver = runtime_driver.clone();
let runtime = Builder::new_current_thread()
.enable_time()
.on_thread_park(move || park_driver.on_thread_park())
.build()?;
let socket_runtime = HostSocketRuntime::new();
let client = {
let _domain = enter_domain(WEB_CLIENT_DOMAIN);
let _runtime = runtime.enter();
let host = Arc::new(new_connector_host(
socket_runtime.clone(),
Arc::new(WasiHostSocketBackend::default()),
config.environment,
Arc::new(WasiHostConnectorEnvironmentIo),
));
let dns = Arc::new(HostDnsResolver::new(
socket_runtime.clone(),
Arc::new(WasiHostDnsIo),
));
let connector = process_runtime.manual_connector(
host,
dns.clone(),
dns,
Arc::new(CoreClientProtocolUpgrader::new(
CoreClientProtocolConfig::default(),
)),
ManualEndpointDiscoveryConfig::default(),
ManualConnectorOptions::default(),
);
let backend = Arc::new(WasiWebClientBackend {
handler: HostManagementHandler {
client: HostManagementClient::new(
socket_runtime.clone(),
Arc::new(WasiHostManagementIo),
),
},
});
WebClient::with_backend(
WasiConfigServerConnector {
url: endpoint.connect_url().clone(),
connector,
},
WebClientConfig {
token: endpoint.token().to_owned(),
machine_id,
hostname: config.hostname,
device_os: DeviceOsInfo {
os_type: config.os_type,
version: String::new(),
distribution: String::new(),
},
easytier_version: env!("CARGO_PKG_VERSION").to_owned(),
secure_mode: config.secure_mode,
},
backend,
)
};
Ok(Self {
socket_runtime,
client,
execution: Mutex::new(WasiWebClientExecution {
runtime,
runtime_driver,
drive_again: false,
}),
})
}
pub(super) fn drive(&self) {
let _domain = enter_domain(WEB_CLIENT_DOMAIN);
let advance_timers = next_deadline_millis(WEB_CLIENT_DOMAIN) == Some(0);
let mut execution = self.execution.lock().unwrap();
execution.drive_again = execution
.runtime_driver
.drive(&execution.runtime, advance_timers)
== RuntimeDriveOutcome::BudgetExhausted;
}
pub(super) fn notify_host_completions(&self) {
self.socket_runtime.notify_completions();
}
pub(super) fn next_wait_millis(&self) -> Option<u64> {
let execution = self.execution.lock().unwrap();
if execution.drive_again {
Some(0)
} else {
next_deadline_millis(WEB_CLIENT_DOMAIN)
}
}
pub(super) fn is_connected(&self) -> bool {
self.client.is_connected()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::proto::{
api::manage::NetworkPeerConfig,
common::{CompressionAlgoPb, SecureModeConfig},
};
#[test]
fn hosted_config_keeps_supported_fields_and_filters_tunnel_urls() {
let original = NetworkConfig {
instance_id: Some(uuid::Uuid::new_v4().to_string()),
dhcp: Some(true),
network_name: Some("network".to_owned()),
network_secret: Some("secret".to_owned()),
networking_method: Some(NetworkingMethod::Manual as i32),
peer_urls: vec![
"tcp://peer.example:11010".to_owned(),
"wg://peer.example:11011".to_owned(),
],
listener_urls: vec![
"udp://0.0.0.0:11010".to_owned(),
"wg://0.0.0.0:11011".to_owned(),
],
peers: vec![
NetworkPeerConfig {
uri: "tcp://peer.example:11010".to_owned(),
peer_public_key: Some("key".to_owned()),
},
NetworkPeerConfig {
uri: "quic://peer.example:11010".to_owned(),
peer_public_key: None,
},
],
secure_mode: Some(SecureModeConfig {
enabled: true,
..Default::default()
}),
enable_private_mode: Some(true),
disable_relay_data: Some(true),
proxy_cidrs: vec!["10.88.0.0/24".to_owned()],
port_forwards: vec![crate::proto::api::manage::PortForwardConfig {
proto: "tcp".to_owned(),
bind_ip: "127.0.0.1".to_owned(),
bind_port: 18080,
dst_ip: "10.88.0.2".to_owned(),
dst_port: 80,
}],
enable_vpn_portal: Some(true),
data_compress_algo: Some(CompressionAlgoPb::Zstd as i32),
credential_file: Some("/unsupported".to_owned()),
..Default::default()
};
let hosted = hosted_network_config(&original);
assert_eq!(
hosted.peer_urls,
vec!["tcp://peer.example:11010".to_owned()]
);
assert_eq!(hosted.listener_urls, vec!["udp://0.0.0.0:11010".to_owned()]);
assert_eq!(hosted.peers, original.peers[..1]);
assert_eq!(hosted.secure_mode, original.secure_mode);
assert_eq!(hosted.enable_private_mode, Some(true));
assert_eq!(hosted.disable_relay_data, Some(true));
assert_eq!(hosted.proxy_cidrs, original.proxy_cidrs);
assert_eq!(hosted.port_forwards, original.port_forwards);
assert_eq!(hosted.enable_vpn_portal, None);
assert_eq!(hosted.data_compress_algo, None);
assert_eq!(hosted.credential_file, None);
assert_eq!(original.listener_urls.len(), 2);
}
#[test]
fn hosted_config_falls_back_to_standalone_without_a_supported_public_peer() {
let hosted = hosted_network_config(&NetworkConfig {
networking_method: Some(NetworkingMethod::PublicServer as i32),
public_server_url: Some("wg://peer.example:11010".to_owned()),
..Default::default()
});
assert_eq!(
hosted.networking_method,
Some(NetworkingMethod::Standalone as i32)
);
assert_eq!(hosted.public_server_url, None);
}
}