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