1use 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
10pub 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}