Skip to main content

lab_ops_natmap/
cli.rs

1//! CLI argument parsing for the `natmap` subcommands.
2//!
3//! Defines the [`Cli`] struct and [`NatMapCommand`] / [`DockerCommand`] enums
4//! that are flattened into the top-level `lab-ops` CLI via clap.
5
6use std::path::PathBuf;
7use std::process::Command;
8
9use clap::Parser;
10use clap::Subcommand;
11use clap_complete::engine::ArgValueCompleter;
12use color_eyre::Result;
13
14use crate::command::add;
15use crate::command::handle_clear;
16use crate::command::handle_dnat;
17use crate::command::handle_hairpin;
18use crate::command::handle_list;
19use crate::command::handle_policy_route;
20use crate::command::handle_snat;
21use crate::command::remap;
22use crate::command::remove;
23use crate::consts::DAEMON_SOCK;
24use crate::consts::PKG_NAME;
25use crate::consts::STATE;
26use crate::daemon::Daemon;
27
28/// CLI arguments for the `natmap` subcommand.
29#[derive(Parser, Debug)]
30#[command(
31    name = PKG_NAME,
32    about = "Manage iptables NAT rules (static VMs & dynamic Docker)"
33)]
34pub struct Cli {
35    /// Path to the daemon's Unix socket.
36    #[arg(long, default_value = DAEMON_SOCK, global = true)]
37    pub socket: PathBuf,
38
39    /// Output results as JSON instead of formatted tables.
40    #[arg(long, global = true)]
41    pub json: bool,
42
43    #[command(subcommand)]
44    pub command: NatMapCommand,
45}
46
47/// Top-level natmap subcommands.
48#[derive(Subcommand, Debug)]
49pub enum NatMapCommand {
50    /// Adds or deletes DNAT port forwarding rules.
51    #[command(name = "dnat")]
52    Dnat {
53        /// External (public) IP address.
54        #[arg(long)]
55        ext_ip: String,
56        /// Internal (private) IP address.
57        #[arg(long)]
58        int_ip: String,
59        /// Transport protocol (`tcp` or `udp`).
60        #[arg(long, default_value = "tcp")]
61        proto: String,
62        /// Comma-separated list of ports or port ranges.
63        #[arg(long)]
64        ports: String,
65        /// External network interface (optional).
66        #[arg(long)]
67        ext_if: Option<String>,
68        /// Whether to delete the rule instead of adding it.
69        #[arg(long)]
70        delete: bool,
71        /// Don't masquerade
72        #[arg(long)]
73        no_masquerade: bool,
74    },
75    /// Adds or deletes SNAT (masquerade) rules.
76    #[command(name = "snat")]
77    Snat {
78        /// Internal source IP address.
79        #[arg(long)]
80        int_ip: String,
81        /// External network interface.
82        #[arg(long)]
83        ext_if: String,
84        /// External (source NAT) IP address.
85        #[arg(long)]
86        ext_ip: String,
87        /// Whether to delete the rule instead of adding it.
88        #[arg(long)]
89        delete: bool,
90    },
91    /// Adds or deletes hairpin NAT rules for internal-to-external access.
92    #[command(name = "hairpin")]
93    Hairpin {
94        /// External IP address.
95        #[arg(long)]
96        ext_ip: String,
97        /// Internal IP address.
98        #[arg(long)]
99        int_ip: String,
100        /// Transport protocol (`tcp` or `udp`).
101        #[arg(long, default_value = "tcp")]
102        proto: String,
103        /// Comma-separated list of ports.
104        #[arg(long)]
105        ports: String,
106        /// LAN source CIDR for MASQUERADE. When set, only traffic from this
107        /// subnet is MASQUERADEd (skips PREROUTING DNAT, uses LAN-limited
108        /// MASQUERADE). Used for preserve_src_ip hairpin.
109        #[arg(long)]
110        lan_cidr: Option<String>,
111        /// Whether to delete the rule instead of adding it.
112        #[arg(long)]
113        delete: bool,
114    },
115    /// Adds or deletes a policy routing rule to send return traffic via a gateway.
116    #[command(name = "policy-route")]
117    PolicyRoute {
118        /// Source IP of this host (packets FROM this IP will use the policy route)
119        #[arg(long)]
120        src_ip: String,
121        /// Gateway IP to route return traffic through
122        #[arg(long)]
123        via: String,
124        /// Routing table ID (default: 100)
125        #[arg(long, default_value = "100")]
126        table: u32,
127        /// Whether to delete the rule instead of adding it
128        #[arg(long)]
129        delete: bool,
130    },
131    /// Lists all NAT rules (static iptables + Docker mappings).
132    #[command(name = "ls")]
133    List {
134        /// Optional container ID or name to filter Docker mappings.
135        #[arg(
136            value_name = "CONTAINER_ID",
137            add = ArgValueCompleter::new(crate::completions::complete_container_id)
138        )]
139        container_id: Option<String>,
140    },
141    /// Removes all managed NAT rules and resets daemon state.
142    #[command(name = "clear")]
143    Clear,
144    /// Manages Docker container port mappings.
145    #[command(name = "docker")]
146    Docker {
147        #[command(subcommand)]
148        cmd: DockerCommand,
149    },
150    /// Saves current iptables rules to `/etc/iptables/rules.v4`.
151    #[command(name = "save")]
152    Save,
153    /// Enables IP forwarding via `sysctl -w net.ipv4.ip_forward=1`.
154    #[command(name = "fwd")]
155    Fwd,
156    /// Runs the natmap daemon.
157    #[command(name = "daemon")]
158    Daemon {
159        /// Path to the state JSON file.
160        #[arg(long, default_value = STATE)]
161        state: PathBuf,
162        /// Path for the Unix socket.
163        #[arg(long, default_value = DAEMON_SOCK)]
164        socket: PathBuf,
165        /// Unix group for socket access.
166        #[arg(long, default_value = PKG_NAME)]
167        socket_group: String,
168    },
169    /// Installs the natmap daemon as a systemd service.
170    #[command(name = "install")]
171    Install {
172        /// Unix group for daemon access.
173        #[arg(long, default_value = PKG_NAME)]
174        group: String,
175        /// Path to the binary to install.
176        #[arg(long, default_value = lab_ops_lab_lib::consts::LABOPS_BIN)]
177        binary: String,
178    },
179}
180
181/// Docker-specific subcommands for port mapping management.
182#[derive(Subcommand, Debug)]
183pub enum DockerCommand {
184    /// Adds a new port mapping to a running container.
185    #[command(name = "add")]
186    Add {
187        /// Container ID.
188        #[arg(
189            value_name = "CONTAINER_ID",
190            add = ArgValueCompleter::new(crate::completions::complete_container_id)
191        )]
192        container_id: String,
193        /// Port mapping in the form `[HOST_IP:]HOST_PORT[:[TARGET_IP:]TARGET_PORT][/PROTO]`.
194        /// Optional when --name is used (CONTAINER_ID becomes the mapping).
195        #[arg(value_name = "MAPPING")]
196        mapping: Option<String>,
197        /// Container name (alternative to CONTAINER_ID). When set, CONTAINER_ID is treated as the mapping.
198        #[arg(
199            long,
200            add = ArgValueCompleter::new(crate::completions::complete_container_id)
201        )]
202        name: Option<String>,
203    },
204    /// Removes one or more Docker port mappings.
205    #[command(name = "rm")]
206    Remove {
207        /// Container ID or name (required unless `--id` or `--name` is used).
208        #[arg(
209            value_name = "CONTAINER_ID",
210            add = ArgValueCompleter::new(crate::completions::complete_container_id)
211        )]
212        container_id: Option<String>,
213        /// Port and optional protocol (e.g., `8080/tcp`).
214        #[arg(value_name = "PORT[/PROTO]")]
215        port: Option<String>,
216        /// Removes all mappings for the specified container.
217        #[arg(long)]
218        all: bool,
219        /// Removes a mapping by its numeric ID.
220        #[arg(long)]
221        id: Option<u64>,
222        /// Container name (alternative to CONTAINER_ID).
223        #[arg(
224            long,
225            add = ArgValueCompleter::new(crate::completions::complete_container_id)
226        )]
227        name: Option<String>,
228    },
229    /// Remaps a host port for a running container without restarting it.
230    #[command(name = "remap")]
231    Remap {
232        /// Container ID or name.
233        #[arg(
234            value_name = "CONTAINER_ID",
235            add = ArgValueCompleter::new(crate::completions::complete_container_id)
236        )]
237        container_id: String,
238        /// Port mapping in the form `OLD_PORT:NEW_PORT`.
239        #[arg(value_name = "OLD_PORT:NEW_PORT")]
240        mapping: String,
241    },
242}
243
244/// Dispatches a parsed [`Cli`] to the appropriate daemon API call.
245pub async fn run_cli(cli: Cli, use_color: bool) -> Result<()> {
246    let socket = cli.socket;
247    let json = cli.json;
248
249    match cli.command {
250        NatMapCommand::Dnat {
251            ext_ip,
252            int_ip,
253            proto,
254            ports,
255            ext_if,
256            delete,
257            no_masquerade,
258        } => {
259            handle_dnat(
260                ext_ip,
261                int_ip,
262                proto,
263                ports,
264                ext_if,
265                delete,
266                no_masquerade,
267                &socket,
268            )
269            .await?;
270        }
271        NatMapCommand::Snat {
272            int_ip,
273            ext_if,
274            ext_ip,
275            delete,
276        } => {
277            handle_snat(int_ip, ext_if, ext_ip, delete, &socket).await?;
278        }
279        NatMapCommand::Hairpin {
280            ext_ip,
281            int_ip,
282            proto,
283            ports,
284            lan_cidr,
285            delete,
286        } => {
287            handle_hairpin(ext_ip, int_ip, proto, ports, lan_cidr, delete, &socket).await?;
288        }
289        NatMapCommand::PolicyRoute {
290            src_ip,
291            via,
292            table,
293            delete,
294        } => {
295            handle_policy_route(src_ip, via, table, delete, &socket).await?;
296        }
297        NatMapCommand::List { container_id } => {
298            handle_list(&socket, container_id, json, use_color).await?;
299        }
300        NatMapCommand::Clear => {
301            handle_clear(&socket).await?;
302        }
303        NatMapCommand::Docker { cmd } => match cmd {
304            DockerCommand::Add {
305                container_id,
306                mapping,
307                name,
308            } => {
309                add(container_id, mapping, name, &socket, json).await?;
310            }
311            DockerCommand::Remove {
312                container_id,
313                port,
314                all,
315                id,
316                name,
317            } => {
318                remove(container_id, port, all, id, name, &socket, json).await?;
319            }
320            DockerCommand::Remap {
321                container_id,
322                mapping,
323            } => {
324                remap(container_id, mapping, &socket, json).await?;
325            }
326        },
327        NatMapCommand::Save => {
328            Command::new("sh")
329                .arg("-c")
330                .arg("iptables-save > /etc/iptables/rules.v4")
331                .status()?;
332        }
333        NatMapCommand::Fwd => {
334            let status = Command::new("sysctl")
335                .arg("-w")
336                .arg("net.ipv4.ip_forward=1")
337                .status()?;
338            if !status.success() {
339                color_eyre::eyre::bail!("Failed to enable IP forwarding");
340            }
341        }
342        NatMapCommand::Daemon {
343            state,
344            socket,
345            socket_group,
346        } => {
347            Daemon::new(socket, state, socket_group)
348                .await?
349                .run()
350                .await?;
351        }
352        NatMapCommand::Install { binary, group } => {
353            crate::install::install_systemd(&binary, &group)?;
354        }
355    }
356    Ok(())
357}