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}