horto_os_ui_shared/ops/
leases.rs1use crate::context::HostContext;
4use crate::error::{HortoError, Result};
5use crate::kits::fs as fs_kit;
6use serde::{Deserialize, Serialize};
7use std::fmt::Write;
8use std::path::Path;
9use std::time::SystemTime;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct LeaseEntry {
14 pub hostname: String,
16 pub ip: String,
18 pub mac: String,
20 pub expires: String,
22}
23
24#[must_use]
26pub fn read_leases(lease_file: &Path, leases_json: &Path) -> Vec<LeaseEntry> {
27 if lease_file.is_file() {
28 if let Ok(text) = std::fs::read_to_string(lease_file) {
29 return parse_dnsmasq_leases(&text);
30 }
31 }
32 if leases_json.is_file() {
33 if let Ok(text) = std::fs::read_to_string(leases_json) {
34 if let Ok(list) = serde_json::from_str::<Vec<LeaseEntry>>(&text) {
35 return list;
36 }
37 }
38 }
39 Vec::new()
40}
41
42#[must_use]
44pub fn parse_dnsmasq_leases(text: &str) -> Vec<LeaseEntry> {
45 let mut out = Vec::new();
46 for line in text.lines() {
47 let parts: Vec<&str> = line.split_whitespace().collect();
48 if parts.len() < 4 {
49 continue;
50 }
51 out.push(LeaseEntry {
52 expires: parts[0].to_string(),
53 mac: parts[1].to_string(),
54 ip: parts[2].to_string(),
55 hostname: parts[3].to_string(),
56 });
57 }
58 out
59}
60
61pub fn export_dhcp_leases(ctx: &mut HostContext) -> Result<()> {
67 let lease_file = ctx.paths.lease_file.clone();
68 let out_dir = ctx.paths.docker_assets();
69 let out_json = ctx.paths.leases_json();
70 let out_html = ctx.paths.leases_html();
71
72 fs_kit::ensure_dir(ctx, &out_dir)?;
73
74 if lease_file.is_file() && out_html.is_file() {
75 if let (Ok(lm), Ok(hm)) = (
76 lease_file.metadata().and_then(|m| m.modified()),
77 out_html.metadata().and_then(|m| m.modified()),
78 ) {
79 if lm <= hm {
80 ctx.log("Leases unchanged since last export; skipping.");
81 return Ok(());
82 }
83 }
84 }
85
86 if !lease_file.is_file() {
87 let empty_json = "[]\n";
88 let empty_html = "<html><body><p>No DHCP leases found.</p></body></html>\n";
89 fs_kit::write_file(ctx, &out_json, empty_json.as_bytes())?;
90 fs_kit::write_file(ctx, &out_html, empty_html.as_bytes())?;
91 ctx.log("Lease file not found, wrote empty output.");
92 return Ok(());
93 }
94
95 if ctx.is_dry_run() {
96 ctx.plan_action(format!(
97 "export leases from {} to {} and {}",
98 lease_file.display(),
99 out_json.display(),
100 out_html.display()
101 ));
102 return Ok(());
103 }
104
105 let text = std::fs::read_to_string(&lease_file)
106 .map_err(|e| HortoError::msg(format!("read leases: {e}")))?;
107 let leases = parse_dnsmasq_leases(&text);
108 let json = serde_json::to_string_pretty(&leases)?;
109 let html = render_leases_html(&leases);
110 std::fs::write(&out_json, json)?;
111 std::fs::write(&out_html, html)?;
112 let _ = SystemTime::now();
113 ctx.log(format!(
114 "DHCP leases exported to {} and {}",
115 out_json.display(),
116 out_html.display()
117 ));
118 Ok(())
119}
120
121fn render_leases_html(leases: &[LeaseEntry]) -> String {
122 let mut out = String::from(
123 r#"<!DOCTYPE html>
124<html lang="en">
125<head>
126<meta charset="utf-8">
127<meta name="viewport" content="width=device-width, initial-scale=1">
128<style>
129body{font-family:ui-sans-serif,sans-serif;margin:0;padding:0;background:transparent;color:inherit}
130table{width:100%;border-collapse:collapse;font-size:.85rem}
131th,td{text-align:left;padding:6px 8px;border-bottom:1px solid rgba(255,255,255,.12)}
132th{font-weight:600;opacity:.7;text-transform:uppercase;font-size:.7rem;letter-spacing:.5px}
133tr:hover{background:rgba(255,255,255,.05)}
134</style>
135</head>
136<body>
137<table>
138<tr><th>Hostname</th><th>IP</th><th>MAC</th></tr>
139"#,
140 );
141 for l in leases {
142 let _ = writeln!(
143 out,
144 "<tr><td>{}</td><td>{}</td><td style=\"font-family:monospace;font-size:.8rem\">{}</td></tr>",
145 html_escape(&l.hostname),
146 html_escape(&l.ip),
147 html_escape(&l.mac)
148 );
149 }
150 out.push_str("</table>\n</body>\n</html>\n");
151 out
152}
153
154fn html_escape(s: &str) -> String {
155 s.replace('&', "&")
156 .replace('<', "<")
157 .replace('>', ">")
158 .replace('"', """)
159}