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    delete: bool,
289    socket: impl AsRef<Path>,
290) -> Result<()> {
291    let req = HairpinRequest {
292        ext_ip,
293        int_ip,
294        ports,
295        proto: proto.parse()?,
296    };
297    if delete {
298        let _: () = request_json(socket, Method::DELETE, "/hairpin", Some(req)).await?;
299        tracing::info!("hairpin rule removed");
300    } else {
301        let _res: HairpinConfig = request_json(socket, Method::POST, "/hairpin", Some(req)).await?;
302        tracing::info!("hairpin rule added");
303    }
304    Ok(())
305}
306
307/// Adds or removes a policy routing rule via the daemon API.
308pub async fn handle_policy_route(
309    src_ip: String,
310    via: String,
311    table: u32,
312    delete: bool,
313    socket: impl AsRef<Path>,
314) -> Result<()> {
315    let req = PolicyRouteRequest { src_ip, via, table };
316    if delete {
317        let _: () = request_json(socket, Method::DELETE, "/policy-route", Some(req)).await?;
318        tracing::info!("policy route removed");
319    } else {
320        let _res: PolicyRouteConfig =
321            request_json(socket, Method::POST, "/policy-route", Some(req)).await?;
322        tracing::info!("policy route added");
323    }
324    Ok(())
325}
326
327/// Sends a clear-all request to the daemon, removing all managed rules and resetting state.
328pub async fn handle_clear(socket: impl AsRef<Path>) -> Result<()> {
329    let _: () = request_json(socket, Method::DELETE, "/clear", None::<()>).await?;
330    tracing::info!("all nat rules cleared");
331    Ok(())
332}
333
334/// Lists Docker port mappings from the daemon (active mappings only).
335pub async fn list(container_id: Option<String>, socket: &str, json: bool) -> Result<()> {
336    let res: Vec<DockerPortMap> =
337        request_json(socket, Method::GET, "/mappings", None::<()>).await?;
338    let res = if let Some(cid) = container_id {
339        res.into_iter()
340            .filter(|m| m.container_id.starts_with(&cid) || m.container_name == cid)
341            .collect()
342    } else {
343        res
344    };
345    if json {
346        println!("{}", serde_json::to_string_pretty(&res)?);
347    } else {
348        let mut table = comfy_table::Table::new();
349        table.set_header(vec![
350            "ID",
351            "CONTAINER",
352            "CONTAINER ID",
353            "HOST ADDR",
354            "CONTAINER ADDR",
355            "PROTO",
356        ]);
357        for m in res {
358            table.add_row(vec![
359                m.id.to_string(),
360                m.container_name,
361                m.container_id.chars().take(12).collect::<String>(),
362                m.request.host_addr.to_string(),
363                m.request.container_addr.to_string(),
364                m.request.proto.to_string(),
365            ]);
366        }
367        println!("{table}");
368    }
369
370    Ok(())
371}
372
373/// Lists Docker mappings, silently returning a message when the daemon is not reachable.
374pub async fn try_list(socket: &str, container_id: Option<String>, json: bool) -> Result<()> {
375    match list(container_id, socket, json).await {
376        Ok(()) => Ok(()),
377        Err(_) => {
378            println!("  (daemon not running)");
379            Ok(())
380        }
381    }
382}
383
384/// Remaps a container's host port to a new port without restarting the container.
385pub async fn remap(
386    container_id: String,
387    mapping: String,
388    socket: impl AsRef<Path>,
389    json: bool,
390) -> Result<()> {
391    let parts: Vec<&str> = mapping.split(':').collect();
392    if parts.len() != 2 {
393        bail!("Invalid mapping format. Use <old_host_port>:<new_host_port>");
394    }
395    let req = DockerRemapRequest {
396        host_port: parts[0].parse()?,
397        new_host_port: parts[1].parse()?,
398    };
399    let uri = format!("/remap/{container_id}");
400    let res: Vec<DockerPortMap> = request_json(socket, Method::PUT, &uri, Some(req)).await?;
401    if json {
402        println!("{}", serde_json::to_string_pretty(&res)?);
403    } else {
404        tracing::info!(count = res.len(), "successfully remapped rules");
405    }
406    Ok(())
407}
408
409/// Adds a new port mapping via the daemon API.
410pub async fn add(
411    container_id: String,
412    mapping_opt: Option<String>,
413    name: Option<String>,
414    socket: impl AsRef<Path>,
415    json: bool,
416) -> Result<()> {
417    let (container_id, mapping) = match (name, mapping_opt) {
418        (Some(n), Some(m)) => (n, m),
419        (Some(n), None) => (n, container_id),
420        (None, Some(m)) => (container_id, m),
421        (None, None) => bail!(
422            "Missing mapping. Usage: docker add <CONTAINER_ID> <MAPPING> or docker add <MAPPING> --name <NAME>"
423        ),
424    };
425
426    let (mapping_part, proto) = match mapping.split_once('/') {
427        Some((m, p)) => (m, p.parse()?),
428        None => (mapping.as_str(), TransportProtocol::default()),
429    };
430
431    let parts: Vec<&str> = mapping_part.split(':').collect();
432
433    let mut host_ip = "0.0.0.0".to_string();
434    let host_port: u16;
435    let mut target_ip = None;
436    let container_port: u16;
437
438    match parts.len() {
439        1 => {
440            host_port = parts[0].parse()?;
441            container_port = host_port;
442        }
443        2 => {
444            if let Ok(ip) = parts[0].parse::<std::net::IpAddr>() {
445                host_ip = ip.to_string();
446                host_port = parts[1].parse()?;
447                container_port = host_port;
448            } else {
449                host_port = parts[0].parse()?;
450                container_port = parts[1].parse()?;
451            }
452        }
453        3 => {
454            if let Ok(ip) = parts[0].parse::<std::net::IpAddr>() {
455                host_ip = ip.to_string();
456                host_port = parts[1].parse()?;
457                container_port = parts[2].parse()?;
458            } else {
459                host_port = parts[0].parse()?;
460                target_ip = Some(parts[1].to_string());
461                container_port = parts[2].parse()?;
462            }
463        }
464        4 => {
465            host_ip = parts[0].to_string();
466            host_port = parts[1].parse()?;
467            target_ip = Some(parts[2].to_string());
468            container_port = parts[3].parse()?;
469        }
470        _ => bail!(
471            "Invalid mapping format. Use [HOST_IP:]HOST_PORT[:[TARGET_IP:]TARGET_PORT][/PROTO]"
472        ),
473    }
474
475    let req = DockerAddMapRequest {
476        host_ip,
477        host_port,
478        container_port,
479        target_ip,
480        proto,
481    };
482    let uri = format!("/mapping/{container_id}");
483    let res: DockerPortMap = request_json(socket, Method::POST, &uri, Some(req)).await?;
484    if json {
485        println!("{}", serde_json::to_string_pretty(&res)?);
486    } else {
487        tracing::info!("successfully added mapping");
488    }
489    Ok(())
490}
491
492/// Removes one or more Docker port mappings via the daemon API.
493pub async fn remove(
494    container_id: Option<String>,
495    port: Option<String>,
496    all: bool,
497    id: Option<u64>,
498    name: Option<String>,
499    socket: impl AsRef<Path>,
500    json: bool,
501) -> Result<()> {
502    if let Some(mapping_id) = id {
503        let uri = format!("/mapping/by-id/{mapping_id}");
504        let _res: () = request_json(socket, Method::DELETE, &uri, None::<()>).await?;
505        if !json {
506            tracing::info!(mapping.id = mapping_id, "successfully removed mapping");
507        }
508    } else if all {
509        bail!("--all not implemented yet");
510    } else {
511        let cid = name
512            .or(container_id)
513            .ok_or_else(|| color_eyre::eyre::eyre!("Missing container ID or --name"))?;
514        let p = port.ok_or_else(|| color_eyre::eyre::eyre!("Missing port to remove"))?;
515        let port_num: u16 = p.split('/').next().unwrap().parse()?;
516        let uri = format!("/mapping/{cid}/{port_num}");
517        let _res: () = request_json(socket, Method::DELETE, &uri, None::<()>).await?;
518        if !json {
519            tracing::info!("successfully removed mapping");
520        }
521    }
522    Ok(())
523}