Skip to main content

xbp_cli/sdk/
network_hetzner.rs

1use crate::sdk::network::NetworkBackend;
2use anyhow::{anyhow, Context, Result};
3use serde::{Deserialize, Serialize};
4use serde_yaml::{Mapping as YamlMapping, Number as YamlNumber, Value as YamlValue};
5use std::fs;
6use std::net::Ipv4Addr;
7use std::path::{Path, PathBuf};
8use tokio::process::Command;
9
10const NETWORK_TEST_ROOT_ENV: &str = "XBP_NETWORK_TEST_ROOT";
11
12#[derive(Clone, Debug, Serialize, Deserialize)]
13pub struct SetupHetznerVswitchRequest {
14    pub ip: String,
15    pub cidr: Option<u8>,
16    pub interface: Option<String>,
17    pub vlan_id: u16,
18    pub mtu: u16,
19    pub gateway: String,
20    pub route_cidr: String,
21    pub apply: bool,
22    pub dry_run: bool,
23}
24
25#[derive(Clone, Debug, Serialize, Deserialize)]
26pub struct SetupHetznerVswitchResponse {
27    pub backend: NetworkBackend,
28    pub interface: String,
29    pub vlan_interface: String,
30    pub address_cidr: String,
31    pub gateway: String,
32    pub route_cidr: String,
33    pub vlan_id: u16,
34    pub mtu: u16,
35    pub changed: bool,
36    pub dry_run: bool,
37    pub applied: bool,
38    pub written_files: Vec<String>,
39    pub message: String,
40}
41
42#[derive(Clone, Debug)]
43struct NetworkPaths {
44    netplan_dir: PathBuf,
45    ifupdown_dir: PathBuf,
46    ifupdown_main: PathBuf,
47    nm_dir: PathBuf,
48    ifcfg_dir: PathBuf,
49}
50
51impl NetworkPaths {
52    fn load() -> Self {
53        let root = std::env::var(NETWORK_TEST_ROOT_ENV).ok();
54        let map = |absolute: &str| {
55            if let Some(root) = &root {
56                PathBuf::from(root).join(absolute.trim_start_matches('/'))
57            } else {
58                PathBuf::from(absolute)
59            }
60        };
61
62        Self {
63            netplan_dir: map("/etc/netplan"),
64            ifupdown_dir: map("/etc/network/interfaces.d"),
65            ifupdown_main: map("/etc/network/interfaces"),
66            nm_dir: map("/etc/NetworkManager/system-connections"),
67            ifcfg_dir: map("/etc/sysconfig/network-scripts"),
68        }
69    }
70}
71
72struct VswitchWriter<'a> {
73    paths: &'a NetworkPaths,
74    dry_run: bool,
75    written_files: &'a mut Vec<String>,
76}
77
78struct VswitchConfig<'a> {
79    interface: &'a str,
80    vlan_interface: &'a str,
81    address_cidr: &'a str,
82    ip: Ipv4Addr,
83    prefix: u8,
84    vlan_id: u16,
85    mtu: u16,
86    gateway: Ipv4Addr,
87    route_cidr: &'a str,
88    route_ip: Ipv4Addr,
89    route_prefix: u8,
90}
91
92pub async fn setup_hetzner_vswitch(
93    request: SetupHetznerVswitchRequest,
94) -> Result<SetupHetznerVswitchResponse> {
95    ensure_linux_host()?;
96
97    let backend = detect_backend(&NetworkPaths::load());
98    if backend == NetworkBackend::Unknown {
99        return Err(anyhow!(
100            "No supported Linux network backend detected (netplan, NetworkManager, ifupdown, ifcfg)."
101        ));
102    }
103
104    let ip: Ipv4Addr = request
105        .ip
106        .parse()
107        .with_context(|| format!("Invalid IPv4 address: {}", request.ip))?;
108    let prefix = normalize_prefix(request.cidr)?;
109    let gateway: Ipv4Addr = request
110        .gateway
111        .parse()
112        .with_context(|| format!("Invalid IPv4 gateway: {}", request.gateway))?;
113    let (route_ip, route_prefix) = parse_ipv4_cidr(&request.route_cidr)?;
114
115    let interface = if let Some(interface) = request.interface.clone() {
116        interface
117    } else {
118        detect_default_interface().await?
119    };
120    let vlan_interface = format!("{}.{}", interface, request.vlan_id);
121    let address_cidr = format!("{}/{}", ip, prefix);
122
123    let paths = NetworkPaths::load();
124    let mut written_files = Vec::new();
125    let config = VswitchConfig {
126        interface: &interface,
127        vlan_interface: &vlan_interface,
128        address_cidr: &address_cidr,
129        ip,
130        prefix,
131        vlan_id: request.vlan_id,
132        mtu: request.mtu,
133        gateway,
134        route_cidr: &request.route_cidr,
135        route_ip,
136        route_prefix,
137    };
138    let mut writer = VswitchWriter {
139        paths: &paths,
140        dry_run: request.dry_run,
141        written_files: &mut written_files,
142    };
143
144    let changed = match backend {
145        NetworkBackend::Netplan => write_netplan_vswitch(&config, &mut writer)?,
146        NetworkBackend::NetworkManager => write_network_manager_vswitch(&config, &mut writer)?,
147        NetworkBackend::Ifupdown => write_ifupdown_vswitch(&config, &mut writer)?,
148        NetworkBackend::Ifcfg => write_ifcfg_vswitch(&config, &mut writer)?,
149        NetworkBackend::Runtime | NetworkBackend::Unknown => false,
150    };
151
152    let mut applied = false;
153    if request.apply && !request.dry_run && changed {
154        apply_vswitch_changes(backend, &vlan_interface, request.vlan_id).await?;
155        applied = true;
156    }
157
158    Ok(SetupHetznerVswitchResponse {
159        backend,
160        interface,
161        vlan_interface,
162        address_cidr,
163        gateway: gateway.to_string(),
164        route_cidr: request.route_cidr,
165        vlan_id: request.vlan_id,
166        mtu: request.mtu,
167        changed,
168        dry_run: request.dry_run,
169        applied,
170        written_files,
171        message: if changed {
172            if request.dry_run {
173                "Dry run complete; no files were written.".to_string()
174            } else if applied {
175                "Hetzner vSwitch configuration written and applied.".to_string()
176            } else {
177                "Hetzner vSwitch configuration written. Run with --apply to activate it now."
178                    .to_string()
179            }
180        } else {
181            "Hetzner vSwitch configuration already matches the requested state.".to_string()
182        },
183    })
184}
185
186fn ensure_linux_host() -> Result<()> {
187    if cfg!(target_os = "linux") || std::env::var(NETWORK_TEST_ROOT_ENV).is_ok() {
188        Ok(())
189    } else {
190        Err(anyhow!(
191            "`xbp network hetzner` is currently supported on Linux hosts only."
192        ))
193    }
194}
195
196fn detect_backend(paths: &NetworkPaths) -> NetworkBackend {
197    if paths.netplan_dir.exists() {
198        return NetworkBackend::Netplan;
199    }
200    if paths.nm_dir.exists() {
201        return NetworkBackend::NetworkManager;
202    }
203    if paths.ifupdown_main.exists() || paths.ifupdown_dir.exists() {
204        return NetworkBackend::Ifupdown;
205    }
206    if paths.ifcfg_dir.exists() {
207        return NetworkBackend::Ifcfg;
208    }
209    NetworkBackend::Unknown
210}
211
212fn normalize_prefix(cidr: Option<u8>) -> Result<u8> {
213    let prefix = cidr.unwrap_or(24);
214    if prefix > 32 {
215        return Err(anyhow!("IPv4 CIDR must be <= 32"));
216    }
217    Ok(prefix)
218}
219
220fn parse_ipv4_cidr(value: &str) -> Result<(Ipv4Addr, u8)> {
221    let (ip, prefix) = value
222        .split_once('/')
223        .ok_or_else(|| anyhow!("CIDR value must look like 10.0.0.0/16"))?;
224    let ip: Ipv4Addr = ip
225        .parse()
226        .with_context(|| format!("Invalid IPv4 CIDR base address: {}", ip))?;
227    let prefix = prefix
228        .parse::<u8>()
229        .with_context(|| format!("Invalid IPv4 CIDR prefix in {}", value))?;
230    if prefix > 32 {
231        return Err(anyhow!("IPv4 CIDR prefix must be <= 32"));
232    }
233    Ok((ip, prefix))
234}
235
236async fn detect_default_interface() -> Result<String> {
237    let output = Command::new("ip")
238        .args(["route", "show", "default"])
239        .output()
240        .await
241        .context("Failed to inspect default route with `ip route show default`")?;
242    if output.status.success() {
243        let stdout = String::from_utf8_lossy(&output.stdout);
244        for line in stdout.lines() {
245            let parts: Vec<&str> = line.split_whitespace().collect();
246            for index in 0..parts.len() {
247                if parts[index] == "dev" {
248                    if let Some(interface) = parts.get(index + 1) {
249                        return Ok((*interface).to_string());
250                    }
251                }
252            }
253        }
254    }
255    Ok("eth0".to_string())
256}
257
258fn write_netplan_vswitch(
259    config: &VswitchConfig<'_>,
260    writer: &mut VswitchWriter<'_>,
261) -> Result<bool> {
262    let target_path = writer.paths.netplan_dir.join("60-xbp-hetzner-vswitch.yaml");
263    let mut root = YamlMapping::new();
264    let mut network = YamlMapping::new();
265    let mut vlans = YamlMapping::new();
266    let mut iface = YamlMapping::new();
267
268    iface.insert(
269        YamlValue::String("id".to_string()),
270        YamlValue::Number(YamlNumber::from(config.vlan_id)),
271    );
272    iface.insert(
273        YamlValue::String("link".to_string()),
274        YamlValue::String(config.interface.to_string()),
275    );
276    iface.insert(
277        YamlValue::String("mtu".to_string()),
278        YamlValue::Number(YamlNumber::from(config.mtu)),
279    );
280    iface.insert(
281        YamlValue::String("addresses".to_string()),
282        YamlValue::Sequence(vec![YamlValue::String(config.address_cidr.to_string())]),
283    );
284    iface.insert(
285        YamlValue::String("routes".to_string()),
286        YamlValue::Sequence(vec![YamlValue::Mapping({
287            let mut route = YamlMapping::new();
288            route.insert(
289                YamlValue::String("to".to_string()),
290                YamlValue::String(config.route_cidr.to_string()),
291            );
292            route.insert(
293                YamlValue::String("via".to_string()),
294                YamlValue::String(config.gateway.to_string()),
295            );
296            route
297        })]),
298    );
299
300    vlans.insert(
301        YamlValue::String(config.vlan_interface.to_string()),
302        YamlValue::Mapping(iface),
303    );
304    network.insert(
305        YamlValue::String("version".to_string()),
306        YamlValue::Number(YamlNumber::from(2)),
307    );
308    network.insert(
309        YamlValue::String("renderer".to_string()),
310        YamlValue::String("networkd".to_string()),
311    );
312    network.insert(
313        YamlValue::String("vlans".to_string()),
314        YamlValue::Mapping(vlans),
315    );
316    root.insert(
317        YamlValue::String("network".to_string()),
318        YamlValue::Mapping(network),
319    );
320
321    let rendered = serde_yaml::to_string(&YamlValue::Mapping(root))
322        .context("Failed to render netplan YAML")?;
323    write_if_changed(
324        &target_path,
325        &rendered,
326        writer.dry_run,
327        writer.written_files,
328    )
329}
330
331fn write_network_manager_vswitch(
332    config: &VswitchConfig<'_>,
333    writer: &mut VswitchWriter<'_>,
334) -> Result<bool> {
335    let target_path = writer.paths.nm_dir.join(format!(
336        "60-xbp-hetzner-vswitch-{}.nmconnection",
337        config.vlan_interface
338    ));
339    let connection_id = format!("xbp-hetzner-vswitch-{}", config.vlan_interface);
340    let rendered = format!(
341        "[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",
342        vlan_interface = config.vlan_interface,
343        vlan_id = config.vlan_id,
344        interface = config.interface,
345        mtu = config.mtu,
346        address_cidr = config.address_cidr,
347        route_ip = config.route_ip,
348        route_prefix = config.route_prefix,
349        gateway = config.gateway,
350    );
351    write_if_changed(
352        &target_path,
353        &rendered,
354        writer.dry_run,
355        writer.written_files,
356    )
357}
358
359fn write_ifupdown_vswitch(
360    config: &VswitchConfig<'_>,
361    writer: &mut VswitchWriter<'_>,
362) -> Result<bool> {
363    let target_path = writer.paths.ifupdown_dir.join(format!(
364        "60-xbp-hetzner-vswitch-{}.cfg",
365        config.vlan_interface
366    ));
367    let rendered = format!(
368        "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",
369        prefix_to_netmask(config.prefix)?,
370        vlan_interface = config.vlan_interface,
371        ip = config.ip,
372        mtu = config.mtu,
373        interface = config.interface,
374        route_cidr = config.route_cidr,
375        gateway = config.gateway,
376    );
377    write_if_changed(
378        &target_path,
379        &rendered,
380        writer.dry_run,
381        writer.written_files,
382    )
383}
384
385fn write_ifcfg_vswitch(config: &VswitchConfig<'_>, writer: &mut VswitchWriter<'_>) -> Result<bool> {
386    let ifcfg_path = writer
387        .paths
388        .ifcfg_dir
389        .join(format!("ifcfg-{}", config.vlan_interface));
390    let route_path = writer
391        .paths
392        .ifcfg_dir
393        .join(format!("route-{}", config.vlan_interface));
394    let ifcfg = format!(
395        "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",
396        interface = config.interface,
397        vlan_interface = config.vlan_interface,
398        ip = config.ip,
399        prefix = config.prefix,
400        mtu = config.mtu,
401    );
402    let route = format!(
403        "{route_cidr} via {gateway} dev {vlan_interface}\n",
404        route_cidr = config.route_cidr,
405        gateway = config.gateway,
406        vlan_interface = config.vlan_interface,
407    );
408
409    let changed_ifcfg =
410        write_if_changed(&ifcfg_path, &ifcfg, writer.dry_run, writer.written_files)?;
411    let changed_route =
412        write_if_changed(&route_path, &route, writer.dry_run, writer.written_files)?;
413    Ok(changed_ifcfg || changed_route)
414}
415
416fn write_if_changed(
417    path: &Path,
418    content: &str,
419    dry_run: bool,
420    written_files: &mut Vec<String>,
421) -> Result<bool> {
422    let changed = if path.exists() {
423        fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))?
424            != content
425    } else {
426        true
427    };
428
429    if changed {
430        written_files.push(path.display().to_string());
431        if !dry_run {
432            if let Some(parent) = path.parent() {
433                fs::create_dir_all(parent)
434                    .with_context(|| format!("Failed to create {}", parent.display()))?;
435            }
436            fs::write(path, content)
437                .with_context(|| format!("Failed to write {}", path.display()))?;
438        }
439    }
440
441    Ok(changed)
442}
443
444fn prefix_to_netmask(prefix: u8) -> Result<Ipv4Addr> {
445    if prefix > 32 {
446        return Err(anyhow!("IPv4 CIDR prefix must be <= 32"));
447    }
448    let mask = if prefix == 0 {
449        0
450    } else {
451        u32::MAX << (32 - prefix)
452    };
453    Ok(Ipv4Addr::from(mask))
454}
455
456async fn apply_vswitch_changes(
457    backend: NetworkBackend,
458    vlan_interface: &str,
459    _vlan_id: u16,
460) -> Result<()> {
461    match backend {
462        NetworkBackend::Netplan => run_apply_command("netplan", &["apply"]).await,
463        NetworkBackend::NetworkManager => {
464            let connection_id = format!("xbp-hetzner-vswitch-{}", vlan_interface);
465            run_apply_command("nmcli", &["connection", "reload"]).await?;
466            run_apply_command("nmcli", &["connection", "up", &connection_id]).await
467        }
468        NetworkBackend::Ifupdown => run_apply_command("ifup", &[vlan_interface]).await,
469        NetworkBackend::Ifcfg => {
470            let ifup_result = run_apply_command("ifup", &[vlan_interface]).await;
471            if ifup_result.is_ok() {
472                Ok(())
473            } else {
474                run_apply_command("systemctl", &["restart", "network"]).await
475            }
476        }
477        NetworkBackend::Runtime | NetworkBackend::Unknown => Ok(()),
478    }
479}
480
481async fn run_apply_command(program: &str, args: &[&str]) -> Result<()> {
482    let output = Command::new(program)
483        .args(args)
484        .output()
485        .await
486        .with_context(|| format!("Failed to run {} {}", program, args.join(" ")))?;
487    if output.status.success() {
488        return Ok(());
489    }
490
491    let sudo_output = Command::new("sudo")
492        .arg("-n")
493        .arg(program)
494        .args(args)
495        .output()
496        .await;
497    if let Ok(output) = sudo_output {
498        if output.status.success() {
499            return Ok(());
500        }
501        return Err(anyhow!(
502            "Failed to apply network changes via sudo: {}",
503            String::from_utf8_lossy(&output.stderr)
504        ));
505    }
506
507    Err(anyhow!(
508        "Failed to apply network changes: {}",
509        String::from_utf8_lossy(&output.stderr)
510    ))
511}
512
513#[cfg(test)]
514mod tests {
515    use super::{
516        prefix_to_netmask, setup_hetzner_vswitch, SetupHetznerVswitchRequest, NETWORK_TEST_ROOT_ENV,
517    };
518    use std::fs;
519    use std::path::PathBuf;
520    use std::sync::{Mutex, OnceLock};
521
522    fn env_lock() -> &'static Mutex<()> {
523        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
524        LOCK.get_or_init(|| Mutex::new(()))
525    }
526
527    fn with_test_root<F>(name: &str, test: F)
528    where
529        F: FnOnce(PathBuf),
530    {
531        let _guard = env_lock().lock().expect("env lock should be available");
532        let mut root = std::env::temp_dir();
533        root.push(format!(
534            "xbp-network-hetzner-test-{}-{}",
535            name,
536            std::process::id()
537        ));
538        let _ = fs::remove_dir_all(&root);
539        fs::create_dir_all(&root).expect("temp root should be created");
540        std::env::set_var(NETWORK_TEST_ROOT_ENV, root.display().to_string());
541        test(root.clone());
542        std::env::remove_var(NETWORK_TEST_ROOT_ENV);
543        let _ = fs::remove_dir_all(&root);
544    }
545
546    #[test]
547    fn prefix_to_netmask_renders_ipv4_masks() {
548        assert_eq!(prefix_to_netmask(24).unwrap().to_string(), "255.255.255.0");
549        assert_eq!(prefix_to_netmask(16).unwrap().to_string(), "255.255.0.0");
550    }
551
552    #[test]
553    fn dry_run_netplan_reports_target_file() {
554        let runtime = tokio::runtime::Runtime::new().expect("runtime should build");
555        with_test_root("netplan-dry-run", |root| {
556            let netplan_dir = root.join("etc/netplan");
557            fs::create_dir_all(&netplan_dir).expect("netplan dir should exist");
558
559            let response = runtime
560                .block_on(setup_hetzner_vswitch(SetupHetznerVswitchRequest {
561                    ip: "10.0.3.2".to_string(),
562                    cidr: Some(24),
563                    interface: Some("enp0s31f6".to_string()),
564                    vlan_id: 4000,
565                    mtu: 1400,
566                    gateway: "10.0.3.1".to_string(),
567                    route_cidr: "10.0.0.0/16".to_string(),
568                    apply: false,
569                    dry_run: true,
570                }))
571                .expect("setup should succeed");
572
573            assert_eq!(
574                response.backend,
575                crate::sdk::network::NetworkBackend::Netplan
576            );
577            assert!(response.changed);
578            assert_eq!(response.vlan_interface, "enp0s31f6.4000");
579            assert_eq!(response.written_files.len(), 1);
580            assert!(!netplan_dir.join("60-xbp-hetzner-vswitch.yaml").exists());
581        });
582    }
583}