Skip to main content

horto_os_ui_shared/steps/
s2_env.rs

1//! reference: horto-os/scripts/s2_init_env_vars.sh (+ `s2_init_env_vars_iot.sh`)
2use crate::context::{HostContext, PlannedAction};
3use crate::embed;
4use crate::error::{HortoError, Result};
5use crate::kits::{apt, envfile, fs};
6use crate::step::Step;
7use std::collections::BTreeMap;
8use std::process::Command;
9
10/// Collect and write OS / `IoT` env files (`s2`).
11pub struct S2Env;
12
13const OS_REQUIRED: &[&str] = &[
14    "MY_HOSTNAME",
15    "OS_TYPE",
16    "NPU_TYPE",
17    "INSTALL_TYP",
18    "IOT_LAN",
19];
20const IOT_ETH_REQUIRED: &[&str] = &["ETH_LAN", "ETH_IOT1", "WIFI_INTERFACE"];
21const IOT_PACKAGES_BASE: &[&str] = &["dnsmasq", "iptables", "avahi-daemon"];
22const IOT_PACKAGES_WIFI: &[&str] = &["hostapd"];
23
24impl Step for S2Env {
25    fn id(&self) -> &'static str {
26        "s2"
27    }
28    fn title(&self) -> &'static str {
29        "Configure environment variables"
30    }
31    fn reference_script(&self) -> &'static str {
32        "s2_init_env_vars.sh"
33    }
34    fn step_version(&self) -> u32 {
35        3
36    }
37    fn depends_on(&self) -> &'static [&'static str] {
38        &["s1"]
39    }
40    fn is_done(&self, ctx: &HostContext) -> bool {
41        let os_path = ctx.paths.os_configuration_file();
42        let Ok(os_map) = envfile::load(&os_path) else {
43            return false;
44        };
45        if envfile::require_keys(&os_map, OS_REQUIRED).is_err() {
46            return false;
47        }
48        if wants_iot_lan(&os_map) {
49            let iot_path = ctx.paths.iot_lan_env_file();
50            return envfile::load(&iot_path).is_ok_and(|m| iot_env_complete(&m));
51        }
52        true
53    }
54    fn plan(&self, ctx: &mut HostContext) -> Result<Vec<PlannedAction>> {
55        ctx.plan_action(format!(
56            "create/update {} from embedded config/os-configuration.env",
57            ctx.paths.os_configuration_file().display()
58        ));
59        ctx.plan_action("prompt OS_TYPE, NPU_TYPE, INSTALL_TYP, IOT_LAN, hostname, URL");
60        ctx.plan_action(
61            "if IOT_LAN=y: apt IoT packages + iot-lan_conf.env + ETH discovery (WiFi optional)",
62        );
63        Ok(ctx.planned.clone())
64    }
65    fn apply(&self, ctx: &mut HostContext) -> Result<()> {
66        fs::ensure_dir(ctx, &ctx.paths.active_setup.clone())?;
67        let os_active = ctx.paths.os_configuration_file();
68        let mut os_map = load_or_embed(ctx, &os_active, "config/os-configuration.env")?;
69
70        if ctx.is_dry_run() {
71            ctx.plan_action(format!("write {}", os_active.display()));
72            ctx.plan_action("optional IoT-LAN env + packages when IOT_LAN=y");
73            return Ok(());
74        }
75
76        prompt_os_conf(ctx, &mut os_map);
77        envfile::require_keys(&os_map, OS_REQUIRED)?;
78        envfile::write(&os_active, &os_map)?;
79        ctx.log(format!("Saved OS configuration to {}", os_active.display()));
80
81        if wants_iot_lan(&os_map) {
82            apply_iot_lan(ctx, &os_map)?;
83        } else {
84            ctx.log("setup without IOT_LAN detected");
85        }
86        Ok(())
87    }
88}
89
90fn load_or_embed(
91    ctx: &HostContext,
92    active: &std::path::Path,
93    embed_path: &str,
94) -> Result<BTreeMap<String, String>> {
95    if active.exists() && !ctx.is_dry_run() {
96        return envfile::load(active);
97    }
98    let template =
99        embed::get_str(embed_path).ok_or_else(|| HortoError::EmbedMissing(embed_path.into()))?;
100    Ok(envfile::parse(&template))
101}
102
103fn prompt_os_conf(ctx: &mut HostContext, map: &mut BTreeMap<String, String>) {
104    let hostname = ctx.prompt(
105        "Device hostname",
106        map.get("MY_HOSTNAME")
107            .map_or("Horto-OS_xxx", String::as_str),
108    );
109    let os_type = ctx.prompt(
110        "OS type (debian/armbian)",
111        map.get("OS_TYPE").map_or("debian", String::as_str),
112    );
113    let npu = ctx.prompt(
114        "NPU type (rkRK3576/rkRK3588/...)",
115        map.get("NPU_TYPE").map_or("rkRK3588", String::as_str),
116    );
117    let ram = ctx.prompt(
118        "RAM size label",
119        map.get("RAM_SYZE").map_or("8gb", String::as_str),
120    );
121    let install = ctx.prompt(
122        "Install type (home/satellite/hortex)",
123        map.get("INSTALL_TYP").map_or("home", String::as_str),
124    );
125    let iot = ctx.prompt(
126        "Enable IOT-LAN (y/n)",
127        map.get("IOT_LAN").map_or("n", String::as_str),
128    );
129    let my_url = ctx.prompt(
130        "Public URL / domain",
131        map.get("MY_URL")
132            .map_or("YourDomainName.net", String::as_str),
133    );
134    let cf = ctx.prompt(
135        "Cloudflare token (optional)",
136        map.get("MY_CLOUDFLARE_TOKEN").map_or("", String::as_str),
137    );
138
139    envfile::set_key(map, "MY_HOSTNAME", hostname);
140    envfile::set_key(map, "OS_TYPE", os_type);
141    envfile::set_key(map, "NPU_TYPE", npu);
142    envfile::set_key(map, "RAM_SYZE", ram);
143    envfile::set_key(map, "INSTALL_TYP", install);
144    envfile::set_key(map, "IOT_LAN", normalize_yn(&iot));
145    envfile::set_key(map, "MY_URL", my_url);
146    envfile::set_key(map, "MY_CLOUDFLARE_TOKEN", cf);
147}
148
149fn apply_iot_lan(ctx: &mut HostContext, os_map: &BTreeMap<String, String>) -> Result<()> {
150    ctx.log("IOT-LAN setup is next");
151
152    let iot_active = ctx.paths.iot_lan_env_file();
153    let mut map = load_or_embed(ctx, &iot_active, "config/iot-lan_conf.env")?;
154
155    if let Some(hostname) = os_map.get("MY_HOSTNAME") {
156        envfile::set_key(&mut map, "MY_HOSTNAME", hostname.clone());
157    }
158
159    let wifi_raw = ctx.prompt(
160        "WiFi interface (none = Ethernet-only)",
161        map.get("WIFI_INTERFACE").map_or("none", String::as_str),
162    );
163    let wifi_if = envfile::normalize_wifi_iface(&wifi_raw);
164    envfile::set_key(&mut map, "WIFI_INTERFACE", wifi_if.clone());
165
166    if envfile::wifi_iface_enabled(&wifi_if) {
167        let wifi_ssid = ctx.prompt(
168            "WiFi SSID",
169            map.get("WIFI_SSID").map_or("Horto-IoT-LAN", String::as_str),
170        );
171        let wifi_pass = ctx.prompt(
172            "WiFi passphrase",
173            map.get("WIFI_PASSPHRASE").map_or("", String::as_str),
174        );
175        envfile::set_key(&mut map, "WIFI_SSID", wifi_ssid);
176        envfile::set_key(&mut map, "WIFI_PASSPHRASE", wifi_pass);
177    } else {
178        ctx.log("WIFI_INTERFACE=none; Ethernet-only IoT-LAN (hostapd skipped)");
179        envfile::set_key(&mut map, "WIFI_SSID", "");
180        envfile::set_key(&mut map, "WIFI_PASSPHRASE", "");
181    }
182
183    install_iot_packages(ctx, envfile::wifi_iface_enabled(&wifi_if))?;
184    discover_eth(&mut map, ctx);
185    envfile::require_keys(&map, IOT_ETH_REQUIRED)?;
186    if envfile::wifi_ap_enabled(&map) {
187        envfile::require_keys(&map, &["WIFI_SSID"])?;
188    }
189    envfile::write(&iot_active, &map)?;
190    ctx.log(format!(
191        "Saved IoT-LAN variables to {}",
192        iot_active.display()
193    ));
194    Ok(())
195}
196
197fn install_iot_packages(ctx: &mut HostContext, wifi: bool) -> Result<()> {
198    ctx.log("Installing IoT LAN components...");
199    if !crate::context::is_root() {
200        ctx.log("Not root; skipping IoT apt install");
201        return Ok(());
202    }
203    apt::apt_install(ctx, IOT_PACKAGES_BASE)?;
204    if wifi {
205        apt::apt_install(ctx, IOT_PACKAGES_WIFI)?;
206        ctx.log("Base packages + hostapd for IOT-LAN installed.");
207    } else {
208        ctx.log("Base packages for Ethernet-only IOT-LAN installed (no hostapd).");
209    }
210    Ok(())
211}
212
213fn iot_env_complete(map: &BTreeMap<String, String>) -> bool {
214    if envfile::require_keys(map, IOT_ETH_REQUIRED).is_err() {
215        return false;
216    }
217    if envfile::wifi_ap_enabled(map) {
218        return envfile::require_keys(map, &["WIFI_SSID"]).is_ok();
219    }
220    true
221}
222
223fn wants_iot_lan(map: &BTreeMap<String, String>) -> bool {
224    map.get("IOT_LAN")
225        .is_some_and(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "y" | "yes"))
226}
227
228fn normalize_yn(raw: &str) -> String {
229    match raw.trim().to_ascii_lowercase().as_str() {
230        "y" | "yes" => "y".into(),
231        _ => "n".into(),
232    }
233}
234
235fn discover_eth(map: &mut BTreeMap<String, String>, ctx: &mut HostContext) {
236    assign_eth(map, ctx, &list_eth_ifaces());
237}
238
239fn assign_eth(
240    map: &mut BTreeMap<String, String>,
241    ctx: &mut HostContext,
242    ifaces: &[(String, bool)],
243) {
244    let (eth0, eth1, eth2) = pick_eth(ifaces);
245    envfile::set_key(map, "ETH_LAN", eth0.clone());
246    envfile::set_key(map, "ETH_IOT1", eth1.clone());
247    if let Some(e2) = eth2 {
248        envfile::set_key(map, "ETH_IOT2", e2.clone());
249        ctx.log(format!(
250            "Discovered ETH_LAN={eth0}, ETH_IOT1={eth1}, ETH_IOT2={e2}"
251        ));
252    } else {
253        ctx.log(format!(
254            "Discovered ETH_LAN={eth0}, ETH_IOT1={eth1}, ETH_IOT2=not-set"
255        ));
256    }
257}
258
259fn list_eth_ifaces() -> Vec<(String, bool)> {
260    let output = Command::new("ip").args(["-o", "link", "show"]).output();
261    let Ok(output) = output else {
262        return Vec::new();
263    };
264    let text = String::from_utf8_lossy(&output.stdout);
265    let mut out = Vec::new();
266    for line in text.lines() {
267        let Some(rest) = line.split_once(": ").map(|(_, r)| r) else {
268            continue;
269        };
270        let name = rest.split(':').next().unwrap_or("").trim();
271        if !is_candidate_eth(name) {
272            continue;
273        }
274        let lower_up = line.contains("LOWER_UP");
275        out.push((name.to_string(), lower_up));
276    }
277    out
278}
279
280fn is_candidate_eth(name: &str) -> bool {
281    let n = name.to_ascii_lowercase();
282    if n == "lo"
283        || n.starts_with("wlan")
284        || n.starts_with("wlx")
285        || n.starts_with("docker")
286        || n.starts_with("br")
287        || n.starts_with("veth")
288        || n.starts_with("virbr")
289    {
290        return false;
291    }
292    n.starts_with("en") || n.starts_with("eth") || n == "wan" || n.starts_with("lan")
293}
294
295fn pick_eth(ifaces: &[(String, bool)]) -> (String, String, Option<String>) {
296    let names: Vec<&str> = ifaces.iter().map(|(n, _)| n.as_str()).collect();
297    if names.contains(&"wan") && names.iter().any(|n| n.starts_with("lan")) {
298        let eth0 = "wan".to_string();
299        let mut lans: Vec<&str> = names
300            .iter()
301            .copied()
302            .filter(|n| n.starts_with("lan"))
303            .collect();
304        lans.sort_unstable();
305        let eth1 = lans.first().unwrap_or(&"lan1").to_string();
306        let eth2 = lans.get(1).map(|s| (*s).to_string());
307        return (eth0, eth1, eth2);
308    }
309
310    let active: Vec<&str> = ifaces
311        .iter()
312        .filter(|(_, up)| *up)
313        .map(|(n, _)| n.as_str())
314        .collect();
315    if !active.is_empty() {
316        let eth0 = active[0].to_string();
317        let rest: Vec<&str> = ifaces
318            .iter()
319            .map(|(n, _)| n.as_str())
320            .filter(|n| *n != eth0)
321            .collect();
322        let eth1 = rest.first().unwrap_or(&"lan1").to_string();
323        let eth2 = rest.get(1).map(|s| (*s).to_string());
324        return (eth0, eth1, eth2);
325    }
326    let eth0 = names.first().unwrap_or(&"wan").to_string();
327    let eth1 = names.get(1).unwrap_or(&"lan1").to_string();
328    let eth2 = names.get(2).map(|s| (*s).to_string());
329    (eth0, eth1, eth2)
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335
336    #[test]
337    fn iot_lan_flag_parsing() {
338        let mut m = BTreeMap::new();
339        m.insert("IOT_LAN".into(), "y".into());
340        assert!(wants_iot_lan(&m));
341        m.insert("IOT_LAN".into(), "yes".into());
342        assert!(wants_iot_lan(&m));
343        m.insert("IOT_LAN".into(), "n".into());
344        assert!(!wants_iot_lan(&m));
345        assert_eq!(normalize_yn("YES"), "y");
346        assert_eq!(normalize_yn("no"), "n");
347    }
348
349    #[test]
350    fn iot_env_complete_allows_wifi_none() {
351        let mut m = BTreeMap::new();
352        m.insert("ETH_LAN".into(), "wan".into());
353        m.insert("ETH_IOT1".into(), "lan1".into());
354        m.insert("WIFI_INTERFACE".into(), "none".into());
355        assert!(iot_env_complete(&m));
356
357        m.insert("WIFI_INTERFACE".into(), "wlan0".into());
358        assert!(!iot_env_complete(&m));
359        m.insert("WIFI_SSID".into(), "Horto-IoT-LAN".into());
360        assert!(iot_env_complete(&m));
361    }
362
363    #[test]
364    fn candidate_eth_includes_horto_names() {
365        assert!(is_candidate_eth("wan"));
366        assert!(is_candidate_eth("lan1"));
367        assert!(is_candidate_eth("lan2"));
368        assert!(is_candidate_eth("eth0"));
369        assert!(is_candidate_eth("enp1s0"));
370        assert!(!is_candidate_eth("lo"));
371        assert!(!is_candidate_eth("wlan0"));
372        assert!(!is_candidate_eth("docker0"));
373        assert!(!is_candidate_eth("br0"));
374    }
375
376    #[test]
377    fn pick_eth_prefers_wan_and_lan_on_r6s() {
378        let ifaces = vec![
379            ("lan2".into(), true),
380            ("wan".into(), true),
381            ("lan1".into(), false),
382        ];
383        let (eth0, eth1, eth2) = pick_eth(&ifaces);
384        assert_eq!(eth0, "wan");
385        assert_eq!(eth1, "lan1");
386        assert_eq!(eth2.as_deref(), Some("lan2"));
387    }
388
389    #[test]
390    fn pick_eth_uses_first_active_when_no_wan_lan() {
391        let ifaces = vec![
392            ("enp1s0".into(), false),
393            ("eth0".into(), true),
394            ("eth1".into(), false),
395        ];
396        let (eth0, eth1, eth2) = pick_eth(&ifaces);
397        assert_eq!(eth0, "eth0");
398        assert_eq!(eth1, "enp1s0");
399        assert_eq!(eth2.as_deref(), Some("eth1"));
400    }
401
402    #[test]
403    fn pick_eth_falls_back_when_none_up() {
404        let ifaces = vec![
405            ("eth0".into(), false),
406            ("eth1".into(), false),
407            ("eth2".into(), false),
408        ];
409        let (eth0, eth1, eth2) = pick_eth(&ifaces);
410        assert_eq!(eth0, "eth0");
411        assert_eq!(eth1, "eth1");
412        assert_eq!(eth2.as_deref(), Some("eth2"));
413    }
414
415    #[test]
416    fn pick_eth_empty_defaults_to_wan_lan1() {
417        let (eth0, eth1, eth2) = pick_eth(&[]);
418        assert_eq!(eth0, "wan");
419        assert_eq!(eth1, "lan1");
420        assert!(eth2.is_none());
421    }
422
423    #[test]
424    fn assign_eth_logs_iot2_when_present() {
425        use crate::context::{ApplyMode, HostContext};
426        use crate::pipeline::SetupKind;
427        use tempfile::TempDir;
428
429        let tmp = TempDir::new().unwrap();
430        let paths = crate::paths::HostPaths {
431            active_setup: tmp.path().join("active"),
432            backup: tmp.path().join("backup"),
433            docker: tmp.path().join("docker"),
434            etc: tmp.path().join("etc"),
435            lease_file: tmp.path().join("leases"),
436        };
437        let mut ctx = HostContext::new(ApplyMode::DryRun, SetupKind::Full).with_paths(paths);
438        let mut map = BTreeMap::new();
439        let ifaces = vec![
440            ("wan".into(), true),
441            ("lan1".into(), true),
442            ("lan2".into(), true),
443        ];
444        assign_eth(&mut map, &mut ctx, &ifaces);
445        assert_eq!(map.get("ETH_LAN").map(String::as_str), Some("wan"));
446        assert_eq!(map.get("ETH_IOT1").map(String::as_str), Some("lan1"));
447        assert_eq!(map.get("ETH_IOT2").map(String::as_str), Some("lan2"));
448        assert!(ctx.logs.iter().any(|l| l.contains("ETH_IOT2=lan2")));
449
450        let mut map2 = BTreeMap::new();
451        assign_eth(&mut map2, &mut ctx, &[("eth0".into(), false)]);
452        assert_eq!(map2.get("ETH_LAN").map(String::as_str), Some("eth0"));
453        assert!(!map2.contains_key("ETH_IOT2"));
454        assert!(ctx.logs.iter().any(|l| l.contains("ETH_IOT2=not-set")));
455    }
456
457    #[test]
458    fn iot_env_complete_false_without_eth_keys() {
459        let mut m = BTreeMap::new();
460        m.insert("WIFI_INTERFACE".into(), "none".into());
461        assert!(!iot_env_complete(&m));
462    }
463}