bacup/remotes/
ssh.rs

1// Copyright 2022 Paolo Galeone <nessuno@nerdz.eu>
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use crate::config::SshConfig;
16use crate::remotes::remote;
17
18use std::io;
19use std::io::prelude::*;
20use std::io::Write;
21
22use std::iter::once;
23use std::path::{Path, PathBuf};
24
25use std::fmt;
26use std::string::String;
27
28use log::warn;
29
30use tokio::fs;
31use tokio::fs::File;
32use tokio::io::AsyncReadExt;
33
34use async_trait::async_trait;
35
36use std::process::{Command, Stdio};
37use which::which;
38
39#[derive(Debug)]
40pub enum Error {
41    InvalidPrivateKey(String),
42    CommandNotFound(which::Error),
43    RuntimeError(io::Error),
44}
45
46impl From<which::Error> for Error {
47    fn from(error: which::Error) -> Self {
48        Error::CommandNotFound(error)
49    }
50}
51
52impl From<io::Error> for Error {
53    fn from(error: io::Error) -> Self {
54        Error::RuntimeError(error)
55    }
56}
57
58impl std::error::Error for Error {}
59
60impl fmt::Display for Error {
61    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62        match self {
63            Error::CommandNotFound(error) => write!(f, "Command not found: {}", error),
64            Error::InvalidPrivateKey(msg) => write!(f, "Invalid private key: {}", msg),
65            Error::RuntimeError(error) => write!(f, "Error while reading/writing: {}", error),
66        }
67    }
68}
69
70#[derive(Clone)]
71pub struct Ssh {
72    remote_name: String,
73    config: SshConfig,
74    ssh_cmd: PathBuf,
75    rsync_cmd: PathBuf,
76    ssh_args: Vec<String>,
77}
78
79impl Ssh {
80    pub async fn new(config: SshConfig, remote_name: &str) -> Result<Ssh, Error> {
81        let ssh_cmd = which("ssh")?;
82
83        let private_key = shellexpand::tilde(&config.private_key).to_string();
84        let private_key = PathBuf::from(private_key);
85        if !private_key.exists() {
86            return Err(Error::InvalidPrivateKey(format!(
87                "Private key {} does not exist.",
88                private_key.display(),
89            )));
90        }
91        let private_key_file = fs::read_to_string(&private_key).await?;
92
93        if private_key_file.contains("Proc-Type") && private_key_file.contains("ENCRYPTED") {
94            return Err(Error::InvalidPrivateKey(format!(
95                "Private key {} is encrypted with a passphrase. \
96                            A key without passphrase is required",
97                private_key.display()
98            )));
99        }
100
101        let port = format!("{}", config.port);
102        let host = format!("{}@{}", config.username, config.host);
103        let mut args = vec![format!("-p{}", port), host, String::from("true")];
104
105        let output = Command::new(&ssh_cmd).args(&args).output();
106        if output.is_err() {
107            return Err(Error::RuntimeError(io::Error::other(format!(
108                "ssh connection to {}@{}:{} failed with error: {}",
109                config.username,
110                config.host,
111                config.port,
112                output.err().unwrap(),
113            ))));
114        }
115
116        let output = output.unwrap();
117        let stdout = String::from_utf8(output.stdout).unwrap();
118        let stderr = String::from_utf8(output.stderr).unwrap();
119
120        if stdout.is_empty() && stderr.contains("true") {
121            // like on github.com -> can connect, can't execute anything on the shell
122            // and we receive a message like
123            //
124            // Invalid command: 'true'
125            //   You appear to be using ssh to clone a git:// URL.
126            //   Make sure your core.gitProxy config option and the
127            //   GIT_PROXY_COMMAND environment variable are NOT set.
128            //
129            // But anyway this is a success since the connection was succesfull.
130            warn!(
131                "Connection to  {}@{}:{} succeded, but received: {}",
132                config.username, config.host, config.port, stderr
133            );
134        } else {
135            // In normal circumstances we repeat the connection capturing only the status
136            // somehow with the Command API it's not possibile to get output and status :S
137
138            let status = Command::new(&ssh_cmd)
139                .args(&args)
140                .stdout(Stdio::null())
141                .stderr(Stdio::null())
142                .status();
143            if status.is_err() {
144                return Err(Error::RuntimeError(status.err().unwrap()));
145            }
146
147            let status = status.unwrap();
148
149            if !status.success() {
150                return Err(Error::RuntimeError(io::Error::other(format!(
151                    "ssh connection to {}@{}:{} failed with status: {}",
152                    config.username,
153                    config.host,
154                    config.port,
155                    status.code().unwrap(),
156                ))));
157            }
158        }
159
160        let rsync_cmd = which("rsync")?;
161        args.remove(args.iter().position(|x| x == "true").unwrap()); // remove "true"
162        let ssh_args = args.iter().map(|s| s.to_string()).collect();
163        Ok(Ssh {
164            remote_name: String::from(remote_name),
165            config,
166            ssh_cmd,
167            rsync_cmd,
168            ssh_args,
169        })
170    }
171}
172
173#[async_trait]
174impl remote::Remote for Ssh {
175    fn name(&self) -> String {
176        self.remote_name.clone()
177    }
178
179    async fn enumerate(&self, remote_path: &Path) -> Result<Vec<String>, remote::Error> {
180        let remote_path = remote_path.to_str().unwrap();
181        // ssh -Pxxx user@host "find remote_path/*"
182        // use find path/* instead of ls path
183        // because find returns the fullpath
184        // the /* is needed to return the content
185        // and not the path itself
186        let mut ssh = Command::new(&self.ssh_cmd)
187            .args(
188                self.ssh_args
189                    .iter()
190                    .chain(once(&format!("find {}/*", remote_path))),
191            )
192            .stdin(Stdio::null())
193            .stdout(Stdio::piped())
194            .stderr(Stdio::null())
195            .spawn()?;
196
197        let status = ssh.wait()?;
198
199        if status.success() {
200            let stdout = ssh.stdout.as_mut().unwrap();
201            let mut output = String::new();
202            stdout.read_to_string(&mut output).unwrap();
203            return Ok(output.split_whitespace().map(|s| s.to_string()).collect());
204        }
205
206        Err(remote::Error::LocalError(io::Error::other(format!(
207            "Error during ls {} on remote host",
208            remote_path
209        ))))
210    }
211
212    async fn delete(&self, remote_path: &Path) -> Result<(), remote::Error> {
213        let remote_path = remote_path.to_str().unwrap();
214        // ssh -Pxxx user@host "rm -r remote_path"
215        let mut ssh = Command::new(&self.ssh_cmd)
216            .args(
217                self.ssh_args
218                    .iter()
219                    .chain(once(&format!("rm -r {}", remote_path))),
220            )
221            .stdin(Stdio::null())
222            .stdout(Stdio::null())
223            .stderr(Stdio::null())
224            .spawn()?;
225
226        let status = ssh.wait()?;
227
228        if status.success() {
229            return Ok(());
230        }
231
232        Err(remote::Error::LocalError(io::Error::other(format!(
233            "Error during rm -r {} on remote host",
234            remote_path
235        ))))
236    }
237
238    async fn upload_file(&self, path: &Path, remote_path: &Path) -> Result<(), remote::Error> {
239        // Read file
240        let mut content: Vec<u8> = vec![];
241        let mut file = File::open(path).await?;
242        file.read_to_end(&mut content).await?;
243        let remote_path = remote_path.to_str().unwrap();
244
245        // cat file | ssh -Pxxx user@host "cat > file"
246        let mut ssh = Command::new(&self.ssh_cmd)
247            .args(
248                self.ssh_args
249                    .iter()
250                    .chain(once(&format!("cat > {}", remote_path))),
251            )
252            .stdin(Stdio::piped())
253            .stdout(Stdio::piped())
254            .stderr(Stdio::piped())
255            .spawn()?;
256
257        {
258            let stdin = ssh.stdin.as_mut().unwrap();
259            // This is the "cat file" on localhost piped into ssh
260            // when stdin is dropped
261            stdin.write_all(&content)?;
262        }
263        // Close stdin for being 100% sure that the process read all the file
264
265        let status = ssh.wait()?;
266
267        if !status.success() {
268            let stdout = ssh.stdout.as_mut().unwrap();
269            let stderr = ssh.stderr.as_mut().unwrap();
270            let mut errlog = String::new();
271            stderr.read_to_string(&mut errlog).unwrap();
272            let mut outlog = String::new();
273            stdout.read_to_string(&mut outlog).unwrap();
274
275            let message = format!(
276                "Failure while executing ssh command.\n\
277                Stderr: {}\nStdout: {}",
278                errlog, outlog
279            );
280            return Err(remote::Error::LocalError(io::Error::other(message)));
281        }
282        Ok(())
283    }
284
285    async fn upload_file_compressed(
286        &self,
287        path: &Path,
288        remote_path: &Path,
289    ) -> Result<(), remote::Error> {
290        // Read and compress
291        let compressed_bytes = self.compress_file(path).await?;
292        let remote_path = self.remote_compressed_file_path(remote_path);
293
294        // cat file | ssh -Pxxx user@host "cat > file"
295        let mut ssh = Command::new(&self.ssh_cmd)
296            .stdin(Stdio::piped())
297            .stdout(Stdio::null())
298            .args(
299                self.ssh_args
300                    .iter()
301                    .chain(once(&format!("cat > {} ", remote_path.display()))),
302            )
303            .spawn()?;
304        ssh.stdin.as_mut().unwrap().write_all(&compressed_bytes)?;
305        let status = ssh.wait()?;
306        if !status.success() {
307            return Err(remote::Error::LocalError(io::Error::other(
308                "Failure while executing ssh command",
309            )));
310        }
311        Ok(())
312    }
313
314    async fn upload_folder(
315        &self,
316        paths: &[PathBuf],
317        remote_path: &Path,
318    ) -> Result<(), remote::Error> {
319        let mut local_prefix = paths.iter().min_by(|a, b| a.cmp(b)).unwrap();
320        // The local_prefix found is:
321        // In case of a folder: the shortest path inside the folder we want to backup.
322
323        // If it is a folder, we of course don't want to consider this a prefix, but its parent.
324        let single_location = paths.len() <= 1;
325        let parent: PathBuf;
326        if !single_location {
327            parent = local_prefix.parent().unwrap().to_path_buf();
328            local_prefix = &parent;
329        }
330
331        let remote_path = remote_path.to_str().unwrap();
332        let dest = format!(
333            "{}@{}:{}",
334            self.config.username, self.config.host, remote_path
335        );
336        let src = local_prefix.to_str().unwrap();
337        let ssh_port_opt = format!(r#"ssh -p {}"#, self.config.port);
338        // rsync -az -e "ssh -p port" /local/folder user@host:remote_path --delete
339        // delete is used to remove from remote and keep it in sync with local
340        let args = vec!["-az", "-e", &ssh_port_opt, src, &dest, "--delete"];
341
342        let status = Command::new(&self.rsync_cmd)
343            .stderr(Stdio::null())
344            .stdout(Stdio::null())
345            .args(&args)
346            .status()?;
347
348        if !status.success() {
349            return Err(remote::Error::LocalError(io::Error::other(
350                "Failed to execute rsync trought ssh command",
351            )));
352        }
353
354        Ok(())
355    }
356
357    async fn upload_folder_compressed(
358        &self,
359        path: &Path,
360        remote_path: &Path,
361    ) -> Result<(), remote::Error> {
362        if !path.is_dir() {
363            return Err(remote::Error::NotADirectory);
364        }
365
366        let remote_path = self.remote_archive_path(remote_path);
367        let compressed_folder = self.compress_folder(path).await?;
368
369        self.upload_file(compressed_folder.path(), &remote_path)
370            .await
371    }
372}