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        /// Whether to delete the rule instead of adding it.
107        #[arg(long)]
108        delete: bool,
109    },
110    /// Adds or deletes a policy routing rule to send return traffic via a gateway.
111    #[command(name = "policy-route")]
112    PolicyRoute {
113        /// Source IP of this host (packets FROM this IP will use the policy route)
114        #[arg(long)]
115        src_ip: String,
116        /// Gateway IP to route return traffic through
117        #[arg(long)]
118        via: String,
119        /// Routing table ID (default: 100)
120        #[arg(long, default_value = "100")]
121        table: u32,
122        /// Whether to delete the rule instead of adding it
123        #[arg(long)]
124        delete: bool,
125    },
126    /// Lists all NAT rules (static iptables + Docker mappings).
127    #[command(name = "ls")]
128    List {
129        /// Optional container ID or name to filter Docker mappings.
130        #[arg(
131            value_name = "CONTAINER_ID",
132            add = ArgValueCompleter::new(crate::completions::complete_container_id)
133        )]
134        container_id: Option<String>,
135    },
136    /// Removes all managed NAT rules and resets daemon state.
137    #[command(name = "clear")]
138    Clear,
139    /// Manages Docker container port mappings.
140    #[command(name = "docker")]
141    Docker {
142        #[command(subcommand)]
143        cmd: DockerCommand,
144    },
145    /// Saves current iptables rules to `/etc/iptables/rules.v4`.
146    #[command(name = "save")]
147    Save,
148    /// Enables IP forwarding via `sysctl -w net.ipv4.ip_forward=1`.
149    #[command(name = "fwd")]
150    Fwd,
151    /// Runs the natmap daemon.
152    #[command(name = "daemon")]
153    Daemon {
154        /// Path to the state JSON file.
155        #[arg(long, default_value = STATE)]
156        state: PathBuf,
157        /// Path for the Unix socket.
158        #[arg(long, default_value = DAEMON_SOCK)]
159        socket: PathBuf,
160        /// Unix group for socket access.
161        #[arg(long, default_value = PKG_NAME)]
162        socket_group: String,
163    },
164    /// Installs the natmap daemon as a systemd service.
165    #[command(name = "install")]
166    Install {
167        /// Unix group for daemon access.
168        #[arg(long, default_value = PKG_NAME)]
169        group: String,
170        /// Path to the binary to install.
171        #[arg(long, default_value = lab_ops_lab_lib::consts::LABOPS_BIN)]
172        binary: String,
173    },
174}
175
176/// Docker-specific subcommands for port mapping management.
177#[derive(Subcommand, Debug)]
178pub enum DockerCommand {
179    /// Adds a new port mapping to a running container.
180    #[command(name = "add")]
181    Add {
182        /// Container ID.
183        #[arg(
184            value_name = "CONTAINER_ID",
185            add = ArgValueCompleter::new(crate::completions::complete_container_id)
186        )]
187        container_id: String,
188        /// Port mapping in the form `[HOST_IP:]HOST_PORT[:[TARGET_IP:]TARGET_PORT][/PROTO]`.
189        /// Optional when --name is used (CONTAINER_ID becomes the mapping).
190        #[arg(value_name = "MAPPING")]
191        mapping: Option<String>,
192        /// Container name (alternative to CONTAINER_ID). When set, CONTAINER_ID is treated as the mapping.
193        #[arg(
194            long,
195            add = ArgValueCompleter::new(crate::completions::complete_container_id)
196        )]
197        name: Option<String>,
198    },
199    /// Removes one or more Docker port mappings.
200    #[command(name = "rm")]
201    Remove {
202        /// Container ID or name (required unless `--id` or `--name` is used).
203        #[arg(
204            value_name = "CONTAINER_ID",
205            add = ArgValueCompleter::new(crate::completions::complete_container_id)
206        )]
207        container_id: Option<String>,
208        /// Port and optional protocol (e.g., `8080/tcp`).
209        #[arg(value_name = "PORT[/PROTO]")]
210        port: Option<String>,
211        /// Removes all mappings for the specified container.
212        #[arg(long)]
213        all: bool,
214        /// Removes a mapping by its numeric ID.
215        #[arg(long)]
216        id: Option<u64>,
217        /// Container name (alternative to CONTAINER_ID).
218        #[arg(
219            long,
220            add = ArgValueCompleter::new(crate::completions::complete_container_id)
221        )]
222        name: Option<String>,
223    },
224    /// Remaps a host port for a running container without restarting it.
225    #[command(name = "remap")]
226    Remap {
227        /// Container ID or name.
228        #[arg(
229            value_name = "CONTAINER_ID",
230            add = ArgValueCompleter::new(crate::completions::complete_container_id)
231        )]
232        container_id: String,
233        /// Port mapping in the form `OLD_PORT:NEW_PORT`.
234        #[arg(value_name = "OLD_PORT:NEW_PORT")]
235        mapping: String,
236    },
237}
238
239/// Dispatches a parsed [`Cli`] to the appropriate daemon API call.
240pub async fn run_cli(cli: Cli, use_color: bool) -> Result<()> {
241    let socket = cli.socket;
242    let json = cli.json;
243
244    match cli.command {
245        NatMapCommand::Dnat {
246            ext_ip,
247            int_ip,
248            proto,
249            ports,
250            ext_if,
251            delete,
252            no_masquerade,
253        } => {
254            handle_dnat(
255                ext_ip,
256                int_ip,
257                proto,
258                ports,
259                ext_if,
260                delete,
261                no_masquerade,
262                &socket,
263            )
264            .await?;
265        }
266        NatMapCommand::Snat {
267            int_ip,
268            ext_if,
269            ext_ip,
270            delete,
271        } => {
272            handle_snat(int_ip, ext_if, ext_ip, delete, &socket).await?;
273        }
274        NatMapCommand::Hairpin {
275            ext_ip,
276            int_ip,
277            proto,
278            ports,
279            delete,
280        } => {
281            handle_hairpin(ext_ip, int_ip, proto, ports, delete, &socket).await?;
282        }
283        NatMapCommand::PolicyRoute {
284            src_ip,
285            via,
286            table,
287            delete,
288        } => {
289            handle_policy_route(src_ip, via, table, delete, &socket).await?;
290        }
291        NatMapCommand::List { container_id } => {
292            handle_list(&socket, container_id, json, use_color).await?;
293        }
294        NatMapCommand::Clear => {
295            handle_clear(&socket).await?;
296        }
297        NatMapCommand::Docker { cmd } => match cmd {
298            DockerCommand::Add {
299                container_id,
300                mapping,
301                name,
302            } => {
303                add(container_id, mapping, name, &socket, json).await?;
304            }
305            DockerCommand::Remove {
306                container_id,
307                port,
308                all,
309                id,
310                name,
311            } => {
312                remove(container_id, port, all, id, name, &socket, json).await?;
313            }
314            DockerCommand::Remap {
315                container_id,
316                mapping,
317            } => {
318                remap(container_id, mapping, &socket, json).await?;
319            }
320        },
321        NatMapCommand::Save => {
322            Command::new("sh")
323                .arg("-c")
324                .arg("iptables-save > /etc/iptables/rules.v4")
325                .status()?;
326        }
327        NatMapCommand::Fwd => {
328            let status = Command::new("sysctl")
329                .arg("-w")
330                .arg("net.ipv4.ip_forward=1")
331                .status()?;
332            if !status.success() {
333                color_eyre::eyre::bail!("Failed to enable IP forwarding");
334            }
335        }
336        NatMapCommand::Daemon {
337            state,
338            socket,
339            socket_group,
340        } => {
341            Daemon::new(socket, state, socket_group)
342                .await?
343                .run()
344                .await?;
345        }
346        NatMapCommand::Install { binary, group } => {
347            crate::install::install_systemd(&binary, &group)?;
348        }
349    }
350    Ok(())
351}