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