Skip to content
Closed
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
803 changes: 803 additions & 0 deletions docs/bridge-anti-spoofing-design.md

Large diffs are not rendered by default.

12 changes: 11 additions & 1 deletion docs/bridge-networking.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ By default, dstack-vmm uses **user** networking (QEMU's built-in SLIRP stack, no
```toml
[cvm.networking]
mode = "bridge"
allowed_modes = ["user", "bridge"]
bridge = "virbr0"
# Optional additional bridges that VM requests may select.
allowed_bridges = ["dstack-br1"]
```

### Per-VM override
Expand All @@ -25,7 +28,14 @@ Individual VMs can override the global networking mode via:
- **Web UI**: Networking dropdown in the deploy dialog
- **API**: `networking: { mode: "bridge" }` in `VmConfiguration`

Only the mode is per-VM; the bridge interface name always comes from the global config.
Both the mode and bridge can be requested per VM, but the VMM authorizes them
against this root-owned configuration. An empty `allowed_modes` keeps legacy
mode behavior. The configured default `bridge` is always allowed; an empty
`allowed_bridges` does not authorize other host bridges.

Bridge names must be valid Linux interface names, refer to an actual Linux
bridge, and match either `bridge` or `allowed_bridges`. Never authorize a host
management or physical uplink bridge for untrusted VMs.

## Host setup

Expand Down
4 changes: 4 additions & 0 deletions dstack/vmm/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1607,7 +1607,9 @@ mod tests {
let mut manifest = test_manifest(1024);
manifest.networks = vec![Networking {
mode: NetworkingMode::Bridge,
allowed_modes: vec![],
bridge: "dstack-br0".to_string(),
allowed_bridges: vec![],
mac_prefix: String::new(),
net: String::new(),
dhcp_start: String::new(),
Expand Down Expand Up @@ -1824,7 +1826,9 @@ mod tests {
let mut bridge_manifest = test_manifest(2048);
bridge_manifest.networks = vec![Networking {
mode: NetworkingMode::Bridge,
allowed_modes: vec![],
bridge: "dstack-br0".to_string(),
allowed_bridges: vec![],
mac_prefix: "02:aa:bb".to_string(),
net: String::new(),
dhcp_start: String::new(),
Expand Down
132 changes: 121 additions & 11 deletions dstack/vmm/src/app/network.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

//! VM network resolution, validation, and interface identity.

use std::path::Path;
use std::path::{Path, PathBuf};

use anyhow::{bail, Result};
use sha2::{Digest, Sha256};
Expand Down Expand Up @@ -46,25 +46,80 @@ pub(crate) fn resolved_networks(manifest: &Manifest, cfg: &CvmConfig) -> Vec<Net
}
}

pub(crate) fn validate_resolved_network(networking: &Networking) -> Result<()> {
fn validate_interface_name(name: &str) -> Result<()> {
if name.is_empty() {
bail!("bridge networking requested but no bridge is configured");
}
if name.len() > 15 {
bail!("invalid bridge interface name '{name}': exceeds IFNAMSIZ");
}
let mut chars = name.chars();
let valid_first = chars
.next()
.is_some_and(|ch| ch.is_ascii_alphanumeric() || ch == '_');
let valid_rest = chars.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '-'));
if !valid_first || !valid_rest {
bail!("invalid bridge interface name '{name}': expected [A-Za-z0-9_][A-Za-z0-9_.-]*");
}
Ok(())
}

fn bridge_sysfs_path(name: &str) -> PathBuf {
Path::new("/sys/class/net").join(name)
}

fn validate_network_policy(networking: &Networking, policy: &Networking) -> Result<()> {
if !policy.allowed_modes.is_empty() && !policy.allowed_modes.contains(&networking.mode) {
bail!("networking mode '{:?}' is not allowed", networking.mode);
}
if networking.mode != NetworkingMode::Bridge {
return Ok(());
}
if networking.bridge.is_empty() {
bail!("bridge networking requested but no bridge is configured");

validate_interface_name(&networking.bridge)?;
let bridge_allowed = networking.bridge == policy.bridge
|| policy
.allowed_bridges
.iter()
.any(|bridge| bridge == &networking.bridge);
if !bridge_allowed {
bail!(
"bridge interface '{}' is not allowed by the VMM configuration",
networking.bridge
);
}

Ok(())
}

pub(crate) fn validate_resolved_network(
networking: &Networking,
policy: &Networking,
) -> Result<()> {
validate_network_policy(networking, policy)?;
if networking.mode != NetworkingMode::Bridge {
return Ok(());
}
if !Path::new("/sys/class/net")
.join(&networking.bridge)
.exists()
{

let sysfs_path = bridge_sysfs_path(&networking.bridge);
if !sysfs_path.exists() {
bail!("bridge interface '{}' does not exist", networking.bridge);
}
if !sysfs_path.join("bridge").is_dir() {
bail!(
"network interface '{}' is not a Linux bridge",
networking.bridge
);
}
Ok(())
}

pub(crate) fn validate_resolved_networks(networks: &[Networking]) -> Result<()> {
pub(crate) fn validate_resolved_networks(
networks: &[Networking],
policy: &Networking,
) -> Result<()> {
for networking in networks {
validate_resolved_network(networking)?;
validate_resolved_network(networking, policy)?;
}
Ok(())
}
Expand Down Expand Up @@ -95,7 +150,22 @@ pub(crate) fn mac_address_for_vm_index(vm_id: &str, prefix: &[u8], index: usize)

#[cfg(test)]
mod tests {
use super::mac_address_for_vm_index;
use super::{mac_address_for_vm_index, validate_interface_name, validate_network_policy};
use crate::config::{Networking, NetworkingMode};

fn networking(mode: NetworkingMode, bridge: &str) -> Networking {
Networking {
mode,
allowed_modes: vec![],
bridge: bridge.to_string(),
allowed_bridges: vec![],
mac_prefix: String::new(),
net: String::new(),
dhcp_start: String::new(),
restrict: false,
netdev: String::new(),
}
}

#[test]
fn primary_mac_keeps_legacy_derivation_and_later_nics_are_distinct() {
Expand All @@ -108,4 +178,44 @@ mod tests {
"c6:74:2c:65:14:b9"
);
}

#[test]
fn bridge_interface_names_are_strictly_validated() {
for valid in ["br0", "dstack-br0", "bridge.100", "_bridge"] {
validate_interface_name(valid).unwrap();
}
for invalid in [
"",
"/etc/passwd",
"br0,helper=/bin/sh",
"br0=bad",
"br 0",
"-br0",
"0123456789abcdef",
] {
assert!(validate_interface_name(invalid).is_err(), "{invalid}");
}
}

#[test]
fn bridge_policy_allows_only_the_default_and_explicit_allowlist() {
let mut policy = networking(NetworkingMode::User, "br0");
let requested = networking(NetworkingMode::Bridge, "br1");
assert!(validate_network_policy(&requested, &policy).is_err());

policy.allowed_bridges.push("br1".to_string());
validate_network_policy(&requested, &policy).unwrap();
validate_network_policy(&networking(NetworkingMode::Bridge, "br0"), &policy).unwrap();
}

#[test]
fn mode_policy_is_fail_closed_when_configured() {
let mut policy = networking(NetworkingMode::User, "br0");
policy.allowed_modes.push(NetworkingMode::User);

validate_network_policy(&networking(NetworkingMode::User, ""), &policy).unwrap();
assert!(
validate_network_policy(&networking(NetworkingMode::Bridge, "br0"), &policy).is_err()
);
}
}
2 changes: 1 addition & 1 deletion dstack/vmm/src/app/qemu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ impl PreparedQemuLaunch {
let app_compose = workdir.app_compose().context("failed to get app compose")?;
let platform = cfg.resolved_platform();
let networks = resolved_networks(&vm.manifest, cfg);
validate_resolved_networks(&networks)?;
validate_resolved_networks(&networks, &cfg.networking)?;
let volumes = vm
.manifest
.volumes
Expand Down
10 changes: 10 additions & 0 deletions dstack/vmm/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -530,11 +530,21 @@ pub enum NetworkingMode {
pub struct Networking {
pub mode: NetworkingMode,

/// Network modes that tenant requests may select. An empty list preserves
/// the legacy behavior and allows every typed mode.
#[serde(default, skip_serializing)]
pub allowed_modes: Vec<NetworkingMode>,

// ── Bridge fields ──────────────────────────────────────────────
/// Bridge interface to attach TAP device to (e.g., "virbr0")
#[serde(default)]
pub bridge: String,

/// Additional bridge interfaces that tenant requests may select. The
/// configured default `bridge` is always allowed.
#[serde(default, skip_serializing)]
pub allowed_bridges: Vec<String>,

// ── MAC prefix ─────────────────────────────────────────────────
/// Fixed MAC address prefix (0-3 colon-separated hex bytes, e.g. "02:ab:cd").
/// Remaining bytes are derived from the VM ID hash.
Expand Down
17 changes: 5 additions & 12 deletions dstack/vmm/src/main_service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,9 @@ fn networking_from_proto(proto: &rpc::NetworkingConfig) -> Result<Option<Network
}
Ok(Some(Networking {
mode,
allowed_modes: vec![],
bridge,
allowed_bridges: vec![],
mac_prefix: String::new(),
net: String::new(),
dhcp_start: String::new(),
Expand All @@ -356,7 +358,7 @@ fn networks_from_proto(networks: &[rpc::NetworkingConfig]) -> Result<Vec<Network
}

fn validate_default_network(cvm_config: &CvmConfig) -> Result<()> {
validate_resolved_network(&cvm_config.networking)
validate_resolved_network(&cvm_config.networking, &cvm_config.networking)
}

fn resolve_requested_networks(
Expand All @@ -367,19 +369,10 @@ fn resolve_requested_networks(
.iter()
.map(|networking| resolve_networking(networking, cvm_config))
.collect::<Vec<_>>();
validate_resolved_networks(&resolved)?;
validate_resolved_networks(&resolved, &cvm_config.networking)?;
Ok(resolved)
}

fn has_host_bridge_interface() -> bool {
let Ok(entries) = fs::read_dir("/sys/class/net") else {
return false;
};
entries
.filter_map(|entry| entry.ok())
.any(|entry| entry.path().join("bridge").exists())
}

fn networks_from_vm_config(
request: &VmConfiguration,
cvm_config: &CvmConfig,
Expand Down Expand Up @@ -715,7 +708,7 @@ impl VmmRpc for RpcHandler {
let default_networking = &self.app.config.cvm.networking;
let mut bridge_networking = default_networking.clone();
bridge_networking.mode = NetworkingMode::Bridge;
if validate_resolved_network(&bridge_networking).is_ok() || has_host_bridge_interface() {
if validate_resolved_network(&bridge_networking, default_networking).is_ok() {
supported_modes.push("bridge".to_string());
}
Ok(GetMetaResponse {
Expand Down
6 changes: 6 additions & 0 deletions dstack/vmm/vmm.toml
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,9 @@ product_name = "dstack"

[cvm.networking]
mode = "user"
# Empty preserves compatibility. Multi-tenant deployments should explicitly
# authorize only the modes tenants may request, for example ["user", "bridge"].
allowed_modes = []

# for mode = "user"
net = "10.0.2.0/24"
Expand All @@ -108,6 +111,9 @@ restrict = false

# for mode = "bridge"
# bridge = "virbr0"
# Additional tenant-selectable bridges. The default bridge above is always
# allowed; an empty list does not authorize arbitrary host bridges.
# allowed_bridges = ["dstack-br1"]

[cvm.port_mapping]
enabled = false
Expand Down
Loading