use a3s_box_core::dns::HostEntry;
use a3s_box_core::error::{BoxError, Result};
use crate::vmm::NetworkInstanceConfig;
use super::VmManager;
impl VmManager {
#[cfg(target_os = "macos")]
pub(crate) fn setup_published_default_network(&mut self) -> Result<NetworkInstanceConfig> {
let ip = std::net::Ipv4Addr::new(10, 89, 0, 2);
let gateway = std::net::Ipv4Addr::new(10, 89, 0, 1);
let prefix_len = 24;
let dns_servers: Vec<std::net::Ipv4Addr> = if self.config.dns.is_empty() {
vec![std::net::Ipv4Addr::new(8, 8, 8, 8)]
} else {
self.config
.dns
.iter()
.filter_map(|value| value.parse().ok())
.collect()
};
let box_dir = self.home_dir.join("boxes").join(&self.box_id);
let mut netproxy = crate::network::NetProxyManager::new(&box_dir);
netproxy.spawn(ip, gateway, prefix_len, &dns_servers, &self.config.port_map)?;
let config = NetworkInstanceConfig {
net_socket_path: netproxy.socket_path().to_path_buf(),
net_stats_path: Some(netproxy.stats_path().to_path_buf()),
net_socket_fd: netproxy.net_socket_fd(),
net_proxy_fd: netproxy.net_proxy_fd(),
bridge_socket_dir: None,
ip_address: ip,
gateway,
prefix_len,
mac_address: [0x02, 0x42, 0x0a, 0x59, 0x00, 0x02],
dns_servers,
};
self.net_manager = Some(Box::new(netproxy));
Ok(config)
}
pub(crate) fn write_hostname_file(&self, layout: &super::BoxLayout) -> Result<()> {
let Some(hostname) = self.config.hostname.as_deref() else {
return Ok(());
};
a3s_box_core::dns::validate_hostname(hostname).map_err(BoxError::ConfigError)?;
crate::oci::rootfs::write_guest_file(
&layout.rootfs_path,
"etc/hostname",
format!("{hostname}\n"),
)?;
Ok(())
}
pub(crate) fn write_standalone_hosts_file(&self, layout: &super::BoxLayout) -> Result<()> {
let add_hosts = self.parse_add_hosts()?;
let aliases = self.hostname_aliases(None);
if aliases.is_empty() && add_hosts.is_empty() {
return self.ensure_standalone_hosts_readable(layout);
}
self.write_hosts_content(layout, None, &aliases, &[], &add_hosts)
}
fn ensure_standalone_hosts_readable(&self, layout: &super::BoxLayout) -> Result<()> {
let hosts_path =
crate::oci::rootfs::resolve_guest_file_path(&layout.rootfs_path, "etc/hosts")?;
if !hosts_path.exists() {
return self.write_hosts_content(layout, None, &[], &[], &[]);
}
#[cfg(unix)]
std::fs::set_permissions(
&hosts_path,
<std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o644),
)
.map_err(|e| {
BoxError::NetworkError(format!(
"Failed to set permissions on {}: {}",
hosts_path.display(),
e
))
})?;
Ok(())
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
pub(crate) fn setup_bridge_network(
&mut self,
network_name: &str,
) -> Result<NetworkInstanceConfig> {
use crate::network::NetworkStore;
let store = NetworkStore::default_path()?;
let net_config = store.get(network_name)?.ok_or_else(|| {
BoxError::NetworkError(format!("network '{}' not found", network_name))
})?;
let endpoint = net_config
.endpoints
.get(&self.box_id)
.ok_or_else(|| BoxError::NetworkError(format!(
"box '{}' is not connected to network '{}'; run with --network or use 'network connect'",
self.box_id, network_name
)))?;
let ip = endpoint.ip_address;
let gateway = net_config.gateway;
let prefix_len: u8 = net_config
.subnet
.split('/')
.nth(1)
.and_then(|s| s.parse().ok())
.unwrap_or(24);
let mac_address = parse_mac(&endpoint.mac_address).map_err(|e| {
BoxError::NetworkError(format!(
"invalid MAC address '{}': {}",
endpoint.mac_address, e
))
})?;
let dns_servers: Vec<std::net::Ipv4Addr> = if !self.config.dns.is_empty() {
self.config
.dns
.iter()
.filter_map(|s| s.parse().ok())
.collect()
} else {
vec![std::net::Ipv4Addr::new(8, 8, 8, 8)]
};
#[cfg(target_os = "macos")]
let box_dir = self.home_dir.join("boxes").join(&self.box_id);
#[cfg(target_os = "linux")]
let (socket_path, net_stats_path, _net_socket_fd, _net_proxy_fd) = {
let passt_socket_dir = self.socket_dir();
let mut passt = crate::network::PasstManager::new(&passt_socket_dir);
passt.spawn(ip, gateway, prefix_len, &dns_servers, &self.config.port_map)?;
let path = passt.socket_path().to_path_buf();
self.net_manager = Some(Box::new(passt));
tracing::info!(network = network_name, ip = %ip, gateway = %gateway, "Bridge networking configured via passt");
(path, None, None::<i32>, None::<i32>)
};
#[cfg(target_os = "macos")]
let (socket_path, net_stats_path, net_socket_fd, net_proxy_fd) = {
let mut netproxy = crate::network::NetProxyManager::new(&box_dir);
netproxy.spawn(ip, gateway, prefix_len, &dns_servers, &self.config.port_map)?;
let fd = netproxy.net_socket_fd();
let proxy_fd = netproxy.net_proxy_fd();
let path = netproxy.socket_path().to_path_buf();
let stats_path = netproxy.stats_path().to_path_buf();
self.net_manager = Some(Box::new(netproxy));
tracing::info!(network = network_name, ip = %ip, gateway = %gateway, "Bridge networking configured via built-in netproxy");
(path, Some(stats_path), fd, proxy_fd)
};
Ok(NetworkInstanceConfig {
net_socket_path: socket_path,
net_stats_path,
#[cfg(target_os = "macos")]
net_socket_fd,
#[cfg(target_os = "macos")]
net_proxy_fd,
#[cfg(target_os = "macos")]
bridge_socket_dir: Some(macos_bridge_socket_dir(&self.home_dir, network_name)),
ip_address: ip,
gateway,
prefix_len,
mac_address,
dns_servers,
})
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
pub(crate) fn setup_bridge_network(
&mut self,
_network_name: &str,
) -> Result<NetworkInstanceConfig> {
Err(BoxError::NetworkError(
"Bridge networking (--network) is not supported on this platform".to_string(),
))
}
pub(crate) fn write_hosts_file(
&self,
layout: &super::BoxLayout,
network_name: &str,
) -> Result<()> {
use crate::network::NetworkStore;
let store = NetworkStore::default_path()?;
let net_config = store.get(network_name)?.ok_or_else(|| {
BoxError::NetworkError(format!("network '{}' not found", network_name))
})?;
let endpoint = net_config.endpoints.get(&self.box_id).ok_or_else(|| {
BoxError::NetworkError(format!(
"box '{}' not connected to network '{}'",
self.box_id, network_name
))
})?;
let own_ip = endpoint.ip_address.to_string();
let own_names = self.hostname_aliases(Some(&endpoint.box_name));
let peers = net_config.peer_endpoints(&self.box_id);
let add_hosts = self.parse_add_hosts()?;
self.write_hosts_content(layout, Some(&own_ip), &own_names, &peers, &add_hosts)?;
Ok(())
}
fn parse_add_hosts(&self) -> Result<Vec<HostEntry>> {
a3s_box_core::dns::parse_add_host_entries(&self.config.add_hosts)
.map_err(BoxError::ConfigError)
}
fn hostname_aliases(&self, default_name: Option<&str>) -> Vec<String> {
let mut aliases = Vec::new();
if let Some(default_name) = default_name.filter(|name| !name.is_empty()) {
aliases.push(default_name.to_string());
}
if let Some(hostname) = self
.config
.hostname
.as_deref()
.filter(|name| !name.is_empty())
{
if !aliases.iter().any(|alias| alias == hostname) {
aliases.push(hostname.to_string());
}
}
aliases
}
fn write_hosts_content(
&self,
layout: &super::BoxLayout,
own_ip: Option<&str>,
own_names: &[String],
peers: &[(String, String)],
add_hosts: &[HostEntry],
) -> Result<()> {
let hosts_content = a3s_box_core::dns::generate_hosts_file_with_entries(
own_ip, own_names, peers, add_hosts,
);
crate::oci::rootfs::write_guest_file(&layout.rootfs_path, "etc/hosts", &hosts_content)?;
tracing::debug!(hosts = %hosts_content.trim(), "Configured guest /etc/hosts for DNS discovery");
Ok(())
}
}
#[cfg(target_os = "macos")]
fn macos_bridge_socket_dir(home: &std::path::Path, network_name: &str) -> std::path::PathBuf {
use sha2::{Digest, Sha256};
let mut digest = Sha256::new();
digest.update(home.as_os_str().as_encoded_bytes());
digest.update([0]);
digest.update(network_name.as_bytes());
let key = hex::encode(digest.finalize());
let uid = unsafe { libc::getuid() };
std::path::PathBuf::from("/private/tmp/a3s-box-switches")
.join(uid.to_string())
.join(&key[..24])
}
#[cfg(any(target_os = "linux", target_os = "macos", test))]
pub(crate) fn parse_mac(mac_str: &str) -> std::result::Result<[u8; 6], String> {
let parts: Vec<&str> = mac_str.split(':').collect();
if parts.len() != 6 {
return Err(format!("expected 6 octets, got {}", parts.len()));
}
let mut mac = [0u8; 6];
for (i, part) in parts.iter().enumerate() {
mac[i] =
u8::from_str_radix(part, 16).map_err(|e| format!("invalid octet '{}': {}", part, e))?;
}
Ok(mac)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
use tempfile::TempDir;
#[cfg(unix)]
fn create_dir_symlink(target: &Path, link: &Path) -> bool {
std::os::unix::fs::symlink(target, link).unwrap();
true
}
#[cfg(windows)]
fn create_dir_symlink(target: &Path, link: &Path) -> bool {
match std::os::windows::fs::symlink_dir(target, link) {
Ok(()) => true,
Err(error) if error.raw_os_error() == Some(1314) => false,
Err(error) => panic!("failed to create Windows test symlink: {error}"),
}
}
fn test_layout(rootfs_path: std::path::PathBuf) -> super::super::BoxLayout {
super::super::BoxLayout {
rootfs_path,
exec_socket_path: std::path::PathBuf::new(),
pty_socket_path: std::path::PathBuf::new(),
attest_socket_path: std::path::PathBuf::new(),
port_forward_socket_path: std::path::PathBuf::new(),
workspace_path: std::path::PathBuf::new(),
console_output: None,
oci_config: None,
prefer_image_rootfs_metadata: false,
tee_instance_config: None,
}
}
#[test]
fn test_parse_mac_valid() {
let mac = parse_mac("02:42:0a:58:00:02").unwrap();
assert_eq!(mac, [0x02, 0x42, 0x0a, 0x58, 0x00, 0x02]);
}
#[test]
fn test_parse_mac_all_zeros() {
let mac = parse_mac("00:00:00:00:00:00").unwrap();
assert_eq!(mac, [0, 0, 0, 0, 0, 0]);
}
#[test]
fn test_parse_mac_all_ff() {
let mac = parse_mac("ff:ff:ff:ff:ff:ff").unwrap();
assert_eq!(mac, [0xff, 0xff, 0xff, 0xff, 0xff, 0xff]);
}
#[test]
fn test_parse_mac_too_few_octets() {
assert!(parse_mac("02:42:0a").is_err());
}
#[test]
fn test_parse_mac_too_many_octets() {
assert!(parse_mac("02:42:0a:58:00:02:ff").is_err());
}
#[test]
fn test_parse_mac_invalid_hex() {
assert!(parse_mac("02:42:zz:58:00:02").is_err());
}
#[test]
fn test_hostname_aliases_include_default_and_override() {
let vm = VmManager::with_box_id(
a3s_box_core::config::BoxConfig {
hostname: Some("web".to_string()),
..Default::default()
},
a3s_box_core::event::EventEmitter::new(16),
"box-id".to_string(),
);
assert_eq!(
vm.hostname_aliases(Some("box-name")),
vec!["box-name", "web"]
);
}
#[test]
fn test_write_standalone_hosts_file_with_hostname_and_add_host() {
let dir = TempDir::new().unwrap();
let layout = test_layout(dir.path().join("rootfs"));
let vm = VmManager::with_box_id(
a3s_box_core::config::BoxConfig {
hostname: Some("web".to_string()),
add_hosts: vec!["db.local:10.88.0.10".to_string()],
..Default::default()
},
a3s_box_core::event::EventEmitter::new(16),
"box-id".to_string(),
);
vm.write_standalone_hosts_file(&layout).unwrap();
let hosts = std::fs::read_to_string(layout.rootfs_path.join("etc/hosts")).unwrap();
assert!(hosts.contains("127.0.1.1 web"));
assert!(hosts.contains("10.88.0.10 db.local"));
}
#[cfg(unix)]
#[test]
fn test_write_standalone_hosts_file_repairs_restrictive_cached_mode() {
use std::os::unix::fs::PermissionsExt;
let dir = TempDir::new().unwrap();
let layout = test_layout(dir.path().join("rootfs"));
let hosts_path = layout.rootfs_path.join("etc/hosts");
std::fs::create_dir_all(hosts_path.parent().unwrap()).unwrap();
std::fs::write(&hosts_path, "127.0.0.1 localhost\n::1 localhost\n").unwrap();
std::fs::set_permissions(&hosts_path, std::fs::Permissions::from_mode(0o600)).unwrap();
let vm = VmManager::with_box_id(
a3s_box_core::config::BoxConfig::default(),
a3s_box_core::event::EventEmitter::new(16),
"box-id".to_string(),
);
vm.write_standalone_hosts_file(&layout).unwrap();
assert_eq!(
std::fs::read_to_string(&hosts_path).unwrap(),
"127.0.0.1 localhost\n::1 localhost\n"
);
assert_eq!(
std::fs::metadata(&hosts_path).unwrap().permissions().mode() & 0o777,
0o644
);
}
#[test]
fn test_write_hostname_file() {
let dir = TempDir::new().unwrap();
let layout = test_layout(dir.path().join("rootfs"));
let vm = VmManager::with_box_id(
a3s_box_core::config::BoxConfig {
hostname: Some("web".to_string()),
..Default::default()
},
a3s_box_core::event::EventEmitter::new(16),
"box-id".to_string(),
);
vm.write_hostname_file(&layout).unwrap();
assert_eq!(
std::fs::read_to_string(layout.rootfs_path.join("etc/hostname")).unwrap(),
"web\n"
);
}
#[test]
fn test_startup_files_follow_guest_symlinks_but_stay_inside_rootfs() {
let dir = TempDir::new().unwrap();
let rootfs = dir.path().join("rootfs");
std::fs::create_dir_all(rootfs.join("usr")).unwrap();
std::fs::create_dir_all(rootfs.join("shared/etc")).unwrap();
if !create_dir_symlink(Path::new("/usr/etc"), &rootfs.join("etc"))
|| !create_dir_symlink(Path::new("../shared/etc"), &rootfs.join("usr/etc"))
{
return;
}
let layout = test_layout(rootfs.clone());
let vm = VmManager::with_box_id(
a3s_box_core::config::BoxConfig {
hostname: Some("web".to_string()),
add_hosts: vec!["db.local:10.88.0.10".to_string()],
..Default::default()
},
a3s_box_core::event::EventEmitter::new(16),
"box-id".to_string(),
);
vm.write_hostname_file(&layout).unwrap();
vm.write_standalone_hosts_file(&layout).unwrap();
assert_eq!(
std::fs::read_to_string(rootfs.join("shared/etc/hostname")).unwrap(),
"web\n"
);
assert!(std::fs::read_to_string(rootfs.join("shared/etc/hosts"))
.unwrap()
.contains("10.88.0.10 db.local"));
}
#[test]
fn test_startup_files_reject_guest_symlink_escape() {
let dir = TempDir::new().unwrap();
let rootfs = dir.path().join("rootfs");
let outside = dir.path().join("outside");
std::fs::create_dir_all(&rootfs).unwrap();
std::fs::create_dir_all(&outside).unwrap();
if !create_dir_symlink(Path::new("../outside"), &rootfs.join("etc")) {
return;
}
let layout = test_layout(rootfs);
let vm = VmManager::with_box_id(
a3s_box_core::config::BoxConfig {
hostname: Some("web".to_string()),
..Default::default()
},
a3s_box_core::event::EventEmitter::new(16),
"box-id".to_string(),
);
let error = vm.write_hostname_file(&layout).unwrap_err().to_string();
assert!(error.contains("escapes rootfs"), "{error}");
assert!(!outside.join("hostname").exists());
}
}