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::SnatConfig;
25use crate::models::SnatRequest;
26use crate::utils::request_json;
27
28pub 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
227pub 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
254pub 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
277pub 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
302pub 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
309pub 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
348pub 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
359pub 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
384pub 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
467pub 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}