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<()> {
303 let comment = config.rule_comment();
304 let multiport = config.ports.contains(',');
305 let port_args: Vec<&str> = if multiport {
306 vec!["-m", "multiport", "--dports", &config.ports]
307 } else {
308 vec!["--dport", &config.ports]
309 };
310 let proto = config.proto.to_lowercase();
311
312 let mut pre_args = vec![
313 "-t",
314 "nat",
315 "-A",
316 "PREROUTING",
317 "-s",
318 &config.int_ip,
319 "-d",
320 &config.ext_ip,
321 "-p",
322 proto,
323 ];
324 pre_args.extend(port_args.clone());
325 pre_args.extend(vec!["-j", "DNAT", "--to-destination", &config.int_ip]);
326 pre_args.extend(vec!["-m", "comment", "--comment", &comment]);
327 self.run_success("iptables", &pre_args)?;
328
329 let mut post_args = vec![
330 "-t",
331 "nat",
332 "-A",
333 "POSTROUTING",
334 "-s",
335 "0.0.0.0/0",
336 "-d",
337 &config.int_ip,
338 "-p",
339 proto,
340 ];
341 post_args.extend(port_args);
342 post_args.extend(vec!["-j", "MASQUERADE"]);
343 post_args.extend(vec!["-m", "comment", "--comment", &comment]);
344 self.run_success("iptables", &post_args)?;
345 Ok(())
346 }
347
348 pub fn remove_hairpin(&self, config: &HairpinConfig) -> Result<()> {
352 let comment = config.rule_comment();
353 self.delete_all_matching("iptables", "nat", "PREROUTING", &comment)?;
354 self.delete_all_matching("iptables", "nat", "POSTROUTING", &comment)?;
355 Ok(())
356 }
357
358 fn delete_all_natmap(&self, cmd: &str, table: &str, chain: &str) -> Result<()> {
360 loop {
361 let rules = self.get_rules(cmd, table, chain)?;
362 let mut deleted = false;
363 for (line_num, rule) in rules.iter().enumerate() {
364 if rule.contains("--comment \"natmap:") || rule.contains("--comment natmap:") {
365 let num = (line_num + 1).to_string();
366 self.run(cmd, ["-t", table, "-D", chain, &num])?;
367 deleted = true;
368 break;
369 }
370 }
371 if !deleted {
372 break;
373 }
374 }
375 Ok(())
376 }
377
378 pub fn remove_mapping(&self, map: &DockerPortMap) -> Result<()> {
380 tracing::debug!(mapping = ?map, "removing mapping");
381 self.remove_by_comment(&map.rule_comment, map.request.is_ipv6())?;
382 Ok(())
383 }
384
385 fn remove_by_comment(&self, comment: &str, is_ipv6: bool) -> Result<()> {
387 let cmd = self.cmd_for(is_ipv6);
388
389 self.delete_all_matching(cmd, "nat", NATMAP, comment)?;
391 self.delete_all_matching(cmd, "filter", NATMAP, comment)?;
393 self.delete_all_matching(cmd, "nat", "POSTROUTING", comment)?;
395 self.delete_all_matching(cmd, "nat", "OUTPUT", comment)?;
397
398 Ok(())
399 }
400
401 fn flush_chain(&self, cmd: &str, table: &str, chain: &str) -> Result<()> {
403 let _ = self.run(cmd, ["-t", table, "-F", chain]);
405 let _ = self.run(cmd, ["-t", table, "-X", chain]);
407 Ok(())
408 }
409
410 fn cmd_for(&self, is_ipv6: bool) -> &'static str {
414 if is_ipv6 { "ip6tables" } else { "iptables" }
415 }
416
417 fn run_success(
419 &self,
420 program: impl AsRef<OsStr>,
421 args: impl IntoIterator<Item = impl AsRef<OsStr>>,
422 ) -> Result<std::process::Output> {
423 let args: Vec<_> = args.into_iter().collect();
424 let out = self.run(&program, &args)?;
425 if out.status.success() {
426 Ok(out)
427 } else {
428 let err = String::from_utf8_lossy(&out.stderr);
429 let args_str = args
430 .iter()
431 .map(|a| a.as_ref().to_string_lossy())
432 .collect::<Vec<_>>()
433 .join(" ");
434 let program = program.as_ref().to_string_lossy();
435 tracing::error!(program = %program, args = %args_str, error = %err, "command failed");
436 bail!("{program} failed: {err}");
437 }
438 }
439
440 fn run(
442 &self,
443 program: impl AsRef<OsStr>,
444 args: impl IntoIterator<Item = impl AsRef<OsStr>>,
445 ) -> Result<std::process::Output> {
446 let args_vec: Vec<_> = args.into_iter().map(|a| a.as_ref().to_owned()).collect();
447 let args_str = args_vec
448 .iter()
449 .map(|a| a.to_string_lossy())
450 .collect::<Vec<_>>()
451 .join(" ");
452 tracing::trace!(command = %program.as_ref().to_string_lossy(), args = %args_str, "raw iptables command");
453 Ok(Command::new(program.as_ref()).args(&args_vec).output()?)
454 }
455
456 fn chain_exists(&self, cmd: &str, table: &str, chain: &str) -> bool {
458 self.run(cmd, ["-t", table, "-L", chain, "-n"])
459 .map(|o| o.status.success())
460 .unwrap_or(false)
461 }
462
463 fn rule_exists(&self, cmd: &str, args: &[&str]) -> bool {
465 self.run(cmd, args)
467 .map(|o| o.status.success())
468 .unwrap_or(false)
469 }
470
471 fn delete_all_matching(
473 &self,
474 cmd: &str,
475 table: &str,
476 chain: &str,
477 comment: &str,
478 ) -> Result<()> {
479 loop {
481 let rules = self.get_rules(cmd, table, chain)?;
482 let mut deleted = false;
483 for (line_num, rule) in rules.iter().enumerate() {
484 if rule.contains(&format!("--comment \"{comment}\""))
485 || rule.contains(&format!("--comment {comment}"))
486 {
487 let num = (line_num + 1).to_string();
489 self.run_success(cmd, ["-t", table, "-D", chain, &num])?;
490 deleted = true;
491 break; }
493 }
494 if !deleted {
495 break;
496 }
497 }
498 Ok(())
499 }
500
501 fn get_rules(&self, cmd: &str, table: &str, chain: &str) -> Result<Vec<String>> {
503 let out = self.run(cmd, ["-t", table, "-S", chain])?;
505
506 let rules = String::from_utf8_lossy(&out.stdout)
509 .lines()
510 .filter(|l| l.starts_with("-A ") || l.starts_with("-I "))
511 .map(|l| l.to_string())
512 .collect();
513
514 Ok(rules)
515 }
516}