1use 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
30pub 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#[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
259pub 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
282pub 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
309pub 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
329pub 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
336pub 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
375pub 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
386pub 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
411pub 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
494pub 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}