use crate::runtime::advanced_options::NetworkRateLimit;
use async_trait::async_trait;
use boxlite_shared::errors::{BoxliteError, BoxliteResult};
use serde_json::Value;
use std::io;
use std::net::SocketAddr;
use std::os::fd::OwnedFd;
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
use tokio::net::UnixStream;
pub(crate) mod ca;
pub mod constants;
pub mod socket_path;
pub mod gvproxy;
pub use gvproxy::GvproxyBackend;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub enum NetworkBackendEndpoint {
UnixSocket {
path: PathBuf,
connection_type: ConnectionType,
mac_address: [u8; 6],
},
}
#[derive(Debug, Clone)]
pub struct NetworkBackendConfig {
pub socket_path: PathBuf,
pub allow_net: Vec<String>,
pub secrets: Vec<crate::runtime::options::Secret>,
pub ca_dir: PathBuf,
pub rate_limit: NetworkRateLimit,
}
#[derive(Clone, serde::Serialize, serde::Deserialize)]
pub struct NetworkBackendSpec {
pub socket_path: PathBuf,
#[serde(default)]
pub allow_net: Vec<String>,
#[serde(default)]
pub secrets: Vec<crate::runtime::options::Secret>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ca_cert_pem: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ca_key_pem: Option<String>,
#[serde(default, skip_serializing_if = "NetworkRateLimit::is_unlimited")]
pub rate_limit: NetworkRateLimit,
}
impl std::fmt::Debug for NetworkBackendSpec {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("NetworkBackendSpec")
.field("socket_path", &self.socket_path)
.field("allow_net", &self.allow_net)
.field("secrets", &self.secrets)
.field(
"ca_cert_pem",
&self.ca_cert_pem.as_ref().map(|_| "[REDACTED]"),
)
.field(
"ca_key_pem",
&self.ca_key_pem.as_ref().map(|_| "[REDACTED]"),
)
.field("rate_limit", &self.rate_limit)
.finish()
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Forward {
pub local: String,
pub remote: String,
#[serde(default)]
pub protocol: String,
}
#[derive(
Debug, Clone, Copy, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize,
)]
#[serde(rename_all = "lowercase")]
pub enum TransportProtocol {
#[default]
Tcp,
Udp,
Unix,
Npipe,
}
impl TransportProtocol {
pub fn as_str(self) -> &'static str {
match self {
TransportProtocol::Tcp => "tcp",
TransportProtocol::Udp => "udp",
TransportProtocol::Unix => "unix",
TransportProtocol::Npipe => "npipe",
}
}
pub fn from_wire(token: &str) -> Option<Self> {
match token {
"tcp" => Some(TransportProtocol::Tcp),
"udp" => Some(TransportProtocol::Udp),
"unix" => Some(TransportProtocol::Unix),
"npipe" => Some(TransportProtocol::Npipe),
_ => None,
}
}
}
impl From<crate::runtime::options::PortProtocol> for TransportProtocol {
fn from(protocol: crate::runtime::options::PortProtocol) -> Self {
match protocol {
crate::runtime::options::PortProtocol::Tcp => TransportProtocol::Tcp,
crate::runtime::options::PortProtocol::Udp => TransportProtocol::Udp,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DnsZoneSpec {
pub name: String,
pub records: Vec<DnsRecordSpec>,
pub default_ip: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DnsRecordSpec {
pub name: String,
pub ip: String,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct NetworkBackendStats {
pub(crate) bytes_sent: u64,
pub(crate) bytes_received: u64,
pub(crate) tcp_established: u64,
pub(crate) tcp_failed_connections: u64,
pub(crate) tcp_retransmits: u64,
pub(crate) tcp_timeouts: u64,
pub(crate) tcp_forward_max_inflight_drop: u64,
}
impl NetworkBackendStats {
pub fn bytes_sent(&self) -> u64 {
self.bytes_sent
}
pub fn bytes_received(&self) -> u64 {
self.bytes_received
}
pub fn tcp_established(&self) -> u64 {
self.tcp_established
}
pub fn tcp_failed_connections(&self) -> u64 {
self.tcp_failed_connections
}
pub fn tcp_retransmits(&self) -> u64 {
self.tcp_retransmits
}
pub fn tcp_timeouts(&self) -> u64 {
self.tcp_timeouts
}
pub fn tcp_forward_max_inflight_drop(&self) -> u64 {
self.tcp_forward_max_inflight_drop
}
}
pub(crate) enum TunnelStream {
Local(UnixStream),
}
pub struct BoxInternalTunnel {
stream: TunnelStream,
peer: SocketAddr,
}
impl BoxInternalTunnel {
pub fn from_local(stream: UnixStream, peer: SocketAddr) -> Self {
Self {
stream: TunnelStream::Local(stream),
peer,
}
}
pub fn peer_addr(&self) -> SocketAddr {
self.peer
}
pub(crate) fn into_owned_fd(self) -> BoxliteResult<OwnedFd> {
match self.stream {
TunnelStream::Local(stream) => stream.into_std().map(OwnedFd::from).map_err(|error| {
BoxliteError::Network(format!("detach tunnel socket for handoff: {error}"))
}),
}
}
}
impl std::fmt::Debug for BoxInternalTunnel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("BoxInternalTunnel")
.field("peer", &self.peer)
.finish_non_exhaustive()
}
}
impl AsyncRead for BoxInternalTunnel {
fn poll_read(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<io::Result<()>> {
match &mut self.get_mut().stream {
TunnelStream::Local(s) => Pin::new(s).poll_read(cx, buf),
}
}
}
impl AsyncWrite for BoxInternalTunnel {
fn poll_write(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<io::Result<usize>> {
match &mut self.get_mut().stream {
TunnelStream::Local(s) => Pin::new(s).poll_write(cx, buf),
}
}
fn poll_write_vectored(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
bufs: &[io::IoSlice<'_>],
) -> Poll<io::Result<usize>> {
match &mut self.get_mut().stream {
TunnelStream::Local(s) => Pin::new(s).poll_write_vectored(cx, bufs),
}
}
fn is_write_vectored(&self) -> bool {
match &self.stream {
TunnelStream::Local(s) => s.is_write_vectored(),
}
}
fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
match &mut self.get_mut().stream {
TunnelStream::Local(s) => Pin::new(s).poll_flush(cx),
}
}
fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
match &mut self.get_mut().stream {
TunnelStream::Local(s) => Pin::new(s).poll_shutdown(cx),
}
}
}
fn control_unsupported(op: &str) -> BoxliteError {
BoxliteError::Unsupported(format!(
"network backend does not support runtime control ({op})"
))
}
#[async_trait]
pub trait NetworkBackend: Send + Sync + std::fmt::Debug {
fn name(&self) -> &'static str;
fn spec(&self) -> NetworkBackendSpec;
async fn expose(
&self,
_local: &str,
_remote: &str,
_protocol: TransportProtocol,
) -> BoxliteResult<Forward> {
Err(control_unsupported("expose"))
}
async fn unexpose(&self, _local: &str, _protocol: TransportProtocol) -> BoxliteResult<()> {
Err(control_unsupported("unexpose"))
}
async fn list_forwards(&self) -> BoxliteResult<Vec<Forward>> {
Err(control_unsupported("list_forwards"))
}
async fn add_dns_zone(&self, _zone: DnsZoneSpec) -> BoxliteResult<()> {
Err(control_unsupported("add_dns_zone"))
}
async fn dns_zones(&self) -> BoxliteResult<Value> {
Err(control_unsupported("dns_zones"))
}
async fn dhcp_leases(&self) -> BoxliteResult<Value> {
Err(control_unsupported("dhcp_leases"))
}
async fn cam(&self) -> BoxliteResult<Value> {
Err(control_unsupported("cam"))
}
async fn stats(&self) -> BoxliteResult<NetworkBackendStats> {
Err(control_unsupported("stats"))
}
async fn tunnel(&self, _target: SocketAddr) -> BoxliteResult<BoxInternalTunnel> {
Err(control_unsupported("tunnel"))
}
}
#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
pub enum ConnectionType {
UnixStream,
UnixDgram,
}
pub trait NetworkBackendFactory: Send + Sync {
fn create(&self, config: &NetworkBackendConfig) -> Option<Box<dyn NetworkBackend>>;
}
pub struct NoBackendFactory;
impl NetworkBackendFactory for NoBackendFactory {
fn create(&self, _: &NetworkBackendConfig) -> Option<Box<dyn NetworkBackend>> {
None
}
}
pub fn default_factory() -> Arc<dyn NetworkBackendFactory> {
Arc::new(gvproxy::GvproxyFactory)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn debug_redacts_ca_pem_fields() {
let key_sentinel = "----BEGIN PRIVATE KEY----TOPSECRETPKCS8";
let cert_sentinel = "----BEGIN CERTIFICATE----TOPSECRETCERT";
let spec = NetworkBackendSpec {
socket_path: PathBuf::from("/tmp/test-net.sock"),
allow_net: Vec::new(),
secrets: Vec::new(),
ca_cert_pem: Some(cert_sentinel.to_string()),
ca_key_pem: Some(key_sentinel.to_string()),
rate_limit: Default::default(),
};
let rendered = format!("{:?}", spec);
assert!(
!rendered.contains(key_sentinel),
"Debug leaked ca_key_pem: {}",
rendered
);
assert!(
!rendered.contains(cert_sentinel),
"Debug leaked ca_cert_pem: {}",
rendered
);
assert!(
rendered.contains("[REDACTED]"),
"expected redaction marker, got: {}",
rendered
);
}
#[test]
fn spec_serde_carries_ca_pems_that_debug_redacts() {
let spec = NetworkBackendSpec {
socket_path: PathBuf::from("/tmp/net.sock"),
allow_net: Vec::new(),
secrets: Vec::new(),
ca_cert_pem: Some("CERTDATA".to_string()),
ca_key_pem: Some("KEYDATA".to_string()),
rate_limit: Default::default(),
};
let json = serde_json::to_string(&spec).unwrap();
let back: NetworkBackendSpec = serde_json::from_str(&json).unwrap();
assert_eq!(back.ca_cert_pem.as_deref(), Some("CERTDATA"));
assert_eq!(back.ca_key_pem.as_deref(), Some("KEYDATA"));
}
#[test]
fn spec_serde_defaults_new_optional_fields_for_legacy_payloads() {
let json = r#"{"port_mappings":[[8080,80]],"socket_path":"/tmp/net.sock"}"#;
let spec: NetworkBackendSpec = serde_json::from_str(json).unwrap();
assert_eq!(spec.socket_path, PathBuf::from("/tmp/net.sock"));
assert!(spec.allow_net.is_empty());
assert!(spec.secrets.is_empty());
assert!(spec.ca_cert_pem.is_none());
assert!(spec.ca_key_pem.is_none());
assert!(spec.rate_limit.is_unlimited());
}
#[test]
fn spec_round_trips_rate_limit_and_omits_it_when_unlimited() {
let spec = NetworkBackendSpec {
socket_path: PathBuf::from("/tmp/net.sock"),
allow_net: Vec::new(),
secrets: Vec::new(),
ca_cert_pem: None,
ca_key_pem: None,
rate_limit: NetworkRateLimit {
tx_kbps: Some(10_000),
rx_kbps: None,
},
};
let json = serde_json::to_string(&spec).unwrap();
let back: NetworkBackendSpec = serde_json::from_str(&json).unwrap();
assert_eq!(back.rate_limit.tx_kbps, Some(10_000));
assert_eq!(back.rate_limit.rx_kbps, None);
let unlimited = NetworkBackendSpec {
rate_limit: NetworkRateLimit::default(),
..spec
};
let json = serde_json::to_string(&unlimited).unwrap();
assert!(
!json.contains("rate_limit"),
"an unlimited cap must be omitted; got: {json}"
);
}
#[test]
fn default_factory_creates_runtime_gvproxy_backend_without_ffi_feature() {
let config = NetworkBackendConfig {
socket_path: PathBuf::from("/tmp/default-factory/net.sock"),
allow_net: vec!["example.com".to_string()],
secrets: Vec::new(),
ca_dir: PathBuf::from("/tmp/default-factory/ca"),
rate_limit: Default::default(),
};
let backend = default_factory()
.create(&config)
.expect("runtime-side gvproxy backend");
let spec = backend.spec();
assert_eq!(backend.name(), "gvisor-tap-vsock");
assert_eq!(spec.socket_path, config.socket_path);
assert_eq!(spec.allow_net, config.allow_net);
assert!(spec.ca_cert_pem.is_none());
assert!(spec.ca_key_pem.is_none());
}
#[test]
fn sdk_and_cli_manifests_do_not_enable_shim_gvproxy_feature() {
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let repo_root = manifest_dir
.parent()
.and_then(|src_dir| src_dir.parent())
.expect("boxlite crate lives under src/boxlite");
let runtime_consumers = [
"src/cli/Cargo.toml",
"sdks/python/Cargo.toml",
"sdks/node/Cargo.toml",
"sdks/c/Cargo.toml",
];
for relative_manifest in runtime_consumers {
let manifest_path = repo_root.join(relative_manifest);
let manifest = std::fs::read_to_string(&manifest_path)
.unwrap_or_else(|e| panic!("read {}: {e}", manifest_path.display()));
let parsed: toml::Value = toml::from_str(&manifest)
.unwrap_or_else(|e| panic!("parse {}: {e}", manifest_path.display()));
assert_manifest_has_no_gvproxy_feature(relative_manifest, &parsed);
}
}
fn assert_manifest_has_no_gvproxy_feature(relative_manifest: &str, value: &toml::Value) {
let Some(table) = value.as_table() else {
return;
};
if let Some(boxlite_dependency) = table.get("boxlite") {
let features = boxlite_dependency
.as_table()
.and_then(|dep| dep.get("features"))
.and_then(toml::Value::as_array);
if features.is_some_and(|features| {
features
.iter()
.any(|feature| feature.as_str() == Some("gvproxy"))
}) {
panic!(
"{relative_manifest} must not enable boxlite/gvproxy; only boxlite-shim should link libgvproxy-sys"
);
}
}
for child in table.values() {
assert_manifest_has_no_gvproxy_feature(relative_manifest, child);
}
}
#[derive(Debug)]
struct UnsupportedBackend;
#[async_trait::async_trait]
impl NetworkBackend for UnsupportedBackend {
fn name(&self) -> &'static str {
"unsupported-test"
}
fn spec(&self) -> NetworkBackendSpec {
NetworkBackendSpec {
socket_path: PathBuf::from("/tmp/net.sock"),
allow_net: Vec::new(),
secrets: Vec::new(),
ca_cert_pem: None,
ca_key_pem: None,
rate_limit: Default::default(),
}
}
}
fn assert_unsupported<T: std::fmt::Debug>(result: BoxliteResult<T>, op: &str) {
let err = result.unwrap_err();
let err = format!("{err}");
assert!(err.contains("runtime control"), "err: {err}");
assert!(err.contains(op), "err: {err}");
}
#[tokio::test]
async fn default_control_methods_report_unsupported_operation() {
let backend = UnsupportedBackend;
let target: SocketAddr = "192.168.127.2:8080".parse().unwrap();
let zone = DnsZoneSpec {
name: "svc.local.".to_string(),
records: vec![DnsRecordSpec {
name: "api".to_string(),
ip: "192.168.127.10".to_string(),
}],
default_ip: None,
};
assert_unsupported(
backend
.expose(
"127.0.0.1:18080",
"192.168.127.2:80",
TransportProtocol::Tcp,
)
.await,
"expose",
);
assert_unsupported(
backend
.unexpose("127.0.0.1:18080", TransportProtocol::Tcp)
.await,
"unexpose",
);
assert_unsupported(backend.list_forwards().await, "list_forwards");
assert_unsupported(backend.add_dns_zone(zone).await, "add_dns_zone");
assert_unsupported(backend.dns_zones().await, "dns_zones");
assert_unsupported(backend.dhcp_leases().await, "dhcp_leases");
assert_unsupported(backend.cam().await, "cam");
assert_unsupported(backend.stats().await, "stats");
assert_unsupported(backend.tunnel(target).await, "tunnel");
}
#[tokio::test]
async fn box_tunnel_pipes_bytes_and_carries_peer() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let (near, mut far) = UnixStream::pair().unwrap();
let peer: SocketAddr = "192.168.127.2:8080".parse().unwrap();
let mut tunnel = BoxInternalTunnel::from_local(near, peer);
assert_eq!(tunnel.peer_addr(), peer);
tunnel.write_all(b"ping").await.unwrap();
let mut got = [0u8; 4];
far.read_exact(&mut got).await.unwrap();
assert_eq!(&got, b"ping");
far.write_all(b"pong").await.unwrap();
let mut back = [0u8; 4];
tunnel.read_exact(&mut back).await.unwrap();
assert_eq!(&back, b"pong");
}
}