Skip to main content

lab_ops_natmap/
command.rs

1//! CLI command implementations that communicate with the natmap daemon.
2//!
3//! Each `handle_*` function constructs the appropriate HTTP request to the
4//! daemon's Unix socket API and formats the response for display.
5
6use std::path::Path;
7use std::process::Command;
8
9use color_eyre::eyre::Result;
10use color_eyre::eyre::bail;
11use comfy_table::Attribute;
12use comfy_table::Color;
13use hyper::Method;
14use lab_ops_lab_lib::TransportProtocol;
15
16use crate::models::DnatConfig;
17use crate::models::DnatRequest;
18use crate::models::DockerAddMapRequest;
19use crate::models::DockerPortMap;
20use crate::models::DockerRemapRequest;
21use crate::models::HairpinConfig;
22use crate::models::HairpinRequest;
23use crate::models::ListResponse;
24use crate::models::SnatConfig;
25use crate::models::SnatRequest;
26use crate::utils::request_json;
27
28/// Displays a combined listing of static iptables NAT rules and daemon-managed state.
29///
30/// Reads live iptables rules via `iptables-save` (filtering to natmap-commented
31/// rules only) and queries the daemon at `GET /mappings` for managed DNAT, SNAT,
32/// hairpin, and Docker mappings.
33pub async fn handle_list(
34    socket: impl AsRef<Path>,
35    container_id: Option<String>,
36    json: bool,
37    use_color: bool,
38) -> Result<()> {
39    println!("── Static iptables NAT rules (natmap-managed) ──");
40    let output = Command::new("iptables-save").output();
41    match output {
42        Ok(o) => {
43            let stdout = String::from_utf8_lossy(&o.stdout);
44            let rules: Vec<&str> = stdout.lines().filter(|l| l.contains("natmap:")).collect();
45            if rules.is_empty() {
46                println!("  (none)");
47            } else {
48                let mut table = comfy_table::Table::new();
49                if use_color {
50                    table.set_header(vec![
51                        comfy_table::Cell::new("CHAIN")
52                            .fg(Color::Cyan)
53                            .add_attribute(Attribute::Bold),
54                        comfy_table::Cell::new("RULE")
55                            .fg(Color::Cyan)
56                            .add_attribute(Attribute::Bold),
57                    ]);
58                } else {
59                    table.set_header(vec!["CHAIN", "RULE"]);
60                }
61                for r in &rules {
62                    let rest = r.strip_prefix("-A ").unwrap_or(r);
63                    let (chain, rule) = rest.split_once(' ').unwrap_or((rest, ""));
64                    table.add_row(vec![chain.to_string(), rule.to_string()]);
65                }
66                println!("{table}");
67            }
68        }
69        Err(_) => println!("  (could not read iptables rules)"),
70    }
71
72    println!("\n── Daemon-managed state ──");
73    match request_json::<ListResponse, ()>(socket, Method::GET, "/mappings", None).await {
74        Ok(resp) => {
75            if !resp.dnats.is_empty() {
76                let mut table = comfy_table::Table::new();
77                if use_color {
78                    table.set_header(vec![
79                        comfy_table::Cell::new("EXT IP")
80                            .fg(Color::Cyan)
81                            .add_attribute(Attribute::Bold),
82                        comfy_table::Cell::new("INT IP")
83                            .fg(Color::Cyan)
84                            .add_attribute(Attribute::Bold),
85                        comfy_table::Cell::new("PORTS")
86                            .fg(Color::Cyan)
87                            .add_attribute(Attribute::Bold),
88                        comfy_table::Cell::new("PROTO")
89                            .fg(Color::Cyan)
90                            .add_attribute(Attribute::Bold),
91                        comfy_table::Cell::new("IFACE")
92                            .fg(Color::Cyan)
93                            .add_attribute(Attribute::Bold),
94                    ]);
95                } else {
96                    table.set_header(vec!["EXT IP", "INT IP", "PORTS", "PROTO", "IFACE"]);
97                }
98                for d in &resp.dnats {
99                    let if_info = d.ext_if.as_deref().unwrap_or("-");
100                    table.add_row(vec![
101                        d.ext_ip.clone(),
102                        d.int_ip.clone(),
103                        d.ports.clone(),
104                        d.proto.to_string(),
105                        if_info.to_string(),
106                    ]);
107                }
108                println!("  DNAT rules:\n{table}");
109            }
110            if !resp.snats.is_empty() {
111                let mut table = comfy_table::Table::new();
112                if use_color {
113                    table.set_header(vec![
114                        comfy_table::Cell::new("INT IP")
115                            .fg(Color::Cyan)
116                            .add_attribute(Attribute::Bold),
117                        comfy_table::Cell::new("EXT IP")
118                            .fg(Color::Cyan)
119                            .add_attribute(Attribute::Bold),
120                        comfy_table::Cell::new("IFACE")
121                            .fg(Color::Cyan)
122                            .add_attribute(Attribute::Bold),
123                    ]);
124                } else {
125                    table.set_header(vec!["INT IP", "EXT IP", "IFACE"]);
126                }
127                for s in &resp.snats {
128                    table.add_row(vec![s.int_ip.clone(), s.ext_ip.clone(), s.ext_if.clone()]);
129                }
130                println!("  SNAT rules:\n{table}");
131            }
132            if !resp.hairpins.is_empty() {
133                let mut table = comfy_table::Table::new();
134                if use_color {
135                    table.set_header(vec![
136                        comfy_table::Cell::new("EXT IP")
137                            .fg(Color::Cyan)
138                            .add_attribute(Attribute::Bold),
139                        comfy_table::Cell::new("INT IP")
140                            .fg(Color::Cyan)
141                            .add_attribute(Attribute::Bold),
142                        comfy_table::Cell::new("PORTS")
143                            .fg(Color::Cyan)
144                            .add_attribute(Attribute::Bold),
145                        comfy_table::Cell::new("PROTO")
146                            .fg(Color::Cyan)
147                            .add_attribute(Attribute::Bold),
148                    ]);
149                } else {
150                    table.set_header(vec!["EXT IP", "INT IP", "PORTS", "PROTO"]);
151                }
152                for h in &resp.hairpins {
153                    table.add_row(vec![
154                        h.ext_ip.clone(),
155                        h.int_ip.clone(),
156                        h.ports.clone(),
157                        h.proto.to_string(),
158                    ]);
159                }
160                println!("  Hairpin rules:\n{table}");
161            }
162            if !resp.docker.is_empty() {
163                println!("  Docker mappings:");
164                if json {
165                    println!("{}", serde_json::to_string_pretty(&resp.docker)?);
166                } else {
167                    let mut table = comfy_table::Table::new();
168                    if use_color {
169                        table.set_header(vec![
170                            comfy_table::Cell::new("ID")
171                                .fg(Color::Cyan)
172                                .add_attribute(Attribute::Bold),
173                            comfy_table::Cell::new("CONTAINER")
174                                .fg(Color::Cyan)
175                                .add_attribute(Attribute::Bold),
176                            comfy_table::Cell::new("CONTAINER ID")
177                                .fg(Color::Cyan)
178                                .add_attribute(Attribute::Bold),
179                            comfy_table::Cell::new("HOST ADDR")
180                                .fg(Color::Cyan)
181                                .add_attribute(Attribute::Bold),
182                            comfy_table::Cell::new("CONTAINER ADDR")
183                                .fg(Color::Cyan)
184                                .add_attribute(Attribute::Bold),
185                            comfy_table::Cell::new("PROTO")
186                                .fg(Color::Cyan)
187                                .add_attribute(Attribute::Bold),
188                        ]);
189                    } else {
190                        table.set_header(vec![
191                            "ID",
192                            "CONTAINER",
193                            "CONTAINER ID",
194                            "HOST ADDR",
195                            "CONTAINER ADDR",
196                            "PROTO",
197                        ]);
198                    }
199                    for m in resp.docker {
200                        if let Some(ref cid) = container_id
201                            && !m.container_id.starts_with(cid)
202                            && m.container_name != *cid
203                        {
204                            continue;
205                        }
206                        table.add_row(vec![
207                            m.id.to_string(),
208                            m.container_name,
209                            m.container_id.chars().take(12).collect::<String>(),
210                            m.request.host_addr.to_string(),
211                            m.request.container_addr.to_string(),
212                            m.request.proto.to_string(),
213                        ]);
214                    }
215                    println!("{table}");
216                }
217            }
218        }
219        Err(_) => {
220            println!("  (daemon not running — use `natmap daemon` to start)");
221        }
222    }
223
224    Ok(())
225}
226
227/// Adds or removes a static DNAT rule via the daemon API.
228pub async fn handle_dnat(
229    ext_ip: String,
230    int_ip: String,
231    proto: String,
232    ports: String,
233    ext_if: Option<String>,
234    delete: bool,
235    socket: impl AsRef<Path>,
236) -> Result<()> {
237    let req = DnatRequest {
238        ext_ip,
239        int_ip,
240        ports,
241        proto: proto.parse()?,
242        ext_if,
243    };
244    if delete {
245        let _: () = request_json(socket, Method::DELETE, "/dnat", Some(req)).await?;
246        tracing::info!("dnat rule removed");
247    } else {
248        let _res: DnatConfig = request_json(socket, Method::POST, "/dnat", Some(req)).await?;
249        tracing::info!("dnat rule added");
250    }
251    Ok(())
252}
253
254/// Adds or removes a static SNAT rule via the daemon API.
255pub async fn handle_snat(
256    int_ip: String,
257    ext_if: String,
258    ext_ip: String,
259    delete: bool,
260    socket: impl AsRef<Path>,
261) -> Result<()> {
262    let req = SnatRequest {
263        int_ip,
264        ext_if,
265        ext_ip,
266    };
267    if delete {
268        let _: () = request_json(socket, Method::DELETE, "/snat", Some(req)).await?;
269        tracing::info!("snat rule removed");
270    } else {
271        let _res: SnatConfig = request_json(socket, Method::POST, "/snat", Some(req)).await?;
272        tracing::info!("snat rule added");
273    }
274    Ok(())
275}
276
277/// Adds or removes a static hairpin NAT rule via the daemon API.
278pub async fn handle_hairpin(
279    ext_ip: String,
280    int_ip: String,
281    proto: String,
282    ports: String,
283    delete: bool,
284    socket: impl AsRef<Path>,
285) -> Result<()> {
286    let req = HairpinRequest {
287        ext_ip,
288        int_ip,
289        ports,
290        proto: proto.parse()?,
291    };
292    if delete {
293        let _: () = request_json(socket, Method::DELETE, "/hairpin", Some(req)).await?;
294        tracing::info!("hairpin rule removed");
295    } else {
296        let _res: HairpinConfig = request_json(socket, Method::POST, "/hairpin", Some(req)).await?;
297        tracing::info!("hairpin rule added");
298    }
299    Ok(())
300}
301
302/// Sends a clear-all request to the daemon, removing all managed rules and resetting state.
303pub async fn handle_clear(socket: impl AsRef<Path>) -> Result<()> {
304    let _: () = request_json(socket, Method::DELETE, "/clear", None::<()>).await?;
305    tracing::info!("all nat rules cleared");
306    Ok(())
307}
308
309/// Lists Docker port mappings from the daemon (active mappings only).
310pub async fn list(container_id: Option<String>, socket: &str, json: bool) -> Result<()> {
311    let res: Vec<DockerPortMap> =
312        request_json(socket, Method::GET, "/mappings", None::<()>).await?;
313    let res = if let Some(cid) = container_id {
314        res.into_iter()
315            .filter(|m| m.container_id.starts_with(&cid) || m.container_name == cid)
316            .collect()
317    } else {
318        res
319    };
320    if json {
321        println!("{}", serde_json::to_string_pretty(&res)?);
322    } else {
323        let mut table = comfy_table::Table::new();
324        table.set_header(vec![
325            "ID",
326            "CONTAINER",
327            "CONTAINER ID",
328            "HOST ADDR",
329            "CONTAINER ADDR",
330            "PROTO",
331        ]);
332        for m in res {
333            table.add_row(vec![
334                m.id.to_string(),
335                m.container_name,
336                m.container_id.chars().take(12).collect::<String>(),
337                m.request.host_addr.to_string(),
338                m.request.container_addr.to_string(),
339                m.request.proto.to_string(),
340            ]);
341        }
342        println!("{table}");
343    }
344
345    Ok(())
346}
347
348/// Lists Docker mappings, silently returning a message when the daemon is not reachable.
349pub async fn try_list(socket: &str, container_id: Option<String>, json: bool) -> Result<()> {
350    match list(container_id, socket, json).await {
351        Ok(()) => Ok(()),
352        Err(_) => {
353            println!("  (daemon not running)");
354            Ok(())
355        }
356    }
357}
358
359/// Remaps a container's host port to a new port without restarting the container.
360pub async fn remap(
361    container_id: String,
362    mapping: String,
363    socket: impl AsRef<Path>,
364    json: bool,
365) -> Result<()> {
366    let parts: Vec<&str> = mapping.split(':').collect();
367    if parts.len() != 2 {
368        bail!("Invalid mapping format. Use <old_host_port>:<new_host_port>");
369    }
370    let req = DockerRemapRequest {
371        host_port: parts[0].parse()?,
372        new_host_port: parts[1].parse()?,
373    };
374    let uri = format!("/remap/{container_id}");
375    let res: Vec<DockerPortMap> = request_json(socket, Method::PUT, &uri, Some(req)).await?;
376    if json {
377        println!("{}", serde_json::to_string_pretty(&res)?);
378    } else {
379        tracing::info!(count = res.len(), "successfully remapped rules");
380    }
381    Ok(())
382}
383
384/// Adds a new port mapping via the daemon API.
385pub async fn add(
386    container_id: String,
387    mapping_opt: Option<String>,
388    name: Option<String>,
389    socket: impl AsRef<Path>,
390    json: bool,
391) -> Result<()> {
392    let (container_id, mapping) = match (name, mapping_opt) {
393        (Some(n), Some(m)) => (n, m),
394        (Some(n), None) => (n, container_id),
395        (None, Some(m)) => (container_id, m),
396        (None, None) => bail!(
397            "Missing mapping. Usage: docker add <CONTAINER_ID> <MAPPING> or docker add <MAPPING> --name <NAME>"
398        ),
399    };
400
401    let (mapping_part, proto) = match mapping.split_once('/') {
402        Some((m, p)) => (m, p.parse()?),
403        None => (mapping.as_str(), TransportProtocol::default()),
404    };
405
406    let parts: Vec<&str> = mapping_part.split(':').collect();
407
408    let mut host_ip = "0.0.0.0".to_string();
409    let host_port: u16;
410    let mut target_ip = None;
411    let container_port: u16;
412
413    match parts.len() {
414        1 => {
415            host_port = parts[0].parse()?;
416            container_port = host_port;
417        }
418        2 => {
419            if let Ok(ip) = parts[0].parse::<std::net::IpAddr>() {
420                host_ip = ip.to_string();
421                host_port = parts[1].parse()?;
422                container_port = host_port;
423            } else {
424                host_port = parts[0].parse()?;
425                container_port = parts[1].parse()?;
426            }
427        }
428        3 => {
429            if let Ok(ip) = parts[0].parse::<std::net::IpAddr>() {
430                host_ip = ip.to_string();
431                host_port = parts[1].parse()?;
432                container_port = parts[2].parse()?;
433            } else {
434                host_port = parts[0].parse()?;
435                target_ip = Some(parts[1].to_string());
436                container_port = parts[2].parse()?;
437            }
438        }
439        4 => {
440            host_ip = parts[0].to_string();
441            host_port = parts[1].parse()?;
442            target_ip = Some(parts[2].to_string());
443            container_port = parts[3].parse()?;
444        }
445        _ => bail!(
446            "Invalid mapping format. Use [HOST_IP:]HOST_PORT[:[TARGET_IP:]TARGET_PORT][/PROTO]"
447        ),
448    }
449
450    let req = DockerAddMapRequest {
451        host_ip,
452        host_port,
453        container_port,
454        target_ip,
455        proto,
456    };
457    let uri = format!("/mapping/{container_id}");
458    let res: DockerPortMap = request_json(socket, Method::POST, &uri, Some(req)).await?;
459    if json {
460        println!("{}", serde_json::to_string_pretty(&res)?);
461    } else {
462        tracing::info!("successfully added mapping");
463    }
464    Ok(())
465}
466
467/// Removes one or more Docker port mappings via the daemon API.
468pub async fn remove(
469    container_id: Option<String>,
470    port: Option<String>,
471    all: bool,
472    id: Option<u64>,
473    name: Option<String>,
474    socket: impl AsRef<Path>,
475    json: bool,
476) -> Result<()> {
477    if let Some(mapping_id) = id {
478        let uri = format!("/mapping/by-id/{mapping_id}");
479        let _res: () = request_json(socket, Method::DELETE, &uri, None::<()>).await?;
480        if !json {
481            tracing::info!(mapping.id = mapping_id, "successfully removed mapping");
482        }
483    } else if all {
484        bail!("--all not implemented yet");
485    } else {
486        let cid = name
487            .or(container_id)
488            .ok_or_else(|| color_eyre::eyre::eyre!("Missing container ID or --name"))?;
489        let p = port.ok_or_else(|| color_eyre::eyre::eyre!("Missing port to remove"))?;
490        let port_num: u16 = p.split('/').next().unwrap().parse()?;
491        let uri = format!("/mapping/{cid}/{port_num}");
492        let _res: () = request_json(socket, Method::DELETE, &uri, None::<()>).await?;
493        if !json {
494            tracing::info!("successfully removed mapping");
495        }
496    }
497    Ok(())
498}