1use std::ffi::OsStr;
9use std::process::Command;
10
11use color_eyre::Result;
12use color_eyre::eyre::bail;
13
14use crate::models::DnatConfig;
15use crate::models::DockerPortMap;
16use crate::models::HairpinConfig;
17use crate::models::SnatConfig;
18
19const NATMAP: &str = "NATMAP";
20
21pub struct IptablesManager;
27
28impl Default for IptablesManager {
29 fn default() -> Self {
30 Self::new()
31 }
32}
33
34impl IptablesManager {
35 pub fn new() -> Self {
37 Self
38 }
39
40 pub fn setup(&self) -> Result<()> {
45 tracing::info!("setting up iptables chains and jumps");
46
47 for &cmd in &["iptables", "ip6tables"] {
48 if !self.chain_exists(cmd, "filter", "DOCKER-USER") {
50 self.run_success(cmd, ["-t", "filter", "-N", "DOCKER-USER"])?;
52 self.run_success(cmd, ["-t", "filter", "-I", "FORWARD", "-j", "DOCKER-USER"])?;
54 }
55
56 if !self.chain_exists(cmd, "nat", NATMAP) {
58 self.run_success(cmd, ["-t", "nat", "-N", NATMAP])?;
59 }
60
61 if !self.chain_exists(cmd, "filter", NATMAP) {
63 self.run_success(cmd, ["-t", "filter", "-N", NATMAP])?;
64 }
65
66 if !self.rule_exists(cmd, &["-t", "filter", "-C", "DOCKER-USER", "-j", NATMAP]) {
68 self.run(cmd, ["-t", "filter", "-I", "DOCKER-USER", "-j", NATMAP])?;
69 }
70
71 if !self.rule_exists(cmd, &["-t", "nat", "-C", "PREROUTING", "-j", NATMAP]) {
73 self.run_success(cmd, ["-t", "nat", "-I", "PREROUTING", "-j", NATMAP])?;
74 }
75 }
76
77 Ok(())
78 }
79
80 pub fn install_dockermap(&self, map: &DockerPortMap) -> Result<()> {
82 tracing::debug!(mapping = ?map, "installing mapping");
83 let cmd = self.cmd_for(map.request.is_ipv6());
84 let req = &map.request;
85
86 let host_ip = req.host_addr.ip();
87 let host_port = req.host_addr.port().to_string();
88 let container_addr = req.container_addr.to_string();
89 let container_ip = req.container_addr.ip().to_string();
90 let container_port = req.container_addr.port().to_string();
91 let proto = req.proto.to_string();
92 let comment = &map.rule_comment;
93
94 self.run(
96 cmd,
97 [
98 "-t",
99 "nat",
100 "-A",
101 NATMAP,
102 "-p",
103 &proto,
104 "--dport",
105 &host_port,
106 "-j",
107 "DNAT",
108 "--to-destination",
109 &container_addr,
110 "-m",
111 "comment",
112 "--comment",
113 comment,
114 ],
115 )?;
116
117 self.run(
119 cmd,
120 [
121 "-t",
122 "filter",
123 "-A",
124 NATMAP,
125 "-d",
126 &container_ip,
127 "-p",
128 &proto,
129 "--dport",
130 &container_port,
131 "-j",
132 "ACCEPT",
133 "-m",
134 "comment",
135 "--comment",
136 comment,
137 ],
138 )?;
139
140 self.run(
142 cmd,
143 [
144 "-t",
145 "nat",
146 "-A",
147 "POSTROUTING",
148 "-s",
149 &container_ip,
150 "-d",
151 &container_ip,
152 "-p",
153 &proto,
154 "--dport",
155 &container_port,
156 "-j",
157 "MASQUERADE",
158 "-m",
159 "comment",
160 "--comment",
161 comment,
162 ],
163 )?;
164
165 let output_dst = if host_ip.is_unspecified() {
169 if map.request.is_ipv6() {
170 "::1"
171 } else {
172 "127.0.0.1"
173 }
174 } else {
175 &map.request.host_addr.ip().to_string()
176 };
177 self.run(
178 cmd,
179 [
180 "-t",
181 "nat",
182 "-A",
183 "OUTPUT",
184 "-d",
185 output_dst,
186 "-p",
187 &proto,
188 "--dport",
189 &host_port,
190 "-j",
191 "DNAT",
192 "--to-destination",
193 &container_addr,
194 "-m",
195 "comment",
196 "--comment",
197 comment,
198 ],
199 )?;
200
201 Ok(())
202 }
203
204 pub fn flush_all_natmap(&self) -> Result<()> {
210 tracing::info!("flushing all NATMAP iptables rules");
211
212 for &cmd in &["iptables", "ip6tables"] {
213 let _ = self.flush_chain(cmd, "nat", NATMAP);
214 let _ = self.flush_chain(cmd, "filter", NATMAP);
215 let _ = self.delete_all_natmap(cmd, "nat", "POSTROUTING");
216 let _ = self.delete_all_natmap(cmd, "nat", "OUTPUT");
217 let _ = self.delete_all_natmap(cmd, "nat", "PREROUTING");
218 let _ = self.delete_all_natmap(cmd, "filter", "FORWARD");
219 }
220 Ok(())
221 }
222
223 pub fn install_dnat(&self, config: &DnatConfig) -> Result<()> {
225 let comment = config.rule_comment();
226 let multiport = config.ports.contains(',');
227 let port_args = if multiport {
228 vec!["-m", "multiport", "--dports", &config.ports]
229 } else {
230 vec!["--dport", &config.ports]
231 };
232
233 let mut pre_args = vec!["-t", "nat", "-A", "PREROUTING"];
234 if let Some(ref iface) = config.ext_if {
235 pre_args.extend(vec!["-i", iface]);
236 }
237 let proto = config.proto.to_lowercase();
238 pre_args.extend(vec!["-d", &config.ext_ip, "-p", proto]);
239 pre_args.extend(port_args.clone());
240 let dest = if multiport {
241 config.int_ip.clone()
242 } else {
243 format!("{}:{}", config.int_ip, config.ports)
244 };
245 pre_args.extend(vec!["-j", "DNAT", "--to-destination", &dest]);
246 pre_args.extend(vec!["-m", "comment", "--comment", &comment]);
247 self.run_success("iptables", &pre_args)?;
248
249 let mut fwd_args = vec!["-A", "FORWARD", "-p", proto, "-d", &config.int_ip];
250 fwd_args.extend(port_args);
251 fwd_args.extend(vec!["-j", "ACCEPT"]);
252 fwd_args.extend(vec!["-m", "comment", "--comment", &comment]);
253 self.run_success("iptables", &fwd_args)?;
254 Ok(())
255 }
256
257 pub fn remove_dnat(&self, config: &DnatConfig) -> Result<()> {
261 let comment = config.rule_comment();
262 self.delete_all_matching("iptables", "nat", "PREROUTING", &comment)?;
263 self.delete_all_matching("iptables", "filter", "FORWARD", &comment)?;
264 Ok(())
265 }
266
267 pub fn install_snat(&self, config: &SnatConfig) -> Result<()> {
269 let comment = config.rule_comment();
270 let args = vec![
271 "-t",
272 "nat",
273 "-A",
274 "POSTROUTING",
275 "-s",
276 &config.int_ip,
277 "-o",
278 &config.ext_if,
279 "-j",
280 "SNAT",
281 "--to-source",
282 &config.ext_ip,
283 "-m",
284 "comment",
285 "--comment",
286 &comment,
287 ];
288 self.run_success("iptables", &args)?;
289 Ok(())
290 }
291
292 pub fn remove_snat(&self, config: &SnatConfig) -> Result<()> {
296 let comment = config.rule_comment();
297 self.delete_all_matching("iptables", "nat", "POSTROUTING", &comment)?;
298 Ok(())
299 }
300
301 pub fn install_hairpin(&self, config: &HairpinConfig) -> Result<()> {
312 let comment = config.rule_comment();
313 let multiport = config.ports.contains(',');
314 let port_args: Vec<&str> = if multiport {
315 vec!["-m", "multiport", "--dports", &config.ports]
316 } else {
317 vec!["--dport", &config.ports]
318 };
319 let proto = config.proto.to_lowercase();
320
321 if config.lan_cidr.is_none() {
325 let mut pre_args = vec![
326 "-t",
327 "nat",
328 "-A",
329 "PREROUTING",
330 "-s",
331 &config.int_ip,
332 "-d",
333 &config.ext_ip,
334 "-p",
335 proto,
336 ];
337 pre_args.extend(port_args.clone());
338 pre_args.extend(vec!["-j", "DNAT", "--to-destination", &config.int_ip]);
339 pre_args.extend(vec!["-m", "comment", "--comment", &comment]);
340 self.run_success("iptables", &pre_args)?;
341 }
342
343 let src = config.lan_cidr.as_deref().unwrap_or("0.0.0.0/0");
344 let mut post_args = vec![
345 "-t",
346 "nat",
347 "-A",
348 "POSTROUTING",
349 "-s",
350 src,
351 "-d",
352 &config.int_ip,
353 "-p",
354 proto,
355 ];
356 post_args.extend(port_args);
357 post_args.extend(vec!["-j", "MASQUERADE"]);
358 post_args.extend(vec!["-m", "comment", "--comment", &comment]);
359 self.run_success("iptables", &post_args)?;
360 Ok(())
361 }
362
363 pub fn remove_hairpin(&self, config: &HairpinConfig) -> Result<()> {
367 let comment = config.rule_comment();
368 self.delete_all_matching("iptables", "nat", "PREROUTING", &comment)?;
369 self.delete_all_matching("iptables", "nat", "POSTROUTING", &comment)?;
370 Ok(())
371 }
372
373 fn delete_all_natmap(&self, cmd: &str, table: &str, chain: &str) -> Result<()> {
375 loop {
376 let rules = self.get_rules(cmd, table, chain)?;
377 let mut deleted = false;
378 for (line_num, rule) in rules.iter().enumerate() {
379 if rule.contains("--comment \"natmap:") || rule.contains("--comment natmap:") {
380 let num = (line_num + 1).to_string();
381 self.run(cmd, ["-t", table, "-D", chain, &num])?;
382 deleted = true;
383 break;
384 }
385 }
386 if !deleted {
387 break;
388 }
389 }
390 Ok(())
391 }
392
393 pub fn remove_mapping(&self, map: &DockerPortMap) -> Result<()> {
395 tracing::debug!(mapping = ?map, "removing mapping");
396 self.remove_by_comment(&map.rule_comment, map.request.is_ipv6())?;
397 Ok(())
398 }
399
400 fn remove_by_comment(&self, comment: &str, is_ipv6: bool) -> Result<()> {
402 let cmd = self.cmd_for(is_ipv6);
403
404 self.delete_all_matching(cmd, "nat", NATMAP, comment)?;
406 self.delete_all_matching(cmd, "filter", NATMAP, comment)?;
408 self.delete_all_matching(cmd, "nat", "POSTROUTING", comment)?;
410 self.delete_all_matching(cmd, "nat", "OUTPUT", comment)?;
412
413 Ok(())
414 }
415
416 fn flush_chain(&self, cmd: &str, table: &str, chain: &str) -> Result<()> {
418 let _ = self.run(cmd, ["-t", table, "-F", chain]);
420 let _ = self.run(cmd, ["-t", table, "-X", chain]);
422 Ok(())
423 }
424
425 fn cmd_for(&self, is_ipv6: bool) -> &'static str {
429 if is_ipv6 { "ip6tables" } else { "iptables" }
430 }
431
432 fn run_success(
434 &self,
435 program: impl AsRef<OsStr>,
436 args: impl IntoIterator<Item = impl AsRef<OsStr>>,
437 ) -> Result<std::process::Output> {
438 let args: Vec<_> = args.into_iter().collect();
439 let out = self.run(&program, &args)?;
440 if out.status.success() {
441 Ok(out)
442 } else {
443 let err = String::from_utf8_lossy(&out.stderr);
444 let args_str = args
445 .iter()
446 .map(|a| a.as_ref().to_string_lossy())
447 .collect::<Vec<_>>()
448 .join(" ");
449 let program = program.as_ref().to_string_lossy();
450 tracing::error!(program = %program, args = %args_str, error = %err, "command failed");
451 bail!("{program} failed: {err}");
452 }
453 }
454
455 fn run(
457 &self,
458 program: impl AsRef<OsStr>,
459 args: impl IntoIterator<Item = impl AsRef<OsStr>>,
460 ) -> Result<std::process::Output> {
461 let args_vec: Vec<_> = args.into_iter().map(|a| a.as_ref().to_owned()).collect();
462 let args_str = args_vec
463 .iter()
464 .map(|a| a.to_string_lossy())
465 .collect::<Vec<_>>()
466 .join(" ");
467 tracing::trace!(command = %program.as_ref().to_string_lossy(), args = %args_str, "raw iptables command");
468 Ok(Command::new(program.as_ref()).args(&args_vec).output()?)
469 }
470
471 fn chain_exists(&self, cmd: &str, table: &str, chain: &str) -> bool {
473 self.run(cmd, ["-t", table, "-L", chain, "-n"])
474 .map(|o| o.status.success())
475 .unwrap_or(false)
476 }
477
478 fn rule_exists(&self, cmd: &str, args: &[&str]) -> bool {
480 self.run(cmd, args)
482 .map(|o| o.status.success())
483 .unwrap_or(false)
484 }
485
486 fn delete_all_matching(
488 &self,
489 cmd: &str,
490 table: &str,
491 chain: &str,
492 comment: &str,
493 ) -> Result<()> {
494 loop {
496 let rules = self.get_rules(cmd, table, chain)?;
497 let mut deleted = false;
498 for (line_num, rule) in rules.iter().enumerate() {
499 if rule.contains(&format!("--comment \"{comment}\""))
500 || rule.contains(&format!("--comment {comment}"))
501 {
502 let num = (line_num + 1).to_string();
504 self.run_success(cmd, ["-t", table, "-D", chain, &num])?;
505 deleted = true;
506 break; }
508 }
509 if !deleted {
510 break;
511 }
512 }
513 Ok(())
514 }
515
516 fn get_rules(&self, cmd: &str, table: &str, chain: &str) -> Result<Vec<String>> {
518 let out = self.run(cmd, ["-t", table, "-S", chain])?;
520
521 let rules = String::from_utf8_lossy(&out.stdout)
524 .lines()
525 .filter(|l| l.starts_with("-A ") || l.starts_with("-I "))
526 .map(|l| l.to_string())
527 .collect();
528
529 Ok(rules)
530 }
531}