mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-09-09 00:00:31 +00:00
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:
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(()));
|
||||
}
|
||||
|
||||
|
||||
@@ -15,3 +15,4 @@ pub mod packet;
|
||||
pub mod socket;
|
||||
#[cfg(test)]
|
||||
pub(crate) mod testkit;
|
||||
pub mod tunnel;
|
||||
|
||||
@@ -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<()>,
|
||||
|
||||
@@ -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<()>>;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<()>;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -7,3 +7,5 @@ pub mod event;
|
||||
pub mod management;
|
||||
pub mod packet;
|
||||
pub mod socket;
|
||||
#[cfg(feature = "wasm-host-tunnel")]
|
||||
pub mod tunnel;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 ./...
|
||||
```
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 => ../..
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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.
@@ -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):
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user