Skip to main content

lab_ops_natmap/
policy_route.rs

1use std::process::Command;
2
3use color_eyre::Result;
4use color_eyre::eyre::WrapErr;
5
6use crate::models::PolicyRouteConfig;
7
8pub struct PolicyRouteManager;
9
10impl PolicyRouteManager {
11    pub fn new() -> Self {
12        Self
13    }
14
15    fn check_rule_exists(&self, config: &PolicyRouteConfig) -> Result<bool> {
16        let output = Command::new("ip")
17            .args(["rule", "show"])
18            .output()
19            .wrap_err("Failed to execute ip rule show")?;
20
21        let stdout = String::from_utf8_lossy(&output.stdout);
22        // Format: "32765:  from <src_ip> lookup <table>"
23        let expected = format!("from {} lookup {}", config.src_ip, config.table);
24        Ok(stdout.contains(&expected))
25    }
26
27    fn check_route_exists(&self, config: &PolicyRouteConfig) -> Result<bool> {
28        let output = Command::new("ip")
29            .args(["route", "show", "table", &config.table.to_string()])
30            .output()
31            .wrap_err("Failed to execute ip route show")?;
32
33        let stdout = String::from_utf8_lossy(&output.stdout);
34        let expected = format!("default via {}", config.via);
35        Ok(stdout.contains(&expected))
36    }
37
38    /// Check if an exact route line already exists in a given table.
39    fn route_line_in_table(&self, route_line: &str, table: u32) -> Result<bool> {
40        let output = Command::new("ip")
41            .args(["route", "show", "table", &table.to_string()])
42            .output()?;
43        let stdout = String::from_utf8_lossy(&output.stdout);
44        Ok(stdout.lines().any(|l| l.trim() == route_line.trim()))
45    }
46
47    /// Collect routes from the main table that should be cloned into the
48    /// policy routing table: non-default, non-local, non-broadcast routes
49    /// for local subnets (Docker bridges, LAN, etc.).
50    fn get_cloneable_routes(&self) -> Result<Vec<String>> {
51        let output = Command::new("ip")
52            .args(["route", "show", "table", "main"])
53            .output()?;
54        let routes = String::from_utf8_lossy(&output.stdout)
55            .lines()
56            .filter(|l| {
57                let t = l.trim();
58                !t.is_empty()
59                    && !t.starts_with("default ")
60                    && !t.starts_with("broadcast ")
61                    && !t.starts_with("local ")
62                    && !t.starts_with("unreachable ")
63                    && !t.starts_with("fe80::")
64                    && !t.starts_with("ff00::")
65            })
66            .map(|l| l.trim().to_string())
67            .filter(|l| !l.is_empty())
68            .collect();
69        Ok(routes)
70    }
71
72    pub fn install(&self, config: &PolicyRouteConfig) -> Result<()> {
73        if !self.check_route_exists(config)? {
74            let status = Command::new("ip")
75                .args([
76                    "route",
77                    "add",
78                    "default",
79                    "via",
80                    &config.via,
81                    "table",
82                    &config.table.to_string(),
83                ])
84                .status()
85                .wrap_err("Failed to execute ip route add")?;
86
87            if !status.success() {
88                color_eyre::eyre::bail!("ip route add failed with status: {}", status);
89            }
90        }
91
92        if !self.check_rule_exists(config)? {
93            let status = Command::new("ip")
94                .args([
95                    "rule",
96                    "add",
97                    "from",
98                    &config.src_ip,
99                    "table",
100                    &config.table.to_string(),
101                ])
102                .status()
103                .wrap_err("Failed to execute ip rule add")?;
104
105            if !status.success() {
106                color_eyre::eyre::bail!("ip rule add failed with status: {}", status);
107            }
108        }
109
110        // Clone local-subnet routes from the main table so traffic from
111        // src_ip to Docker bridges, the LAN, etc. uses the correct interface
112        // instead of the proxy gateway (which would break local connectivity).
113        let table = config.table;
114        for route_line in self.get_cloneable_routes()? {
115            if !self.route_line_in_table(&route_line, table)? {
116                let status = Command::new("sh")
117                    .args(["-c", &format!("ip route add {route_line} table {table}")])
118                    .status()
119                    .wrap_err("Failed to execute ip route add")?;
120                if !status.success() {
121                    tracing::warn!("failed to clone route to table {table}: {route_line}");
122                }
123            }
124        }
125
126        Ok(())
127    }
128
129    pub fn remove(&self, config: &PolicyRouteConfig) -> Result<()> {
130        // We ignore errors on remove, in case the rules are already gone.
131        let _ = Command::new("ip")
132            .args([
133                "rule",
134                "del",
135                "from",
136                &config.src_ip,
137                "table",
138                &config.table.to_string(),
139            ])
140            .status();
141
142        let _ = Command::new("ip")
143            .args([
144                "route",
145                "del",
146                "default",
147                "via",
148                &config.via,
149                "table",
150                &config.table.to_string(),
151            ])
152            .status();
153
154        Ok(())
155    }
156
157    pub fn flush_all(&self, policy_routes: &[PolicyRouteConfig]) -> Result<()> {
158        for config in policy_routes {
159            self.remove(config)?;
160        }
161        Ok(())
162    }
163}