Skip to main content

simploxide_ws_core/
cli.rs

1pub use simploxide_core::SimplexVersion;
2
3use tokio::process::{Child, Command};
4
5use std::{
6    ffi::OsString,
7    io,
8    iter::{Chain, Empty, Once},
9    process::Stdio,
10};
11
12/// An instance representing the running SimpleX CLI. Ensure to call [`SimplexCli::kill`] manually
13/// to avoid zombie/hang processes on Linux. The Drop impl tries its best to reap the process if it
14/// wasn't killed by the user but it is not guarnteed to succeed.
15///
16/// # Security
17///
18/// - SimpleX CLI requires to pass the database key via the `-k` argument. On most Linux setups the
19///   `-k` parameter is readable from `ps fx` output and `/proc` by **any** user so
20///   [SimplexCliBuilder::db_key] doesn't provide any meaningful security on untrusted machines
21pub struct SimplexCli {
22    handle: Option<Child>,
23    port: u16,
24    version: SimplexVersion,
25}
26
27impl SimplexCli {
28    const MIN_SUPPORTED_VERSION: SimplexVersion = simploxide_core::MIN_SUPPORTED_VERSION;
29    const MAX_SUPPORTED_VERSION: SimplexVersion = simploxide_core::MAX_SUPPORTED_VERSION;
30
31    /// Begin building a [`SimplexCli`] that will spawn a `simplex-chat` process.
32    ///
33    /// Call [`SimplexCliBuilder::spawn`] to launch the process after configuring the builder.
34    pub fn builder(default_bot_name: impl Into<String>, port: u16) -> SimplexCliBuilder {
35        SimplexCliBuilder {
36            port,
37            default_bot_name: default_bot_name.into(),
38            db_path: "bot".into(),
39            db_key: None,
40            extra_args: std::iter::empty(),
41        }
42    }
43
44    pub fn port(&self) -> u16 {
45        self.port
46    }
47
48    pub fn version(&self) -> &SimplexVersion {
49        &self.version
50    }
51
52    /// Kills the child process and waits for it to exit.
53    pub async fn kill(&mut self) -> io::Result<()> {
54        if let Some(mut handle) = self.handle.take() {
55            handle.kill().await?;
56        }
57
58        Ok(())
59    }
60}
61
62impl Drop for SimplexCli {
63    fn drop(&mut self) {
64        if let Some(ref mut handle) = self.handle {
65            // Reap the process if it has already exited to avoid a zombie.
66            // If it is still running, send SIGKILL and attempt an immediate reap
67            // on the happy path where the process exits quickly after the signal.
68            if handle.try_wait().ok().flatten().is_none() {
69                let _ = handle.start_kill();
70                let _ = handle.try_wait();
71            }
72        }
73    }
74}
75
76/// Builder for [`SimplexCli`].
77///
78/// Obtained via [`SimplexCli::builder`].
79///
80/// # Example
81/// ```ignore
82/// let cli = SimplexCli::builder("Bot", 5225)
83///     .db_prefix("/var/db/simplex")
84///     .db_key(secret)
85///     .arg("--smp-servers=smp://example.com")
86///     .spawn()
87///     .await?;
88/// ```
89pub struct SimplexCliBuilder<I = Empty<OsString>> {
90    port: u16,
91    default_bot_name: String,
92    db_path: String,
93    db_key: Option<String>,
94    extra_args: I,
95}
96
97impl<I> SimplexCliBuilder<I>
98where
99    I: Iterator<Item = OsString>,
100{
101    /// Sets the path to the SimpleX database directory (defaults to `"."`).
102    pub fn db_prefix(mut self, path: impl Into<String>) -> Self {
103        self.db_path = path.into();
104        self
105    }
106
107    /// Passes a database encryption key via the `-k` flag.
108    pub fn db_key(mut self, key: impl Into<String>) -> Self {
109        self.db_key = Some(key.into());
110        self
111    }
112
113    /// Adds an extra command argument
114    pub fn arg(self, arg: impl Into<OsString>) -> SimplexCliBuilder<Chain<I, Once<OsString>>> {
115        SimplexCliBuilder {
116            port: self.port,
117            default_bot_name: self.default_bot_name,
118            db_path: self.db_path,
119            db_key: self.db_key,
120            extra_args: self.extra_args.chain(std::iter::once(arg.into())),
121        }
122    }
123
124    /// Adds multiple extra command arguments
125    pub fn args<J>(self, args: J) -> SimplexCliBuilder<Chain<I, J::IntoIter>>
126    where
127        J: IntoIterator<Item = OsString>,
128    {
129        SimplexCliBuilder {
130            port: self.port,
131            default_bot_name: self.default_bot_name,
132            db_path: self.db_path,
133            db_key: self.db_key,
134            extra_args: self.extra_args.chain(args),
135        }
136    }
137
138    /// Spawns the `simplex-chat` process and returns a [`SimplexCli`] handle.
139    ///
140    /// Checks the installed CLI version against the supported range before spawning.
141    pub async fn spawn(self) -> io::Result<SimplexCli> {
142        let sxc_cmd = if std::path::Path::new("./simplex-chat").exists() {
143            "./simplex-chat"
144        } else {
145            "simplex-chat"
146        };
147
148        let version_output = Command::new(sxc_cmd).arg("--version").output().await?;
149
150        let output_str = String::from_utf8(version_output.stdout)
151            .map_err(|_| io::Error::other("simplex-chat --version returned invalid string"))?;
152
153        let version_str = output_str
154            .lines()
155            .next()
156            .and_then(|line| line.trim().strip_prefix("SimpleX Chat v"))
157            .ok_or_else(|| {
158                io::Error::other(format!("Cannot parse SimpleX Chat version: {output_str:?}"))
159            })?;
160
161        let version: SimplexVersion = version_str.parse().map_err(|_| {
162            io::Error::other(format!(
163                "Cannot parse SimpleX Chat version: {version_str:?}"
164            ))
165        })?;
166
167        if !version.is_supported() {
168            return Err(io::Error::other(format!(
169                "The Simplex CLI {version} is incompatible with current simploxide version\n\
170                Supported CLI versions: {}...{}",
171                SimplexCli::MIN_SUPPORTED_VERSION,
172                SimplexCli::MAX_SUPPORTED_VERSION
173            )));
174        }
175
176        let mut cmd = Command::new(sxc_cmd);
177        cmd.stdin(Stdio::null())
178            .stdout(Stdio::null())
179            .stderr(Stdio::null());
180
181        // `simploxide` users may install Ctrl-C handlers to handle graceful shutdown. However,
182        // Ctrl-C still kills all child processes on Linux by default dropping the web socket
183        // connection interrupting the graceful shutdown logic. This call "unlinks" CLI from its
184        // parent process allowing the graceful shutdown phase to complete
185        #[cfg(unix)]
186        cmd.process_group(0);
187
188        cmd.arg("-d")
189            .arg(&self.db_path)
190            .arg("-p")
191            .arg(self.port.to_string())
192            .arg("--create-bot-display-name")
193            .arg(&self.default_bot_name);
194
195        if let Some(ref key) = self.db_key {
196            cmd.arg("-k").arg(key);
197        }
198
199        cmd.args(self.extra_args);
200
201        let mut handle = cmd.spawn()?;
202
203        if let Ok(ret) =
204            tokio::time::timeout(std::time::Duration::from_secs(2), handle.wait()).await
205        {
206            let exit = ret?;
207            return Err(io::Error::other(format!(
208                "SimpleX-CLI terminated unexpectedly: {exit}"
209            )));
210        }
211
212        Ok(SimplexCli {
213            handle: Some(handle),
214            port: self.port,
215            version,
216        })
217    }
218}