Skip to main content

lab_ops_natmap/
iptables.rs

1//! iptables rule management for DNAT, SNAT, hairpin, and Docker mappings.
2//!
3//! All rules are installed in the `NATMAP` chain (a sub-chain of `PREROUTING`
4//! in the `nat` table and `DOCKER-USER` in the `filter` table). This keeps
5//! natmap rules separate from Docker's own rules and ensures clean crash
6//! recovery via chain flush.
7
8use 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
21/// Manages the lifecycle of iptables rules used by natmap.
22///
23/// Creates the `NATMAP` chain in both the `nat` and `filter` tables,
24/// inserts jumps from `PREROUTING` and `DOCKER-USER`, and provides
25/// methods to install/remove individual rules.
26pub struct IptablesManager;
27
28impl Default for IptablesManager {
29    fn default() -> Self {
30        Self::new()
31    }
32}
33
34impl IptablesManager {
35    /// Creates a new [`IptablesManager`].
36    pub fn new() -> Self {
37        Self
38    }
39
40    /// Creates the `NATMAP` chains and inserts jump rules.
41    ///
42    /// Operates on both `iptables` (IPv4) and `ip6tables` (IPv6).
43    /// This method is idempotent.
44    pub fn setup(&self) -> Result<()> {
45        tracing::info!("setting up iptables chains and jumps");
46
47        for &cmd in &["iptables", "ip6tables"] {
48            // Verify DOCKER-USER exists (it should, Docker makes it). Create if missing.
49            if !self.chain_exists(cmd, "filter", "DOCKER-USER") {
50                // create new DOCKER-USER chain
51                self.run_success(cmd, ["-t", "filter", "-N", "DOCKER-USER"])?;
52                // insert a jump rule on first position of FORWARD chain to DOCKER-USER
53                self.run_success(cmd, ["-t", "filter", "-I", "FORWARD", "-j", "DOCKER-USER"])?;
54            }
55
56            // Create NATMAP subchain in nat table (DNAT rules live here)
57            if !self.chain_exists(cmd, "nat", NATMAP) {
58                self.run_success(cmd, ["-t", "nat", "-N", NATMAP])?;
59            }
60
61            // Create NATMAP subchain in filter table (FORWARD ACCEPT rules live here)
62            if !self.chain_exists(cmd, "filter", NATMAP) {
63                self.run_success(cmd, ["-t", "filter", "-N", NATMAP])?;
64            }
65
66            // Jump from DOCKER-USER to NATMAP in filter table (if not exists)
67            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            // Jump from PREROUTING to NATMAP in nat table (if not exists)
72            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    /// Installs DNAT, FORWARD ACCEPT, MASQUERADE, and OUTPUT DNAT rules for a Docker mapping.
81    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        // 1. DNAT rule via nat NATMAP
95        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        // 2. FORWARD ACCEPT rule in filter NATMAP
118        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        // 3. Masquerade (hairpin NAT)
141        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        // 4. OUTPUT DNAT rule — always needed for locally-generated traffic.
166        //    PREROUTING only catches forwarded/ingress traffic; locally-generated
167        //    packets (curl localhost, curl <host-ip>) go through OUTPUT.
168        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    /// Flushes and deletes the `NATMAP` chains and removes all natmap-commented
205    /// rules from `POSTROUTING`, `OUTPUT`, `PREROUTING`, and `FORWARD` in both
206    /// `iptables` (IPv4) and `ip6tables` (IPv6).
207    ///
208    /// Used during crash recovery and clean shutdown to reset all natmap-managed rules.
209    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    /// Installs a static DNAT rule (PREROUTING + FORWARD ACCEPT).
224    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    /// Removes a static DNAT rule (PREROUTING + FORWARD ACCEPT).
258    ///
259    /// Uses the rule comment to find and delete matching rules.
260    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    /// Installs a static SNAT (source NAT) rule in the POSTROUTING chain.
268    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    /// Removes a static SNAT rule from the POSTROUTING chain.
293    ///
294    /// Uses the rule comment to find and delete matching rules.
295    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    /// Installs a hairpin NAT rule (PREROUTING DNAT + POSTROUTING MASQUERADE).
302    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    /// Removes a hairpin NAT rule (PREROUTING DNAT + POSTROUTING MASQUERADE).
349    ///
350    /// Uses the rule comment to find and delete matching rules.
351    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    /// Deletes all rules in a chain whose comment starts with "natmap:".
359    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    /// Removes all iptables rules associated with a Docker mapping by its rule comment.
379    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    /// Deletes rules matching the comment string across all relevant tables and chains.
386    fn remove_by_comment(&self, comment: &str, is_ipv6: bool) -> Result<()> {
387        let cmd = self.cmd_for(is_ipv6);
388
389        // Delete from NATMAP in nat table
390        self.delete_all_matching(cmd, "nat", NATMAP, comment)?;
391        // Delete from NATMAP in filter table
392        self.delete_all_matching(cmd, "filter", NATMAP, comment)?;
393        // Delete from POSTROUTING in nat table
394        self.delete_all_matching(cmd, "nat", "POSTROUTING", comment)?;
395        // Delete from OUTPUT in nat table (localhost DNAT)
396        self.delete_all_matching(cmd, "nat", "OUTPUT", comment)?;
397
398        Ok(())
399    }
400
401    /// Flushes and deletes a specific chain in a given table.
402    fn flush_chain(&self, cmd: &str, table: &str, chain: &str) -> Result<()> {
403        // flush chain
404        let _ = self.run(cmd, ["-t", table, "-F", chain]);
405        // delete chain
406        let _ = self.run(cmd, ["-t", table, "-X", chain]);
407        Ok(())
408    }
409
410    // --- Helper functions ---
411
412    /// Returns `"ip6tables"` or `"iptables"` based on address family.
413    fn cmd_for(&self, is_ipv6: bool) -> &'static str {
414        if is_ipv6 { "ip6tables" } else { "iptables" }
415    }
416
417    /// Runs a command. Fails and logs an error if the command returned a non-zero exit status.
418    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    /// Runs a command.
441    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    /// Checks whether a chain exists in the given table.
457    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    /// Checks whether a specific iptables rule already exists.
464    fn rule_exists(&self, cmd: &str, args: &[&str]) -> bool {
465        // cmd_success logs on fail
466        self.run(cmd, args)
467            .map(|o| o.status.success())
468            .unwrap_or(false)
469    }
470
471    /// Deletes all rules in a chain whose comment matches the given string.
472    fn delete_all_matching(
473        &self,
474        cmd: &str,
475        table: &str,
476        chain: &str,
477        comment: &str,
478    ) -> Result<()> {
479        // Rules and delete by line numbers.
480        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                    // Delete by line number from bottom up (or just one by one)
488                    let num = (line_num + 1).to_string();
489                    self.run_success(cmd, ["-t", table, "-D", chain, &num])?;
490                    deleted = true;
491                    break; // Start over since line numbers changed
492                }
493            }
494            if !deleted {
495                break;
496            }
497        }
498        Ok(())
499    }
500
501    /// Returns the list of active rules in a chain (lines starting with `-A` or `-I`).
502    fn get_rules(&self, cmd: &str, table: &str, chain: &str) -> Result<Vec<String>> {
503        // -S -- short for --list-rules
504        let out = self.run(cmd, ["-t", table, "-S", chain])?;
505
506        // Get only -A (append) and -I (insert)
507        // Ignore others, such as chain declarations
508        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}