Skip to main content

horto_os_ui_shared/steps/
s7_activate.rs

1//! reference: horto-os/scripts/s7_activate_services.sh
2use 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
9/// Activate network services after staged configs are validated (`s7`).
10pub 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        // v3: skip hostapd restart when WIFI_INTERFACE=none
24        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/// Parse `ip route show default` output for the WAN device name.
247#[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    // Align with horto-os s7: system cron.d entry as root, not a user crontab.
289    // Call horto (resolved absolute when possible) so the box need not keep horto-os scripts.
290    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        // Write env without ETH_LAN so default_wan_iface / fallback "eth0" is used.
508        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}