feat(wasi): run EasyTier core on Cloudflare Workers and browsers (#2548)

* fix(core): normalize secure keys for TOML instances

* feat(wasi): run core behind Cloudflare WebSockets

Introduce the Cloudflare Worker WASI host that runs the EasyTier core
behind host-upgraded WebSockets.

- Worker package scaffold (wrangler Durable Object, build-wasm script,
  vitest config) and core-runtime/websocket-host/data-plane runtime.
- WASI host WebSocket tunnel ABI (imports, adapter, runtime exports)
  with bounded receive memory and bounded admission queue.
- Route host sockets through the portable listener plan
  (HostListenerRegistration, listener queue, admission handler split).
- Build the WASM guest with the aes-gcm feature so secure peer
  sessions have their cipher available.

* feat(wasi): add outbound browser client runtime

Add the outbound-only WASI runtime and browser connector host so
browser pages can dial EasyTier peers through WebSocket relays.

- CoreConnectivityMode::{OutboundOnly, InboundOnly} gating for
  listeners, discovery, and direct connectivity modules.
- ExternalTunnelConnector plumbing through composite/connector_host/
  manual for browser WebSocket dials.
- Browser/Node smoke entries with shared helpers
  (smoke-shared.ts).

* feat(wasi): extend browser data plane with TCP half-close

Add the data-plane pieces the browser runtime needs for full-duplex
TCP streams behind host WebSockets:

- Guest TCP shutdown_write operation with submit/take ABI pair
  (DATA_PLANE_ABI_VERSION 3 -> 4) and smoltcp half-close support.
- Worker data-plane TCP listener/stream plumbing and core-runtime
  listener registration.
- Unit coverage for the new session ops and listener wiring.

* refactor(wasi): make host tunnel ABI transport-neutral

Replace WebSocket-specific core and WASI boundaries with a
message-oriented Host Tunnel interface. Keep WebSocket framing and text
rejection in the Cloudflare host while preserving payload boundaries,
ownership, cancellation, backpressure, and EOF behavior.

Rename feature flags and guest imports and exports to the Host Tunnel
ABI. Update both Worker profiles, tests, and architecture documentation.

* feat(web): split WASI hosts into publishable npm packages

Extract the shared JSPI, WASI, Host Tunnel, and data-plane runtime
into @easytier/runtime. Keep ABI handles, guest memory, TOML, and
operation broker details behind its adapter entry point.

Add typed, auto-starting @easytier/browser and factory-based
@easytier/cloudflare packages. Ship a matching Wasm profile with
each platform package and validate its capabilities before packing.

Persist Cloudflare instance identity in Durable Object storage,
centralize WebSocket admission ownership, and add package-level
coverage for the public interfaces.

* fix(web): make public packages portable

Embed the browser Wasm artifact in the published JavaScript entry
point. This lets esbuild consumers bundle the package without an asset
loader or a copied file.

Return Cloudflare's nominal Durable Object base type and document the
named subclass export required by generated Wrangler bindings.

* docs(web): add public package walkthrough

Expand both package READMEs with installation, configuration, local
validation, health checks, and deployment instructions.

Add a standalone Vite and Wrangler example that imports only the
public Browser and Cloudflare entries. Generate Worker bindings from
configuration and keep local secrets outside version control.

* chore(go): import EasyTier Go host

Add the standalone Go host runtime as a monorepo subtree without
carrying its development branch ancestry.

Preserve its API, tests, examples, generated protobuf bindings, and
embedded WASI artifacts.

* refactor(hosts): colocate Go and JavaScript runtimes

Move the browser, Cloudflare, shared runtime, and web example into
the easytier-js subtree. Update workspace metadata, build paths, and
documentation for the new layout.

Adopt github.com/EasyTier/EasyTier/easytier-go as the Go module path.
Resolve artifact and protobuf generation from the enclosing monorepo.

* build(web): isolate JavaScript host workspace

Keep public browser and Cloudflare packages outside the legacy frontend
workspace so root installs and cross-platform builds do not pull workerd.

Make each package build generate its required WASI artifact from a clean
checkout. Add a dedicated workflow that runs the same install and check
commands documented for contributors.

Move JavaScript dependencies into a scoped lockfile and restore the root
workspace lockfile to its pre-host state.
This commit is contained in:
KKRainbow
2026-09-06 13:35:02 +08:00
committed by GitHub
parent 56be71c7f9
commit f26c2aa147
215 changed files with 45277 additions and 144 deletions
+62
View File
@@ -0,0 +1,62 @@
name: EasyTier JavaScript Hosts
on:
push:
branches: ["develop", "main", "releases/**"]
paths:
- "Cargo.toml"
- "Cargo.lock"
- "easytier-core/**"
- "easytier-proto/**"
- "easytier-js/**"
- ".github/workflows/js.yml"
pull_request:
branches: ["develop", "main"]
types: [opened, synchronize, reopened, ready_for_review]
paths:
- "Cargo.toml"
- "Cargo.lock"
- "easytier-core/**"
- "easytier-proto/**"
- "easytier-js/**"
- ".github/workflows/js.yml"
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: 1.95
target: wasm32-wasip1
cache: false
rustflags: ''
- uses: arduino/setup-protoc@v3
with:
version: '35.1'
repo-token: ${{ github.token }}
- uses: actions/setup-node@v5
with:
node-version: 22
- uses: pnpm/action-setup@v5
with:
version: 10
run_install: false
- name: Install dependencies
run: pnpm --dir easytier-js install --frozen-lockfile
- name: Build and test
run: pnpm --dir easytier-js check
+13
View File
@@ -90,3 +90,16 @@ A compact compatibility Host retains accepted values in the authoritative TOML
model for management readback, while the shared host-aware normalization path
omits capabilities that the compact runtime cannot execute. Omitted settings
are silent no-ops and must not be advertised as live network capabilities.
## Web compatibility Host
The Web compatibility Host runs the portable EasyTier guest in JavaScript
runtimes that provide WebAssembly JSPI. Its shared runtime Module owns guest
lifecycle, Host capability operations, data-plane resources, and WebSocket
message handling. Browser and Cloudflare Adapters own only the platform-specific
way that WebSockets are dialed or accepted and the matching guest artifact.
The Browser Adapter is an outbound-only EasyTier instance with a smoltcp TCP
data plane. The Cloudflare Adapter is an inbound-only relay hosted by one named
Durable Object. Their public configuration exposes only capabilities each Host
can execute; guest ABI details and serialized TOML remain internal.
+16 -1
View File
@@ -184,12 +184,27 @@ wire codecs. It does not own socket I/O or connection policy.
- DNS and DNS record resolution;
- connector environment observations;
- packet ingress and egress;
- Host socket operation bridges and handle-based TCP/UDP/listener adapters.
- Host socket operation bridges and handle-based TCP/UDP/listener adapters;
- host-owned, message-preserving tunnel endpoints.
Core owns scheduling, backpressure, cancellation, UDP session state, and
protocol state even when each actual operation crosses a Host Adapter. A Host
Adapter owns the real resource and performs the OS operation.
For host-owned tunnels, ownership crosses the guest ABI only after a bounded
Host listener queue accepts the tunnel. The queue is registered through
`CoreHostAdapters` and consumed by the normal `CoreListenerRuntime`, so the
tunnel still reaches `PeerAcceptedTunnelHandler` and cannot bypass peer
handshake, admission, events, or routing policy. Each Host receive produces
one complete `DummyTunnel` payload; no stream framing is added. The Host owns
transport-specific message validation and maps a clean close to tunnel EOF.
Hosts without outbound sockets select `CoreConnectivityMode::InboundOnly` for
one instance. That startup plan retains listeners and the peer/router while
omitting STUN, outbound connectors, and hole punching. Cargo features only
compile Host Adapters; they do not change `CoreInstance` fields, lifecycle, or
management semantics.
The native `NativeHostRuntime` is process-wide and does not retain an instance
`GlobalCtx`, namespace guard, socket mark, or connectivity state. Differences
between instances travel in each request's `SocketContext`. A narrow
+7 -7
View File
@@ -5,9 +5,8 @@
Accepted.
This document is the implementation plan for restructuring the EasyTier data
plane and exposing it through native FFI and the standalone
`easytier-go-host` project. It describes a target architecture, not the current
implementation.
plane and exposing it through native FFI and the `easytier-go` module. It
describes a target architecture, not the current implementation.
The implementation scope is:
@@ -16,7 +15,7 @@ The implementation scope is:
backend;
- `easytier-contrib/easytier-ffi`;
- the WASI guest ABI implemented by `easytier-core`;
- `/data/project/easytier-go-host`;
- `easytier-go`;
- TCP, UDP, and smoltcp data-plane paths;
- moving KCP route selection and source-connection ownership below the
`DataPlaneRuntime` Interface without making KCP portable.
@@ -987,7 +986,7 @@ Repository: EasyTier.
### Phase 8: Go `coreabi` and engine
Repository: `easytier-go-host`.
Repository: EasyTier (`easytier-go`).
- Add typed data-plane guest calls and wire codecs.
- Extend the single driver with submit, cancel, close, and completion drain.
@@ -996,7 +995,7 @@ Repository: `easytier-go-host`.
### Phase 9: Go standard network Adapters and artifact
Repository: `easytier-go-host`.
Repository: EasyTier (`easytier-go`).
- Add `Dial`, `Listen`, and `ListenPacket`.
- Implement TCP, UDP, deadlines, cancellation, close, and error mapping.
@@ -1006,7 +1005,8 @@ Repository: `easytier-go-host`.
- Run real two-instance TCP and UDP integration tests.
Each phase ends in a reviewable commit. Commit messages use a 72-column text
width. The complete task receives one final review across both repositories.
width. The complete task receives one final review across both implementation
areas.
The operation-broker commit may receive an additional high-risk incremental
review because it contains concurrency logic.
+2
View File
@@ -146,6 +146,8 @@ proxy-smoltcp-stack = [
"smoltcp/proto-ipv6",
"smoltcp/async",
]
wasm-host-tunnel = []
wasm-host-tunnel-outbound = ["wasm-host-tunnel"]
test-utils = []
tracing-log = ["tracing/log"]
zstd = ["dep:zstd"]
@@ -103,6 +103,17 @@ impl InterfaceAddrCache {
/// Mechanical connector operations supplied by one process-wide runtime.
#[async_trait]
pub trait ConnectorRuntime: VirtualTcpSocketFactory + Send + Sync + 'static {
fn supports_external_tunnel(&self, _scheme: &str) -> bool {
false
}
async fn connect_external_tunnel(
&self,
_url: &Url,
) -> anyhow::Result<Option<Box<dyn crate::tunnel::Tunnel>>> {
Ok(None)
}
async fn connect_byte_stream(
&self,
url: &Url,
@@ -207,6 +218,17 @@ where
S: ConnectorRuntime + VirtualUdpSocketFactory,
E: ConnectorEnvironment,
{
fn supports_external_tunnel(&self, scheme: &str) -> bool {
self.sockets.supports_external_tunnel(scheme)
}
async fn connect_external_tunnel(
&self,
url: &Url,
) -> anyhow::Result<Option<Box<dyn crate::tunnel::Tunnel>>> {
self.sockets.connect_external_tunnel(url).await
}
async fn local_addr_for_remote(
&self,
remote_addr: SocketAddr,
@@ -24,6 +24,7 @@ use url::Url;
use crate::{
connectivity::{
composite::{ConnectorEnvironment, ConnectorHostAdapter, ConnectorRuntime},
manual::ExternalTunnelConnector,
transport::ConnectedByteStream,
},
host::environment::{HostConnectorEnvironmentIo, local_addr_for_remote},
@@ -113,6 +114,7 @@ where
listeners: HostTcpListenerFactory<B>,
environment: Arc<HostConnectorEnvironmentSnapshot>,
environment_io: Arc<E>,
external_tunnel_connector: Option<Arc<dyn ExternalTunnelConnector>>,
}
impl<B, E> HostConnectorRuntime<B, E>
@@ -132,8 +134,17 @@ where
listeners: HostTcpListenerFactory::new(runtime, backend),
environment: Arc::new(environment),
environment_io,
external_tunnel_connector: None,
}
}
pub fn with_external_tunnel_connector(
mut self,
connector: Arc<dyn ExternalTunnelConnector>,
) -> Self {
self.external_tunnel_connector = Some(connector);
self
}
}
#[async_trait]
@@ -181,6 +192,25 @@ where
B: ConnectorHostSocketBackend,
E: HostConnectorEnvironmentIo,
{
fn supports_external_tunnel(&self, scheme: &str) -> bool {
self.external_tunnel_connector
.as_ref()
.is_some_and(|connector| connector.supports_scheme(scheme))
}
async fn connect_external_tunnel(
&self,
url: &Url,
) -> anyhow::Result<Option<Box<dyn crate::tunnel::Tunnel>>> {
let Some(connector) = &self.external_tunnel_connector else {
return Ok(None);
};
if !connector.supports_scheme(url.scheme()) {
return Ok(None);
}
Ok(Some(connector.connect(url).await?))
}
async fn connect_byte_stream(
&self,
url: &Url,
@@ -257,6 +287,24 @@ where
ConnectorHostAdapter::new(runtime.clone(), runtime)
}
pub fn new_connector_host_with_external_tunnel<B, E>(
socket_runtime: HostSocketRuntime,
backend: Arc<B>,
environment: HostConnectorEnvironmentSnapshot,
environment_io: Arc<E>,
connector: Arc<dyn ExternalTunnelConnector>,
) -> ConnectorHost<B, E>
where
B: ConnectorHostSocketBackend,
E: HostConnectorEnvironmentIo,
{
let runtime = Arc::new(
HostConnectorRuntime::new(socket_runtime, backend, environment, environment_io)
.with_external_tunnel_connector(connector),
);
ConnectorHostAdapter::new(runtime.clone(), runtime)
}
#[cfg(test)]
mod tests {
use std::{
@@ -299,6 +347,19 @@ mod tests {
struct FixedStunProvider;
struct ExternalFailingConnector;
#[async_trait]
impl ExternalTunnelConnector for ExternalFailingConnector {
fn supports_scheme(&self, scheme: &str) -> bool {
scheme == "ws"
}
async fn connect(&self, _url: &Url) -> anyhow::Result<Box<dyn crate::tunnel::Tunnel>> {
anyhow::bail!("external connector called")
}
}
#[async_trait]
impl StunInfoProvider for FixedStunProvider {
fn get_stun_info(&self) -> StunInfo {
@@ -579,6 +640,34 @@ mod tests {
);
}
#[tokio::test]
async fn delegates_external_tunnel_connections() {
let host = new_connector_host_with_external_tunnel(
HostSocketRuntime::new(),
Arc::new(UnsupportedBackend::default()),
test_environment_snapshot(),
Arc::new(TestEnvironmentIo::default()),
Arc::new(ExternalFailingConnector),
);
let error = ManualConnectorHost::connect_external_tunnel(
&host,
&"ws://relay.example/".parse().unwrap(),
)
.await
.unwrap_err();
assert_eq!(error.to_string(), "external connector called");
assert!(
ManualConnectorHost::connect_external_tunnel(
&host,
&"tcp://relay.example:11010".parse().unwrap(),
)
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn direct_rpc_projects_host_observations_without_instance_policy() {
let host = Arc::new(new_connector_host(
+52 -13
View File
@@ -94,6 +94,14 @@ pub struct ManualInterfaceAddrs {
#[async_trait]
pub trait ManualConnectorHost: VirtualTcpSocketFactory + VirtualUdpSocketFactory {
fn supports_external_tunnel(&self, _scheme: &str) -> bool {
false
}
async fn connect_external_tunnel(&self, _url: &Url) -> anyhow::Result<Option<Box<dyn Tunnel>>> {
Ok(None)
}
async fn local_addr_for_remote(
&self,
remote_addr: SocketAddr,
@@ -110,6 +118,13 @@ pub trait ManualConnectorHost: VirtualTcpSocketFactory + VirtualUdpSocketFactory
}
}
#[async_trait]
pub trait ExternalTunnelConnector: Send + Sync + 'static {
fn supports_scheme(&self, scheme: &str) -> bool;
async fn connect(&self, url: &Url) -> anyhow::Result<Box<dyn Tunnel>>;
}
#[async_trait]
pub(crate) trait ManualEndpointResolver: Send + Sync + 'static {
async fn resolve_endpoint(&self, url: &Url) -> anyhow::Result<Url>;
@@ -182,6 +197,14 @@ where
));
}
if let Some(tunnel) = self.host.connect_external_tunnel(&endpoint.url).await? {
return Ok(apply_resolved_endpoint_info(
tunnel,
requested_url,
endpoint.tunnel_prefixes,
));
}
if !self.protocol.supports_scheme(endpoint.url.scheme()) {
anyhow::bail!(
"unsupported client protocol upgrader: {}",
@@ -710,17 +733,20 @@ where
return Err(error);
}
};
let ip_versions = match resolve_reconnect_ip_versions(
&normalized_url,
connect_timeout,
ManualTransport::from_url(&normalized_url)
.ok()
.map(|transport| data.options.socket_context(transport, IpVersion::Both))
.unwrap_or_default(),
data.dns.as_ref(),
)
.await
{
let ip_versions = match if data.host.supports_external_tunnel(normalized_url.scheme()) {
Ok(vec![IpVersion::Both])
} else {
resolve_reconnect_ip_versions(
&normalized_url,
connect_timeout,
ManualTransport::from_url(&normalized_url)
.ok()
.map(|transport| data.options.socket_context(transport, IpVersion::Both))
.unwrap_or_default(),
data.dns.as_ref(),
)
.await
} {
Ok(ip_versions) => ip_versions,
Err(error) => {
emit_connect_error(&data, &url, IpVersion::Both, &error);
@@ -770,13 +796,17 @@ where
),
)
.await?;
if endpoint.url.scheme() != "ring" && !data.protocol.supports_scheme(endpoint.url.scheme()) {
let uses_external_tunnel = data.host.supports_external_tunnel(endpoint.url.scheme());
if endpoint.url.scheme() != "ring"
&& !uses_external_tunnel
&& !data.protocol.supports_scheme(endpoint.url.scheme())
{
anyhow::bail!(
"unsupported client protocol upgrader: {}",
endpoint.url.scheme()
);
}
let transport = (endpoint.url.scheme() != "ring")
let transport = (endpoint.url.scheme() != "ring" && !uses_external_tunnel)
.then(|| ManualTransport::from_url(&endpoint.url))
.transpose()?;
let resolved = match transport {
@@ -822,6 +852,15 @@ where
if endpoint.url.scheme() == "ring" {
return connect_ring_tunnel(&data.ring_registry, &endpoint.url);
}
if uses_external_tunnel {
return data
.host
.connect_external_tunnel(&endpoint.url)
.await?
.ok_or_else(|| {
anyhow::anyhow!("host did not provide external tunnel for {}", endpoint.url)
});
}
let transport = transport.expect("non-Ring endpoint should have a transport");
let connected = match resolved {
Some((remote_addr, bind_addrs)) => {
@@ -53,6 +53,7 @@ pub enum DataPlaneOperationKind {
UdpBind = 6,
UdpReceive = 7,
UdpSend = 8,
TcpShutdownWrite = 9,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -100,6 +101,7 @@ pub enum DataPlaneOperationResult {
TcpWritten {
len: usize,
},
TcpWriteShutdown,
UdpBound {
socket: DataPlaneResourceId,
local_addr: SocketAddr,
@@ -136,6 +138,7 @@ impl DataPlaneOperationResult {
Self::UdpBound { socket, .. } => Some(*socket),
Self::TcpRead { .. }
| Self::TcpWritten { .. }
| Self::TcpWriteShutdown
| Self::UdpReceived { .. }
| Self::UdpSent { .. } => None,
}
@@ -168,6 +168,7 @@ enum PendingOperationResult {
eof: bool,
},
TcpWritten(usize),
TcpWriteShutdown,
UdpBound(DataPlaneUdpSocket),
UdpReceived {
data: Vec<u8>,
@@ -624,6 +625,36 @@ where
Ok(operation_id)
}
pub fn submit_tcp_shutdown_write(
self: &Arc<Self>,
stream_id: DataPlaneResourceId,
) -> DataPlaneResult<DataPlaneOperationId> {
Self::ensure_executor()?;
let (stream, operation_id, cancel) = {
let mut state = self.lock_state();
let stream = Self::require_tcp(&state, stream_id)?;
let (operation_id, cancel) = self.admit_locked(
&mut state,
DataPlaneOperationKind::TcpShutdownWrite,
Some(stream_id),
0,
false,
)?;
(stream, operation_id, cancel)
};
self.spawn_operation(operation_id, async move {
stream
.write_deadline
.run(cancel, async {
stream.write.lock().await.shutdown().await?;
Ok::<_, std::io::Error>(())
})
.await?;
Ok(PendingOperationResult::TcpWriteShutdown)
});
Ok(operation_id)
}
pub fn submit_udp_bind(
self: &Arc<Self>,
local_port: u16,
@@ -880,6 +911,7 @@ where
DataPlaneOperationResult::TcpRead { data, eof }
}
PendingOperationResult::TcpWritten(len) => DataPlaneOperationResult::TcpWritten { len },
PendingOperationResult::TcpWriteShutdown => DataPlaneOperationResult::TcpWriteShutdown,
PendingOperationResult::UdpBound(socket) => {
let local_addr = socket.local_addr();
let socket = Self::insert_udp_resource_locked(resources, socket)?;
@@ -327,6 +327,55 @@ async fn data_plane_sessions_complete_tcp_operations_end_to_end() {
assert_eq!(written, 4);
assert_eq!(received, b"ping");
let eof_read = session_b.submit_tcp_read(server, 16).unwrap();
let shutdown = session_a.submit_tcp_shutdown_write(client).unwrap();
let shutdown_completion = wait_for_session_completion(&session_a).await;
let eof_completion = wait_for_session_completion(&session_b).await;
assert_eq!(shutdown_completion.operation_id, shutdown);
assert_eq!(eof_completion.operation_id, eof_read);
session_a
.take_result_with(shutdown, |outcome| match outcome {
Ok(DataPlaneOperationResult::TcpWriteShutdown) => Some(()),
_ => None,
})
.unwrap()
.unwrap();
let eof = session_b
.take_result_with(eof_read, |outcome| match outcome {
Ok(DataPlaneOperationResult::TcpRead { data, eof }) => Some((data.clone(), *eof)),
_ => None,
})
.unwrap()
.unwrap();
assert_eq!(eof, (Vec::new(), true));
let response_read = session_a.submit_tcp_read(client, 16).unwrap();
let response_write = session_b
.submit_tcp_write(server, b"pong".to_vec())
.unwrap();
let (response_read_completion, response_write_completion) = tokio::join!(
wait_for_session_completion(&session_a),
wait_for_session_completion(&session_b),
);
assert_eq!(response_read_completion.operation_id, response_read);
assert_eq!(response_write_completion.operation_id, response_write);
let response = session_a
.take_result_with(response_read, |outcome| match outcome {
Ok(DataPlaneOperationResult::TcpRead { data, eof }) if !eof => Some(data.clone()),
_ => None,
})
.unwrap()
.unwrap();
let response_len = session_b
.take_result_with(response_write, |outcome| match outcome {
Ok(DataPlaneOperationResult::TcpWritten { len }) => Some(*len),
_ => None,
})
.unwrap()
.unwrap();
assert_eq!(response, b"pong");
assert_eq!(response_len, 4);
let blocked_read = session_b.submit_tcp_read(server, 16).unwrap();
session_b.close_resource(server);
let close_completion = wait_for_session_completion(&session_b).await;
@@ -199,11 +199,14 @@ impl AsyncWrite for TcpStream {
fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), io::Error>> {
let mut socket = self.reactor.get_socket::<tcp::Socket>(*self.handle);
if socket.is_open() {
if socket.may_send() {
socket.close();
self.reactor.notify();
}
if socket.state() == tcp::State::Closed {
if matches!(
socket.state(),
tcp::State::FinWait2 | tcp::State::TimeWait | tcp::State::Closed
) {
return Poll::Ready(Ok(()));
}
+1
View File
@@ -15,3 +15,4 @@ pub mod packet;
pub mod socket;
#[cfg(test)]
pub(crate) mod testkit;
pub mod tunnel;
+1 -1
View File
@@ -184,7 +184,7 @@ impl HostSocketRuntime {
}
}
pub(in crate::host) async fn run_operation<I, T>(
pub(crate) async fn run_operation<I, T>(
&self,
io: Arc<I>,
submit: impl FnOnce(&I, HostOperationId) -> io::Result<()>,
+36
View File
@@ -0,0 +1,36 @@
//! Host-backed message tunnels.
//!
//! The host owns the concrete transport and exposes complete EasyTier tunnel
//! payloads. Core owns packet semantics and the peer lifecycle above them.
use std::{io, task::Poll};
use super::socket::{HostOperationId, HostSocketHandle, HostSocketIo};
/// Maximum message payload accepted from a host tunnel adapter.
pub const MAX_HOST_TUNNEL_PAYLOAD_LEN: usize = 1024 * 1024;
/// Mechanical host I/O below EasyTier's message-tunnel seam.
///
/// Submit methods copy their complete input before returning. A receive keeps
/// the host transport's message boundary intact. A clean remote close is
/// reported as [`io::ErrorKind::UnexpectedEof`] by `take_receive`.
pub trait HostTunnelIo: HostSocketIo {
fn submit_receive(
&self,
handle: HostSocketHandle,
operation: HostOperationId,
capacity: usize,
) -> io::Result<()>;
fn take_receive(&self, operation: HostOperationId) -> Poll<io::Result<Vec<u8>>>;
fn submit_send(
&self,
handle: HostSocketHandle,
operation: HostOperationId,
source: &[u8],
) -> io::Result<()>;
fn take_send(&self, operation: HostOperationId) -> Poll<io::Result<()>>;
}
+11 -1
View File
@@ -28,7 +28,7 @@ use crate::{
use easytier_proto::common::CompressionAlgoPb;
use super::{CoreConnectivityConfig, CoreInstanceConfig};
use super::{CoreConnectivityConfig, CoreConnectivityMode, CoreInstanceConfig};
const OSPF_UPDATE_MY_FOREIGN_NETWORK_INTERVAL_SEC: u64 = 10;
const MAX_DIRECT_CONNS_PER_PEER_IN_FOREIGN_NETWORK: usize = 3;
@@ -58,6 +58,7 @@ pub struct CoreInstanceHostConfig {
pub upnp_enabled: bool,
pub tcp_hole_punching_enabled: bool,
pub ignore_unsupported_config: bool,
pub connectivity: CoreConnectivityMode,
pub easytier_version: String,
pub endpoint_protocols: Vec<String>,
}
@@ -83,6 +84,7 @@ impl Default for CoreInstanceHostConfig {
upnp_enabled: true,
tcp_hole_punching_enabled: true,
ignore_unsupported_config: false,
connectivity: CoreConnectivityMode::Full,
easytier_version: env!("CARGO_PKG_VERSION").to_owned(),
endpoint_protocols: ManualEndpointDiscoveryConfig::default().srv_protocols,
}
@@ -352,6 +354,8 @@ impl CoreInstanceConfig {
runtime,
startup_plan: super::CoreInstanceStartupPlan {
gateway: host.gateway_enabled,
packet_proxy: host.proxy_enabled,
connectivity: host.connectivity,
},
stun: StunServerConfig {
udp_servers: stun_servers
@@ -588,6 +592,7 @@ disable_p2p = true
icmp_failure_is_fatal: true,
public_ipv6_provider_supported: true,
gateway_enabled: false,
connectivity: CoreConnectivityMode::InboundOnly,
easytier_version: "host-version".to_owned(),
endpoint_protocols: vec!["host-protocol".to_owned()],
..Default::default()
@@ -606,6 +611,10 @@ disable_p2p = true
.as_deref(),
Some("host-fallback")
);
assert_eq!(
normalized.connectivity.startup_plan.connectivity,
CoreConnectivityMode::InboundOnly
);
assert!(
normalized
.peer
@@ -677,6 +686,7 @@ data_compress_algo = "Zstd"
let normalized = CoreInstanceConfig::from_toml_with_host(&config, &host).unwrap();
assert_eq!(config.dump(), before);
assert!(!normalized.connectivity.startup_plan.packet_proxy);
assert_eq!(normalized.connectivity.initial_peers.len(), 1);
assert_eq!(
normalized
+27 -9
View File
@@ -111,10 +111,16 @@ where
.ok_or_else(|| anyhow::anyhow!("packet egress is one-shot and already started"))?;
self.packet_egress.start(packet_receiver).await?;
self.peer_manager.run().await.map_err(anyhow::Error::from)?;
self.direct.run();
if let Some(direct) = &self.direct {
direct.run();
}
#[cfg(feature = "tcp-hole-punch")]
self.tcp_hole_punch.run();
self.manual.start();
if let Some(tcp_hole_punch) = &self.tcp_hole_punch {
tcp_hole_punch.run();
}
if let Some(manual) = &self.manual {
manual.start();
}
#[cfg(feature = "public-ipv6-provider")]
self.public_ipv6_provider.start().await;
@@ -128,8 +134,12 @@ where
wrapped_transport.start().await?;
}
#[cfg(feature = "proxy-packet")]
self.start_packet_proxy().await?;
self.udp_hole_punch.start().await?;
if self.startup_plan.packet_proxy {
self.start_packet_proxy().await?;
}
if let Some(udp_hole_punch) = &self.udp_hole_punch {
udp_hole_punch.start().await?;
}
self.peer_center.init().await;
#[cfg(feature = "proxy-cidr-monitor")]
self.proxy_cidr_monitor
@@ -170,7 +180,9 @@ where
if let Some(listener) = &self.listener {
listener.stop().await;
}
self.udp_hole_punch.stop().await;
if let Some(udp_hole_punch) = &self.udp_hole_punch {
udp_hole_punch.stop().await;
}
#[cfg(feature = "proxy-smoltcp-stack")]
self.port_forward_adapter.stop().await;
#[cfg(feature = "proxy-smoltcp-stack")]
@@ -185,10 +197,16 @@ where
}
#[cfg(feature = "proxy-packet")]
self.packet_proxy.stop().await;
self.manual.stop().await;
if let Some(manual) = &self.manual {
manual.stop().await;
}
#[cfg(feature = "tcp-hole-punch")]
self.tcp_hole_punch.stop().await;
self.direct.stop().await;
if let Some(tcp_hole_punch) = &self.tcp_hole_punch {
tcp_hole_punch.stop().await;
}
if let Some(direct) = &self.direct {
direct.stop().await;
}
self.peer_center.stop().await;
// Host packet tasks can still call the packet plane, so stop them
+19 -8
View File
@@ -30,19 +30,29 @@ where
}
pub fn add_connector(&self, url: Url) -> anyhow::Result<()> {
self.manual.add_connector(url)
self.manual
.as_ref()
.ok_or_else(|| anyhow::anyhow!("inbound-only connectivity has no outbound connectors"))?
.add_connector(url)
}
pub fn remove_connector(&self, url: &Url) -> bool {
self.manual.remove_connector(url)
self.manual
.as_ref()
.is_some_and(|manual| manual.remove_connector(url))
}
pub fn clear_connectors(&self) {
self.manual.clear_connectors();
if let Some(manual) = &self.manual {
manual.clear_connectors();
}
}
pub fn list_connectors(&self) -> Vec<ManualConnectorSnapshot> {
self.manual.list_connectors()
self.manual
.as_ref()
.map(|manual| manual.list_connectors())
.unwrap_or_default()
}
pub fn running_listeners(&self) -> Vec<Url> {
@@ -92,10 +102,11 @@ where
.peer_manager
.node_snapshot(self.running_listeners())
.await;
snapshot.ip_list = self
.direct
.local_address_observations_with_stun(&snapshot.stun_info)
.await;
if let Some(direct) = &self.direct {
snapshot.ip_list = direct
.local_address_observations_with_stun(&snapshot.stun_info)
.await;
}
snapshot
}
+170 -83
View File
@@ -64,8 +64,8 @@ use crate::{
packet::{HostPacketReceiver, PacketSink, host_packet_channel},
},
listener::{
AcceptedSocketHandler, ExternalListenerFactory, ExternalListenerRequest, ListenerFactory,
RunningListenerRegistry,
AcceptedSocketHandler, ExternalListenerFactory, ExternalListenerRequest,
HostListenerRegistration, ListenerFactory, RunningListenerRegistry,
plan::{ListenerRuntimeConfig, PreparedListenerPlan, prepare_listener_plan},
transport::{
AcceptedTransport, CoreListenerRuntime, HostAcceptedTcpSocket,
@@ -146,9 +146,18 @@ impl CoreInstanceState {
}
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct CoreInstanceStartupPlan {
#[serde(default = "default_true")]
pub gateway: bool,
#[serde(default = "default_true")]
pub packet_proxy: bool,
#[serde(default)]
pub connectivity: CoreConnectivityMode,
}
impl CoreInstanceStartupPlan {
@@ -159,10 +168,26 @@ impl CoreInstanceStartupPlan {
impl Default for CoreInstanceStartupPlan {
fn default() -> Self {
Self { gateway: true }
Self {
gateway: true,
packet_proxy: true,
connectivity: CoreConnectivityMode::Full,
}
}
}
/// Selects which portable connectivity Modules participate in one instance.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum CoreConnectivityMode {
#[default]
Full,
/// Dial configured peers without listeners, discovery, or direct connectivity.
OutboundOnly,
/// Accept Host-registered listeners without constructing outbound socket Modules.
InboundOnly,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CoreConnectivityConfig {
pub initial_peers: Vec<Url>,
@@ -279,6 +304,7 @@ where
pub protocol: Option<Arc<dyn ClientProtocolUpgrader<<H as VirtualTcpSocketFactory>::Socket>>>,
pub external_listener_factory:
Option<Arc<dyn ExternalListenerFactory<AcceptedTransport<HostAcceptedTcpSocket<H>>>>>,
pub host_listener_registrations: Vec<HostListenerRegistration>,
pub server_protocol: Option<Arc<dyn ServerProtocolUpgrader<HostAcceptedTcpSocket<H>>>>,
/// Optional OS port-mapping adapter. STUN-only hole punching remains
/// available when the host does not provide one.
@@ -338,6 +364,7 @@ where
wrapped_transports: WrappedTransportEngines::default(),
protocol: None,
external_listener_factory: None,
host_listener_registrations: Vec::new(),
server_protocol: None,
udp_hole_punch_platform: None,
#[cfg(feature = "proxy-packet")]
@@ -380,13 +407,13 @@ where
pub(super) cancel: CancellationToken,
pub(super) peer_manager: Arc<PeerManagerCore>,
packet_plane: Arc<CorePacketPlane>,
pub(super) manual: ManualConnectorManager<H>,
pub(super) direct: DirectConnectorManager<H>,
pub(super) manual: Option<ManualConnectorManager<H>>,
pub(super) direct: Option<DirectConnectorManager<H>>,
#[cfg(feature = "tcp-hole-punch")]
tcp_hole_punch: TcpHolePunchConnector<H, PeerManagerCore>,
tcp_hole_punch: Option<TcpHolePunchConnector<H, PeerManagerCore>>,
pub(super) listener: Option<Arc<CoreListenerRuntime<H>>>,
running_listeners: Arc<RunningListenerRegistry>,
pub(super) udp_hole_punch: CoreUdpHolePunchService<H, PeerManagerCore>,
pub(super) udp_hole_punch: Option<CoreUdpHolePunchService<H, PeerManagerCore>>,
#[cfg(feature = "wrapped-transport")]
wrapped_transport: Option<Arc<WrappedTransportProxyModule>>,
#[cfg(feature = "proxy-smoltcp-stack")]
@@ -411,7 +438,7 @@ where
public_ipv6_provider: PublicIpv6ProviderRuntime,
#[cfg(feature = "vpn-portal")]
vpn_portal: Arc<PortalModule>,
#[cfg(feature = "proxy-smoltcp-stack")]
#[cfg(feature = "proxy-packet")]
pub(super) startup_plan: CoreInstanceStartupPlan,
pub(super) runtime_config: CoreRuntimeConfigStore,
#[cfg(feature = "test-utils")]
@@ -472,6 +499,7 @@ where
mut adapters: CoreHostAdapters<H>,
) -> anyhow::Result<Arc<Self>> {
build_capabilities::validate(&config)?;
let connectivity_mode = config.connectivity.startup_plan.connectivity;
let instance_name = config.instance_name;
#[cfg(feature = "vpn-portal")]
let vpn_portal_config = config.vpn_portal.clone();
@@ -497,17 +525,26 @@ where
public_ipv6_host,
public_ipv6_events,
);
let stun = Self::prepare_stun(&adapters, &config.connectivity);
let peer_stun: Arc<dyn StunInfoProvider> = stun.clone();
let foreign_rpc_registrar = Arc::new(ForeignDirectConnectorRpcRegistrar::new(
adapters.host.clone(),
stun.clone(),
));
let stun = (connectivity_mode == CoreConnectivityMode::Full)
.then(|| Self::prepare_stun(&adapters, &config.connectivity));
let (peer_stun, foreign_rpc_registrar): (
Arc<dyn PeerStunInfoSource>,
Arc<dyn crate::peers::foreign_network::ForeignNetworkRpcRegistrar>,
) = match &stun {
Some(stun) => (
Arc::new(CoreStunPeerInfoSource(stun.clone())),
Arc::new(ForeignDirectConnectorRpcRegistrar::new(
adapters.host.clone(),
stun.clone(),
)),
),
None => (Arc::new(()), Arc::new(())),
};
let peer_manager = Arc::new(PeerManagerCore::new(
config.peer,
config.managed_credentials,
runtime_config.clone(),
Arc::new(CoreStunPeerInfoSource(peer_stun)),
peer_stun,
packet_tx,
public_ipv6_runtime.clone(),
events.clone(),
@@ -515,8 +552,11 @@ where
foreign_rpc_registrar,
)?);
let config = config.connectivity;
let configured_listeners = (connectivity_mode != CoreConnectivityMode::OutboundOnly)
.then_some(config.listeners.as_ref())
.flatten();
let listener_plan = prepare_listener_plan(
config.listeners.as_ref(),
configured_listeners,
peer_manager.instance_id(),
adapters.server_protocol.as_deref(),
adapters.external_listener_factory.as_deref(),
@@ -536,6 +576,7 @@ where
wrapped_transports,
protocol,
external_listener_factory,
host_listener_registrations,
server_protocol,
udp_hole_punch_platform,
#[cfg(feature = "proxy-packet")]
@@ -549,6 +590,12 @@ where
#[cfg(feature = "vpn-portal")]
vpn_portal,
} = adapters;
let host_listener_registrations = if connectivity_mode == CoreConnectivityMode::OutboundOnly
{
Vec::new()
} else {
host_listener_registrations
};
let dns_records: Arc<dyn DnsRecordResolver> = dns.clone();
let dns: Arc<dyn DnsResolver> = dns;
let ring_registry = process_runtime.ring_registry();
@@ -563,19 +610,22 @@ where
manual: manual_options,
direct: direct_options,
} = config;
#[cfg(not(feature = "proxy-smoltcp-stack"))]
#[cfg(not(feature = "proxy-packet"))]
let _ = startup_plan;
if connectivity_mode == CoreConnectivityMode::InboundOnly && !initial_peers.is_empty() {
anyhow::bail!("inbound-only connectivity does not support outbound peers");
}
let accepted_tunnel_handler = PeerAcceptedTunnelHandler::new(&peer_manager, events.clone());
let accepted_transport_handler: Arc<
dyn AcceptedSocketHandler<AcceptedTransport<HostAcceptedTcpSocket<H>>>,
> = match server_protocol {
Some(server_protocol) => {
let tunnel_handler = PeerAcceptedTunnelHandler::new(&peer_manager, events.clone());
Arc::new(ProtocolAcceptedTransportHandler::new(
&tunnel_handler,
server_protocol,
))
}
None => Arc::new(RawAcceptedTransportHandler::new(&peer_manager)),
Some(server_protocol) => Arc::new(ProtocolAcceptedTransportHandler::new(
&accepted_tunnel_handler,
server_protocol,
)),
None => Arc::new(RawAcceptedTransportHandler::new(
accepted_tunnel_handler.clone(),
)),
};
let running_listeners = Arc::new(RunningListenerRegistry::default());
let PreparedListenerPlan {
@@ -583,8 +633,11 @@ where
external,
failures,
} = listener_plan;
let mut external_factories = Vec::with_capacity(external.len());
if !external.is_empty() && external_listener_factory.is_none() {
let mut external_factories =
Vec::with_capacity(external.len() + host_listener_registrations.len());
if (!external.is_empty() || !host_listener_registrations.is_empty())
&& external_listener_factory.is_none()
{
anyhow::bail!("listener plan requires an external listener factory");
}
for (listener, socket_context) in external {
@@ -598,6 +651,19 @@ where
listener.must_succeed,
));
}
for request in host_listener_registrations {
let factory = external_listener_factory.clone().unwrap();
if !factory.supports_scheme(request.url.scheme()) {
anyhow::bail!(
"external listener factory does not support Host listener scheme {}",
request.url.scheme()
);
}
external_factories.push(ListenerFactory::new(
move || factory.create(request.clone()),
true,
));
}
let has_listener_work =
!transports.is_empty() || !external_factories.is_empty() || !failures.is_empty();
let listener = has_listener_work.then(|| {
@@ -613,40 +679,51 @@ where
running_listeners.clone(),
))
});
let protocol = protocol.unwrap_or_else(|| {
Arc::new(CoreClientProtocolUpgrader::new(
CoreClientProtocolConfig::default(),
))
let protocol = (connectivity_mode != CoreConnectivityMode::InboundOnly).then(|| {
protocol.unwrap_or_else(|| {
Arc::new(CoreClientProtocolUpgrader::new(
CoreClientProtocolConfig::default(),
))
})
});
let endpoint_resolver = Arc::new(CoreManualEndpointResolver::new(
host.clone(),
dns.clone(),
dns_records,
endpoint_discovery,
));
let manual = ManualConnectorManager::new(
peer_manager.clone(),
host.clone(),
dns.clone(),
endpoint_resolver,
protocol.clone(),
ring_registry,
manual_options,
events.clone(),
);
for url in initial_peers {
manual.add_connector(url)?;
}
let udp_hole_punch_socket_context = direct_options.udp_bind.context.clone();
let udp_hole_punch = CoreUdpHolePunchService::new(
peer_manager.clone(),
host.clone(),
stun.clone(),
udp_hole_punch_platform,
events.clone(),
udp_hole_punch_socket_context,
protocol.clone(),
);
let manual = if let Some(protocol) = &protocol {
let endpoint_resolver = Arc::new(CoreManualEndpointResolver::new(
host.clone(),
dns.clone(),
dns_records,
endpoint_discovery,
));
let manual = ManualConnectorManager::new(
peer_manager.clone(),
host.clone(),
dns.clone(),
endpoint_resolver,
protocol.clone(),
ring_registry,
manual_options,
events.clone(),
);
for url in initial_peers {
manual.add_connector(url)?;
}
Some(manual)
} else {
None
};
let udp_hole_punch = stun
.as_ref()
.zip(protocol.as_ref())
.map(|(stun, protocol)| {
CoreUdpHolePunchService::new(
peer_manager.clone(),
host.clone(),
stun.clone(),
udp_hole_punch_platform,
events.clone(),
direct_options.udp_bind.context.clone(),
protocol.clone(),
)
});
let proxy_cidr_table = Arc::new(ProxyCidrTable::from_snapshot(proxy_cidr_snapshot(
runtime_config.snapshot().as_ref(),
)));
@@ -708,28 +785,38 @@ where
events.clone(),
);
#[cfg(feature = "tcp-hole-punch")]
let tcp_hole_punch = TcpHolePunchConnector::new(
peer_manager.clone(),
host.clone(),
stun.clone(),
direct_options.tcp_bind.context.clone(),
protocol.clone(),
Arc::new(crate::connectivity::protocol::CoreServerProtocolUpgrader::<
HostAcceptedTcpSocket<H>,
>::new(
crate::connectivity::protocol::CoreServerProtocolConfig::default(),
)),
);
let direct = DirectConnectorManager::new_with_running_listeners(
peer_manager.clone(),
host.clone(),
protected_tcp_ports,
stun.clone(),
running_listeners.clone(),
dns,
protocol,
direct_options,
);
let tcp_hole_punch = stun
.as_ref()
.zip(protocol.as_ref())
.map(|(stun, protocol)| {
TcpHolePunchConnector::new(
peer_manager.clone(),
host.clone(),
stun.clone(),
direct_options.tcp_bind.context.clone(),
protocol.clone(),
Arc::new(crate::connectivity::protocol::CoreServerProtocolUpgrader::<
HostAcceptedTcpSocket<H>,
>::new(
crate::connectivity::protocol::CoreServerProtocolConfig::default(),
)),
)
});
let direct = match (stun, protocol) {
(Some(stun), Some(protocol)) => {
Some(DirectConnectorManager::new_with_running_listeners(
peer_manager.clone(),
host.clone(),
protected_tcp_ports,
stun,
running_listeners.clone(),
dns,
protocol,
direct_options,
))
}
_ => None,
};
let peer_center = Arc::new(PeerCenterInstance::new(peer_manager.clone()));
#[cfg(feature = "public-ipv6-provider")]
let public_ipv6_provider = PublicIpv6ProviderRuntime::new(
@@ -799,7 +886,7 @@ where
public_ipv6_provider,
#[cfg(feature = "vpn-portal")]
vpn_portal,
#[cfg(feature = "proxy-smoltcp-stack")]
#[cfg(feature = "proxy-packet")]
startup_plan,
runtime_config,
#[cfg(feature = "test-utils")]
+88
View File
@@ -185,6 +185,10 @@ fn core_instance_config_round_trips_as_normalized_json() {
assert!(!decoded.connectivity.direct.testing);
assert!(decoded.connectivity.startup_plan.gateway);
assert_eq!(
decoded.connectivity.startup_plan.connectivity,
CoreConnectivityMode::Full
);
assert_eq!(serde_json::to_value(&decoded).unwrap(), encoded);
let mut legacy = encoded;
@@ -3016,4 +3020,88 @@ virtual_ip = "10.82.0.2/24"
instance.stop().await;
assert!(instance.running_listeners().is_empty());
}
#[tokio::test]
async fn inbound_only_uses_host_registered_listener_lifecycle() {
let external_url: Url = "unix:///tmp/easytier-host-listener-test".parse().unwrap();
let mut config = test_config("host-listener");
config.connectivity.startup_plan.connectivity = CoreConnectivityMode::InboundOnly;
let (packet_sink, _packet_receiver) = tokio::sync::mpsc::channel(16);
let mut adapters = adapters(
Some(Arc::new(ReadyExternalListenerFactory)),
Arc::new(packet_sink),
);
adapters
.host_listener_registrations
.push(ExternalListenerRequest {
url: external_url.clone(),
socket_context: SocketContext::default(),
});
let instance = CoreInstance::new(config, adapters).unwrap();
assert!(
instance
.add_connector("tcp://127.0.0.1:11010".parse().unwrap())
.is_err()
);
instance.start().await.unwrap();
assert!(instance.running_listeners().contains(&external_url));
instance.stop().await;
assert!(instance.running_listeners().is_empty());
}
#[tokio::test]
async fn inbound_only_rejects_initial_peers() {
let mut config = test_config("inbound-only-peer");
config.connectivity.startup_plan.connectivity = CoreConnectivityMode::InboundOnly;
config
.connectivity
.initial_peers
.push("tcp://127.0.0.1:11010".parse().unwrap());
let Err(error) = build_instance(config) else {
panic!("inbound-only instance accepted an outbound peer");
};
assert!(
error
.to_string()
.contains("inbound-only connectivity does not support outbound peers"),
"unexpected construction error: {error:#}"
);
}
#[tokio::test]
async fn outbound_only_ignores_configured_and_host_registered_listeners() {
let configured_url: Url = "unix:///tmp/easytier-outbound-configured-listener"
.parse()
.unwrap();
let host_url: Url = "unix:///tmp/easytier-outbound-host-listener"
.parse()
.unwrap();
let mut config = test_config("outbound-only-listeners");
config.connectivity.startup_plan.connectivity = CoreConnectivityMode::OutboundOnly;
config.connectivity.listeners = Some(ListenerRuntimeConfig::new(
vec![configured_url],
false,
SocketContext::default(),
));
let (packet_sink, _packet_receiver) = tokio::sync::mpsc::channel(16);
let mut adapters = adapters(
Some(Arc::new(ReadyExternalListenerFactory)),
Arc::new(packet_sink),
);
adapters
.host_listener_registrations
.push(ExternalListenerRequest {
url: host_url,
socket_context: SocketContext::default(),
});
let instance = CoreInstance::new(config, adapters).unwrap();
instance.start().await.unwrap();
assert!(instance.running_listeners().is_empty());
instance.stop().await;
}
}
+12
View File
@@ -15,6 +15,14 @@ use crate::{
};
pub mod plan;
#[cfg(any(
test,
all(
feature = "wasm-host-tunnel",
not(feature = "wasm-host-tunnel-outbound")
)
))]
pub(crate) mod queue;
pub mod transport;
pub trait ExternalListenerFactory<Accepted>: Send + Sync + 'static
@@ -35,6 +43,10 @@ pub struct ExternalListenerRequest {
pub socket_context: SocketContext,
}
/// One listener supplied by the Host rather than the portable TOML model.
/// Host listeners are mandatory: startup fails when one cannot bind.
pub type HostListenerRegistration = ExternalListenerRequest;
#[async_trait]
pub trait AcceptedSocketHandler<Accepted>: Send + Sync {
async fn handle_accepted_socket(&self, accepted: Accepted) -> anyhow::Result<()>;
+149
View File
@@ -0,0 +1,149 @@
use std::{collections::VecDeque, sync::Mutex};
use tokio::sync::Notify;
struct HostListenerQueueState<T> {
closed: bool,
listeners: usize,
pending: VecDeque<T>,
}
/// Bounded handoff from a synchronous Host callback to async listeners.
pub(crate) struct HostListenerQueue<T> {
capacity: usize,
state: Mutex<HostListenerQueueState<T>>,
changed: Notify,
}
impl<T> HostListenerQueue<T> {
pub(crate) fn new(capacity: usize) -> Self {
Self {
capacity,
state: Mutex::new(HostListenerQueueState {
closed: false,
listeners: 0,
pending: VecDeque::new(),
}),
changed: Notify::new(),
}
}
pub(crate) fn register_listener(&self) -> bool {
let mut state = self.state.lock().unwrap();
if state.closed {
return false;
}
state.listeners += 1;
true
}
pub(crate) fn unregister_listener(&self) {
let pending = {
let mut state = self.state.lock().unwrap();
debug_assert!(state.listeners > 0);
state.listeners -= 1;
if state.listeners != 0 {
return;
}
state.closed = true;
std::mem::take(&mut state.pending)
};
drop(pending);
self.changed.notify_waiters();
}
/// Constructs `T` only after the queue accepts Host-to-guest ownership.
pub(crate) fn enqueue_with(&self, create: impl FnOnce() -> T) -> anyhow::Result<()> {
{
let mut state = self.state.lock().unwrap();
if state.closed || state.listeners == 0 {
anyhow::bail!("Host listener queue is closed");
}
if state.pending.len() >= self.capacity {
anyhow::bail!("Host listener admission queue is full");
}
state.pending.push_back(create());
}
self.changed.notify_one();
Ok(())
}
pub(crate) async fn accept(&self) -> Option<T> {
loop {
let changed = self.changed.notified();
{
let mut state = self.state.lock().unwrap();
if let Some(item) = state.pending.pop_front() {
return Some(item);
}
if state.closed {
return None;
}
}
changed.await;
}
}
}
#[cfg(test)]
mod tests {
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
use super::HostListenerQueue;
struct DropCounter(Arc<AtomicUsize>);
impl Drop for DropCounter {
fn drop(&mut self) {
self.0.fetch_add(1, Ordering::Relaxed);
}
}
#[tokio::test]
async fn constructs_only_after_listener_accepts_ownership() {
let queue = HostListenerQueue::new(1);
let constructed = AtomicUsize::new(0);
assert!(
queue
.enqueue_with(|| {
constructed.fetch_add(1, Ordering::Relaxed);
})
.is_err()
);
assert_eq!(constructed.load(Ordering::Relaxed), 0);
assert!(queue.register_listener());
queue
.enqueue_with(|| {
constructed.fetch_add(1, Ordering::Relaxed);
})
.unwrap();
assert!(
queue
.enqueue_with(|| {
constructed.fetch_add(1, Ordering::Relaxed);
})
.is_err()
);
assert_eq!(constructed.load(Ordering::Relaxed), 1);
queue.accept().await.unwrap();
queue.unregister_listener();
}
#[test]
fn last_listener_closes_and_drains_pending_items() {
let queue = HostListenerQueue::new(1);
let drops = Arc::new(AtomicUsize::new(0));
assert!(queue.register_listener());
queue.enqueue_with(|| DropCounter(drops.clone())).unwrap();
queue.unregister_listener();
assert_eq!(drops.load(Ordering::Relaxed), 1);
assert!(!queue.register_listener());
}
}
+46 -11
View File
@@ -77,14 +77,12 @@ impl AcceptedTunnelHandler for PeerAcceptedTunnelHandler {
}
pub(crate) struct RawAcceptedTransportHandler {
peer_manager: Weak<PeerManagerCore>,
tunnel_handler: Arc<dyn AcceptedTunnelHandler>,
}
impl RawAcceptedTransportHandler {
pub(crate) fn new(peer_manager: &Arc<PeerManagerCore>) -> Self {
Self {
peer_manager: Arc::downgrade(peer_manager),
}
pub(crate) fn new(tunnel_handler: Arc<dyn AcceptedTunnelHandler>) -> Self {
Self { tunnel_handler }
}
}
@@ -97,10 +95,6 @@ where
&self,
accepted: AcceptedTransport<TcpSocket>,
) -> anyhow::Result<()> {
let peer_manager = self
.peer_manager
.upgrade()
.ok_or_else(|| anyhow::anyhow!("peer manager is gone"))?;
let tunnel = match accepted {
AcceptedTransport::Tunnel { tunnel, .. } => tunnel,
AcceptedTransport::Tcp {
@@ -125,7 +119,48 @@ where
remote_url,
} => raw::upgrade_accepted_byte_stream(socket, local_url, remote_url)?,
};
peer_manager.add_tunnel_as_server(tunnel, true).await?;
Ok(())
self.tunnel_handler.handle_tunnel(tunnel).await
}
}
#[cfg(test)]
mod tests {
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
use super::*;
use crate::{host::testkit::TestTcpSocket, tunnel::ring::RingTunnelRegistry};
struct RecordingTunnelHandler(AtomicUsize);
#[async_trait]
impl AcceptedTunnelHandler for RecordingTunnelHandler {
async fn handle_tunnel(&self, _tunnel: Box<dyn Tunnel>) -> anyhow::Result<()> {
self.0.fetch_add(1, Ordering::Relaxed);
Ok(())
}
}
#[tokio::test]
async fn raw_transport_delegates_tunnel_to_shared_admission_handler() {
let registry = Arc::new(RingTunnelRegistry::default());
let local_id = uuid::Uuid::new_v4();
let mut listener = registry.bind(local_id).unwrap();
let _client = registry.connect(local_id).unwrap();
let tunnel = listener.accept().await.unwrap().into_tunnel();
let recorder = Arc::new(RecordingTunnelHandler(AtomicUsize::new(0)));
let handler = RawAcceptedTransportHandler::new(recorder.clone());
handler
.handle_accepted_socket(AcceptedTransport::<TestTcpSocket>::Tunnel {
tunnel,
local_url: format!("ring://{local_id}").parse().unwrap(),
})
.await
.unwrap();
assert_eq!(recorder.0.load(Ordering::Relaxed), 1);
}
}
+221
View File
@@ -0,0 +1,221 @@
//! EasyTier message tunnel over a host-owned transport.
use std::{io, sync::Arc};
use futures::{sink, stream};
use url::Url;
use crate::{
host::{
socket::{HostSocketHandle, HostSocketRuntime},
tunnel::{HostTunnelIo, MAX_HOST_TUNNEL_PAYLOAD_LEN},
},
packet::{ZCPacket, ZCPacketType},
proto::common::TunnelInfo,
};
use super::{Tunnel, TunnelError, wrapper::TunnelWrapper};
struct HostTunnelResource {
io: Arc<dyn HostTunnelIo>,
handle: HostSocketHandle,
}
impl Drop for HostTunnelResource {
fn drop(&mut self) {
let _ = self.io.close(self.handle);
}
}
/// Builds a message-preserving EasyTier tunnel around a host-owned transport.
pub fn new_host_tunnel(
runtime: HostSocketRuntime,
io: Arc<dyn HostTunnelIo>,
handle: HostSocketHandle,
local_url: Url,
remote_url: Url,
resolved_remote_url: Option<Url>,
) -> Box<dyn Tunnel> {
let resource = Arc::new(HostTunnelResource { io, handle });
let reader_resource = resource.clone();
let reader_runtime = runtime.clone();
let reader = stream::unfold(
(reader_runtime, reader_resource),
|(runtime, resource)| async move {
let result = runtime
.run_operation(
resource.io.clone(),
|io, operation| {
io.submit_receive(resource.handle, operation, MAX_HOST_TUNNEL_PAYLOAD_LEN)
},
|io, operation| io.take_receive(operation),
|io, operation| io.cancel_operation(operation),
)
.await;
let item = match result {
Ok(message) => Ok(ZCPacket::new_from_buf(
bytes::BytesMut::from(message.as_slice()),
ZCPacketType::DummyTunnel,
)),
Err(error) if error.kind() == io::ErrorKind::UnexpectedEof => return None,
Err(error) => Err(TunnelError::IOError(error)),
};
Some((item, (runtime, resource)))
},
);
let writer = sink::unfold(
(runtime, resource),
|(runtime, resource), packet: ZCPacket| async move {
let payload = packet.tunnel_payload_bytes();
runtime
.run_operation(
resource.io.clone(),
|io, operation| io.submit_send(resource.handle, operation, &payload),
|io, operation| io.take_send(operation),
|io, operation| io.cancel_operation(operation),
)
.await
.map_err(TunnelError::IOError)?;
Ok((runtime, resource))
},
);
let remote_addr = remote_url.clone().into();
let resolved_remote_addr = resolved_remote_url
.unwrap_or_else(|| remote_url.clone())
.into();
let info = TunnelInfo {
tunnel_type: local_url.scheme().to_owned(),
local_addr: Some(local_url.into()),
remote_addr: Some(remote_addr),
resolved_remote_addr: Some(resolved_remote_addr),
};
Box::new(TunnelWrapper::new(reader, writer, Some(info)))
}
#[cfg(test)]
mod tests {
use std::{
collections::{HashMap, VecDeque},
sync::{
Mutex,
atomic::{AtomicUsize, Ordering},
},
task::Poll,
};
use futures::{SinkExt as _, StreamExt as _};
use super::*;
use crate::host::socket::{HostOperationId, HostSocketIo};
#[derive(Default)]
struct MockTunnelIo {
incoming: Mutex<VecDeque<io::Result<Vec<u8>>>>,
receives: Mutex<HashMap<HostOperationId, io::Result<Vec<u8>>>>,
sends: Mutex<HashMap<HostOperationId, Vec<u8>>>,
sent: Mutex<Vec<Vec<u8>>>,
closes: AtomicUsize,
}
impl HostSocketIo for MockTunnelIo {
fn cancel_operation(&self, operation: HostOperationId) -> io::Result<()> {
self.receives.lock().unwrap().remove(&operation);
self.sends.lock().unwrap().remove(&operation);
Ok(())
}
fn close(&self, _handle: HostSocketHandle) -> io::Result<()> {
self.closes.fetch_add(1, Ordering::SeqCst);
Ok(())
}
}
impl HostTunnelIo for MockTunnelIo {
fn submit_receive(
&self,
_handle: HostSocketHandle,
operation: HostOperationId,
_capacity: usize,
) -> io::Result<()> {
let result = self.incoming.lock().unwrap().pop_front().unwrap();
self.receives.lock().unwrap().insert(operation, result);
Ok(())
}
fn take_receive(&self, operation: HostOperationId) -> Poll<io::Result<Vec<u8>>> {
Poll::Ready(self.receives.lock().unwrap().remove(&operation).unwrap())
}
fn submit_send(
&self,
_handle: HostSocketHandle,
operation: HostOperationId,
source: &[u8],
) -> io::Result<()> {
self.sends
.lock()
.unwrap()
.insert(operation, source.to_vec());
Ok(())
}
fn take_send(&self, operation: HostOperationId) -> Poll<io::Result<()>> {
let message = self.sends.lock().unwrap().remove(&operation).unwrap();
self.sent.lock().unwrap().push(message);
Poll::Ready(Ok(()))
}
}
fn tunnel(io: Arc<MockTunnelIo>) -> Box<dyn Tunnel> {
new_host_tunnel(
HostSocketRuntime::new(),
io,
HostSocketHandle(7),
Url::parse("test-tunnel://relay.example/").unwrap(),
Url::parse("test-tunnel://client.example/").unwrap(),
None,
)
}
#[test]
fn preserves_message_boundaries_and_closes_once() {
let io = Arc::new(MockTunnelIo::default());
io.incoming.lock().unwrap().push_back(Ok(vec![1, 2, 3]));
io.incoming.lock().unwrap().push_back(Ok(vec![8, 9]));
let tunnel = tunnel(io.clone());
assert_eq!(tunnel.info().unwrap().tunnel_type, "test-tunnel");
let (mut reader, mut writer) = tunnel.split();
let first = futures::executor::block_on(reader.next()).unwrap().unwrap();
let second = futures::executor::block_on(reader.next()).unwrap().unwrap();
assert_eq!(first.tunnel_payload(), &[1, 2, 3]);
assert_eq!(second.tunnel_payload(), &[8, 9]);
let packet = ZCPacket::new_from_buf(
bytes::BytesMut::from(&[4, 5, 6][..]),
ZCPacketType::DummyTunnel,
);
futures::executor::block_on(writer.send(packet)).unwrap();
assert_eq!(*io.sent.lock().unwrap(), vec![vec![4, 5, 6]]);
drop(tunnel);
drop(reader);
drop(writer);
assert_eq!(io.closes.load(Ordering::SeqCst), 1);
}
#[test]
fn maps_clean_remote_close_to_stream_eof() {
let io = Arc::new(MockTunnelIo::default());
io.incoming
.lock()
.unwrap()
.push_back(Err(io::Error::new(io::ErrorKind::UnexpectedEof, "closed")));
let tunnel = tunnel(io);
let (mut reader, _writer) = tunnel.split();
assert!(futures::executor::block_on(reader.next()).is_none());
}
}
+1
View File
@@ -18,6 +18,7 @@ pub use crate::socket::IpVersion;
pub(crate) mod encrypt;
pub mod filter;
pub mod framed;
pub mod host_tunnel;
pub mod mpsc;
pub mod ring;
pub(crate) mod secure_datagram;
+15 -1
View File
@@ -34,7 +34,7 @@ pub const CORE_INSTANCE_CONFIG_VERSION: u32 = 14;
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;
pub const DATA_PLANE_ABI_VERSION: u32 = 4;
/// Version of the protobuf RPC guest export contract.
#[cfg(feature = "management-rpc")]
@@ -54,6 +54,8 @@ pub const DATA_PLANE_UDP_CAPABILITY: u64 = 1 << 2;
pub const DATA_PLANE_DEADLINE_READ: u32 = 1 << 0;
/// Update the write deadline in `easytier_data_plane_resource_deadline_set`.
pub const DATA_PLANE_DEADLINE_WRITE: u32 = 1 << 1;
/// Version of the host tunnel metadata and export contract.
pub const HOST_TUNNEL_ABI_VERSION: u32 = 1;
/// Guest exports a WASI runtime calls to manage a core instance.
///
@@ -62,6 +64,9 @@ pub const DATA_PLANE_DEADLINE_WRITE: u32 = 1 << 1;
/// all asynchronous guest work through `easytier_instance_drive` and host
/// completion notifications.
pub const GUEST_EXPORTS: &[&str] = &[
// WASI command initialization. This only binds the runtime; core lifecycle
// still starts through the instance exports below.
"_start",
// Guest-memory buffers.
"easytier_buffer_alloc",
"easytier_buffer_free",
@@ -112,6 +117,13 @@ pub const RPC_GUEST_EXPORTS: &[&str] = &[
"easytier_rpc_operation_free",
];
/// Guest exports present with the host tunnel feature.
#[cfg(feature = "wasm-host-tunnel")]
pub const HOST_TUNNEL_GUEST_EXPORTS: &[&str] = &[
"easytier_host_tunnel_abi_version",
"easytier_instance_accept_tunnel",
];
/// 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] = &[
@@ -124,6 +136,7 @@ pub const DATA_PLANE_GUEST_EXPORTS: &[&str] = &[
"easytier_data_plane_tcp_accept_submit",
"easytier_data_plane_tcp_read_submit",
"easytier_data_plane_tcp_write_submit",
"easytier_data_plane_tcp_shutdown_write_submit",
"easytier_data_plane_udp_bind_submit",
"easytier_data_plane_udp_receive_submit",
"easytier_data_plane_udp_send_submit",
@@ -136,6 +149,7 @@ pub const DATA_PLANE_GUEST_EXPORTS: &[&str] = &[
"easytier_data_plane_tcp_accept_result_take",
"easytier_data_plane_tcp_read_result_take",
"easytier_data_plane_tcp_write_result_take",
"easytier_data_plane_tcp_shutdown_write_result_take",
"easytier_data_plane_udp_bind_result_take",
"easytier_data_plane_udp_receive_result_take",
"easytier_data_plane_udp_send_result_take",
+2
View File
@@ -7,3 +7,5 @@ pub mod event;
pub mod management;
pub mod packet;
pub mod socket;
#[cfg(feature = "wasm-host-tunnel")]
pub mod tunnel;
+397
View File
@@ -0,0 +1,397 @@
//! WASI imports for host-owned message tunnels.
use std::{
collections::HashMap,
io,
sync::{Arc, Mutex},
task::Poll,
};
#[cfg(not(feature = "wasm-host-tunnel-outbound"))]
use std::{fmt, marker::PhantomData};
use crate::{
host::{
socket::{HostOperationId, HostSocketHandle, HostSocketIo},
tunnel::HostTunnelIo,
},
tunnel::Tunnel,
wasi::{
imports::{
HOST_PENDING, HOST_TUNNEL_CLOSED, cancel_operation, close, start_tunnel_receive,
start_tunnel_send, take_tunnel_receive, take_tunnel_send,
},
wire::common::{host_error, status},
},
};
#[cfg(not(feature = "wasm-host-tunnel-outbound"))]
use crate::{
listener::{
ExternalListenerFactory, ExternalListenerRequest, queue::HostListenerQueue,
transport::AcceptedTransport,
},
socket::{SocketListener, tcp::VirtualTcpSocket},
};
#[cfg(feature = "wasm-host-tunnel-outbound")]
use crate::connectivity::manual::ExternalTunnelConnector;
#[cfg(feature = "wasm-host-tunnel-outbound")]
use crate::wasi::imports::{start_tunnel_connect, take_tunnel_connect};
#[cfg(not(feature = "wasm-host-tunnel-outbound"))]
const MAX_PENDING_HOST_TUNNELS: usize = 256;
#[derive(Default)]
pub struct WasiHostTunnelIo {
receive_capacities: Mutex<HashMap<HostOperationId, usize>>,
}
impl WasiHostTunnelIo {
fn forget_operation(&self, operation: HostOperationId) {
self.receive_capacities.lock().unwrap().remove(&operation);
}
}
impl HostSocketIo for WasiHostTunnelIo {
fn cancel_operation(&self, operation: HostOperationId) -> io::Result<()> {
self.forget_operation(operation);
status("cancel_operation", unsafe { cancel_operation(operation.0) })
}
fn close(&self, handle: HostSocketHandle) -> io::Result<()> {
status("close", unsafe { close(handle.0) })
}
}
impl HostTunnelIo for WasiHostTunnelIo {
fn submit_receive(
&self,
handle: HostSocketHandle,
operation: HostOperationId,
capacity: usize,
) -> io::Result<()> {
let capacity = u32::try_from(capacity).map_err(|_| {
io::Error::new(
io::ErrorKind::InvalidInput,
"host tunnel receive buffer is too large",
)
})?;
status("start_tunnel_receive", unsafe {
start_tunnel_receive(handle.0, operation.0, capacity)
})?;
self.receive_capacities
.lock()
.unwrap()
.insert(operation, capacity as usize);
Ok(())
}
fn take_receive(&self, operation: HostOperationId) -> Poll<io::Result<Vec<u8>>> {
let mut capacities = self.receive_capacities.lock().unwrap();
let Some(&capacity) = capacities.get(&operation) else {
return Poll::Ready(Err(io::Error::new(
io::ErrorKind::NotFound,
"WASI host tunnel receive capacity is missing",
)));
};
let result = unsafe { take_tunnel_receive(operation.0, 0, 0) };
match result {
HOST_PENDING => Poll::Pending,
HOST_TUNNEL_CLOSED => {
capacities.remove(&operation);
Poll::Ready(Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"host tunnel closed",
)))
}
length if length >= 0 => {
let length = length as usize;
if length > capacity {
capacities.remove(&operation);
let _ = unsafe { cancel_operation(operation.0) };
return Poll::Ready(Err(io::Error::new(
io::ErrorKind::InvalidData,
"host tunnel payload exceeds submitted capacity",
)));
}
let mut buffer = vec![0; length];
let copied = unsafe {
take_tunnel_receive(
operation.0,
buffer.as_mut_ptr() as u32,
buffer.len() as u32,
)
};
capacities.remove(&operation);
if copied == length as i32 {
Poll::Ready(Ok(buffer))
} else {
let _ = unsafe { cancel_operation(operation.0) };
Poll::Ready(Err(host_error("take_tunnel_receive copy", copied)))
}
}
code => {
capacities.remove(&operation);
Poll::Ready(Err(host_error("take_tunnel_receive", code)))
}
}
}
fn submit_send(
&self,
handle: HostSocketHandle,
operation: HostOperationId,
source: &[u8],
) -> io::Result<()> {
let length = u32::try_from(source.len()).map_err(|_| {
io::Error::new(
io::ErrorKind::InvalidInput,
"host tunnel send buffer is too large",
)
})?;
status("start_tunnel_send", unsafe {
start_tunnel_send(handle.0, operation.0, source.as_ptr() as u32, length)
})
}
fn take_send(&self, operation: HostOperationId) -> Poll<io::Result<()>> {
match unsafe { take_tunnel_send(operation.0) } {
HOST_PENDING => Poll::Pending,
0 => Poll::Ready(Ok(())),
code => Poll::Ready(Err(host_error("take_tunnel_send", code))),
}
}
}
#[cfg(feature = "wasm-host-tunnel-outbound")]
pub(crate) struct WasiHostTunnelConnector {
runtime: crate::host::socket::HostSocketRuntime,
io: Arc<WasiHostTunnelIo>,
supported_schemes: Arc<[String]>,
}
#[cfg(feature = "wasm-host-tunnel-outbound")]
impl WasiHostTunnelConnector {
pub(crate) fn new(
runtime: crate::host::socket::HostSocketRuntime,
io: Arc<WasiHostTunnelIo>,
supported_schemes: Arc<[String]>,
) -> Self {
Self {
runtime,
io,
supported_schemes,
}
}
}
#[cfg(feature = "wasm-host-tunnel-outbound")]
#[async_trait::async_trait]
impl ExternalTunnelConnector for WasiHostTunnelConnector {
fn supports_scheme(&self, scheme: &str) -> bool {
self.supported_schemes
.iter()
.any(|supported| supported == scheme)
}
async fn connect(&self, url: &url::Url) -> anyhow::Result<Box<dyn Tunnel>> {
let encoded = url.as_str().as_bytes();
let encoded_len = u32::try_from(encoded.len())
.map_err(|_| anyhow::anyhow!("host tunnel URL exceeds WASI guest memory"))?;
let handle = self
.runtime
.run_operation(
self.io.clone(),
|_, operation| {
status("start_tunnel_connect", unsafe {
start_tunnel_connect(operation.0, encoded.as_ptr() as u32, encoded_len)
})
},
|_, operation| match unsafe { take_tunnel_connect(operation.0) } {
value if value == i64::from(HOST_PENDING) => Poll::Pending,
value if value > 0 => Poll::Ready(Ok(HostSocketHandle(value as u64))),
value => Poll::Ready(Err(host_error(
"take_tunnel_connect",
i32::try_from(value).unwrap_or(i32::MIN),
))),
},
|io, operation| io.cancel_operation(operation),
)
.await?;
let local_url = url::Url::parse(&format!("{}://0.0.0.0:0", url.scheme()))?;
Ok(crate::tunnel::host_tunnel::new_host_tunnel(
self.runtime.clone(),
self.io.clone(),
handle,
local_url,
url.clone(),
None,
))
}
}
#[cfg(not(feature = "wasm-host-tunnel-outbound"))]
type HostTunnelQueue = HostListenerQueue<Box<dyn Tunnel>>;
/// Owns the Host Tunnel I/O Adapter and its listener admission queue.
#[cfg(not(feature = "wasm-host-tunnel-outbound"))]
pub(crate) struct WasiHostTunnelIngress {
runtime: crate::host::socket::HostSocketRuntime,
io: Arc<WasiHostTunnelIo>,
queue: Arc<HostTunnelQueue>,
supported_schemes: Arc<[String]>,
}
#[cfg(not(feature = "wasm-host-tunnel-outbound"))]
impl WasiHostTunnelIngress {
pub(crate) fn new(
runtime: crate::host::socket::HostSocketRuntime,
supported_schemes: Arc<[String]>,
) -> Self {
Self::with_io(
runtime,
Arc::new(WasiHostTunnelIo::default()),
supported_schemes,
)
}
pub(crate) fn with_io(
runtime: crate::host::socket::HostSocketRuntime,
io: Arc<WasiHostTunnelIo>,
supported_schemes: Arc<[String]>,
) -> Self {
Self {
runtime,
io,
queue: Arc::new(HostListenerQueue::new(MAX_PENDING_HOST_TUNNELS)),
supported_schemes,
}
}
pub(crate) fn listener_factory<TcpSocket>(
&self,
) -> Arc<dyn ExternalListenerFactory<AcceptedTransport<TcpSocket>>>
where
TcpSocket: VirtualTcpSocket,
{
Arc::new(WasiHostTunnelListenerFactory {
queue: self.queue.clone(),
supported_schemes: self.supported_schemes.clone(),
})
}
pub(crate) fn accept(
&self,
handle: HostSocketHandle,
metadata: crate::wasi::schema::WasiHostTunnelMetadata,
) -> anyhow::Result<()> {
let crate::wasi::schema::WasiHostTunnelMetadata {
local_url,
remote_url,
resolved_remote_url,
..
} = metadata;
self.queue.enqueue_with(|| {
crate::tunnel::host_tunnel::new_host_tunnel(
self.runtime.clone(),
self.io.clone(),
handle,
local_url,
remote_url,
resolved_remote_url,
)
})
}
}
#[cfg(not(feature = "wasm-host-tunnel-outbound"))]
struct WasiHostTunnelListenerFactory {
queue: Arc<HostTunnelQueue>,
supported_schemes: Arc<[String]>,
}
#[cfg(not(feature = "wasm-host-tunnel-outbound"))]
impl<TcpSocket> ExternalListenerFactory<AcceptedTransport<TcpSocket>>
for WasiHostTunnelListenerFactory
where
TcpSocket: VirtualTcpSocket,
{
fn supports_scheme(&self, scheme: &str) -> bool {
self.supported_schemes
.iter()
.any(|supported| supported == scheme)
}
fn create(
&self,
request: ExternalListenerRequest,
) -> Box<dyn SocketListener<Accepted = AcceptedTransport<TcpSocket>>> {
Box::new(WasiHostTunnelListener {
registered: self.queue.register_listener(),
queue: self.queue.clone(),
local_url: request.url,
tcp_socket: PhantomData,
})
}
}
#[cfg(not(feature = "wasm-host-tunnel-outbound"))]
struct WasiHostTunnelListener<TcpSocket> {
registered: bool,
queue: Arc<HostTunnelQueue>,
local_url: url::Url,
tcp_socket: PhantomData<fn() -> TcpSocket>,
}
#[cfg(not(feature = "wasm-host-tunnel-outbound"))]
impl<TcpSocket> fmt::Debug for WasiHostTunnelListener<TcpSocket> {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("WasiHostTunnelListener")
.field("local_url", &self.local_url)
.field("registered", &self.registered)
.finish()
}
}
#[async_trait::async_trait]
#[cfg(not(feature = "wasm-host-tunnel-outbound"))]
impl<TcpSocket> SocketListener for WasiHostTunnelListener<TcpSocket>
where
TcpSocket: VirtualTcpSocket,
{
type Accepted = AcceptedTransport<TcpSocket>;
async fn listen(&mut self) -> anyhow::Result<()> {
if !self.registered {
anyhow::bail!("Host Tunnel listener queue is closed");
}
Ok(())
}
async fn accept(&mut self) -> anyhow::Result<Self::Accepted> {
let tunnel = self
.queue
.accept()
.await
.ok_or_else(|| anyhow::anyhow!("Host Tunnel listener queue is closed"))?;
Ok(AcceptedTransport::Tunnel {
tunnel,
local_url: self.local_url.clone(),
})
}
fn local_url(&self) -> url::Url {
self.local_url.clone()
}
}
#[cfg(not(feature = "wasm-host-tunnel-outbound"))]
impl<TcpSocket> Drop for WasiHostTunnelListener<TcpSocket> {
fn drop(&mut self) {
if self.registered {
self.queue.unregister_listener();
}
}
}
+29
View File
@@ -6,6 +6,8 @@
pub(crate) const HOST_PENDING: i32 = -1;
pub(crate) const HOST_WOULD_BLOCK: i32 = -5;
#[cfg(feature = "wasm-host-tunnel")]
pub(crate) const HOST_TUNNEL_CLOSED: i32 = -10;
#[link(wasm_import_module = "easytier_host")]
unsafe extern "C" {
@@ -188,6 +190,33 @@ unsafe extern "C" {
/// Reports packet-sink write readiness; it never accepts a packet itself.
pub(crate) fn take_packet_write_ready(operation: u64) -> i32;
#[cfg(feature = "wasm-host-tunnel")]
/// Starts receiving one complete host tunnel payload.
pub(crate) fn start_tunnel_receive(handle: u64, operation: u64, capacity: u32) -> i32;
#[cfg(feature = "wasm-host-tunnel")]
/// Probes or copies one payload, or returns the closed sentinel.
///
/// A null destination with zero capacity returns the payload length without
/// consuming it. A second call copies and consumes the payload.
pub(crate) fn take_tunnel_receive(operation: u64, destination: u32, capacity: u32) -> i32;
#[cfg(feature = "wasm-host-tunnel")]
/// Starts sending one complete host tunnel payload.
pub(crate) fn start_tunnel_send(handle: u64, operation: u64, source: u32, length: u32) -> i32;
#[cfg(feature = "wasm-host-tunnel")]
/// Reports completion of one host tunnel send.
pub(crate) fn take_tunnel_send(operation: u64) -> i32;
#[cfg(feature = "wasm-host-tunnel-outbound")]
/// Starts opening one outbound host tunnel for the requested URL.
pub(crate) fn start_tunnel_connect(operation: u64, url: u32, url_len: u32) -> i32;
#[cfg(feature = "wasm-host-tunnel-outbound")]
/// Returns the connected tunnel handle, or a negative host status.
pub(crate) fn take_tunnel_connect(operation: u64) -> i64;
/// Cancels a pending or completed-but-unread operation and releases host state.
///
/// Cancellation must be idempotent when the operation is already absent.
+164 -3
View File
@@ -45,6 +45,11 @@ pub(super) type WasiCore = crate::instance::CoreInstance<
pub(super) struct WasiCoreRuntime {
socket_runtime: crate::host::socket::HostSocketRuntime,
#[cfg(all(
feature = "wasm-host-tunnel",
not(feature = "wasm-host-tunnel-outbound")
))]
tunnel_ingress: crate::wasi::adapter::tunnel::WasiHostTunnelIngress,
core: std::sync::Arc<WasiCore>,
}
@@ -56,6 +61,27 @@ impl WasiCoreRuntime {
pub(super) fn notify_host_completions(&self) {
self.socket_runtime.notify_completions();
}
#[cfg(all(
feature = "wasm-host-tunnel",
not(feature = "wasm-host-tunnel-outbound")
))]
pub(super) fn accept_tunnel(
&self,
handle: crate::host::socket::HostSocketHandle,
metadata: crate::wasi::schema::WasiHostTunnelMetadata,
) -> anyhow::Result<()> {
self.tunnel_ingress.accept(handle, metadata)
}
#[cfg(feature = "wasm-host-tunnel-outbound")]
pub(super) fn accept_tunnel(
&self,
_handle: crate::host::socket::HostSocketHandle,
_metadata: crate::wasi::schema::WasiHostTunnelMetadata,
) -> anyhow::Result<()> {
anyhow::bail!("Host Tunnel admission is unavailable in outbound-only WASI")
}
}
pub(super) fn new_wasi_core_runtime(
@@ -69,7 +95,6 @@ pub(super) fn new_wasi_core_runtime(
use crate::host::{dns::HostDnsResolver, packet::HostPacketSink, socket::HostSocketRuntime};
use crate::{
connectivity::connector_host::new_connector_host,
instance::{CoreHostAdapters, CoreInstance},
wasi::adapter::{
dns::WasiHostDnsIo, environment::WasiHostConnectorEnvironmentIo,
@@ -78,12 +103,36 @@ pub(super) fn new_wasi_core_runtime(
},
};
#[cfg(not(feature = "wasm-host-tunnel-outbound"))]
use crate::connectivity::connector_host::new_connector_host;
#[cfg(feature = "wasm-host-tunnel-outbound")]
use crate::connectivity::connector_host::new_connector_host_with_external_tunnel;
let socket_runtime = HostSocketRuntime::new();
let socket_backend = Arc::new(WasiHostSocketBackend::default());
let environment_io = Arc::new(WasiHostConnectorEnvironmentIo);
#[cfg(feature = "wasm-host-tunnel")]
let host_tunnel_schemes: Arc<[String]> = Arc::from(["ws".to_owned(), "wss".to_owned()]);
#[cfg(feature = "wasm-host-tunnel-outbound")]
let tunnel_io = Arc::new(crate::wasi::adapter::tunnel::WasiHostTunnelIo::default());
#[cfg(feature = "wasm-host-tunnel-outbound")]
let host = Arc::new(new_connector_host_with_external_tunnel(
socket_runtime.clone(),
socket_backend,
environment_snapshot,
environment_io,
Arc::new(crate::wasi::adapter::tunnel::WasiHostTunnelConnector::new(
socket_runtime.clone(),
tunnel_io,
host_tunnel_schemes.clone(),
)),
));
#[cfg(not(feature = "wasm-host-tunnel-outbound"))]
let host = Arc::new(new_connector_host(
socket_runtime.clone(),
Arc::new(WasiHostSocketBackend::default()),
socket_backend,
environment_snapshot,
Arc::new(WasiHostConnectorEnvironmentIo),
environment_io,
));
let dns = Arc::new(HostDnsResolver::new(
socket_runtime.clone(),
@@ -97,10 +146,45 @@ pub(super) fn new_wasi_core_runtime(
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));
#[cfg(feature = "wasm-host-tunnel-outbound")]
{
adapters.config.connectivity = crate::instance::CoreConnectivityMode::OutboundOnly;
adapters.config.smoltcp_available = true;
adapters.config.requires_smoltcp = true;
adapters.config.gateway_enabled = false;
adapters.config.proxy_enabled = false;
adapters.config.ignore_unsupported_config = true;
adapters.config.endpoint_protocols = host_tunnel_schemes.to_vec();
}
#[cfg(feature = "wasm-host-tunnel")]
#[cfg(not(feature = "wasm-host-tunnel-outbound"))]
let tunnel_ingress = crate::wasi::adapter::tunnel::WasiHostTunnelIngress::new(
socket_runtime.clone(),
host_tunnel_schemes,
);
#[cfg(feature = "wasm-host-tunnel")]
#[cfg(not(feature = "wasm-host-tunnel-outbound"))]
{
adapters.config.connectivity = crate::instance::CoreConnectivityMode::InboundOnly;
adapters.external_listener_factory = Some(tunnel_ingress.listener_factory());
adapters
.host_listener_registrations
.push(crate::listener::ExternalListenerRequest {
url: "wss://0.0.0.0:443"
.parse()
.expect("Host Tunnel listener URL must be valid"),
socket_context: crate::socket::SocketContext::default(),
});
}
let core = CoreInstance::from_toml(config, adapters)?;
Ok(WasiCoreRuntime {
socket_runtime,
#[cfg(all(
feature = "wasm-host-tunnel",
not(feature = "wasm-host-tunnel-outbound")
))]
tunnel_ingress,
core,
})
}
@@ -128,6 +212,8 @@ mod abi {
use super::{WasiCoreRuntime, new_wasi_core_runtime};
use crate::wasi::schema::WasiCoreInstanceCreateConfig;
#[cfg(feature = "wasm-host-tunnel")]
use crate::wasi::schema::WasiHostTunnelMetadata;
#[cfg(feature = "proxy-smoltcp-stack")]
mod data_plane;
@@ -137,6 +223,8 @@ mod abi {
mod web_client;
const MAX_CREATE_CONFIG_LEN: usize = 16 * 1024 * 1024;
#[cfg(feature = "wasm-host-tunnel")]
const MAX_HOST_TUNNEL_METADATA_LEN: usize = 16 * 1024;
const MAX_GUEST_BUFFER_LEN: usize = MAX_CREATE_CONFIG_LEN;
#[cfg(feature = "management-rpc")]
const MAX_RPC_MESSAGE_LEN: usize = 16 * 1024 * 1024;
@@ -480,6 +568,18 @@ mod abi {
}
});
}
#[cfg(feature = "wasm-host-tunnel")]
fn accept_tunnel(
&self,
tunnel_handle: crate::host::socket::HostSocketHandle,
metadata: WasiHostTunnelMetadata,
) -> anyhow::Result<()> {
if self.core.core().state() != CoreInstanceState::Running {
anyhow::bail!("core instance is not running");
}
self.core.accept_tunnel(tunnel_handle, metadata)
}
}
fn decode_create_config(encoded: &[u8]) -> anyhow::Result<WasiCoreInstanceCreateConfig> {
@@ -583,6 +683,10 @@ mod abi {
with_abi_state(|state| state.read_buffer(pointer, length))
}
#[unsafe(no_mangle)]
/// WASI command entrypoint used only to initialize the host runtime.
pub extern "C" fn _start() {}
#[unsafe(no_mangle)]
/// Allocates a guest-owned ABI buffer and returns its linear-memory offset.
///
@@ -795,6 +899,63 @@ mod abi {
})
}
#[cfg(feature = "wasm-host-tunnel")]
#[unsafe(no_mangle)]
/// Returns the host tunnel ABI version implemented by this guest.
pub extern "C" fn easytier_host_tunnel_abi_version() -> u32 {
crate::wasi::abi::HOST_TUNNEL_ABI_VERSION
}
#[cfg(feature = "wasm-host-tunnel")]
#[unsafe(no_mangle)]
/// Transfers one host-owned transport into server tunnel admission.
///
/// `metadata` is a versioned JSON document. A zero return transfers
/// ownership of `tunnel_handle` to the guest; on failure the host keeps
/// ownership and must close it. Admission is scheduled and completed by
/// later drive calls so the export never waits for the peer handshake.
pub extern "C" fn easytier_instance_accept_tunnel(
handle: u64,
tunnel_handle: u64,
metadata_pointer: u32,
metadata_length: u32,
) -> i32 {
if tunnel_handle == 0 {
set_instance_error(handle, "host tunnel handle must be non-zero");
return INVALID_INPUT;
}
let encoded = match read_guest_buffer(
metadata_pointer,
metadata_length,
MAX_HOST_TUNNEL_METADATA_LEN,
) {
Ok(encoded) => encoded,
Err(error) => {
set_instance_error(handle, error);
return INVALID_INPUT;
}
};
let metadata: WasiHostTunnelMetadata = match serde_json::from_slice(&encoded) {
Ok(metadata) => metadata,
Err(error) => {
set_instance_error(handle, error);
return INVALID_INPUT;
}
};
if let Err(error) = metadata.validate() {
set_instance_error(handle, error);
return INVALID_INPUT;
}
with_instance(handle, |instance| {
instance.accept_tunnel(
crate::host::socket::HostSocketHandle(tunnel_handle),
metadata,
)?;
Ok(0)
})
}
#[unsafe(no_mangle)]
/// Destroys an instance and releases its lifecycle, timer, and runtime state.
pub extern "C" fn easytier_instance_drop(handle: u64) -> i32 {
@@ -233,7 +233,7 @@ fn require_ipv4(address: SocketAddr) -> Result<SocketAddr, DataPlaneError> {
address.is_ipv4().then_some(address).ok_or_else(|| {
error(
DataPlaneErrorKind::AddressFamilyUnsupported,
"data-plane ABI v2 supports IPv4 only",
"data-plane ABI supports IPv4 only",
)
})
}
@@ -355,6 +355,24 @@ pub extern "C" fn easytier_data_plane_tcp_write_submit(
})
}
#[unsafe(no_mangle)]
pub extern "C" fn easytier_data_plane_tcp_shutdown_write_submit(
handle: u64,
stream: u64,
output_operation: u32,
) -> i32 {
let stream = match resource_id(stream) {
Ok(stream) => stream,
Err(error) => {
set_instance_error(handle, error.message());
return error_status(error.kind());
}
};
submit_operation(handle, output_operation, |instance| {
instance.submit_data_plane(|session| session.submit_tcp_shutdown_write(stream))
})
}
#[unsafe(no_mangle)]
pub extern "C" fn easytier_data_plane_udp_bind_submit(
handle: u64,
@@ -660,6 +678,26 @@ pub extern "C" fn easytier_data_plane_tcp_write_result_take(handle: u64, operati
})
}
#[unsafe(no_mangle)]
pub extern "C" fn easytier_data_plane_tcp_shutdown_write_result_take(
handle: u64,
operation: u64,
) -> i32 {
data_plane_call(handle, |instance| {
let session = instance.data_plane_session();
take_result(
&session,
operation_id(operation)?,
DataPlaneOperationKind::TcpShutdownWrite,
|result| match result {
DataPlaneOperationResult::TcpWriteShutdown => Ok(()),
_ => Err(invalid_input("TCP write shutdown result variant mismatch")),
},
)?;
Ok(0)
})
}
#[unsafe(no_mangle)]
pub extern "C" fn easytier_data_plane_udp_bind_result_take(
handle: u64,
+5 -1
View File
@@ -12,6 +12,10 @@ use std::{
use tokio::runtime::Runtime;
// A zero-duration timer can win before the executor's park hook observes
// quiescence, especially when JSPI resumes the guest on a slower embedder.
const DRIVE_BUDGET: Duration = Duration::from_millis(10);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum RuntimeDriveOutcome {
Quiescent,
@@ -52,7 +56,7 @@ impl RuntimeDriver {
let _active = RuntimeDriverGuard::activate(self.state.as_ref());
runtime.block_on(async {
let budget = tokio::time::sleep(Duration::ZERO);
let budget = tokio::time::sleep(DRIVE_BUDGET);
tokio::pin!(budget);
poll_fn(|context| {
if self.state.poll_quiescent(context.waker()) {
+37
View File
@@ -60,3 +60,40 @@ impl WasiWebClientCreateConfig {
Ok(())
}
}
#[cfg(feature = "wasm-host-tunnel")]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct WasiHostTunnelMetadata {
pub version: u32,
pub local_url: url::Url,
pub remote_url: url::Url,
#[serde(default)]
pub resolved_remote_url: Option<url::Url>,
}
#[cfg(feature = "wasm-host-tunnel")]
impl WasiHostTunnelMetadata {
pub(crate) fn validate(&self) -> anyhow::Result<()> {
if self.version != crate::wasi::abi::HOST_TUNNEL_ABI_VERSION {
anyhow::bail!("unsupported host tunnel ABI version: {}", self.version);
}
Ok(())
}
}
#[cfg(all(test, feature = "wasm-host-tunnel"))]
mod tests {
use super::*;
#[test]
fn host_tunnel_metadata_accepts_transport_neutral_urls() {
let metadata = WasiHostTunnelMetadata {
version: crate::wasi::abi::HOST_TUNNEL_ABI_VERSION,
local_url: "test-tunnel://listener.example/".parse().unwrap(),
remote_url: "test-tunnel://peer.example/".parse().unwrap(),
resolved_remote_url: None,
};
metadata.validate().unwrap();
}
}
+1 -1
View File
@@ -22,7 +22,7 @@ pub(crate) fn decode_ipv4_socket_address(wire: &[u8]) -> io::Result<SocketAddr>
if !address.is_ipv4() {
return Err(io::Error::new(
io::ErrorKind::Unsupported,
"data-plane ABI v2 supports IPv4 only",
"data-plane ABI supports IPv4 only",
));
}
Ok(address)
+27
View File
@@ -0,0 +1,27 @@
# Known limitations
These edge cases are intentionally kept separate from feature changes so they
can be addressed without expanding unrelated patches.
## Port-forward bind conflicts
`InstanceConfigBuilder.Build` rejects exact duplicate port-forward rules, but
two rules with the same protocol and bind address and different destinations
are rejected later by `Instance.Start` when Core binds the second socket.
Callers should keep each `(protocol, bind address)` pair unique.
## Maximum UDP datagram reassembly
Core buffers can carry the maximum IPv4 UDP payload of 65,507 bytes. At the
current data-plane MTU this requires about 52 IPv4 fragments, while smoltcp can
track 16 disjoint reassembly segments. Extremely out-of-order delivery that
creates more than 16 holes can therefore drop an otherwise valid maximum-size
datagram. Ordered delivery is covered by the current tests.
## First inbound `ListenPacket` flow
An `Instance.ListenPacket` socket currently installs an exact UDP data-plane
flow after sending to a peer. A newly bound socket may not receive the first
datagram from a previously unseen peer until a reciprocal flow has been
established. This does not affect the TUN packet plane or Core port-forward
destinations reached through it.
+165
View File
@@ -0,0 +1,165 @@
GNU LESSER GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
This version of the GNU Lesser General Public License incorporates
the terms and conditions of version 3 of the GNU General Public
License, supplemented by the additional permissions listed below.
0. Additional Definitions.
As used herein, "this License" refers to version 3 of the GNU Lesser
General Public License, and the "GNU GPL" refers to version 3 of the GNU
General Public License.
"The Library" refers to a covered work governed by this License,
other than an Application or a Combined Work as defined below.
An "Application" is any work that makes use of an interface provided
by the Library, but which is not otherwise based on the Library.
Defining a subclass of a class defined by the Library is deemed a mode
of using an interface provided by the Library.
A "Combined Work" is a work produced by combining or linking an
Application with the Library. The particular version of the Library
with which the Combined Work was made is also called the "Linked
Version".
The "Minimal Corresponding Source" for a Combined Work means the
Corresponding Source for the Combined Work, excluding any source code
for portions of the Combined Work that, considered in isolation, are
based on the Application, and not on the Linked Version.
The "Corresponding Application Code" for a Combined Work means the
object code and/or source code for the Application, including any data
and utility programs needed for reproducing the Combined Work from the
Application, but excluding the System Libraries of the Combined Work.
1. Exception to Section 3 of the GNU GPL.
You may convey a covered work under sections 3 and 4 of this License
without being bound by section 3 of the GNU GPL.
2. Conveying Modified Versions.
If you modify a copy of the Library, and, in your modifications, a
facility refers to a function or data to be supplied by an Application
that uses the facility (other than as an argument passed when the
facility is invoked), then you may convey a copy of the modified
version:
a) under this License, provided that you make a good faith effort to
ensure that, in the event an Application does not supply the
function or data, the facility still operates, and performs
whatever part of its purpose remains meaningful, or
b) under the GNU GPL, with none of the additional permissions of
this License applicable to that copy.
3. Object Code Incorporating Material from Library Header Files.
The object code form of an Application may incorporate material from
a header file that is part of the Library. You may convey such object
code under terms of your choice, provided that, if the incorporated
material is not limited to numerical parameters, data structure
layouts and accessors, or small macros, inline functions and templates
(ten or fewer lines in length), you do both of the following:
a) Give prominent notice with each copy of the object code that the
Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the object code with a copy of the GNU GPL and this license
document.
4. Combined Works.
You may convey a Combined Work under terms of your choice that,
taken together, effectively do not restrict modification of the
portions of the Library contained in the Combined Work and reverse
engineering for debugging such modifications, if you also do each of
the following:
a) Give prominent notice with each copy of the Combined Work that
the Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the Combined Work with a copy of the GNU GPL and this license
document.
c) For a Combined Work that displays copyright notices during
execution, include the copyright notice for the Library among
these notices, as well as a reference directing the user to the
copies of the GNU GPL and this license document.
d) Do one of the following:
0) Convey the Minimal Corresponding Source under the terms of this
License, and the Corresponding Application Code in a form
suitable for, and under terms that permit, the user to
recombine or relink the Application with a modified version of
the Linked Version to produce a modified Combined Work, in the
manner specified by section 6 of the GNU GPL for conveying
Corresponding Source.
1) Use a suitable shared library mechanism for linking with the
Library. A suitable mechanism is one that (a) uses at run time
a copy of the Library already present on the user's computer
system, and (b) will operate properly with a modified version
of the Library that is interface-compatible with the Linked
Version.
e) Provide Installation Information, but only if you would otherwise
be required to provide such information under section 6 of the
GNU GPL, and only to the extent that such information is
necessary to install and execute a modified version of the
Combined Work produced by recombining or relinking the
Application with a modified version of the Linked Version. (If
you use option 4d0, the Installation Information must accompany
the Minimal Corresponding Source and Corresponding Application
Code. If you use option 4d1, you must provide the Installation
Information in the manner specified by section 6 of the GNU GPL
for conveying Corresponding Source.)
5. Combined Libraries.
You may place library facilities that are a work based on the
Library side by side in a single library together with other library
facilities that are not Applications and are not covered by this
License, and convey such a combined library under terms of your
choice, if you do both of the following:
a) Accompany the combined library with a copy of the same work based
on the Library, uncombined with any other library facilities,
conveyed under the terms of this License.
b) Give prominent notice with the combined library that part of it
is a work based on the Library, and explaining where to find the
accompanying uncombined form of the same work.
6. Revised Versions of the GNU Lesser General Public License.
The Free Software Foundation may publish revised and/or new versions
of the GNU Lesser General Public License from time to time. Such new
versions will be similar in spirit to the present version, but may
differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the
Library as you received it specifies that a certain numbered version
of the GNU Lesser General Public License "or any later version"
applies to it, you have the option of following the terms and
conditions either of that published version or of any later version
published by the Free Software Foundation. If the Library as you
received it does not specify a version number of the GNU Lesser
General Public License, you may choose any version of the GNU Lesser
General Public License ever published by the Free Software Foundation.
If the Library as you received it specifies that a proxy can decide
whether future versions of the GNU Lesser General Public License shall
apply, that proxy's public statement of acceptance of any version is
permanent authorization for you to choose that version for the
Library.
+378
View File
@@ -0,0 +1,378 @@
# Performance log
This file records reproducible `easytier-go` data-plane optimizations.
Each round must describe the change, the benchmark conditions, the before and
after result, and any profile evidence used to choose the next change.
## Benchmark setup
- Host CPU: Intel Core i7-14700KF.
- Go host CPUs: P-core logical CPUs `8,10,12,14`.
- Native peer and iperf CPUs: P-core logical CPUs `0,2,4,6`.
- Underlay: UDP over the local Docker bridge.
- Native peer: `10.15.15.1`, listening on underlay UDP port `12012`.
- Go host: `10.15.15.2`, forwarding local port `5202` through
`Instance.Dial` to `10.15.15.1:5201`.
- Go forwarder: `go run ./cmd/dial-forward-bench`; it intentionally keeps
forwarding outside the Core so measurements include the public data-plane
ABI.
- MTU: 1380; encryption enabled with the default algorithm.
- TCP tests use one stream unless noted otherwise.
- UDP tests use 1200-byte datagrams.
- Reported results exclude the first two seconds of each iperf run.
The native comparison uses EasyTier's built-in port forward on the same
machine, CPU groups, peer, network, and iperf server. It isolates the overhead
of the Go/WASM data-plane interface from EasyTier's virtual TCP/UDP stack and
transport.
## Round 0: Dial baseline
Go host commit: `a73f2bac`
EasyTier commit: `30764897`
### Throughput
| Path | Direction | Offered load | Result |
| --- | --- | ---: | ---: |
| Go `DialTCP` | Go to native | unlimited | 14.0 Mbit/s |
| Go `DialTCP` | native to Go | unlimited | 1.56 Gbit/s |
| Go `DialTCP`, 4 streams | Go to native | unlimited | 56.6 Mbit/s |
| Native TCP port forward | forward | unlimited | 1.35 Gbit/s |
| Native TCP port forward | reverse | unlimited | 3.65 Gbit/s |
| Go `DialUDP` | Go to native | 10 Mbit/s | 10.0 Mbit/s, 0% loss |
| Go `DialUDP` | Go to native | 100 Mbit/s | tail stalled after 4 seconds |
| Go `DialUDP` | native to Go | 100 Mbit/s | 61.6 Mbit/s, 38% loss |
| Go `DialUDP` | native to Go | 1 Gbit/s | 86.5 Mbit/s, 91% loss |
| Native UDP port forward | forward | 1 Gbit/s | 999 Mbit/s |
At a 100 Mbit/s forward UDP load, the receiver averaged 40.9 Mbit/s over the
full test because traffic stopped after four measured seconds. This is not a
stable throughput result.
### TCP forward profile
`perf stat` attached to the Go host during a 10-second single-stream test:
| Counter | Value |
| --- | ---: |
| elapsed time | 12.014 s |
| task clock | 1.186 s |
| context switches | 89,072 |
| CPU migrations | 12,015 |
| core cycles | 5.98 billion |
| core instructions | 4.09 billion |
| core cache misses | 23.0 million |
The Go process consumed only 0.099 CPU equivalents while forwarding at
14 Mbit/s. Four TCP streams scaled almost exactly linearly, showing a
per-stream serialization limit rather than CPU or transport saturation.
The Go `streamConn.Write` waits for every guest TCP-write operation. The guest
operation currently calls `AsyncWriteExt::write`, which may complete after a
partial write. Each partial result therefore incurs another complete
submit/drive/complete/take host-to-WASM cycle. The native port-forward result
shows that EasyTier's underlying data plane is not the limiting component.
## Round 1: finish each guest TCP write
Go host commit: `a73f2bac`
EasyTier commit: `73612470`
The guest TCP operation now uses `write_all` semantics instead of completing
after the first partial virtual-socket write.
| Path | Before | After | Change |
| --- | ---: | ---: | ---: |
| TCP forward | 14.0 Mbit/s | 15.9 Mbit/s | +13.6% |
This removed redundant operation completion cycles, but CPU utilization
remained low. The result showed that partial completion was real overhead but
was not the main serialization point.
## Round 2: repoll immediately when smoltcp has ready egress
EasyTier commit: `824e01a3`
The smoltcp reactor reported a zero poll delay when more egress was ready, but
the portable runtime crossed the host timer boundary before polling again.
The reactor now polls again in the same drive turn for a zero delay.
| Path | Before | After | Change |
| --- | ---: | ---: | ---: |
| TCP forward | 15.9 Mbit/s | 1.17 Gbit/s | 73.6x |
| Median packet gap | 891 us | 8.41 us | -99.1% |
The packet-gap distribution was captured in
`/tmp/easytier-dial-forward-immediate-repoll.pcap`. Extending every WASI drive
turn by a fixed 50 us did not improve throughput and was later reverted; the
zero-delay signal was the correct condition for immediate work.
## Round 3: preserve pending UDP operations
Go host commit: `cf53f92`
The example refreshed `SetDeadline` before every datagram. Each refresh
cancelled the currently pending read operation, causing forwarding to stall
under sustained load. Deadline refreshes are now limited to half of the idle
timeout, retaining idle-session cleanup without repeatedly cancelling active
operations.
| UDP forward offered load | Before | After |
| --- | ---: | ---: |
| 300 Mbit/s | 45.3 Mbit/s average, tail stalled | 298 Mbit/s, 0.8% loss |
Logging added while diagnosing this behavior showed successful writes until
the receive operation was cancelled; the stall was not a transport write
failure.
## Round 4: reduce per-datagram host/guest overhead
The following measurements use a 1 Gbit/s offered UDP load. Each row is
measured against the immediately preceding retained state.
| Change | Commit | Before | After | Change |
| --- | --- | ---: | ---: | ---: |
| Reuse operation deadline contexts | `3d7b622` | 525 Mbit/s | 558 Mbit/s | +6.3% |
| Reuse separate UDP address and payload inputs | `f9092c9` | 558 Mbit/s | 587 Mbit/s | +5.2% |
| Reuse data-plane output storage | `f469a34` | 587 Mbit/s | 652 Mbit/s | +11.1% |
| Restore a zero-duration WASI drive budget | `a5ea9b6` | 652 Mbit/s | 672 Mbit/s | +3.1% |
| Cache hot guest function handles | `47f0809` | 672 Mbit/s | 677 Mbit/s | +0.7% |
| Reuse the instance deadline timer | `657022b` | 677 Mbit/s | 677 Mbit/s | neutral |
| Coalesce immediately completed outcomes | `9d1ec76` | 677 Mbit/s | 688 Mbit/s | +1.6% |
The reusable input experiment initially used an interior pointer into one
larger guest allocation. The ABI only accepts allocation base pointers, so
the final implementation keeps the remote address and payload in distinct
reusable allocations.
Restoring the zero-duration drive budget also raised TCP forward throughput
from 1.17 to approximately 1.30 Gbit/s. Reusing the deadline timer was kept
despite neutral throughput because it removes a timer allocation from every
operation without adding synchronization.
The final rebuilt artifact and host measured 693 Mbit/s UDP forward, within
the variation of the 688 Mbit/s retained baseline.
### Profile evidence
The final-stage profile is stored at
`/tmp/easytier-dial-udp-zero-budget.perf.data`. Approximately 49% of samples
were in the WASM JIT and 31% in the Go executable, with the remainder
primarily in the kernel. Before function caching, `NextDeadline` accounted
for 2.25% and Go map hashing for 1.48%. After removing these lookup costs, no
remaining individual host-side helper accounted for enough time to explain
the gap to TCP; guest drive execution and one-operation-per-datagram boundary
crossings dominate.
## Round 5: enlarge data-plane UDP receive queues
EasyTier commit: `1df439d`
The data-plane TCP buffers were already 128 KiB, while UDP inherited the
smoltcp default receive queue of about 8 KiB, enough for only a few 1200-byte
datagrams. The data-plane-only UDP receive buffer is now 128 KiB with 128
packet metadata entries.
| UDP reverse offered load | Before | After |
| --- | ---: | ---: |
| 100 Mbit/s | 61.6 Mbit/s, 38% loss | 100 Mbit/s, 0% loss |
| 300 Mbit/s | not stable | 300 Mbit/s, 0.15% loss |
| 500 Mbit/s | not stable | 495 Mbit/s, 0.95% loss |
| 800 Mbit/s | 558 Mbit/s saturated | about 570 Mbit/s saturated |
Removing the redundant receive-result-size ABI query in `104223c` raised the
800 Mbit/s saturated result from 558 to 563 Mbit/s. Subsequent retained
changes brought it to about 570 Mbit/s.
## Rejected experiments
Rejected changes were reverted and are not part of the final data path.
| Experiment | Before | After | Reason rejected |
| --- | ---: | ---: | --- |
| Fixed 50 us WASI drive budget | 1.17 Gbit/s TCP | no gain | Added unconditional work instead of following readiness |
| Pool queued UDP payloads | 652 Mbit/s | 651 Mbit/s | `sync.Pool` overhead offset allocation savings |
| Lock guest driver to one OS thread | 652 Mbit/s | 436 Mbit/s | Prevented the Go scheduler from placing other work effectively |
| Return tickets before the first drive | 677 Mbit/s | 564 Mbit/s | Added an extra wakeup to the normal completion path |
| Pool one-shot response channels | 688 Mbit/s | 654 Mbit/s | Pool bookkeeping cost more than direct allocation |
Pinning the complete process to one P-core produced 680 Mbit/s on the earlier
652 Mbit/s build, showing that CPU migration and core selection affect the
result. Thread pinning inside the library was nevertheless rejected because
it regressed throughput and imposed scheduling policy on applications.
## Round 6: preserve TCP write progress across cancellation
EasyTier commits: `6b55489`, `4d270f3`
`write_all` kept the TCP fast path inside the guest, but it could lose the
already-written prefix when a deadline update cancelled the future. The host
would then replay the complete chunk. The replacement still completes writes
inside the guest, but uses cancellation-safe underlying writes and reports a
completed prefix before observing cancellation or deadline expiry.
WebClient retry, feature timeout, heartbeat, and UDP port-mapping renewal
timers now use the tracked portable time abstraction. This lets an externally
driven host advertise and advance their deadlines without adding a periodic
timer-driving fallback.
| CPU | Direction | Before | After | Change |
| --- | --- | ---: | ---: | ---: |
| i7-14700KF | TCP forward | 1.30 Gbit/s | 1.31 Gbit/s | within variation |
| i7-14700KF | TCP reverse | 1.60 Gbit/s | 1.61 Gbit/s | within variation |
| Intel N100 | TCP forward | 442 Mbit/s | 436 Mbit/s | -1.4% |
| Intel N100 | TCP reverse | 524 Mbit/s | 517 Mbit/s | -1.3% |
The N100 results varied by more than these deltas between one-second
intervals, so the change has no measurable systematic throughput cost.
## Round 7: store deadlines on guest resources
EasyTier commits: `05ba813`, `66252ed`
Go host commits: `200f32c`, `59d6e5a`
The ABI previously passed a remaining timeout with every TCP and UDP data
operation. Once a Go caller configured a connection deadline, the WASI guest
therefore registered a new timer for every read and write. Deadline changes
also required the Go host to cancel and resubmit an active operation.
Data-plane ABI v3 stores independent read and write deadlines on each TCP or
UDP resource. Hosts update them through one resource setter, while data
operations reuse the active expiration signal without creating timers.
Connect, bind, and accept retain their per-operation timeouts.
The dedicated A/B benchmark compared the pre-change `fd1be5c` Go host with
the updated host. The helper connected directly to the native peer through
`udp://172.17.0.3:12012`, set a one-hour deadline once when requested, and
excluded the first two seconds. It used the CPU groups described above.
| TCP forward | Before | After | Change |
| --- | ---: | ---: | ---: |
| No deadline | 1.30 Gbit/s | 1.30 Gbit/s | neutral |
| One-hour deadline | 1.29 Gbit/s | 1.32 Gbit/s | within variation |
An earlier run reported approximately 750 Mbit/s, but its helper used a
loopback peer URI across different network namespaces while the machine was
also under compiler load. Those TCP and UDP figures were discarded because
the traffic path was not controlled. The direct A/B result shows that
resource deadlines retain the established TCP throughput and remove the
per-operation timer construction without a measurable deadline penalty.
## Round 8: batch host TCP underlay writes
EasyTier commit: `cf26d50`
Go host commits: `8d010e0`, `4197f0d`
The TUN example connected directly to the long-running native peer through
`tcp://172.17.0.2:11010`. The overlay addresses were `10.12.12.2` and
`10.12.12.1`. Tests used one iperf TCP stream for 15 seconds and excluded
the first two seconds. The application and native peer remained unpinned to
match the reported command; the iperf client used CPUs `0,2,4,6`.
The TCP host imports still used wazero's reflection-based `WithFunc` path.
Changing the four stream read and write imports to typed module functions
removed reflection and signature decoding from every host operation.
EasyTier's `FramedWriter` already queues up to 64 frames and exposes them as
vectored slices. `HostTcpStream` did not advertise vectored-write support, so
`poll_write_buf` submitted only the first roughly 1.4 KB frame. Each VPN
packet therefore required a separate asynchronous host operation and guest
wakeup. `HostTcpStream` now combines the queued slices and submits the batch
through the existing ordered write operation. The ABI and concurrency model
are unchanged.
| Underlay and implementation | Forward throughput | Go host CPU |
| --- | ---: | ---: |
| TCP, baseline | 302-305 Mbit/s | about 1.44 cores |
| TCP, typed host imports | 394 Mbit/s | about 1.58 cores |
| TCP, typed imports and vectored writes | 1.88-1.93 Gbit/s | about 2.12 cores |
| UDP reference | 914-924 Mbit/s | about 2.01 cores |
With probes attached, baseline TCP issued about 25,000 host writes and
36,000 guest drive calls per second while forwarding at 234 Mbit/s. The
vectored build issued about 6,800 host writes and 14,000 drive calls per
second while forwarding at 1.85 Gbit/s. Average payload per host write grew
from approximately 1.2 KB to 34 KB.
TCP reverse throughput reached 969 Mbit/s, compared with 1.05 Gbit/s over
the UDP underlay in the same unpinned setup. The remaining reverse difference
was 7.7%, rather than the original threefold forward gap.
## Round 9: batch host TCP underlay reads
EasyTier commit: `398b699`
The forward write batching left the opposite direction asymmetric.
`FramedReader` normally supplied only 2-4 KiB of spare capacity, so each host
TCP read transferred at most a few frames before completing the Go operation,
copying into guest memory, and waking the guest executor.
`HostTcpStream` now requests a bounded 64 KiB read even when its caller
provides a smaller buffer. It returns the requested prefix immediately and
retains the remainder in its existing read buffer. The stream still permits
only one pending host read, so the ABI and concurrency model are unchanged
and read-ahead remains bounded per active TCP stream.
The direct A/B used the Round 8 topology and CPU placement. System variation
put the old artifact at 850 Mbit/s reverse and 1.79 Gbit/s forward during this
comparison.
| Artifact and direction | Throughput | Go host CPU |
| --- | ---: | ---: |
| Old artifact, reverse | 850 Mbit/s | not sampled |
| 64 KiB read-ahead, reverse, first run | 1.80 Gbit/s | about 2 cores |
| 64 KiB read-ahead, reverse, repeat | 1.84 Gbit/s | about 2 cores |
| 64 KiB read-ahead, reverse, 60 seconds | 1.90 Gbit/s | not sampled |
| Old artifact, forward | 1.79 Gbit/s | not sampled |
| 64 KiB read-ahead, forward | 1.75-1.76 Gbit/s | not sampled |
The repeat reverse run improved by 116%, and the 60-second run showed no
tail stall. Forward changed by about 2%, within the observed run-to-run
variation. Reverse and forward are therefore symmetric under the same test
conditions without enlarging the public ABI or adding concurrent reads.
## Intel N100 comparison
The N100 has four physical E-cores with no SMT. The Go host was pinned to
CPUs `0-2`, while iperf used CPU `3`. The native EasyTier peer and iperf
server remained on the i7-14700KF host.
Raw TCP between the machines reached 2.30 Gbit/s forward and 2.35 Gbit/s
reverse, excluding the network as the limiting component.
| Path | Direction/load | Result |
| --- | --- | ---: |
| Go `DialTCP` | Go to native | 436 Mbit/s |
| Go `DialTCP` | native to Go | 517 Mbit/s |
| Go `DialUDP` | forward, 1 Gbit/s offered | 134 Mbit/s, saturated |
| Go `DialUDP` | reverse, 300 Mbit/s offered | 120 Mbit/s, saturated |
| Go `DialUDP` | forward, 100 Mbit/s offered | 99.3 Mbit/s, 0.79% loss |
| Go `DialUDP` | reverse, 100 Mbit/s offered | 99.7 Mbit/s, 0.33% loss |
At saturation the Go host used approximately 1.24-1.41 CPU equivalents.
UDP loses proportionally more performance than TCP on the weaker cores,
consistent with the one-operation-per-datagram ABI and scheduling cost.
## Final result
Final Go host commit under test: `47949b5`
Embedded EasyTier commit: `4d270f3`
| Path | Direction | Offered load | Final result |
| --- | --- | ---: | ---: |
| Go `DialTCP` | Go to native | unlimited | 1.31 Gbit/s |
| Go `DialTCP` | native to Go | unlimited | 1.61 Gbit/s |
| Go `DialUDP` | Go to native | 1 Gbit/s | 684 Mbit/s |
| Go `DialUDP` | native to Go | 500 Mbit/s | 494 Mbit/s, 1.2% loss |
| Go `DialUDP` | native to Go | 800 Mbit/s | 578 Mbit/s, 28% loss |
Final TCP and UDP runs lasted 15 seconds after a three-second warm-up. The
UDP API remains one operation per datagram. A batch API was deliberately not
introduced because it would add a new public operation model and buffering
policy for a path whose current performance is acceptable.
+352
View File
@@ -0,0 +1,352 @@
# EasyTier Go
`easytier-go` runs the `wasm32-wasip1` build of `easytier-core` in a
pure-Go process through wazero. EasyTier remains the source and producer of the
embedded WASM; this repository adapts Go host capabilities to the ABI exported
and imported by that artifact.
```go
import (
"net/netip"
corehost "github.com/EasyTier/EasyTier/easytier-go"
)
```
## Public API
The public package owns wazero, standard WASI, the EasyTier host ABI, guest
driving, completion notification, and resource shutdown. Applications create a
host, build a typed instance configuration, and then use standard Go network
interfaces:
```go
host, err := corehost.New(ctx, corehost.Options{})
if err != nil {
return err
}
defer host.Close(ctx)
config, err := corehost.NewInstanceConfigBuilder("office").
NetworkSecret("secret").
IPv4(netip.MustParsePrefix("10.144.0.10/24")).
AddPeers("tcp://198.51.100.10:11010").
Build()
if err != nil {
return err
}
instance, err := host.CreateInstance(ctx, config)
if err != nil {
return err
}
defer instance.Close(ctx)
if err := instance.Start(ctx); err != nil {
return err
}
if err := instance.SendPacket(ctx, packet); err != nil {
return err
}
received, err := instance.ReceivePacket(ctx)
listener, err := instance.Listen("tcp4", ":8080")
connection, err := instance.Dial(ctx, "tcp4", "10.144.0.2:8080")
packets, err := instance.ListenPacket("udp4", ":5353")
```
### Web Client management
A host can also connect to an EasyTier Web configuration server. The embedded
Rust WebClient retains the config-server protocol, heartbeat, reconnect, and
secure-tunnel behavior; Go owns the resulting process-level instances:
```go
webClient, err := host.ConnectWebClient(ctx, corehost.WebClientOptions{
Endpoint: "udp://config.example.com:22020/team-token",
MachineID: "11111111-2222-4333-8444-555555555555",
Hostname: "edge-gateway",
SecureMode: true,
})
if err != nil {
return err
}
defer webClient.Close(ctx)
for _, instance := range host.Instances() {
log.Printf("%s: %v", instance.ID(), instance.State())
}
```
`MachineID` must be a stable UUID persisted by the application. `Endpoint`
accepts `tcp://`, `udp://`, or the same shorthand token understood by native
EasyTier. WebSocket transports are not part of this initial host integration.
One WebClient may run per `Host`.
Web-created instances support the complete `WebClientService` lifecycle and
status surface. Instances created through `Host.CreateInstance` are included
in heartbeats and status listings, but are reported as read-only and cannot be
overwritten, retained away, or deleted by the Web server. `Host.Instances`
returns both ownership classes; Web-created instances use the same `Instance`
data-plane and management APIs as application-created instances.
`Instance.ListPeer` and `Instance.ListRoute` call the embedded core's existing
instance-scoped management RPCs and return peer and route slices directly.
Their element types reuse the generated EasyTier protobuf models, while the
request and response envelopes stay internal to the host. Callers never
construct wire bytes or a separate RPC client. Cancelling the context frees
the pending guest operation.
`InstanceConfigBuilder` exposes the instance settings supported by this host:
network identity, hostname, virtual IPv4 address, peers and listeners, IPv4 and
IPv6 STUN servers, Core-owned TCP and UDP port forwards, P2P policy,
hole-punching methods, encryption, and secure mode. Omitted optional settings
retain the embedded core's defaults. Calling `STUNServers()` or
`STUNServersV6()` with no arguments explicitly selects an empty list.
`AddPortForwards` accepts typed rules containing a `PortForwardTCP` or
`PortForwardUDP` protocol and `netip.AddrPort` bind and destination addresses.
The embedded core owns their listener, overlay-flow, reload, and shutdown
lifecycle.
Secure mode can generate an X25519 key with `SecureMode()` or use a caller
supplied raw 32-byte private key with `SecureModeWithPrivateKey(key)`. The
public key is always derived by the builder. Secure mode currently requires a
non-empty shared network secret; credential-based networks are a separate
future configuration path.
`Dial` returns `net.Conn`, `Listen` returns `net.Listener`, and `ListenPacket`
returns `net.PacketConn`. ABI v2 currently supports `tcp`, `tcp4`, `udp`, and
`udp4`; destinations must be IPv4 literals and listeners bind all overlay IPv4
addresses. These APIs are overlay-only: an absent EasyTier route is returned as
a normal network error and never falls back to the host network.
See [KNOWN_LIMITATIONS.md](KNOWN_LIMITATIONS.md) for current UDP and
port-forward edge cases.
No public type exposes wazero runtimes, WebAssembly pointers, raw handles,
submit/take operations, or the cooperative `drive` loop. Each host owns one
wazero runtime, guest module, and host completion domain; each instance is
represented by an EasyTier guest handle. Per-instance drivers serialize guest
calls through the host. The engine continues driving EasyTier after `Start`
returns, calls `easytier_instance_notify_completions` before driving a host
completion, and drains bounded data-plane completion batches after each guest
turn.
The host serializes the typed configuration to TOML internally, wraps it in
EasyTier's version 14 create envelope, and adds the configured environment
snapshot. TOML, schema versions, and JSON envelopes are not
application-facing APIs.
## Cross-platform TUN example
The TUN example joins an existing EasyTier network with a fixed virtual IPv4
address on Linux, macOS, or Windows. It creates and configures the native TUN
interface itself, then forwards raw IPv4 packets through `SendPacket` and
`ReceivePacket`:
```sh
cd examples/tun
sudo go run . \
-p tcp://198.51.100.10:11010 \
--network-name office \
--network-secret secret \
--ipv4 10.144.0.10/24
```
Repeat `-p` to configure more peers. The command creates `et-goN` on Linux and
Windows or `utunN` on macOS, assigns the requested address, and sets an MTU of
1380. Run it as root or with `CAP_NET_ADMIN` on Linux, with `sudo` on macOS, or
from an Administrator terminal on Windows. Closing the command removes the TUN
interface. The example does not install a default route or enable GSO.
On Linux and macOS, send `SIGUSR1` to print the current peer list or `SIGUSR2`
to print the current route list.
Repeat `-port-forward` to expose local TCP or UDP ports through the embedded
core's port-forward manager:
```sh
sudo go run . \
-p tcp://198.51.100.10:11010 \
--network-name office \
--network-secret secret \
--ipv4 10.144.0.10/24 \
-port-forward tcp://127.0.0.1:5202/10.144.0.20:5201 \
-port-forward udp://127.0.0.1:5202/10.144.0.20:5201
```
For example, run `iperf3 -c 127.0.0.1 -p 5202` for TCP or add
`-u -b 0 -l 1200` for UDP. iperf3's UDP mode still needs the TCP forward for
its control connection. The TUN example only parses these rules into the
instance configuration; the core owns the host listeners and per-client
overlay flows.
## Instance.Dial example
The Dial example is a small overlay client dedicated to the public
`Instance.Dial` API. TCP mode bridges the connected stream to standard input
and output until the remote side closes or the command is interrupted:
```sh
printf 'GET / HTTP/1.0\r\nHost: 10.144.0.20\r\n\r\n' |
go run ./examples/dial \
-p tcp://198.51.100.10:11010 \
--network-name office \
--network-secret secret \
--ipv4 10.144.0.10/24 \
--network tcp4 \
--address 10.144.0.20:8080
```
With `--network udp4`, standard input is sent as one datagram and one response
datagram is written to standard output. The command does not create a local
listener or implement port-forward management. It waits up to 10 seconds for a
matching overlay or proxy route before dialing; override that limit with
`--connect-timeout`.
## Web Client example
The Web Client example registers a Host with an EasyTier Web configuration
server and lets the server create, delete, and inspect its instances:
```sh
go run ./examples/web-client \
--web-endpoint tcp://config.example.com:22020/team-token \
--web-machine-id 11111111-2222-4333-8444-555555555555 \
--web-hostname edge-gateway \
--web-secure
```
`--web-machine-id` must remain stable across restarts. `--web-hostname` defaults
to the system hostname. This example manages Host instances but does not create
or attach an operating-system TUN interface.
## Performance compared with native EasyTier
In this A/B benchmark two nodes on one i7-14700KF host (Linux 6.11) each run
in their own network namespace, joined by a veth pair. Node A always runs a
native `easytier-core` build of EasyTier master (`2.6.4-6a186167`) with
overlay address `10.144.0.1/24`; node B runs either the same native binary or
this Go host (commit `78889d12`, embedded EasyTier `af640d49`) with
`10.144.0.2/24`. A master build is used as the native baseline because the
2.6.4 release predates several native data-plane throughput fixes (EasyTier
#2451, #2452). The underlay tunnel between the nodes is either `tcp://` or
`udp://`. Encryption is enabled and the overlay MTU is 1360 on both ends.
Node A and the iperf3 server are pinned to CPUs `0,2,4,6`, node B to
`8,10,12,14`. Each iperf3 run lasts 15 seconds and excludes the first
3 seconds. Forward means node B sends to node A; reverse uses `iperf3 -R`.
Measured 2026-07-28.
The forwarding rows deliberately compare native Core port forwarding with the
Go host's benchmark-only `cmd/dial-forward-bench`, which carries traffic
through the public `Instance.Dial` API.
TCP, one stream:
| Scenario | Direction | `tcp://` native | `tcp://` Go host | `udp://` native | `udp://` Go host |
| --- | --- | ---: | ---: | ---: | ---: |
| TUN | forward | 6.06 Gbit/s | 2.12 Gbit/s | 3.71 Gbit/s | 1.57 Gbit/s |
| TUN | reverse | 6.03 Gbit/s | 2.57 Gbit/s | 3.69 Gbit/s | 1.48 Gbit/s |
| Native port forward / Go Dial | forward | 1.25 Gbit/s | 1.29 Gbit/s | 1.19 Gbit/s | 1.20 Gbit/s |
| Native port forward / Go Dial | reverse | 6.49 Gbit/s | 1.95 Gbit/s | 4.26 Gbit/s | 1.43 Gbit/s |
UDP native port forward / Go Dial, 1 Gbit/s offered with 1200-byte datagrams
(received / lost):
| Direction | `tcp://` native | `tcp://` Go host | `udp://` native | `udp://` Go host |
| --- | ---: | ---: | ---: | ---: |
| forward | 996 Mbit/s / 0.3% | 546 Mbit/s / 45% | 996 Mbit/s / 0.3% | 699 Mbit/s / 30% |
| reverse | 950 Mbit/s / 5.0% | 490 Mbit/s / 51% | 983 Mbit/s / 1.7% | 350 Mbit/s / 65% |
Reading the numbers:
- On TUN the Go host reaches roughly 35-45% of native single-stream
throughput. Both sides run the same EasyTier core logic, so the gap is the
WASM/Go data-plane boundary rather than routing or cryptography.
- TCP forwarding is a tie at about 1.2 Gbit/s: both paths are bounded by the
virtual TCP send path inside the shared EasyTier core, not by the host.
- TCP reverse forwarding favors native by about 3x (4.3-6.5 versus
1.4-2.0 Gbit/s); the Go host benchmark's per-operation receive path is the
limit.
- Native sustains the offered 1 Gbit/s UDP nearly loss-free in both
directions, while the Go host saturates at 350-700 Mbit/s with significant
loss, consistent with the one-operation-per-datagram data-plane ABI
documented in `PERFORMANCE.md`.
## Platform capabilities
The default platform implementation uses Go's standard `net` and
`net.Resolver` packages. Applications that need netns, socket marks, device
binding, reuse policy, or custom DNS can inject capabilities through
`platform.Services`:
```go
host, err := corehost.New(ctx, corehost.Options{
Platform: platform.Services{
Sockets: socketFactory,
DNS: dnsResolver,
Environment: connectorEnvironment,
Snapshot: environmentSnapshot,
},
})
```
`platform.SocketFactory` owns only TCP connect, UDP bind, and TCP listen
creation. Once a standard Go network resource is returned, the host runtime
owns its reads, writes, accepts, cancellation, and close path. EasyTier retains
all routing, peer admission, protocol, retry, and connection policy.
The implementation is split by responsibility:
- `platform` defines public capability ports; `platform/netstd` implements
their portable defaults.
- `proto` contains generated Go bindings for the existing EasyTier management
protobuf definitions.
- `internal/reactor` owns typed asynchronous operations, resources, operation
IDs, backpressure, and completion signals without depending on wazero.
- `internal/hostabi` implements the custom `easytier_host` imports, guest
memory copying, wire codecs, and ABI status translation.
- `internal/coreabi` owns guest memory, the big-endian data-plane wire codec,
ABI discovery, and typed `easytier_instance_*`, `easytier_data_plane_*`, and
`easytier_rpc_*` export calls.
- `internal/engine` composes standard WASI, both EasyTier ABI directions, the
single-owner driver, operation cancellation, deadlines, standard Go network
resources, and instance shutdown.
- `internal/artifact` contains only the embedded core and its provenance.
## Embedded artifact
The committed WASM lets downstream Go builds and tests run without a Rust
toolchain. Refresh it from a clean EasyTier checkout whenever the guest ABI or
core implementation changes:
```sh
EASYTIER_SOURCE=/path/to/EasyTier go generate ./...
```
Generation runs EasyTier's `script/build-wasi-core.sh`, which builds release
`easytier_core.wasm` with the Go-host features and writes an optimized
`easytier_core_go_host.wasm` with the pinned, SHA-256-verified Binaryen release.
The generator supplies a fixed source path remap and source-date epoch, then
records the EasyTier commit and optimized artifact SHA-256. Tracked EasyTier
changes block generation; unrelated untracked files do not.
`corehost.CoreInfo()` exposes that provenance without exposing the artifact
bytes.
The same generator rebuilds the Go protobuf bindings from that exact clean
EasyTier commit and records their source commit and schema SHA-256. Host
creation rejects an artifact/binding commit mismatch. Generation requires
`protoc` 35.1 and `protoc-gen-go` 1.36.11 on `PATH`.
The test-only socket probe is retained from
[EasyTier commit 6a3d15f](https://github.com/EasyTier/EasyTier/tree/6a3d15f8758eed759d55401ff4ed7c47021b0819/tools/wasi-socket-poc/guest);
its full commit and checksum are recorded in
`testdata/wasi_socket_guest.source`.
Run all reactor, ABI conformance, lifecycle, and two-instance network tests
with:
```sh
go test -count=1 ./...
```
+364
View File
@@ -0,0 +1,364 @@
// Command dial-forward-bench measures the public Instance.Dial data path.
//
// User-facing port forwarding belongs to the embedded EasyTier core. This
// command intentionally keeps the forwarding loop in Go so benchmarks include
// the public Go/WASM data-plane boundary.
package main
import (
"context"
"errors"
"flag"
"fmt"
"io"
"log"
"net"
"net/netip"
"os"
"os/signal"
"strings"
"sync/atomic"
"syscall"
corehost "github.com/EasyTier/EasyTier/easytier-go"
"github.com/EasyTier/EasyTier/easytier-go/internal/contextutil"
)
type options struct {
peers peerList
portForwards portForwardList
networkName string
networkSecret string
ipv4 string
}
func main() {
options, err := parseOptions(os.Args[1:])
if err == flag.ErrHelp {
return
}
if err != nil {
log.Printf("invalid arguments: %v", err)
os.Exit(2)
}
ctx, stop := signal.NotifyContext(
context.Background(),
os.Interrupt,
syscall.SIGTERM,
)
defer stop()
if err := run(ctx, options); err != nil {
log.Printf("EasyTier Dial benchmark failed: %v", err)
os.Exit(1)
}
}
func run(ctx context.Context, options options) error {
prefix, err := netip.ParsePrefix(options.ipv4)
if err != nil {
return fmt.Errorf("parse instance IPv4 prefix: %w", err)
}
config, err := corehost.NewInstanceConfigBuilder(options.networkName).
NetworkSecret(options.networkSecret).
IPv4(prefix).
AddPeers([]string(options.peers)...).
Build()
if err != nil {
return fmt.Errorf("build EasyTier instance config: %w", err)
}
host, err := corehost.New(ctx, corehost.Options{})
if err != nil {
return fmt.Errorf("create EasyTier host: %w", err)
}
defer host.Close(context.Background())
instance, err := host.CreateInstance(ctx, config)
if err != nil {
return fmt.Errorf("create EasyTier instance: %w", err)
}
defer instance.Close(context.Background())
if err := instance.Start(ctx); err != nil {
return fmt.Errorf("start EasyTier instance: %w", err)
}
forwarders, err := startPortForwards(
ctx,
instance.Dial,
options.portForwards,
)
if err != nil {
return err
}
defer forwarders.Close()
<-ctx.Done()
return nil
}
func parseOptions(arguments []string) (options, error) {
flags := flag.NewFlagSet("dial-forward-bench", flag.ContinueOnError)
var options options
flags.Var(&options.peers, "p", "EasyTier peer URI; may be repeated")
flags.Var(
&options.portForwards,
"port-forward",
"tcp://bind/overlay-target or udp://bind/overlay-target; may be repeated",
)
flags.StringVar(&options.networkName, "network-name", "", "EasyTier network name")
flags.StringVar(&options.networkSecret, "network-secret", "", "EasyTier network secret")
flags.StringVar(&options.ipv4, "ipv4", "", "fixed EasyTier IPv4 address and prefix")
if err := flags.Parse(arguments); err != nil {
return options, err
}
if flags.NArg() != 0 {
return options, fmt.Errorf("unexpected argument %q", flags.Arg(0))
}
if err := options.validate(); err != nil {
return options, err
}
return options, nil
}
func (options options) validate() error {
if len(options.peers) == 0 {
return fmt.Errorf("at least one -p peer is required")
}
if len(options.portForwards) == 0 {
return fmt.Errorf("at least one --port-forward rule is required")
}
if strings.TrimSpace(options.networkName) == "" {
return fmt.Errorf("--network-name is required")
}
if options.networkSecret == "" {
return fmt.Errorf("--network-secret is required")
}
prefix, err := netip.ParsePrefix(options.ipv4)
if err != nil {
return fmt.Errorf("parse --ipv4: %w", err)
}
if !prefix.Addr().Is4() {
return fmt.Errorf("--ipv4 must be an IPv4 prefix")
}
return nil
}
type peerList []string
func (peers *peerList) Set(value string) error {
if strings.TrimSpace(value) == "" {
return fmt.Errorf("peer URI must not be empty")
}
*peers = append(*peers, value)
return nil
}
func (peers *peerList) String() string {
return strings.Join(*peers, ",")
}
type portForwardRule struct {
protocol string
bind netip.AddrPort
target netip.AddrPort
}
func (rule portForwardRule) String() string {
return fmt.Sprintf("%s://%s/%s", rule.protocol, rule.bind, rule.target)
}
type portForwardList []portForwardRule
func (rules *portForwardList) Set(value string) error {
rule, err := parsePortForwardRule(value)
if err == nil {
*rules = append(*rules, rule)
}
return err
}
func (rules *portForwardList) String() string {
values := make([]string, len(*rules))
for index, rule := range *rules {
values[index] = rule.String()
}
return strings.Join(values, ",")
}
func parsePortForwardRule(value string) (portForwardRule, error) {
protocol, addresses, ok := strings.Cut(value, "://")
if !ok || (protocol != "tcp" && protocol != "udp") {
return portForwardRule{}, fmt.Errorf(
"port forward %q must use tcp:// or udp://",
value,
)
}
bindText, targetText, ok := strings.Cut(addresses, "/")
if !ok {
return portForwardRule{}, fmt.Errorf(
"port forward %q must contain bind/overlay-target",
value,
)
}
bind, err := netip.ParseAddrPort(bindText)
if err != nil {
return portForwardRule{}, fmt.Errorf("parse bind address: %w", err)
}
target, err := netip.ParseAddrPort(targetText)
if err != nil {
return portForwardRule{}, fmt.Errorf("parse target address: %w", err)
}
if !bind.Addr().Is4() || !target.Addr().Is4() {
return portForwardRule{}, fmt.Errorf("port forward requires IPv4 addresses")
}
return portForwardRule{protocol: protocol, bind: bind, target: target}, nil
}
type overlayDialFunc func(context.Context, string, string) (net.Conn, error)
type portForwardSet struct {
ctx context.Context
cancel context.CancelFunc
dial overlayDialFunc
addresses []net.Addr
closers []io.Closer
}
func startPortForwards(
ctx context.Context,
dial overlayDialFunc,
rules []portForwardRule,
) (*portForwardSet, error) {
if dial == nil {
return nil, fmt.Errorf("port forward dial function is nil")
}
forwardContext, cancel := context.WithCancel(ctx)
forwards := &portForwardSet{
ctx: forwardContext,
cancel: cancel,
dial: dial,
}
for _, rule := range rules {
var err error
switch rule.protocol {
case "tcp":
err = forwards.startTCP(rule)
case "udp":
err = forwards.startUDP(rule)
default:
err = fmt.Errorf("unsupported protocol %q", rule.protocol)
}
if err != nil {
_ = forwards.Close()
return nil, fmt.Errorf("start port forward %s: %w", rule, err)
}
}
return forwards, nil
}
func (forwards *portForwardSet) Close() error {
forwards.cancel()
var errs []error
for _, closer := range forwards.closers {
errs = append(errs, closer.Close())
}
return errors.Join(errs...)
}
func (forwards *portForwardSet) startTCP(rule portForwardRule) error {
listener, err := net.ListenTCP("tcp4", net.TCPAddrFromAddrPort(rule.bind))
if err != nil {
return err
}
forwards.closers = append(forwards.closers, listener)
forwards.addresses = append(forwards.addresses, listener.Addr())
log.Printf("forwarding tcp://%s to %s through Instance.Dial", listener.Addr(), rule.target)
go func() {
for {
local, err := listener.Accept()
if err != nil {
return
}
go forwards.forwardTCP(local, rule.target)
}
}()
return nil
}
func (forwards *portForwardSet) forwardTCP(
local net.Conn,
target netip.AddrPort,
) {
defer local.Close()
overlay, err := forwards.dial(
forwards.ctx,
"tcp4",
target.String(),
)
if err != nil {
return
}
defer overlay.Close()
stopClose := contextutil.AfterFunc(forwards.ctx, func() {
_ = local.Close()
_ = overlay.Close()
})
defer stopClose()
done := make(chan struct{}, 2)
go func() { _, _ = io.Copy(overlay, local); done <- struct{}{} }()
go func() { _, _ = io.Copy(local, overlay); done <- struct{}{} }()
<-done
}
func (forwards *portForwardSet) startUDP(rule portForwardRule) error {
overlay, err := forwards.dial(
forwards.ctx,
"udp4",
rule.target.String(),
)
if err != nil {
return err
}
listener, err := net.ListenUDP("udp4", net.UDPAddrFromAddrPort(rule.bind))
if err != nil {
_ = overlay.Close()
return err
}
forwards.closers = append(forwards.closers, listener, overlay)
forwards.addresses = append(forwards.addresses, listener.LocalAddr())
log.Printf(
"forwarding udp://%s to %s through Instance.Dial",
listener.LocalAddr(),
rule.target,
)
var lastClient atomic.Value
go func() {
packet := make([]byte, 65535)
for {
length, client, err := listener.ReadFromUDPAddrPort(packet)
if err != nil {
return
}
lastClient.Store(client)
if written, err := overlay.Write(packet[:length]); err != nil ||
written != length {
return
}
}
}()
go func() {
packet := make([]byte, 65535)
for {
length, err := overlay.Read(packet)
if err != nil {
return
}
client, ok := lastClient.Load().(netip.AddrPort)
if ok {
_, _ = listener.WriteToUDPAddrPort(packet[:length], client)
}
}
}()
return nil
}
@@ -0,0 +1,188 @@
package main
import (
"bytes"
"context"
"io"
"net"
"net/netip"
"strings"
"testing"
"time"
)
func TestParseOptions(t *testing.T) {
options, err := parseOptions([]string{
"-p", "tcp://198.51.100.10:11010",
"--network-name", "office",
"--network-secret", "secret",
"--ipv4", "10.144.0.10/24",
"--port-forward", "tcp://127.0.0.1:5202/10.144.0.20:5201",
"--port-forward", "udp://127.0.0.1:5202/10.144.0.20:5201",
})
if err != nil {
t.Fatalf("parse options: %v", err)
}
if len(options.portForwards) != 2 {
t.Fatalf("port-forward count = %d", len(options.portForwards))
}
}
func TestPortForwardList(t *testing.T) {
var rules portForwardList
for _, value := range []string{
"tcp://127.0.0.1:5202/10.144.0.20:5201",
"udp://0.0.0.0:5203/10.144.0.21:5201",
} {
if err := rules.Set(value); err != nil {
t.Fatalf("set %q: %v", value, err)
}
}
if got := rules.String(); got !=
"tcp://127.0.0.1:5202/10.144.0.20:5201,"+
"udp://0.0.0.0:5203/10.144.0.21:5201" {
t.Fatalf("rules string = %q", got)
}
if err := rules.Set("sctp://127.0.0.1:1/10.0.0.1:2"); err == nil {
t.Fatal("unsupported protocol was accepted")
}
}
func TestTCPPortForward(t *testing.T) {
echo, err := net.Listen("tcp4", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen echo: %v", err)
}
defer echo.Close()
go func() {
connection, acceptErr := echo.Accept()
if acceptErr == nil {
defer connection.Close()
_, _ = io.Copy(connection, connection)
}
}()
rule, err := parsePortForwardRule(
"tcp://127.0.0.1:0/" + echo.Addr().String(),
)
if err != nil {
t.Fatalf("parse forward: %v", err)
}
forwards, err := startPortForwards(
context.Background(),
standardDial,
[]portForwardRule{rule},
)
if err != nil {
t.Fatalf("start forward: %v", err)
}
defer forwards.Close()
connection, err := net.Dial("tcp4", forwards.addresses[0].String())
if err != nil {
t.Fatalf("dial forward: %v", err)
}
defer connection.Close()
_ = connection.SetDeadline(time.Now().Add(5 * time.Second))
sent := bytes.Repeat([]byte("easytier"), 1024)
if _, err := connection.Write(sent); err != nil {
t.Fatalf("write forward: %v", err)
}
received := make([]byte, len(sent))
if _, err := io.ReadFull(connection, received); err != nil {
t.Fatalf("read forward: %v", err)
}
if !bytes.Equal(received, sent) {
t.Fatal("forwarded TCP data differs")
}
}
func TestUDPPortForward(t *testing.T) {
echo, err := net.ListenUDP(
"udp4",
net.UDPAddrFromAddrPort(netip.MustParseAddrPort("127.0.0.1:0")),
)
if err != nil {
t.Fatalf("listen echo: %v", err)
}
defer echo.Close()
_ = echo.SetDeadline(time.Now().Add(5 * time.Second))
firstReceived := make(chan struct{})
go func() {
packet := make([]byte, 65535)
if _, _, readErr := echo.ReadFromUDPAddrPort(packet); readErr != nil {
close(firstReceived)
return
}
close(firstReceived)
length, client, readErr := echo.ReadFromUDPAddrPort(packet)
if readErr != nil {
return
}
_, _ = echo.WriteToUDPAddrPort(packet[:length], client)
}()
rule, err := parsePortForwardRule(
"udp://127.0.0.1:0/" + echo.LocalAddr().String(),
)
if err != nil {
t.Fatalf("parse forward: %v", err)
}
forwards, err := startPortForwards(
context.Background(),
standardDial,
[]portForwardRule{rule},
)
if err != nil {
t.Fatalf("start forward: %v", err)
}
defer forwards.Close()
firstClient, err := net.Dial("udp4", forwards.addresses[0].String())
if err != nil {
t.Fatalf("dial forward: %v", err)
}
defer firstClient.Close()
if _, err := firstClient.Write([]byte("first")); err != nil {
t.Fatalf("write from first client: %v", err)
}
<-firstReceived
lastClient, err := net.Dial("udp4", forwards.addresses[0].String())
if err != nil {
t.Fatalf("dial forward from last client: %v", err)
}
defer lastClient.Close()
_ = lastClient.SetDeadline(time.Now().Add(5 * time.Second))
sent := []byte("last")
if _, err := lastClient.Write(sent); err != nil {
t.Fatalf("write from last client: %v", err)
}
received := make([]byte, len(sent))
if _, err := io.ReadFull(lastClient, received); err != nil {
t.Fatalf("read forward: %v", err)
}
if !bytes.Equal(received, sent) {
t.Fatal("forwarded UDP data differs")
}
}
func standardDial(
ctx context.Context,
network string,
address string,
) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, network, address)
}
func TestParseOptionsRequiresForward(t *testing.T) {
_, err := parseOptions([]string{
"-p", "tcp://198.51.100.10:11010",
"--network-name", "office",
"--network-secret", "secret",
"--ipv4", "10.144.0.10/24",
})
if err == nil || !strings.Contains(err.Error(), "--port-forward") {
t.Fatalf("parse error = %v, want missing port-forward", err)
}
}
+254
View File
@@ -0,0 +1,254 @@
package main
import (
"crypto/sha256"
"encoding/hex"
"flag"
"fmt"
"go/format"
"os"
"os/exec"
"path/filepath"
"strings"
)
const (
coreArtifact = "wasm32-wasip1/release/easytier_core_go_host.wasm"
coreBuildScript = "script/build-wasi-core.sh"
protoGenerateScript = "script/generate-proto.sh"
)
var managementProtoFiles = []string{
"common.proto",
"error.proto",
"acl.proto",
"peer_rpc.proto",
"api_instance.proto",
"api_config.proto",
"api_manage.proto",
"web.proto",
}
func main() {
var source string
flag.StringVar(&source, "easytier", os.Getenv("EASYTIER_SOURCE"), "path to a clean EasyTier checkout")
flag.Parse()
if err := update(source); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func update(source string) error {
workingDirectory, err := os.Getwd()
if err != nil {
return fmt.Errorf("resolve generator directory: %w", err)
}
moduleFile, err := commandOutput(workingDirectory, "go", "env", "GOMOD")
if err != nil {
return err
}
if moduleFile == "" || moduleFile == os.DevNull {
return fmt.Errorf("locate Go module from %s", workingDirectory)
}
repositoryRoot := filepath.Dir(moduleFile)
if source == "" {
source = filepath.Join(repositoryRoot, "..")
}
source, err = filepath.Abs(source)
if err != nil {
return fmt.Errorf("resolve EasyTier source: %w", err)
}
if _, err := os.Stat(filepath.Join(source, "Cargo.toml")); err != nil {
return fmt.Errorf("validate EasyTier source %s: %w", source, err)
}
if err := requireCleanCheckout(source); err != nil {
return err
}
commit, err := commandOutput(source, "git", "rev-parse", "HEAD")
if err != nil {
return err
}
sourceDateEpoch, err := commandOutput(source, "git", "show", "-s", "--format=%ct", "HEAD")
if err != nil {
return err
}
if err := buildCore(source, sourceDateEpoch); err != nil {
return err
}
if err := generateProto(repositoryRoot, source); err != nil {
return err
}
protoDigest, err := protoSchemaDigest(source)
if err != nil {
return err
}
coreDigest, err := copyArtifact(
coreArtifactPath(source, os.Getenv("CARGO_TARGET_DIR")),
filepath.Join(repositoryRoot, "internal", "artifact", "easytier_core.wasm"),
)
if err != nil {
return err
}
if err := writeProvenance(repositoryRoot, commit, coreDigest); err != nil {
return err
}
if err := writeProtoProvenance(repositoryRoot, commit, protoDigest); err != nil {
return err
}
fmt.Printf("embedded EasyTier %s (sha256 %s)\n", commit, coreDigest)
return nil
}
func coreArtifactPath(source, cargoTargetDirectory string) string {
if cargoTargetDirectory == "" {
cargoTargetDirectory = "target"
}
if !filepath.IsAbs(cargoTargetDirectory) {
cargoTargetDirectory = filepath.Join(source, cargoTargetDirectory)
}
return filepath.Join(cargoTargetDirectory, coreArtifact)
}
func requireCleanCheckout(source string) error {
status, err := commandOutput(source, "git", "status", "--porcelain", "--untracked-files=no")
if err != nil {
return err
}
if status != "" {
return fmt.Errorf("EasyTier checkout is dirty:\n%s", status)
}
return nil
}
func commandOutput(directory, name string, args ...string) (string, error) {
command := exec.Command(name, args...)
command.Dir = directory
output, err := command.CombinedOutput()
if err != nil {
return "", fmt.Errorf("run %s: %w\n%s", name, err, output)
}
return strings.TrimSpace(string(output)), nil
}
func buildCore(source, sourceDateEpoch string) error {
command := exec.Command("bash", filepath.Join(source, coreBuildScript))
command.Dir = source
command.Env = reproducibleBuildEnvironment(source, sourceDateEpoch)
command.Stdout = os.Stdout
command.Stderr = os.Stderr
if err := command.Run(); err != nil {
return fmt.Errorf("run EasyTier WASI build script: %w", err)
}
return nil
}
func generateProto(repositoryRoot, source string) error {
command := exec.Command(
"bash",
filepath.Join(repositoryRoot, protoGenerateScript),
source,
)
command.Dir = repositoryRoot
command.Stdout = os.Stdout
command.Stderr = os.Stderr
if err := command.Run(); err != nil {
return fmt.Errorf("generate EasyTier Go protobuf bindings: %w", err)
}
return nil
}
func protoSchemaDigest(source string) (string, error) {
digest := sha256.New()
protoRoot := filepath.Join(source, "easytier-proto", "proto")
for _, name := range managementProtoFiles {
contents, err := os.ReadFile(filepath.Join(protoRoot, name))
if err != nil {
return "", fmt.Errorf("read EasyTier protobuf source %s: %w", name, err)
}
digest.Write([]byte(name))
digest.Write([]byte{0})
digest.Write(contents)
digest.Write([]byte{0})
}
return hex.EncodeToString(digest.Sum(nil)), nil
}
func reproducibleBuildEnvironment(source, sourceDateEpoch string) []string {
environment := make([]string, 0, len(os.Environ())+3)
for _, entry := range os.Environ() {
if strings.HasPrefix(entry, "RUSTFLAGS=") ||
strings.HasPrefix(entry, "CARGO_ENCODED_RUSTFLAGS=") ||
strings.HasPrefix(entry, "SOURCE_DATE_EPOCH=") {
continue
}
environment = append(environment, entry)
}
remap := "--remap-path-prefix=" + source + "=/workspace/EasyTier"
return append(
environment,
"RUSTFLAGS=",
"CARGO_ENCODED_RUSTFLAGS="+remap,
"SOURCE_DATE_EPOCH="+sourceDateEpoch,
)
}
func copyArtifact(source, destination string) (string, error) {
contents, err := os.ReadFile(source)
if err != nil {
return "", fmt.Errorf("read artifact %s: %w", source, err)
}
if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil {
return "", fmt.Errorf("create artifact directory: %w", err)
}
if err := os.WriteFile(destination, contents, 0o644); err != nil {
return "", fmt.Errorf("write artifact %s: %w", destination, err)
}
digest := sha256.Sum256(contents)
return hex.EncodeToString(digest[:]), nil
}
func writeProvenance(repositoryRoot, commit, digest string) error {
source := fmt.Sprintf(`// Code generated by go generate; DO NOT EDIT.
package artifact
const (
Commit = %q
SHA256 = %q
)
`, commit, digest)
formatted, err := format.Source([]byte(source))
if err != nil {
return fmt.Errorf("format provenance: %w", err)
}
path := filepath.Join(repositoryRoot, "internal", "artifact", "provenance.go")
if err := os.WriteFile(path, formatted, 0o644); err != nil {
return fmt.Errorf("write provenance: %w", err)
}
return nil
}
func writeProtoProvenance(repositoryRoot, commit, digest string) error {
source := fmt.Sprintf(`// Code generated by go generate; DO NOT EDIT.
package proto
const (
EasyTierCommit = %q
SchemaSHA256 = %q
)
`, commit, digest)
formatted, err := format.Source([]byte(source))
if err != nil {
return fmt.Errorf("format protobuf provenance: %w", err)
}
path := filepath.Join(repositoryRoot, "proto", "provenance.go")
if err := os.WriteFile(path, formatted, 0o644); err != nil {
return fmt.Errorf("write protobuf provenance: %w", err)
}
return nil
}
+92
View File
@@ -0,0 +1,92 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestCoreArtifactPathUsesCargoTargetDirectory(t *testing.T) {
source := filepath.Join(string(filepath.Separator), "checkout")
for _, test := range []struct {
name string
cargoTargetDir string
wantTargetDir string
}{
{name: "default", wantTargetDir: filepath.Join(source, "target")},
{
name: "relative",
cargoTargetDir: "build",
wantTargetDir: filepath.Join(source, "build"),
},
{
name: "absolute",
cargoTargetDir: filepath.Join(string(filepath.Separator), "cache"),
wantTargetDir: filepath.Join(string(filepath.Separator), "cache"),
},
} {
t.Run(test.name, func(t *testing.T) {
want := filepath.Join(test.wantTargetDir, coreArtifact)
if got := coreArtifactPath(source, test.cargoTargetDir); got != want {
t.Fatalf("core artifact path = %q, want %q", got, want)
}
})
}
}
func TestReproducibleBuildEnvironmentControlsRustInputs(t *testing.T) {
t.Setenv("RUSTFLAGS", "-C target-cpu=native")
t.Setenv("CARGO_ENCODED_RUSTFLAGS", "-C\u001fopt-level=1")
t.Setenv("SOURCE_DATE_EPOCH", "1")
environment := reproducibleBuildEnvironment("/checkout", "1700000000")
joined := strings.Join(environment, "\n")
if strings.Contains(joined, "target-cpu=native") ||
strings.Contains(joined, "opt-level=1") {
t.Fatalf("uncontrolled Rust flags remained in environment:\n%s", joined)
}
for _, want := range []string{
"CARGO_ENCODED_RUSTFLAGS=--remap-path-prefix=/checkout=/workspace/EasyTier",
"SOURCE_DATE_EPOCH=1700000000",
} {
if !strings.Contains(joined, want) {
t.Fatalf("build environment does not contain %q", want)
}
}
}
func TestProtoSchemaDigestCoversEveryInput(t *testing.T) {
source := t.TempDir()
protoRoot := filepath.Join(source, "easytier-proto", "proto")
if err := os.MkdirAll(protoRoot, 0o755); err != nil {
t.Fatalf("create proto root: %v", err)
}
for _, name := range managementProtoFiles {
if err := os.WriteFile(
filepath.Join(protoRoot, name),
[]byte(name),
0o644,
); err != nil {
t.Fatalf("write %s: %v", name, err)
}
}
before, err := protoSchemaDigest(source)
if err != nil {
t.Fatalf("digest proto schema: %v", err)
}
changed := managementProtoFiles[len(managementProtoFiles)-1]
if err := os.WriteFile(
filepath.Join(protoRoot, changed),
[]byte("changed"),
0o644,
); err != nil {
t.Fatalf("change %s: %v", changed, err)
}
after, err := protoSchemaDigest(source)
if err != nil {
t.Fatalf("digest changed proto schema: %v", err)
}
if before == after {
t.Fatal("schema digest did not change with a protobuf input")
}
}
+305
View File
@@ -0,0 +1,305 @@
package main
import (
"context"
"encoding/binary"
"errors"
"flag"
"fmt"
"io"
"log"
"net"
"net/netip"
"os"
"os/signal"
"strings"
"syscall"
"time"
corehost "github.com/EasyTier/EasyTier/easytier-go"
"github.com/EasyTier/EasyTier/easytier-go/internal/contextutil"
)
const maxUDPPayload = 65507
type peerList []string
func (peers *peerList) Set(value string) error {
if strings.TrimSpace(value) == "" {
return fmt.Errorf("peer URI must not be empty")
}
*peers = append(*peers, value)
return nil
}
func (peers *peerList) String() string {
return strings.Join(*peers, ",")
}
type options struct {
peers peerList
networkName string
networkSecret string
ipv4 string
network string
address string
connectTimeout time.Duration
}
func main() {
options, err := parseOptions(os.Args[1:])
if err == flag.ErrHelp {
return
}
if err != nil {
log.Printf("invalid arguments: %v", err)
os.Exit(2)
}
ctx, stop := signal.NotifyContext(
context.Background(),
os.Interrupt,
syscall.SIGTERM,
)
defer stop()
if err := run(ctx, options, os.Stdin, os.Stdout); err != nil {
log.Printf("EasyTier dial failed: %v", err)
os.Exit(1)
}
}
func parseOptions(arguments []string) (options, error) {
flags := flag.NewFlagSet("dial", flag.ContinueOnError)
var options options
flags.Var(&options.peers, "p", "EasyTier peer URI; may be repeated")
flags.StringVar(&options.networkName, "network-name", "", "EasyTier network name")
flags.StringVar(&options.networkSecret, "network-secret", "", "EasyTier network secret")
flags.StringVar(&options.ipv4, "ipv4", "", "fixed EasyTier IPv4 address and prefix")
flags.StringVar(&options.network, "network", "tcp4", "overlay network: tcp4 or udp4")
flags.StringVar(&options.address, "address", "", "overlay IPv4 destination")
flags.DurationVar(
&options.connectTimeout,
"connect-timeout",
10*time.Second,
"time to wait for an overlay route and connection",
)
if err := flags.Parse(arguments); err != nil {
return options, err
}
if flags.NArg() != 0 {
return options, fmt.Errorf("unexpected argument %q", flags.Arg(0))
}
if err := options.validate(); err != nil {
return options, err
}
return options, nil
}
func (options options) validate() error {
if len(options.peers) == 0 {
return fmt.Errorf("at least one -p peer is required")
}
if strings.TrimSpace(options.networkName) == "" {
return fmt.Errorf("--network-name is required")
}
if options.networkSecret == "" {
return fmt.Errorf("--network-secret is required")
}
prefix, err := netip.ParsePrefix(options.ipv4)
if err != nil {
return fmt.Errorf("parse --ipv4: %w", err)
}
if !prefix.Addr().Is4() {
return fmt.Errorf("--ipv4 must be an IPv4 prefix")
}
if options.network != "tcp4" && options.network != "udp4" {
return fmt.Errorf("--network must be tcp4 or udp4")
}
if options.connectTimeout <= 0 {
return fmt.Errorf("--connect-timeout must be positive")
}
address, err := netip.ParseAddrPort(options.address)
if err != nil || !address.Addr().Is4() || address.Port() == 0 {
return fmt.Errorf("--address must be an IPv4 socket address with a nonzero port")
}
return nil
}
func run(
ctx context.Context,
options options,
input io.Reader,
output io.Writer,
) error {
prefix, err := netip.ParsePrefix(options.ipv4)
if err != nil {
return fmt.Errorf("parse instance IPv4 prefix: %w", err)
}
config, err := corehost.NewInstanceConfigBuilder(options.networkName).
NetworkSecret(options.networkSecret).
IPv4(prefix).
AddPeers([]string(options.peers)...).
Build()
if err != nil {
return fmt.Errorf("build EasyTier instance config: %w", err)
}
host, err := corehost.New(ctx, corehost.Options{})
if err != nil {
return fmt.Errorf("create EasyTier host: %w", err)
}
defer host.Close(context.Background())
instance, err := host.CreateInstance(ctx, config)
if err != nil {
return fmt.Errorf("create EasyTier instance: %w", err)
}
defer instance.Close(context.Background())
if err := instance.Start(ctx); err != nil {
return fmt.Errorf("start EasyTier instance: %w", err)
}
target := netip.MustParseAddrPort(options.address)
connectContext, cancelConnect := context.WithTimeout(ctx, options.connectTimeout)
defer cancelConnect()
if target.Addr() != prefix.Addr() {
if err := waitForRoute(connectContext, instance, target.Addr()); err != nil {
return fmt.Errorf("wait for overlay route to %s: %w", target.Addr(), err)
}
}
connection, err := instance.Dial(
connectContext,
options.network,
options.address,
)
if err != nil {
return err
}
defer connection.Close()
stopClose := contextutil.AfterFunc(ctx, func() {
_ = connection.Close()
})
defer stopClose()
log.Printf("connected %s to %s through EasyTier", options.network, options.address)
switch options.network {
case "tcp4":
err = relayTCP(connection, input, output)
case "udp4":
err = exchangeUDP(ctx, connection, input, output)
}
if ctx.Err() != nil {
return nil
}
return err
}
func waitForRoute(
ctx context.Context,
instance *corehost.Instance,
target netip.Addr,
) error {
ticker := time.NewTicker(50 * time.Millisecond)
defer ticker.Stop()
for {
routes, err := instance.ListRoute(ctx)
if err != nil {
return err
}
if routesReach(routes, target) {
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
}
}
}
func routesReach(routes []*corehost.Route, target netip.Addr) bool {
octets := target.As4()
targetValue := binary.BigEndian.Uint32(octets[:])
for _, route := range routes {
if route.GetIpv4Addr().GetAddress().GetAddr() == targetValue {
return true
}
for _, cidr := range route.GetProxyCidrs() {
prefix, err := netip.ParsePrefix(cidr)
if err == nil && prefix.Contains(target) {
return true
}
}
}
return false
}
func relayTCP(connection net.Conn, input io.Reader, output io.Writer) error {
writeResult := make(chan error, 1)
go func() {
_, err := io.Copy(connection, input)
writeResult <- err
}()
_, readErr := io.Copy(output, connection)
select {
case writeErr := <-writeResult:
return errors.Join(writeErr, readErr)
default:
return readErr
}
}
func exchangeUDP(
ctx context.Context,
connection net.Conn,
input io.Reader,
output io.Writer,
) error {
type readResult struct {
payload []byte
err error
}
result := make(chan readResult, 1)
go func() {
payload, err := io.ReadAll(io.LimitReader(input, maxUDPPayload+1))
result <- readResult{payload: payload, err: err}
}()
var payload []byte
select {
case <-ctx.Done():
return ctx.Err()
case read := <-result:
if read.err != nil {
return fmt.Errorf("read UDP payload: %w", read.err)
}
payload = read.payload
}
if len(payload) == 0 {
return fmt.Errorf("UDP input must contain one non-empty datagram")
}
if len(payload) > maxUDPPayload {
return fmt.Errorf("UDP input exceeds the maximum payload of %d bytes", maxUDPPayload)
}
written, err := connection.Write(payload)
if err != nil {
return fmt.Errorf("write UDP datagram: %w", err)
}
if written != len(payload) {
return io.ErrShortWrite
}
response := make([]byte, maxUDPPayload)
length, err := connection.Read(response)
if err != nil {
return fmt.Errorf("read UDP datagram: %w", err)
}
written, err = output.Write(response[:length])
if err != nil {
return fmt.Errorf("write UDP response: %w", err)
}
if written != length {
return io.ErrShortWrite
}
return nil
}
+209
View File
@@ -0,0 +1,209 @@
package main
import (
"bytes"
"context"
"encoding/binary"
"io"
"net"
"net/netip"
"strings"
"testing"
"time"
corehost "github.com/EasyTier/EasyTier/easytier-go"
"github.com/EasyTier/EasyTier/easytier-go/proto/common"
)
func TestParseOptions(t *testing.T) {
options, err := parseOptions([]string{
"-p", "tcp://198.51.100.10:11010",
"--network-name", "office",
"--network-secret", "secret",
"--ipv4", "10.144.0.10/24",
"--network", "udp4",
"--address", "10.144.0.20:7000",
"--connect-timeout", "3s",
})
if err != nil {
t.Fatalf("parse options: %v", err)
}
if options.network != "udp4" ||
options.address != "10.144.0.20:7000" ||
options.connectTimeout != 3*time.Second ||
len(options.peers) != 1 {
t.Fatalf("parsed options = %+v", options)
}
}
func TestParseOptionsRejectsInvalidArguments(t *testing.T) {
valid := []string{
"-p", "tcp://198.51.100.10:11010",
"--network-name", "office",
"--network-secret", "secret",
"--ipv4", "10.144.0.10/24",
"--address", "10.144.0.20:7000",
}
tests := []struct {
name string
arguments []string
want string
}{
{
name: "network",
arguments: append(append([]string(nil), valid...), "--network", "sctp"),
want: "--network",
},
{
name: "destination",
arguments: []string{
"-p", "tcp://198.51.100.10:11010",
"--network-name", "office",
"--network-secret", "secret",
"--ipv4", "10.144.0.10/24",
"--address", "[2001:db8::1]:7000",
},
want: "--address",
},
{
name: "connect timeout",
arguments: append(append([]string(nil), valid...), "--connect-timeout", "0s"),
want: "--connect-timeout",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := parseOptions(test.arguments)
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("parse error = %v, want containing %q", err, test.want)
}
})
}
}
func TestRoutesReachOverlayOrProxyDestination(t *testing.T) {
overlay := netip.MustParseAddr("10.144.0.20")
overlayOctets := overlay.As4()
routes := []*corehost.Route{
{
Ipv4Addr: &common.Ipv4Inet{
Address: &common.Ipv4Addr{
Addr: binary.BigEndian.Uint32(overlayOctets[:]),
},
},
},
{ProxyCidrs: []string{"10.200.0.0/24"}},
}
if !routesReach(routes, overlay) {
t.Fatal("overlay destination was not reachable")
}
if !routesReach(routes, netip.MustParseAddr("10.200.0.8")) {
t.Fatal("proxy destination was not reachable")
}
if routesReach(routes, netip.MustParseAddr("10.201.0.8")) {
t.Fatal("unknown destination was reachable")
}
}
func TestRelayTCP(t *testing.T) {
client, server := net.Pipe()
defer client.Close()
defer server.Close()
serverDone := make(chan error, 1)
go func() {
request := make([]byte, len("request"))
if _, err := io.ReadFull(server, request); err != nil {
serverDone <- err
return
}
if string(request) != "request" {
serverDone <- io.ErrUnexpectedEOF
return
}
if _, err := server.Write([]byte("response")); err != nil {
serverDone <- err
return
}
serverDone <- server.Close()
}()
var output bytes.Buffer
if err := relayTCP(client, strings.NewReader("request"), &output); err != nil {
t.Fatalf("relay TCP: %v", err)
}
if err := <-serverDone; err != nil {
t.Fatalf("serve TCP exchange: %v", err)
}
if output.String() != "response" {
t.Fatalf("TCP output = %q", output.String())
}
}
func TestExchangeUDP(t *testing.T) {
echo, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
if err != nil {
t.Fatalf("listen UDP echo: %v", err)
}
defer echo.Close()
echoDone := make(chan error, 1)
go func() {
packet := make([]byte, maxUDPPayload)
length, client, err := echo.ReadFromUDP(packet)
if err == nil {
_, err = echo.WriteToUDP(packet[:length], client)
}
echoDone <- err
}()
connection, err := net.DialUDP("udp4", nil, echo.LocalAddr().(*net.UDPAddr))
if err != nil {
t.Fatalf("dial UDP echo: %v", err)
}
defer connection.Close()
_ = connection.SetDeadline(time.Now().Add(5 * time.Second))
var output bytes.Buffer
if err := exchangeUDP(
context.Background(),
connection,
strings.NewReader("datagram"),
&output,
); err != nil {
t.Fatalf("exchange UDP: %v", err)
}
if err := <-echoDone; err != nil {
t.Fatalf("serve UDP exchange: %v", err)
}
if output.String() != "datagram" {
t.Fatalf("UDP output = %q", output.String())
}
}
func TestExchangeUDPRejectsOversizedInput(t *testing.T) {
connection, peer := net.Pipe()
defer connection.Close()
defer peer.Close()
err := exchangeUDP(
context.Background(),
connection,
strings.NewReader(strings.Repeat("x", maxUDPPayload+1)),
io.Discard,
)
if err == nil || !strings.Contains(err.Error(), "maximum payload") {
t.Fatalf("oversized UDP error = %v", err)
}
}
func TestExchangeUDPCanCancelInputRead(t *testing.T) {
connection, peer := net.Pipe()
defer connection.Close()
defer peer.Close()
input, inputWriter := io.Pipe()
defer input.Close()
defer inputWriter.Close()
ctx, cancel := context.WithCancel(context.Background())
cancel()
if err := exchangeUDP(ctx, connection, input, io.Discard); err != context.Canceled {
t.Fatalf("cancelled UDP exchange error = %v", err)
}
}
+34
View File
@@ -0,0 +1,34 @@
module github.com/EasyTier/EasyTier/easytier-go/examples/tun
go 1.25.0
require (
github.com/EasyTier/EasyTier/easytier-go v0.0.0
github.com/sagernet/sing-tun v0.8.11
)
require (
github.com/florianl/go-nfqueue/v2 v2.0.2 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/google/btree v1.1.3 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/mdlayher/netlink v1.9.0 // indirect
github.com/mdlayher/socket v0.5.1 // indirect
github.com/metacubex/wazero v0.0.0-20260628025728-9ae6bdcf2a7d // indirect
github.com/sagernet/fswatch v0.1.1 // indirect
github.com/sagernet/gvisor v0.0.0-20250811.0-sing-box-mod.1 // indirect
github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a // indirect
github.com/sagernet/nftables v0.3.0-mod.2 // indirect
github.com/sagernet/sing v0.8.0 // indirect
github.com/vishvananda/netns v0.0.4 // indirect
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/sync v0.7.0 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/time v0.7.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)
replace github.com/EasyTier/EasyTier/easytier-go => ../..
+53
View File
@@ -0,0 +1,53 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/florianl/go-nfqueue/v2 v2.0.2 h1:FL5lQTeetgpCvac1TRwSfgaXUn0YSO7WzGvWNIp3JPE=
github.com/florianl/go-nfqueue/v2 v2.0.2/go.mod h1:VA09+iPOT43OMoCKNfXHyzujQUty2xmzyCRkBOlmabc=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/mdlayher/netlink v1.9.0 h1:G8+GLq2x3v4D4MVIqDdNUhTUC7TKiCy/6MDkmItfKco=
github.com/mdlayher/netlink v1.9.0/go.mod h1:YBnl5BXsCoRuwBjKKlZ+aYmEoq0r12FDA/3JC+94KDg=
github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos=
github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ=
github.com/metacubex/wazero v0.0.0-20260628025728-9ae6bdcf2a7d h1:UMFI+kdp0jA8s9tC7oul0pWeTW6s8lnm0dD5PT+RY14=
github.com/metacubex/wazero v0.0.0-20260628025728-9ae6bdcf2a7d/go.mod h1:p48xp436h1oGfoXuEnVONeDhTW+E2UpY94GAVxA62oI=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/sagernet/fswatch v0.1.1 h1:YqID+93B7VRfqIH3PArW/XpJv5H4OLEVWDfProGoRQs=
github.com/sagernet/fswatch v0.1.1/go.mod h1:nz85laH0mkQqJfaOrqPpkwtU1znMFNVTpT/5oRsVz/o=
github.com/sagernet/gvisor v0.0.0-20250811.0-sing-box-mod.1 h1:AzCE2RhBjLJ4WIWc/GejpNh+z30d5H1hwaB0nD9eY3o=
github.com/sagernet/gvisor v0.0.0-20250811.0-sing-box-mod.1/go.mod h1:NJKBtm9nVEK3iyOYWsUlrDQuoGh4zJ4KOPhSYVidvQ4=
github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a h1:ObwtHN2VpqE0ZNjr6sGeT00J8uU7JF4cNUdb44/Duis=
github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a/go.mod h1:xLnfdiJbSp8rNqYEdIW/6eDO4mVoogml14Bh2hSiFpM=
github.com/sagernet/nftables v0.3.0-mod.2 h1:ck2KMU02OxL1eDFgGaWYglMDpoOZ7OHzxje+vW5Q0OQ=
github.com/sagernet/nftables v0.3.0-mod.2/go.mod h1:8kslHG4VvYNihcco+i6uxIX7qbT8A56T0y5q7U44ZaQ=
github.com/sagernet/sing v0.8.0 h1:OwLEwbcYfZHvu4olZVljxxC1XRicBqJ1HfiFr6F2WEE=
github.com/sagernet/sing v0.8.0/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak=
github.com/sagernet/sing-tun v0.8.11 h1:BFu4+8LNl2JiTQtto5f+5AbkH90qgdoZEAqUbGiEXCg=
github.com/sagernet/sing-tun v0.8.11/go.mod h1:QvarqUtHfj1ULaRR+6kZOS/OoCE+pYGq67A5tyIy+dQ=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8=
github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0JsFHwrHdT3Yh6szTnfY=
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ=
golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+498
View File
@@ -0,0 +1,498 @@
package main
import (
"context"
"encoding/binary"
"errors"
"flag"
"fmt"
"io"
"log"
"net/netip"
"os"
"os/signal"
"runtime"
"strings"
"sync"
"syscall"
"time"
corehost "github.com/EasyTier/EasyTier/easytier-go"
tun "github.com/sagernet/sing-tun"
)
const (
tunMTU = 1380
tunNamePrefix = "et-go"
tunReadWorkers = 32
)
func main() {
options := parseOptions()
if _, err := options.validate(); err != nil {
log.Printf("invalid arguments: %v", err)
flag.Usage()
os.Exit(2)
}
ctx, stop := signal.NotifyContext(
context.Background(),
os.Interrupt,
syscall.SIGTERM,
)
defer stop()
if err := run(ctx, options); err != nil {
log.Printf("EasyTier TUN failed: %v", err)
os.Exit(1)
}
}
// EasyTier lifecycle
func run(ctx context.Context, options options) error {
prefix, err := options.validate()
if err != nil {
return err
}
host, err := corehost.New(ctx, corehost.Options{})
if err != nil {
return fmt.Errorf("create EasyTier host: %w", err)
}
defer host.Close(context.Background())
config, err := corehost.NewInstanceConfigBuilder(options.networkName).
NetworkSecret(options.networkSecret).
IPv4(prefix).
AddPeers([]string(options.peers)...).
AddPortForwards([]corehost.PortForwardConfig(options.portForwards)...).
Build()
if err != nil {
return fmt.Errorf("build EasyTier instance config: %w", err)
}
instance, err := host.CreateInstance(ctx, config)
if err != nil {
return fmt.Errorf("create EasyTier instance: %w", err)
}
defer instance.Close(context.Background())
go logEvents(ctx, instance.Events())
device, err := createTun(prefix)
if err != nil {
return err
}
defer device.Close()
deviceName, err := device.Name()
if err != nil {
return fmt.Errorf("query TUN interface name: %w", err)
}
if err := instance.Start(ctx); err != nil {
return fmt.Errorf("start EasyTier instance: %w", err)
}
startManagementSignals(ctx, instance)
log.Printf(
"connected %s to EasyTier network %q as %s",
deviceName,
options.networkName,
prefix,
)
return bridgePackets(ctx, device, instance)
}
func logEvents(ctx context.Context, events <-chan corehost.Event) {
for {
select {
case event, open := <-events:
if !open {
return
}
log.Printf("EasyTier event [%s]: %s", event.Kind, event.Message)
case <-ctx.Done():
return
}
}
}
func startManagementSignals(
ctx context.Context,
instance *corehost.Instance,
) {
var peerSignal syscall.Signal
var routeSignal syscall.Signal
// SIGUSR1 and SIGUSR2 have different numbers on Linux and macOS.
switch runtime.GOOS {
case "linux":
peerSignal, routeSignal = 10, 12
case "darwin":
peerSignal, routeSignal = 30, 31
default:
return
}
signals := make(chan os.Signal, 1)
signal.Notify(signals, peerSignal, routeSignal)
go func() {
defer signal.Stop(signals)
for {
select {
case received := <-signals:
queryContext, cancel := context.WithTimeout(ctx, 5*time.Second)
switch received {
case peerSignal:
response, err := instance.ListPeer(queryContext)
if err != nil {
log.Printf("query EasyTier peer list: %v", err)
} else {
log.Printf("EasyTier peer list: %v", response)
}
case routeSignal:
response, err := instance.ListRoute(queryContext)
if err != nil {
log.Printf("query EasyTier route list: %v", err)
} else {
log.Printf("EasyTier route list: %v", response)
}
}
cancel()
case <-ctx.Done():
return
}
}
}()
}
// Native TUN adapter
func createTun(prefix netip.Prefix) (*packetDevice, error) {
name := tun.CalculateInterfaceName(tunNamePrefix)
nativeTun, err := tun.New(tun.Options{
Name: name,
Inet4Address: []netip.Prefix{prefix},
MTU: tunMTU,
GSO: false,
AutoRoute: false,
StrictRoute: false,
InterfaceMonitor: noRouteInterfaceMonitor{},
})
if err != nil {
return nil, fmt.Errorf(
"create TUN %q (requires elevated privileges): %w",
name,
err,
)
}
device := newPacketDevice(nativeTun)
if err := nativeTun.Start(); err != nil {
_ = device.Close()
return nil, fmt.Errorf("start TUN %q: %w", name, err)
}
return device, nil
}
type packetDevice struct {
tun.Tun
packetOffset int
readMutex sync.Mutex
writeMutex sync.Mutex
readBuffer []byte
writeBuffer []byte
closeOnce sync.Once
closeErr error
}
func newPacketDevice(nativeTun tun.Tun) *packetDevice {
device := &packetDevice{Tun: nativeTun}
if runtime.GOOS == "darwin" {
device.packetOffset = 4
device.readBuffer = make([]byte, 65535+device.packetOffset)
device.writeBuffer = make([]byte, 65535+device.packetOffset)
}
return device
}
func (device *packetDevice) Read(packet []byte) (int, error) {
device.readMutex.Lock()
defer device.readMutex.Unlock()
if device.packetOffset == 0 {
return device.Tun.Read(packet)
}
if len(packet)+device.packetOffset > len(device.readBuffer) {
return 0, io.ErrShortBuffer
}
frame := device.readBuffer[:len(packet)+device.packetOffset]
length, err := device.Tun.Read(frame)
if err != nil {
return 0, err
}
if length < device.packetOffset {
return 0, io.ErrUnexpectedEOF
}
length -= device.packetOffset
copy(packet, frame[device.packetOffset:device.packetOffset+length])
return length, nil
}
func (device *packetDevice) Write(packet []byte) (int, error) {
device.writeMutex.Lock()
defer device.writeMutex.Unlock()
if device.packetOffset == 0 {
return device.Tun.Write(packet)
}
if len(packet)+device.packetOffset > len(device.writeBuffer) {
return 0, io.ErrShortBuffer
}
frame := device.writeBuffer[:len(packet)+device.packetOffset]
if err := encodeNativeTunPacketHeader(
frame[:device.packetOffset],
packet,
); err != nil {
return 0, err
}
copy(frame[device.packetOffset:], packet)
written, err := device.Tun.Write(frame)
if written <= device.packetOffset {
return 0, err
}
return written - device.packetOffset, err
}
func (device *packetDevice) Close() error {
device.closeOnce.Do(func() {
device.closeErr = device.Tun.Close()
})
return device.closeErr
}
func encodeNativeTunPacketHeader(header, packet []byte) error {
if len(header) == 0 {
return nil
}
if len(packet) == 0 {
return fmt.Errorf("encode utun header: empty packet")
}
family := uint32(syscall.AF_INET)
switch packet[0] >> 4 {
case 4:
case 6:
family = syscall.AF_INET6
default:
return fmt.Errorf("unsupported IP version %d", packet[0]>>4)
}
binary.BigEndian.PutUint32(header, family)
return nil
}
// AutoRoute is disabled, so sing-tun v0.8.11 only calls this method.
type noRouteInterfaceMonitor struct {
tun.DefaultInterfaceMonitor
}
func (noRouteInterfaceMonitor) RegisterMyInterface(string) {
}
// Packet bridge
func bridgePackets(
ctx context.Context,
device *packetDevice,
instance *corehost.Instance,
) error {
pumpContext, cancel := context.WithCancel(ctx)
defer cancel()
results := make(chan error, tunReadWorkers+1)
for range tunReadWorkers {
go func() { results <- copyTunToEasyTier(pumpContext, device, instance) }()
}
go func() { results <- copyEasyTierToTun(pumpContext, instance, device) }()
errs := []error{<-results}
cancel()
errs = append(errs, device.Close())
for range tunReadWorkers {
errs = append(errs, <-results)
}
return errors.Join(errs...)
}
func copyTunToEasyTier(
ctx context.Context,
device io.Reader,
instance *corehost.Instance,
) error {
packet := make([]byte, 65535)
for {
length, err := device.Read(packet)
if err != nil {
if ctx.Err() != nil {
return nil
}
return fmt.Errorf("read TUN packet: %w", err)
}
if length == 0 || packet[0]>>4 != 4 {
continue
}
if err := instance.SendPacket(ctx, packet[:length]); err != nil {
if ctx.Err() != nil {
return nil
}
return fmt.Errorf("send TUN packet to EasyTier: %w", err)
}
}
}
func copyEasyTierToTun(
ctx context.Context,
instance *corehost.Instance,
device io.Writer,
) error {
for {
packet, err := instance.ReceivePacket(ctx)
if err != nil {
if ctx.Err() != nil {
return nil
}
return fmt.Errorf("receive EasyTier packet: %w", err)
}
written, err := device.Write(packet)
if err != nil {
if ctx.Err() != nil {
return nil
}
return fmt.Errorf("write EasyTier packet to TUN: %w", err)
}
// Wintun reports a full send ring as (0, nil), meaning that it has
// dropped this packet. Keep the bridge alive for subsequent packets.
if written == 0 {
continue
}
if written != len(packet) {
return io.ErrShortWrite
}
}
}
// Optional port forwarding
type portForwardList []corehost.PortForwardConfig
func (rules *portForwardList) Set(value string) error {
rule, err := parsePortForwardRule(value)
if err == nil {
*rules = append(*rules, rule)
}
return err
}
func (rules *portForwardList) String() string {
values := make([]string, len(*rules))
for index, rule := range *rules {
values[index] = fmt.Sprintf(
"%s://%s/%s",
rule.Protocol,
rule.Bind,
rule.Destination,
)
}
return strings.Join(values, ",")
}
func parsePortForwardRule(value string) (corehost.PortForwardConfig, error) {
protocolText, addresses, ok := strings.Cut(value, "://")
if !ok || (protocolText != "tcp" && protocolText != "udp") {
return corehost.PortForwardConfig{}, fmt.Errorf(
"port forward %q must use tcp:// or udp://",
value,
)
}
bindText, targetText, ok := strings.Cut(addresses, "/")
if !ok {
return corehost.PortForwardConfig{}, fmt.Errorf(
"port forward %q must contain bind/overlay-target",
value,
)
}
bind, err := netip.ParseAddrPort(bindText)
if err != nil {
return corehost.PortForwardConfig{}, fmt.Errorf("parse bind address: %w", err)
}
target, err := netip.ParseAddrPort(targetText)
if err != nil {
return corehost.PortForwardConfig{}, fmt.Errorf("parse target address: %w", err)
}
if !bind.Addr().Is4() || !target.Addr().Is4() {
return corehost.PortForwardConfig{}, fmt.Errorf(
"port forward requires IPv4 addresses",
)
}
protocol := corehost.PortForwardTCP
if protocolText == "udp" {
protocol = corehost.PortForwardUDP
}
return corehost.PortForwardConfig{
Protocol: protocol,
Bind: bind,
Destination: target,
}, nil
}
// Command-line options
func parseOptions() options {
var options options
flag.Var(&options.peers, "p", "EasyTier peer URI; may be repeated")
flag.Var(
&options.portForwards,
"port-forward",
"tcp://bind/overlay-target or udp://bind/overlay-target; may be repeated",
)
flag.StringVar(&options.networkName, "network-name", "", "EasyTier network name")
flag.StringVar(&options.networkSecret, "network-secret", "", "EasyTier network secret")
flag.StringVar(&options.ipv4, "ipv4", "", "fixed EasyTier IPv4 address and prefix")
flag.Parse()
return options
}
type peerList []string
func (peers *peerList) Set(value string) error {
if strings.TrimSpace(value) == "" {
return fmt.Errorf("peer URI must not be empty")
}
*peers = append(*peers, value)
return nil
}
func (peers *peerList) String() string {
return strings.Join(*peers, ",")
}
type options struct {
peers peerList
portForwards portForwardList
networkName string
networkSecret string
ipv4 string
}
func (options options) validate() (netip.Prefix, error) {
if len(options.peers) == 0 {
return netip.Prefix{}, fmt.Errorf("at least one -p peer is required")
}
if strings.TrimSpace(options.networkName) == "" {
return netip.Prefix{}, fmt.Errorf("--network-name is required")
}
if options.networkSecret == "" {
return netip.Prefix{}, fmt.Errorf("--network-secret is required")
}
prefix, err := netip.ParsePrefix(options.ipv4)
if err != nil {
return netip.Prefix{}, fmt.Errorf("parse --ipv4: %w", err)
}
if !prefix.Addr().Is4() {
return netip.Prefix{}, fmt.Errorf("--ipv4 must be an IPv4 prefix")
}
return prefix, nil
}
+256
View File
@@ -0,0 +1,256 @@
package main
import (
"io"
"net/netip"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
tun "github.com/sagernet/sing-tun"
)
func TestOptionsValidate(t *testing.T) {
valid := options{
peers: peerList{"tcp://198.51.100.10:11010"},
networkName: "office",
networkSecret: "secret",
ipv4: "10.144.0.10/24",
}
tests := []struct {
name string
mutate func(*options)
wantErr string
}{
{
name: "missing peer",
mutate: func(value *options) { value.peers = nil },
wantErr: "at least one -p",
},
{
name: "missing network name",
mutate: func(value *options) { value.networkName = "" },
wantErr: "--network-name",
},
{
name: "missing network secret",
mutate: func(value *options) { value.networkSecret = "" },
wantErr: "--network-secret",
},
{
name: "invalid IPv4 prefix",
mutate: func(value *options) { value.ipv4 = "10.144.0.10" },
wantErr: "--ipv4",
},
{
name: "IPv6 prefix",
mutate: func(value *options) { value.ipv4 = "fd00::10/64" },
wantErr: "IPv4",
},
}
prefix, err := valid.validate()
if err != nil {
t.Fatalf("validate valid options: %v", err)
}
if prefix != netip.MustParsePrefix("10.144.0.10/24") {
t.Fatalf("validated prefix = %s", prefix)
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
value := valid
value.peers = append(peerList(nil), valid.peers...)
test.mutate(&value)
_, err := value.validate()
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("validate error = %v, want containing %q", err, test.wantErr)
}
})
}
}
func TestPeerListRejectsEmptyPeer(t *testing.T) {
var peers peerList
if err := peers.Set(" "); err == nil {
t.Fatal("empty peer was accepted")
}
if err := peers.Set("tcp://198.51.100.10:11010"); err != nil {
t.Fatalf("set peer: %v", err)
}
if got := peers.String(); got != "tcp://198.51.100.10:11010" {
t.Fatalf("peer list string = %q", got)
}
}
type blockingTun struct {
readStarted chan struct{}
closed chan struct{}
startOnce sync.Once
closeOnce sync.Once
closeCount atomic.Int32
}
func (device *blockingTun) Read([]byte) (int, error) {
device.startOnce.Do(func() { close(device.readStarted) })
<-device.closed
return 0, io.ErrClosedPipe
}
func (*blockingTun) Write(packet []byte) (int, error) {
return len(packet), nil
}
func (*blockingTun) Name() (string, error) {
return "test-tun", nil
}
func (*blockingTun) Start() error {
return nil
}
func (device *blockingTun) Close() error {
device.closeOnce.Do(func() {
device.closeCount.Add(1)
close(device.closed)
})
return nil
}
func (*blockingTun) UpdateRouteOptions(tun.Options) error {
return nil
}
func TestPacketDeviceCloseUnblocksRead(t *testing.T) {
nativeTun := &blockingTun{
readStarted: make(chan struct{}),
closed: make(chan struct{}),
}
device := newPacketDevice(nativeTun)
readDone := make(chan error, 1)
go func() {
_, err := device.Read(make([]byte, 1500))
readDone <- err
}()
<-nativeTun.readStarted
if err := device.Close(); err != nil {
t.Fatalf("close device: %v", err)
}
if err := device.Close(); err != nil {
t.Fatalf("close device again: %v", err)
}
select {
case err := <-readDone:
if err != io.ErrClosedPipe {
t.Fatalf("read error = %v, want %v", err, io.ErrClosedPipe)
}
case <-time.After(time.Second):
t.Fatal("read remained blocked after close")
}
if count := nativeTun.closeCount.Load(); count != 1 {
t.Fatalf("native close count = %d, want 1", count)
}
}
type memoryTun struct {
readPacket []byte
wrotePacket []byte
}
func (device *memoryTun) Read(packet []byte) (int, error) {
return copy(packet, device.readPacket), nil
}
func (device *memoryTun) Write(packet []byte) (int, error) {
device.wrotePacket = append(device.wrotePacket[:0], packet...)
return len(packet), nil
}
func (*memoryTun) Name() (string, error) {
return "memory-tun", nil
}
func (*memoryTun) Start() error {
return nil
}
func (*memoryTun) Close() error {
return nil
}
func (*memoryTun) UpdateRouteOptions(tun.Options) error {
return nil
}
func TestPacketDeviceNativeFraming(t *testing.T) {
ipPacket := []byte{
0x45, 0, 0, 20,
0, 0, 0, 0,
64, 1, 0, 0,
10, 0, 0, 1,
10, 0, 0, 2,
}
nativeTun := &memoryTun{}
device := newPacketDevice(nativeTun)
nativePacket := make([]byte, device.packetOffset+len(ipPacket))
if err := encodeNativeTunPacketHeader(
nativePacket[:device.packetOffset],
ipPacket,
); err != nil {
t.Fatalf("encode native test packet: %v", err)
}
copy(nativePacket[device.packetOffset:], ipPacket)
nativeTun.readPacket = nativePacket
received := make([]byte, 1500)
length, err := device.Read(received)
if err != nil {
t.Fatalf("read packet: %v", err)
}
if string(received[:length]) != string(ipPacket) {
t.Fatalf("read packet = %x, want %x", received[:length], ipPacket)
}
written, err := device.Write(ipPacket)
if err != nil {
t.Fatalf("write packet: %v", err)
}
if written != len(ipPacket) {
t.Fatalf("write length = %d, want %d", written, len(ipPacket))
}
if string(nativeTun.wrotePacket) != string(nativePacket) {
t.Fatalf(
"native write packet = %x, want %x",
nativeTun.wrotePacket,
nativePacket,
)
}
}
func TestPortForwardList(t *testing.T) {
var rules portForwardList
for _, value := range []string{
"tcp://127.0.0.1:5202/10.144.0.20:5201",
"udp://0.0.0.0:5203/10.144.0.21:5201",
} {
if err := rules.Set(value); err != nil {
t.Fatalf("set %q: %v", value, err)
}
}
if got := rules.String(); got !=
"tcp://127.0.0.1:5202/10.144.0.20:5201,"+
"udp://0.0.0.0:5203/10.144.0.21:5201" {
t.Fatalf("rules string = %q", got)
}
if err := rules.Set("sctp://127.0.0.1:1/10.0.0.1:2"); err == nil {
t.Fatal("unsupported protocol was accepted")
}
if rules[0].Protocol != "tcp" ||
rules[0].Bind != netip.MustParseAddrPort("127.0.0.1:5202") ||
rules[0].Destination != netip.MustParseAddrPort("10.144.0.20:5201") {
t.Fatalf("first port-forward rule = %+v", rules[0])
}
}
+91
View File
@@ -0,0 +1,91 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"os/signal"
"strings"
"syscall"
corehost "github.com/EasyTier/EasyTier/easytier-go"
)
type options struct {
endpoint string
machineID string
hostname string
secure bool
}
func main() {
options, err := parseOptions(os.Args[1:])
if err == flag.ErrHelp {
return
}
if err != nil {
log.Printf("invalid arguments: %v", err)
os.Exit(2)
}
ctx, stop := signal.NotifyContext(
context.Background(),
os.Interrupt,
syscall.SIGTERM,
)
defer stop()
if err := run(ctx, options); err != nil {
log.Printf("EasyTier Web client failed: %v", err)
os.Exit(1)
}
}
func parseOptions(arguments []string) (options, error) {
flags := flag.NewFlagSet("web-client", flag.ContinueOnError)
var options options
flags.StringVar(&options.endpoint, "web-endpoint", "", "Web config-server endpoint")
flags.StringVar(&options.machineID, "web-machine-id", "", "stable machine UUID")
flags.StringVar(&options.hostname, "web-hostname", "", "machine hostname")
flags.BoolVar(&options.secure, "web-secure", false, "use a secure Web connection")
if err := flags.Parse(arguments); err != nil {
return options, err
}
if flags.NArg() != 0 {
return options, fmt.Errorf("unexpected argument %q", flags.Arg(0))
}
options.endpoint = strings.TrimSpace(options.endpoint)
options.machineID = strings.TrimSpace(options.machineID)
options.hostname = strings.TrimSpace(options.hostname)
if options.endpoint == "" {
return options, fmt.Errorf("--web-endpoint is required")
}
if options.machineID == "" {
return options, fmt.Errorf("--web-machine-id is required")
}
return options, nil
}
func run(ctx context.Context, options options) error {
host, err := corehost.New(ctx, corehost.Options{})
if err != nil {
return fmt.Errorf("create EasyTier host: %w", err)
}
defer host.Close(context.Background())
client, err := host.ConnectWebClient(ctx, corehost.WebClientOptions{
Endpoint: options.endpoint,
MachineID: options.machineID,
Hostname: options.hostname,
SecureMode: options.secure,
})
if err != nil {
return fmt.Errorf("connect EasyTier Web client: %w", err)
}
defer client.Close(context.Background())
log.Printf("EasyTier Web client started for machine %s", options.machineID)
<-ctx.Done()
return nil
}
@@ -0,0 +1,53 @@
package main
import (
"strings"
"testing"
)
func TestParseOptions(t *testing.T) {
options, err := parseOptions([]string{
"--web-endpoint", " tcp://config.example.com:22020/team ",
"--web-machine-id", " 11111111-2222-4333-8444-555555555555 ",
"--web-hostname", " edge-gateway ",
"--web-secure",
})
if err != nil {
t.Fatalf("parse options: %v", err)
}
if options.endpoint != "tcp://config.example.com:22020/team" ||
options.machineID != "11111111-2222-4333-8444-555555555555" ||
options.hostname != "edge-gateway" ||
!options.secure {
t.Fatalf("parsed options = %+v", options)
}
}
func TestParseOptionsRequiresEndpointAndMachineID(t *testing.T) {
tests := []struct {
name string
arguments []string
want string
}{
{
name: "endpoint",
arguments: []string{
"--web-machine-id", "11111111-2222-4333-8444-555555555555",
},
want: "--web-endpoint",
},
{
name: "machine ID",
arguments: []string{"--web-endpoint", "tcp://config.example.com:22020/team"},
want: "--web-machine-id",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := parseOptions(test.arguments)
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("parse error = %v, want containing %q", err, test.want)
}
})
}
}
+9
View File
@@ -0,0 +1,9 @@
module github.com/EasyTier/EasyTier/easytier-go
go 1.20
require (
github.com/metacubex/wazero v0.0.0-20260628025728-9ae6bdcf2a7d
golang.org/x/sys v0.30.0
google.golang.org/protobuf v1.34.2
)
+8
View File
@@ -0,0 +1,8 @@
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/metacubex/wazero v0.0.0-20260628025728-9ae6bdcf2a7d h1:UMFI+kdp0jA8s9tC7oul0pWeTW6s8lnm0dD5PT+RY14=
github.com/metacubex/wazero v0.0.0-20260628025728-9ae6bdcf2a7d/go.mod h1:p48xp436h1oGfoXuEnVONeDhTW+E2UpY94GAVxA62oI=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
+36
View File
@@ -0,0 +1,36 @@
package host
import (
"context"
internalhost "github.com/EasyTier/EasyTier/easytier-go/internal/host"
)
type State = internalhost.State
const (
StateCreated State = internalhost.StateCreated
StateStarting State = internalhost.StateStarting
StateRunning State = internalhost.StateRunning
StateStopping State = internalhost.StateStopping
StateStopped State = internalhost.StateStopped
)
type Options = internalhost.Options
type EmbeddedCoreInfo = internalhost.EmbeddedCoreInfo
// Event is one best-effort notification emitted by an EasyTier instance.
type Event = internalhost.Event
type Host = internalhost.Host
type Instance = internalhost.Instance
func New(ctx context.Context, options Options) (*Host, error) {
return internalhost.New(ctx, options)
}
func CoreInfo() EmbeddedCoreInfo {
return internalhost.CoreInfo()
}
+31
View File
@@ -0,0 +1,31 @@
package host
import internalhost "github.com/EasyTier/EasyTier/easytier-go/internal/host"
// P2PPolicy controls when an instance attempts peer-to-peer connections.
type P2PPolicy = internalhost.P2PPolicy
// HolePunchingPolicy selects the hole-punching methods available to an instance.
type HolePunchingPolicy = internalhost.HolePunchingPolicy
// PortForwardProtocol identifies the transport used by a port-forward rule.
type PortForwardProtocol = internalhost.PortForwardProtocol
const (
PortForwardTCP PortForwardProtocol = internalhost.PortForwardTCP
PortForwardUDP PortForwardProtocol = internalhost.PortForwardUDP
)
// PortForwardConfig exposes a host socket through the EasyTier data plane.
type PortForwardConfig = internalhost.PortForwardConfig
// InstanceConfig is an immutable EasyTier instance configuration.
type InstanceConfig = internalhost.InstanceConfig
// InstanceConfigBuilder builds a validated InstanceConfig.
type InstanceConfigBuilder = internalhost.InstanceConfigBuilder
// NewInstanceConfigBuilder starts a configuration for networkName.
func NewInstanceConfigBuilder(networkName string) *InstanceConfigBuilder {
return internalhost.NewInstanceConfigBuilder(networkName)
}
Binary file not shown.
+12
View File
@@ -0,0 +1,12 @@
package artifact
import _ "embed"
//go:generate go run ../../cmd/update-wasm
//go:embed easytier_core.wasm
var core []byte
func Core() []byte {
return core
}
@@ -0,0 +1,17 @@
package artifact
import (
"crypto/sha256"
"encoding/hex"
"testing"
)
func TestEmbeddedCoreMatchesProvenance(t *testing.T) {
if len(Commit) != 40 {
t.Fatalf("EasyTier commit = %q", Commit)
}
digest := sha256.Sum256(Core())
if got := hex.EncodeToString(digest[:]); got != SHA256 {
t.Fatalf("embedded core SHA-256 = %s, want %s", got, SHA256)
}
}
@@ -0,0 +1,8 @@
// Code generated by go generate; DO NOT EDIT.
package artifact
const (
Commit = "63519db2b5f2a6a1b9b7f20905f036dab54eb829"
SHA256 = "8b82f37d62fba2ffe8e256386096fddf448d4261d51c495d5bd97cd11bbb24a0"
)
@@ -0,0 +1,57 @@
package contextutil
import (
"context"
"sync"
"time"
)
type withoutCancelContext struct {
context.Context
}
func (withoutCancelContext) Deadline() (time.Time, bool) {
return time.Time{}, false
}
func (withoutCancelContext) Done() <-chan struct{} {
return nil
}
func (withoutCancelContext) Err() error {
return nil
}
// WithoutCancel returns a copy of parent that is not canceled when parent is.
//
// This is the Go 1.20-compatible equivalent of context.WithoutCancel.
func WithoutCancel(parent context.Context) context.Context {
if parent == nil {
panic("cannot create context from nil parent")
}
return withoutCancelContext{Context: parent}
}
// AfterFunc arranges to call function in its own goroutine after context is
// canceled. The returned stop function reports whether it prevented the call.
//
// This is the Go 1.20-compatible equivalent of context.AfterFunc.
func AfterFunc(ctx context.Context, function func()) func() bool {
var once sync.Once
stopped := make(chan struct{})
go func() {
select {
case <-ctx.Done():
once.Do(func() { go function() })
case <-stopped:
}
}()
return func() bool {
prevented := false
once.Do(func() {
prevented = true
close(stopped)
})
return prevented
}
}
@@ -0,0 +1,62 @@
package contextutil
import (
"context"
"testing"
"time"
)
type contextKey struct{}
func TestWithoutCancelPreservesValuesOnly(t *testing.T) {
deadline := time.Now().Add(time.Minute)
parent, cancel := context.WithDeadline(
context.WithValue(context.Background(), contextKey{}, "value"),
deadline,
)
cancel()
ctx := WithoutCancel(parent)
if got := ctx.Value(contextKey{}); got != "value" {
t.Fatalf("value = %v, want value", got)
}
if _, ok := ctx.Deadline(); ok {
t.Fatal("deadline was preserved")
}
if ctx.Done() != nil {
t.Fatal("Done channel is non-nil")
}
if err := ctx.Err(); err != nil {
t.Fatalf("Err = %v, want nil", err)
}
}
func TestAfterFuncRunsOnCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
called := make(chan struct{})
stop := AfterFunc(ctx, func() { close(called) })
cancel()
select {
case <-called:
case <-time.After(time.Second):
t.Fatal("function was not called")
}
if stop() {
t.Fatal("stop prevented an already started function")
}
}
func TestAfterFuncCanBeStopped(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
called := make(chan struct{})
stop := AfterFunc(ctx, func() { close(called) })
if !stop() {
t.Fatal("stop did not prevent function")
}
cancel()
select {
case <-called:
t.Fatal("stopped function was called")
case <-time.After(10 * time.Millisecond):
}
}
+495
View File
@@ -0,0 +1,495 @@
package coreabi
import (
"context"
"errors"
"fmt"
"github.com/EasyTier/EasyTier/easytier-go/internal/contextutil"
"github.com/metacubex/wazero/api"
)
type State int32
const (
StateCreated State = iota
StateStarting
StateRunning
StateStopping
StateStopped
)
type Core struct {
module api.Module
functions map[string]guestFunction
driveFunction guestFunction
deadlineFunction guestFunction
completionFunction guestFunction
handle uint64
packetBuffer uint32
packetBufferCapacity uint32
dataPlaneAddress uint32
dataPlaneInput uint32
dataPlaneInputSize uint32
dataPlaneOutput uint32
dataPlaneOutputSize uint32
dropped bool
}
type guestFunction struct {
call api.Function
parameterCount int
}
func New(module api.Module) (*Core, error) {
if module == nil {
return nil, fmt.Errorf("create core ABI with nil guest module")
}
core := &Core{
module: module,
functions: make(map[string]guestFunction),
}
if err := core.validateDataPlaneABI(context.Background()); err != nil {
return nil, fmt.Errorf("validate EasyTier data plane ABI: %w", err)
}
if err := core.validateRPCABI(context.Background()); err != nil {
return nil, fmt.Errorf("validate EasyTier RPC ABI: %w", err)
}
return core, nil
}
func (core *Core) Create(
ctx context.Context,
configEnvelope []byte,
packetSink uint64,
eventSink uint64,
) (err error) {
if core.handle != 0 || core.dropped {
return fmt.Errorf("create EasyTier instance in used guest module")
}
pointer, err := core.allocate(ctx, uint32(len(configEnvelope)))
if err != nil {
return err
}
defer core.cleanupBuffer(ctx, pointer, &err)
if !core.module.Memory().Write(pointer, configEnvelope) {
return fmt.Errorf("write EasyTier config to guest memory")
}
result, err := core.callOne(
ctx,
"easytier_instance_create",
uint64(pointer),
uint64(len(configEnvelope)),
packetSink,
eventSink,
)
if err != nil {
return err
}
if result == 0 {
message, readErr := core.errorMessage(ctx, 0)
if readErr != nil {
return fmt.Errorf("create EasyTier instance: %w", readErr)
}
return fmt.Errorf("create EasyTier instance: %s", message)
}
core.handle = result
return nil
}
func (core *Core) Start(ctx context.Context) error {
return core.callStatus(ctx, "easytier_instance_start")
}
func (core *Core) Stop(ctx context.Context) error {
return core.callStatus(ctx, "easytier_instance_stop")
}
func (core *Core) Drive(ctx context.Context) (State, error) {
result, err := core.callCached(
ctx,
"easytier_instance_drive",
&core.driveFunction,
core.handle,
)
if err != nil {
return 0, err
}
state := State(int32(result))
if state < 0 {
return 0, core.statusError(ctx, "drive EasyTier instance", int32(state))
}
return state, nil
}
func (core *Core) NotifyCompletions(ctx context.Context) error {
return core.callStatus(ctx, "easytier_instance_notify_completions")
}
func (core *Core) State(ctx context.Context) (State, error) {
result, err := core.callOne(ctx, "easytier_instance_state", core.handle)
if err != nil {
return 0, err
}
state := State(int32(result))
if state < 0 {
return 0, core.statusError(ctx, "query EasyTier state", int32(state))
}
return state, nil
}
func (core *Core) NextDeadline(ctx context.Context) (int64, error) {
result, err := core.callCached(
ctx,
"easytier_instance_next_deadline_millis",
&core.deadlineFunction,
core.handle,
)
if err != nil {
return 0, err
}
deadline := int64(result)
if deadline < 0 {
return 0, core.statusError(ctx, "query EasyTier deadline", int32(deadline))
}
return deadline, nil
}
func (core *Core) SendPacket(ctx context.Context, packet []byte) (err error) {
if len(packet) == 0 {
return fmt.Errorf("send empty packet to EasyTier instance")
}
pointer, err := core.ensurePacketBuffer(ctx, uint32(len(packet)))
if err != nil {
return err
}
if !core.module.Memory().Write(pointer, packet) {
return fmt.Errorf("write packet to guest memory")
}
result, err := core.callOne(
ctx,
"easytier_instance_send_packet",
core.handle,
uint64(pointer),
uint64(len(packet)),
)
if err != nil {
return err
}
if status := int32(result); status != 0 {
return core.statusError(ctx, "send packet to EasyTier instance", status)
}
return nil
}
func (core *Core) Drop(ctx context.Context) error {
if core.dropped || core.handle == 0 {
return nil
}
if err := core.callStatus(ctx, "easytier_instance_drop"); err != nil {
return err
}
core.dropped = true
core.handle = 0
var releaseErr error
if core.packetBuffer != 0 {
err := core.free(ctx, core.packetBuffer)
core.packetBuffer = 0
core.packetBufferCapacity = 0
if err != nil {
releaseErr = errors.Join(
releaseErr,
fmt.Errorf("release EasyTier packet buffer: %w", err),
)
}
}
if core.dataPlaneInput != 0 {
err := core.free(ctx, core.dataPlaneInput)
core.dataPlaneInput = 0
core.dataPlaneInputSize = 0
if err != nil {
releaseErr = errors.Join(
releaseErr,
fmt.Errorf("release EasyTier data plane input buffer: %w", err),
)
}
}
if core.dataPlaneAddress != 0 {
err := core.free(ctx, core.dataPlaneAddress)
core.dataPlaneAddress = 0
if err != nil {
releaseErr = errors.Join(
releaseErr,
fmt.Errorf("release EasyTier data plane address buffer: %w", err),
)
}
}
if core.dataPlaneOutput != 0 {
err := core.free(ctx, core.dataPlaneOutput)
core.dataPlaneOutput = 0
core.dataPlaneOutputSize = 0
if err != nil {
releaseErr = errors.Join(
releaseErr,
fmt.Errorf("release EasyTier data plane output buffer: %w", err),
)
}
}
return releaseErr
}
func (core *Core) callStatus(ctx context.Context, name string) error {
if core.dropped || core.handle == 0 {
return fmt.Errorf("%s on unavailable EasyTier instance", name)
}
result, err := core.callOne(ctx, name, core.handle)
if err != nil {
return err
}
if status := int32(result); status != 0 {
return core.statusError(ctx, name, status)
}
return nil
}
func (core *Core) statusError(
ctx context.Context,
operation string,
status int32,
) error {
message, err := core.errorMessage(ctx, core.handle)
if err != nil {
return fmt.Errorf("%s: status=%d: %w", operation, status, err)
}
return fmt.Errorf("%s: status=%d: %s", operation, status, message)
}
func (core *Core) errorMessage(
ctx context.Context,
handle uint64,
) (message string, err error) {
length, err := core.callOne(ctx, "easytier_instance_error_len", handle)
if err != nil {
return "", err
}
if length == 0 {
return "<no core error>", nil
}
pointer, err := core.allocate(ctx, uint32(length))
if err != nil {
return "", err
}
defer core.cleanupBuffer(ctx, pointer, &err)
result, err := core.callOne(
ctx,
"easytier_instance_error_copy",
handle,
uint64(pointer),
length,
)
if err != nil {
return "", err
}
if status := int32(result); status < 0 {
return "", fmt.Errorf("copy EasyTier error: status=%d", status)
}
encoded, ok := core.module.Memory().Read(pointer, uint32(length))
if !ok {
return "", fmt.Errorf("read EasyTier error from guest memory")
}
return string(encoded), nil
}
func (core *Core) allocate(ctx context.Context, length uint32) (uint32, error) {
result, err := core.callOne(ctx, "easytier_buffer_alloc", uint64(length))
if err != nil {
return 0, err
}
if result == 0 {
return 0, fmt.Errorf("allocate %d guest bytes", length)
}
return uint32(result), nil
}
func (core *Core) free(ctx context.Context, pointer uint32) error {
result, err := core.callOne(ctx, "easytier_buffer_free", uint64(pointer))
if err != nil {
return err
}
if status := int32(result); status != 0 {
return fmt.Errorf("free guest buffer: status=%d", status)
}
return nil
}
func (core *Core) ensurePacketBuffer(
ctx context.Context,
length uint32,
) (uint32, error) {
if core.packetBufferCapacity >= length {
return core.packetBuffer, nil
}
if core.packetBuffer != 0 {
if err := core.free(ctx, core.packetBuffer); err != nil {
return 0, fmt.Errorf("grow EasyTier packet buffer: %w", err)
}
core.packetBuffer = 0
core.packetBufferCapacity = 0
}
pointer, err := core.allocate(ctx, length)
if err != nil {
return 0, err
}
core.packetBuffer = pointer
core.packetBufferCapacity = length
return pointer, nil
}
func (core *Core) ensureDataPlaneInput(
ctx context.Context,
length uint32,
) (uint32, error) {
if core.dataPlaneInputSize >= length {
return core.dataPlaneInput, nil
}
if core.dataPlaneInput != 0 {
if err := core.free(ctx, core.dataPlaneInput); err != nil {
return 0, fmt.Errorf("grow EasyTier data plane input buffer: %w", err)
}
core.dataPlaneInput = 0
core.dataPlaneInputSize = 0
}
pointer, err := core.allocate(ctx, length)
if err != nil {
return 0, err
}
core.dataPlaneInput = pointer
core.dataPlaneInputSize = length
return pointer, nil
}
func (core *Core) ensureDataPlaneAddress(ctx context.Context) (uint32, error) {
if core.dataPlaneAddress != 0 {
return core.dataPlaneAddress, nil
}
pointer, err := core.allocate(ctx, socketAddressLength)
if err != nil {
return 0, err
}
core.dataPlaneAddress = pointer
return pointer, nil
}
func (core *Core) ensureDataPlaneOutput(
ctx context.Context,
length uint32,
) (uint32, error) {
if core.dataPlaneOutputSize >= length {
return core.dataPlaneOutput, nil
}
if core.dataPlaneOutput != 0 {
if err := core.free(ctx, core.dataPlaneOutput); err != nil {
return 0, fmt.Errorf("grow EasyTier data plane output buffer: %w", err)
}
core.dataPlaneOutput = 0
core.dataPlaneOutputSize = 0
}
pointer, err := core.allocate(ctx, length)
if err != nil {
return 0, err
}
core.dataPlaneOutput = pointer
core.dataPlaneOutputSize = length
return pointer, nil
}
func (core *Core) cleanupBuffer(
ctx context.Context,
pointer uint32,
operationErr *error,
) {
cleanupErr := core.free(contextutil.WithoutCancel(ctx), pointer)
if *operationErr == nil && cleanupErr != nil {
*operationErr = cleanupErr
}
}
func (core *Core) callOne(
ctx context.Context,
name string,
params ...uint64,
) (uint64, error) {
function, err := core.resolveFunction(name)
if err != nil {
return 0, err
}
return callGuestFunction(ctx, name, function, params)
}
func (core *Core) callCached(
ctx context.Context,
name string,
cached *guestFunction,
params ...uint64,
) (uint64, error) {
if cached.call == nil {
function, err := core.resolveFunction(name)
if err != nil {
return 0, err
}
*cached = function
}
return callGuestFunction(ctx, name, *cached, params)
}
func (core *Core) resolveFunction(name string) (guestFunction, error) {
function, exists := core.functions[name]
if !exists {
call := core.module.ExportedFunction(name)
if call == nil {
return guestFunction{}, fmt.Errorf(
"EasyTier guest does not export %s",
name,
)
}
definition := call.Definition()
if count := len(definition.ResultTypes()); count != 1 {
return guestFunction{}, fmt.Errorf(
"%s returned %d values",
name,
count,
)
}
function = guestFunction{
call: call,
parameterCount: len(definition.ParamTypes()),
}
core.functions[name] = function
}
return function, nil
}
func callGuestFunction(
ctx context.Context,
name string,
function guestFunction,
params []uint64,
) (uint64, error) {
if len(params) != function.parameterCount {
return 0, fmt.Errorf(
"%s expected %d params, but passed %d",
name,
function.parameterCount,
len(params),
)
}
stack := params
if len(stack) == 0 {
stack = make([]uint64, 1)
}
if err := function.call.CallWithStack(ctx, stack); err != nil {
return 0, fmt.Errorf("%s: %w", name, err)
}
return stack[0], nil
}
+62
View File
@@ -0,0 +1,62 @@
package coreabi
import (
"context"
"testing"
"github.com/metacubex/wazero/api"
"github.com/metacubex/wazero/experimental/wazerotest"
)
func TestCallOneCachesFunction(t *testing.T) {
callCount := 0
increment := wazerotest.NewFunction(func(
_ context.Context,
_ api.Module,
value uint64,
) uint64 {
callCount++
return value + 1
})
increment.ExportNames = []string{"increment"}
module := wazerotest.NewModule(nil, increment)
core := &Core{
module: module,
functions: make(map[string]guestFunction),
}
for i := uint64(0); i < 2; i++ {
result, err := core.callOne(context.Background(), "increment", i)
if err != nil {
t.Fatalf("call increment: %v", err)
}
if result != i+1 {
t.Fatalf("increment result = %d, want %d", result, i+1)
}
}
if callCount != 2 {
t.Fatalf("call count = %d, want 2", callCount)
}
if len(core.functions) != 1 {
t.Fatalf("cached functions = %d, want 1", len(core.functions))
}
}
func TestCallOneValidatesSignature(t *testing.T) {
noResult := wazerotest.NewFunction(func(
_ context.Context,
_ api.Module,
) {
})
noResult.ExportNames = []string{"no_result"}
module := wazerotest.NewModule(nil, noResult)
core := &Core{
module: module,
functions: make(map[string]guestFunction),
}
_, err := core.callOne(context.Background(), "no_result")
if err == nil {
t.Fatal("call function without result: expected error")
}
}
+715
View File
@@ -0,0 +1,715 @@
package coreabi
import (
"context"
"encoding/binary"
"errors"
"fmt"
"math"
"net/netip"
"github.com/EasyTier/EasyTier/easytier-go/internal/contextutil"
)
const (
DataPlaneABIVersion = 3
DataPlaneCapability uint64 = 1 << 0
DataPlaneTCPCapability uint64 = 1 << 1
DataPlaneUDPCapability uint64 = 1 << 2
DeadlineRead DeadlineDirection = 1 << 0
DeadlineWrite DeadlineDirection = 1 << 1
requiredDataPlaneCapabilities = DataPlaneCapability |
DataPlaneTCPCapability |
DataPlaneUDPCapability
)
type OperationID uint64
type ResourceID uint64
type DeadlineDirection uint32
type OperationKind uint16
const (
OperationTCPConnect OperationKind = iota + 1
OperationTCPBind
OperationTCPAccept
OperationTCPRead
OperationTCPWrite
OperationUDPBind
OperationUDPReceive
OperationUDPSend
)
func (kind OperationKind) valid() bool {
return kind >= OperationTCPConnect && kind <= OperationUDPSend
}
type ErrorKind uint16
const (
ErrorNone ErrorKind = iota
ErrorCancelled
ErrorDeadlineExceeded
ErrorInstanceStopped
ErrorHandleClosed
ErrorNoOverlayRoute
ErrorPathNotReady
ErrorAddressFamilyUnsupported
ErrorAddressInUse
ErrorConnectionRefused
ErrorNetworkChanged
ErrorResourceLimit
ErrorIO
ErrorBufferTooSmall
)
func (kind ErrorKind) validCompletionStatus() bool {
return kind <= ErrorBufferTooSmall
}
type DataPlaneError struct {
Kind ErrorKind
Message string
}
func (err *DataPlaneError) Error() string {
if err.Message == "" {
return fmt.Sprintf("data plane error %d", err.Kind)
}
return err.Message
}
type Completion struct {
Operation OperationID
Kind OperationKind
Status ErrorKind
}
type OperationResult struct {
Kind OperationKind
Resource ResourceID
Local netip.AddrPort
Peer netip.AddrPort
Data []byte
Length int
EOF bool
Truncated bool
}
func (core *Core) validateDataPlaneABI(ctx context.Context) error {
version, err := core.callOne(ctx, "easytier_data_plane_abi_version")
if err != nil {
return err
}
if version != DataPlaneABIVersion {
return fmt.Errorf(
"unsupported EasyTier data plane ABI version %d, want %d",
version,
DataPlaneABIVersion,
)
}
capabilities, err := core.callOne(ctx, "easytier_data_plane_capabilities")
if err != nil {
return err
}
if missing := requiredDataPlaneCapabilities &^ capabilities; missing != 0 {
return fmt.Errorf(
"EasyTier data plane capabilities %#x are missing %#x",
capabilities,
missing,
)
}
return nil
}
func (core *Core) SubmitTCPConnect(
ctx context.Context,
peer netip.AddrPort,
timeoutMillis uint64,
) (OperationID, error) {
wire, err := encodeSocketAddress(peer)
if err != nil {
return 0, err
}
return core.submitWithInput(
ctx,
"easytier_data_plane_tcp_connect_submit",
wire[:],
timeoutMillis,
)
}
func (core *Core) SubmitTCPBind(
ctx context.Context,
port uint16,
timeoutMillis uint64,
) (OperationID, error) {
return core.submit(
ctx,
"easytier_data_plane_tcp_bind_submit",
uint64(port),
timeoutMillis,
)
}
func (core *Core) SubmitTCPAccept(
ctx context.Context,
listener ResourceID,
timeoutMillis uint64,
) (OperationID, error) {
return core.submit(
ctx,
"easytier_data_plane_tcp_accept_submit",
uint64(listener),
timeoutMillis,
)
}
func (core *Core) SubmitTCPRead(
ctx context.Context,
stream ResourceID,
maximum uint32,
) (OperationID, error) {
return core.submit(
ctx,
"easytier_data_plane_tcp_read_submit",
uint64(stream),
uint64(maximum),
)
}
func (core *Core) SubmitTCPWrite(
ctx context.Context,
stream ResourceID,
data []byte,
) (operation OperationID, err error) {
if len(data) > maxDataPlaneTransferBytes {
return 0, fmt.Errorf(
"TCP write length %d exceeds data plane limit %d",
len(data),
maxDataPlaneTransferBytes,
)
}
pointer, err := core.writeInput(ctx, data)
if err != nil {
return 0, err
}
if pointer != 0 {
defer core.cleanupBuffer(ctx, pointer, &err)
}
return core.submit(
ctx,
"easytier_data_plane_tcp_write_submit",
uint64(stream),
uint64(pointer),
uint64(len(data)),
)
}
func (core *Core) SubmitUDPBind(
ctx context.Context,
port uint16,
timeoutMillis uint64,
) (OperationID, error) {
return core.submit(
ctx,
"easytier_data_plane_udp_bind_submit",
uint64(port),
timeoutMillis,
)
}
func (core *Core) SubmitUDPReceive(
ctx context.Context,
socket ResourceID,
maximum uint32,
) (OperationID, error) {
return core.submit(
ctx,
"easytier_data_plane_udp_receive_submit",
uint64(socket),
uint64(maximum),
)
}
func (core *Core) SubmitUDPSend(
ctx context.Context,
socket ResourceID,
peer netip.AddrPort,
data []byte,
) (operation OperationID, err error) {
if len(data) > maxDataPlaneTransferBytes {
return 0, fmt.Errorf(
"UDP send length %d exceeds data plane limit %d",
len(data),
maxDataPlaneTransferBytes,
)
}
address, err := encodeSocketAddress(peer)
if err != nil {
return 0, err
}
addressPointer, err := core.ensureDataPlaneAddress(ctx)
if err != nil {
return 0, err
}
if !core.module.Memory().Write(addressPointer, address[:]) {
return 0, fmt.Errorf("write UDP peer address to guest memory")
}
var dataPointer uint32
if len(data) != 0 {
dataPointer, err = core.ensureDataPlaneInput(ctx, uint32(len(data)))
if err != nil {
return 0, err
}
if !core.module.Memory().Write(dataPointer, data) {
return 0, fmt.Errorf("write UDP payload to guest memory")
}
}
return core.submit(
ctx,
"easytier_data_plane_udp_send_submit",
uint64(socket),
uint64(addressPointer),
uint64(dataPointer),
uint64(len(data)),
)
}
func (core *Core) SetResourceDeadline(
ctx context.Context,
resource ResourceID,
direction DeadlineDirection,
timeoutMillis uint64,
) error {
result, err := core.callOne(
ctx,
"easytier_data_plane_resource_deadline_set",
core.handle,
uint64(resource),
uint64(direction),
timeoutMillis,
)
if err != nil {
return err
}
if status := int32(result); status != 0 {
return core.dataPlaneStatusError(
ctx,
"set data plane resource deadline",
status,
)
}
return nil
}
func (core *Core) submitWithInput(
ctx context.Context,
name string,
input []byte,
params ...uint64,
) (operation OperationID, err error) {
pointer, err := core.writeInput(ctx, input)
if err != nil {
return 0, err
}
defer core.cleanupBuffer(ctx, pointer, &err)
return core.submit(ctx, name, append([]uint64{uint64(pointer)}, params...)...)
}
func (core *Core) submit(
ctx context.Context,
name string,
params ...uint64,
) (operation OperationID, err error) {
pointer, err := core.ensureDataPlaneOutput(ctx, operationIDLength)
if err != nil {
return 0, err
}
callParams := make([]uint64, 0, len(params)+2)
callParams = append(callParams, core.handle)
callParams = append(callParams, params...)
callParams = append(callParams, uint64(pointer))
result, err := core.callOne(ctx, name, callParams...)
if err != nil {
return 0, err
}
if status := int32(result); status != 0 {
return 0, core.dataPlaneStatusError(ctx, name, status)
}
wire, ok := core.module.Memory().Read(pointer, operationIDLength)
if !ok {
return 0, fmt.Errorf("read operation ID from guest memory")
}
operation = OperationID(binary.BigEndian.Uint64(wire))
if operation == 0 {
return 0, fmt.Errorf("%s returned zero operation ID", name)
}
return operation, nil
}
func (core *Core) DrainCompletions(
ctx context.Context,
maximum uint32,
) (completions []Completion, err error) {
if maximum == 0 {
return nil, nil
}
if uint64(maximum)*completionLength > math.MaxUint32 {
return nil, fmt.Errorf("completion batch %d exceeds guest address space", maximum)
}
length := maximum * completionLength
pointer, err := core.ensureDataPlaneOutput(ctx, length)
if err != nil {
return nil, err
}
result, err := core.callCached(
ctx,
"easytier_data_plane_completion_drain",
&core.completionFunction,
core.handle,
uint64(pointer),
uint64(maximum),
)
if err != nil {
return nil, err
}
count := int32(result)
if count < 0 {
return nil, core.dataPlaneStatusError(
ctx,
"drain data plane completions",
count,
)
}
if uint32(count) > maximum {
return nil, fmt.Errorf(
"data plane drained %d completions into capacity %d",
count,
maximum,
)
}
wire, ok := core.module.Memory().Read(
pointer,
uint32(count)*completionLength,
)
if !ok {
return nil, fmt.Errorf("read data plane completions from guest memory")
}
return decodeCompletions(wire)
}
func (core *Core) TakeResult(
ctx context.Context,
completion Completion,
) (OperationResult, error) {
result := OperationResult{Kind: completion.Kind}
var err error
switch completion.Kind {
case OperationTCPConnect, OperationTCPAccept:
result.Resource, result.Local, result.Peer, err =
core.takeStreamResult(ctx, completion)
case OperationTCPBind, OperationUDPBind:
result.Resource, result.Local, err =
core.takeBindResult(ctx, completion)
case OperationTCPRead:
result.Data, result.EOF, err = core.takeTCPReadResult(ctx, completion)
case OperationTCPWrite:
result.Length, err = core.takeLengthResult(
ctx,
completion,
"easytier_data_plane_tcp_write_result_take",
)
case OperationUDPReceive:
result.Data, result.Peer, result.Truncated, err =
core.takeUDPReceiveResult(ctx, completion)
case OperationUDPSend:
result.Length, err = core.takeLengthResult(
ctx,
completion,
"easytier_data_plane_udp_send_result_take",
)
default:
return OperationResult{}, fmt.Errorf(
"take unknown data plane operation kind %d",
completion.Kind,
)
}
return result, err
}
func (core *Core) takeStreamResult(
ctx context.Context,
completion Completion,
) (resource ResourceID, local, peer netip.AddrPort, err error) {
name := "easytier_data_plane_tcp_connect_result_take"
if completion.Kind == OperationTCPAccept {
name = "easytier_data_plane_tcp_accept_result_take"
}
wire, err := core.takeFixedResult(ctx, completion, name, streamResultLength)
if err != nil {
return 0, netip.AddrPort{}, netip.AddrPort{}, err
}
return decodeStreamResult(wire)
}
func (core *Core) takeBindResult(
ctx context.Context,
completion Completion,
) (resource ResourceID, local netip.AddrPort, err error) {
name := "easytier_data_plane_tcp_bind_result_take"
if completion.Kind == OperationUDPBind {
name = "easytier_data_plane_udp_bind_result_take"
}
wire, err := core.takeFixedResult(ctx, completion, name, bindResultLength)
if err != nil {
return 0, netip.AddrPort{}, err
}
return decodeBindResult(wire)
}
func (core *Core) takeFixedResult(
ctx context.Context,
completion Completion,
name string,
length uint32,
) (wire []byte, err error) {
pointer, err := core.ensureDataPlaneOutput(ctx, length)
if err != nil {
return nil, err
}
result, err := core.callOne(
ctx,
name,
core.handle,
uint64(completion.Operation),
uint64(pointer),
)
if err != nil {
return nil, err
}
if status := int32(result); status != 0 {
return nil, core.dataPlaneStatusError(ctx, name, status)
}
bytes, ok := core.module.Memory().Read(pointer, length)
if !ok {
return nil, fmt.Errorf("read %s from guest memory", name)
}
return append([]byte(nil), bytes...), nil
}
func (core *Core) takeTCPReadResult(
ctx context.Context,
completion Completion,
) (data []byte, eof bool, err error) {
data, metadata, err := core.takeVariableResult(
ctx,
completion,
"easytier_data_plane_tcp_read_result_take",
tcpReadMetadataLength,
)
if err != nil {
return nil, false, err
}
if metadata[0] > 1 {
return nil, false, fmt.Errorf(
"data plane returned invalid TCP EOF flag %d",
metadata[0],
)
}
return data, metadata[0] == 1, nil
}
func (core *Core) takeUDPReceiveResult(
ctx context.Context,
completion Completion,
) (data []byte, peer netip.AddrPort, truncated bool, err error) {
data, metadata, err := core.takeVariableResult(
ctx,
completion,
"easytier_data_plane_udp_receive_result_take",
udpReceiveMetadataLength,
)
if err != nil {
return nil, netip.AddrPort{}, false, err
}
peer, err = decodeSocketAddress(metadata[:socketAddressLength])
if err != nil {
return nil, netip.AddrPort{}, false, err
}
if metadata[socketAddressLength] > 1 {
return nil, netip.AddrPort{}, false, fmt.Errorf(
"data plane returned invalid UDP truncation flag %d",
metadata[socketAddressLength],
)
}
return data, peer, metadata[socketAddressLength] == 1, nil
}
func (core *Core) takeVariableResult(
ctx context.Context,
completion Completion,
name string,
metadataLength uint32,
) (data, metadata []byte, err error) {
capacity := uint32(maxDataPlaneTransferBytes)
if completion.Kind == OperationUDPReceive {
capacity = math.MaxUint16
}
dataPointer, err := core.ensureDataPlaneInput(ctx, capacity)
if err != nil {
return nil, nil, err
}
metadataPointer, err := core.ensureDataPlaneOutput(ctx, metadataLength)
if err != nil {
return nil, nil, err
}
result, err := core.callOne(
ctx,
name,
core.handle,
uint64(completion.Operation),
uint64(dataPointer),
uint64(capacity),
uint64(metadataPointer),
)
if err != nil {
return nil, nil, err
}
length := int32(result)
if length < 0 {
return nil, nil, core.dataPlaneStatusError(ctx, name, length)
}
if uint32(length) > capacity {
return nil, nil, fmt.Errorf(
"%s returned length %d into capacity %d",
name,
length,
capacity,
)
}
if length != 0 {
bytes, ok := core.module.Memory().Read(dataPointer, uint32(length))
if !ok {
return nil, nil, fmt.Errorf("read %s payload from guest memory", name)
}
data = append([]byte(nil), bytes...)
}
bytes, ok := core.module.Memory().Read(metadataPointer, metadataLength)
if !ok {
return nil, nil, fmt.Errorf("read %s metadata from guest memory", name)
}
return data, append([]byte(nil), bytes...), nil
}
func (core *Core) takeLengthResult(
ctx context.Context,
completion Completion,
name string,
) (int, error) {
result, err := core.callOne(
ctx,
name,
core.handle,
uint64(completion.Operation),
)
if err != nil {
return 0, err
}
length := int32(result)
if length < 0 {
return 0, core.dataPlaneStatusError(ctx, name, length)
}
return int(length), nil
}
func (core *Core) CancelOperation(
ctx context.Context,
operation OperationID,
) error {
return core.dataPlaneCallStatus(
ctx,
"easytier_data_plane_operation_cancel",
uint64(operation),
)
}
func (core *Core) FreeOperation(
ctx context.Context,
operation OperationID,
) error {
return core.dataPlaneCallStatus(
ctx,
"easytier_data_plane_operation_free",
uint64(operation),
)
}
func (core *Core) CloseResource(ctx context.Context, resource ResourceID) error {
return core.dataPlaneCallStatus(
ctx,
"easytier_data_plane_resource_close",
uint64(resource),
)
}
func (core *Core) dataPlaneCallStatus(
ctx context.Context,
name string,
params ...uint64,
) error {
callParams := append([]uint64{core.handle}, params...)
result, err := core.callOne(ctx, name, callParams...)
if err != nil {
return err
}
if status := int32(result); status != 0 {
return core.dataPlaneStatusError(ctx, name, status)
}
return nil
}
func (core *Core) dataPlaneStatusError(
ctx context.Context,
operation string,
status int32,
) error {
if status >= 0 || status < -int32(ErrorBufferTooSmall) {
return core.statusError(ctx, operation, status)
}
message, err := core.errorMessage(ctx, core.handle)
if err != nil {
return errors.Join(
&DataPlaneError{Kind: ErrorKind(-status)},
fmt.Errorf("%s: %w", operation, err),
)
}
return &DataPlaneError{
Kind: ErrorKind(-status),
Message: message,
}
}
func (core *Core) writeInput(
ctx context.Context,
data []byte,
) (uint32, error) {
if len(data) == 0 {
return 0, nil
}
if uint64(len(data)) > math.MaxUint32 {
return 0, fmt.Errorf("guest input length %d exceeds wasm32", len(data))
}
pointer, err := core.allocate(ctx, uint32(len(data)))
if err != nil {
return 0, err
}
if !core.module.Memory().Write(pointer, data) {
_ = core.free(contextutil.WithoutCancel(ctx), pointer)
return 0, fmt.Errorf("write data plane input to guest memory")
}
return pointer, nil
}
@@ -0,0 +1,129 @@
package coreabi
import (
"encoding/binary"
"fmt"
"net/netip"
)
const (
socketAddressLength = 27
operationIDLength = 8
completionLength = 12
streamResultLength = 8 + socketAddressLength*2
bindResultLength = 8 + socketAddressLength
tcpReadMetadataLength = 1
udpReceiveMetadataLength = socketAddressLength + 1
maxDataPlaneTransferBytes = 1024 * 1024
)
func encodeSocketAddress(address netip.AddrPort) ([socketAddressLength]byte, error) {
var wire [socketAddressLength]byte
if !address.IsValid() || !address.Addr().Is4() {
return wire, fmt.Errorf("data plane ABI v2 requires an IPv4 address")
}
wire[0] = 4
ip := address.Addr().As4()
copy(wire[1:5], ip[:])
binary.BigEndian.PutUint16(wire[17:19], address.Port())
return wire, nil
}
func decodeSocketAddress(wire []byte) (netip.AddrPort, error) {
if len(wire) != socketAddressLength {
return netip.AddrPort{}, fmt.Errorf(
"decode socket address with length %d", len(wire),
)
}
if wire[0] != 4 {
return netip.AddrPort{}, fmt.Errorf(
"data plane ABI v2 returned address family %d", wire[0],
)
}
for _, value := range wire[5:17] {
if value != 0 {
return netip.AddrPort{}, fmt.Errorf(
"data plane ABI returned noncanonical IPv4 padding",
)
}
}
for _, value := range wire[19:27] {
if value != 0 {
return netip.AddrPort{}, fmt.Errorf(
"data plane ABI returned IPv4 flow or scope metadata",
)
}
}
address := netip.AddrFrom4([4]byte(wire[1:5]))
return netip.AddrPortFrom(address, binary.BigEndian.Uint16(wire[17:19])), nil
}
func decodeCompletions(wire []byte) ([]Completion, error) {
if len(wire)%completionLength != 0 {
return nil, fmt.Errorf("decode completion bytes with length %d", len(wire))
}
completions := make([]Completion, 0, len(wire)/completionLength)
for len(wire) != 0 {
operation := OperationID(binary.BigEndian.Uint64(wire[:8]))
kind := OperationKind(binary.BigEndian.Uint16(wire[8:10]))
status := ErrorKind(binary.BigEndian.Uint16(wire[10:12]))
if operation == 0 {
return nil, fmt.Errorf("data plane returned zero operation ID")
}
if !kind.valid() {
return nil, fmt.Errorf("data plane returned operation kind %d", kind)
}
if !status.validCompletionStatus() {
return nil, fmt.Errorf("data plane returned completion status %d", status)
}
completions = append(completions, Completion{
Operation: operation,
Kind: kind,
Status: status,
})
wire = wire[completionLength:]
}
return completions, nil
}
func decodeStreamResult(wire []byte) (ResourceID, netip.AddrPort, netip.AddrPort, error) {
if len(wire) != streamResultLength {
return 0, netip.AddrPort{}, netip.AddrPort{}, fmt.Errorf(
"decode stream result with length %d", len(wire),
)
}
resource := ResourceID(binary.BigEndian.Uint64(wire[:8]))
if resource == 0 {
return 0, netip.AddrPort{}, netip.AddrPort{}, fmt.Errorf(
"data plane returned zero stream resource ID",
)
}
local, err := decodeSocketAddress(wire[8 : 8+socketAddressLength])
if err != nil {
return 0, netip.AddrPort{}, netip.AddrPort{}, err
}
peer, err := decodeSocketAddress(wire[8+socketAddressLength:])
if err != nil {
return 0, netip.AddrPort{}, netip.AddrPort{}, err
}
return resource, local, peer, nil
}
func decodeBindResult(wire []byte) (ResourceID, netip.AddrPort, error) {
if len(wire) != bindResultLength {
return 0, netip.AddrPort{}, fmt.Errorf(
"decode bind result with length %d", len(wire),
)
}
resource := ResourceID(binary.BigEndian.Uint64(wire[:8]))
if resource == 0 {
return 0, netip.AddrPort{}, fmt.Errorf(
"data plane returned zero bound resource ID",
)
}
address, err := decodeSocketAddress(wire[8:])
if err != nil {
return 0, netip.AddrPort{}, err
}
return resource, address, nil
}
@@ -0,0 +1,73 @@
package coreabi
import (
"encoding/binary"
"net/netip"
"reflect"
"testing"
)
func TestSocketAddressWireMatchesEasyTierABI(t *testing.T) {
address := netip.MustParseAddrPort("192.0.2.1:11013")
wire, err := encodeSocketAddress(address)
if err != nil {
t.Fatalf("encode address: %v", err)
}
want := [socketAddressLength]byte{
4, 192, 0, 2, 1,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0x2b, 0x05,
0, 0, 0, 0,
0, 0, 0, 0,
}
if wire != want {
t.Fatalf("encoded address = %v, want %v", wire, want)
}
decoded, err := decodeSocketAddress(wire[:])
if err != nil {
t.Fatalf("decode address: %v", err)
}
if decoded != address {
t.Fatalf("decoded address = %v, want %v", decoded, address)
}
}
func TestSocketAddressWireRejectsIPv6AndNoncanonicalIPv4(t *testing.T) {
if _, err := encodeSocketAddress(
netip.MustParseAddrPort("[2001:db8::1]:80"),
); err == nil {
t.Fatal("encode IPv6 address succeeded")
}
wire, err := encodeSocketAddress(netip.MustParseAddrPort("192.0.2.1:80"))
if err != nil {
t.Fatalf("encode IPv4 address: %v", err)
}
wire[5] = 1
if _, err := decodeSocketAddress(wire[:]); err == nil {
t.Fatal("decode noncanonical IPv4 address succeeded")
}
}
func TestCompletionWireMatchesEasyTierABI(t *testing.T) {
wire := make([]byte, completionLength*2)
binary.BigEndian.PutUint64(wire[:8], 0x0102_0304_0506_0708)
binary.BigEndian.PutUint16(wire[8:10], uint16(OperationUDPReceive))
binary.BigEndian.PutUint16(wire[10:12], uint16(ErrorBufferTooSmall))
binary.BigEndian.PutUint64(wire[12:20], 9)
binary.BigEndian.PutUint16(wire[20:22], uint16(OperationTCPWrite))
completions, err := decodeCompletions(wire)
if err != nil {
t.Fatalf("decode completions: %v", err)
}
want := []Completion{
{
Operation: 0x0102_0304_0506_0708,
Kind: OperationUDPReceive,
Status: ErrorBufferTooSmall,
},
{Operation: 9, Kind: OperationTCPWrite, Status: ErrorNone},
}
if !reflect.DeepEqual(completions, want) {
t.Fatalf("completions = %#v, want %#v", completions, want)
}
}
+190
View File
@@ -0,0 +1,190 @@
package coreabi
import (
"context"
"encoding/binary"
"fmt"
)
const (
RPCABIVersion = 2
rpcStatusPending int32 = -6
maxRPCMessageBytes = 16 * 1024 * 1024
rpcOperationIDBytes = 8
)
type RPCOperationID uint64
func (core *Core) validateRPCABI(ctx context.Context) error {
version, err := core.callOne(ctx, "easytier_rpc_abi_version")
if err != nil {
return err
}
if version != RPCABIVersion {
return fmt.Errorf(
"unsupported EasyTier RPC ABI version %d, want %d",
version,
RPCABIVersion,
)
}
return nil
}
func (core *Core) SubmitRPC(
ctx context.Context,
encodedRequest []byte,
) (operation RPCOperationID, err error) {
if len(encodedRequest) == 0 {
return 0, fmt.Errorf("submit empty RPC request")
}
if len(encodedRequest) > maxRPCMessageBytes {
return 0, fmt.Errorf(
"RPC request length %d exceeds limit %d",
len(encodedRequest),
maxRPCMessageBytes,
)
}
requestPointer, err := core.allocate(ctx, uint32(len(encodedRequest)))
if err != nil {
return 0, err
}
defer core.cleanupBuffer(ctx, requestPointer, &err)
if !core.module.Memory().Write(requestPointer, encodedRequest) {
return 0, fmt.Errorf("write RPC request to guest memory")
}
operationPointer, err := core.allocate(ctx, rpcOperationIDBytes)
if err != nil {
return 0, err
}
defer core.cleanupBuffer(ctx, operationPointer, &err)
result, err := core.callOne(
ctx,
"easytier_rpc_request_submit",
core.handle,
uint64(requestPointer),
uint64(len(encodedRequest)),
uint64(operationPointer),
)
if err != nil {
return 0, err
}
if status := int32(result); status != 0 {
return 0, core.statusError(ctx, "submit EasyTier RPC request", status)
}
wire, ok := core.module.Memory().Read(
operationPointer,
rpcOperationIDBytes,
)
if !ok {
return 0, fmt.Errorf("read RPC operation ID from guest memory")
}
operation = RPCOperationID(binary.BigEndian.Uint64(wire))
if operation == 0 {
return 0, fmt.Errorf("EasyTier RPC submit returned zero operation ID")
}
return operation, nil
}
func (core *Core) TakeRPCResponse(
ctx context.Context,
operation RPCOperationID,
) (response []byte, ready bool, err error) {
result, err := core.callOne(
ctx,
"easytier_rpc_response_take",
core.handle,
uint64(operation),
0,
0,
)
if err != nil {
return nil, false, err
}
required := int32(result)
if required == rpcStatusPending {
return nil, false, nil
}
if required < 0 {
return nil, false, core.statusError(
ctx,
"probe EasyTier RPC response",
required,
)
}
if required > maxRPCMessageBytes {
return nil, false, fmt.Errorf(
"RPC response length %d exceeds limit %d",
required,
maxRPCMessageBytes,
)
}
capacity := uint32(required)
if capacity == 0 {
// A non-zero output pointer distinguishes consumption from a size probe.
capacity = 1
}
outputPointer, err := core.allocate(ctx, capacity)
if err != nil {
return nil, false, err
}
defer core.cleanupBuffer(ctx, outputPointer, &err)
result, err = core.callOne(
ctx,
"easytier_rpc_response_take",
core.handle,
uint64(operation),
uint64(outputPointer),
uint64(capacity),
)
if err != nil {
return nil, false, err
}
length := int32(result)
if length == rpcStatusPending {
return nil, false, nil
}
if length < 0 {
return nil, false, core.statusError(
ctx,
"take EasyTier RPC response",
length,
)
}
if uint32(length) > capacity {
return nil, false, fmt.Errorf(
"RPC response grew from %d to %d bytes",
required,
length,
)
}
if length == 0 {
return []byte{}, true, nil
}
wire, ok := core.module.Memory().Read(outputPointer, uint32(length))
if !ok {
return nil, false, fmt.Errorf("read RPC response from guest memory")
}
return append([]byte(nil), wire...), true, nil
}
func (core *Core) FreeRPCOperation(
ctx context.Context,
operation RPCOperationID,
) error {
result, err := core.callOne(
ctx,
"easytier_rpc_operation_free",
core.handle,
uint64(operation),
)
if err != nil {
return err
}
if status := int32(result); status != 0 {
return core.statusError(ctx, "free EasyTier RPC operation", status)
}
return nil
}
+188
View File
@@ -0,0 +1,188 @@
package coreabi
import (
"bytes"
"context"
"encoding/binary"
"testing"
"github.com/metacubex/wazero/api"
"github.com/metacubex/wazero/experimental/wazerotest"
)
func TestValidateRPCABI(t *testing.T) {
for _, test := range []struct {
name string
version uint32
wantErr bool
}{
{name: "supported", version: RPCABIVersion},
{name: "unsupported", version: RPCABIVersion + 1, wantErr: true},
} {
t.Run(test.name, func(t *testing.T) {
version := exportTestFunction(
"easytier_rpc_abi_version",
func(context.Context, api.Module) uint32 {
return test.version
},
)
core := &Core{
module: wazerotest.NewModule(nil, version),
functions: make(map[string]guestFunction),
}
err := core.validateRPCABI(context.Background())
if (err != nil) != test.wantErr {
t.Fatalf(
"validate RPC ABI version %d: err=%v, wantErr=%v",
test.version,
err,
test.wantErr,
)
}
})
}
}
func TestRPCRequestResponseAndFree(t *testing.T) {
memory := wazerotest.NewMemory(64 * 1024)
nextPointer := uint32(1024)
var submitted []byte
var freed RPCOperationID
response := []byte{0x0a, 0x02, 0x08, 0x01}
allocate := exportTestFunction(
"easytier_buffer_alloc",
func(_ context.Context, _ api.Module, length uint32) uint32 {
pointer := nextPointer
nextPointer += length + 8
return pointer
},
)
free := exportTestFunction(
"easytier_buffer_free",
func(context.Context, api.Module, uint32) int32 {
return 0
},
)
submit := exportTestFunction(
"easytier_rpc_request_submit",
func(
_ context.Context,
module api.Module,
_ uint64,
requestPointer uint32,
requestLength uint32,
operationPointer uint32,
) int32 {
wire, _ := module.Memory().Read(requestPointer, requestLength)
submitted = append([]byte(nil), wire...)
var operation [rpcOperationIDBytes]byte
binary.BigEndian.PutUint64(operation[:], 41)
module.Memory().Write(operationPointer, operation[:])
return 0
},
)
take := exportTestFunction(
"easytier_rpc_response_take",
func(
_ context.Context,
module api.Module,
_ uint64,
_ uint64,
output uint32,
capacity uint32,
) int32 {
if output == 0 && capacity == 0 {
return int32(len(response))
}
module.Memory().Write(output, response)
return int32(len(response))
},
)
freeOperation := exportTestFunction(
"easytier_rpc_operation_free",
func(
_ context.Context,
_ api.Module,
_ uint64,
operation uint64,
) int32 {
freed = RPCOperationID(operation)
return 0
},
)
core := &Core{
module: wazerotest.NewModule(
memory,
allocate,
free,
submit,
take,
freeOperation,
),
functions: make(map[string]guestFunction),
handle: 7,
}
request := []byte{0x0a, 0x01, 0x00}
operation, err := core.SubmitRPC(context.Background(), request)
if err != nil {
t.Fatalf("submit RPC: %v", err)
}
if operation != 41 {
t.Fatalf("RPC operation = %d, want 41", operation)
}
if !bytes.Equal(submitted, request) {
t.Fatalf("submitted RPC = %x, want %x", submitted, request)
}
got, ready, err := core.TakeRPCResponse(context.Background(), operation)
if err != nil {
t.Fatalf("take RPC response: %v", err)
}
if !ready {
t.Fatal("RPC response remained pending")
}
if !bytes.Equal(got, response) {
t.Fatalf("RPC response = %x, want %x", got, response)
}
if err := core.FreeRPCOperation(context.Background(), 52); err != nil {
t.Fatalf("free RPC operation: %v", err)
}
if freed != 52 {
t.Fatalf("freed RPC operation = %d, want 52", freed)
}
}
func TestTakeRPCResponseReportsPending(t *testing.T) {
take := exportTestFunction(
"easytier_rpc_response_take",
func(
context.Context,
api.Module,
uint64,
uint64,
uint32,
uint32,
) int32 {
return rpcStatusPending
},
)
core := &Core{
module: wazerotest.NewModule(nil, take),
functions: make(map[string]guestFunction),
handle: 7,
}
response, ready, err := core.TakeRPCResponse(context.Background(), 1)
if err != nil {
t.Fatalf("take pending RPC response: %v", err)
}
if ready || response != nil {
t.Fatalf("pending RPC response = (%x, %v), want (nil, false)", response, ready)
}
}
func exportTestFunction(name string, implementation any) *wazerotest.Function {
function := wazerotest.NewFunction(implementation)
function.ExportNames = []string{name}
return function
}
+146
View File
@@ -0,0 +1,146 @@
package coreabi
import (
"context"
"fmt"
"github.com/metacubex/wazero/api"
)
type WebClient struct {
core *Core
driveFunction guestFunction
deadlineFunction guestFunction
completionFunction guestFunction
created bool
dropped bool
}
func NewWebClient(module api.Module) (*WebClient, error) {
core, err := New(module)
if err != nil {
return nil, err
}
return &WebClient{core: core}, nil
}
func (client *WebClient) Create(ctx context.Context, envelope []byte) (err error) {
if client.created || client.dropped {
return fmt.Errorf("create used EasyTier WebClient")
}
pointer, err := client.core.allocate(ctx, uint32(len(envelope)))
if err != nil {
return err
}
defer client.core.cleanupBuffer(ctx, pointer, &err)
if !client.core.module.Memory().Write(pointer, envelope) {
return fmt.Errorf("write EasyTier WebClient config to guest memory")
}
result, err := client.core.callOne(
ctx,
"easytier_web_client_create",
uint64(pointer),
uint64(len(envelope)),
)
if err != nil {
return err
}
if status := int32(result); status != 0 {
return client.statusError(ctx, "create EasyTier WebClient", status)
}
client.created = true
return nil
}
func (client *WebClient) Drive(ctx context.Context) error {
result, err := client.core.callCached(
ctx,
"easytier_web_client_drive",
&client.driveFunction,
)
if err != nil {
return err
}
if status := int32(result); status != 0 {
return client.statusError(ctx, "drive EasyTier WebClient", status)
}
return nil
}
func (client *WebClient) NotifyCompletions(ctx context.Context) error {
result, err := client.core.callCached(
ctx,
"easytier_web_client_notify_completions",
&client.completionFunction,
)
if err != nil {
return err
}
if status := int32(result); status != 0 {
return client.statusError(ctx, "notify EasyTier WebClient", status)
}
return nil
}
func (client *WebClient) NextDeadline(ctx context.Context) (int64, error) {
result, err := client.core.callCached(
ctx,
"easytier_web_client_next_deadline_millis",
&client.deadlineFunction,
)
if err != nil {
return 0, err
}
deadline := int64(result)
if deadline < 0 {
return 0, client.statusError(
ctx,
"query EasyTier WebClient deadline",
int32(deadline),
)
}
return deadline, nil
}
func (client *WebClient) IsConnected(ctx context.Context) (bool, error) {
result, err := client.core.callOne(ctx, "easytier_web_client_is_connected")
if err != nil {
return false, err
}
status := int32(result)
if status < 0 {
return false, client.statusError(
ctx,
"query EasyTier WebClient connection",
status,
)
}
return status != 0, nil
}
func (client *WebClient) Drop(ctx context.Context) error {
if !client.created || client.dropped {
return nil
}
result, err := client.core.callOne(ctx, "easytier_web_client_drop")
if err != nil {
return err
}
if status := int32(result); status != 0 {
return client.statusError(ctx, "drop EasyTier WebClient", status)
}
client.dropped = true
return nil
}
func (client *WebClient) statusError(
ctx context.Context,
operation string,
status int32,
) error {
message, err := client.core.errorMessage(ctx, 0)
if err != nil {
return fmt.Errorf("%s: status=%d: %w", operation, status, err)
}
return fmt.Errorf("%s: status=%d: %s", operation, status, message)
}
+194
View File
@@ -0,0 +1,194 @@
package engine
import (
"encoding/json"
"fmt"
"net/netip"
"net/url"
"github.com/EasyTier/EasyTier/easytier-go/platform"
)
const createConfigVersion = 14
const webClientConfigVersion = 1
type createEnvelope struct {
Version uint32 `json:"version"`
Config string `json:"config"`
Environment environmentSnapshot `json:"environment"`
}
type webClientEnvelope struct {
Version uint32 `json:"version"`
Endpoint string `json:"endpoint"`
MachineID string `json:"machine_id"`
Hostname string `json:"hostname"`
SecureMode bool `json:"secure_mode"`
OSType string `json:"os_type"`
Environment environmentSnapshot `json:"environment"`
}
type environmentSnapshot struct {
PublicIPv4 *string `json:"public_ipv4"`
InterfaceIPv4s []string `json:"interface_ipv4s"`
PublicIPv6 *string `json:"public_ipv6"`
InterfaceIPv6s []string `json:"interface_ipv6s"`
MappedListeners []string `json:"mapped_listeners"`
LocalIPs []string `json:"local_ips"`
ProtectedTCPPorts []uint16 `json:"protected_tcp_ports"`
PreferredIPv6Sources []preferredIPv6Source `json:"preferred_ipv6_sources"`
}
type preferredIPv6Source struct {
IP string `json:"ip"`
IfIndex uint32 `json:"ifindex"`
}
func encodeCreateEnvelope(
configTOML string,
snapshot platform.EnvironmentSnapshot,
) ([]byte, error) {
if configTOML == "" {
return nil, fmt.Errorf("EasyTier TOML configuration is empty")
}
environment, err := encodeEnvironmentSnapshot(snapshot)
if err != nil {
return nil, err
}
encoded, err := json.Marshal(createEnvelope{
Version: createConfigVersion,
Config: configTOML,
Environment: environment,
})
if err != nil {
return nil, fmt.Errorf("encode EasyTier create envelope: %w", err)
}
return encoded, nil
}
func encodeWebClientEnvelope(
options WebClientOptions,
snapshot platform.EnvironmentSnapshot,
) ([]byte, error) {
if options.Endpoint == "" {
return nil, fmt.Errorf("EasyTier WebClient endpoint is empty")
}
if options.MachineID == "" {
return nil, fmt.Errorf("EasyTier WebClient machine ID is empty")
}
if options.Hostname == "" {
return nil, fmt.Errorf("EasyTier WebClient hostname is empty")
}
environment, err := encodeEnvironmentSnapshot(snapshot)
if err != nil {
return nil, err
}
encoded, err := json.Marshal(webClientEnvelope{
Version: webClientConfigVersion,
Endpoint: options.Endpoint,
MachineID: options.MachineID,
Hostname: options.Hostname,
SecureMode: options.SecureMode,
OSType: options.OSType,
Environment: environment,
})
if err != nil {
return nil, fmt.Errorf("encode EasyTier WebClient envelope: %w", err)
}
return encoded, nil
}
func encodeEnvironmentSnapshot(
snapshot platform.EnvironmentSnapshot,
) (environmentSnapshot, error) {
encoded := environmentSnapshot{
InterfaceIPv4s: make([]string, len(snapshot.InterfaceIPv4s)),
InterfaceIPv6s: make([]string, len(snapshot.InterfaceIPv6s)),
MappedListeners: append([]string{}, snapshot.MappedListeners...),
LocalIPs: make([]string, len(snapshot.LocalIPs)),
ProtectedTCPPorts: append([]uint16{}, snapshot.ProtectedTCPPorts...),
PreferredIPv6Sources: make([]preferredIPv6Source, len(snapshot.PreferredIPv6Sources)),
}
var err error
encoded.PublicIPv4, err = encodeOptionalIP("public IPv4", snapshot.PublicIPv4, true)
if err != nil {
return environmentSnapshot{}, err
}
encoded.PublicIPv6, err = encodeOptionalIP("public IPv6", snapshot.PublicIPv6, false)
if err != nil {
return environmentSnapshot{}, err
}
for index, address := range snapshot.InterfaceIPv4s {
if !address.Is4() {
return environmentSnapshot{}, fmt.Errorf(
"interface IPv4 %d is not IPv4: %s",
index,
address,
)
}
encoded.InterfaceIPv4s[index] = address.String()
}
for index, address := range snapshot.InterfaceIPv6s {
if !isIPv6(address) {
return environmentSnapshot{}, fmt.Errorf(
"interface IPv6 %d is not IPv6: %s",
index,
address,
)
}
encoded.InterfaceIPv6s[index] = address.String()
}
for index, listener := range encoded.MappedListeners {
parsed, parseErr := url.ParseRequestURI(listener)
if parseErr != nil || parsed.Scheme == "" {
return environmentSnapshot{}, fmt.Errorf(
"mapped listener %d is not an absolute URL: %q",
index,
listener,
)
}
}
for index, address := range snapshot.LocalIPs {
if !address.IsValid() {
return environmentSnapshot{}, fmt.Errorf("local IP %d is invalid", index)
}
encoded.LocalIPs[index] = address.String()
}
for index, source := range snapshot.PreferredIPv6Sources {
if !isIPv6(source.IP) {
return environmentSnapshot{}, fmt.Errorf(
"preferred IPv6 source %d is not IPv6: %s",
index,
source.IP,
)
}
encoded.PreferredIPv6Sources[index] = preferredIPv6Source{
IP: source.IP.String(),
IfIndex: source.IfIndex,
}
}
return encoded, nil
}
func encodeOptionalIP(
name string,
address *netip.Addr,
ipv4 bool,
) (*string, error) {
if address == nil {
return nil, nil
}
valid := address.Is4()
if !ipv4 {
valid = isIPv6(*address)
}
if !valid {
return nil, fmt.Errorf("%s has wrong address family: %s", name, address)
}
encoded := address.String()
return &encoded, nil
}
func isIPv6(address netip.Addr) bool {
return address.Is6() && !address.Is4In6()
}
@@ -0,0 +1,56 @@
package engine
import (
"encoding/json"
"net/netip"
"testing"
"github.com/EasyTier/EasyTier/easytier-go/platform"
)
func TestEncodeCreateEnvelopeOwnsVersionAndEnvironmentSchema(t *testing.T) {
ipv4 := netip.MustParseAddr("198.51.100.4")
ipv6 := netip.MustParseAddr("2001:db8::4")
encoded, err := encodeCreateEnvelope("instance_name = \"test\"\n", platform.EnvironmentSnapshot{
PublicIPv4: &ipv4,
InterfaceIPv6s: []netip.Addr{ipv6},
MappedListeners: []string{"tcp://127.0.0.1:11010"},
PreferredIPv6Sources: []platform.PreferredIPv6Source{{
IP: ipv6,
IfIndex: 7,
}},
})
if err != nil {
t.Fatalf("encode create envelope: %v", err)
}
var document map[string]any
if err := json.Unmarshal(encoded, &document); err != nil {
t.Fatalf("decode create envelope: %v", err)
}
if document["version"] != float64(createConfigVersion) {
t.Fatalf("version = %v, want %d", document["version"], createConfigVersion)
}
environment := document["environment"].(map[string]any)
if environment["public_ipv4"] != ipv4.String() {
t.Fatalf("public_ipv4 = %v", environment["public_ipv4"])
}
if environment["public_ipv6"] != nil {
t.Fatalf("public_ipv6 = %v, want nil", environment["public_ipv6"])
}
if got := environment["preferred_ipv6_sources"].([]any)[0].(map[string]any)["ifindex"]; got != float64(7) {
t.Fatalf("preferred IPv6 ifindex = %v", got)
}
if environment["interface_ipv4s"] == nil || environment["local_ips"] == nil {
t.Fatal("empty environment collections encoded as null")
}
}
func TestEncodeCreateEnvelopeRejectsWrongAddressFamily(t *testing.T) {
ipv6 := netip.MustParseAddr("2001:db8::1")
_, err := encodeCreateEnvelope("instance_name = \"test\"\n", platform.EnvironmentSnapshot{
PublicIPv4: &ipv6,
})
if err == nil {
t.Fatal("accepted IPv6 public address as public IPv4")
}
}
+205
View File
@@ -0,0 +1,205 @@
package engine
import (
"context"
"errors"
"io"
"net"
"net/netip"
"os"
"sync"
"sync/atomic"
"time"
"github.com/EasyTier/EasyTier/easytier-go/internal/coreabi"
)
const maxDataPlaneTransfer = 1024 * 1024
type streamConn struct {
instance *Instance
resource coreabi.ResourceID
local netip.AddrPort
peer netip.AddrPort
readMu sync.Mutex
writeMu sync.Mutex
closed atomic.Bool
closeOnce sync.Once
closeDone chan struct{}
closeErr error
}
func newStreamConn(
instance *Instance,
result coreabi.OperationResult,
) *streamConn {
return &streamConn{
instance: instance,
resource: result.Resource,
local: result.Local,
peer: result.Peer,
closeDone: make(chan struct{}),
}
}
func (conn *streamConn) Read(buffer []byte) (int, error) {
if len(buffer) == 0 {
return 0, nil
}
conn.readMu.Lock()
defer conn.readMu.Unlock()
if conn.closed.Load() {
return 0, net.ErrClosed
}
maximum := len(buffer)
if maximum > maxDataPlaneTransfer {
maximum = maxDataPlaneTransfer
}
result, err := conn.instance.performOperation(
context.Background(),
coreabi.OperationTCPRead,
func(
callCtx context.Context,
core dataPlaneCore,
) (coreabi.OperationID, error) {
return core.SubmitTCPRead(
callCtx,
conn.resource,
uint32(maximum),
)
},
)
if err != nil {
return 0, normalizeDeadlineError(err)
}
n := copy(buffer, result.Data)
if n == 0 && result.EOF {
return 0, io.EOF
}
return n, nil
}
func (conn *streamConn) Write(buffer []byte) (int, error) {
if len(buffer) == 0 {
return 0, nil
}
conn.writeMu.Lock()
defer conn.writeMu.Unlock()
if conn.closed.Load() {
return 0, net.ErrClosed
}
written := 0
for written < len(buffer) {
end := written + maxDataPlaneTransfer
if end > len(buffer) {
end = len(buffer)
}
chunk := buffer[written:end]
result, err := conn.instance.performOperation(
context.Background(),
coreabi.OperationTCPWrite,
func(
callCtx context.Context,
core dataPlaneCore,
) (coreabi.OperationID, error) {
return core.SubmitTCPWrite(
callCtx,
conn.resource,
chunk,
)
},
)
if err != nil {
return written, normalizeDeadlineError(err)
}
if result.Length <= 0 || result.Length > len(chunk) {
return written, io.ErrShortWrite
}
written += result.Length
}
return written, nil
}
func (conn *streamConn) Close() error {
conn.closeOnce.Do(func() {
conn.closed.Store(true)
conn.closeErr = conn.instance.closeDataPlaneResource(conn.resource)
close(conn.closeDone)
})
<-conn.closeDone
return conn.closeErr
}
func (conn *streamConn) LocalAddr() net.Addr {
return net.TCPAddrFromAddrPort(conn.local)
}
func (conn *streamConn) RemoteAddr() net.Addr {
return net.TCPAddrFromAddrPort(conn.peer)
}
func (conn *streamConn) SetDeadline(deadline time.Time) error {
if conn.closed.Load() {
return net.ErrClosed
}
return conn.instance.setDataPlaneResourceDeadline(
conn.resource,
coreabi.DeadlineRead|coreabi.DeadlineWrite,
deadline,
)
}
func (conn *streamConn) SetReadDeadline(deadline time.Time) error {
if conn.closed.Load() {
return net.ErrClosed
}
return conn.instance.setDataPlaneResourceDeadline(
conn.resource,
coreabi.DeadlineRead,
deadline,
)
}
func (conn *streamConn) SetWriteDeadline(deadline time.Time) error {
if conn.closed.Load() {
return net.ErrClosed
}
return conn.instance.setDataPlaneResourceDeadline(
conn.resource,
coreabi.DeadlineWrite,
deadline,
)
}
func normalizeDeadlineError(err error) error {
if errors.Is(err, context.DeadlineExceeded) {
return os.ErrDeadlineExceeded
}
return err
}
func (instance *Instance) Dial(
ctx context.Context,
peer netip.AddrPort,
) (net.Conn, error) {
timeout, err := contextTimeoutMillis(ctx)
if err != nil {
return nil, normalizeDeadlineError(err)
}
result, err := instance.performOperation(
ctx,
coreabi.OperationTCPConnect,
func(
callCtx context.Context,
core dataPlaneCore,
) (coreabi.OperationID, error) {
return core.SubmitTCPConnect(callCtx, peer, timeout)
},
)
if err != nil {
return nil, normalizeDeadlineError(err)
}
return newStreamConn(instance, result), nil
}
+510
View File
@@ -0,0 +1,510 @@
package engine
import (
"context"
"errors"
"fmt"
"math"
"net"
"net/netip"
"os"
"syscall"
"time"
"github.com/EasyTier/EasyTier/easytier-go/internal/coreabi"
)
const dataPlaneCompletionBatch = 64
type dataPlaneCore interface {
SubmitTCPConnect(
context.Context,
netip.AddrPort,
uint64,
) (coreabi.OperationID, error)
SubmitTCPBind(
context.Context,
uint16,
uint64,
) (coreabi.OperationID, error)
SubmitTCPAccept(
context.Context,
coreabi.ResourceID,
uint64,
) (coreabi.OperationID, error)
SubmitTCPRead(
context.Context,
coreabi.ResourceID,
uint32,
) (coreabi.OperationID, error)
SubmitTCPWrite(
context.Context,
coreabi.ResourceID,
[]byte,
) (coreabi.OperationID, error)
SubmitUDPBind(
context.Context,
uint16,
uint64,
) (coreabi.OperationID, error)
SubmitUDPReceive(
context.Context,
coreabi.ResourceID,
uint32,
) (coreabi.OperationID, error)
SubmitUDPSend(
context.Context,
coreabi.ResourceID,
netip.AddrPort,
[]byte,
) (coreabi.OperationID, error)
SetResourceDeadline(
context.Context,
coreabi.ResourceID,
coreabi.DeadlineDirection,
uint64,
) error
DrainCompletions(context.Context, uint32) ([]coreabi.Completion, error)
TakeResult(
context.Context,
coreabi.Completion,
) (coreabi.OperationResult, error)
CancelOperation(context.Context, coreabi.OperationID) error
CloseResource(context.Context, coreabi.ResourceID) error
}
type dataPlaneCommandKind uint8
const (
dataPlaneSubmit dataPlaneCommandKind = iota + 1
dataPlaneCancel
dataPlaneCloseResource
dataPlaneSetDeadline
)
type dataPlaneCommand struct {
kind dataPlaneCommandKind
operation coreabi.OperationID
resource coreabi.ResourceID
direction coreabi.DeadlineDirection
deadline time.Time
submit func(context.Context, dataPlaneCore) (coreabi.OperationID, error)
opKind coreabi.OperationKind
response chan dataPlaneCommandResponse
}
type dataPlaneCommandResponse struct {
ticket operationTicket
outcome operationOutcome
completed bool
err error
}
type operationTicket struct {
id coreabi.OperationID
result <-chan operationOutcome
}
type operationOutcome struct {
result coreabi.OperationResult
err error
}
type pendingOperation struct {
kind coreabi.OperationKind
result chan operationOutcome
}
func (instance *Instance) performOperation(
ctx context.Context,
kind coreabi.OperationKind,
submit func(context.Context, dataPlaneCore) (coreabi.OperationID, error),
) (coreabi.OperationResult, error) {
if ctx == nil {
return coreabi.OperationResult{}, fmt.Errorf(
"submit EasyTier data plane operation with nil context",
)
}
response := make(chan dataPlaneCommandResponse, 1)
request := dataPlaneCommand{
kind: dataPlaneSubmit,
submit: submit,
opKind: kind,
response: response,
}
select {
case instance.dataPlaneCommands <- request:
case <-instance.done:
return coreabi.OperationResult{}, net.ErrClosed
case <-instance.closeRequested:
return coreabi.OperationResult{}, net.ErrClosed
case <-ctx.Done():
return coreabi.OperationResult{}, ctx.Err()
}
var ticket operationTicket
select {
case submitted := <-response:
if submitted.err != nil {
return coreabi.OperationResult{}, mapDataPlaneError(submitted.err)
}
if submitted.completed {
return submitted.outcome.result, mapDataPlaneError(submitted.outcome.err)
}
ticket = submitted.ticket
case <-instance.done:
return coreabi.OperationResult{}, net.ErrClosed
case <-instance.closeRequested:
return coreabi.OperationResult{}, net.ErrClosed
case <-ctx.Done():
// The driver may already have submitted the operation. Wait for its
// response so that cancellation always targets the actual operation.
select {
case submitted := <-response:
if submitted.err != nil {
return coreabi.OperationResult{}, mapDataPlaneError(submitted.err)
}
if submitted.completed {
return submitted.outcome.result, mapDataPlaneError(
submitted.outcome.err,
)
}
ticket = submitted.ticket
case <-instance.done:
return coreabi.OperationResult{}, net.ErrClosed
case <-instance.closeRequested:
return coreabi.OperationResult{}, net.ErrClosed
}
return instance.cancelAndWait(ctx, ticket)
}
select {
case outcome := <-ticket.result:
return outcome.result, mapDataPlaneError(outcome.err)
case <-ctx.Done():
return instance.cancelAndWait(ctx, ticket)
case <-instance.done:
return coreabi.OperationResult{}, net.ErrClosed
case <-instance.closeRequested:
return coreabi.OperationResult{}, net.ErrClosed
}
}
func (instance *Instance) cancelAndWait(
cancelled context.Context,
ticket operationTicket,
) (coreabi.OperationResult, error) {
response := make(chan dataPlaneCommandResponse, 1)
request := dataPlaneCommand{
kind: dataPlaneCancel,
operation: ticket.id,
response: response,
}
select {
case instance.dataPlaneCommands <- request:
case outcome := <-ticket.result:
return outcome.result, mapDataPlaneError(outcome.err)
case <-instance.done:
return coreabi.OperationResult{}, net.ErrClosed
case <-instance.closeRequested:
return coreabi.OperationResult{}, net.ErrClosed
}
select {
case result := <-response:
if result.err != nil {
return coreabi.OperationResult{}, mapDataPlaneError(result.err)
}
case outcome := <-ticket.result:
return cancelledOutcome(cancelled, outcome)
case <-instance.done:
return coreabi.OperationResult{}, net.ErrClosed
case <-instance.closeRequested:
return coreabi.OperationResult{}, net.ErrClosed
}
select {
case outcome := <-ticket.result:
return cancelledOutcome(cancelled, outcome)
case <-instance.done:
return coreabi.OperationResult{}, net.ErrClosed
case <-instance.closeRequested:
return coreabi.OperationResult{}, net.ErrClosed
}
}
func cancelledOutcome(
cancelled context.Context,
outcome operationOutcome,
) (coreabi.OperationResult, error) {
var dataPlaneErr *coreabi.DataPlaneError
if errors.As(outcome.err, &dataPlaneErr) &&
dataPlaneErr.Kind == coreabi.ErrorCancelled {
return coreabi.OperationResult{}, cancelled.Err()
}
return outcome.result, mapDataPlaneError(outcome.err)
}
func (instance *Instance) closeDataPlaneResource(
resource coreabi.ResourceID,
) error {
response := make(chan dataPlaneCommandResponse, 1)
request := dataPlaneCommand{
kind: dataPlaneCloseResource,
resource: resource,
response: response,
}
select {
case instance.dataPlaneCommands <- request:
case <-instance.done:
return net.ErrClosed
case <-instance.closeRequested:
return net.ErrClosed
}
select {
case result := <-response:
return mapDataPlaneError(result.err)
case <-instance.done:
return net.ErrClosed
case <-instance.closeRequested:
return net.ErrClosed
}
}
func (instance *Instance) setDataPlaneResourceDeadline(
resource coreabi.ResourceID,
direction coreabi.DeadlineDirection,
deadline time.Time,
) error {
response := make(chan dataPlaneCommandResponse, 1)
request := dataPlaneCommand{
kind: dataPlaneSetDeadline,
resource: resource,
direction: direction,
deadline: deadline,
response: response,
}
select {
case instance.dataPlaneCommands <- request:
case <-instance.done:
return net.ErrClosed
case <-instance.closeRequested:
return net.ErrClosed
}
select {
case result := <-response:
return mapDataPlaneError(result.err)
case <-instance.done:
return net.ErrClosed
case <-instance.closeRequested:
return net.ErrClosed
}
}
func (instance *Instance) handleDataPlaneCommand(
request dataPlaneCommand,
) dataPlaneCommandResponse {
instance.host.guestMu.Lock()
defer instance.host.guestMu.Unlock()
switch request.kind {
case dataPlaneSubmit:
operation, err := request.submit(instance.ctx, instance.dataPlane)
if err != nil {
return dataPlaneCommandResponse{err: err}
}
result := make(chan operationOutcome, 1)
instance.pendingOperations[operation] = &pendingOperation{
kind: request.opKind,
result: result,
}
return dataPlaneCommandResponse{ticket: operationTicket{
id: operation,
result: result,
}}
case dataPlaneCancel:
if _, exists := instance.pendingOperations[request.operation]; !exists {
return dataPlaneCommandResponse{}
}
return dataPlaneCommandResponse{
err: instance.dataPlane.CancelOperation(
instance.ctx,
request.operation,
),
}
case dataPlaneCloseResource:
return dataPlaneCommandResponse{
err: instance.dataPlane.CloseResource(
instance.ctx,
request.resource,
),
}
case dataPlaneSetDeadline:
return dataPlaneCommandResponse{
err: instance.dataPlane.SetResourceDeadline(
instance.ctx,
request.resource,
request.direction,
resourceDeadlineTimeoutMillis(request.deadline),
),
}
default:
return dataPlaneCommandResponse{
err: fmt.Errorf("unknown data plane command %d", request.kind),
}
}
}
func (instance *Instance) drainDataPlaneCompletions() (bool, error) {
if instance.dataPlane == nil || len(instance.pendingOperations) == 0 {
return false, nil
}
completions, err := instance.dataPlane.DrainCompletions(
instance.ctx,
dataPlaneCompletionBatch,
)
if err != nil {
return false, err
}
for _, completion := range completions {
pending := instance.pendingOperations[completion.Operation]
if pending == nil {
return false, fmt.Errorf(
"EasyTier completed unknown data plane operation %d",
completion.Operation,
)
}
if pending.kind != completion.Kind {
return false, fmt.Errorf(
"EasyTier completed operation %d as kind %d, want %d",
completion.Operation,
completion.Kind,
pending.kind,
)
}
result, resultErr := instance.dataPlane.TakeResult(
instance.ctx,
completion,
)
if err := validateCompletionResult(completion, resultErr); err != nil {
return false, err
}
delete(instance.pendingOperations, completion.Operation)
pending.result <- operationOutcome{result: result, err: resultErr}
}
return len(completions) == dataPlaneCompletionBatch, nil
}
func validateCompletionResult(
completion coreabi.Completion,
resultErr error,
) error {
var dataPlaneErr *coreabi.DataPlaneError
if completion.Status == coreabi.ErrorNone {
if resultErr != nil {
return fmt.Errorf(
"take successful data plane operation %d: %w",
completion.Operation,
resultErr,
)
}
return nil
}
if !errors.As(resultErr, &dataPlaneErr) {
return fmt.Errorf(
"take failed data plane operation %d with status %d: %w",
completion.Operation,
completion.Status,
resultErr,
)
}
if dataPlaneErr.Kind != completion.Status {
return fmt.Errorf(
"data plane operation %d status %d disagrees with result %d",
completion.Operation,
completion.Status,
dataPlaneErr.Kind,
)
}
return nil
}
func (instance *Instance) failPendingOperations(err error) {
for operation, pending := range instance.pendingOperations {
pending.result <- operationOutcome{err: err}
delete(instance.pendingOperations, operation)
}
}
func timeoutFromDeadline(deadline time.Time) (uint64, error) {
if deadline.IsZero() {
return math.MaxUint64, nil
}
remaining := time.Until(deadline)
if remaining <= 0 {
return 0, os.ErrDeadlineExceeded
}
millis := remaining / time.Millisecond
if remaining%time.Millisecond != 0 {
millis++
}
return uint64(millis), nil
}
func resourceDeadlineTimeoutMillis(deadline time.Time) uint64 {
if deadline.IsZero() {
return math.MaxUint64
}
remaining := time.Until(deadline)
if remaining <= 0 {
return 0
}
millis := remaining / time.Millisecond
if remaining%time.Millisecond != 0 {
millis++
}
return uint64(millis)
}
func contextTimeoutMillis(ctx context.Context) (uint64, error) {
if err := ctx.Err(); err != nil {
return 0, err
}
deadline, exists := ctx.Deadline()
if !exists {
return math.MaxUint64, nil
}
return timeoutFromDeadline(deadline)
}
func mapDataPlaneError(err error) error {
if err == nil {
return nil
}
var dataPlaneErr *coreabi.DataPlaneError
if !errors.As(err, &dataPlaneErr) {
return err
}
switch dataPlaneErr.Kind {
case coreabi.ErrorCancelled:
return context.Canceled
case coreabi.ErrorDeadlineExceeded:
return os.ErrDeadlineExceeded
case coreabi.ErrorInstanceStopped, coreabi.ErrorHandleClosed:
return net.ErrClosed
case coreabi.ErrorNoOverlayRoute, coreabi.ErrorPathNotReady:
return syscall.ENETUNREACH
case coreabi.ErrorAddressFamilyUnsupported:
return syscall.EAFNOSUPPORT
case coreabi.ErrorAddressInUse:
return syscall.EADDRINUSE
case coreabi.ErrorConnectionRefused:
return syscall.ECONNREFUSED
case coreabi.ErrorNetworkChanged:
return syscall.ENETRESET
case coreabi.ErrorResourceLimit:
return syscall.ENOBUFS
case coreabi.ErrorBufferTooSmall:
return syscall.EMSGSIZE
default:
return err
}
}
+164
View File
@@ -0,0 +1,164 @@
package engine
import (
"context"
"errors"
"math"
"reflect"
"testing"
"time"
"github.com/EasyTier/EasyTier/easytier-go/internal/coreabi"
)
type recordingCore struct {
calls []string
}
func (*recordingCore) Start(context.Context) error { return nil }
func (*recordingCore) Stop(context.Context) error { return nil }
func (core *recordingCore) Drive(context.Context) (coreabi.State, error) {
core.calls = append(core.calls, "drive")
return coreabi.StateRunning, nil
}
func (core *recordingCore) NotifyCompletions(context.Context) error {
core.calls = append(core.calls, "notify")
return nil
}
func (core *recordingCore) NextDeadline(context.Context) (int64, error) {
core.calls = append(core.calls, "deadline")
return math.MaxInt64, nil
}
func (core *recordingCore) SendPacket(context.Context, []byte) error {
core.calls = append(core.calls, "send")
return nil
}
func (*recordingCore) Drop(context.Context) error { return nil }
func TestCompletionNotifiesGuestBeforeDriving(t *testing.T) {
core := &recordingCore{}
instance := &Instance{
host: &Host{},
ctx: context.Background(),
core: core,
running: make(chan struct{}),
stopped: make(chan struct{}),
}
if _, err := instance.drive(true); err != nil {
t.Fatalf("drive completion: %v", err)
}
want := []string{"notify", "drive", "deadline"}
if !reflect.DeepEqual(core.calls, want) {
t.Fatalf("completion call order = %v, want %v", core.calls, want)
}
}
func TestPacketIngressBatchDrivesOnce(t *testing.T) {
core := &recordingCore{}
instance := &Instance{
host: &Host{},
ctx: context.Background(),
core: core,
commands: make(chan command, maximumPacketIngressBatch),
running: make(chan struct{}),
stopped: make(chan struct{}),
}
requests := make([]command, 3)
for index := range requests {
requests[index] = command{
kind: commandSendPacket,
packet: []byte{byte(index)},
response: make(chan error, 1),
}
if index != 0 {
instance.commands <- requests[index]
}
}
if _, err := instance.handlePacketBatch(requests[0], math.MaxInt64); err != nil {
t.Fatalf("handle packet batch: %v", err)
}
for index := range requests {
if err := <-requests[index].response; err != nil {
t.Fatalf("packet %d response: %v", index, err)
}
}
want := []string{"send", "send", "send", "drive", "deadline"}
if !reflect.DeepEqual(core.calls, want) {
t.Fatalf("packet batch call order = %v, want %v", core.calls, want)
}
}
func TestSendPacketBorrowsBufferUntilGuestConsumesIt(t *testing.T) {
instance := &Instance{
commands: make(chan command, 1),
closeRequested: make(chan struct{}),
done: make(chan struct{}),
}
ctx, cancel := context.WithCancel(context.Background())
packet := []byte{1, 2, 3}
result := make(chan error, 1)
go func() {
result <- instance.SendPacket(ctx, packet)
}()
var request command
select {
case request = <-instance.commands:
case <-time.After(time.Second):
t.Fatal("SendPacket did not enqueue")
}
if &request.packet[0] != &packet[0] {
t.Fatal("SendPacket copied the packet before enqueue")
}
cancel()
select {
case err := <-result:
t.Fatalf("SendPacket returned before guest consumption: %v", err)
case <-time.After(20 * time.Millisecond):
}
request.response <- nil
select {
case err := <-result:
if err != nil {
t.Fatalf("SendPacket after guest consumption: %v", err)
}
case <-time.After(time.Second):
t.Fatal("SendPacket did not return after guest consumption")
}
}
func TestSendPacketBackpressureRemainsCancelableBeforeEnqueue(t *testing.T) {
instance := &Instance{
commands: make(chan command, 1),
closeRequested: make(chan struct{}),
done: make(chan struct{}),
}
instance.commands <- command{kind: commandStart}
ctx, cancel := context.WithCancel(context.Background())
result := make(chan error, 1)
go func() {
result <- instance.SendPacket(ctx, []byte{1})
}()
select {
case err := <-result:
t.Fatalf("SendPacket bypassed full command queue: %v", err)
case <-time.After(20 * time.Millisecond):
}
cancel()
select {
case err := <-result:
if !errors.Is(err, context.Canceled) {
t.Fatalf("SendPacket on full queue returned %v, want context cancellation", err)
}
case <-time.After(time.Second):
t.Fatal("SendPacket on full queue ignored context cancellation")
}
if got := len(instance.commands); got != 1 {
t.Fatalf("queued commands = %d, want the original command only", got)
}
}
+311
View File
@@ -0,0 +1,311 @@
package engine
import (
"context"
"crypto/rand"
"errors"
"fmt"
"sync"
"time"
"github.com/EasyTier/EasyTier/easytier-go/internal/artifact"
"github.com/EasyTier/EasyTier/easytier-go/internal/contextutil"
"github.com/EasyTier/EasyTier/easytier-go/internal/coreabi"
"github.com/EasyTier/EasyTier/easytier-go/internal/hostabi"
"github.com/EasyTier/EasyTier/easytier-go/internal/reactor"
"github.com/EasyTier/EasyTier/easytier-go/platform"
"github.com/metacubex/wazero"
"github.com/metacubex/wazero/api"
"github.com/metacubex/wazero/imports/wasi_snapshot_preview1"
)
const defaultPacketQueueCapacity = 64
const maximumPacketIngressBatch = 32
type Options struct {
Services platform.Services
PacketQueueCapacity int
Management reactor.ManagementHandler
}
type Host struct {
ctx context.Context
cancel context.CancelFunc
runtime wazero.Runtime
module api.Module
reactor *reactor.Reactor
options Options
guestMu sync.Mutex
mu sync.Mutex
closed bool
instances map[*Instance]struct{}
webClient *WebClient
closeOnce sync.Once
closeDone chan struct{}
closeErr error
}
func NewHost(ctx context.Context, options Options) (_ *Host, err error) {
if ctx == nil {
return nil, fmt.Errorf("create EasyTier engine host with nil context")
}
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
if options.PacketQueueCapacity == 0 {
options.PacketQueueCapacity = defaultPacketQueueCapacity
}
if options.PacketQueueCapacity < 0 {
return nil, fmt.Errorf("packet queue capacity must be positive")
}
lifetime, cancel := context.WithCancel(contextutil.WithoutCancel(ctx))
runtime := wazero.NewRuntime(lifetime)
var module api.Module
var hostReactor *reactor.Reactor
defer func() {
if err == nil {
return
}
cancel()
if module != nil {
_ = module.Close(contextutil.WithoutCancel(ctx))
}
if hostReactor != nil {
hostReactor.Close()
}
_ = runtime.Close(contextutil.WithoutCancel(ctx))
}()
if _, err = wasi_snapshot_preview1.Instantiate(ctx, runtime); err != nil {
return nil, fmt.Errorf("instantiate WASI preview1: %w", err)
}
hostReactor = reactor.New(lifetime, reactor.Options{
Services: options.Services,
Management: options.Management,
})
adapter, err := hostabi.New(hostReactor)
if err != nil {
return nil, err
}
if err = adapter.Instantiate(ctx, runtime); err != nil {
return nil, fmt.Errorf("instantiate EasyTier host ABI: %w", err)
}
compiled, err := runtime.CompileModule(ctx, artifact.Core())
if err != nil {
return nil, fmt.Errorf("compile embedded EasyTier core: %w", err)
}
defer compiled.Close(contextutil.WithoutCancel(ctx))
module, err = runtime.InstantiateModule(ctx, compiled, newModuleConfig())
if err != nil {
return nil, fmt.Errorf("instantiate embedded EasyTier core: %w", err)
}
host := &Host{
ctx: lifetime,
cancel: cancel,
runtime: runtime,
module: module,
reactor: hostReactor,
options: options,
instances: make(map[*Instance]struct{}),
closeDone: make(chan struct{}),
}
go host.broadcastCompletions()
return host, nil
}
func newModuleConfig() wazero.ModuleConfig {
return wazero.NewModuleConfig().
WithRandSource(rand.Reader).
WithSysWalltime().
WithSysNanotime().
WithSysNanosleep()
}
func (host *Host) CreateInstance(
ctx context.Context,
configTOML string,
) (*Instance, error) {
if host == nil {
return nil, fmt.Errorf("create instance with nil EasyTier engine host")
}
if ctx == nil {
return nil, fmt.Errorf("create EasyTier instance with nil context")
}
envelope, err := encodeCreateEnvelope(configTOML, host.options.Services.Snapshot)
if err != nil {
return nil, err
}
host.mu.Lock()
defer host.mu.Unlock()
if host.closed {
return nil, fmt.Errorf("create instance with closed EasyTier engine host")
}
packetSink, err := host.reactor.RegisterPacketSink(host.options.PacketQueueCapacity)
if err != nil {
return nil, fmt.Errorf("register EasyTier packet sink: %w", err)
}
events := make(chan Event, instanceEventQueueCapacity)
journal := newEventJournal()
eventSink, err := host.reactor.RegisterEventSink(func(kind, message string) bool {
journal.add(kind, message)
select {
case events <- Event{Kind: kind, Message: message}:
return true
default:
return false
}
})
if err != nil {
host.reactor.UnregisterPacketSink(packetSink)
close(events)
return nil, fmt.Errorf("register EasyTier event sink: %w", err)
}
host.guestMu.Lock()
core, err := coreabi.New(host.module)
if err == nil {
err = core.Create(ctx, envelope, packetSink, eventSink)
}
host.guestMu.Unlock()
if err != nil {
host.reactor.UnregisterPacketSink(packetSink)
host.reactor.UnregisterEventSink(eventSink)
close(events)
return nil, err
}
lifetime, cancel := context.WithCancel(host.ctx)
instance := &Instance{
host: host,
ctx: lifetime,
cancel: cancel,
core: core,
dataPlane: core,
rpc: core,
reactor: host.reactor,
packetSink: packetSink,
eventSink: eventSink,
events: events,
journal: journal,
commands: make(chan command, maximumPacketIngressBatch),
dataPlaneCommands: make(chan dataPlaneCommand),
rpcCommands: make(chan rpcCommand),
pendingOperations: make(
map[coreabi.OperationID]*pendingOperation,
),
pendingRPCs: make(
map[coreabi.RPCOperationID]*pendingRPC,
),
completions: make(chan struct{}, 1),
closeRequested: make(chan struct{}),
done: make(chan struct{}),
running: make(chan struct{}),
stopped: make(chan struct{}),
}
instance.state.Store(int32(coreabi.StateCreated))
host.instances[instance] = struct{}{}
go instance.run()
return instance, nil
}
func (host *Host) Close(ctx context.Context) error {
if host == nil {
return nil
}
if ctx == nil {
return fmt.Errorf("close EasyTier engine host with nil context")
}
host.closeOnce.Do(func() {
host.mu.Lock()
host.closed = true
webClient := host.webClient
instances := make([]*Instance, 0, len(host.instances))
for instance := range host.instances {
instances = append(instances, instance)
}
host.mu.Unlock()
go host.shutdown(webClient, instances)
})
select {
case <-host.closeDone:
host.mu.Lock()
err := host.closeErr
host.mu.Unlock()
return err
case <-ctx.Done():
return ctx.Err()
}
}
func (host *Host) shutdown(webClient *WebClient, instances []*Instance) {
var closeErrors []error
if webClient != nil {
if err := webClient.Close(host.ctx); err != nil {
closeErrors = append(closeErrors, err)
}
}
for _, instance := range instances {
if err := instance.Close(host.ctx); err != nil {
closeErrors = append(closeErrors, err)
}
}
host.cancel()
host.reactor.Close()
cleanupContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
moduleErr := host.module.Close(cleanupContext)
runtimeErr := host.runtime.Close(cleanupContext)
host.mu.Lock()
host.closeErr = errors.Join(
append(closeErrors, moduleErr, runtimeErr)...,
)
host.mu.Unlock()
close(host.closeDone)
}
func (host *Host) broadcastCompletions() {
for {
select {
case <-host.reactor.Completions():
host.mu.Lock()
if host.webClient != nil {
select {
case host.webClient.completions <- struct{}{}:
default:
}
}
for instance := range host.instances {
select {
case instance.completions <- struct{}{}:
default:
}
}
host.mu.Unlock()
case <-host.ctx.Done():
return
}
}
}
func (host *Host) removeWebClient(client *WebClient) {
host.mu.Lock()
if host.webClient == client {
host.webClient = nil
}
host.mu.Unlock()
}
func (host *Host) removeInstance(instance *Instance) {
host.mu.Lock()
delete(host.instances, instance)
host.mu.Unlock()
}
+526
View File
@@ -0,0 +1,526 @@
package engine
import (
"context"
"errors"
"fmt"
"math"
"net"
"sync"
"sync/atomic"
"time"
"github.com/EasyTier/EasyTier/easytier-go/internal/contextutil"
"github.com/EasyTier/EasyTier/easytier-go/internal/coreabi"
"github.com/EasyTier/EasyTier/easytier-go/internal/reactor"
)
type commandKind uint8
const (
commandStart commandKind = iota + 1
commandStop
commandSendPacket
)
type command struct {
kind commandKind
packet []byte
response chan error
}
type guestCore interface {
Start(context.Context) error
Stop(context.Context) error
Drive(context.Context) (coreabi.State, error)
NotifyCompletions(context.Context) error
NextDeadline(context.Context) (int64, error)
SendPacket(context.Context, []byte) error
Drop(context.Context) error
}
type Event struct {
// Kind is a stable snake-case CoreEvent variant name.
Kind string
// Message is a human-readable description intended for logging.
Message string
}
const instanceEventQueueCapacity = 256
type eventJournal struct {
mu sync.Mutex
events []string
}
func newEventJournal() *eventJournal {
return &eventJournal{events: make([]string, 0, instanceEventQueueCapacity)}
}
func (journal *eventJournal) add(kind, message string) {
if message == "" {
message = kind
}
journal.mu.Lock()
if len(journal.events) == cap(journal.events) {
copy(journal.events, journal.events[1:])
journal.events = journal.events[:len(journal.events)-1]
}
journal.events = append(journal.events, message)
journal.mu.Unlock()
}
func (journal *eventJournal) snapshot() []string {
journal.mu.Lock()
defer journal.mu.Unlock()
return append([]string(nil), journal.events...)
}
type Instance struct {
host *Host
ctx context.Context
cancel context.CancelFunc
core guestCore
dataPlane dataPlaneCore
rpc rpcCore
reactor *reactor.Reactor
packetSink uint64
eventSink uint64
events chan Event
journal *eventJournal
commands chan command
dataPlaneCommands chan dataPlaneCommand
rpcCommands chan rpcCommand
pendingOperations map[coreabi.OperationID]*pendingOperation
pendingRPCs map[coreabi.RPCOperationID]*pendingRPC
completions chan struct{}
closeRequested chan struct{}
closeOnce sync.Once
closing atomic.Bool
done chan struct{}
running chan struct{}
runningOnce sync.Once
stopped chan struct{}
stoppedOnce sync.Once
state atomic.Int32
errMu sync.Mutex
terminalErr error
}
func (instance *Instance) Start(ctx context.Context) error {
if ctx == nil {
return fmt.Errorf("start EasyTier instance with nil context")
}
if err := instance.execute(ctx, command{kind: commandStart}); err != nil {
return err
}
select {
case <-instance.running:
return nil
case <-instance.done:
return instance.finishedError("start EasyTier instance")
case <-ctx.Done():
return ctx.Err()
}
}
func (instance *Instance) Stop(ctx context.Context) error {
if ctx == nil {
return fmt.Errorf("stop EasyTier instance with nil context")
}
if err := instance.execute(ctx, command{kind: commandStop}); err != nil {
return err
}
select {
case <-instance.stopped:
return nil
case <-instance.done:
return instance.finishedError("stop EasyTier instance")
case <-ctx.Done():
return ctx.Err()
}
}
func (instance *Instance) SendPacket(ctx context.Context, packet []byte) error {
if ctx == nil {
return fmt.Errorf("send EasyTier packet with nil context")
}
if len(packet) == 0 {
return fmt.Errorf("send empty packet")
}
return instance.execute(ctx, command{
kind: commandSendPacket,
packet: packet,
})
}
func (instance *Instance) ReceivePacket(ctx context.Context) ([]byte, error) {
if ctx == nil {
return nil, fmt.Errorf("receive EasyTier packet with nil context")
}
select {
case <-instance.done:
return nil, instance.finishedError("receive EasyTier packet")
default:
}
return instance.reactor.ReceivePacket(ctx, instance.packetSink)
}
func (instance *Instance) Events() <-chan Event {
return instance.events
}
func (instance *Instance) Wait(ctx context.Context) error {
if ctx == nil {
return fmt.Errorf("wait for EasyTier instance with nil context")
}
select {
case <-instance.stopped:
return nil
case <-instance.done:
return instance.finishedError("wait for EasyTier instance")
case <-ctx.Done():
return ctx.Err()
}
}
func (instance *Instance) State() coreabi.State {
return coreabi.State(instance.state.Load())
}
func (instance *Instance) ManagementEvents() []string {
return instance.journal.snapshot()
}
func (instance *Instance) TerminalError() error {
return instance.terminalError()
}
func (instance *Instance) Close(ctx context.Context) error {
if ctx == nil {
return fmt.Errorf("close EasyTier instance with nil context")
}
instance.closeOnce.Do(func() {
instance.closing.Store(true)
close(instance.closeRequested)
})
select {
case <-instance.done:
return instance.terminalError()
case <-ctx.Done():
return ctx.Err()
}
}
func (instance *Instance) execute(ctx context.Context, request command) error {
if ctx == nil {
return fmt.Errorf("execute EasyTier command with nil context")
}
request.response = make(chan error, 1)
if instance.closing.Load() {
return fmt.Errorf("execute EasyTier command on closing instance")
}
select {
case instance.commands <- request:
case <-instance.done:
return instance.finishedError("execute EasyTier command")
case <-instance.closeRequested:
return fmt.Errorf("execute EasyTier command on closing instance")
case <-ctx.Done():
return ctx.Err()
}
if request.kind == commandSendPacket {
// The guest may retain packet until it responds. Do not let caller
// cancellation end the borrow after the request enters the queue.
select {
case err := <-request.response:
return err
case <-instance.done:
return instance.finishedError("execute EasyTier command")
}
}
select {
case err := <-request.response:
return err
case <-instance.done:
return instance.finishedError("execute EasyTier command")
case <-instance.closeRequested:
return fmt.Errorf("execute EasyTier command on closing instance")
case <-ctx.Done():
return ctx.Err()
}
}
func (instance *Instance) run() {
runErr := instance.driveLoop()
instance.state.Store(int32(coreabi.StateStopped))
instance.failPendingOperations(net.ErrClosed)
instance.failPendingRPCs(net.ErrClosed)
cleanupErr := instance.shutdown()
instance.errMu.Lock()
instance.terminalErr = errors.Join(runErr, cleanupErr)
instance.errMu.Unlock()
close(instance.done)
}
func (instance *Instance) driveLoop() error {
deadline := int64(math.MaxInt64)
timer := time.NewTimer(time.Hour)
stopTimer(timer)
defer stopTimer(timer)
for {
timerChannel := deadlineTimer(timer, deadline)
select {
case request := <-instance.commands:
stopTimer(timer)
if request.kind == commandSendPacket {
next, err := instance.handlePacketBatch(request, deadline)
if err != nil {
return err
}
deadline = next
continue
}
commandErr := instance.handleCommand(request)
if commandErr != nil {
request.response <- commandErr
continue
}
next, driveErr := instance.drive(false)
request.response <- driveErr
if driveErr != nil {
return driveErr
}
deadline = next
case request := <-instance.dataPlaneCommands:
stopTimer(timer)
response := instance.handleDataPlaneCommand(request)
if response.err != nil {
request.response <- response
continue
}
next, driveErr := instance.drive(false)
if driveErr != nil {
response.err = driveErr
} else {
select {
case response.outcome = <-response.ticket.result:
response.completed = true
default:
}
}
request.response <- response
if driveErr != nil {
return driveErr
}
deadline = next
case request := <-instance.rpcCommands:
stopTimer(timer)
response := instance.handleRPCCommand(request)
if response.err != nil {
request.response <- response
continue
}
next, driveErr := instance.drive(false)
if driveErr != nil {
response.err = driveErr
} else if response.ticket.result != nil {
select {
case response.outcome = <-response.ticket.result:
response.completed = true
default:
}
}
request.response <- response
if driveErr != nil {
return driveErr
}
deadline = next
case <-instance.completions:
stopTimer(timer)
next, err := instance.drive(true)
if err != nil {
return err
}
deadline = next
case <-timerChannel:
next, err := instance.drive(false)
if err != nil {
return err
}
deadline = next
case <-instance.closeRequested:
stopTimer(timer)
return nil
case <-instance.ctx.Done():
stopTimer(timer)
return instance.ctx.Err()
}
}
}
func (instance *Instance) handlePacketBatch(
first command,
deadline int64,
) (int64, error) {
var batch [maximumPacketIngressBatch]command
batch[0] = first
count := 1
var following *command
drain:
for count < len(batch) {
select {
case request := <-instance.commands:
if request.kind != commandSendPacket {
following = &request
break drain
}
batch[count] = request
count++
default:
break drain
}
}
successful := false
var sendErrors [maximumPacketIngressBatch]error
for index := 0; index < count; index++ {
err := instance.handleCommand(batch[index])
sendErrors[index] = err
successful = successful || err == nil
}
var driveErr error
if successful {
deadline, driveErr = instance.drive(false)
}
for index := 0; index < count; index++ {
if sendErrors[index] != nil {
batch[index].response <- sendErrors[index]
} else {
batch[index].response <- driveErr
}
}
if driveErr != nil {
return deadline, driveErr
}
if following == nil {
return deadline, nil
}
commandErr := instance.handleCommand(*following)
if commandErr != nil {
following.response <- commandErr
return deadline, nil
}
deadline, driveErr = instance.drive(false)
following.response <- driveErr
return deadline, driveErr
}
func (instance *Instance) handleCommand(request command) error {
instance.host.guestMu.Lock()
defer instance.host.guestMu.Unlock()
switch request.kind {
case commandStart:
return instance.core.Start(instance.ctx)
case commandStop:
return instance.core.Stop(instance.ctx)
case commandSendPacket:
return instance.core.SendPacket(instance.ctx, request.packet)
default:
return fmt.Errorf("unknown EasyTier command %d", request.kind)
}
}
func (instance *Instance) drive(notify bool) (int64, error) {
instance.host.guestMu.Lock()
defer instance.host.guestMu.Unlock()
if notify {
if err := instance.core.NotifyCompletions(instance.ctx); err != nil {
return 0, err
}
}
state, err := instance.core.Drive(instance.ctx)
if err != nil {
return 0, err
}
instance.state.Store(int32(state))
if state == coreabi.StateRunning {
instance.runningOnce.Do(func() { close(instance.running) })
}
if state == coreabi.StateStopped {
instance.stoppedOnce.Do(func() { close(instance.stopped) })
}
moreDataPlaneCompletions, err := instance.drainDataPlaneCompletions()
if err != nil {
return 0, err
}
if err := instance.takeRPCResponses(); err != nil {
return 0, err
}
deadline, err := instance.core.NextDeadline(instance.ctx)
if err != nil {
return 0, err
}
if moreDataPlaneCompletions {
return 0, nil
}
return deadline, nil
}
func (instance *Instance) shutdown() error {
cleanupContext, cancel := context.WithTimeout(
contextutil.WithoutCancel(instance.ctx),
5*time.Second,
)
defer cancel()
instance.host.guestMu.Lock()
dropErr := instance.core.Drop(cleanupContext)
instance.host.guestMu.Unlock()
instance.reactor.UnregisterPacketSink(instance.packetSink)
instance.reactor.UnregisterEventSink(instance.eventSink)
close(instance.events)
instance.cancel()
instance.host.removeInstance(instance)
return dropErr
}
func (instance *Instance) finishedError(operation string) error {
if err := instance.terminalError(); err != nil {
return fmt.Errorf("%s: %w", operation, err)
}
return fmt.Errorf("%s: instance is closed", operation)
}
func (instance *Instance) terminalError() error {
instance.errMu.Lock()
defer instance.errMu.Unlock()
return instance.terminalErr
}
func deadlineTimer(timer *time.Timer, deadline int64) <-chan time.Time {
stopTimer(timer)
if deadline == math.MaxInt64 {
return nil
}
duration := time.Duration(deadline) * time.Millisecond
if deadline > int64(math.MaxInt64/time.Millisecond) {
duration = time.Duration(math.MaxInt64)
}
timer.Reset(duration)
return timer.C
}
func stopTimer(timer *time.Timer) {
if timer == nil || timer.Stop() {
return
}
select {
case <-timer.C:
default:
}
}
@@ -0,0 +1,96 @@
package engine
import (
"context"
"strings"
"testing"
"time"
"github.com/EasyTier/EasyTier/easytier-go/internal/coreabi"
"github.com/EasyTier/EasyTier/easytier-go/platform/netstd"
)
const minimalConfig = `instance_id = "018f4fb1-7a2c-7d1f-9d89-935b0ad7e135"
instance_name = "go-host-engine"
[network_identity]
network_name = "default"
network_secret = "test"
[flags]
disable_p2p = true
enable_encryption = false
bind_device = false
`
func TestHostCreatesAndDrivesInstanceLifecycle(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
host, err := NewHost(ctx, Options{Services: netstd.Services()})
if err != nil {
t.Fatalf("create host: %v", err)
}
defer host.Close(ctx)
instance, err := host.CreateInstance(ctx, minimalConfig)
if err != nil {
t.Fatalf("create instance: %v", err)
}
defer instance.Close(ctx)
if err := instance.Start(ctx); err != nil {
t.Fatalf("start instance: %v", err)
}
if state := instance.State(); state != 2 {
t.Fatalf("running state = %d, want 2", state)
}
if err := instance.Stop(ctx); err != nil {
t.Fatalf("stop instance: %v", err)
}
if err := instance.Wait(ctx); err != nil {
t.Fatalf("wait for stopped instance: %v", err)
}
}
func TestClosingOneHandleLeavesOtherInstanceRunning(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
host, err := NewHost(ctx, Options{Services: netstd.Services()})
if err != nil {
t.Fatalf("create host: %v", err)
}
defer host.Close(ctx)
first, err := host.CreateInstance(ctx, minimalConfig)
if err != nil {
t.Fatalf("create first instance: %v", err)
}
secondConfig := strings.NewReplacer(
"018f4fb1-7a2c-7d1f-9d89-935b0ad7e135",
"018f4fb1-7a2c-7d1f-9d89-935b0ad7e136",
"go-host-engine",
"go-host-engine-second",
).Replace(minimalConfig)
second, err := host.CreateInstance(ctx, secondConfig)
if err != nil {
t.Fatalf("create second instance: %v", err)
}
defer second.Close(ctx)
if err := first.Start(ctx); err != nil {
t.Fatalf("start first instance: %v", err)
}
if err := second.Start(ctx); err != nil {
t.Fatalf("start second instance: %v", err)
}
if err := first.Close(ctx); err != nil {
t.Fatalf("close first instance: %v", err)
}
if state := first.State(); state != coreabi.StateStopped {
t.Fatalf("closed first state = %d, want stopped", state)
}
if state := second.State(); state != 2 {
t.Fatalf("second state after first close = %d, want running", state)
}
if err := second.Stop(ctx); err != nil {
t.Fatalf("stop second instance: %v", err)
}
}
+84
View File
@@ -0,0 +1,84 @@
package engine
import (
"context"
"net"
"net/netip"
"sync"
"sync/atomic"
"github.com/EasyTier/EasyTier/easytier-go/internal/coreabi"
)
type streamListener struct {
instance *Instance
resource coreabi.ResourceID
local netip.AddrPort
closed atomic.Bool
closeOnce sync.Once
closeDone chan struct{}
closeErr error
}
func (listener *streamListener) Accept() (net.Conn, error) {
if listener.closed.Load() {
return nil, net.ErrClosed
}
result, err := listener.instance.performOperation(
context.Background(),
coreabi.OperationTCPAccept,
func(
ctx context.Context,
core dataPlaneCore,
) (coreabi.OperationID, error) {
return core.SubmitTCPAccept(
ctx,
listener.resource,
^uint64(0),
)
},
)
if err != nil {
return nil, err
}
return newStreamConn(listener.instance, result), nil
}
func (listener *streamListener) Close() error {
listener.closeOnce.Do(func() {
listener.closed.Store(true)
listener.closeErr = listener.instance.closeDataPlaneResource(
listener.resource,
)
close(listener.closeDone)
})
<-listener.closeDone
return listener.closeErr
}
func (listener *streamListener) Addr() net.Addr {
return net.TCPAddrFromAddrPort(listener.local)
}
func (instance *Instance) Listen(port uint16) (net.Listener, error) {
result, err := instance.performOperation(
context.Background(),
coreabi.OperationTCPBind,
func(
ctx context.Context,
core dataPlaneCore,
) (coreabi.OperationID, error) {
return core.SubmitTCPBind(ctx, port, ^uint64(0))
},
)
if err != nil {
return nil, err
}
return &streamListener{
instance: instance,
resource: result.Resource,
local: result.Local,
closeDone: make(chan struct{}),
}, nil
}
@@ -0,0 +1,61 @@
package engine
import (
"context"
"testing"
"time"
"github.com/metacubex/wazero"
"github.com/metacubex/wazero/imports/wasi_snapshot_preview1"
)
// wasiWalltimeProbe imports clock_time_get and exports now(), which returns
// the realtime clock value written by WASI at memory offset zero.
var wasiWalltimeProbe = []byte{
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,
0x01, 0x0c, 0x02, 0x60, 0x03, 0x7f, 0x7e, 0x7f,
0x01, 0x7f, 0x60, 0x00, 0x01, 0x7e,
0x02, 0x29, 0x01, 0x16,
'w', 'a', 's', 'i', '_', 's', 'n', 'a', 'p', 's', 'h',
'o', 't', '_', 'p', 'r', 'e', 'v', 'i', 'e', 'w', '1',
0x0e,
'c', 'l', 'o', 'c', 'k', '_', 't', 'i', 'm', 'e', '_', 'g', 'e', 't',
0x00, 0x00,
0x03, 0x02, 0x01, 0x01,
0x05, 0x03, 0x01, 0x00, 0x01,
0x07, 0x10, 0x02,
0x06, 'm', 'e', 'm', 'o', 'r', 'y', 0x02, 0x00,
0x03, 'n', 'o', 'w', 0x00, 0x01,
0x0a, 0x12, 0x01, 0x10, 0x00,
0x41, 0x00, 0x42, 0x00, 0x41, 0x00, 0x10, 0x00, 0x1a,
0x41, 0x00, 0x29, 0x03, 0x00, 0x0b,
}
func TestModuleConfigUsesSystemWalltime(t *testing.T) {
ctx := context.Background()
runtime := wazero.NewRuntime(ctx)
defer runtime.Close(ctx)
if _, err := wasi_snapshot_preview1.Instantiate(ctx, runtime); err != nil {
t.Fatalf("instantiate WASI: %v", err)
}
compiled, err := runtime.CompileModule(ctx, wasiWalltimeProbe)
if err != nil {
t.Fatalf("compile walltime probe: %v", err)
}
defer compiled.Close(ctx)
before := time.Now()
module, err := runtime.InstantiateModule(ctx, compiled, newModuleConfig())
if err != nil {
t.Fatalf("instantiate walltime probe: %v", err)
}
results, err := module.ExportedFunction("now").Call(ctx)
if err != nil {
t.Fatalf("read WASI walltime: %v", err)
}
after := time.Now()
got := time.Unix(0, int64(results[0]))
if got.Before(before.Add(-time.Second)) || got.After(after.Add(time.Second)) {
t.Fatalf("WASI walltime = %s, want system time between %s and %s", got, before, after)
}
}
+284
View File
@@ -0,0 +1,284 @@
package engine
import (
"context"
"io"
"net"
"net/netip"
"sync"
"sync/atomic"
"syscall"
"time"
"github.com/EasyTier/EasyTier/easytier-go/internal/coreabi"
)
type packetConn struct {
instance *Instance
resource coreabi.ResourceID
local netip.AddrPort
readMu sync.Mutex
writeMu sync.Mutex
closed atomic.Bool
closeOnce sync.Once
closeDone chan struct{}
closeErr error
}
type datagramConn struct {
packet *packetConn
peer netip.AddrPort
}
var _ net.Conn = (*datagramConn)(nil)
func newPacketConn(
instance *Instance,
result coreabi.OperationResult,
) *packetConn {
return &packetConn{
instance: instance,
resource: result.Resource,
local: result.Local,
closeDone: make(chan struct{}),
}
}
func (conn *packetConn) ReadFrom(buffer []byte) (int, net.Addr, error) {
conn.readMu.Lock()
defer conn.readMu.Unlock()
if conn.closed.Load() {
return 0, nil, net.ErrClosed
}
maximum := len(buffer)
if maximum > maxDataPlaneTransfer {
maximum = maxDataPlaneTransfer
}
result, err := conn.instance.performOperation(
context.Background(),
coreabi.OperationUDPReceive,
func(
callCtx context.Context,
core dataPlaneCore,
) (coreabi.OperationID, error) {
return core.SubmitUDPReceive(
callCtx,
conn.resource,
uint32(maximum),
)
},
)
if err != nil {
return 0, nil, normalizeDeadlineError(err)
}
n := copy(buffer, result.Data)
peer := net.UDPAddrFromAddrPort(result.Peer)
if result.Truncated {
return n, peer, io.ErrShortBuffer
}
return n, peer, nil
}
func (conn *packetConn) WriteTo(buffer []byte, address net.Addr) (int, error) {
conn.writeMu.Lock()
defer conn.writeMu.Unlock()
if conn.closed.Load() {
return 0, net.ErrClosed
}
if len(buffer) > maxDataPlaneTransfer {
return 0, syscall.EMSGSIZE
}
peer, err := udpAddrPort(address)
if err != nil {
return 0, err
}
result, err := conn.instance.performOperation(
context.Background(),
coreabi.OperationUDPSend,
func(
callCtx context.Context,
core dataPlaneCore,
) (coreabi.OperationID, error) {
return core.SubmitUDPSend(
callCtx,
conn.resource,
peer,
buffer,
)
},
)
if err != nil {
return 0, normalizeDeadlineError(err)
}
if result.Length != len(buffer) {
return result.Length, io.ErrShortWrite
}
return result.Length, nil
}
func (conn *packetConn) Close() error {
conn.closeOnce.Do(func() {
conn.closed.Store(true)
conn.closeErr = conn.instance.closeDataPlaneResource(conn.resource)
close(conn.closeDone)
})
<-conn.closeDone
return conn.closeErr
}
func (conn *packetConn) LocalAddr() net.Addr {
return net.UDPAddrFromAddrPort(conn.local)
}
func (conn *packetConn) SetDeadline(deadline time.Time) error {
if conn.closed.Load() {
return net.ErrClosed
}
return conn.instance.setDataPlaneResourceDeadline(
conn.resource,
coreabi.DeadlineRead|coreabi.DeadlineWrite,
deadline,
)
}
func (conn *packetConn) SetReadDeadline(deadline time.Time) error {
if conn.closed.Load() {
return net.ErrClosed
}
return conn.instance.setDataPlaneResourceDeadline(
conn.resource,
coreabi.DeadlineRead,
deadline,
)
}
func (conn *packetConn) SetWriteDeadline(deadline time.Time) error {
if conn.closed.Load() {
return net.ErrClosed
}
return conn.instance.setDataPlaneResourceDeadline(
conn.resource,
coreabi.DeadlineWrite,
deadline,
)
}
func udpAddrPort(address net.Addr) (netip.AddrPort, error) {
if address == nil {
return netip.AddrPort{}, &net.AddrError{
Err: "address is nil",
}
}
udp, ok := address.(*net.UDPAddr)
if !ok {
return netip.AddrPort{}, &net.AddrError{
Err: "address is not UDP",
Addr: address.String(),
}
}
peer := udp.AddrPort()
if peer.IsValid() {
peer = netip.AddrPortFrom(peer.Addr().Unmap(), peer.Port())
}
if !peer.IsValid() || !peer.Addr().Is4() {
return netip.AddrPort{}, &net.AddrError{
Err: "EasyTier data plane requires an IPv4 address",
Addr: address.String(),
}
}
return peer, nil
}
func (instance *Instance) ListenPacket(port uint16) (net.PacketConn, error) {
result, err := instance.performOperation(
context.Background(),
coreabi.OperationUDPBind,
func(
ctx context.Context,
core dataPlaneCore,
) (coreabi.OperationID, error) {
return core.SubmitUDPBind(ctx, port, ^uint64(0))
},
)
if err != nil {
return nil, err
}
return newPacketConn(instance, result), nil
}
func (instance *Instance) DialUDP(
ctx context.Context,
peer netip.AddrPort,
) (net.Conn, error) {
timeout, err := contextTimeoutMillis(ctx)
if err != nil {
return nil, normalizeDeadlineError(err)
}
result, err := instance.performOperation(
ctx,
coreabi.OperationUDPBind,
func(
callCtx context.Context,
core dataPlaneCore,
) (coreabi.OperationID, error) {
return core.SubmitUDPBind(callCtx, 0, timeout)
},
)
if err != nil {
return nil, normalizeDeadlineError(err)
}
return &datagramConn{
packet: newPacketConn(instance, result),
peer: peer,
}, nil
}
func (conn *datagramConn) Read(buffer []byte) (int, error) {
if len(buffer) == 0 {
return 0, nil
}
for {
n, source, err := conn.packet.ReadFrom(buffer)
if source == nil {
return n, err
}
sourcePeer, addressErr := udpAddrPort(source)
if addressErr != nil {
return 0, addressErr
}
if sourcePeer != conn.peer {
continue
}
return n, err
}
}
func (conn *datagramConn) Write(buffer []byte) (int, error) {
return conn.packet.WriteTo(buffer, net.UDPAddrFromAddrPort(conn.peer))
}
func (conn *datagramConn) Close() error {
return conn.packet.Close()
}
func (conn *datagramConn) LocalAddr() net.Addr {
return conn.packet.LocalAddr()
}
func (conn *datagramConn) RemoteAddr() net.Addr {
return net.UDPAddrFromAddrPort(conn.peer)
}
func (conn *datagramConn) SetDeadline(deadline time.Time) error {
return conn.packet.SetDeadline(deadline)
}
func (conn *datagramConn) SetReadDeadline(deadline time.Time) error {
return conn.packet.SetReadDeadline(deadline)
}
func (conn *datagramConn) SetWriteDeadline(deadline time.Time) error {
return conn.packet.SetWriteDeadline(deadline)
}
+228
View File
@@ -0,0 +1,228 @@
package engine
import (
"context"
"fmt"
"net"
"github.com/EasyTier/EasyTier/easytier-go/internal/coreabi"
)
type rpcCore interface {
SubmitRPC(
context.Context,
[]byte,
) (coreabi.RPCOperationID, error)
TakeRPCResponse(
context.Context,
coreabi.RPCOperationID,
) ([]byte, bool, error)
FreeRPCOperation(context.Context, coreabi.RPCOperationID) error
}
type rpcCommandKind uint8
const (
rpcSubmit rpcCommandKind = iota + 1
rpcFree
)
type rpcCommand struct {
kind rpcCommandKind
request []byte
operation coreabi.RPCOperationID
response chan rpcCommandResponse
}
type rpcCommandResponse struct {
ticket rpcTicket
outcome rpcOutcome
completed bool
err error
}
type rpcTicket struct {
id coreabi.RPCOperationID
result <-chan rpcOutcome
}
type rpcOutcome struct {
response []byte
err error
}
type pendingRPC struct {
result chan rpcOutcome
}
func (instance *Instance) RPC(
ctx context.Context,
encodedRequest []byte,
) ([]byte, error) {
if ctx == nil {
return nil, fmt.Errorf("submit EasyTier RPC with nil context")
}
response := make(chan rpcCommandResponse, 1)
request := rpcCommand{
kind: rpcSubmit,
request: encodedRequest,
response: response,
}
select {
case instance.rpcCommands <- request:
case <-instance.done:
return nil, net.ErrClosed
case <-instance.closeRequested:
return nil, net.ErrClosed
case <-ctx.Done():
return nil, ctx.Err()
}
var ticket rpcTicket
select {
case submitted := <-response:
if submitted.err != nil {
return nil, submitted.err
}
if submitted.completed {
return submitted.outcome.response, submitted.outcome.err
}
ticket = submitted.ticket
case <-instance.done:
return nil, net.ErrClosed
case <-ctx.Done():
// The driver has accepted the request and may already have submitted
// it. Wait for its operation ID so cancellation cannot leak it.
select {
case submitted := <-response:
if submitted.err != nil {
return nil, submitted.err
}
if submitted.completed {
return submitted.outcome.response, submitted.outcome.err
}
ticket = submitted.ticket
case <-instance.done:
return nil, net.ErrClosed
}
return instance.freeRPCAndWait(ctx, ticket)
}
select {
case outcome := <-ticket.result:
return outcome.response, outcome.err
case <-ctx.Done():
return instance.freeRPCAndWait(ctx, ticket)
case <-instance.done:
return nil, net.ErrClosed
case <-instance.closeRequested:
return nil, net.ErrClosed
}
}
func (instance *Instance) freeRPCAndWait(
cancelled context.Context,
ticket rpcTicket,
) ([]byte, error) {
response := make(chan rpcCommandResponse, 1)
request := rpcCommand{
kind: rpcFree,
operation: ticket.id,
response: response,
}
select {
case instance.rpcCommands <- request:
case outcome := <-ticket.result:
return outcome.response, outcome.err
case <-instance.done:
return nil, net.ErrClosed
case <-instance.closeRequested:
return nil, net.ErrClosed
}
select {
case result := <-response:
if result.err != nil {
return nil, result.err
}
case outcome := <-ticket.result:
return outcome.response, outcome.err
case <-instance.done:
return nil, net.ErrClosed
case <-instance.closeRequested:
return nil, net.ErrClosed
}
select {
case outcome := <-ticket.result:
return outcome.response, outcome.err
default:
return nil, cancelled.Err()
}
}
func (instance *Instance) handleRPCCommand(
request rpcCommand,
) rpcCommandResponse {
instance.host.guestMu.Lock()
defer instance.host.guestMu.Unlock()
switch request.kind {
case rpcSubmit:
operation, err := instance.rpc.SubmitRPC(
instance.ctx,
request.request,
)
if err != nil {
return rpcCommandResponse{err: err}
}
result := make(chan rpcOutcome, 1)
instance.pendingRPCs[operation] = &pendingRPC{result: result}
return rpcCommandResponse{ticket: rpcTicket{
id: operation,
result: result,
}}
case rpcFree:
if _, exists := instance.pendingRPCs[request.operation]; !exists {
return rpcCommandResponse{}
}
if err := instance.rpc.FreeRPCOperation(
instance.ctx,
request.operation,
); err != nil {
return rpcCommandResponse{err: err}
}
delete(instance.pendingRPCs, request.operation)
return rpcCommandResponse{}
default:
return rpcCommandResponse{
err: fmt.Errorf("unknown RPC command %d", request.kind),
}
}
}
// takeRPCResponses runs with host.guestMu held.
func (instance *Instance) takeRPCResponses() error {
if instance.rpc == nil || len(instance.pendingRPCs) == 0 {
return nil
}
for operation, pending := range instance.pendingRPCs {
response, ready, err := instance.rpc.TakeRPCResponse(
instance.ctx,
operation,
)
if err != nil {
return err
}
if !ready {
continue
}
delete(instance.pendingRPCs, operation)
pending.result <- rpcOutcome{response: response}
}
return nil
}
func (instance *Instance) failPendingRPCs(err error) {
for operation, pending := range instance.pendingRPCs {
pending.result <- rpcOutcome{err: err}
delete(instance.pendingRPCs, operation)
}
}
+128
View File
@@ -0,0 +1,128 @@
package engine
import (
"bytes"
"context"
"errors"
"math"
"testing"
"time"
"github.com/EasyTier/EasyTier/easytier-go/internal/coreabi"
)
type readyRPC struct {
response []byte
taken []coreabi.RPCOperationID
}
func (*readyRPC) SubmitRPC(
context.Context,
[]byte,
) (coreabi.RPCOperationID, error) {
return 0, errors.New("unexpected RPC submission")
}
func (rpc *readyRPC) TakeRPCResponse(
_ context.Context,
operation coreabi.RPCOperationID,
) ([]byte, bool, error) {
rpc.taken = append(rpc.taken, operation)
return append([]byte(nil), rpc.response...), true, nil
}
func (*readyRPC) FreeRPCOperation(
context.Context,
coreabi.RPCOperationID,
) error {
return errors.New("unexpected RPC free")
}
func TestDriveTakesReadyRPCResponse(t *testing.T) {
core := &recordingCore{}
rpc := &readyRPC{response: []byte{1, 2, 3}}
result := make(chan rpcOutcome, 1)
instance := &Instance{
host: &Host{},
ctx: context.Background(),
core: core,
rpc: rpc,
pendingRPCs: map[coreabi.RPCOperationID]*pendingRPC{
9: {result: result},
},
running: make(chan struct{}),
stopped: make(chan struct{}),
}
deadline, err := instance.drive(false)
if err != nil {
t.Fatalf("drive RPC response: %v", err)
}
if deadline != math.MaxInt64 {
t.Fatalf("next deadline = %d, want no deadline", deadline)
}
outcome := <-result
if outcome.err != nil {
t.Fatalf("RPC outcome: %v", outcome.err)
}
if !bytes.Equal(outcome.response, rpc.response) {
t.Fatalf("RPC response = %x, want %x", outcome.response, rpc.response)
}
if len(instance.pendingRPCs) != 0 {
t.Fatalf("pending RPCs = %d, want 0", len(instance.pendingRPCs))
}
if len(rpc.taken) != 1 || rpc.taken[0] != 9 {
t.Fatalf("taken RPCs = %v, want [9]", rpc.taken)
}
}
func TestRPCCancellationFreesSubmittedOperation(t *testing.T) {
instance := &Instance{
rpcCommands: make(chan rpcCommand),
closeRequested: make(chan struct{}),
done: make(chan struct{}),
}
ctx, cancel := context.WithCancel(context.Background())
callResult := make(chan error, 1)
go func() {
_, err := instance.RPC(ctx, []byte{1, 2, 3})
callResult <- err
}()
var submit rpcCommand
select {
case submit = <-instance.rpcCommands:
case <-time.After(time.Second):
t.Fatal("RPC did not enqueue submission")
}
result := make(chan rpcOutcome, 1)
cancel()
submit.response <- rpcCommandResponse{ticket: rpcTicket{
id: 17,
result: result,
}}
var free rpcCommand
select {
case free = <-instance.rpcCommands:
case <-time.After(time.Second):
t.Fatal("RPC cancellation did not enqueue free")
}
if free.kind != rpcFree || free.operation != 17 {
t.Fatalf(
"RPC cancellation command = (kind=%d, operation=%d)",
free.kind,
free.operation,
)
}
free.response <- rpcCommandResponse{}
select {
case err := <-callResult:
if !errors.Is(err, context.Canceled) {
t.Fatalf("cancelled RPC error = %v, want context cancellation", err)
}
case <-time.After(time.Second):
t.Fatal("cancelled RPC did not return")
}
}
+182
View File
@@ -0,0 +1,182 @@
package engine
import (
"context"
"errors"
"fmt"
"sync"
"sync/atomic"
"time"
"github.com/EasyTier/EasyTier/easytier-go/internal/contextutil"
"github.com/EasyTier/EasyTier/easytier-go/internal/coreabi"
)
type WebClientOptions struct {
Endpoint string
MachineID string
Hostname string
SecureMode bool
OSType string
}
type WebClient struct {
host *Host
ctx context.Context
cancel context.CancelFunc
core *coreabi.WebClient
completions chan struct{}
closeRequested chan struct{}
closeOnce sync.Once
done chan struct{}
connected atomic.Bool
errMu sync.Mutex
terminalErr error
}
func (host *Host) CreateWebClient(
ctx context.Context,
options WebClientOptions,
) (*WebClient, error) {
if host == nil {
return nil, fmt.Errorf("create WebClient with nil EasyTier engine host")
}
if ctx == nil {
return nil, fmt.Errorf("create EasyTier WebClient with nil context")
}
envelope, err := encodeWebClientEnvelope(options, host.options.Services.Snapshot)
if err != nil {
return nil, err
}
host.mu.Lock()
defer host.mu.Unlock()
if host.closed {
return nil, fmt.Errorf("create WebClient with closed EasyTier engine host")
}
if host.webClient != nil {
return nil, fmt.Errorf("EasyTier WebClient is already running")
}
host.guestMu.Lock()
core, err := coreabi.NewWebClient(host.module)
if err == nil {
err = core.Create(ctx, envelope)
}
host.guestMu.Unlock()
if err != nil {
return nil, err
}
lifetime, cancel := context.WithCancel(host.ctx)
client := &WebClient{
host: host,
ctx: lifetime,
cancel: cancel,
core: core,
completions: make(chan struct{}, 1),
closeRequested: make(chan struct{}),
done: make(chan struct{}),
}
host.webClient = client
go client.run()
return client, nil
}
func (client *WebClient) Connected() bool {
return client != nil && client.connected.Load()
}
func (client *WebClient) Close(ctx context.Context) error {
if client == nil {
return nil
}
if ctx == nil {
return fmt.Errorf("close EasyTier WebClient with nil context")
}
client.closeOnce.Do(func() { close(client.closeRequested) })
select {
case <-client.done:
return client.terminalError()
case <-ctx.Done():
return ctx.Err()
}
}
func (client *WebClient) run() {
runErr := client.driveLoop()
cleanupErr := client.shutdown()
client.errMu.Lock()
client.terminalErr = errors.Join(runErr, cleanupErr)
client.errMu.Unlock()
close(client.done)
}
func (client *WebClient) driveLoop() error {
deadline := int64(0)
timer := time.NewTimer(0)
stopTimer(timer)
defer stopTimer(timer)
for {
select {
case <-client.completions:
stopTimer(timer)
next, err := client.drive(true)
if err != nil {
return err
}
deadline = next
case <-deadlineTimer(timer, deadline):
next, err := client.drive(false)
if err != nil {
return err
}
deadline = next
case <-client.closeRequested:
return nil
case <-client.ctx.Done():
return client.ctx.Err()
}
}
}
func (client *WebClient) drive(notify bool) (int64, error) {
client.host.guestMu.Lock()
defer client.host.guestMu.Unlock()
if notify {
if err := client.core.NotifyCompletions(client.ctx); err != nil {
return 0, err
}
}
if err := client.core.Drive(client.ctx); err != nil {
return 0, err
}
connected, err := client.core.IsConnected(client.ctx)
if err != nil {
return 0, err
}
client.connected.Store(connected)
return client.core.NextDeadline(client.ctx)
}
func (client *WebClient) shutdown() error {
cleanupContext, cancel := context.WithTimeout(
contextutil.WithoutCancel(client.ctx),
5*time.Second,
)
defer cancel()
client.host.guestMu.Lock()
err := client.core.Drop(cleanupContext)
client.host.guestMu.Unlock()
client.connected.Store(false)
client.cancel()
client.host.removeWebClient(client)
return err
}
func (client *WebClient) terminalError() error {
client.errMu.Lock()
defer client.errMu.Unlock()
return client.terminalErr
}
+417
View File
@@ -0,0 +1,417 @@
package host
import (
"context"
"fmt"
"net"
"net/netip"
"strconv"
"github.com/EasyTier/EasyTier/easytier-go/internal/artifact"
"github.com/EasyTier/EasyTier/easytier-go/internal/contextutil"
"github.com/EasyTier/EasyTier/easytier-go/internal/coreabi"
"github.com/EasyTier/EasyTier/easytier-go/internal/engine"
"github.com/EasyTier/EasyTier/easytier-go/platform"
"github.com/EasyTier/EasyTier/easytier-go/platform/netstd"
hostproto "github.com/EasyTier/EasyTier/easytier-go/proto"
"github.com/EasyTier/EasyTier/easytier-go/proto/api/manage"
)
type State int32
const (
StateCreated State = iota
StateStarting
StateRunning
StateStopping
StateStopped
)
type Options struct {
Platform platform.Services
PacketQueueCapacity int
}
type EmbeddedCoreInfo struct {
EasyTierCommit string
SHA256 string
}
// Event is one best-effort notification emitted by an EasyTier instance.
type Event = engine.Event
type Host struct {
engine *engine.Host
manager *instanceManager
}
type Instance struct {
engine *engine.Instance
id string
manager *instanceManager
}
func New(ctx context.Context, options Options) (*Host, error) {
if ctx == nil {
return nil, fmt.Errorf("create EasyTier host with nil context")
}
if artifact.Commit != hostproto.EasyTierCommit {
return nil, fmt.Errorf(
"embedded EasyTier commit %s does not match protobuf commit %s",
artifact.Commit,
hostproto.EasyTierCommit,
)
}
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
manager := newInstanceManager()
runtime, err := engine.NewHost(
ctx,
engine.Options{
Services: mergeServices(options.Platform),
PacketQueueCapacity: options.PacketQueueCapacity,
Management: manager.handle,
},
)
if err != nil {
return nil, err
}
host := &Host{engine: runtime, manager: manager}
manager.host = host
return host, nil
}
func CoreInfo() EmbeddedCoreInfo {
return EmbeddedCoreInfo{
EasyTierCommit: artifact.Commit,
SHA256: artifact.SHA256,
}
}
// CreateInstance creates an EasyTier instance from a built configuration.
func (host *Host) CreateInstance(
ctx context.Context,
config InstanceConfig,
) (*Instance, error) {
if host == nil {
return nil, fmt.Errorf("create instance with nil EasyTier host")
}
configTOML, err := encodeInstanceConfig(config)
if err != nil {
return nil, err
}
id, idString, err := newInstanceUUID()
if err != nil {
return nil, err
}
runtime, err := host.engine.CreateInstance(
ctx,
bindInstanceIdentity(configTOML, idString, config.document.networkName),
)
if err != nil {
return nil, err
}
instance := &Instance{
engine: runtime,
id: idString,
manager: host.manager,
}
if err := host.manager.register(&managedInstance{
id: id,
instance: instance,
owner: instanceOwnerApplication,
source: manage.ConfigSource_ConfigSourceUser,
name: config.document.networkName,
}); err != nil {
_ = runtime.Close(contextutil.WithoutCancel(ctx))
return nil, err
}
return instance, nil
}
// Instances returns a stable snapshot of all application- and Web-owned instances.
func (host *Host) Instances() []*Instance {
if host == nil || host.manager == nil {
return nil
}
entries := host.manager.snapshot()
instances := make([]*Instance, len(entries))
for index, entry := range entries {
instances[index] = entry.instance
}
return instances
}
func (host *Host) Close(ctx context.Context) error {
if host == nil {
return nil
}
if ctx == nil {
return fmt.Errorf("close EasyTier host with nil context")
}
err := host.engine.Close(ctx)
if ctx.Err() == nil {
host.manager.clear()
}
return err
}
func (instance *Instance) Start(ctx context.Context) error {
if instance == nil || instance.engine == nil {
return fmt.Errorf("start nil EasyTier instance")
}
return instance.engine.Start(ctx)
}
func (instance *Instance) Stop(ctx context.Context) error {
if instance == nil || instance.engine == nil {
return fmt.Errorf("stop nil EasyTier instance")
}
return instance.engine.Stop(ctx)
}
func (instance *Instance) SendPacket(ctx context.Context, packet []byte) error {
if instance == nil || instance.engine == nil {
return fmt.Errorf("send packet through nil EasyTier instance")
}
return instance.engine.SendPacket(ctx, packet)
}
func (instance *Instance) ReceivePacket(ctx context.Context) ([]byte, error) {
if instance == nil || instance.engine == nil {
return nil, fmt.Errorf("receive packet from nil EasyTier instance")
}
return instance.engine.ReceivePacket(ctx)
}
// Events returns the instance's bounded event stream.
//
// Slow consumers may miss events. The channel closes with the instance.
func (instance *Instance) Events() <-chan Event {
if instance == nil || instance.engine == nil {
return nil
}
return instance.engine.Events()
}
func (instance *Instance) Dial(
ctx context.Context,
network string,
address string,
) (net.Conn, error) {
if instance == nil || instance.engine == nil {
return nil, fmt.Errorf("dial through nil EasyTier instance")
}
if ctx == nil {
return nil, fmt.Errorf("dial through EasyTier with nil context")
}
switch network {
case "tcp", "tcp4", "udp", "udp4":
default:
return nil, net.UnknownNetworkError(network)
}
peer, err := parseDialAddress(address)
if err != nil {
return nil, err
}
var connection net.Conn
switch network {
case "tcp", "tcp4":
connection, err = instance.engine.Dial(ctx, peer)
case "udp", "udp4":
connection, err = instance.engine.DialUDP(ctx, peer)
}
if err != nil {
var target net.Addr = net.TCPAddrFromAddrPort(peer)
if network == "udp" || network == "udp4" {
target = net.UDPAddrFromAddrPort(peer)
}
return nil, &net.OpError{
Op: "dial",
Net: network,
Addr: target,
Err: err,
}
}
return connection, nil
}
func (instance *Instance) Listen(
network string,
address string,
) (net.Listener, error) {
if instance == nil || instance.engine == nil {
return nil, fmt.Errorf("listen through nil EasyTier instance")
}
if err := requireNetwork(network, "tcp"); err != nil {
return nil, err
}
port, err := parseBindPort(address)
if err != nil {
return nil, err
}
listener, err := instance.engine.Listen(port)
if err != nil {
return nil, &net.OpError{
Op: "listen",
Net: network,
Addr: &net.TCPAddr{
IP: net.IPv4zero,
Port: int(port),
},
Err: err,
}
}
return listener, nil
}
func (instance *Instance) ListenPacket(
network string,
address string,
) (net.PacketConn, error) {
if instance == nil || instance.engine == nil {
return nil, fmt.Errorf("listen for packets through nil EasyTier instance")
}
if err := requireNetwork(network, "udp"); err != nil {
return nil, err
}
port, err := parseBindPort(address)
if err != nil {
return nil, err
}
connection, err := instance.engine.ListenPacket(port)
if err != nil {
return nil, &net.OpError{
Op: "listen",
Net: network,
Addr: &net.UDPAddr{
IP: net.IPv4zero,
Port: int(port),
},
Err: err,
}
}
return connection, nil
}
func (instance *Instance) Wait(ctx context.Context) error {
if instance == nil || instance.engine == nil {
return fmt.Errorf("wait for nil EasyTier instance")
}
return instance.engine.Wait(ctx)
}
func (instance *Instance) State() State {
if instance == nil || instance.engine == nil {
return StateStopped
}
return mapState(instance.engine.State())
}
// ID returns the instance's stable UUID.
func (instance *Instance) ID() string {
if instance == nil {
return ""
}
return instance.id
}
func (instance *Instance) Close(ctx context.Context) error {
if instance == nil || instance.engine == nil {
return nil
}
err := instance.engine.Close(ctx)
if ctx != nil && ctx.Err() == nil && instance.manager != nil {
instance.manager.remove(instance)
}
return err
}
func mergeServices(configured platform.Services) platform.Services {
services := netstd.Services()
if configured.Sockets != nil {
services.Sockets = configured.Sockets
}
if configured.DNS != nil {
services.DNS = configured.DNS
}
if configured.Environment != nil {
services.Environment = configured.Environment
}
services.Snapshot = configured.Snapshot
return services
}
func mapState(state coreabi.State) State {
switch state {
case coreabi.StateCreated:
return StateCreated
case coreabi.StateStarting:
return StateStarting
case coreabi.StateRunning:
return StateRunning
case coreabi.StateStopping:
return StateStopping
case coreabi.StateStopped:
return StateStopped
default:
return StateStopped
}
}
func requireNetwork(network string, protocol string) error {
if network == protocol || network == protocol+"4" {
return nil
}
return net.UnknownNetworkError(network)
}
func parseDialAddress(address string) (netip.AddrPort, error) {
host, port, err := splitAddress(address)
if err != nil {
return netip.AddrPort{}, err
}
ip, err := netip.ParseAddr(host)
if err != nil || !ip.Is4() {
return netip.AddrPort{}, &net.AddrError{
Err: "EasyTier data plane requires an IPv4 literal",
Addr: address,
}
}
return netip.AddrPortFrom(ip, port), nil
}
func parseBindPort(address string) (uint16, error) {
host, port, err := splitAddress(address)
if err != nil {
return 0, err
}
if host != "" {
ip, parseErr := netip.ParseAddr(host)
if parseErr != nil || !ip.Is4() || !ip.IsUnspecified() {
return 0, &net.AddrError{
Err: "EasyTier listeners bind all overlay IPv4 addresses",
Addr: address,
}
}
}
return port, nil
}
func splitAddress(address string) (string, uint16, error) {
host, service, err := net.SplitHostPort(address)
if err != nil {
return "", 0, err
}
port, err := strconv.ParseUint(service, 10, 16)
if err != nil {
return "", 0, &net.AddrError{
Err: "port must be a decimal number from 0 to 65535",
Addr: address,
}
}
return host, uint16(port), nil
}
@@ -0,0 +1,607 @@
package host
import (
"crypto/ecdh"
"crypto/rand"
"fmt"
"net/netip"
"net/url"
"strconv"
"strings"
"unicode"
"unicode/utf8"
)
// P2PPolicy controls when an instance attempts peer-to-peer connections.
//
// Disable normally suppresses P2P, but a peer advertising Need may still
// request it. Need advertises that this instance requires P2P and may override
// a peer's Disable setting. Lazy defers background P2P until traffic needs it,
// except for peers advertising Need.
type P2PPolicy struct {
Disable bool
Need bool
Lazy bool
}
// HolePunchingPolicy selects the hole-punching methods available to an
// instance. SymmetricUDP requires UDP.
type HolePunchingPolicy struct {
TCP bool
UDP bool
SymmetricUDP bool
}
// PortForwardProtocol identifies the transport used by a port-forward rule.
type PortForwardProtocol string
const (
PortForwardTCP PortForwardProtocol = "tcp"
PortForwardUDP PortForwardProtocol = "udp"
)
// PortForwardConfig exposes a host socket through the EasyTier data plane.
type PortForwardConfig struct {
Protocol PortForwardProtocol
Bind netip.AddrPort
Destination netip.AddrPort
}
// InstanceConfig is an immutable EasyTier instance configuration.
//
// Its zero value is invalid. Construct one with InstanceConfigBuilder.
type InstanceConfig struct {
document *instanceConfigDocument
}
// InstanceConfigBuilder builds a validated InstanceConfig.
//
// A builder is not safe for concurrent use.
type InstanceConfigBuilder struct {
document instanceConfigDocument
networkSecretSet bool
}
type instanceConfigDocument struct {
networkName string
networkSecret string
hostname *string
ipv4 *netip.Prefix
peers []string
listeners []string
portForwards []PortForwardConfig
stunServers []string
stunServersSet bool
stunServersV6 []string
stunServersV6Set bool
p2p *P2PPolicy
holePunching *HolePunchingPolicy
encryption *bool
secureMode secureMode
securePrivateKey []byte
}
type secureMode uint8
const (
secureModeDisabled secureMode = iota
secureModeAutomatic
secureModeManual
)
// NewInstanceConfigBuilder starts a configuration for networkName.
func NewInstanceConfigBuilder(networkName string) *InstanceConfigBuilder {
return &InstanceConfigBuilder{
document: instanceConfigDocument{networkName: networkName},
}
}
// NetworkSecret selects shared-secret network authentication.
//
// An empty secret is valid unless secure mode is enabled.
func (builder *InstanceConfigBuilder) NetworkSecret(
secret string,
) *InstanceConfigBuilder {
builder.document.networkSecret = secret
builder.networkSecretSet = true
return builder
}
// Hostname sets the hostname advertised by this instance.
func (builder *InstanceConfigBuilder) Hostname(
hostname string,
) *InstanceConfigBuilder {
builder.document.hostname = &hostname
return builder
}
// IPv4 sets the instance's virtual IPv4 address and network prefix.
func (builder *InstanceConfigBuilder) IPv4(
prefix netip.Prefix,
) *InstanceConfigBuilder {
builder.document.ipv4 = &prefix
return builder
}
// AddPeers appends TCP or UDP peer endpoints.
func (builder *InstanceConfigBuilder) AddPeers(
uris ...string,
) *InstanceConfigBuilder {
builder.document.peers = append(builder.document.peers, uris...)
return builder
}
// AddListeners appends TCP or UDP listener endpoints.
func (builder *InstanceConfigBuilder) AddListeners(
uris ...string,
) *InstanceConfigBuilder {
builder.document.listeners = append(builder.document.listeners, uris...)
return builder
}
// AddPortForwards appends TCP or UDP host-to-overlay forwarding rules.
func (builder *InstanceConfigBuilder) AddPortForwards(
forwards ...PortForwardConfig,
) *InstanceConfigBuilder {
builder.document.portForwards = append(builder.document.portForwards, forwards...)
return builder
}
// STUNServers replaces the IPv4 UDP STUN server list.
//
// Calling STUNServers with no arguments explicitly disables the list. Omitting
// the call uses the embedded core's defaults.
func (builder *InstanceConfigBuilder) STUNServers(
servers ...string,
) *InstanceConfigBuilder {
builder.document.stunServers = append([]string(nil), servers...)
builder.document.stunServersSet = true
return builder
}
// STUNServersV6 replaces the IPv6 UDP STUN server list.
//
// Calling STUNServersV6 with no arguments explicitly disables the list.
// Omitting the call uses the embedded core's defaults.
func (builder *InstanceConfigBuilder) STUNServersV6(
servers ...string,
) *InstanceConfigBuilder {
builder.document.stunServersV6 = append([]string(nil), servers...)
builder.document.stunServersV6Set = true
return builder
}
// P2P sets the instance's P2P connection policy.
func (builder *InstanceConfigBuilder) P2P(
policy P2PPolicy,
) *InstanceConfigBuilder {
builder.document.p2p = &policy
return builder
}
// HolePunching sets the instance's TCP and UDP hole-punching policy.
func (builder *InstanceConfigBuilder) HolePunching(
policy HolePunchingPolicy,
) *InstanceConfigBuilder {
builder.document.holePunching = &policy
return builder
}
// Encryption explicitly enables or disables EasyTier data encryption.
func (builder *InstanceConfigBuilder) Encryption(
enabled bool,
) *InstanceConfigBuilder {
builder.document.encryption = &enabled
return builder
}
// SecureMode enables secure mode with an automatically generated X25519 key.
func (builder *InstanceConfigBuilder) SecureMode() *InstanceConfigBuilder {
builder.document.secureMode = secureModeAutomatic
builder.document.securePrivateKey = nil
return builder
}
// SecureModeWithPrivateKey enables secure mode with a caller-provided raw
// 32-byte X25519 private key. The key is copied immediately.
func (builder *InstanceConfigBuilder) SecureModeWithPrivateKey(
privateKey []byte,
) *InstanceConfigBuilder {
builder.document.secureMode = secureModeManual
builder.document.securePrivateKey = append([]byte(nil), privateKey...)
return builder
}
// Build validates the builder and returns an immutable configuration.
func (builder *InstanceConfigBuilder) Build() (InstanceConfig, error) {
if builder == nil {
return InstanceConfig{}, invalidInstanceConfig("", "builder is nil")
}
if err := builder.validateIdentity(); err != nil {
return InstanceConfig{}, err
}
document := builder.document.clone()
if err := validateHostname(document.hostname); err != nil {
return InstanceConfig{}, err
}
if err := validateIPv4(document.ipv4); err != nil {
return InstanceConfig{}, err
}
if err := normalizeEndpoints(document.peers, false, "peers"); err != nil {
return InstanceConfig{}, err
}
if err := normalizeEndpoints(document.listeners, true, "listeners"); err != nil {
return InstanceConfig{}, err
}
if err := validatePortForwards(document.portForwards); err != nil {
return InstanceConfig{}, err
}
if document.stunServersSet {
if err := validateSTUNServers(document.stunServers, false, "stun_servers"); err != nil {
return InstanceConfig{}, err
}
}
if document.stunServersV6Set {
if err := validateSTUNServers(
document.stunServersV6,
true,
"stun_servers_v6",
); err != nil {
return InstanceConfig{}, err
}
}
if document.holePunching != nil &&
document.holePunching.SymmetricUDP &&
!document.holePunching.UDP {
return InstanceConfig{}, invalidInstanceConfig(
"hole_punching.symmetric_udp",
"requires UDP hole punching",
)
}
if document.secureMode != secureModeDisabled {
if document.networkSecret == "" {
return InstanceConfig{}, invalidInstanceConfig(
"secure_mode",
"requires a non-empty network secret",
)
}
privateKey, err := builder.secureModePrivateKey()
if err != nil {
return InstanceConfig{}, err
}
document.securePrivateKey = privateKey
}
return InstanceConfig{document: &document}, nil
}
func (builder *InstanceConfigBuilder) validateIdentity() error {
if !utf8.ValidString(builder.document.networkName) {
return invalidInstanceConfig("network.name", "must be valid UTF-8")
}
if builder.document.networkName == "" {
return invalidInstanceConfig("network.name", "must not be empty")
}
if !builder.networkSecretSet {
return invalidInstanceConfig("network.secret", "must be explicitly set")
}
if !utf8.ValidString(builder.document.networkSecret) {
return invalidInstanceConfig("network.secret", "must be valid UTF-8")
}
return nil
}
func (builder *InstanceConfigBuilder) secureModePrivateKey() ([]byte, error) {
switch builder.document.secureMode {
case secureModeAutomatic:
if len(builder.document.securePrivateKey) == 0 {
privateKey, err := ecdh.X25519().GenerateKey(rand.Reader)
if err != nil {
return nil, fmt.Errorf("generate secure mode X25519 key: %w", err)
}
builder.document.securePrivateKey = privateKey.Bytes()
}
case secureModeManual:
if len(builder.document.securePrivateKey) != 32 {
return nil, invalidInstanceConfig(
"secure_mode.private_key",
"must contain exactly 32 bytes",
)
}
if _, err := ecdh.X25519().NewPrivateKey(
builder.document.securePrivateKey,
); err != nil {
return nil, invalidInstanceConfig(
"secure_mode.private_key",
"is not a valid X25519 private key",
)
}
default:
return nil, invalidInstanceConfig("secure_mode", "has an invalid mode")
}
return append([]byte(nil), builder.document.securePrivateKey...), nil
}
func validateHostname(hostname *string) error {
if hostname == nil {
return nil
}
if !utf8.ValidString(*hostname) {
return invalidInstanceConfig("hostname", "must be valid UTF-8")
}
if *hostname == "" {
return invalidInstanceConfig("hostname", "must not be empty")
}
if utf8.RuneCountInString(*hostname) > 32 {
return invalidInstanceConfig("hostname", "must not exceed 32 characters")
}
for _, character := range *hostname {
if unicode.IsControl(character) {
return invalidInstanceConfig("hostname", "must not contain control characters")
}
}
return nil
}
func validateIPv4(prefix *netip.Prefix) error {
if prefix == nil {
return nil
}
if !prefix.IsValid() || !prefix.Addr().Is4() {
return invalidInstanceConfig("ipv4", "must be a valid IPv4 prefix")
}
if prefix.Bits() == 32 {
return invalidInstanceConfig(
"ipv4",
"must include a network prefix shorter than /32",
)
}
return nil
}
func validatePortForwards(forwards []PortForwardConfig) error {
seen := make(map[PortForwardConfig]struct{}, len(forwards))
for index, forward := range forwards {
field := fmt.Sprintf("port_forwards[%d]", index)
if forward.Protocol != PortForwardTCP &&
forward.Protocol != PortForwardUDP {
return invalidInstanceConfig(
field+".protocol",
"must be TCP or UDP",
)
}
if !forward.Bind.IsValid() || !forward.Bind.Addr().Is4() {
return invalidInstanceConfig(
field+".bind",
"must be a valid IPv4 socket address",
)
}
if !forward.Destination.IsValid() ||
!forward.Destination.Addr().Is4() {
return invalidInstanceConfig(
field+".destination",
"must be a valid IPv4 socket address",
)
}
if forward.Destination.Port() == 0 {
return invalidInstanceConfig(
field+".destination",
"port must not be zero",
)
}
if _, exists := seen[forward]; exists {
return invalidInstanceConfig(field, "duplicates another port forward")
}
seen[forward] = struct{}{}
}
return nil
}
func normalizeEndpoints(values []string, listener bool, field string) error {
seen := make(map[string]struct{}, len(values))
for index, value := range values {
normalized, err := normalizeEndpoint(value, listener)
if err != nil {
return invalidInstanceConfig(
fmt.Sprintf("%s[%d]", field, index),
err.Error(),
)
}
if _, exists := seen[normalized]; exists {
return invalidInstanceConfig(
fmt.Sprintf("%s[%d]", field, index),
"duplicates another endpoint",
)
}
seen[normalized] = struct{}{}
values[index] = normalized
}
return nil
}
func normalizeEndpoint(value string, listener bool) (string, error) {
if !utf8.ValidString(value) {
return "", fmt.Errorf("must be valid UTF-8")
}
if value == "" || strings.TrimSpace(value) != value {
return "", fmt.Errorf("must be a non-empty URI without surrounding whitespace")
}
parsed, err := url.ParseRequestURI(value)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return "", fmt.Errorf("must be an absolute TCP or UDP URI")
}
parsed.Scheme = strings.ToLower(parsed.Scheme)
if parsed.Scheme != "tcp" && parsed.Scheme != "udp" {
return "", fmt.Errorf("must use the TCP or UDP scheme")
}
if parsed.User != nil ||
parsed.Opaque != "" ||
parsed.Path != "" ||
parsed.RawPath != "" ||
parsed.RawQuery != "" ||
parsed.ForceQuery ||
parsed.Fragment != "" {
return "", fmt.Errorf("must not contain credentials, a path, query, or fragment")
}
host := parsed.Hostname()
if host == "" {
return "", fmt.Errorf("must include a host")
}
if port := parsed.Port(); port != "" {
value, parseErr := strconv.ParseUint(port, 10, 16)
if parseErr != nil || (!listener && value == 0) {
return "", fmt.Errorf("has an invalid port")
}
}
if listener {
if _, err := netip.ParseAddr(host); err != nil {
return "", fmt.Errorf("listener host must be an IP address")
}
}
return parsed.String(), nil
}
func validateSTUNServers(values []string, ipv6 bool, field string) error {
seen := make(map[string]struct{}, len(values))
for index, value := range values {
if err := validateSTUNServer(value, ipv6); err != nil {
return invalidInstanceConfig(
fmt.Sprintf("%s[%d]", field, index),
err.Error(),
)
}
if _, exists := seen[value]; exists {
return invalidInstanceConfig(
fmt.Sprintf("%s[%d]", field, index),
"duplicates another STUN server",
)
}
seen[value] = struct{}{}
}
return nil
}
func validateSTUNServer(value string, ipv6 bool) error {
if !utf8.ValidString(value) {
return fmt.Errorf("must be valid UTF-8")
}
if value == "" || strings.TrimSpace(value) != value {
return fmt.Errorf("must be non-empty without surrounding whitespace")
}
if domain, found := strings.CutPrefix(value, "txt:"); found {
if !validSTUNHostname(domain) {
return fmt.Errorf("has an invalid TXT lookup name")
}
return nil
}
if address, err := netip.ParseAddr(value); err == nil {
return validateSTUNAddressFamily(address, ipv6)
}
if address, err := netip.ParseAddrPort(value); err == nil {
if address.Port() == 0 {
return fmt.Errorf("port must not be zero")
}
return validateSTUNAddressFamily(address.Addr(), ipv6)
}
parsed, err := url.Parse("stun://" + value)
if err != nil ||
parsed.Host == "" ||
parsed.User != nil ||
parsed.Path != "" ||
parsed.RawQuery != "" ||
parsed.Fragment != "" {
return fmt.Errorf("must be a host, IP address, socket address, or txt: name")
}
host := parsed.Hostname()
if !validSTUNHostname(host) {
return fmt.Errorf("has an invalid host")
}
if strings.HasSuffix(parsed.Host, ":") {
return fmt.Errorf("has an invalid port")
}
if port := parsed.Port(); port != "" {
value, parseErr := strconv.ParseUint(port, 10, 16)
if parseErr != nil || value == 0 {
return fmt.Errorf("has an invalid port")
}
}
if address, err := netip.ParseAddr(host); err == nil {
return validateSTUNAddressFamily(address, ipv6)
}
return nil
}
func validSTUNHostname(value string) bool {
if value == "" || strings.ContainsAny(value, " \t\r\n/?#@[]:") {
return false
}
for _, character := range value {
if unicode.IsControl(character) {
return false
}
}
return true
}
func validateSTUNAddressFamily(address netip.Addr, ipv6 bool) error {
if ipv6 {
if address.Is6() && !address.Is4In6() {
return nil
}
return fmt.Errorf("literal address must be IPv6")
}
if address.Is4() {
return nil
}
return fmt.Errorf("literal address must be IPv4")
}
func invalidInstanceConfig(field string, message string) error {
if field == "" {
return fmt.Errorf("invalid EasyTier instance config: %s", message)
}
return fmt.Errorf("invalid EasyTier instance config %s: %s", field, message)
}
func (document instanceConfigDocument) clone() instanceConfigDocument {
cloned := document
cloned.peers = append([]string(nil), document.peers...)
cloned.listeners = append([]string(nil), document.listeners...)
cloned.portForwards = append([]PortForwardConfig(nil), document.portForwards...)
cloned.stunServers = append([]string(nil), document.stunServers...)
cloned.stunServersV6 = append([]string(nil), document.stunServersV6...)
cloned.securePrivateKey = append([]byte(nil), document.securePrivateKey...)
if document.hostname != nil {
value := *document.hostname
cloned.hostname = &value
}
if document.ipv4 != nil {
value := *document.ipv4
cloned.ipv4 = &value
}
if document.p2p != nil {
value := *document.p2p
cloned.p2p = &value
}
if document.holePunching != nil {
value := *document.holePunching
cloned.holePunching = &value
}
if document.encryption != nil {
value := *document.encryption
cloned.encryption = &value
}
return cloned
}
// Format prevents configuration secrets and private keys from being printed.
func (config InstanceConfig) Format(state fmt.State, _ rune) {
_, _ = state.Write([]byte("InstanceConfig{<redacted>}"))
}
// Format prevents configuration secrets and private keys from being printed.
func (builder InstanceConfigBuilder) Format(state fmt.State, _ rune) {
_, _ = state.Write([]byte("InstanceConfigBuilder{<redacted>}"))
}
@@ -0,0 +1,534 @@
package host
import (
"bytes"
"crypto/ecdh"
"encoding/base64"
"fmt"
"net/netip"
"strings"
"testing"
)
func TestInstanceConfigEncodesSupportedSettings(t *testing.T) {
privateKeyBytes := make([]byte, 32)
for index := range privateKeyBytes {
privateKeyBytes[index] = byte(index + 1)
}
privateKey, err := ecdh.X25519().NewPrivateKey(privateKeyBytes)
if err != nil {
t.Fatalf("create expected private key: %v", err)
}
config, err := NewInstanceConfigBuilder(`office "blue"`).
NetworkSecret("line1\nline2").
Hostname(`node "blue"`).
IPv4(netip.MustParsePrefix("10.144.0.10/24")).
AddPeers(
"TCP://peer.example.com:11010",
"udp://203.0.113.20",
).
AddListeners(
"tcp://0.0.0.0:11010",
"udp://[::]:0",
).
AddPortForwards(
PortForwardConfig{
Protocol: PortForwardTCP,
Bind: netip.MustParseAddrPort("127.0.0.1:5202"),
Destination: netip.MustParseAddrPort("10.144.0.20:5201"),
},
PortForwardConfig{
Protocol: PortForwardUDP,
Bind: netip.MustParseAddrPort("0.0.0.0:5203"),
Destination: netip.MustParseAddrPort("10.144.0.21:5201"),
},
).
STUNServers(
"stun.example.com",
"192.0.2.1:3478",
"txt:_stun.example.com",
).
STUNServersV6(
"2001:db8::1",
"[2001:db8::2]:3478",
).
P2P(P2PPolicy{Disable: true, Need: true, Lazy: true}).
HolePunching(HolePunchingPolicy{
TCP: true,
UDP: true,
SymmetricUDP: false,
}).
Encryption(false).
SecureModeWithPrivateKey(privateKeyBytes).
Build()
if err != nil {
t.Fatalf("build config: %v", err)
}
encoded, err := encodeInstanceConfig(config)
if err != nil {
t.Fatalf("encode config: %v", err)
}
want := fmt.Sprintf(`hostname = "node \"blue\""
ipv4 = "10.144.0.10/24"
listeners = ["tcp://0.0.0.0:11010", "udp://[::]:0"]
stun_servers = ["stun.example.com", "192.0.2.1:3478", "txt:_stun.example.com"]
stun_servers_v6 = ["2001:db8::1", "[2001:db8::2]:3478"]
[network_identity]
network_name = "office \"blue\""
network_secret = "line1\nline2"
[[peer]]
uri = "tcp://peer.example.com:11010"
[[peer]]
uri = "udp://203.0.113.20"
[[port_forward]]
bind_addr = "127.0.0.1:5202"
dst_addr = "10.144.0.20:5201"
proto = "tcp"
[[port_forward]]
bind_addr = "0.0.0.0:5203"
dst_addr = "10.144.0.21:5201"
proto = "udp"
[flags]
enable_encryption = false
disable_p2p = true
need_p2p = true
lazy_p2p = true
disable_tcp_hole_punching = false
disable_udp_hole_punching = false
disable_sym_hole_punching = true
[secure_mode]
enabled = true
local_private_key = %q
local_public_key = %q
`,
base64.StdEncoding.EncodeToString(privateKey.Bytes()),
base64.StdEncoding.EncodeToString(privateKey.PublicKey().Bytes()),
)
if encoded != want {
t.Fatalf("encoded config:\n%s\nwant:\n%s", encoded, want)
}
}
func TestInstanceConfigPreservesCoreDefaults(t *testing.T) {
config, err := NewInstanceConfigBuilder("default").
NetworkSecret("").
Build()
if err != nil {
t.Fatalf("build config: %v", err)
}
encoded, err := encodeInstanceConfig(config)
if err != nil {
t.Fatalf("encode config: %v", err)
}
want := `[network_identity]
network_name = "default"
network_secret = ""
`
if encoded != want {
t.Fatalf("encoded config:\n%s\nwant:\n%s", encoded, want)
}
}
func TestInstanceConfigCanExplicitlyDisableSTUNServers(t *testing.T) {
config, err := NewInstanceConfigBuilder("default").
NetworkSecret("").
STUNServers().
STUNServersV6().
Build()
if err != nil {
t.Fatalf("build config: %v", err)
}
encoded, err := encodeInstanceConfig(config)
if err != nil {
t.Fatalf("encode config: %v", err)
}
want := `stun_servers = []
stun_servers_v6 = []
[network_identity]
network_name = "default"
network_secret = ""
`
if encoded != want {
t.Fatalf("encoded config:\n%s\nwant:\n%s", encoded, want)
}
}
func TestInstanceConfigValidation(t *testing.T) {
invalidUTF8 := string([]byte{0xff})
tests := []struct {
name string
build func() error
wantField string
}{
{
name: "nil builder",
build: func() error {
var builder *InstanceConfigBuilder
_, err := builder.Build()
return err
},
wantField: "builder is nil",
},
{
name: "missing network name",
build: func() error {
_, err := NewInstanceConfigBuilder("").
NetworkSecret("").
Build()
return err
},
wantField: "network.name",
},
{
name: "invalid network name encoding",
build: func() error {
_, err := NewInstanceConfigBuilder(invalidUTF8).
NetworkSecret("").
Build()
return err
},
wantField: "network.name",
},
{
name: "network secret not specified",
build: func() error {
_, err := NewInstanceConfigBuilder("default").Build()
return err
},
wantField: "network.secret",
},
{
name: "invalid network secret encoding",
build: func() error {
_, err := NewInstanceConfigBuilder("default").
NetworkSecret(invalidUTF8).
Build()
return err
},
wantField: "network.secret",
},
{
name: "empty hostname",
build: func() error {
_, err := validInstanceConfigBuilder().Hostname("").Build()
return err
},
wantField: "hostname",
},
{
name: "hostname exceeds core limit",
build: func() error {
_, err := validInstanceConfigBuilder().
Hostname(strings.Repeat("a", 33)).
Build()
return err
},
wantField: "hostname",
},
{
name: "invalid IPv4 prefix",
build: func() error {
_, err := validInstanceConfigBuilder().
IPv4(netip.Prefix{}).
Build()
return err
},
wantField: "ipv4",
},
{
name: "IPv6 prefix",
build: func() error {
_, err := validInstanceConfigBuilder().
IPv4(netip.MustParsePrefix("fd00::1/64")).
Build()
return err
},
wantField: "ipv4",
},
{
name: "IPv4 host prefix",
build: func() error {
_, err := validInstanceConfigBuilder().
IPv4(netip.MustParsePrefix("10.0.0.1/32")).
Build()
return err
},
wantField: "ipv4",
},
{
name: "unsupported peer scheme",
build: func() error {
_, err := validInstanceConfigBuilder().
AddPeers("ws://peer.example.com:11010").
Build()
return err
},
wantField: "peers[0]",
},
{
name: "peer port zero",
build: func() error {
_, err := validInstanceConfigBuilder().
AddPeers("tcp://peer.example.com:0").
Build()
return err
},
wantField: "peers[0]",
},
{
name: "duplicate peer",
build: func() error {
_, err := validInstanceConfigBuilder().
AddPeers(
"TCP://peer.example.com:11010",
"tcp://peer.example.com:11010",
).
Build()
return err
},
wantField: "peers[1]",
},
{
name: "listener hostname",
build: func() error {
_, err := validInstanceConfigBuilder().
AddListeners("tcp://listener.example.com:11010").
Build()
return err
},
wantField: "listeners[0]",
},
{
name: "unsupported port forward protocol",
build: func() error {
_, err := validInstanceConfigBuilder().
AddPortForwards(PortForwardConfig{
Protocol: "sctp",
Bind: netip.MustParseAddrPort("127.0.0.1:5202"),
Destination: netip.MustParseAddrPort("10.144.0.20:5201"),
}).
Build()
return err
},
wantField: "port_forwards[0].protocol",
},
{
name: "IPv6 port forward bind",
build: func() error {
_, err := validInstanceConfigBuilder().
AddPortForwards(PortForwardConfig{
Protocol: PortForwardTCP,
Bind: netip.MustParseAddrPort("[::1]:5202"),
Destination: netip.MustParseAddrPort("10.144.0.20:5201"),
}).
Build()
return err
},
wantField: "port_forwards[0].bind",
},
{
name: "zero port forward destination port",
build: func() error {
_, err := validInstanceConfigBuilder().
AddPortForwards(PortForwardConfig{
Protocol: PortForwardUDP,
Bind: netip.MustParseAddrPort("127.0.0.1:5202"),
Destination: netip.MustParseAddrPort("10.144.0.20:0"),
}).
Build()
return err
},
wantField: "port_forwards[0].destination",
},
{
name: "duplicate port forward",
build: func() error {
forward := PortForwardConfig{
Protocol: PortForwardTCP,
Bind: netip.MustParseAddrPort("127.0.0.1:5202"),
Destination: netip.MustParseAddrPort("10.144.0.20:5201"),
}
_, err := validInstanceConfigBuilder().
AddPortForwards(forward, forward).
Build()
return err
},
wantField: "port_forwards[1]",
},
{
name: "IPv6 literal in IPv4 STUN list",
build: func() error {
_, err := validInstanceConfigBuilder().
STUNServers("2001:db8::1").
Build()
return err
},
wantField: "stun_servers[0]",
},
{
name: "IPv4 literal in IPv6 STUN list",
build: func() error {
_, err := validInstanceConfigBuilder().
STUNServersV6("192.0.2.1").
Build()
return err
},
wantField: "stun_servers_v6[0]",
},
{
name: "duplicate STUN server",
build: func() error {
_, err := validInstanceConfigBuilder().
STUNServers("stun.example.com", "stun.example.com").
Build()
return err
},
wantField: "stun_servers[1]",
},
{
name: "symmetric UDP without UDP",
build: func() error {
_, err := validInstanceConfigBuilder().
HolePunching(HolePunchingPolicy{SymmetricUDP: true}).
Build()
return err
},
wantField: "hole_punching.symmetric_udp",
},
{
name: "secure mode without shared secret",
build: func() error {
_, err := NewInstanceConfigBuilder("default").
NetworkSecret("").
SecureMode().
Build()
return err
},
wantField: "secure_mode",
},
{
name: "invalid manual private key",
build: func() error {
_, err := validInstanceConfigBuilder().
SecureModeWithPrivateKey(make([]byte, 31)).
Build()
return err
},
wantField: "secure_mode.private_key",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
err := test.build()
if err == nil || !strings.Contains(err.Error(), test.wantField) {
t.Fatalf("Build() error = %v, want containing %q", err, test.wantField)
}
})
}
}
func TestInstanceConfigCopiesInputsAndRedactsSecrets(t *testing.T) {
privateKey := bytes.Repeat([]byte{7}, 32)
peers := []string{"tcp://peer.example.com:11010"}
builder := NewInstanceConfigBuilder("private-network").
NetworkSecret("very-secret-value").
AddPeers(peers...).
SecureModeWithPrivateKey(privateKey)
config, err := builder.Build()
if err != nil {
t.Fatalf("build config: %v", err)
}
before, err := encodeInstanceConfig(config)
if err != nil {
t.Fatalf("encode config before input mutation: %v", err)
}
privateKey[0] = 9
peers[0] = "udp://other.example.com:11010"
builder.NetworkSecret("changed-secret").
AddPeers("tcp://another.example.com:11010")
after, err := encodeInstanceConfig(config)
if err != nil {
t.Fatalf("encode config after input mutation: %v", err)
}
if after != before {
t.Fatalf("built config changed after mutating inputs:\nbefore:\n%s\nafter:\n%s", before, after)
}
formatted := []string{
fmt.Sprintf("%v", config),
fmt.Sprintf("%+v", config),
fmt.Sprintf("%#v", config),
fmt.Sprintf("%v", builder),
fmt.Sprintf("%+v", *builder),
fmt.Sprintf("%#v", *builder),
}
for _, value := range formatted {
if strings.Contains(value, "very-secret-value") ||
strings.Contains(value, "changed-secret") {
t.Fatalf("formatted configuration exposed a secret: %s", value)
}
if !strings.Contains(value, "<redacted>") {
t.Fatalf("formatted configuration was not redacted: %s", value)
}
}
}
func TestAutomaticSecureModeProducesStableKeyPair(t *testing.T) {
builder := validInstanceConfigBuilder().SecureMode()
first, err := builder.Build()
if err != nil {
t.Fatalf("build first config: %v", err)
}
second, err := builder.Build()
if err != nil {
t.Fatalf("build second config: %v", err)
}
if !bytes.Equal(
first.document.securePrivateKey,
second.document.securePrivateKey,
) {
t.Fatal("automatic secure-mode key changed between builds")
}
privateKey, err := ecdh.X25519().NewPrivateKey(first.document.securePrivateKey)
if err != nil {
t.Fatalf("parse generated private key: %v", err)
}
encoded, err := encodeInstanceConfig(first)
if err != nil {
t.Fatalf("encode config: %v", err)
}
privateKeyText := base64.StdEncoding.EncodeToString(privateKey.Bytes())
publicKeyText := base64.StdEncoding.EncodeToString(privateKey.PublicKey().Bytes())
if !strings.Contains(encoded, `local_private_key = "`+privateKeyText+`"`) {
t.Fatal("encoded config does not contain generated private key")
}
if !strings.Contains(encoded, `local_public_key = "`+publicKeyText+`"`) {
t.Fatal("encoded config does not contain derived public key")
}
}
func TestZeroInstanceConfigIsInvalid(t *testing.T) {
if _, err := encodeInstanceConfig(InstanceConfig{}); err == nil {
t.Fatal("zero InstanceConfig was accepted")
}
}
func validInstanceConfigBuilder() *InstanceConfigBuilder {
return NewInstanceConfigBuilder("default").NetworkSecret("secret")
}
@@ -0,0 +1,142 @@
package host
import (
"crypto/ecdh"
"encoding/base64"
"encoding/json"
"fmt"
"strings"
)
func encodeInstanceConfig(config InstanceConfig) (string, error) {
if config.document == nil {
return "", invalidInstanceConfig("", "configuration was not built")
}
document := config.document
var encoded strings.Builder
if document.hostname != nil {
writeTOMLStringField(&encoded, "hostname", *document.hostname)
}
if document.ipv4 != nil {
writeTOMLStringField(&encoded, "ipv4", document.ipv4.String())
}
if len(document.listeners) != 0 {
writeTOMLStringArrayField(&encoded, "listeners", document.listeners)
}
if document.stunServersSet {
writeTOMLStringArrayField(&encoded, "stun_servers", document.stunServers)
}
if document.stunServersV6Set {
writeTOMLStringArrayField(&encoded, "stun_servers_v6", document.stunServersV6)
}
if encoded.Len() != 0 {
encoded.WriteByte('\n')
}
encoded.WriteString("[network_identity]\n")
writeTOMLStringField(&encoded, "network_name", document.networkName)
writeTOMLStringField(&encoded, "network_secret", document.networkSecret)
for _, peer := range document.peers {
encoded.WriteString("\n[[peer]]\n")
writeTOMLStringField(&encoded, "uri", peer)
}
for _, forward := range document.portForwards {
encoded.WriteString("\n[[port_forward]]\n")
writeTOMLStringField(&encoded, "bind_addr", forward.Bind.String())
writeTOMLStringField(
&encoded,
"dst_addr",
forward.Destination.String(),
)
writeTOMLStringField(&encoded, "proto", string(forward.Protocol))
}
if document.encryption != nil ||
document.p2p != nil ||
document.holePunching != nil {
encoded.WriteString("\n[flags]\n")
if document.encryption != nil {
writeTOMLBoolField(&encoded, "enable_encryption", *document.encryption)
}
if document.p2p != nil {
writeTOMLBoolField(&encoded, "disable_p2p", document.p2p.Disable)
writeTOMLBoolField(&encoded, "need_p2p", document.p2p.Need)
writeTOMLBoolField(&encoded, "lazy_p2p", document.p2p.Lazy)
}
if document.holePunching != nil {
writeTOMLBoolField(
&encoded,
"disable_tcp_hole_punching",
!document.holePunching.TCP,
)
writeTOMLBoolField(
&encoded,
"disable_udp_hole_punching",
!document.holePunching.UDP,
)
writeTOMLBoolField(
&encoded,
"disable_sym_hole_punching",
!document.holePunching.SymmetricUDP,
)
}
}
if document.secureMode != secureModeDisabled {
privateKey, err := ecdh.X25519().NewPrivateKey(document.securePrivateKey)
if err != nil {
return "", invalidInstanceConfig(
"secure_mode.private_key",
"is not a valid X25519 private key",
)
}
encoded.WriteString("\n[secure_mode]\n")
writeTOMLBoolField(&encoded, "enabled", true)
writeTOMLStringField(
&encoded,
"local_private_key",
base64.StdEncoding.EncodeToString(privateKey.Bytes()),
)
writeTOMLStringField(
&encoded,
"local_public_key",
base64.StdEncoding.EncodeToString(privateKey.PublicKey().Bytes()),
)
}
return encoded.String(), nil
}
func writeTOMLStringField(encoded *strings.Builder, name string, value string) {
fmt.Fprintf(encoded, "%s = %s\n", name, quoteTOMLString(value))
}
func writeTOMLBoolField(encoded *strings.Builder, name string, value bool) {
fmt.Fprintf(encoded, "%s = %t\n", name, value)
}
func writeTOMLStringArrayField(
encoded *strings.Builder,
name string,
values []string,
) {
fmt.Fprintf(encoded, "%s = [", name)
for index, value := range values {
if index != 0 {
encoded.WriteString(", ")
}
encoded.WriteString(quoteTOMLString(value))
}
encoded.WriteString("]\n")
}
func quoteTOMLString(value string) string {
encoded, err := json.Marshal(value)
if err != nil {
panic("encoding a validated Go string as JSON cannot fail")
}
return string(encoded)
}
+774
View File
@@ -0,0 +1,774 @@
package host
import (
"context"
"crypto/rand"
"encoding/binary"
"errors"
"fmt"
"sort"
"strings"
"sync"
"time"
"github.com/EasyTier/EasyTier/easytier-go/internal/contextutil"
apiconfig "github.com/EasyTier/EasyTier/easytier-go/proto/api/config"
apiinstance "github.com/EasyTier/EasyTier/easytier-go/proto/api/instance"
"github.com/EasyTier/EasyTier/easytier-go/proto/api/manage"
"github.com/EasyTier/EasyTier/easytier-go/proto/common"
errorpb "github.com/EasyTier/EasyTier/easytier-go/proto/error"
"google.golang.org/protobuf/proto"
)
const (
runNetworkInstanceMethod = "api.manage.WebClientService.RunNetworkInstance"
retainNetworkInstanceMethod = "api.manage.WebClientService.RetainNetworkInstance"
collectNetworkInfoMethod = "api.manage.WebClientService.CollectNetworkInfo"
listNetworkInstanceMethod = "api.manage.WebClientService.ListNetworkInstance"
deleteNetworkInstanceMethod = "api.manage.WebClientService.DeleteNetworkInstance"
getNetworkInstanceConfigMethod = "api.manage.WebClientService.GetNetworkInstanceConfig"
listNetworkInstanceMetaMethod = "api.manage.WebClientService.ListNetworkInstanceMeta"
patchConfigMethod = "api.config.ConfigRpc.PatchConfig"
getConfigMethod = "api.config.ConfigRpc.GetConfig"
)
type instanceOwner uint8
const (
instanceOwnerApplication instanceOwner = iota
instanceOwnerWeb
)
type managedInstance struct {
id *common.UUID
instance *Instance
owner instanceOwner
config *manage.NetworkConfig
configTOML string
source manage.ConfigSource
name string
}
type instanceManager struct {
mu sync.RWMutex
mutations sync.Mutex
host *Host
instances map[string]*managedInstance
}
func newInstanceManager() *instanceManager {
return &instanceManager{instances: make(map[string]*managedInstance)}
}
func (manager *instanceManager) register(entry *managedInstance) error {
id := uuidString(entry.id)
manager.mu.Lock()
defer manager.mu.Unlock()
if _, exists := manager.instances[id]; exists {
return fmt.Errorf("EasyTier instance %s already exists", id)
}
manager.instances[id] = entry
return nil
}
func (manager *instanceManager) remove(instance *Instance) {
manager.mu.Lock()
if entry := manager.instances[instance.id]; entry != nil &&
entry.instance == instance {
delete(manager.instances, instance.id)
}
manager.mu.Unlock()
}
func (manager *instanceManager) clear() {
manager.mu.Lock()
manager.instances = make(map[string]*managedInstance)
manager.mu.Unlock()
}
func (manager *instanceManager) snapshot() []*managedInstance {
manager.mu.RLock()
entries := make([]*managedInstance, 0, len(manager.instances))
for _, entry := range manager.instances {
snapshot := *entry
entries = append(entries, &snapshot)
}
manager.mu.RUnlock()
sort.Slice(entries, func(i, j int) bool {
return uuidString(entries[i].id) < uuidString(entries[j].id)
})
return entries
}
func (manager *instanceManager) handle(
ctx context.Context,
encoded []byte,
) []byte {
started := time.Now()
var envelope common.HostManagementRequest
if err := proto.Unmarshal(encoded, &envelope); err != nil {
return encodeManagementResponse(nil, err, started)
}
if envelope.Rpc == nil {
return encodeManagementResponse(
nil,
fmt.Errorf("host management RPC is required"),
started,
)
}
response, err := manager.dispatch(
ctx,
envelope.Rpc.FullMethodName,
envelope.Rpc.Request,
envelope.GetPreparedConfig(),
envelope.GetPreparedInstanceId(),
)
return encodeManagementResponse(response, err, started)
}
func (manager *instanceManager) dispatch(
ctx context.Context,
method string,
encoded []byte,
preparedConfig string,
preparedInstanceID *common.UUID,
) (proto.Message, error) {
switch method {
case runNetworkInstanceMethod:
request := new(manage.RunNetworkInstanceRequest)
if err := proto.Unmarshal(encoded, request); err != nil {
return nil, err
}
return manager.runNetworkInstance(
ctx,
request,
preparedConfig,
preparedInstanceID,
)
case retainNetworkInstanceMethod:
request := new(manage.RetainNetworkInstanceRequest)
if err := proto.Unmarshal(encoded, request); err != nil {
return nil, err
}
return manager.retainNetworkInstances(ctx, request)
case collectNetworkInfoMethod:
request := new(manage.CollectNetworkInfoRequest)
if err := proto.Unmarshal(encoded, request); err != nil {
return nil, err
}
return manager.collectNetworkInfo(ctx, request)
case listNetworkInstanceMethod:
request := new(manage.ListNetworkInstanceRequest)
if err := proto.Unmarshal(encoded, request); err != nil {
return nil, err
}
return manager.listNetworkInstances(), nil
case deleteNetworkInstanceMethod:
request := new(manage.DeleteNetworkInstanceRequest)
if err := proto.Unmarshal(encoded, request); err != nil {
return nil, err
}
return manager.deleteNetworkInstances(ctx, request)
case getNetworkInstanceConfigMethod:
request := new(manage.GetNetworkInstanceConfigRequest)
if err := proto.Unmarshal(encoded, request); err != nil {
return nil, err
}
return manager.getNetworkInstanceConfig(request)
case listNetworkInstanceMetaMethod:
request := new(manage.ListNetworkInstanceMetaRequest)
if err := proto.Unmarshal(encoded, request); err != nil {
return nil, err
}
return manager.listNetworkInstanceMeta(request), nil
case patchConfigMethod:
request := new(apiconfig.PatchConfigRequest)
if err := proto.Unmarshal(encoded, request); err != nil {
return nil, err
}
return manager.patchConfig(ctx, request)
case getConfigMethod:
request := new(apiconfig.GetConfigRequest)
if err := proto.Unmarshal(encoded, request); err != nil {
return nil, err
}
return manager.getConfig(request)
default:
return nil, fmt.Errorf("unsupported host management method %q", method)
}
}
func (manager *instanceManager) runNetworkInstance(
ctx context.Context,
request *manage.RunNetworkInstanceRequest,
preparedConfig string,
preparedInstanceID *common.UUID,
) (*manage.RunNetworkInstanceResponse, error) {
if request.Config == nil {
return nil, fmt.Errorf("config is required")
}
if preparedConfig == "" {
return nil, fmt.Errorf("prepared EasyTier config is required")
}
if preparedInstanceID == nil {
return nil, fmt.Errorf("prepared EasyTier instance ID is required")
}
request.InstId = cloneUUID(preparedInstanceID)
id := uuidString(preparedInstanceID)
request.Config.InstanceId = &id
manager.mutations.Lock()
defer manager.mutations.Unlock()
manager.mu.RLock()
previous := manager.instances[id]
manager.mu.RUnlock()
if previous != nil {
if previous.owner != instanceOwnerWeb {
return nil, fmt.Errorf("configuration for instance %s is read-only", id)
}
if !request.Overwrite && previous.instance.engine.TerminalError() == nil {
return &manage.RunNetworkInstanceResponse{
InstId: cloneUUID(request.InstId),
}, nil
}
if err := closeManagedInstance(ctx, previous.instance); err != nil {
return nil, fmt.Errorf("replace EasyTier instance %s: %w", id, err)
}
}
source := request.Source
if source == manage.ConfigSource_ConfigSourceUnspecified && previous != nil {
source = previous.source
}
entry, err := manager.createWebInstance(
ctx,
request.InstId,
request.Config,
source,
preparedConfig,
)
if err != nil && previous != nil {
restoreContext, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_, restoreErr := manager.createWebInstance(
restoreContext,
previous.id,
previous.config,
previous.source,
previous.configTOML,
)
err = errors.Join(err, restoreErr)
}
if err != nil {
return nil, err
}
return &manage.RunNetworkInstanceResponse{InstId: cloneUUID(entry.id)}, nil
}
func (manager *instanceManager) createWebInstance(
ctx context.Context,
id *common.UUID,
config *manage.NetworkConfig,
source manage.ConfigSource,
configTOML string,
) (*managedInstance, error) {
runtime, err := manager.host.engine.CreateInstance(ctx, configTOML)
if err != nil {
return nil, err
}
instance := &Instance{
engine: runtime,
id: uuidString(id),
manager: manager,
}
if err := runtime.Start(ctx); err != nil {
_ = runtime.Close(contextutil.WithoutCancel(ctx))
return nil, err
}
if source == manage.ConfigSource_ConfigSourceUnspecified {
source = manage.ConfigSource_ConfigSourceUser
}
entry := &managedInstance{
id: cloneUUID(id),
instance: instance,
owner: instanceOwnerWeb,
config: proto.Clone(config).(*manage.NetworkConfig),
configTOML: configTOML,
source: source,
name: config.GetNetworkName(),
}
if err := manager.register(entry); err != nil {
_ = runtime.Close(contextutil.WithoutCancel(ctx))
return nil, err
}
return entry, nil
}
func (manager *instanceManager) patchConfig(
ctx context.Context,
request *apiconfig.PatchConfigRequest,
) (*apiconfig.PatchConfigResponse, error) {
id, err := selectedInstanceID(request.Instance)
if err != nil {
return nil, err
}
key := uuidString(id)
manager.mutations.Lock()
defer manager.mutations.Unlock()
manager.mu.RLock()
entry := manager.instances[key]
manager.mu.RUnlock()
if entry == nil {
return nil, fmt.Errorf("EasyTier instance %s not found", key)
}
if entry.owner != instanceOwnerWeb {
return nil, fmt.Errorf("configuration for instance %s is read-only", key)
}
if request.Patch == nil {
return new(apiconfig.PatchConfigResponse), nil
}
response := new(apiconfig.PatchConfigResponse)
patchErr := entry.instance.callRPCRequest(
ctx,
patchConfigMethod,
&apiconfig.PatchConfigRequest{
Patch: hostedConfigPatch(request.Patch),
Instance: request.Instance,
},
response,
)
snapshotContext, cancel := context.WithTimeout(
contextutil.WithoutCancel(ctx),
10*time.Second,
)
defer cancel()
effective := new(apiconfig.GetConfigResponse)
if err := entry.instance.callRPCRequest(
snapshotContext,
getConfigMethod,
&apiconfig.GetConfigRequest{Instance: request.Instance},
effective,
); err != nil {
return nil, errors.Join(
patchErr,
fmt.Errorf("read effective EasyTier configuration: %w", err),
)
}
if effective.Config == nil || effective.TomlConfig == "" {
return nil, errors.Join(
patchErr,
fmt.Errorf("EasyTier instance %s returned an incomplete config snapshot", key),
)
}
manager.mu.Lock()
if manager.instances[key] != entry {
manager.mu.Unlock()
return nil, errors.Join(
patchErr,
fmt.Errorf("EasyTier instance %s is no longer running", key),
)
}
entry.config = mergeHostedConfigPatch(entry.config, effective.Config, request.Patch)
entry.configTOML = effective.TomlConfig
manager.mu.Unlock()
if patchErr != nil {
return nil, patchErr
}
return response, nil
}
func (manager *instanceManager) getConfig(
request *apiconfig.GetConfigRequest,
) (*apiconfig.GetConfigResponse, error) {
id, err := selectedInstanceID(request.Instance)
if err != nil {
return nil, err
}
response, err := manager.getNetworkInstanceConfig(
&manage.GetNetworkInstanceConfigRequest{InstId: id},
)
if err != nil {
return nil, err
}
return &apiconfig.GetConfigResponse{Config: response.Config}, nil
}
func selectedInstanceID(
identifier *apiinstance.InstanceIdentifier,
) (*common.UUID, error) {
id := identifier.GetId()
if id == nil {
return nil, fmt.Errorf("instance ID is required")
}
return id, nil
}
func hostedConfigPatch(
patch *apiconfig.InstanceConfigPatch,
) *apiconfig.InstanceConfigPatch {
return &apiconfig.InstanceConfigPatch{
PortForwards: patch.PortForwards,
Acl: patch.Acl,
ProxyNetworks: patch.ProxyNetworks,
DisableRelayData: patch.DisableRelayData,
}
}
func mergeHostedConfigPatch(
desired *manage.NetworkConfig,
effective *manage.NetworkConfig,
patch *apiconfig.InstanceConfigPatch,
) *manage.NetworkConfig {
merged := proto.Clone(desired).(*manage.NetworkConfig)
runtime := proto.Clone(effective).(*manage.NetworkConfig)
if len(patch.PortForwards) != 0 {
merged.PortForwards = runtime.PortForwards
}
if patch.Acl != nil {
merged.Acl = runtime.Acl
}
if len(patch.ProxyNetworks) != 0 {
merged.ProxyCidrs = runtime.ProxyCidrs
}
if patch.DisableRelayData != nil {
merged.DisableRelayData = runtime.DisableRelayData
}
return merged
}
func (manager *instanceManager) retainNetworkInstances(
ctx context.Context,
request *manage.RetainNetworkInstanceRequest,
) (*manage.RetainNetworkInstanceResponse, error) {
retained := uuidSet(request.InstIds)
manager.mutations.Lock()
defer manager.mutations.Unlock()
if err := manager.closeWebInstances(ctx, func(id string) bool {
_, keep := retained[id]
return !keep
}); err != nil {
return nil, err
}
return &manage.RetainNetworkInstanceResponse{
RemainInstIds: manager.instanceIDs(),
}, nil
}
func (manager *instanceManager) deleteNetworkInstances(
ctx context.Context,
request *manage.DeleteNetworkInstanceRequest,
) (*manage.DeleteNetworkInstanceResponse, error) {
deleted := uuidSet(request.InstIds)
manager.mutations.Lock()
defer manager.mutations.Unlock()
if err := manager.closeWebInstances(ctx, func(id string) bool {
_, remove := deleted[id]
return remove
}); err != nil {
return nil, err
}
return &manage.DeleteNetworkInstanceResponse{
RemainInstIds: manager.instanceIDs(),
}, nil
}
func (manager *instanceManager) closeWebInstances(
ctx context.Context,
shouldClose func(string) bool,
) error {
for _, entry := range manager.snapshot() {
if entry.owner != instanceOwnerWeb ||
!shouldClose(uuidString(entry.id)) {
continue
}
if err := closeManagedInstance(ctx, entry.instance); err != nil {
return err
}
}
return nil
}
func closeManagedInstance(ctx context.Context, instance *Instance) error {
alreadyFailed := instance.engine.TerminalError() != nil
err := instance.Close(ctx)
if alreadyFailed && ctx.Err() == nil {
return nil
}
return err
}
func (manager *instanceManager) listNetworkInstances() *manage.ListNetworkInstanceResponse {
return &manage.ListNetworkInstanceResponse{InstIds: manager.instanceIDs()}
}
func (manager *instanceManager) instanceIDs() []*common.UUID {
entries := manager.snapshot()
ids := make([]*common.UUID, len(entries))
for index, entry := range entries {
ids[index] = cloneUUID(entry.id)
}
return ids
}
func (manager *instanceManager) collectNetworkInfo(
ctx context.Context,
request *manage.CollectNetworkInfoRequest,
) (*manage.CollectNetworkInfoResponse, error) {
included := uuidSet(request.InstIds)
info := make(map[string]*manage.NetworkInstanceRunningInfo)
for _, entry := range manager.snapshot() {
id := uuidString(entry.id)
if len(included) != 0 {
if _, exists := included[id]; !exists {
continue
}
}
snapshot, err := entry.runningInfo(ctx)
if err != nil {
return nil, err
}
info[id] = snapshot
}
return &manage.CollectNetworkInfoResponse{
Info: &manage.NetworkInstanceRunningInfoMap{Map: info},
}, nil
}
func (entry *managedInstance) runningInfo(
ctx context.Context,
) (*manage.NetworkInstanceRunningInfo, error) {
state := entry.instance.State()
running := state != StateCreated && state != StateStopped
info := &manage.NetworkInstanceRunningInfo{
Running: running,
Events: entry.instance.engine.ManagementEvents(),
}
if err := entry.instance.engine.TerminalError(); err != nil {
message := err.Error()
info.ErrorMsg = &message
}
if state != StateRunning {
return info, nil
}
peers, err := entry.instance.listPeerResponse(ctx)
if err != nil {
return nil, err
}
routes, err := entry.instance.listRouteResponse(ctx)
if err != nil {
return nil, err
}
nodeResponse, err := entry.instance.showNodeInfo(ctx)
if err != nil {
return nil, err
}
foreign, err := entry.instance.foreignNetworkSummary(ctx)
if err != nil {
return nil, err
}
node := nodeResponse.NodeInfo
listeners := make([]*common.Url, len(node.GetListeners()))
for index, listener := range node.GetListeners() {
listeners[index] = &common.Url{Url: listener}
}
var virtualIPv4 *common.Ipv4Inet
for _, route := range routes.Routes {
if route.PeerId == node.GetPeerId() {
virtualIPv4 = route.Ipv4Addr
break
}
}
info.DevName = entry.config.GetDevName()
info.MyNodeInfo = &manage.MyNodeInfo{
VirtualIpv4: virtualIPv4,
Hostname: node.GetHostname(),
Version: node.GetVersion(),
Ips: node.GetIpList(),
StunInfo: node.GetStunInfo(),
Listeners: listeners,
PeerId: node.GetPeerId(),
}
info.Routes = routes.Routes
info.Peers = peers.PeerInfos
info.PeerRoutePairs = peerRoutePairs(peers.PeerInfos, routes.Routes)
info.ForeignNetworkSummary = foreign.Summary
return info, nil
}
func (manager *instanceManager) getNetworkInstanceConfig(
request *manage.GetNetworkInstanceConfigRequest,
) (*manage.GetNetworkInstanceConfigResponse, error) {
if request.InstId == nil {
return nil, fmt.Errorf("instance ID is required")
}
id := uuidString(request.InstId)
manager.mu.RLock()
defer manager.mu.RUnlock()
entry := manager.instances[id]
if entry == nil {
return nil, fmt.Errorf("EasyTier instance %s not found", id)
}
if entry.owner != instanceOwnerWeb {
return nil, fmt.Errorf("configuration for instance %s is read-only", id)
}
config := proto.Clone(entry.config).(*manage.NetworkConfig)
return &manage.GetNetworkInstanceConfigResponse{
Config: config,
Source: entry.source,
}, nil
}
func (manager *instanceManager) listNetworkInstanceMeta(
request *manage.ListNetworkInstanceMetaRequest,
) *manage.ListNetworkInstanceMetaResponse {
included := uuidSet(request.InstIds)
response := &manage.ListNetworkInstanceMetaResponse{}
for _, entry := range manager.snapshot() {
if _, exists := included[uuidString(entry.id)]; !exists {
continue
}
permission := uint32(0)
if entry.owner == instanceOwnerApplication {
permission = 3
}
networkName := entry.name
if entry.config != nil {
networkName = entry.config.GetNetworkName()
}
response.Metas = append(response.Metas, &manage.NetworkMeta{
InstId: cloneUUID(entry.id),
NetworkName: networkName,
ConfigPermission: permission,
InstanceName: entry.name,
Source: entry.source,
})
}
return response
}
func encodeManagementResponse(
response proto.Message,
responseErr error,
started time.Time,
) []byte {
runtimeUs := uint64(time.Since(started).Microseconds())
if runtimeUs == 0 {
runtimeUs = 1
}
envelope := &common.RpcResponse{
RuntimeUs: runtimeUs,
}
if responseErr == nil {
envelope.Response, responseErr = proto.Marshal(response)
}
if responseErr != nil {
envelope.Error = &errorpb.Error{
ErrorKind: &errorpb.Error_ExecuteError{
ExecuteError: &errorpb.ExecuteError{
ErrorMessage: responseErr.Error(),
},
},
}
}
encoded, err := proto.Marshal(envelope)
if err != nil {
panic("marshal EasyTier RPC response: " + err.Error())
}
return encoded
}
func peerRoutePairs(
peers []*apiinstance.PeerInfo,
routes []*apiinstance.Route,
) []*apiinstance.PeerRoutePair {
byID := make(map[uint32]*apiinstance.PeerInfo, len(peers))
for _, peer := range peers {
byID[peer.PeerId] = peer
}
pairs := make([]*apiinstance.PeerRoutePair, 0, len(routes))
for _, route := range routes {
if peer := byID[route.PeerId]; peer != nil {
pairs = append(pairs, &apiinstance.PeerRoutePair{
Route: route,
Peer: peer,
})
}
}
sort.Slice(pairs, func(i, j int) bool {
left, right := pairs[i].Route, pairs[j].Route
leftPublic := left.GetFeatureFlag().GetIsPublicServer()
rightPublic := right.GetFeatureFlag().GetIsPublicServer()
if leftPublic != rightPublic {
return leftPublic
}
return left.GetIpv4Addr().GetAddress().GetAddr() <
right.GetIpv4Addr().GetAddress().GetAddr()
})
return pairs
}
func newInstanceUUID() (*common.UUID, string, error) {
var value [16]byte
if _, err := rand.Read(value[:]); err != nil {
return nil, "", fmt.Errorf("generate EasyTier instance ID: %w", err)
}
value[6] = value[6]&0x0f | 0x40
value[8] = value[8]&0x3f | 0x80
id := &common.UUID{
Part1: binary.BigEndian.Uint32(value[0:4]),
Part2: binary.BigEndian.Uint32(value[4:8]),
Part3: binary.BigEndian.Uint32(value[8:12]),
Part4: binary.BigEndian.Uint32(value[12:16]),
}
return id, uuidString(id), nil
}
func uuidString(id *common.UUID) string {
var value [16]byte
binary.BigEndian.PutUint32(value[0:4], id.GetPart1())
binary.BigEndian.PutUint32(value[4:8], id.GetPart2())
binary.BigEndian.PutUint32(value[8:12], id.GetPart3())
binary.BigEndian.PutUint32(value[12:16], id.GetPart4())
return fmt.Sprintf(
"%x-%x-%x-%x-%x",
value[0:4],
value[4:6],
value[6:8],
value[8:10],
value[10:16],
)
}
func cloneUUID(id *common.UUID) *common.UUID {
if id == nil {
return nil
}
return &common.UUID{
Part1: id.Part1,
Part2: id.Part2,
Part3: id.Part3,
Part4: id.Part4,
}
}
func uuidSet(ids []*common.UUID) map[string]struct{} {
set := make(map[string]struct{}, len(ids))
for _, id := range ids {
if id != nil {
set[uuidString(id)] = struct{}{}
}
}
return set
}
func bindInstanceIdentity(config, id, name string) string {
var encoded strings.Builder
writeTOMLStringField(&encoded, "instance_id", id)
writeTOMLStringField(&encoded, "instance_name", name)
encoded.WriteByte('\n')
encoded.WriteString(config)
return encoded.String()
}
@@ -0,0 +1,348 @@
package host
import (
"bytes"
"context"
"fmt"
"net/netip"
"testing"
"time"
apiconfig "github.com/EasyTier/EasyTier/easytier-go/proto/api/config"
apiinstance "github.com/EasyTier/EasyTier/easytier-go/proto/api/instance"
"github.com/EasyTier/EasyTier/easytier-go/proto/api/manage"
"github.com/EasyTier/EasyTier/easytier-go/proto/common"
"google.golang.org/protobuf/encoding/protowire"
"google.golang.org/protobuf/proto"
)
func TestApplicationInstanceIsReadOnlyToWebManagement(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
host, err := New(ctx, Options{})
if err != nil {
t.Fatalf("create host: %v", err)
}
defer host.Close(ctx)
instance, err := host.CreateInstance(ctx, managementTestConfig(t, "application"))
if err != nil {
t.Fatalf("create application instance: %v", err)
}
if instance.ID() == "" {
t.Fatal("application instance ID is empty")
}
if got := host.Instances(); len(got) != 1 || got[0] != instance {
t.Fatalf("host instances = %v, want application instance", got)
}
entry := host.manager.snapshot()[0]
meta := host.manager.listNetworkInstanceMeta(
&manage.ListNetworkInstanceMetaRequest{
InstIds: []*common.UUID{entry.id},
},
)
if len(meta.Metas) != 1 || meta.Metas[0].ConfigPermission != 3 {
t.Fatalf("application metadata = %v, want read-only/no-delete", meta.Metas)
}
if _, err := host.manager.getNetworkInstanceConfig(
&manage.GetNetworkInstanceConfigRequest{InstId: entry.id},
); err == nil {
t.Fatal("application config was writable through Web management")
}
response, err := host.manager.deleteNetworkInstances(
ctx,
&manage.DeleteNetworkInstanceRequest{
InstIds: []*common.UUID{entry.id},
},
)
if err != nil {
t.Fatalf("delete application instance through Web management: %v", err)
}
if len(response.RemainInstIds) != 1 {
t.Fatalf("remaining IDs = %v, want application instance", response.RemainInstIds)
}
if err := instance.Close(ctx); err != nil {
t.Fatalf("close application instance: %v", err)
}
if len(host.Instances()) != 0 {
t.Fatal("closed application instance remained registered")
}
}
func TestWebManagementRunsCollectsAndDeletesInstance(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
host, err := New(ctx, Options{})
if err != nil {
t.Fatalf("create host: %v", err)
}
defer host.Close(ctx)
id := &common.UUID{Part1: 1, Part2: 2, Part3: 3, Part4: 4}
idString := uuidString(id)
config := managementTestConfig(t, "managed")
configTOML, err := encodeInstanceConfig(config)
if err != nil {
t.Fatalf("encode managed config: %v", err)
}
networkName := "managed"
networkConfig := &manage.NetworkConfig{
InstanceId: &idString,
NetworkName: &networkName,
}
unknownConfig := protowire.AppendTag(nil, 1000, protowire.BytesType)
unknownConfig = protowire.AppendString(unknownConfig, "future-config")
networkConfig.ProtoReflect().SetUnknown(unknownConfig)
run := callManagement(
t,
host,
ctx,
runNetworkInstanceMethod,
&manage.RunNetworkInstanceRequest{
InstId: id,
Config: networkConfig,
Source: manage.ConfigSource_ConfigSourceWeb,
},
bindInstanceIdentity(configTOML, idString, networkName),
new(manage.RunNetworkInstanceResponse),
)
if run.(*manage.RunNetworkInstanceResponse).GetInstId() == nil {
t.Fatal("run response omitted instance ID")
}
if instances := host.Instances(); len(instances) != 1 ||
instances[0].ID() != idString {
t.Fatalf("host instances = %v, want managed instance %s", instances, idString)
}
listed := callManagement(
t,
host,
ctx,
listNetworkInstanceMethod,
new(manage.ListNetworkInstanceRequest),
"",
new(manage.ListNetworkInstanceResponse),
).(*manage.ListNetworkInstanceResponse)
if len(listed.InstIds) != 1 {
t.Fatalf("listed IDs = %v, want managed instance", listed.InstIds)
}
metas := callManagement(
t,
host,
ctx,
listNetworkInstanceMetaMethod,
&manage.ListNetworkInstanceMetaRequest{
InstIds: []*common.UUID{id},
},
"",
new(manage.ListNetworkInstanceMetaResponse),
).(*manage.ListNetworkInstanceMetaResponse)
if len(metas.Metas) != 1 || metas.Metas[0].ConfigPermission != 0 {
t.Fatalf("managed metadata = %v, want writable instance", metas.Metas)
}
retained := callManagement(
t,
host,
ctx,
retainNetworkInstanceMethod,
&manage.RetainNetworkInstanceRequest{
InstIds: []*common.UUID{id},
},
"",
new(manage.RetainNetworkInstanceResponse),
).(*manage.RetainNetworkInstanceResponse)
if len(retained.RemainInstIds) != 1 {
t.Fatalf("retained IDs = %v, want managed instance", retained.RemainInstIds)
}
collected := callManagement(
t,
host,
ctx,
collectNetworkInfoMethod,
&manage.CollectNetworkInfoRequest{InstIds: []*common.UUID{id}},
"",
new(manage.CollectNetworkInfoResponse),
).(*manage.CollectNetworkInfoResponse)
info := collected.GetInfo().GetMap()[idString]
if info == nil || !info.Running || info.MyNodeInfo == nil {
t.Fatalf("managed instance status = %v, want running node info", info)
}
if info.MyNodeInfo.Hostname != "managed-host" ||
info.MyNodeInfo.PeerId == 0 {
t.Fatalf("managed node info = %v, want live node identity", info.MyNodeInfo)
}
managedConfig := callManagement(
t,
host,
ctx,
getNetworkInstanceConfigMethod,
&manage.GetNetworkInstanceConfigRequest{InstId: id},
"",
new(manage.GetNetworkInstanceConfigResponse),
).(*manage.GetNetworkInstanceConfigResponse)
if managedConfig.GetConfig().GetNetworkName() != networkName ||
managedConfig.Source != manage.ConfigSource_ConfigSourceWeb {
t.Fatalf("managed config response = %v", managedConfig)
}
if unknown := managedConfig.Config.ProtoReflect().GetUnknown(); !bytes.Equal(
unknown,
unknownConfig,
) {
t.Fatalf("managed config unknown fields = %x, want %x", unknown, unknownConfig)
}
disableRelayData := true
unsupportedHostname := "ignored-hostname"
identifier := &apiinstance.InstanceIdentifier{
Selector: &apiinstance.InstanceIdentifier_Id{Id: id},
}
callManagement(
t,
host,
ctx,
patchConfigMethod,
&apiconfig.PatchConfigRequest{
Instance: identifier,
Patch: &apiconfig.InstanceConfigPatch{
Hostname: &unsupportedHostname,
DisableRelayData: &disableRelayData,
},
},
"",
new(apiconfig.PatchConfigResponse),
)
patchedConfig := callManagement(
t,
host,
ctx,
getConfigMethod,
&apiconfig.GetConfigRequest{Instance: identifier},
"",
new(apiconfig.GetConfigResponse),
).(*apiconfig.GetConfigResponse).Config
if patchedConfig == nil || !patchedConfig.GetDisableRelayData() {
t.Fatalf("patched config = %v, want relay data disabled", patchedConfig)
}
if patchedConfig.Hostname != nil {
t.Fatalf("unsupported hostname patch was applied: %q", patchedConfig.GetHostname())
}
if unknown := patchedConfig.ProtoReflect().GetUnknown(); !bytes.Equal(
unknown,
unknownConfig,
) {
t.Fatalf("patched config unknown fields = %x, want %x", unknown, unknownConfig)
}
originalInstance := host.manager.snapshot()[0].instance
replacementConfig := proto.Clone(networkConfig).(*manage.NetworkConfig)
_, err = host.manager.runNetworkInstance(
ctx,
&manage.RunNetworkInstanceRequest{
InstId: id,
Config: replacementConfig,
Overwrite: true,
Source: manage.ConfigSource_ConfigSourceWeb,
},
"[",
id,
)
if err == nil {
t.Fatal("invalid replacement config unexpectedly succeeded")
}
restoredEntries := host.manager.snapshot()
if len(restoredEntries) != 1 || restoredEntries[0].instance == originalInstance {
t.Fatalf("restored entries = %v, want one replacement instance", restoredEntries)
}
effectiveConfig := new(apiconfig.GetConfigResponse)
if err := restoredEntries[0].instance.callRPCRequest(
ctx,
getConfigMethod,
&apiconfig.GetConfigRequest{Instance: identifier},
effectiveConfig,
); err != nil {
t.Fatalf("get restored runtime config: %v", err)
}
if effectiveConfig.Config == nil ||
!effectiveConfig.Config.GetDisableRelayData() {
t.Fatalf("restored runtime config = %v, want relay data disabled", effectiveConfig.Config)
}
if effectiveConfig.TomlConfig == "" ||
effectiveConfig.TomlConfig != restoredEntries[0].configTOML {
t.Fatal("restored TOML config does not match the running Core")
}
deleted := callManagement(
t,
host,
ctx,
deleteNetworkInstanceMethod,
&manage.DeleteNetworkInstanceRequest{InstIds: []*common.UUID{id}},
"",
new(manage.DeleteNetworkInstanceResponse),
).(*manage.DeleteNetworkInstanceResponse)
if len(deleted.RemainInstIds) != 0 || len(host.Instances()) != 0 {
t.Fatalf("managed instance remained after delete: %v", deleted.RemainInstIds)
}
}
func callManagement(
t *testing.T,
host *Host,
ctx context.Context,
method string,
request proto.Message,
preparedConfig string,
response proto.Message,
) proto.Message {
t.Helper()
encodedRequest, err := proto.Marshal(request)
if err != nil {
t.Fatalf("encode %s request: %v", method, err)
}
var prepared *string
var preparedInstanceID *common.UUID
if preparedConfig != "" {
prepared = &preparedConfig
}
if request, ok := request.(*manage.RunNetworkInstanceRequest); ok {
preparedInstanceID = request.InstId
}
encodedEnvelope, err := proto.Marshal(&common.HostManagementRequest{
Rpc: &common.DirectRpcRequest{
FullMethodName: method,
Request: encodedRequest,
},
PreparedConfig: prepared,
PreparedInstanceId: preparedInstanceID,
})
if err != nil {
t.Fatalf("encode %s envelope: %v", method, err)
}
var result common.RpcResponse
if err := proto.Unmarshal(host.manager.handle(ctx, encodedEnvelope), &result); err != nil {
t.Fatalf("decode %s envelope: %v", method, err)
}
if result.Error != nil {
t.Fatalf("%s failed: %v", method, result.Error)
}
if err := proto.Unmarshal(result.Response, response); err != nil {
t.Fatalf("decode %s response: %v", method, err)
}
return response
}
func managementTestConfig(t *testing.T, name string) InstanceConfig {
t.Helper()
config, err := NewInstanceConfigBuilder(name).
NetworkSecret("test").
Hostname(fmt.Sprintf("%s-host", name)).
IPv4(netip.MustParsePrefix("10.144.0.1/24")).
P2P(P2PPolicy{Disable: true}).
Encryption(false).
Build()
if err != nil {
t.Fatalf("build management test config: %v", err)
}
return config
}
+169
View File
@@ -0,0 +1,169 @@
package host
import (
"context"
"fmt"
"time"
apiinstance "github.com/EasyTier/EasyTier/easytier-go/proto/api/instance"
"github.com/EasyTier/EasyTier/easytier-go/proto/common"
"google.golang.org/protobuf/proto"
)
const (
listPeerRPCMethod = "api.instance.PeerManageRpc.ListPeer"
listRouteRPCMethod = "api.instance.PeerManageRpc.ListRoute"
showNodeInfoRPCMethod = "api.instance.PeerManageRpc.ShowNodeInfo"
foreignNetworkSummaryRPCMethod = "api.instance.PeerManageRpc.GetForeignNetworkSummary"
)
// PeerInfo describes one peer visible to an EasyTier instance.
type PeerInfo = apiinstance.PeerInfo
// Route describes one route visible to an EasyTier instance.
type Route = apiinstance.Route
// ListPeer returns the peers visible to this EasyTier instance.
func (instance *Instance) ListPeer(
ctx context.Context,
) ([]*PeerInfo, error) {
response, err := instance.listPeerResponse(ctx)
if err != nil {
return nil, err
}
return response.PeerInfos, nil
}
// ListRoute returns the routing table visible to this EasyTier instance.
func (instance *Instance) ListRoute(
ctx context.Context,
) ([]*Route, error) {
response, err := instance.listRouteResponse(ctx)
if err != nil {
return nil, err
}
return response.Routes, nil
}
func (instance *Instance) listPeerResponse(
ctx context.Context,
) (*apiinstance.ListPeerResponse, error) {
response := new(apiinstance.ListPeerResponse)
if err := instance.callRPC(ctx, listPeerRPCMethod, response); err != nil {
return nil, err
}
return response, nil
}
func (instance *Instance) listRouteResponse(
ctx context.Context,
) (*apiinstance.ListRouteResponse, error) {
response := new(apiinstance.ListRouteResponse)
if err := instance.callRPC(ctx, listRouteRPCMethod, response); err != nil {
return nil, err
}
return response, nil
}
func (instance *Instance) showNodeInfo(
ctx context.Context,
) (*apiinstance.ShowNodeInfoResponse, error) {
response := new(apiinstance.ShowNodeInfoResponse)
if err := instance.callRPC(ctx, showNodeInfoRPCMethod, response); err != nil {
return nil, err
}
return response, nil
}
func (instance *Instance) foreignNetworkSummary(
ctx context.Context,
) (*apiinstance.GetForeignNetworkSummaryResponse, error) {
response := new(apiinstance.GetForeignNetworkSummaryResponse)
if err := instance.callRPC(
ctx,
foreignNetworkSummaryRPCMethod,
response,
); err != nil {
return nil, err
}
return response, nil
}
func (instance *Instance) callRPC(
ctx context.Context,
fullMethodName string,
response proto.Message,
) error {
return instance.callRPCRequest(ctx, fullMethodName, nil, response)
}
func (instance *Instance) callRPCRequest(
ctx context.Context,
fullMethodName string,
request proto.Message,
response proto.Message,
) error {
if instance == nil || instance.engine == nil {
return fmt.Errorf("call RPC through nil EasyTier instance")
}
if ctx == nil {
return fmt.Errorf("call EasyTier RPC with nil context")
}
if err := ctx.Err(); err != nil {
return err
}
var timeoutMillis *uint64
if deadline, exists := ctx.Deadline(); exists {
remaining := time.Until(deadline)
if remaining <= 0 {
return context.DeadlineExceeded
}
millis := uint64(remaining / time.Millisecond)
if remaining%time.Millisecond != 0 {
millis++
}
timeoutMillis = &millis
}
var payload []byte
var err error
if request != nil {
payload, err = proto.Marshal(request)
if err != nil {
return fmt.Errorf("encode EasyTier RPC %s request: %w", fullMethodName, err)
}
}
encodedRequest, err := proto.Marshal(&common.DirectRpcRequest{
FullMethodName: fullMethodName,
Request: payload,
TimeoutMs: timeoutMillis,
})
if err != nil {
return fmt.Errorf("encode EasyTier RPC envelope: %w", err)
}
encodedResponse, err := instance.engine.RPC(ctx, encodedRequest)
if contextErr := ctx.Err(); contextErr != nil {
return contextErr
}
if err != nil {
return err
}
var envelope common.RpcResponse
if err := proto.Unmarshal(encodedResponse, &envelope); err != nil {
return fmt.Errorf("decode EasyTier RPC %s response: %w", fullMethodName, err)
}
if envelope.Error != nil {
return fmt.Errorf(
"EasyTier RPC %s failed: %s",
fullMethodName,
envelope.Error,
)
}
if err := proto.Unmarshal(envelope.Response, response); err != nil {
return fmt.Errorf(
"decode EasyTier RPC %s payload: %w",
fullMethodName,
err,
)
}
return nil
}
+65
View File
@@ -0,0 +1,65 @@
package host
import (
"context"
"fmt"
"os"
"runtime"
"github.com/EasyTier/EasyTier/easytier-go/internal/engine"
)
type WebClientOptions struct {
// Endpoint is a tcp:// or udp:// config-server URL, or a shorthand token.
Endpoint string
// MachineID is the stable UUID used to identify this host.
MachineID string
Hostname string
SecureMode bool
}
type WebClient struct {
engine *engine.WebClient
}
// ConnectWebClient connects this Host to an EasyTier configuration server.
func (host *Host) ConnectWebClient(
ctx context.Context,
options WebClientOptions,
) (*WebClient, error) {
if host == nil || host.engine == nil {
return nil, fmt.Errorf("connect WebClient with nil EasyTier host")
}
if ctx == nil {
return nil, fmt.Errorf("connect EasyTier WebClient with nil context")
}
if options.Hostname == "" {
hostname, err := os.Hostname()
if err != nil {
return nil, fmt.Errorf("resolve WebClient hostname: %w", err)
}
options.Hostname = hostname
}
client, err := host.engine.CreateWebClient(ctx, engine.WebClientOptions{
Endpoint: options.Endpoint,
MachineID: options.MachineID,
Hostname: options.Hostname,
SecureMode: options.SecureMode,
OSType: runtime.GOOS,
})
if err != nil {
return nil, err
}
return &WebClient{engine: client}, nil
}
func (client *WebClient) Connected() bool {
return client != nil && client.engine != nil && client.engine.Connected()
}
func (client *WebClient) Close(ctx context.Context) error {
if client == nil || client.engine == nil {
return nil
}
return client.engine.Close(ctx)
}
+157
View File
@@ -0,0 +1,157 @@
package hostabi
import (
"context"
"fmt"
"github.com/EasyTier/EasyTier/easytier-go/internal/reactor"
"github.com/metacubex/wazero"
)
const importModule = "easytier_host"
type Adapter struct {
reactor *reactor.Reactor
aeads aeadCache
}
func New(runtime *reactor.Reactor) (*Adapter, error) {
if runtime == nil {
return nil, fmt.Errorf("create host ABI adapter with nil reactor")
}
return &Adapter{reactor: runtime}, nil
}
func (adapter *Adapter) Instantiate(ctx context.Context, runtime wazero.Runtime) error {
if runtime == nil {
return fmt.Errorf("instantiate host ABI with nil wazero runtime")
}
_, err := runtime.NewHostModuleBuilder(importModule).
NewFunctionBuilder().
WithGoModuleFunction(
adapter.emitEventFunction(),
emitEventParameterTypes,
hostI32ResultTypes,
).
Export("emit_event").
NewFunctionBuilder().
WithGoModuleFunction(
adapter.cryptoAEADFunction(false),
cryptoAEADParameterTypes,
hostI32ResultTypes,
).
Export("crypto_aead_seal").
NewFunctionBuilder().
WithGoModuleFunction(
adapter.cryptoAEADFunction(true),
cryptoAEADParameterTypes,
hostI32ResultTypes,
).
Export("crypto_aead_open").
NewFunctionBuilder().
WithGoModuleFunction(
adapter.startReadFunction(),
startUDPReceiveParameterTypes,
hostI32ResultTypes,
).
Export("start_read").
NewFunctionBuilder().
WithGoModuleFunction(
adapter.takeReadFunction(),
takeReadParameterTypes,
hostI32ResultTypes,
).
Export("take_read").
NewFunctionBuilder().
WithGoModuleFunction(
adapter.startWriteFunction(),
startWriteParameterTypes,
hostI32ResultTypes,
).
Export("start_write").
NewFunctionBuilder().
WithGoModuleFunction(
adapter.takeWriteFunction(),
operationParameterTypes,
hostI32ResultTypes,
).
Export("take_write").
NewFunctionBuilder().
WithGoModuleFunction(
adapter.startUDPReceiveFunction(),
startUDPReceiveParameterTypes,
hostI32ResultTypes,
).
Export("start_udp_recv").
NewFunctionBuilder().
WithGoModuleFunction(
adapter.takeUDPReceiveFunction(),
takeUDPReceiveParameterTypes,
hostI32ResultTypes,
).
Export("take_udp_recv").
NewFunctionBuilder().
WithGoModuleFunction(
adapter.tryUDPSendFunction(),
tryUDPSendParameterTypes,
hostI32ResultTypes,
).
Export("try_udp_send").
NewFunctionBuilder().
WithGoModuleFunction(
adapter.startUDPSendReadyFunction(),
handleOperationParameterTypes,
hostI32ResultTypes,
).
Export("start_udp_send_ready").
NewFunctionBuilder().
WithGoModuleFunction(
adapter.takeUDPSendReadyFunction(),
operationParameterTypes,
hostI32ResultTypes,
).
Export("take_udp_send_ready").
NewFunctionBuilder().WithFunc(adapter.startTCPConnect).Export("start_tcp_connect").
NewFunctionBuilder().WithFunc(adapter.takeTCPConnect).Export("take_tcp_connect").
NewFunctionBuilder().WithFunc(adapter.startUDPBind).Export("start_udp_bind").
NewFunctionBuilder().WithFunc(adapter.takeUDPBind).Export("take_udp_bind").
NewFunctionBuilder().WithFunc(adapter.startTCPListen).Export("start_tcp_bind").
NewFunctionBuilder().WithFunc(adapter.takeTCPListen).Export("take_tcp_bind").
NewFunctionBuilder().WithFunc(adapter.startTCPAccept).Export("start_tcp_accept").
NewFunctionBuilder().WithFunc(adapter.takeTCPAccept).Export("take_tcp_accept").
NewFunctionBuilder().WithFunc(adapter.startDNSAddress).Export("start_dns_resolve").
NewFunctionBuilder().WithFunc(adapter.takeDNSAddress).Export("take_dns_resolve").
NewFunctionBuilder().WithFunc(adapter.startDNSTXT).Export("start_dns_txt").
NewFunctionBuilder().WithFunc(adapter.takeDNSTXT).Export("take_dns_txt").
NewFunctionBuilder().WithFunc(adapter.startDNSSRV).Export("start_dns_srv").
NewFunctionBuilder().WithFunc(adapter.takeDNSSRV).Export("take_dns_srv").
NewFunctionBuilder().WithFunc(adapter.startLocalAddrForRemote).Export("start_local_addr_for_remote").
NewFunctionBuilder().WithFunc(adapter.takeLocalAddrForRemote).Export("take_local_addr_for_remote").
NewFunctionBuilder().WithFunc(adapter.startManagement).Export("start_management_call").
NewFunctionBuilder().WithFunc(adapter.takeManagement).Export("take_management_call").
NewFunctionBuilder().
WithGoModuleFunction(
adapter.tryPacketWriteFunction(),
tryPacketWriteParameterTypes,
hostI32ResultTypes,
).
Export("try_packet_write").
NewFunctionBuilder().
WithGoModuleFunction(
adapter.startPacketWriteReadyFunction(),
handleOperationParameterTypes,
hostI32ResultTypes,
).
Export("start_packet_write_ready").
NewFunctionBuilder().
WithGoModuleFunction(
adapter.takePacketWriteReadyFunction(),
operationParameterTypes,
hostI32ResultTypes,
).
Export("take_packet_write_ready").
NewFunctionBuilder().WithFunc(adapter.cancelOperation).Export("cancel_operation").
NewFunctionBuilder().WithFunc(adapter.closeHandle).Export("close").
Instantiate(ctx)
return err
}
@@ -0,0 +1,546 @@
package hostabi
import (
"context"
"fmt"
"io"
"net"
"net/netip"
"os"
"path/filepath"
"sync"
"testing"
"time"
"github.com/EasyTier/EasyTier/easytier-go/internal/contextutil"
"github.com/EasyTier/EasyTier/easytier-go/internal/reactor"
"github.com/EasyTier/EasyTier/easytier-go/platform"
"github.com/metacubex/wazero"
"github.com/metacubex/wazero/api"
"github.com/metacubex/wazero/imports/wasi_snapshot_preview1"
)
const (
probeTimerProgress = 1 << 0
probeSecondSocketProgress = 1 << 1
probePendingReadCompleted = 1 << 2
probePendingReadIsolated = 1 << 3
probeDone = 1 << 4
probeError = 1 << 31
)
func TestStreamABIConformance(t *testing.T) {
const (
pendingHandle uint64 = 1<<40 | 1
activeHandle uint64 = 1<<40 | 2
)
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
pendingHost, pendingPeer := net.Pipe()
activeHost, activePeer := net.Pipe()
defer pendingPeer.Close()
defer activePeer.Close()
hostReactor := reactor.New(ctx, reactor.Options{
InitialStreams: map[uint64]net.Conn{
pendingHandle: pendingHost,
activeHandle: activeHost,
},
})
defer hostReactor.Close()
module := instantiateProbe(t, ctx, hostReactor)
results, err := module.ExportedFunction("init_opaque_probe").Call(
ctx,
pendingHandle,
activeHandle,
)
requireStatus(t, "initialize stream probe", results, err, 0)
status := driveProbe(t, ctx, module, "drive_opaque_probe")
echo := make(chan error, 1)
go func() {
if err := activePeer.SetDeadline(time.Now().Add(10 * time.Second)); err != nil {
echo <- err
return
}
if _, err := activePeer.Write([]byte{0x5a}); err != nil {
echo <- err
return
}
got := make([]byte, 1)
if _, err := io.ReadFull(activePeer, got); err != nil {
echo <- err
return
}
if got[0] != 0x5a {
echo <- fmt.Errorf("echo = %x, want 5a", got)
return
}
echo <- nil
}()
for status&probeSecondSocketProgress == 0 {
waitCompletion(t, ctx, hostReactor)
status = driveProbe(t, ctx, module, "drive_opaque_probe")
}
for status&probeDone == 0 {
timer := time.NewTimer(5 * time.Millisecond)
select {
case <-hostReactor.Completions():
stopTimer(timer)
case <-timer.C:
case <-ctx.Done():
stopTimer(timer)
t.Fatalf("wait for stream probe: %v", ctx.Err())
}
status = driveProbe(t, ctx, module, "drive_opaque_probe")
}
want := uint32(
probeTimerProgress |
probeSecondSocketProgress |
probePendingReadIsolated |
probeDone,
)
if status != want || status&probePendingReadCompleted != 0 {
t.Fatalf("stream probe status = 0x%x, want 0x%x", status, want)
}
select {
case err := <-echo:
if err != nil {
t.Fatal(err)
}
case <-ctx.Done():
t.Fatalf("wait for stream echo: %v", ctx.Err())
}
}
func TestUDPABIConformance(t *testing.T) {
const handle uint64 = 1<<40 | 3
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
hostPacket, err := net.ListenPacket("udp4", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen host UDP: %v", err)
}
peerPacket, err := net.ListenPacket("udp4", "127.0.0.1:0")
if err != nil {
hostPacket.Close()
t.Fatalf("listen peer UDP: %v", err)
}
defer peerPacket.Close()
writes := make(chan packetWrite, 1)
hostReactor := reactor.New(ctx, reactor.Options{
InitialDatagrams: map[uint64]net.PacketConn{handle: &recordingPacketConn{
PacketConn: hostPacket,
writes: writes,
}},
})
defer hostReactor.Close()
module := instantiateProbe(t, ctx, hostReactor)
results, err := module.ExportedFunction("init_udp_probe").Call(ctx, handle)
requireStatus(t, "initialize UDP probe", results, err, 0)
if status := driveProbe(t, ctx, module, "drive_udp_probe"); status != 0 {
t.Fatalf("initial UDP status = 0x%x", status)
}
if err := peerPacket.SetDeadline(time.Now().Add(10 * time.Second)); err != nil {
t.Fatal(err)
}
if _, err := peerPacket.WriteTo([]byte("udp"), hostPacket.LocalAddr()); err != nil {
t.Fatalf("send UDP payload: %v", err)
}
waitCompletion(t, ctx, hostReactor)
if status := driveProbe(t, ctx, module, "drive_udp_probe"); status != probeDone {
t.Fatalf("UDP probe status = 0x%x, want 0x%x", status, probeDone)
}
select {
case write := <-writes:
if write.err != nil {
t.Fatalf("host UDP write to %v: %v", write.peer, write.err)
}
if write.peer.String() != peerPacket.LocalAddr().String() {
t.Fatalf("host UDP peer = %v, want %v", write.peer, peerPacket.LocalAddr())
}
case <-ctx.Done():
t.Fatalf("wait for host UDP write: %v", ctx.Err())
}
buffer := make([]byte, 16)
n, source, err := peerPacket.ReadFrom(buffer)
if err != nil {
t.Fatalf("read UDP echo: %v", err)
}
if string(buffer[:n]) != "udp" || source.String() != hostPacket.LocalAddr().String() {
t.Fatalf("UDP echo = %q from %v", buffer[:n], source)
}
}
type packetWrite struct {
peer net.Addr
err error
}
type recordingPacketConn struct {
net.PacketConn
writes chan<- packetWrite
}
func (connection *recordingPacketConn) WriteTo(
packet []byte,
peer net.Addr,
) (int, error) {
written, err := connection.PacketConn.WriteTo(packet, peer)
connection.writes <- packetWrite{peer: peer, err: err}
return written, err
}
type probeDNSResolver struct {
mu sync.Mutex
queries []platform.DNSQuery
addressStarted chan struct{}
releaseAddress chan struct{}
startOnce sync.Once
}
func (resolver *probeDNSResolver) record(query platform.DNSQuery) {
resolver.mu.Lock()
resolver.queries = append(resolver.queries, query)
resolver.mu.Unlock()
}
func (resolver *probeDNSResolver) LookupIP(
ctx context.Context,
query platform.DNSQuery,
) ([]netip.Addr, error) {
resolver.record(query)
resolver.startOnce.Do(func() { close(resolver.addressStarted) })
select {
case <-resolver.releaseAddress:
case <-ctx.Done():
return nil, ctx.Err()
}
return []netip.Addr{
netip.MustParseAddr("192.0.2.1"),
netip.MustParseAddr("2001:db8::1"),
}, nil
}
func (resolver *probeDNSResolver) LookupTXT(
_ context.Context,
query platform.DNSQuery,
) (string, error) {
resolver.record(query)
return "tcp://peer.example:11010", nil
}
func (resolver *probeDNSResolver) LookupSRV(
_ context.Context,
query platform.DNSQuery,
) ([]*net.SRV, error) {
resolver.record(query)
return []*net.SRV{{
Target: "peer.example.",
Port: 11010,
Priority: 10,
Weight: 20,
}}, nil
}
func TestDNSABIConformance(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
resolver := &probeDNSResolver{
addressStarted: make(chan struct{}),
releaseAddress: make(chan struct{}),
}
hostReactor := reactor.New(ctx, reactor.Options{
Services: platform.Services{DNS: resolver},
})
defer hostReactor.Close()
module := instantiateProbe(t, ctx, hostReactor)
results, err := module.ExportedFunction("init_dns_probe").Call(ctx)
requireStatus(t, "initialize DNS probe", results, err, 0)
if status := driveProbe(t, ctx, module, "drive_dns_probe"); status != 0 {
t.Fatalf("initial DNS status = 0x%x", status)
}
select {
case <-resolver.addressStarted:
case <-ctx.Done():
t.Fatalf("wait for DNS lookup: %v", ctx.Err())
}
select {
case <-hostReactor.Completions():
t.Fatal("DNS completed before resolver release")
default:
}
close(resolver.releaseAddress)
status := uint32(0)
for status&probeDone == 0 {
waitCompletion(t, ctx, hostReactor)
status = driveProbe(t, ctx, module, "drive_dns_probe")
}
resolver.mu.Lock()
queries := append([]platform.DNSQuery(nil), resolver.queries...)
resolver.mu.Unlock()
if len(queries) != 3 {
t.Fatalf("DNS query count = %d, want 3", len(queries))
}
assertDNSQuery(t, queries[0], "peer.example", 0, uint32Pointer(7), stringPointer("mihomo"))
assertDNSQuery(t, queries[1], "_easytier.example", 4, nil, nil)
assertDNSQuery(t, queries[2], "_easytier._udp.example", 6, uint32Pointer(9), stringPointer(""))
}
type probeEnvironment struct {
mu sync.Mutex
remote *net.UDPAddr
context platform.SocketContext
started chan struct{}
released chan struct{}
}
func (environment *probeEnvironment) LocalAddrForRemote(
ctx context.Context,
remote *net.UDPAddr,
socketContext platform.SocketContext,
) (net.Addr, error) {
environment.mu.Lock()
environment.remote = remote
environment.context = socketContext
environment.mu.Unlock()
close(environment.started)
select {
case <-environment.released:
case <-ctx.Done():
return nil, ctx.Err()
}
return &net.UDPAddr{IP: net.ParseIP("192.0.2.10"), Port: 40000}, nil
}
func TestEnvironmentABIConformance(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
environment := &probeEnvironment{
started: make(chan struct{}),
released: make(chan struct{}),
}
hostReactor := reactor.New(ctx, reactor.Options{
Services: platform.Services{Environment: environment},
})
defer hostReactor.Close()
module := instantiateProbe(t, ctx, hostReactor)
results, err := module.ExportedFunction("init_environment_probe").Call(ctx)
requireStatus(t, "initialize environment probe", results, err, 0)
if status := driveProbe(t, ctx, module, "drive_environment_probe"); status != 0 {
t.Fatalf("initial environment status = 0x%x", status)
}
select {
case <-environment.started:
case <-ctx.Done():
t.Fatalf("wait for environment operation: %v", ctx.Err())
}
close(environment.released)
waitCompletion(t, ctx, hostReactor)
if status := driveProbe(t, ctx, module, "drive_environment_probe"); status != probeDone {
t.Fatalf("environment status = 0x%x, want 0x%x", status, probeDone)
}
environment.mu.Lock()
defer environment.mu.Unlock()
if environment.remote.String() != "203.0.113.2:443" ||
environment.context.IPVersion != platform.IPVersionBoth ||
environment.context.SocketMark != nil ||
environment.context.NetNS != nil {
t.Fatalf(
"environment request = remote %v, context %#v",
environment.remote,
environment.context,
)
}
}
func TestPacketABIBackpressure(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
hostReactor := reactor.New(ctx, reactor.Options{})
defer hostReactor.Close()
handle, err := hostReactor.RegisterPacketSink(1)
if err != nil {
t.Fatalf("register packet sink: %v", err)
}
module := instantiateProbe(t, ctx, hostReactor)
results, err := module.ExportedFunction("init_packet_probe").Call(ctx, handle)
requireStatus(t, "initialize packet probe", results, err, 0)
if status := driveProbe(t, ctx, module, "drive_packet_probe"); status != 0 {
t.Fatalf("initial packet status = 0x%x", status)
}
select {
case <-hostReactor.Completions():
t.Fatal("packet writer became ready while sink was full")
default:
}
first, err := hostReactor.ReceivePacket(ctx, handle)
if err != nil || string(first) != "first-packet" {
t.Fatalf("first packet = %q, error %v", first, err)
}
waitCompletion(t, ctx, hostReactor)
if status := driveProbe(t, ctx, module, "drive_packet_probe"); status != probeDone {
t.Fatalf("packet status = 0x%x, want 0x%x", status, probeDone)
}
second, err := hostReactor.ReceivePacket(ctx, handle)
if err != nil || string(second) != "second-packet" {
t.Fatalf("second packet = %q, error %v", second, err)
}
}
func TestUDPMetadataWireFormat(t *testing.T) {
expected := [udpMetadataLen]byte{
0x04, 0xc0, 0x00, 0x02, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x2b, 0x05, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0xc6, 0x33, 0x64, 0x02, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00,
}
encoded, err := encodeUDPMetadata(
&net.UDPAddr{IP: net.IPv4(192, 0, 2, 1), Port: 11013},
net.IPv4(198, 51, 100, 2),
0,
)
if err != nil {
t.Fatal(err)
}
if encoded != expected {
t.Fatalf("UDP metadata = %x, want %x", encoded, expected)
}
}
func instantiateProbe(
t *testing.T,
ctx context.Context,
hostReactor *reactor.Reactor,
) api.Module {
t.Helper()
runtime := wazero.NewRuntimeWithConfig(
ctx,
wazero.NewRuntimeConfig().WithCloseOnContextDone(true),
)
t.Cleanup(func() {
if err := runtime.Close(contextutil.WithoutCancel(ctx)); err != nil {
t.Errorf("close probe runtime: %v", err)
}
})
adapter, err := New(hostReactor)
if err != nil {
t.Fatalf("create host ABI adapter: %v", err)
}
if err := adapter.Instantiate(ctx, runtime); err != nil {
t.Fatalf("instantiate host ABI: %v", err)
}
wasi_snapshot_preview1.MustInstantiate(ctx, runtime)
wasm, err := os.ReadFile(filepath.Join("..", "..", "testdata", "wasi_socket_guest.wasm"))
if err != nil {
t.Fatalf("read probe WASM: %v", err)
}
compiled, err := runtime.CompileModule(ctx, wasm)
if err != nil {
t.Fatalf("compile probe WASM: %v", err)
}
t.Cleanup(func() { _ = compiled.Close(contextutil.WithoutCancel(ctx)) })
module, err := runtime.InstantiateModule(
ctx,
compiled,
wazero.NewModuleConfig().
WithStartFunctions("_initialize").
WithSysWalltime().
WithSysNanotime().
WithSysNanosleep(),
)
if err != nil {
t.Fatalf("instantiate probe WASM: %v", err)
}
return module
}
func driveProbe(
t *testing.T,
ctx context.Context,
module api.Module,
export string,
) uint32 {
t.Helper()
results, err := module.ExportedFunction(export).Call(ctx)
if err != nil || len(results) != 1 {
t.Fatalf("%s: results=%v error=%v", export, results, err)
}
status := uint32(results[0])
if status&probeError != 0 {
t.Fatalf("%s failed with status 0x%x", export, status)
}
return status
}
func requireStatus(
t *testing.T,
operation string,
results []uint64,
err error,
want int32,
) {
t.Helper()
if err != nil || len(results) != 1 || int32(results[0]) != want {
t.Fatalf("%s: results=%v error=%v want=%d", operation, results, err, want)
}
}
func waitCompletion(
t *testing.T,
ctx context.Context,
hostReactor *reactor.Reactor,
) {
t.Helper()
select {
case <-hostReactor.Completions():
case <-ctx.Done():
t.Fatalf("wait for host completion: %v", ctx.Err())
}
}
func assertDNSQuery(
t *testing.T,
query platform.DNSQuery,
host string,
ipVersion uint8,
mark *uint32,
netns *string,
) {
t.Helper()
if query.Host != host ||
query.IPVersion != ipVersion ||
!equalPointer(query.SocketMark, mark) ||
!equalPointer(query.NetNS, netns) {
t.Fatalf("DNS query = %#v", query)
}
}
func equalPointer[T comparable](left, right *T) bool {
return left == nil && right == nil ||
left != nil && right != nil && *left == *right
}
func uint32Pointer(value uint32) *uint32 {
return &value
}
func stringPointer(value string) *string {
return &value
}
func stopTimer(timer *time.Timer) {
if timer == nil || timer.Stop() {
return
}
select {
case <-timer.C:
default:
}
}
@@ -0,0 +1,11 @@
//go:build js || plan9 || wasip1
package hostabi
func isConnectionRefused(_ error) bool { return false }
func isConnectionAborted(_ error) bool { return false }
func isConnectionReset(_ error) bool { return false }
func isNotConnected(_ error) bool { return false }
@@ -0,0 +1,24 @@
//go:build aix || android || darwin || dragonfly || freebsd || illumos || ios || linux || netbsd || openbsd || solaris
package hostabi
import (
"errors"
"syscall"
)
func isConnectionRefused(err error) bool {
return errors.Is(err, syscall.ECONNREFUSED)
}
func isConnectionAborted(err error) bool {
return errors.Is(err, syscall.ECONNABORTED)
}
func isConnectionReset(err error) bool {
return errors.Is(err, syscall.ECONNRESET)
}
func isNotConnected(err error) bool {
return errors.Is(err, syscall.ENOTCONN)
}
@@ -0,0 +1,31 @@
//go:build windows
package hostabi
import (
"errors"
"syscall"
)
const (
wsaNotConnected = syscall.Errno(10057)
wsaConnectionAborted = syscall.Errno(10053)
wsaConnectionReset = syscall.Errno(10054)
wsaConnectionRefused = syscall.Errno(10061)
)
func isConnectionRefused(err error) bool {
return errors.Is(err, wsaConnectionRefused)
}
func isConnectionAborted(err error) bool {
return errors.Is(err, wsaConnectionAborted)
}
func isConnectionReset(err error) bool {
return errors.Is(err, wsaConnectionReset)
}
func isNotConnected(err error) bool {
return errors.Is(err, wsaNotConnected)
}
+236
View File
@@ -0,0 +1,236 @@
package hostabi
import (
"context"
"crypto/aes"
"crypto/cipher"
"sync"
"github.com/metacubex/wazero/api"
)
const (
aeadAES128GCM uint32 = 1
aeadAES256GCM uint32 = 2
aeadCacheCapacity = 64
statusCryptoAuthFailed int32 = -10
statusCryptoFallback int32 = -11
)
var (
cryptoAEADParameterTypes = []api.ValueType{
api.ValueTypeI32,
api.ValueTypeI32,
api.ValueTypeI32,
api.ValueTypeI32,
api.ValueTypeI32,
api.ValueTypeI32,
api.ValueTypeI32,
api.ValueTypeI32,
api.ValueTypeI32,
}
hostI32ResultTypes = []api.ValueType{api.ValueTypeI32}
)
type aeadCacheKey struct {
algorithm uint32
key [32]byte
}
type aeadCache struct {
mu sync.Mutex
entries map[aeadCacheKey]cipher.AEAD
order [aeadCacheCapacity]aeadCacheKey
size int
next int
}
func (adapter *Adapter) cryptoAEADFunction(open bool) api.GoModuleFunction {
return api.GoModuleFunc(func(ctx context.Context, module api.Module, stack []uint64) {
function := adapter.cryptoAEADSeal
if open {
function = adapter.cryptoAEADOpen
}
stack[0] = api.EncodeI32(function(
ctx,
module,
api.DecodeU32(stack[0]),
api.DecodeU32(stack[1]),
api.DecodeU32(stack[2]),
api.DecodeU32(stack[3]),
api.DecodeU32(stack[4]),
api.DecodeU32(stack[5]),
api.DecodeU32(stack[6]),
api.DecodeU32(stack[7]),
api.DecodeU32(stack[8]),
))
})
}
func (cache *aeadCache) get(
algorithm uint32,
key []byte,
) (cipher.AEAD, bool) {
if (algorithm != aeadAES128GCM || len(key) != 16) &&
(algorithm != aeadAES256GCM || len(key) != 32) {
return nil, false
}
var cacheKey aeadCacheKey
cacheKey.algorithm = algorithm
copy(cacheKey.key[:], key)
cache.mu.Lock()
defer cache.mu.Unlock()
if cached := cache.entries[cacheKey]; cached != nil {
return cached, true
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, false
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, false
}
if cache.entries == nil {
cache.entries = make(map[aeadCacheKey]cipher.AEAD, aeadCacheCapacity)
}
if cache.size < aeadCacheCapacity {
cache.order[cache.size] = cacheKey
cache.size++
} else {
delete(cache.entries, cache.order[cache.next])
cache.order[cache.next] = cacheKey
cache.next = (cache.next + 1) % aeadCacheCapacity
}
cache.entries[cacheKey] = aead
return aead, true
}
func (adapter *Adapter) cryptoAEADSeal(
_ context.Context,
module api.Module,
algorithm uint32,
keyPointer uint32,
keyLength uint32,
noncePointer uint32,
nonceLength uint32,
aadPointer uint32,
aadLength uint32,
bufferPointer uint32,
textLength uint32,
) int32 {
nonce, aad, aead, ok := adapter.cryptoAEADInputs(
module,
algorithm,
keyPointer,
keyLength,
noncePointer,
nonceLength,
aadPointer,
aadLength,
)
if !ok {
return statusCryptoFallback
}
if textLength > ^uint32(0)-uint32(aead.Overhead()) {
return statusCryptoFallback
}
buffer, ok := module.Memory().Read(
bufferPointer,
textLength+uint32(aead.Overhead()),
)
if !ok {
return statusCryptoFallback
}
aead.Seal(buffer[:0], nonce, buffer[:textLength], aad)
return 0
}
func (adapter *Adapter) cryptoAEADOpen(
_ context.Context,
module api.Module,
algorithm uint32,
keyPointer uint32,
keyLength uint32,
noncePointer uint32,
nonceLength uint32,
aadPointer uint32,
aadLength uint32,
bufferPointer uint32,
textLength uint32,
) int32 {
nonce, aad, aead, ok := adapter.cryptoAEADInputs(
module,
algorithm,
keyPointer,
keyLength,
noncePointer,
nonceLength,
aadPointer,
aadLength,
)
if !ok {
return statusCryptoFallback
}
if textLength > ^uint32(0)-uint32(aead.Overhead()) {
return statusCryptoFallback
}
buffer, ok := module.Memory().Read(
bufferPointer,
textLength+uint32(aead.Overhead()),
)
if !ok {
return statusCryptoFallback
}
opened, err := aead.Open(buffer[:0], nonce, buffer, aad)
if err != nil {
return statusCryptoAuthFailed
}
if len(opened) != int(textLength) {
return statusCryptoAuthFailed
}
return 0
}
func (adapter *Adapter) cryptoAEADInputs(
module api.Module,
algorithm uint32,
keyPointer uint32,
keyLength uint32,
noncePointer uint32,
nonceLength uint32,
aadPointer uint32,
aadLength uint32,
) ([]byte, []byte, cipher.AEAD, bool) {
key, ok := readCryptoBytes(module, keyPointer, keyLength)
if !ok {
return nil, nil, nil, false
}
aead, ok := adapter.aeads.get(algorithm, key)
if !ok {
return nil, nil, nil, false
}
nonce, ok := readCryptoBytes(module, noncePointer, nonceLength)
if !ok || len(nonce) != aead.NonceSize() {
return nil, nil, nil, false
}
aad, ok := readCryptoBytes(module, aadPointer, aadLength)
if !ok {
return nil, nil, nil, false
}
return nonce, aad, aead, true
}
func readCryptoBytes(
module api.Module,
pointer uint32,
length uint32,
) ([]byte, bool) {
if length == 0 {
return nil, true
}
return module.Memory().Read(pointer, length)
}
+146
View File
@@ -0,0 +1,146 @@
package hostabi
import (
"bytes"
"context"
"testing"
"github.com/metacubex/wazero/api"
"github.com/metacubex/wazero/experimental/wazerotest"
)
func TestCryptoAEADAES128GCMVector(t *testing.T) {
adapter, module := newCryptoTestModule(t)
key := make([]byte, 16)
nonce := make([]byte, 12)
plaintext := make([]byte, 16)
writeCryptoTestBytes(t, module, 0, key)
writeCryptoTestBytes(t, module, 32, nonce)
writeCryptoTestBytes(t, module, 64, append(plaintext, make([]byte, 16)...))
status := adapter.cryptoAEADSeal(
context.Background(),
module,
aeadAES128GCM,
0,
uint32(len(key)),
32,
uint32(len(nonce)),
0,
0,
64,
uint32(len(plaintext)),
)
if status != 0 {
t.Fatalf("seal status = %d", status)
}
got, _ := module.Memory().Read(64, 32)
want := []byte{
0x03, 0x88, 0xda, 0xce, 0x60, 0xb6, 0xa3, 0x92,
0xf3, 0x28, 0xc2, 0xb9, 0x71, 0xb2, 0xfe, 0x78,
0xab, 0x6e, 0x47, 0xd4, 0x2c, 0xec, 0x13, 0xbd,
0xf5, 0x3a, 0x67, 0xb2, 0x12, 0x57, 0xbd, 0xdf,
}
if !bytes.Equal(got, want) {
t.Fatalf("sealed bytes = %x, want %x", got, want)
}
status = adapter.cryptoAEADOpen(
context.Background(),
module,
aeadAES128GCM,
0,
uint32(len(key)),
32,
uint32(len(nonce)),
0,
0,
64,
uint32(len(plaintext)),
)
if status != 0 {
t.Fatalf("open status = %d", status)
}
got, _ = module.Memory().Read(64, uint32(len(plaintext)))
if !bytes.Equal(got, plaintext) {
t.Fatalf("opened bytes = %x, want %x", got, plaintext)
}
}
func TestCryptoAEADFallbackDoesNotModifyBuffer(t *testing.T) {
adapter, module := newCryptoTestModule(t)
key := make([]byte, 32)
nonce := make([]byte, 12)
buffer := bytes.Repeat([]byte{7}, 32)
writeCryptoTestBytes(t, module, 0, key)
writeCryptoTestBytes(t, module, 32, nonce)
writeCryptoTestBytes(t, module, 64, buffer)
status := adapter.cryptoAEADSeal(
context.Background(),
module,
3,
0,
uint32(len(key)),
32,
uint32(len(nonce)),
0,
0,
64,
16,
)
if status != statusCryptoFallback {
t.Fatalf("seal status = %d, want fallback", status)
}
got, _ := module.Memory().Read(64, uint32(len(buffer)))
if !bytes.Equal(got, buffer) {
t.Fatalf("fallback changed buffer: %x", got)
}
}
func TestCryptoAEADOpenReportsAuthenticationFailure(t *testing.T) {
adapter, module := newCryptoTestModule(t)
key := make([]byte, 16)
nonce := make([]byte, 12)
buffer := make([]byte, 32)
buffer[len(buffer)-1] = 1
writeCryptoTestBytes(t, module, 0, key)
writeCryptoTestBytes(t, module, 32, nonce)
writeCryptoTestBytes(t, module, 64, buffer)
status := adapter.cryptoAEADOpen(
context.Background(),
module,
aeadAES128GCM,
0,
uint32(len(key)),
32,
uint32(len(nonce)),
0,
0,
64,
16,
)
if status != statusCryptoAuthFailed {
t.Fatalf("open status = %d, want authentication failure", status)
}
}
func newCryptoTestModule(t *testing.T) (*Adapter, api.Module) {
t.Helper()
return &Adapter{}, wazerotest.NewModule(
wazerotest.NewMemory(wazerotest.PageSize),
)
}
func writeCryptoTestBytes(
t *testing.T,
module api.Module,
pointer uint32,
value []byte,
) {
t.Helper()
if !module.Memory().Write(pointer, value) {
t.Fatalf("write %d bytes at %d", len(value), pointer)
}
}
+132
View File
@@ -0,0 +1,132 @@
package hostabi
import (
"context"
"github.com/EasyTier/EasyTier/easytier-go/internal/reactor"
"github.com/metacubex/wazero/api"
)
const maxDNSQueryLen = 4096
func (adapter *Adapter) startDNSAddress(
_ context.Context,
module api.Module,
operation uint64,
queryPointer uint32,
queryLength uint32,
) int32 {
return adapter.startDNS(module, operation, queryPointer, queryLength, reactor.DNSAddress)
}
func (adapter *Adapter) startDNSTXT(
_ context.Context,
module api.Module,
operation uint64,
queryPointer uint32,
queryLength uint32,
) int32 {
return adapter.startDNS(module, operation, queryPointer, queryLength, reactor.DNSTXT)
}
func (adapter *Adapter) startDNSSRV(
_ context.Context,
module api.Module,
operation uint64,
queryPointer uint32,
queryLength uint32,
) int32 {
return adapter.startDNS(module, operation, queryPointer, queryLength, reactor.DNSSRV)
}
func (adapter *Adapter) startDNS(
module api.Module,
operation uint64,
queryPointer uint32,
queryLength uint32,
kind reactor.DNSKind,
) int32 {
if queryLength == 0 || queryLength > maxDNSQueryLen {
return statusInvalid
}
encoded, ok := module.Memory().Read(queryPointer, queryLength)
if !ok {
return statusMemory
}
query, err := decodeDNSQuery(append([]byte(nil), encoded...))
if err != nil {
return statusInvalid
}
return operationStatus(adapter.reactor.StartDNS(operation, kind, query))
}
func (adapter *Adapter) takeDNSAddress(
_ context.Context,
module api.Module,
operation uint64,
resultPointer uint32,
resultCapacity uint32,
) int32 {
return adapter.takeDNS(module, operation, resultPointer, resultCapacity, reactor.DNSAddress)
}
func (adapter *Adapter) takeDNSTXT(
_ context.Context,
module api.Module,
operation uint64,
resultPointer uint32,
resultCapacity uint32,
) int32 {
return adapter.takeDNS(module, operation, resultPointer, resultCapacity, reactor.DNSTXT)
}
func (adapter *Adapter) takeDNSSRV(
_ context.Context,
module api.Module,
operation uint64,
resultPointer uint32,
resultCapacity uint32,
) int32 {
return adapter.takeDNS(module, operation, resultPointer, resultCapacity, reactor.DNSSRV)
}
func (adapter *Adapter) takeDNS(
module api.Module,
operation uint64,
resultPointer uint32,
resultCapacity uint32,
kind reactor.DNSKind,
) int32 {
result, err := adapter.reactor.DNSResult(operation)
if err != nil {
return operationStatus(err)
}
if result.Kind != kind {
return statusInvalid
}
var encoded []byte
switch kind {
case reactor.DNSAddress:
encoded, err = encodeDNSAddresses(result.Addresses)
case reactor.DNSTXT:
encoded, err = encodeDNSTXT(result.Text)
case reactor.DNSSRV:
encoded, err = encodeDNSSRV(result.Records)
default:
return statusInvalid
}
if err != nil {
_ = adapter.reactor.FinishDNS(operation)
return statusInvalid
}
if resultCapacity < uint32(len(encoded)) {
return int32(len(encoded))
}
if err := adapter.reactor.FinishDNS(operation); err != nil {
return operationStatus(err)
}
if !module.Memory().Write(resultPointer, encoded) {
return statusMemory
}
return int32(len(encoded))
}
+124
View File
@@ -0,0 +1,124 @@
package hostabi
import (
"encoding/binary"
"fmt"
"net"
"net/netip"
"unicode/utf8"
"github.com/EasyTier/EasyTier/easytier-go/platform"
)
const (
dnsWireVersion = 1
maxDNSResultLen = 1024 * 1024
)
func decodeDNSQuery(encoded []byte) (platform.DNSQuery, error) {
if len(encoded) < 16 || encoded[0] != dnsWireVersion {
return platform.DNSQuery{}, fmt.Errorf("invalid DNS query header")
}
query := platform.DNSQuery{IPVersion: encoded[1]}
if query.IPVersion != 0 && query.IPVersion != 4 && query.IPVersion != 6 {
return platform.DNSQuery{}, fmt.Errorf("invalid DNS IP version")
}
if encoded[2] > 1 {
return platform.DNSQuery{}, fmt.Errorf("invalid DNS socket mark presence")
}
if encoded[2] == 1 {
mark := binary.BigEndian.Uint32(encoded[3:7])
query.SocketMark = &mark
} else if binary.BigEndian.Uint32(encoded[3:7]) != 0 {
return platform.DNSQuery{}, fmt.Errorf("DNS socket mark value without presence")
}
if encoded[7] > 1 {
return platform.DNSQuery{}, fmt.Errorf("invalid DNS netns presence")
}
offset := 12
netnsLengthWire := binary.BigEndian.Uint32(encoded[8:12])
if uint64(netnsLengthWire) > uint64(len(encoded)-offset) {
return platform.DNSQuery{}, fmt.Errorf("truncated DNS netns token")
}
netnsLength := int(netnsLengthWire)
if encoded[7] == 0 && netnsLength != 0 {
return platform.DNSQuery{}, fmt.Errorf("DNS netns length without presence")
}
if encoded[7] == 1 {
netnsBytes := encoded[offset : offset+netnsLength]
if !utf8.Valid(netnsBytes) {
return platform.DNSQuery{}, fmt.Errorf("DNS netns token is not UTF-8")
}
netns := string(netnsBytes)
query.NetNS = &netns
}
offset += netnsLength
if len(encoded)-offset < 4 {
return platform.DNSQuery{}, fmt.Errorf("missing DNS host length")
}
hostLengthWire := binary.BigEndian.Uint32(encoded[offset : offset+4])
offset += 4
if hostLengthWire == 0 || uint64(hostLengthWire) != uint64(len(encoded)-offset) {
return platform.DNSQuery{}, fmt.Errorf("invalid DNS host length")
}
host := encoded[offset:]
if !utf8.Valid(host) {
return platform.DNSQuery{}, fmt.Errorf("DNS host is not UTF-8")
}
query.Host = string(host)
return query, nil
}
func encodeDNSAddresses(addresses []netip.Addr) ([]byte, error) {
result := make([]byte, 4)
binary.BigEndian.PutUint32(result, uint32(len(addresses)))
for _, address := range addresses {
if address.Is4() {
ipv4 := address.As4()
result = append(result, 4)
result = append(result, ipv4[:]...)
continue
}
if !address.Is6() {
return nil, fmt.Errorf("invalid DNS address")
}
ipv6 := address.As16()
result = append(result, 6)
result = append(result, ipv6[:]...)
}
return boundedDNSResult(result)
}
func encodeDNSTXT(text string) ([]byte, error) {
if !utf8.ValidString(text) {
return nil, fmt.Errorf("normalized DNS TXT value is not UTF-8")
}
result := make([]byte, 4, 4+len(text))
binary.BigEndian.PutUint32(result, uint32(len(text)))
result = append(result, text...)
return boundedDNSResult(result)
}
func encodeDNSSRV(records []*net.SRV) ([]byte, error) {
result := make([]byte, 4)
binary.BigEndian.PutUint32(result, uint32(len(records)))
for _, record := range records {
if record == nil {
return nil, fmt.Errorf("nil DNS SRV record")
}
target := []byte(record.Target)
result = binary.BigEndian.AppendUint16(result, record.Priority)
result = binary.BigEndian.AppendUint16(result, record.Weight)
result = binary.BigEndian.AppendUint16(result, record.Port)
result = binary.BigEndian.AppendUint32(result, uint32(len(target)))
result = append(result, target...)
}
return boundedDNSResult(result)
}
func boundedDNSResult(result []byte) ([]byte, error) {
if len(result) == 0 || len(result) > maxDNSResultLen {
return nil, fmt.Errorf("DNS result exceeds bridge limit")
}
return result, nil
}
@@ -0,0 +1,65 @@
package hostabi
import (
"context"
"github.com/metacubex/wazero/api"
)
func (adapter *Adapter) startLocalAddrForRemote(
_ context.Context,
module api.Module,
operation uint64,
remotePointer uint32,
remoteLength uint32,
contextPointer uint32,
contextLength uint32,
) int32 {
if remoteLength != socketAddressLen {
return statusInvalid
}
encoded, ok := module.Memory().Read(remotePointer, remoteLength)
if !ok {
return statusMemory
}
remote, err := decodeSocketAddress(append([]byte(nil), encoded...), false)
if err != nil {
return statusInvalid
}
encodedContext, ok := readOwnedOptions(module, contextPointer, contextLength)
if !ok {
return statusMemory
}
socketContext, remainder, err := decodeSocketContext(encodedContext)
if err != nil || len(remainder) != 0 {
return statusInvalid
}
return operationStatus(
adapter.reactor.StartLocalAddrForRemote(operation, remote, socketContext),
)
}
func (adapter *Adapter) takeLocalAddrForRemote(
_ context.Context,
module api.Module,
operation uint64,
resultPointer uint32,
resultLength uint32,
) int32 {
if !validResultMemory(module, resultPointer, resultLength, socketAddressLen) {
_ = adapter.reactor.CancelOperation(operation)
return statusMemory
}
address, err := adapter.reactor.TakeLocalAddrForRemote(operation)
if err != nil {
return operationStatus(err)
}
encoded, err := encodeNetAddr(address)
if err != nil {
return statusInvalid
}
if !module.Memory().Write(resultPointer, encoded[:]) {
return statusMemory
}
return 0
}

Some files were not shown because too many files have changed in this diff Show More