Skip to main content

cloudpub_client/
commands.rs

1use anyhow::{bail, Context, Result};
2use clap::builder::TypedValueParser;
3use clap::{Args, Subcommand};
4use cloudpub_common::config::MaskedString;
5use cloudpub_common::protocol::{
6    Acl, Auth, ClientEndpoint, DefaultPort, FilterAction, FilterRule, Header, Protocol, Role,
7};
8use serde::{Deserialize, Serialize};
9use std::net::ToSocketAddrs;
10use std::str::FromStr;
11
12const ROLE_SEP: &str = ":";
13
14#[derive(Subcommand, Debug, Clone)]
15pub enum Commands {
16    #[clap(about = "Set value in the config")]
17    Set(SetArgs),
18    #[clap(about = "Get value from the config")]
19    Get(GetArgs),
20    #[clap(about = "Show all config options and their values")]
21    Options,
22    #[clap(about = "Run all registered services")]
23    Run {
24        #[clap(long, hide = true)]
25        run_as_service: bool,
26    },
27    #[clap(about = "Start publication")]
28    Start(GuidArgs),
29    #[clap(about = "Stop publication")]
30    Stop(GuidArgs),
31    #[clap(about = "Break current operation (used internally)", hide = true)]
32    Break,
33    #[clap(about = "Register service on server")]
34    Register(PublishArgs),
35    #[clap(about = "Register service and run it")]
36    Publish(PublishArgs),
37    #[clap(about = "Unregister service")]
38    Unpublish(GuidArgs),
39    #[clap(about = "List all registered services")]
40    Ls,
41    #[clap(about = "Clean all registered services")]
42    Clean,
43    #[clap(about = "Purge cache")]
44    Purge,
45    #[clap(about = "Ping server and measure roundtrip time")]
46    Ping(PingArg),
47    #[clap(about = "Login to server and save token")]
48    Login(LoginArgs),
49    #[clap(about = "Logout and clear saved token")]
50    Logout,
51    #[clap(about = "Upgrade client to the latest version")]
52    Upgrade,
53    #[clap(about = "Send a bug report with the client logs to support")]
54    Report(ReportArgs),
55    #[clap(about = "Manage system service")]
56    Service {
57        #[clap(subcommand)]
58        action: ServiceAction,
59    },
60}
61
62#[derive(Subcommand, Debug, Clone)]
63pub enum ServiceAction {
64    #[clap(about = "Install as a system service")]
65    Install {
66        #[clap(short, long, help = "Path to config file to use for the service")]
67        conf: Option<String>,
68    },
69
70    #[clap(about = "Uninstall the system service")]
71    Uninstall,
72
73    #[clap(about = "Start the service")]
74    Start,
75
76    #[clap(about = "Stop the service")]
77    Stop,
78
79    #[clap(about = "Get service status")]
80    Status,
81}
82
83#[derive(Args, Debug, Serialize, Deserialize, Clone)]
84pub struct ReportArgs {
85    #[clap(
86        short,
87        long,
88        help = "What went wrong (optional, sent to support with the logs)"
89    )]
90    pub message: Option<String>,
91}
92
93#[derive(Args, Debug, Serialize, Deserialize, Clone)]
94pub struct SetArgs {
95    pub key: String,
96    pub value: String,
97}
98
99#[derive(Args, Debug, Serialize, Deserialize, Clone)]
100pub struct PingArg {
101    #[clap(short = 'N', long = "num", help = "Number of parallel pings")]
102    pub num: Option<i32>,
103    #[clap(short = 'B', long = "bare", help = "Output time in µs")]
104    pub bare: bool,
105}
106
107#[derive(Args, Debug, Serialize, Deserialize, Clone)]
108pub struct GetArgs {
109    pub key: String,
110}
111
112#[derive(Args, Debug, Clone)]
113pub struct PublishArgs {
114    #[clap(help = "Protocol to use")]
115    pub protocol: Protocol,
116    #[clap(help = "URL, socket address, port or file path")]
117    pub address: String,
118    #[clap(short = 'U', long = "username", help = "Username")]
119    pub username: Option<String>,
120    #[clap(short = 'P', long = "password", help = "Password")]
121    pub password: Option<MaskedString>,
122    #[clap(short, long, help = "Optional name of the service to publish")]
123    pub name: Option<String>,
124    #[clap(short, long, help = "Authentification type")]
125    pub auth: Option<Auth>,
126    #[clap(short='A', long="acl", help = "Access list", value_parser = AclParser)]
127    pub acl: Vec<Acl>,
128    #[clap(short='H', long="header", help = "HTTP headers", value_parser = HeaderParser)]
129    pub headers: Vec<Header>,
130    #[clap(short='R', long="rule", help = "Filter rules", value_parser = RuleParser)]
131    pub rules: Vec<FilterRule>,
132}
133
134#[derive(Args, Debug, Serialize, Deserialize, Clone)]
135pub struct GuidArgs {
136    pub guid: String,
137}
138
139#[derive(Args, Debug, Serialize, Deserialize, Clone)]
140pub struct LoginArgs {
141    #[clap(
142        help = "Email address (if not provided, will prompt)",
143        required = false
144    )]
145    pub email: Option<String>,
146    #[clap(
147        help = "Password (if not provided, will prompt or use CLO_PASSWORD env var)",
148        env = "CLO_PASSWORD",
149        hide_env_values = true,
150        required = false
151    )]
152    pub password: Option<String>,
153}
154
155impl PublishArgs {
156    pub fn parse(&self) -> Result<ClientEndpoint> {
157        // A missing flag stays missing all the way to the server: re-registering an
158        // existing publication must keep the auth settings the user configured rather
159        // than reset them to the default - CLOUDPUB-1208. The default for a *new*
160        // publication is applied when it is created, in create_publication_sql.
161        let auth = self.auth.map(Into::into);
162        if self.address.contains("://") {
163            let url = url::Url::parse(&self.address).context(crate::t!("invalid-url"))?;
164            let local_proto =
165                Protocol::from_str(url.scheme()).context(crate::t!("invalid-protocol"))?;
166            let local_addr = url.host_str().unwrap().to_string();
167            let local_port = url
168                .port()
169                .or_else(|| self.protocol.default_port())
170                .context(crate::t!("port-required"))?;
171            let mut local_path = url.path().to_string();
172            if url.query().is_some() {
173                local_path.push('?');
174                local_path.push_str(url.query().unwrap());
175            }
176            let mut username = None;
177            if !url.username().is_empty() {
178                username = Some(url.username().to_string());
179            }
180            let password = url.password().map(|pass| MaskedString(pass.to_string()));
181            let mut filter_rules = self.rules.clone();
182            for (index, rule) in filter_rules.iter_mut().enumerate() {
183                rule.order = index as i32;
184            }
185
186            Ok(ClientEndpoint {
187                description: self.name.clone(),
188                local_proto: local_proto.into(),
189                local_addr,
190                local_port: local_port as u32,
191                local_path,
192                nodelay: Some(true),
193                auth,
194                acl: self.acl.clone(),
195                headers: self.headers.clone(),
196                filter_rules,
197                username,
198                password: password.map(|p| p.0),
199                // No CLI flag for it: say nothing so a re-registration keeps the
200                // configured value - CLOUDPUB-1241.
201                proxy_protocol: None,
202            })
203        } else {
204            let (local_addr, local_port, local_path) = match self.protocol {
205                Protocol::OneC | Protocol::Minecraft | Protocol::Webdav => {
206                    (self.address.clone(), 0, String::new())
207                }
208
209                Protocol::Http
210                | Protocol::Https
211                | Protocol::Tcp
212                | Protocol::Udp
213                | Protocol::Rtsp
214                | Protocol::Rdp
215                | Protocol::Vnc
216                | Protocol::Ssh => {
217                    if let Ok(port) = self.address.parse::<u16>() {
218                        ("localhost".to_string(), port, String::new())
219                    } else {
220                        let mut address = self.address.split('/').next().unwrap().to_string();
221                        let path = self.address[address.len()..].to_string();
222
223                        if let Some(port) = self.protocol.default_port() {
224                            if !address.contains(":") {
225                                address.push(':');
226                                address.push_str(port.to_string().as_str());
227                            }
228                        }
229
230                        match address.to_socket_addrs() {
231                            Ok(mut addrs) => {
232                                if addrs.next().is_some() {
233                                    // Split original address to addr and port
234                                    let parts = address.split(':').collect::<Vec<&str>>();
235                                    (parts[0].to_string(), parts[1].parse::<u16>().unwrap(), path)
236                                } else {
237                                    bail!(crate::t!("invalid-address", "address" => address));
238                                }
239                            }
240                            Err(err) => bail!(
241                                crate::t!("invalid-address-error", "error" => err.to_string(), "address" => address)
242                            ),
243                        }
244                    }
245                }
246            };
247
248            let mut filter_rules = self.rules.clone();
249            for (index, rule) in filter_rules.iter_mut().enumerate() {
250                rule.order = index as i32;
251            }
252
253            Ok(ClientEndpoint {
254                description: self.name.clone(),
255                local_proto: self.protocol.into(),
256                local_addr,
257                local_port: local_port as u32,
258                local_path,
259                nodelay: Some(true),
260                auth,
261                acl: self.acl.clone(),
262                headers: self.headers.clone(),
263                filter_rules,
264                username: self.username.clone(),
265                password: self.password.clone().map(|p| p.to_string()),
266                // No CLI flag for it: say nothing so a re-registration keeps the
267                // configured value - CLOUDPUB-1241.
268                proxy_protocol: None,
269            })
270        }
271    }
272}
273
274impl PartialEq for PublishArgs {
275    fn eq(&self, other: &Self) -> bool {
276        self.protocol == other.protocol && self.address == other.address
277    }
278}
279
280const HEADER_SEP: &str = ":";
281
282#[derive(Debug, Clone)]
283struct HeaderParser;
284
285impl TypedValueParser for HeaderParser {
286    type Value = Header;
287
288    fn parse_ref(
289        &self,
290        _cmd: &clap::Command,
291        _arg: Option<&clap::Arg>,
292        value: &std::ffi::OsStr,
293    ) -> Result<Self::Value, clap::Error> {
294        let value = value.to_string_lossy();
295        let parts: Vec<&str> = value.splitn(2, HEADER_SEP).collect();
296        if parts.len() != 2 {
297            return Err(clap::Error::raw(
298                clap::error::ErrorKind::ValueValidation,
299                format!("Invalid Header format (should be 'name:value'): {}", value),
300            ));
301        }
302        Ok(Header {
303            name: parts[0].trim().to_string(),
304            value: parts[1].trim().to_string(),
305        })
306    }
307}
308
309#[derive(Debug, Clone)]
310struct AclParser;
311
312impl TypedValueParser for AclParser {
313    type Value = Acl;
314
315    fn parse_ref(
316        &self,
317        _cmd: &clap::Command,
318        _arg: Option<&clap::Arg>,
319        value: &std::ffi::OsStr,
320    ) -> Result<Self::Value, clap::Error> {
321        let value = value.to_string_lossy();
322        let parts: Vec<&str> = value.split(ROLE_SEP).collect();
323        if parts.len() != 2 {
324            return Err(clap::Error::raw(
325                clap::error::ErrorKind::ValueValidation,
326                format!("Invalid Acl: {}", value),
327            ));
328        }
329        let role = Role::from_str(parts[1]).map_err(|_err| {
330            clap::Error::raw(
331                clap::error::ErrorKind::ValueValidation,
332                format!("Invalid role: {}", parts[1]),
333            )
334        })?;
335        Ok(Acl {
336            user: parts[0].to_string(),
337            role: role.into(),
338        })
339    }
340}
341
342#[derive(Debug, Clone)]
343struct RuleParser;
344
345impl TypedValueParser for RuleParser {
346    type Value = FilterRule;
347
348    fn parse_ref(
349        &self,
350        _cmd: &clap::Command,
351        _arg: Option<&clap::Arg>,
352        value: &std::ffi::OsStr,
353    ) -> Result<Self::Value, clap::Error> {
354        let value = value.to_string_lossy();
355        let parts: Vec<&str> = value.splitn(3, ":").collect();
356        if parts.is_empty() {
357            return Err(clap::Error::raw(
358                clap::error::ErrorKind::ValueValidation,
359                format!(
360                    "Invalid rule format (should be 'action_type[:action_value][:data]'): {}",
361                    value
362                ),
363            ));
364        }
365
366        let action_type = FilterAction::from_str(parts[0]).map_err(|_| {
367            clap::Error::raw(
368                clap::error::ErrorKind::ValueValidation,
369                format!("Invalid action type: {}", parts[0]),
370            )
371        })?;
372
373        let (action_value, data) = if action_type == FilterAction::FilterRedirect {
374            // For FILTER_REDIRECT, expect action_value and optional data
375            if parts.len() < 2 {
376                return Err(clap::Error::raw(
377                    clap::error::ErrorKind::ValueValidation,
378                    format!("FILTER_REDIRECT requires action_value: {}", value),
379                ));
380            }
381            let action_value = if parts[1].is_empty() {
382                None
383            } else {
384                Some(parts[1].to_string())
385            };
386            let data = parts.get(2).unwrap_or(&"").to_string();
387            (action_value, data)
388        } else {
389            // For other actions, no action_value, but data can be in parts[1]
390            let data = parts.get(1).unwrap_or(&"").to_string();
391            (None, data)
392        };
393
394        Ok(FilterRule {
395            order: 0, // Will be set based on position in the arguments list
396            action_value,
397            action_type: action_type.into(),
398            data,
399            ..Default::default()
400        })
401    }
402}
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407
408    fn publish_args(protocol: Protocol, address: &str, auth: Option<Auth>) -> PublishArgs {
409        PublishArgs {
410            protocol,
411            address: address.to_string(),
412            username: None,
413            password: None,
414            name: None,
415            auth,
416            acl: Vec::new(),
417            headers: Vec::new(),
418            rules: Vec::new(),
419        }
420    }
421
422    /// Without --auth the endpoint must carry no auth type at all, so the server can
423    /// tell "not specified" from "explicitly none" and keep the setting the user made
424    /// in the UI. The docker entrypoint re-runs `clo register` on every container
425    /// start, which used to reset it - CLOUDPUB-1208.
426    /// STATUS: FIXED - see CLOUDPUB-1208
427    #[test]
428    fn omitted_auth_flag_is_not_sent() {
429        let endpoint = publish_args(Protocol::Http, "8080", None).parse().unwrap();
430        assert_eq!(endpoint.auth, None);
431        assert_eq!(endpoint.username, None);
432        assert_eq!(endpoint.password, None);
433    }
434
435    /// WebDAV used to default to basic auth here; the default now belongs to
436    /// publication creation, so re-registering a WebDAV service cannot force basic
437    /// back onto a publication the user opened up.
438    #[test]
439    fn omitted_auth_flag_is_not_sent_for_webdav() {
440        let endpoint = publish_args(Protocol::Webdav, "/srv/files", None)
441            .parse()
442            .unwrap();
443        assert_eq!(endpoint.auth, None);
444    }
445
446    #[test]
447    fn explicit_auth_flag_is_sent() {
448        let endpoint = publish_args(Protocol::Http, "8080", Some(Auth::Basic))
449            .parse()
450            .unwrap();
451        assert_eq!(endpoint.auth, Some(Auth::Basic.into()));
452
453        // Explicitly asking for no auth stays distinguishable from omitting the flag
454        let endpoint = publish_args(Protocol::Http, "8080", Some(Auth::None))
455            .parse()
456            .unwrap();
457        assert_eq!(endpoint.auth, Some(Auth::None.into()));
458    }
459
460    /// There is no CLI flag for the proxy protocol, so the endpoint must not claim
461    /// one: re-registering a publication used to reset a configured PROXY protocol v2
462    /// back to none - CLOUDPUB-1241.
463    /// STATUS: FIXED - see CLOUDPUB-1241
464    #[test]
465    fn proxy_protocol_is_never_sent() {
466        let endpoint = publish_args(Protocol::Tcp, "5432", None).parse().unwrap();
467        assert_eq!(endpoint.proxy_protocol, None);
468
469        let endpoint = publish_args(Protocol::Http, "http://localhost:8080", None)
470            .parse()
471            .unwrap();
472        assert_eq!(endpoint.proxy_protocol, None);
473    }
474
475    /// Credentials embedded in the address are still forwarded.
476    #[test]
477    fn credentials_from_url_are_sent() {
478        let endpoint = publish_args(Protocol::Http, "http://alice:secret@localhost:8080", None)
479            .parse()
480            .unwrap();
481        assert_eq!(endpoint.username, Some("alice".to_string()));
482        assert_eq!(endpoint.password, Some("secret".to_string()));
483    }
484}