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
16 changes: 14 additions & 2 deletions crates/ember-core/src/backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -337,11 +337,23 @@ pub trait NetworkBackend {

/// Tear down networking for a VM.
///
/// Linux: removes iptables rules (matched by per-installation
/// comment), deletes TAP device, releases IP.
/// Linux: removes the VM's iptables rules, deletes its TAP device,
/// releases its IP.
/// macOS: no-op (vmnet cleans up automatically).
fn teardown(&self, vm: &VmMetadata, config: &GlobalConfig) -> Result<()>;

/// Remove host-wide network state owned by this installation.
///
/// Called from `ember deinit`, which refuses to run while any VM is
/// registered, so no per-VM state is left to consider.
///
/// Linux: removes the installation's firewall chains.
///
/// Default: no-op, for backends that keep no host-wide state.
fn deinit(&self, _config: &GlobalConfig) -> Result<()> {
Ok(())
}

/// Discover the guest's IP address from its MAC address.
///
/// Only meaningful on platforms where the guest IP is dynamically assigned
Expand Down
11 changes: 11 additions & 0 deletions crates/ember-core/src/state/vm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,16 @@ pub struct NetworkInfo {
/// even if the default route changes between start and stop.
#[serde(default)]
pub wan_iface: Option<String>,
/// iptables chain holding this VM's forwarding rules (Linux only).
///
/// `None` means the rules were appended to the built-in FORWARD
/// chain, tagged with the per-installation comment, which is what
/// binaries predating the install-owned policy chains wrote.
/// Teardown of such a VM has to delete them from there, so where a
/// VM's rules live is recorded per VM rather than derived from the
/// current config.
#[serde(default)]
pub firewall_chain: Option<String>,
}

/// SSH connection configuration for a VM.
Expand Down Expand Up @@ -529,6 +539,7 @@ mod tests {
netmask: "255.255.255.252".to_string(),
guest_mac: Some("AA:FC:00:00:00:01".to_string()),
wan_iface: Some("eth0".to_string()),
firewall_chain: Some("ember-a3f4-forward".to_string()),
});
vm.status = VmStatus::Running;
vm.pid = Some(42);
Expand Down
8 changes: 4 additions & 4 deletions crates/ember-linux/src/dm_thin_storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -224,17 +224,17 @@ impl DmThinStorage {
pool::PoolMode::ReadWrite => Ok(()),
pool::PoolMode::ReadOnly => Err(Error::Pool(format!(
"dm-thin pool '{}' is read-only — run `thin_check` and `thin_repair` to recover",
&self.pool_name
self.pool_name
))),
pool::PoolMode::OutOfDataSpace => Err(Error::Pool(format!(
"dm-thin pool '{}' is out of data space ({}/{} blocks used) — run `ember storage grow --size <bigger>` to extend it",
&self.pool_name,
self.pool_name,
status.used_data_blocks,
status.total_data_blocks,
))),
pool::PoolMode::Failed => Err(Error::Pool(format!(
"dm-thin pool '{}' has failed — inspect dmesg and `thin_check` the metadata device",
&self.pool_name
self.pool_name
))),
}
}
Expand Down Expand Up @@ -622,7 +622,7 @@ impl StorageBackend for DmThinStorage {
let _ = fs::remove_dir(&self.storage_path);
}
}
println!("dm-thin pool '{}' torn down.", &self.pool_name);
println!("dm-thin pool '{}' torn down.", self.pool_name);
Ok(())
}

Expand Down
27 changes: 17 additions & 10 deletions crates/ember-linux/src/network.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
pub mod dns;
pub mod ip;
pub mod iptables;
pub mod nat;
pub mod policy;
pub mod tap;
pub mod wan;

Expand All @@ -10,19 +12,24 @@ use ember_core::state::vm::NetworkInfo;

