Skip to main content

horto_os_ui_shared/ops/
leases.rs

1//! DHCP lease parsing (dnsmasq) and JSON export helpers.
2
3use 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/// One DHCP lease row for status / export.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct LeaseEntry {
14    /// Client hostname when known.
15    pub hostname: String,
16    /// Leased IPv4 address.
17    pub ip: String,
18    /// Client MAC address.
19    pub mac: String,
20    /// Lease expiry (dnsmasq epoch or formatted string).
21    pub expires: String,
22}
23
24/// Read leases from a dnsmasq lease file, falling back to a JSON export path.
25#[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/// Parse dnsmasq `dnsmasq.leases` text into [`LeaseEntry`] rows.
43#[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
61/// Port of `export_dhcp_leases.sh`: write leases.json + leases.html under docker assets.
62///
63/// # Errors
64///
65/// Returns [`HortoError`] when directory creation or lease file writes fail.
66pub 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('&', "&amp;")
156        .replace('<', "&lt;")
157        .replace('>', "&gt;")
158        .replace('"', "&quot;")
159}