1use crate::context::{HostContext, PlannedAction};
3use crate::error::{HortoError, Result};
4use crate::kits::{envfile, fs, systemd};
5use crate::ops::leases;
6use crate::step::Step;
7use std::process::Command;
8
9pub struct S7Activate;
11
12impl Step for S7Activate {
13 fn id(&self) -> &'static str {
14 "s7"
15 }
16 fn title(&self) -> &'static str {
17 "Activate network services"
18 }
19 fn reference_script(&self) -> &'static str {
20 "s7_activate_services.sh"
21 }
22 fn step_version(&self) -> u32 {
23 3
25 }
26 fn depends_on(&self) -> &'static [&'static str] {
27 &["s6"]
28 }
29 fn is_done(&self, _ctx: &HostContext) -> bool {
30 false
31 }
32 fn plan(&self, ctx: &mut HostContext) -> Result<Vec<PlannedAction>> {
33 ctx.plan_action("sysctl --system");
34 ctx.plan_action("netplan generate && netplan apply");
35 ctx.plan_action("restart dnsmasq, avahi-daemon (hostapd when WiFi AP enabled)");
36 ctx.plan_action("optional NAT (HORTO_APPLY_NAT=1 or confirm)");
37 ctx.plan_action(
38 "export DHCP leases + install /etc/cron.d/export_dhcp_leases (horto net export-leases)",
39 );
40 Ok(ctx.planned.clone())
41 }
42 fn apply(&self, ctx: &mut HostContext) -> Result<()> {
43 if !ctx.paths.full_env_file().exists() {
44 if ctx.is_dry_run() {
45 self.plan(ctx)?;
46 return Ok(());
47 }
48 ctx.log("s7 only needed for IoT-LAN (full) setup; skipping");
49 return Ok(());
50 }
51 if ctx.is_dry_run() {
52 self.plan(ctx)?;
53 return Ok(());
54 }
55
56 if !crate::context::is_root() {
57 ctx.log(
58 "Not root; skipping privileged activate (sysctl/netplan/services/NAT/cron). Exporting leases only.",
59 );
60 let assets = ctx.paths.docker_assets();
61 fs::ensure_dir(ctx, &assets)?;
62 leases::export_dhcp_leases(ctx)?;
63 return Ok(());
64 }
65
66 ctx.log("Reloading sysctl settings...");
67 let _ = Command::new("sysctl").arg("--system").status();
68
69 if which::which("netplan").is_ok() {
70 ctx.log("Applying netplan configuration...");
71 let _ = Command::new("netplan").arg("generate").status();
72 let status = Command::new("netplan").arg("apply").status();
73 if let Ok(s) = status {
74 if !s.success() {
75 ctx.log("warning: netplan apply returned non-zero");
76 }
77 }
78 } else {
79 ctx.log("Skipping netplan apply: netplan command not found.");
80 }
81
82 restart_iot_services(ctx);
83
84 let do_nat =
85 ctx.apply_nat || ctx.confirm("Apply NAT / masquerade iptables rules now?", false);
86 if do_nat {
87 apply_nat_rules(ctx)?;
88 } else {
89 ctx.log("Skipping NAT / masquerade rule setup.");
90 }
91
92 ctx.log("Setting up periodic DHCP lease export...");
93 let assets = ctx.paths.docker_assets();
94 fs::ensure_dir(ctx, &assets)?;
95 leases::export_dhcp_leases(ctx)?;
96
97 install_export_cron(ctx)?;
98 ctx.log("Step s7 complete: applied configuration activated.");
99 Ok(())
100 }
101}
102
103fn restart_iot_services(ctx: &mut HostContext) {
104 let wifi =
105 envfile::load(&ctx.paths.full_env_file()).is_ok_and(|m| envfile::wifi_ap_enabled(&m));
106 restart_if_present(ctx, "dnsmasq");
107 if wifi {
108 restart_if_present(ctx, "hostapd");
109 } else {
110 ctx.log("WIFI_INTERFACE=none; skipping hostapd restart");
111 }
112 restart_if_present(ctx, "avahi-daemon");
113}
114
115fn restart_if_present(ctx: &mut HostContext, unit: &str) {
116 if systemd::unit_present(unit) || which::which("systemctl").is_ok() {
117 ctx.log(format!("Restarting {unit}..."));
118 systemd::try_restart(ctx, unit);
119 systemd::try_enable(ctx, unit);
120 } else {
121 ctx.log(format!("Skipping {unit}: service not installed."));
122 }
123}
124
125fn apply_nat_rules(ctx: &mut HostContext) -> Result<()> {
126 let map = envfile::load(&ctx.paths.full_env_file()).unwrap_or_default();
127 let wan = map
128 .get("ETH_LAN")
129 .cloned()
130 .or_else(default_wan_iface)
131 .unwrap_or_else(|| "eth0".into());
132 ctx.log(format!(
133 "Applying NAT / masquerade rules for interface '{wan}'..."
134 ));
135 if ctx.is_dry_run() {
136 ctx.plan_action(format!(
137 "iptables -t nat -A POSTROUTING -o {wan} -j MASQUERADE"
138 ));
139 ctx.plan_action(format!("iptables -A FORWARD -i br0 -o {wan} -j ACCEPT"));
140 ctx.plan_action(format!(
141 "iptables -A FORWARD -i {wan} -o br0 -m state --state RELATED,ESTABLISHED -j ACCEPT"
142 ));
143 return Ok(());
144 }
145 ensure_nat_masquerade(ctx, &wan)?;
146 ensure_forward_accept(ctx, &wan);
147 ctx.log("Applied NAT / masquerade rules.");
148 maybe_persist_iptables(ctx)?;
149 Ok(())
150}
151
152fn ensure_nat_masquerade(ctx: &mut HostContext, wan: &str) -> Result<()> {
153 run_iptables(
154 ctx,
155 &[
156 "-t",
157 "nat",
158 "-C",
159 "POSTROUTING",
160 "-o",
161 wan,
162 "-j",
163 "MASQUERADE",
164 ],
165 )
166 .or_else(|_| {
167 run_iptables(
168 ctx,
169 &[
170 "-t",
171 "nat",
172 "-A",
173 "POSTROUTING",
174 "-o",
175 wan,
176 "-j",
177 "MASQUERADE",
178 ],
179 )
180 })
181}
182
183fn ensure_forward_accept(ctx: &mut HostContext, wan: &str) {
184 let _ = run_iptables(
185 ctx,
186 &["-C", "FORWARD", "-i", "br0", "-o", wan, "-j", "ACCEPT"],
187 )
188 .or_else(|_| {
189 run_iptables(
190 ctx,
191 &["-A", "FORWARD", "-i", "br0", "-o", wan, "-j", "ACCEPT"],
192 )
193 });
194 let _ = run_iptables(
195 ctx,
196 &[
197 "-C",
198 "FORWARD",
199 "-i",
200 wan,
201 "-o",
202 "br0",
203 "-m",
204 "state",
205 "--state",
206 "RELATED,ESTABLISHED",
207 "-j",
208 "ACCEPT",
209 ],
210 )
211 .or_else(|_| {
212 run_iptables(
213 ctx,
214 &[
215 "-A",
216 "FORWARD",
217 "-i",
218 wan,
219 "-o",
220 "br0",
221 "-m",
222 "state",
223 "--state",
224 "RELATED,ESTABLISHED",
225 "-j",
226 "ACCEPT",
227 ],
228 )
229 });
230}
231
232fn maybe_persist_iptables(ctx: &mut HostContext) -> Result<()> {
233 if ctx.confirm(
234 "Install iptables-persistent to save these rules across reboot?",
235 false,
236 ) {
237 crate::kits::apt::apt_install(ctx, &["iptables-persistent"])?;
238 if crate::context::is_root() {
239 let _ = Command::new("netfilter-persistent").arg("save").status();
240 }
241 ctx.log("Saved iptables rules via iptables-persistent.");
242 }
243 Ok(())
244}
245
246#[must_use]
248pub(crate) fn parse_default_wan_iface(text: &str) -> Option<String> {
249 let mut prev = "";
250 for part in text.split_whitespace() {
251 if prev == "dev" {
252 return Some(part.to_string());
253 }
254 prev = part;
255 }
256 None
257}
258
259fn default_wan_iface() -> Option<String> {
260 let output = Command::new("ip")
261 .args(["route", "show", "default"])
262 .output()
263 .ok()?;
264 let text = String::from_utf8_lossy(&output.stdout);
265 parse_default_wan_iface(&text)
266}
267
268fn run_iptables(ctx: &mut HostContext, args: &[&str]) -> Result<()> {
269 if ctx.is_dry_run() {
270 ctx.plan_action(format!("iptables {}", args.join(" ")));
271 return Ok(());
272 }
273 let status = Command::new("iptables")
274 .args(args)
275 .status()
276 .map_err(|e| HortoError::msg(format!("iptables: {e}")))?;
277 if status.success() {
278 Ok(())
279 } else {
280 Err(HortoError::msg(format!(
281 "iptables {} failed",
282 args.join(" ")
283 )))
284 }
285}
286
287fn install_export_cron(ctx: &mut HostContext) -> Result<()> {
288 let horto_bin =
291 which::which("horto").map_or_else(|_| "horto".into(), |p| p.display().to_string());
292 let cron_dir = ctx.paths.etc.join("cron.d");
293 let cron_file = cron_dir.join("export_dhcp_leases");
294 let cron_body = format!("* * * * * root {horto_bin} net export-leases >/dev/null 2>&1\n");
295
296 if ctx.is_dry_run() {
297 ctx.plan_action("ensure package cron is installed");
298 ctx.plan_action(format!(
299 "write {} with: {}",
300 cron_file.display(),
301 cron_body.trim()
302 ));
303 ctx.plan_action("systemctl enable --now cron");
304 return Ok(());
305 }
306
307 if !crate::kits::apt::package_installed("cron") {
308 if crate::context::is_root() {
309 ctx.log("Installing cron service...");
310 crate::kits::apt::apt_update(ctx)?;
311 crate::kits::apt::apt_install(ctx, &["cron"])?;
312 } else {
313 ctx.log("cron package missing and not root; writing cron.d entry only");
314 }
315 }
316
317 fs::ensure_dir(ctx, &cron_dir)?;
318 fs::write_file(ctx, &cron_file, cron_body.as_bytes())?;
319 fs::chmod(ctx, &cron_file, 0o644)?;
320 if crate::context::is_root() {
321 systemd::try_enable(ctx, "cron");
322 let _ = Command::new("systemctl").args(["start", "cron"]).status();
323 }
324 ctx.log(format!(
325 "cron job installed in {} (every minute: {horto_bin} net export-leases)",
326 cron_file.display()
327 ));
328 Ok(())
329}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334 use crate::context::{ApplyMode, NonInteractivePrompts};
335 use crate::paths::HostPaths;
336 use crate::pipeline::SetupKind;
337 use tempfile::TempDir;
338
339 fn temp_paths(root: &std::path::Path) -> HostPaths {
340 HostPaths {
341 active_setup: root.join("active_setup"),
342 backup: root.join("backup"),
343 docker: root.join("docker"),
344 etc: root.join("etc"),
345 lease_file: root.join("leases"),
346 }
347 }
348
349 #[test]
350 fn parse_wan_from_ip_route() {
351 assert_eq!(
352 parse_default_wan_iface("default via 1.1.1.1 dev eth0 proto dhcp"),
353 Some("eth0".into())
354 );
355 assert!(parse_default_wan_iface("unreachable default").is_none());
356 }
357
358 #[test]
359 fn nat_rules_dry_run_plans_iptables() {
360 let tmp = TempDir::new().unwrap();
361 let paths = temp_paths(tmp.path());
362 std::fs::create_dir_all(&paths.active_setup).unwrap();
363 let mut map = std::collections::BTreeMap::new();
364 map.insert("ETH_LAN".into(), "enp1s0".into());
365 crate::kits::envfile::write(&paths.full_env_file(), &map).unwrap();
366 let mut ctx = HostContext::new(ApplyMode::DryRun, SetupKind::Full).with_paths(paths);
367 apply_nat_rules(&mut ctx).unwrap();
368 assert!(ctx
369 .planned
370 .iter()
371 .any(|p| p.summary.contains("MASQUERADE") && p.summary.contains("enp1s0")));
372 }
373
374 #[test]
375 fn install_cron_writes_under_etc() {
376 let tmp = TempDir::new().unwrap();
377 let paths = temp_paths(tmp.path());
378 std::fs::create_dir_all(&paths.etc).unwrap();
379 let mut ctx = HostContext::new(ApplyMode::Apply, SetupKind::Full)
380 .with_paths(paths)
381 .with_prompts(Box::new(NonInteractivePrompts));
382 install_export_cron(&mut ctx).unwrap();
383 let cron = ctx.paths.etc.join("cron.d/export_dhcp_leases");
384 assert!(cron.is_file());
385 let body = std::fs::read_to_string(&cron).unwrap();
386 assert!(body.contains("export-leases"));
387 }
388
389 #[test]
390 fn restart_if_present_logs() {
391 let tmp = TempDir::new().unwrap();
392 let mut ctx =
393 HostContext::new(ApplyMode::DryRun, SetupKind::Full).with_paths(temp_paths(tmp.path()));
394 restart_if_present(&mut ctx, "dnsmasq");
395 assert!(!ctx.logs.is_empty() || !ctx.planned.is_empty());
396 }
397
398 #[test]
399 fn restart_iot_services_skips_hostapd_when_wifi_none() {
400 let tmp = TempDir::new().unwrap();
401 let paths = temp_paths(tmp.path());
402 std::fs::create_dir_all(&paths.active_setup).unwrap();
403 let mut map = std::collections::BTreeMap::new();
404 map.insert("WIFI_INTERFACE".into(), "none".into());
405 crate::kits::envfile::write(&paths.full_env_file(), &map).unwrap();
406 let mut ctx = HostContext::new(ApplyMode::DryRun, SetupKind::Full).with_paths(paths);
407 restart_iot_services(&mut ctx);
408 assert!(ctx
409 .logs
410 .iter()
411 .any(|l| l.contains("skipping hostapd restart")));
412 }
413
414 #[test]
415 fn restart_iot_services_skips_hostapd_when_env_missing() {
416 let tmp = TempDir::new().unwrap();
417 let mut ctx =
418 HostContext::new(ApplyMode::DryRun, SetupKind::Full).with_paths(temp_paths(tmp.path()));
419 restart_iot_services(&mut ctx);
420 assert!(ctx
421 .logs
422 .iter()
423 .any(|l| l.contains("skipping hostapd restart")));
424 }
425
426 #[test]
427 fn restart_iot_services_restarts_hostapd_when_wifi_set() {
428 let tmp = TempDir::new().unwrap();
429 let paths = temp_paths(tmp.path());
430 std::fs::create_dir_all(&paths.active_setup).unwrap();
431 let mut map = std::collections::BTreeMap::new();
432 map.insert("WIFI_INTERFACE".into(), "wlan0".into());
433 crate::kits::envfile::write(&paths.full_env_file(), &map).unwrap();
434 let mut ctx = HostContext::new(ApplyMode::DryRun, SetupKind::Full).with_paths(paths);
435 restart_iot_services(&mut ctx);
436 assert!(ctx.logs.iter().any(|l| l.contains("Restarting hostapd")));
437 assert!(!ctx.logs.iter().any(|l| l.contains("skipping hostapd")));
438 }
439
440 #[test]
441 fn trait_metadata_is_stable() {
442 let step = S7Activate;
443 assert_eq!(step.id(), "s7");
444 assert_eq!(step.reference_script(), "s7_activate_services.sh");
445 assert_eq!(step.step_version(), 3);
446 assert_eq!(step.depends_on(), &["s6"]);
447 assert_ne!(step.title(), "");
448 assert!(!step.is_done(&HostContext::new(ApplyMode::DryRun, SetupKind::Full)));
449 }
450
451 #[test]
452 fn plan_records_activation_actions() {
453 let tmp = TempDir::new().unwrap();
454 let mut ctx =
455 HostContext::new(ApplyMode::DryRun, SetupKind::Full).with_paths(temp_paths(tmp.path()));
456 let planned = S7Activate.plan(&mut ctx).unwrap();
457 assert!(planned.iter().any(|p| p.summary.contains("sysctl")));
458 assert!(planned.iter().any(|p| p.summary.contains("netplan")));
459 assert!(planned.iter().any(|p| p.summary.contains("dnsmasq")));
460 assert!(planned
461 .iter()
462 .any(|p| p.summary.contains("hostapd when WiFi AP enabled")));
463 assert!(planned.iter().any(|p| p.summary.contains("DHCP leases")));
464 }
465
466 #[test]
467 fn apply_dry_run_without_full_env_still_plans() {
468 let tmp = TempDir::new().unwrap();
469 let mut ctx =
470 HostContext::new(ApplyMode::DryRun, SetupKind::Full).with_paths(temp_paths(tmp.path()));
471 S7Activate.apply(&mut ctx).unwrap();
472 assert!(ctx.planned.iter().any(|p| p.summary.contains("sysctl")));
473 }
474
475 #[test]
476 fn apply_dry_run_with_full_env_plans_activation() {
477 let tmp = TempDir::new().unwrap();
478 let paths = temp_paths(tmp.path());
479 std::fs::create_dir_all(&paths.active_setup).unwrap();
480 let mut map = std::collections::BTreeMap::new();
481 map.insert("MY_HOSTNAME".into(), "cov-box".into());
482 crate::kits::envfile::write(&paths.full_env_file(), &map).unwrap();
483 let mut ctx = HostContext::new(ApplyMode::DryRun, SetupKind::Full).with_paths(paths);
484 S7Activate.apply(&mut ctx).unwrap();
485 assert!(ctx.planned.iter().any(|p| p.summary.contains("netplan")));
486 }
487
488 #[test]
489 fn install_cron_dry_run_records_plan_actions() {
490 let tmp = TempDir::new().unwrap();
491 let paths = temp_paths(tmp.path());
492 std::fs::create_dir_all(&paths.active_setup).unwrap();
493 let mut ctx = HostContext::new(ApplyMode::DryRun, SetupKind::Full).with_paths(paths);
494 install_export_cron(&mut ctx).unwrap();
495 assert!(ctx.planned.iter().any(|p| p.summary.contains("cron.d")));
496 assert!(ctx
497 .planned
498 .iter()
499 .any(|p| p.summary.contains("systemctl enable")));
500 }
501
502 #[test]
503 fn nat_rules_dry_run_uses_default_iface_when_missing_env_key() {
504 let tmp = TempDir::new().unwrap();
505 let paths = temp_paths(tmp.path());
506 std::fs::create_dir_all(&paths.active_setup).unwrap();
507 let map = std::collections::BTreeMap::new();
509 crate::kits::envfile::write(&paths.full_env_file(), &map).unwrap();
510 let mut ctx = HostContext::new(ApplyMode::DryRun, SetupKind::Full).with_paths(paths);
511 apply_nat_rules(&mut ctx).unwrap();
512 assert!(ctx.planned.iter().any(|p| p.summary.contains("MASQUERADE")));
513 assert!(ctx.planned.iter().any(|p| p.summary.contains("FORWARD")));
514 }
515
516 #[test]
517 fn run_iptables_dry_run_plans_only() {
518 let tmp = TempDir::new().unwrap();
519 let mut ctx =
520 HostContext::new(ApplyMode::DryRun, SetupKind::Full).with_paths(temp_paths(tmp.path()));
521 run_iptables(&mut ctx, &["-A", "FORWARD", "-i", "br0"]).unwrap();
522 assert!(ctx
523 .planned
524 .iter()
525 .any(|p| p.summary.starts_with("iptables") && p.summary.contains("FORWARD")));
526 }
527
528 #[test]
529 fn parse_default_wan_iface_handles_multiple_hops() {
530 assert_eq!(
531 parse_default_wan_iface("default via 10.0.0.1 dev enp2s0 proto static"),
532 Some("enp2s0".into())
533 );
534 assert_eq!(parse_default_wan_iface(""), None);
535 assert_eq!(parse_default_wan_iface("dev"), None);
536 }
537
538 #[test]
539 fn ensure_nat_helpers_dry_run_plan_iptables() {
540 let tmp = TempDir::new().unwrap();
541 let mut ctx =
542 HostContext::new(ApplyMode::DryRun, SetupKind::Full).with_paths(temp_paths(tmp.path()));
543 ensure_nat_masquerade(&mut ctx, "enp1s0").unwrap();
544 ensure_forward_accept(&mut ctx, "enp1s0");
545 assert!(ctx
546 .planned
547 .iter()
548 .any(|p| p.summary.contains("MASQUERADE") && p.summary.contains("enp1s0")));
549 assert!(ctx
550 .planned
551 .iter()
552 .any(|p| p.summary.contains("FORWARD") && p.summary.contains("br0")));
553 assert!(ctx
554 .planned
555 .iter()
556 .any(|p| p.summary.contains("RELATED,ESTABLISHED")));
557 }
558
559 #[test]
560 fn maybe_persist_iptables_skips_when_confirm_false() {
561 let tmp = TempDir::new().unwrap();
562 let mut ctx = HostContext::new(ApplyMode::DryRun, SetupKind::Full)
563 .with_paths(temp_paths(tmp.path()))
564 .with_prompts(Box::new(NonInteractivePrompts));
565 maybe_persist_iptables(&mut ctx).unwrap();
566 assert!(!ctx
567 .planned
568 .iter()
569 .any(|p| p.summary.contains("iptables-persistent")));
570 assert!(!ctx.logs.iter().any(|l| l.contains("iptables-persistent")));
571 }
572
573 #[test]
574 fn maybe_persist_iptables_plans_apt_when_confirm_true() {
575 struct YesPrompts;
576 impl crate::context::PromptsProvider for YesPrompts {
577 fn prompt(&mut self, _label: &str, default: &str) -> String {
578 default.to_owned()
579 }
580 fn confirm(&mut self, _question: &str, _default_yes: bool) -> bool {
581 true
582 }
583 }
584
585 let tmp = TempDir::new().unwrap();
586 let mut ctx = HostContext::new(ApplyMode::DryRun, SetupKind::Full)
587 .with_paths(temp_paths(tmp.path()))
588 .with_prompts(Box::new(YesPrompts));
589 maybe_persist_iptables(&mut ctx).unwrap();
590 assert!(ctx
591 .planned
592 .iter()
593 .any(|p| p.summary.contains("iptables-persistent")));
594 assert!(ctx.logs.iter().any(|l| l.contains("iptables-persistent")));
595 }
596
597 #[test]
598 fn apply_without_full_env_skips_when_not_dry_run() {
599 let tmp = TempDir::new().unwrap();
600 let mut ctx = HostContext::new(ApplyMode::Apply, SetupKind::Full)
601 .with_paths(temp_paths(tmp.path()))
602 .with_prompts(Box::new(NonInteractivePrompts));
603 S7Activate.apply(&mut ctx).unwrap();
604 assert!(ctx.logs.iter().any(|l| l.contains("skipping")));
605 assert_eq!(ctx.planned.len(), 0);
606 }
607}