Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,9 @@ See [Configuring Networking](./guides/README.md#configuring-networking) for deta

2. **Use host network:**
- Box A exposes port
- Box B connects to `host.docker.internal:port` (or localhost on Linux)
- Box B connects to `host.boxlite.internal:port`
- This bypasses `allow_net`; if networking is enabled, host loopback
services are reachable from inside the box

3. **External service:**
- Both boxes connect to Redis/database on host or network
Expand Down
22 changes: 22 additions & 0 deletions docs/guides/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,28 @@ async with boxlite.SimpleBox(image="alpine:latest") as box:
print(result.stdout)
```

**From Box to Host Loopback:**

```bash
# On the host, start a service bound to loopback
python3 -m http.server 8081 --bind 127.0.0.1
```

```python
async with boxlite.SimpleBox(image="alpine:latest") as box:
result = await box.exec(
"wget",
"-O-",
"http://host.boxlite.internal:8081",
)
print(result.stdout)
```

`host.boxlite.internal` is a built-in BoxLite hostname that resolves to the
host loopback proxy address. It is not a Docker compatibility alias.
Security note: any service bound to host loopback is reachable from inside the
box while networking is enabled.

### Network Metrics

Monitor network usage:
Expand Down
6 changes: 6 additions & 0 deletions docs/reference/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,10 @@ Structured network configuration for outbound connectivity.
- `"enabled"` gives the guest outbound connectivity.
- `"disabled"` removes the guest network interface entirely.
- Empty or omitted `allow_net` means full outbound access.
- `host.boxlite.internal` is always available as a built-in hostname for
reaching host loopback services and is not governed by `allow_net`.
- Security: when networking is enabled, any service bound to host loopback can
be reached from inside the box via `host.boxlite.internal` or `192.168.127.254`.

**Supported patterns:**
- Exact hostname: `"api.openai.com"`
Expand Down Expand Up @@ -307,6 +311,8 @@ ports=[
- Host port must be available (not in use)
- Multiple boxes can forward to same host port (error if conflict)
- Supports both TCP and UDP protocols
- Port mappings are only for host → box traffic. Use
`host.boxlite.internal:<port>` for box → host loopback traffic.

#### `auto_remove: bool`

Expand Down
29 changes: 29 additions & 0 deletions src/boxlite/src/net/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,23 @@ pub const GATEWAY_IP: &str = "192.168.127.1";
/// Guest IP address (assigned via DHCP static lease)
pub const GUEST_IP: &str = "192.168.127.2";

/// Built-in DNS label for reaching host loopback services from inside the box.
pub const HOST_ALIAS_LABEL: &str = "host";

/// Built-in DNS zone for reaching host loopback services from inside the box.
pub const HOST_ALIAS_ZONE: &str = "boxlite.internal.";

/// Built-in DNS hostname for reaching host loopback services from inside the box.
pub const HOST_HOSTNAME: &str = "host.boxlite.internal";

/// Virtual host IP address exposed inside the guest as the host endpoint.
///
/// gvproxy NATs traffic sent to this IP to `127.0.0.1` on the host.
/// The built-in [`HOST_HOSTNAME`] alias resolves to this address.
/// This destination is always allowed while networking is enabled, even when
/// `allow_net` would otherwise restrict outbound traffic.
pub const HOST_IP: &str = "192.168.127.254";

/// Guest IP with subnet prefix (for static IP assignment in guest)
pub const GUEST_CIDR: &str = "192.168.127.2/24";

Expand Down Expand Up @@ -75,4 +92,16 @@ mod tests {
assert_eq!(GUEST_MAC[5], 0xee);
assert_eq!(GATEWAY_MAC[5], 0xdd);
}

#[test]
fn test_host_alias_components_match_hostname() {
assert_eq!(
HOST_HOSTNAME,
format!(
"{}.{}",
HOST_ALIAS_LABEL,
HOST_ALIAS_ZONE.trim_end_matches('.')
)
);
}
}
119 changes: 113 additions & 6 deletions src/boxlite/src/net/gvproxy/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,26 @@ use std::path::PathBuf;
///
/// Defines local DNS records served by the gateway's embedded DNS server.
/// Queries not matching any zone are forwarded to the host's system DNS.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DnsZone {
/// Zone name (e.g., "myapp.local.", "." for root)
pub name: String,
/// Exact A records within this zone.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub records: Vec<DnsRecord>,
/// Default IP for unmatched queries in this zone
pub default_ip: String,
}

/// Exact A record within a local DNS zone.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DnsRecord {
/// Record label within the zone (e.g., "host" in "boxlite.internal.")
pub name: String,
/// IPv4 address returned for this record
pub ip: String,
}

/// Port mapping configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PortMapping {
Expand Down Expand Up @@ -46,6 +58,9 @@ pub struct GvproxyConfig {
/// Guest IP address
pub guest_ip: String,

/// Virtual host IP address that routes to host loopback services
pub host_ip: String,

/// Guest MAC address
pub guest_mac: String,

Expand Down Expand Up @@ -130,10 +145,11 @@ fn defaults_with_socket_path(socket_path: PathBuf) -> GvproxyConfig {
gateway_ip: GATEWAY_IP.to_string(),
gateway_mac: GATEWAY_MAC_STRING.to_string(),
guest_ip: GUEST_IP.to_string(),
host_ip: HOST_IP.to_string(),
guest_mac: GUEST_MAC_STRING.to_string(),
mtu: DEFAULT_MTU,
port_mappings: Vec::new(),
dns_zones: Vec::new(),
dns_zones: vec![boxlite_internal_dns_zone()],
dns_search_domains: DNS_SEARCH_DOMAINS.iter().map(|s| s.to_string()).collect(),
debug: false,
capture_file: None,
Expand All @@ -144,6 +160,20 @@ fn defaults_with_socket_path(socket_path: PathBuf) -> GvproxyConfig {
}
}

fn boxlite_internal_dns_zone() -> DnsZone {
use crate::net::constants::{HOST_ALIAS_LABEL, HOST_ALIAS_ZONE, HOST_IP};

DnsZone {
name: HOST_ALIAS_ZONE.to_string(),
records: vec![DnsRecord {
name: HOST_ALIAS_LABEL.to_string(),
ip: HOST_IP.to_string(),
}],
// Empty default_ip means this zone only serves the exact `host` record.
default_ip: String::new(),
}
}

impl GvproxyConfig {
/// Create a new configuration with the given socket path and port mappings
///
Expand Down Expand Up @@ -191,9 +221,11 @@ impl GvproxyConfig {
self
}

/// Set custom DNS zones
/// Add custom DNS zones after the built-in BoxLite zones.
///
/// This method appends; repeated calls keep earlier zones in place.
pub fn with_dns_zones(mut self, dns_zones: Vec<DnsZone>) -> Self {
self.dns_zones = dns_zones;
self.dns_zones.extend(dns_zones);
self
}

Expand All @@ -212,8 +244,9 @@ impl GvproxyConfig {
///
/// ```no_run
/// use boxlite::net::gvproxy::GvproxyConfig;
/// use std::path::PathBuf;
///
/// let config = GvproxyConfig::new(vec![(8080, 80)])
/// let config = GvproxyConfig::new(PathBuf::from("/tmp/network.sock"), vec![(8080, 80)])
/// .with_capture_file("/tmp/network.pcap".to_string());
/// ```
pub fn with_capture_file(mut self, capture_file: String) -> Self {
Expand Down Expand Up @@ -244,6 +277,7 @@ impl GvproxyConfig {
#[cfg(test)]
mod tests {
use super::*;
use crate::net::constants::{HOST_ALIAS_LABEL, HOST_ALIAS_ZONE, HOST_HOSTNAME, HOST_IP};

fn test_socket_path() -> PathBuf {
PathBuf::from("/tmp/test-gvproxy.sock")
Expand All @@ -256,9 +290,19 @@ mod tests {
assert_eq!(config.subnet, "192.168.127.0/24");
assert_eq!(config.gateway_ip, "192.168.127.1");
assert_eq!(config.guest_ip, "192.168.127.2");
assert_eq!(config.host_ip, HOST_IP);
assert_eq!(config.mtu, 1500);
assert!(!config.debug);
assert!(config.dns_zones.is_empty());
assert_eq!(config.dns_zones.len(), 1);
assert_eq!(config.dns_zones[0].name, HOST_ALIAS_ZONE);
assert_eq!(
config.dns_zones[0].records,
vec![DnsRecord {
name: HOST_ALIAS_LABEL.to_string(),
ip: HOST_IP.to_string(),
}]
);
assert!(config.dns_zones[0].default_ip.is_empty());
}

#[test]
Expand Down Expand Up @@ -286,7 +330,9 @@ mod tests {
let deserialized: GvproxyConfig = serde_json::from_str(&json).unwrap();
assert_eq!(config.subnet, deserialized.subnet);
assert_eq!(config.socket_path, deserialized.socket_path);
assert_eq!(config.host_ip, deserialized.host_ip);
assert_eq!(config.port_mappings.len(), deserialized.port_mappings.len());
assert_eq!(config.dns_zones, deserialized.dns_zones);
}

#[test]
Expand Down Expand Up @@ -453,4 +499,65 @@ mod tests {
);
assert_ne!(config_a.socket_path, config_b.socket_path);
}

#[test]
fn test_with_dns_zones_preserves_builtin_host_alias() {
let config = GvproxyConfig::new(test_socket_path(), vec![]).with_dns_zones(vec![DnsZone {
name: "example.internal.".to_string(),
records: vec![DnsRecord {
name: "api".to_string(),
ip: "192.168.127.10".to_string(),
}],
default_ip: String::new(),
}]);

assert_eq!(config.dns_zones.len(), 2);
assert_eq!(config.dns_zones[0].name, HOST_ALIAS_ZONE);
assert_eq!(config.dns_zones[1].name, "example.internal.");
}

#[test]
fn test_with_dns_zones_appends_across_multiple_calls() {
let config = GvproxyConfig::new(test_socket_path(), vec![])
.with_dns_zones(vec![DnsZone {
name: "one.internal.".to_string(),
records: vec![],
default_ip: "192.168.127.10".to_string(),
}])
.with_dns_zones(vec![DnsZone {
name: "two.internal.".to_string(),
records: vec![],
default_ip: "192.168.127.11".to_string(),
}]);

assert_eq!(config.dns_zones.len(), 3);
assert_eq!(config.dns_zones[0].name, HOST_ALIAS_ZONE);
assert_eq!(config.dns_zones[1].name, "one.internal.");
assert_eq!(config.dns_zones[2].name, "two.internal.");
}

#[test]
fn test_serialization_contains_host_alias_record() {
let config = GvproxyConfig::new(test_socket_path(), vec![]);
let json = serde_json::to_string(&config).unwrap();

assert!(json.contains(&format!("\"host_ip\":\"{HOST_IP}\"")));
assert!(json.contains(&format!("\"name\":\"{HOST_ALIAS_ZONE}\"")));
assert!(json.contains("\"records\""));
assert!(json.contains(&format!("\"name\":\"{HOST_ALIAS_LABEL}\"")));
assert!(json.contains(&format!("\"ip\":\"{HOST_IP}\"")));
}

#[test]
fn test_host_hostname_matches_built_in_zone() {
let zone = boxlite_internal_dns_zone();
let record = zone.records.first().expect("built-in host alias record");

assert_eq!(zone.name, HOST_ALIAS_ZONE);
assert_eq!(record.name, HOST_ALIAS_LABEL);
assert_eq!(
HOST_HOSTNAME,
format!("{}.{}", record.name, zone.name.trim_end_matches('.'))
);
}
}
40 changes: 6 additions & 34 deletions src/boxlite/src/net/gvproxy/instance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,20 +38,10 @@ use super::stats::NetworkStats;
///
/// ## Example
///
/// ```no_run
/// use boxlite::net::gvproxy::GvproxyInstance;
/// use std::path::PathBuf;
///
/// // Create instance with caller-provided socket path
/// let socket_path = PathBuf::from("/tmp/my-box/net.sock");
/// let instance = GvproxyInstance::new(socket_path, &[(8080, 80), (8443, 443)], vec![], vec![], None, None)?;
///
/// // Socket path is known from creation — no FFI call needed
/// println!("Socket: {:?}", instance.socket_path());
///
/// // Instance is automatically cleaned up when dropped
/// # Ok::<(), boxlite_shared::errors::BoxliteError>(())
/// ```
/// `GvproxyInstance` is created internally by BoxLite's gvproxy backend during
/// box startup. Once initialized, the instance exposes its socket path via
/// [`GvproxyInstance::socket_path`] and automatically destroys the underlying
/// gvproxy handle on drop.
#[derive(Debug)]
pub struct GvproxyInstance {
id: i64,
Expand Down Expand Up @@ -146,26 +136,8 @@ impl GvproxyInstance {
/// - VirtualNetwork not initialized yet (too early)
/// - JSON parsing failed
///
/// # Example
///
/// ```no_run
/// use boxlite::net::gvproxy::GvproxyInstance;
///
/// let instance = GvproxyInstance::new(path, &[(8080, 80)], vec![], vec![])?;
/// let stats = instance.get_stats()?;
///
/// // Check for packet drops due to maxInFlight limit
/// if stats.tcp.forward_max_inflight_drop > 0 {
/// tracing::warn!(
/// drops = stats.tcp.forward_max_inflight_drop,
/// "Connections dropped - consider increasing maxInFlight"
/// );
/// }
///
/// println!("Sent: {} bytes, Received: {} bytes",
/// stats.bytes_sent, stats.bytes_received);
/// # Ok::<(), boxlite_shared::errors::BoxliteError>(())
/// ```
/// Call this on an existing gvproxy instance to inspect bandwidth counters
/// and debugging metrics such as `forward_max_inflight_drop`.
pub fn get_stats(&self) -> BoxliteResult<NetworkStats> {
// Get JSON from FFI layer
let json_str = ffi::get_stats_json(self.id)?;
Expand Down
2 changes: 1 addition & 1 deletion src/boxlite/src/net/gvproxy/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ use std::path::PathBuf;
use std::sync::Arc;

// Re-export public API
pub use config::{DnsZone, GvproxyConfig, GvproxySecretConfig, PortMapping};
pub use config::{DnsRecord, DnsZone, GvproxyConfig, GvproxySecretConfig, PortMapping};
pub use instance::GvproxyInstance;
pub use logging::init_logging;
pub use stats::{NetworkStats, TcpStats};
Expand Down
Loading
Loading