/// Best-effort cleanup of networking resources for a VM (Linux only).
///
/// The iptables comment is derived via [`nat::comment`] from the
/// install's namespace so the `-D` calls only match this
/// installation's rules even when another ember install on the same
/// host has rules for the same TAP/IP.
/// Rules are removed in the shape they were added: `net_info` records
/// both the WAN interface captured at start and the chain the VM's
/// forwarding rules went into, so a default-route change or a binary
/// upgrade between start and stop cannot turn the `-D` calls into
/// silent no-ops. The iptables comment comes from [`nat::comment`] so
/// deletions in shared chains only ever match this installation's
/// rules.
pub fn cleanup(store: &StateStore, config: &GlobalConfig, vm_name: &str, net_info: &NetworkInfo) {
let wan_iface = net_info.wan_iface.clone().or_else(|| wan::detect().ok());
if let Some(wan_iface) = wan_iface {
let _ = nat::remove_rules(
&net_info.tap_device,
&net_info.guest_ip,
&wan_iface,
&nat::comment(config.instance_namespace()),
);
nat::VmRules {
chain: net_info.firewall_chain.as_deref(),
tap_device: &net_info.tap_device,
guest_ip: &net_info.guest_ip,
wan_iface: &wan_iface,
comment: &nat::comment(config.instance_namespace()),
}
.remove();
}
let _ = tap::delete(&net_info.tap_device);
let _ = ip::release(store, vm_name);
Expand Down
135 changes: 116 additions & 19 deletions crates/ember-linux/src/network/dns.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,32 +30,85 @@ const MAX_NAMESERVERS: usize = 2;
/// 3. `/etc/resolv.conf` — direct resolv.conf
/// 4. Fallback to 1.1.1.1 + 8.8.8.8
///
/// Filters out IPv6 addresses (VMs only have IPv4) and loopback
/// addresses (unreachable from the guest).
/// Filters out IPv6 addresses (VMs only have IPv4), loopback
/// addresses, and the host's own addresses. All three are unreachable
/// from a guest, the last because [`super::policy`] blocks VM-to-host
/// traffic, so a host running its own resolver (dnsmasq or a pihole
/// bound to the LAN address) would otherwise hand every guest a
/// nameserver that answers nothing.
pub fn detect_nameservers(wan_iface: &str) -> Vec<String> {
// Try interface-specific DNS via resolvectl (most accurate).
if let Some(servers) = resolvectl_dns(wan_iface) {
if !servers.is_empty() {
return servers;
}
let host = host_addresses();
let mut blocked = Vec::new();

// Sources in order of accuracy, each dropping anything the guest
// can't reach and falling through to the next if nothing survives.
let mut take = |servers: Option<Vec<String>>| -> Option<Vec<String>> {
let (reachable, unreachable) = partition_reachable(servers.unwrap_or_default(), &host);
blocked.extend(unreachable);
(!reachable.is_empty()).then_some(reachable)
};

let mut servers = take(resolvectl_dns(wan_iface));
if servers.is_none() {
servers = take(parse_resolv_conf(Path::new(
"/run/systemd/resolve/resolv.conf",
)));
}
if servers.is_none() {
servers = take(parse_resolv_conf(Path::new("/etc/resolv.conf")));
}

let servers =
servers.unwrap_or_else(|| FALLBACK_NAMESERVERS.iter().map(|s| s.to_string()).collect());

// Fall back to systemd-resolved upstream config.
if let Some(servers) = parse_resolv_conf(Path::new("/run/systemd/resolve/resolv.conf")) {
if !servers.is_empty() {
return servers;
}
if !blocked.is_empty() {
eprintln!(
"Warning: DNS server(s) {} belong to the host, which guests cannot reach.",
blocked.join(", ")
);
eprintln!(" Using {} instead.", servers.join(", "));
}
servers
}

/// Split nameservers into those a guest can reach and those it can't
/// because they are host addresses.
fn partition_reachable(servers: Vec<String>, host: &[String]) -> (Vec<String>, Vec<String>) {
servers
.into_iter()
.partition(|server| !host.iter().any(|addr| addr == server))
}

// Fall back to /etc/resolv.conf.
if let Some(servers) = parse_resolv_conf(Path::new("/etc/resolv.conf")) {
if !servers.is_empty() {
return servers;
}
/// The host's own IPv4 addresses, including TAP gateways.
///
/// Empty if the addresses can't be listed, which leaves detection
/// exactly as permissive as it was before this filter existed rather
/// than failing a VM start over a diagnostic.
fn host_addresses() -> Vec<String> {
let Ok(output) = Command::new("ip")
.args(["-4", "-o", "addr", "show"])
.output()
else {
return Vec::new();
};
if !output.status.success() {
return Vec::new();
}
parse_host_addresses(&String::from_utf8_lossy(&output.stdout))
}

// Last resort: hardcoded public DNS.
FALLBACK_NAMESERVERS.iter().map(|s| s.to_string()).collect()
/// Pull the addresses out of `ip -4 -o addr show` output, whose lines
/// look like `2: enp7s0 inet 192.168.0.23/24 brd ... scope global`.
fn parse_host_addresses(output: &str) -> Vec<String> {
output
.lines()
.filter_map(|line| {
let mut fields = line.split_whitespace();
fields.find(|f| *f == "inet")?;
let cidr = fields.next()?;
Some(cidr.split('/').next()?.to_string())
})
.collect()
}

/// Query DNS servers for a specific interface via `resolvectl dns`.
Expand Down Expand Up @@ -206,6 +259,50 @@ mod tests {
assert!(parse_resolv_conf(Path::new("/nonexistent/resolv.conf")).is_none());
}

#[test]
fn host_addresses_parsed_from_ip_output() {
let output = "\
1: lo inet 127.0.0.1/8 scope host lo\\ valid_lft forever preferred_lft forever
2: enp7s0 inet 192.168.0.23/24 brd 192.168.0.255 scope global dynamic enp7s0\\ valid_lft 5000sec
8: em-eefd28d inet 10.100.0.1/30 brd 10.100.0.3 scope global em-eefd28d\\ valid_lft forever
";
assert_eq!(
parse_host_addresses(output),
["127.0.0.1", "192.168.0.23", "10.100.0.1"]
);
}

#[test]
fn host_addresses_tolerates_empty_output() {
assert!(parse_host_addresses("").is_empty());
}

/// A host-run resolver is unreachable from a guest, so it has to be
/// dropped rather than handed over and left to time out.
#[test]
fn nameservers_on_host_addresses_are_dropped() {
let host = ["192.168.0.23".to_string(), "10.100.0.1".to_string()];
let (reachable, blocked) = partition_reachable(
vec![
"192.168.0.23".to_string(),
"1.1.1.1".to_string(),
"10.100.0.1".to_string(),
],
&host,
);
assert_eq!(reachable, ["1.1.1.1"]);
assert_eq!(blocked, ["192.168.0.23", "10.100.0.1"]);
}

#[test]
fn nameservers_off_host_are_all_kept() {
let host = ["192.168.0.23".to_string()];
let servers = vec!["9.9.9.9".to_string(), "1.1.1.1".to_string()];
let (reachable, blocked) = partition_reachable(servers.clone(), &host);
assert_eq!(reachable, servers);
assert!(blocked.is_empty());
}

#[test]
fn detect_nameservers_returns_something() {
// Use a bogus interface — should fall back to resolv.conf or hardcoded.
Expand Down
Loading
Loading