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