1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
use std::process::Command;
use serde::{Deserialize, Serialize};
use validator::{Validate, ValidationError};
use crate::utils::command::command_to_string;
use crate::auth_type::{
deserialize_auth_type_from_string, serialize_auth_type_to_string, AuthType,
};
use crate::errors::SftpManError;
pub const DEFAULT_MOUNT_PATH_PREFIX: &str = "/mnt/sshfs";
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
#[validate(schema(
function = "validate_ssh_key_for_publickey_auth",
skip_on_field_errors = false
))]
pub struct FilesystemMountDefinition {
/// Unique identifier for this definition.
/// If `mount_dest_path` is `None`, this will also influence where the filesystem gets mounted locally (see `local_mount_path()`).
#[validate(
length(min = 1, message = "An ID must be provided."),
custom(
function = "validate_id",
message = "The ID must be a valid identifier (alphanumeric characters, underscores, dashes, or dots)."
)
)]
pub id: String,
/// Hostname or IP address of the remote machine
#[validate(length(min = 1, message = "A host must be provided."))]
pub host: String,
/// Port number of the remote machine (e.g. `22`).
pub port: u16,
/// Username to SSH in on the remote machine (e.g. `user`).
#[validate(length(min = 1, message = "A user must be provided."))]
pub user: String,
/// Mount options to pass to sshfs (-o).
/// Example: [`follow_symlinks`, `rename`]
#[serde(rename = "mountOptions")]
pub mount_options: Vec<String>,
/// Path on the remote server that will be mounted locally (e.g. `/storage`).
#[serde(rename = "mountPoint")]
#[validate(
length(min = 1, message = "A remote path must be provided."),
custom(
function = "validate_absolute_path",
message = "The remote path must be absolute."
)
)]
pub remote_path: String,
/// Path where the filesystem will be mounted locally (e.g. `/home/user/storage`).
/// If not provided, it defaults to `{DEFAULT_MOUNT_PATH_PREFIX}/{id}`.
#[serde(rename = "mountDestPath")]
#[validate(
length(min = 1, message = "A local mount destination path must be provided."),
custom(
function = "validate_absolute_path",
message = "The local mount destination path must be absolute."
)
)]
pub mount_dest_path: Option<String>,
/// Command to run before mounting (e.g. `/bin/true`)
#[serde(rename = "beforeMount")]
#[serde(default)]
pub cmd_before_mount: String,
/// Authentication method.
/// Most of the potential values match SSH's `PreferredAuthentications` list, but some are special values that we recognize & handle here.
#[serde(rename = "authType")]
#[serde(
serialize_with = "serialize_auth_type_to_string",
deserialize_with = "deserialize_auth_type_from_string"
)]
pub auth_type: AuthType,
/// Path to an SSH private key (e.g. `/home/user/.ssh/id_ed25519`) for authentication types (like `AuthType::PublicKey`) that use a key.
#[serde(rename = "sshKey")]
pub ssh_key: String,
}
const SSH_DEFAULT_TIMEOUT: u32 = 10;
impl Default for FilesystemMountDefinition {
fn default() -> Self {
FilesystemMountDefinition {
id: String::new(),
host: String::new(),
port: 22,
user: String::new(),
mount_options: Vec::new(),
remote_path: String::new(),
mount_dest_path: None,
cmd_before_mount: String::new(),
auth_type: AuthType::PublicKey,
ssh_key: String::new(),
}
}
}
impl FilesystemMountDefinition {
pub fn from_json_string(contents: &str) -> Result<Self, serde_json::Error> {
let deserialized: Self = serde_json::from_str(contents)?;
Ok(deserialized)
}
pub fn to_json_string(&self) -> serde_json::Result<String> {
serde_json::to_string_pretty(self)
}
/// Returns the local mount path for this definition.
/// If `mount_dest_path` is not `None` for this definition, it will be used.
/// Otherwise, the default mount path (`DEFAULT_MOUNT_PATH_PREFIX`) will be used (e.g. `/mnt/sshfs/{id}`).
pub fn local_mount_path(&self) -> String {
match &self.mount_dest_path {
Some(path) => path.clone(),
None => format!("{0}/{1}", DEFAULT_MOUNT_PATH_PREFIX, self.id),
}
}
/// Returns a list of commands for mounting the filesystem definition.
/// Mounting is performed via `sshfs` and `ssh` commands.
pub fn mount_commands(&self) -> Result<Vec<Command>, SftpManError> {
log::debug!("{0}: building list of mount commands", self.id);
let mut list: Vec<Command> = Vec::new();
if !self.cmd_before_mount.is_empty() {
if self.cmd_before_mount == "/bin/true" || self.cmd_before_mount == "true" {
// sftpman-gtk used to hardcode `/bin/true` or `true` as a before-mount command.
// We don't really need to run this.
log::debug!(
"{0}: ignoring no-op before-mount command {1}",
self.id,
self.cmd_before_mount
);
} else {
let mut program_name = "";
let mut args: Vec<&str> = Vec::new();
for (idx, arg) in self.cmd_before_mount.split(' ').enumerate() {
match idx {
0 => {
program_name = arg;
}
_ => {
args.push(arg);
}
}
}
if program_name.is_empty() {
return Err(SftpManError::MountCommandBuilding(format!(
"could not extract program name from {0}",
self.cmd_before_mount
)));
}
let mut cmd_before = Command::new(program_name);
for arg in args {
cmd_before.arg(arg);
}
list.push(cmd_before);
}
}
let mut cmd_ssh = Command::new("ssh");
cmd_ssh
.arg("-p")
.arg(self.port.to_string())
.arg("-o")
.arg(format!("ConnectTimeout={0}", SSH_DEFAULT_TIMEOUT));
match &self.auth_type {
AuthType::PublicKey => {
cmd_ssh.arg(format!(
"-o PreferredAuthentications={0}",
AuthType::PublicKey.to_static_str()
));
cmd_ssh.arg(format!("-i {0}", self.ssh_key));
}
AuthType::AuthenticationAgent => {
// By not specifying a key and preferred authentication type,
// we're hoping to delegate all this to an already running SSH agent, if available.
}
any_other => {
cmd_ssh.arg(format!(
"-o PreferredAuthentications={0}",
any_other.to_static_str()
));
}
};
let mut cmd_sshfs = Command::new("sshfs");
cmd_sshfs
// Add mount options prefixed with "-o" (ignored if empty).
.args(self.mount_options.iter().flat_map(|opt| ["-o", opt]))
// Add the formatted SSH command as an sshfs option.
.arg("-o")
.arg(format!("ssh_command={0}", command_to_string(&cmd_ssh)))
// We use `[]` around the host to avoid issues with hostnames (IPv6 addresses) containing `:`.
// This also works well for IPv4 addresses and name-based hostnames.
.arg(format!(
"{0}@[{1}]:{2}",
self.user, self.host, self.remote_path
))
// Set the local mount point for the remote directory.
.arg(self.local_mount_path());
list.push(cmd_sshfs);
Ok(list)
}
/// Returns a list of commands for unmounting the filesystem definition.
///
/// Unmounting with this command may fail if the filesystem is busy and a fallback mechanism may be necessary
/// (killing the `sshfs` process responsible for the mount).
pub fn umount_commands(&self) -> Result<Vec<Command>, SftpManError> {
log::debug!("{0}: building list of unmount commands", self.id);
let mut list: Vec<Command> = Vec::new();
// Unmounting is done via `fusermount -u`.
// Using `nix::mount::umount` or `nix::mount::umount2` sounds like a good idea,
// but those require special privileges (`CAP_SYS_ADMIN``) and return `EPERM` to regular users.
let mut cmd = Command::new("fusermount");
cmd.arg("-u").arg(self.local_mount_path());
list.push(cmd);
Ok(list)
}
/// Returns a command that opens a file manager (via `xdg-open`) at the local mount path (see `local_mount_path()`).
///
/// Opening requires that the filesystem is already mounted.
pub fn open_command(&self) -> Command {
let mut cmd = Command::new("xdg-open");
cmd.arg(self.local_mount_path());
cmd
}
}
fn validate_id(id: &str) -> Result<(), ValidationError> {
if !id
.chars()
.all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.')
{
return Err(ValidationError::new("invalid_id").with_message(
"The ID must contain only alphanumeric characters, underscores, dashes, or dots."
.into(),
));
}
Ok(())
}
fn validate_absolute_path(path: &str) -> Result<(), ValidationError> {
if !path.starts_with('/') {
return Err(ValidationError::new("not_absolute_path")
.with_message(format!("The path {0} is not absolute.", path).into()));
}
Ok(())
}
fn validate_ssh_key_for_publickey_auth(
entity: &&FilesystemMountDefinition,
) -> Result<(), ValidationError> {
match entity.auth_type {
AuthType::PublicKey => {
if entity.ssh_key.is_empty() {
Err(
ValidationError::new("no_ssh_key_for_publickey_auth").with_message(
format!(
"The {0} authentication type requires an SSH key to be provided.",
AuthType::PublicKey,
)
.into(),
),
)
} else {
Ok(())
}
}
_ => Ok(()),
}
}