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}