use crate::sdk::network::NetworkBackend;
use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use serde_yaml::{Mapping as YamlMapping, Number as YamlNumber, Value as YamlValue};
use std::fs;
use std::net::Ipv4Addr;
use std::path::{Path, PathBuf};
use tokio::process::Command;
const NETWORK_TEST_ROOT_ENV: &str = "XBP_NETWORK_TEST_ROOT";
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SetupHetznerVswitchRequest {
pub ip: String,
pub cidr: Option<u8>,
pub interface: Option<String>,
pub vlan_id: u16,
pub mtu: u16,
pub gateway: String,
pub route_cidr: String,
pub apply: bool,
pub dry_run: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SetupHetznerVswitchResponse {
pub backend: NetworkBackend,
pub interface: String,
pub vlan_interface: String,
pub address_cidr: String,
pub gateway: String,
pub route_cidr: String,
pub vlan_id: u16,
pub mtu: u16,
pub changed: bool,
pub dry_run: bool,
pub applied: bool,
pub written_files: Vec<String>,
pub message: String,
}
#[derive(Clone, Debug)]
struct NetworkPaths {
netplan_dir: PathBuf,
ifupdown_dir: PathBuf,
ifupdown_main: PathBuf,
nm_dir: PathBuf,
ifcfg_dir: PathBuf,
}
impl NetworkPaths {
fn load() -> Self {
let root = std::env::var(NETWORK_TEST_ROOT_ENV).ok();
let map = |absolute: &str| {
if let Some(root) = &root {
PathBuf::from(root).join(absolute.trim_start_matches('/'))
} else {
PathBuf::from(absolute)
}
};
Self {
netplan_dir: map("/etc/netplan"),
ifupdown_dir: map("/etc/network/interfaces.d"),
ifupdown_main: map("/etc/network/interfaces"),
nm_dir: map("/etc/NetworkManager/system-connections"),
ifcfg_dir: map("/etc/sysconfig/network-scripts"),
}
}
}
struct VswitchWriter<'a> {
paths: &'a NetworkPaths,
dry_run: bool,
written_files: &'a mut Vec<String>,
}
struct VswitchConfig<'a> {
interface: &'a str,
vlan_interface: &'a str,
address_cidr: &'a str,
ip: Ipv4Addr,
prefix: u8,
vlan_id: u16,
mtu: u16,
gateway: Ipv4Addr,
route_cidr: &'a str,
route_ip: Ipv4Addr,
route_prefix: u8,
}
pub async fn setup_hetzner_vswitch(
request: SetupHetznerVswitchRequest,
) -> Result<SetupHetznerVswitchResponse> {
ensure_linux_host()?;
let backend = detect_backend(&NetworkPaths::load());
if backend == NetworkBackend::Unknown {
return Err(anyhow!(
"No supported Linux network backend detected (netplan, NetworkManager, ifupdown, ifcfg)."
));
}
let ip: Ipv4Addr = request
.ip
.parse()
.with_context(|| format!("Invalid IPv4 address: {}", request.ip))?;
let prefix = normalize_prefix(request.cidr)?;
let gateway: Ipv4Addr = request
.gateway
.parse()
.with_context(|| format!("Invalid IPv4 gateway: {}", request.gateway))?;
let (route_ip, route_prefix) = parse_ipv4_cidr(&request.route_cidr)?;
let interface = if let Some(interface) = request.interface.clone() {
interface
} else {
detect_default_interface().await?
};
let vlan_interface = format!("{}.{}", interface, request.vlan_id);
let address_cidr = format!("{}/{}", ip, prefix);
let paths = NetworkPaths::load();
let mut written_files = Vec::new();
let config = VswitchConfig {
interface: &interface,
vlan_interface: &vlan_interface,
address_cidr: &address_cidr,
ip,
prefix,
vlan_id: request.vlan_id,
mtu: request.mtu,
gateway,
route_cidr: &request.route_cidr,
route_ip,
route_prefix,
};
let mut writer = VswitchWriter {
paths: &paths,
dry_run: request.dry_run,
written_files: &mut written_files,
};
let changed = match backend {
NetworkBackend::Netplan => write_netplan_vswitch(&config, &mut writer)?,
NetworkBackend::NetworkManager => write_network_manager_vswitch(&config, &mut writer)?,
NetworkBackend::Ifupdown => write_ifupdown_vswitch(&config, &mut writer)?,
NetworkBackend::Ifcfg => write_ifcfg_vswitch(&config, &mut writer)?,
NetworkBackend::Runtime | NetworkBackend::Unknown => false,
};
let mut applied = false;
if request.apply && !request.dry_run && changed {
apply_vswitch_changes(backend, &vlan_interface, request.vlan_id).await?;
applied = true;
}
Ok(SetupHetznerVswitchResponse {
backend,
interface,
vlan_interface,
address_cidr,
gateway: gateway.to_string(),
route_cidr: request.route_cidr,
vlan_id: request.vlan_id,
mtu: request.mtu,
changed,
dry_run: request.dry_run,
applied,
written_files,
message: if changed {
if request.dry_run {
"Dry run complete; no files were written.".to_string()
} else if applied {
"Hetzner vSwitch configuration written and applied.".to_string()
} else {
"Hetzner vSwitch configuration written. Run with --apply to activate it now."
.to_string()
}
} else {
"Hetzner vSwitch configuration already matches the requested state.".to_string()
},
})
}
fn ensure_linux_host() -> Result<()> {
if cfg!(target_os = "linux") || std::env::var(NETWORK_TEST_ROOT_ENV).is_ok() {
Ok(())
} else {
Err(anyhow!(
"`xbp network hetzner` is currently supported on Linux hosts only."
))
}
}
fn detect_backend(paths: &NetworkPaths) -> NetworkBackend {
if paths.netplan_dir.exists() {
return NetworkBackend::Netplan;
}
if paths.nm_dir.exists() {
return NetworkBackend::NetworkManager;
}
if paths.ifupdown_main.exists() || paths.ifupdown_dir.exists() {
return NetworkBackend::Ifupdown;
}
if paths.ifcfg_dir.exists() {
return NetworkBackend::Ifcfg;
}
NetworkBackend::Unknown
}
fn normalize_prefix(cidr: Option<u8>) -> Result<u8> {
let prefix = cidr.unwrap_or(24);
if prefix > 32 {
return Err(anyhow!("IPv4 CIDR must be <= 32"));
}
Ok(prefix)
}
fn parse_ipv4_cidr(value: &str) -> Result<(Ipv4Addr, u8)> {
let (ip, prefix) = value
.split_once('/')
.ok_or_else(|| anyhow!("CIDR value must look like 10.0.0.0/16"))?;
let ip: Ipv4Addr = ip
.parse()
.with_context(|| format!("Invalid IPv4 CIDR base address: {}", ip))?;
let prefix = prefix
.parse::<u8>()
.with_context(|| format!("Invalid IPv4 CIDR prefix in {}", value))?;
if prefix > 32 {
return Err(anyhow!("IPv4 CIDR prefix must be <= 32"));
}
Ok((ip, prefix))
}
async fn detect_default_interface() -> Result<String> {
let output = Command::new("ip")
.args(["route", "show", "default"])
.output()
.await
.context("Failed to inspect default route with `ip route show default`")?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
let parts: Vec<&str> = line.split_whitespace().collect();
for index in 0..parts.len() {
if parts[index] == "dev" {
if let Some(interface) = parts.get(index + 1) {
return Ok((*interface).to_string());
}
}
}
}
}
Ok("eth0".to_string())
}
fn write_netplan_vswitch(
config: &VswitchConfig<'_>,
writer: &mut VswitchWriter<'_>,
) -> Result<bool> {
let target_path = writer.paths.netplan_dir.join("60-xbp-hetzner-vswitch.yaml");
let mut root = YamlMapping::new();
let mut network = YamlMapping::new();
let mut vlans = YamlMapping::new();
let mut iface = YamlMapping::new();
iface.insert(
YamlValue::String("id".to_string()),
YamlValue::Number(YamlNumber::from(config.vlan_id)),
);
iface.insert(
YamlValue::String("link".to_string()),
YamlValue::String(config.interface.to_string()),
);
iface.insert(
YamlValue::String("mtu".to_string()),
YamlValue::Number(YamlNumber::from(config.mtu)),
);
iface.insert(
YamlValue::String("addresses".to_string()),
YamlValue::Sequence(vec![YamlValue::String(config.address_cidr.to_string())]),
);
iface.insert(
YamlValue::String("routes".to_string()),
YamlValue::Sequence(vec![YamlValue::Mapping({
let mut route = YamlMapping::new();
route.insert(
YamlValue::String("to".to_string()),
YamlValue::String(config.route_cidr.to_string()),
);
route.insert(
YamlValue::String("via".to_string()),
YamlValue::String(config.gateway.to_string()),
);
route
})]),
);
vlans.insert(
YamlValue::String(config.vlan_interface.to_string()),
YamlValue::Mapping(iface),
);
network.insert(
YamlValue::String("version".to_string()),
YamlValue::Number(YamlNumber::from(2)),
);
network.insert(
YamlValue::String("renderer".to_string()),
YamlValue::String("networkd".to_string()),
);
network.insert(
YamlValue::String("vlans".to_string()),
YamlValue::Mapping(vlans),
);
root.insert(
YamlValue::String("network".to_string()),
YamlValue::Mapping(network),
);
let rendered = serde_yaml::to_string(&YamlValue::Mapping(root))
.context("Failed to render netplan YAML")?;
write_if_changed(
&target_path,
&rendered,
writer.dry_run,
writer.written_files,
)
}
fn write_network_manager_vswitch(
config: &VswitchConfig<'_>,
writer: &mut VswitchWriter<'_>,
) -> Result<bool> {
let target_path = writer.paths.nm_dir.join(format!(
"60-xbp-hetzner-vswitch-{}.nmconnection",
config.vlan_interface
));
let connection_id = format!("xbp-hetzner-vswitch-{}", config.vlan_interface);
let rendered = format!(
"[connection]\nid={connection_id}\ntype=vlan\ninterface-name={vlan_interface}\nautoconnect=true\n\n[vlan]\nid={vlan_id}\nparent={interface}\n\n[ethernet]\nmtu={mtu}\n\n[ipv4]\nmethod=manual\naddress1={address_cidr}\nroute1={route_ip}/{route_prefix},{gateway}\n\n[ipv6]\nmethod=ignore\n\n[proxy]\n",
vlan_interface = config.vlan_interface,
vlan_id = config.vlan_id,
interface = config.interface,
mtu = config.mtu,
address_cidr = config.address_cidr,
route_ip = config.route_ip,
route_prefix = config.route_prefix,
gateway = config.gateway,
);
write_if_changed(
&target_path,
&rendered,
writer.dry_run,
writer.written_files,
)
}
fn write_ifupdown_vswitch(
config: &VswitchConfig<'_>,
writer: &mut VswitchWriter<'_>,
) -> Result<bool> {
let target_path = writer.paths.ifupdown_dir.join(format!(
"60-xbp-hetzner-vswitch-{}.cfg",
config.vlan_interface
));
let rendered = format!(
"auto {vlan_interface}\niface {vlan_interface} inet static\n address {ip}\n netmask {}\n mtu {mtu}\n vlan-raw-device {interface}\n up ip route replace {route_cidr} via {gateway} dev {vlan_interface}\n",
prefix_to_netmask(config.prefix)?,
vlan_interface = config.vlan_interface,
ip = config.ip,
mtu = config.mtu,
interface = config.interface,
route_cidr = config.route_cidr,
gateway = config.gateway,
);
write_if_changed(
&target_path,
&rendered,
writer.dry_run,
writer.written_files,
)
}
fn write_ifcfg_vswitch(config: &VswitchConfig<'_>, writer: &mut VswitchWriter<'_>) -> Result<bool> {
let ifcfg_path = writer
.paths
.ifcfg_dir
.join(format!("ifcfg-{}", config.vlan_interface));
let route_path = writer
.paths
.ifcfg_dir
.join(format!("route-{}", config.vlan_interface));
let ifcfg = format!(
"VLAN=yes\nTYPE=Vlan\nPHYSDEV={interface}\nDEVICE={vlan_interface}\nNAME={vlan_interface}\nBOOTPROTO=none\nONBOOT=yes\nIPADDR={ip}\nPREFIX={prefix}\nMTU={mtu}\nDEFROUTE=no\n",
interface = config.interface,
vlan_interface = config.vlan_interface,
ip = config.ip,
prefix = config.prefix,
mtu = config.mtu,
);
let route = format!(
"{route_cidr} via {gateway} dev {vlan_interface}\n",
route_cidr = config.route_cidr,
gateway = config.gateway,
vlan_interface = config.vlan_interface,
);
let changed_ifcfg =
write_if_changed(&ifcfg_path, &ifcfg, writer.dry_run, writer.written_files)?;
let changed_route =
write_if_changed(&route_path, &route, writer.dry_run, writer.written_files)?;
Ok(changed_ifcfg || changed_route)
}
fn write_if_changed(
path: &Path,
content: &str,
dry_run: bool,
written_files: &mut Vec<String>,
) -> Result<bool> {
let changed = if path.exists() {
fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))?
!= content
} else {
true
};
if changed {
written_files.push(path.display().to_string());
if !dry_run {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create {}", parent.display()))?;
}
fs::write(path, content)
.with_context(|| format!("Failed to write {}", path.display()))?;
}
}
Ok(changed)
}
fn prefix_to_netmask(prefix: u8) -> Result<Ipv4Addr> {
if prefix > 32 {
return Err(anyhow!("IPv4 CIDR prefix must be <= 32"));
}
let mask = if prefix == 0 {
0
} else {
u32::MAX << (32 - prefix)
};
Ok(Ipv4Addr::from(mask))
}
async fn apply_vswitch_changes(
backend: NetworkBackend,
vlan_interface: &str,
_vlan_id: u16,
) -> Result<()> {
match backend {
NetworkBackend::Netplan => run_apply_command("netplan", &["apply"]).await,
NetworkBackend::NetworkManager => {
let connection_id = format!("xbp-hetzner-vswitch-{}", vlan_interface);
run_apply_command("nmcli", &["connection", "reload"]).await?;
run_apply_command("nmcli", &["connection", "up", &connection_id]).await
}
NetworkBackend::Ifupdown => run_apply_command("ifup", &[vlan_interface]).await,
NetworkBackend::Ifcfg => {
let ifup_result = run_apply_command("ifup", &[vlan_interface]).await;
if ifup_result.is_ok() {
Ok(())
} else {
run_apply_command("systemctl", &["restart", "network"]).await
}
}
NetworkBackend::Runtime | NetworkBackend::Unknown => Ok(()),
}
}
async fn run_apply_command(program: &str, args: &[&str]) -> Result<()> {
let output = Command::new(program)
.args(args)
.output()
.await
.with_context(|| format!("Failed to run {} {}", program, args.join(" ")))?;
if output.status.success() {
return Ok(());
}
let sudo_output = Command::new("sudo")
.arg("-n")
.arg(program)
.args(args)
.output()
.await;
if let Ok(output) = sudo_output {
if output.status.success() {
return Ok(());
}
return Err(anyhow!(
"Failed to apply network changes via sudo: {}",
String::from_utf8_lossy(&output.stderr)
));
}
Err(anyhow!(
"Failed to apply network changes: {}",
String::from_utf8_lossy(&output.stderr)
))
}
#[cfg(test)]
mod tests {
use super::{
prefix_to_netmask, setup_hetzner_vswitch, SetupHetznerVswitchRequest, NETWORK_TEST_ROOT_ENV,
};
use std::fs;
use std::path::PathBuf;
use std::sync::{Mutex, OnceLock};
fn env_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
fn with_test_root<F>(name: &str, test: F)
where
F: FnOnce(PathBuf),
{
let _guard = env_lock().lock().expect("env lock should be available");
let mut root = std::env::temp_dir();
root.push(format!(
"xbp-network-hetzner-test-{}-{}",
name,
std::process::id()
));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(&root).expect("temp root should be created");
std::env::set_var(NETWORK_TEST_ROOT_ENV, root.display().to_string());
test(root.clone());
std::env::remove_var(NETWORK_TEST_ROOT_ENV);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn prefix_to_netmask_renders_ipv4_masks() {
assert_eq!(prefix_to_netmask(24).unwrap().to_string(), "255.255.255.0");
assert_eq!(prefix_to_netmask(16).unwrap().to_string(), "255.255.0.0");
}
#[test]
fn dry_run_netplan_reports_target_file() {
let runtime = tokio::runtime::Runtime::new().expect("runtime should build");
with_test_root("netplan-dry-run", |root| {
let netplan_dir = root.join("etc/netplan");
fs::create_dir_all(&netplan_dir).expect("netplan dir should exist");
let response = runtime
.block_on(setup_hetzner_vswitch(SetupHetznerVswitchRequest {
ip: "10.0.3.2".to_string(),
cidr: Some(24),
interface: Some("enp0s31f6".to_string()),
vlan_id: 4000,
mtu: 1400,
gateway: "10.0.3.1".to_string(),
route_cidr: "10.0.0.0/16".to_string(),
apply: false,
dry_run: true,
}))
.expect("setup should succeed");
assert_eq!(
response.backend,
crate::sdk::network::NetworkBackend::Netplan
);
assert!(response.changed);
assert_eq!(response.vlan_interface, "enp0s31f6.4000");
assert_eq!(response.written_files.len(), 1);
assert!(!netplan_dir.join("60-xbp-hetzner-vswitch.yaml").exists());
});
}
}