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.
302    ///
303    /// When `config.lan_cidr` is set:
304    /// - Skips the PREROUTING DNAT (service node self-connections go through
305    ///   the regular DNAT rule instead).
306    /// - Uses `lan_cidr` as the MASQUERADE source match, limiting hairpin to
307    ///   LAN clients only (preserving source IP for WAN clients).
308    ///
309    /// When `lan_cidr` is `None`, creates the full hairpin (PREROUTING DNAT +
310    ///   POSTROUTING MASQUERADE with `-s 0.0.0.0/0`).
311    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        // When lan_cidr is set, skip the PREROUTING DNAT — the regular DNAT
322        // rule already handles the forward direction. We only need the
323        // LAN-limited MASQUERADE to fix hairpin for LAN clients.
324        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    /// Removes a hairpin NAT rule (PREROUTING DNAT + POSTROUTING MASQUERADE).
364    ///
365    /// Uses the rule comment to find and delete matching rules.
366    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    /// Deletes all rules in a chain whose comment starts with "natmap:".
374    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    /// Removes all iptables rules associated with a Docker mapping by its rule comment.
394    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    /// Deletes rules matching the comment string across all relevant tables and chains.
401    fn remove_by_comment(&self, comment: &str, is_ipv6: bool) -> Result<()> {
402        let cmd = self.cmd_for(is_ipv6);
403
404        // Delete from NATMAP in nat table
405        self.delete_all_matching(cmd, "nat", NATMAP, comment)?;
406        // Delete from NATMAP in filter table
407        self.delete_all_matching(cmd, "filter", NATMAP, comment)?;
408        // Delete from POSTROUTING in nat table
409        self.delete_all_matching(cmd, "nat", "POSTROUTING", comment)?;
410        // Delete from OUTPUT in nat table (localhost DNAT)
411        self.delete_all_matching(cmd, "nat", "OUTPUT", comment)?;
412
413        Ok(())
414    }
415
416    /// Flushes and deletes a specific chain in a given table.
417    fn flush_chain(&self, cmd: &str, table: &str, chain: &str) -> Result<()> {
418        // flush chain
419        let _ = self.run(cmd, ["-t", table, "-F", chain]);
420        // delete chain
421        let _ = self.run(cmd, ["-t", table, "-X", chain]);
422        Ok(())
423    }
424
425    // --- Helper functions ---
426
427    /// Returns `"ip6tables"` or `"iptables"` based on address family.
428    fn cmd_for(&self, is_ipv6: bool) -> &'static str {
429        if is_ipv6 { "ip6tables" } else { "iptables" }
430    }
431
432    /// Runs a command. Fails and logs an error if the command returned a non-zero exit status.
433    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    /// Runs a command.
456    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    /// Checks whether a chain exists in the given table.
472    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    /// Checks whether a specific iptables rule already exists.
479    fn rule_exists(&self, cmd: &str, args: &[&str]) -> bool {
480        // cmd_success logs on fail
481        self.run(cmd, args)
482            .map(|o| o.status.success())
483            .unwrap_or(false)
484    }
485
486    /// Deletes all rules in a chain whose comment matches the given string.
487    fn delete_all_matching(
488        &self,
489        cmd: &str,
490        table: &str,
491        chain: &str,
492        comment: &str,
493    ) -> Result<()> {
494        // Rules and delete by line numbers.
495        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                    // Delete by line number from bottom up (or just one by one)
503                    let num = (line_num + 1).to_string();
504                    self.run_success(cmd, ["-t", table, "-D", chain, &num])?;
505                    deleted = true;
506                    break; // Start over since line numbers changed
507                }
508            }
509            if !deleted {
510                break;
511            }
512        }
513        Ok(())
514    }
515
516    /// Returns the list of active rules in a chain (lines starting with `-A` or `-I`).
517    fn get_rules(&self, cmd: &str, table: &str, chain: &str) -> Result<Vec<String>> {
518        // -S -- short for --list-rules
519        let out = self.run(cmd, ["-t", table, "-S", chain])?;
520
521        // Get only -A (append) and -I (insert)
522        // Ignore others, such as chain declarations
523        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}