]>
Commit | Line | Data |
---|---|---|
f1a83e97 | 1 | //! Shared tools useful for common CLI clients. |
f1a83e97 | 2 | use std::collections::HashMap; |
bdfa6370 | 3 | use std::env::VarError::{NotPresent, NotUnicode}; |
16a01c19 | 4 | use std::fs::File; |
bdfa6370 | 5 | use std::io::{BufRead, BufReader}; |
16a01c19 | 6 | use std::os::unix::io::FromRawFd; |
16a01c19 | 7 | use std::process::Command; |
f1a83e97 | 8 | |
ff8945fd | 9 | use anyhow::{bail, format_err, Context, Error}; |
f1a83e97 SR |
10 | use serde_json::{json, Value}; |
11 | use xdg::BaseDirectories; | |
12 | ||
b3f279e2 | 13 | use proxmox_router::cli::{complete_file_name, shellword_split}; |
bdfa6370 | 14 | use proxmox_schema::*; |
25877d05 | 15 | use proxmox_sys::fs::file_get_json; |
f1a83e97 | 16 | |
bdfa6370 | 17 | use pbs_api_types::{Authid, RateLimitConfig, UserWithTokens, BACKUP_REPO_URL}; |
9eb78407 WB |
18 | use pbs_datastore::BackupDir; |
19 | use pbs_tools::json::json_object_to_query; | |
af06decd | 20 | |
2b7f8dd5 | 21 | use crate::{BackupRepository, HttpClient, HttpClientOptions}; |
f1a83e97 | 22 | |
ff8945fd SR |
23 | pub mod key_source; |
24 | ||
f1a83e97 SR |
25 | const ENV_VAR_PBS_FINGERPRINT: &str = "PBS_FINGERPRINT"; |
26 | const ENV_VAR_PBS_PASSWORD: &str = "PBS_PASSWORD"; | |
27 | ||
28 | pub const REPO_URL_SCHEMA: Schema = StringSchema::new("Repository URL.") | |
29 | .format(&BACKUP_REPO_URL) | |
30 | .max_length(256) | |
31 | .schema(); | |
32 | ||
f1a83e97 SR |
33 | pub const CHUNK_SIZE_SCHEMA: Schema = IntegerSchema::new("Chunk size in KB. Must be a power of 2.") |
34 | .minimum(64) | |
35 | .maximum(4096) | |
36 | .default(4096) | |
37 | .schema(); | |
38 | ||
16a01c19 DM |
39 | /// Helper to read a secret through a environment variable (ENV). |
40 | /// | |
41 | /// Tries the following variable names in order and returns the value | |
42 | /// it will resolve for the first defined one: | |
43 | /// | |
44 | /// BASE_NAME => use value from ENV(BASE_NAME) directly as secret | |
45 | /// BASE_NAME_FD => read the secret from the specified file descriptor | |
46 | /// BASE_NAME_FILE => read the secret from the specified file name | |
47 | /// BASE_NAME_CMD => read the secret from specified command first line of output on stdout | |
48 | /// | |
49 | /// Only return the first line of data (without CRLF). | |
50 | pub fn get_secret_from_env(base_name: &str) -> Result<Option<String>, Error> { | |
16a01c19 DM |
51 | let firstline = |data: String| -> String { |
52 | match data.lines().next() { | |
53 | Some(line) => line.to_string(), | |
54 | None => String::new(), | |
55 | } | |
56 | }; | |
57 | ||
58 | let firstline_file = |file: &mut File| -> Result<String, Error> { | |
59 | let reader = BufReader::new(file); | |
60 | match reader.lines().next() { | |
61 | Some(Ok(line)) => Ok(line), | |
62 | Some(Err(err)) => Err(err.into()), | |
63 | None => Ok(String::new()), | |
64 | } | |
65 | }; | |
66 | ||
67 | match std::env::var(base_name) { | |
68 | Ok(p) => return Ok(Some(firstline(p))), | |
69 | Err(NotUnicode(_)) => bail!(format!("{} contains bad characters", base_name)), | |
bdfa6370 | 70 | Err(NotPresent) => {} |
16a01c19 DM |
71 | }; |
72 | ||
73 | let env_name = format!("{}_FD", base_name); | |
74 | match std::env::var(&env_name) { | |
75 | Ok(fd_str) => { | |
bdfa6370 TL |
76 | let fd: i32 = fd_str.parse().map_err(|err| { |
77 | format_err!( | |
78 | "unable to parse file descriptor in ENV({}): {}", | |
79 | env_name, | |
80 | err | |
81 | ) | |
82 | })?; | |
16a01c19 DM |
83 | let mut file = unsafe { File::from_raw_fd(fd) }; |
84 | return Ok(Some(firstline_file(&mut file)?)); | |
85 | } | |
86 | Err(NotUnicode(_)) => bail!(format!("{} contains bad characters", env_name)), | |
bdfa6370 | 87 | Err(NotPresent) => {} |
16a01c19 DM |
88 | } |
89 | ||
90 | let env_name = format!("{}_FILE", base_name); | |
91 | match std::env::var(&env_name) { | |
92 | Ok(filename) => { | |
93 | let mut file = std::fs::File::open(filename) | |
94 | .map_err(|err| format_err!("unable to open file in ENV({}): {}", env_name, err))?; | |
95 | return Ok(Some(firstline_file(&mut file)?)); | |
96 | } | |
97 | Err(NotUnicode(_)) => bail!(format!("{} contains bad characters", env_name)), | |
bdfa6370 | 98 | Err(NotPresent) => {} |
16a01c19 DM |
99 | } |
100 | ||
101 | let env_name = format!("{}_CMD", base_name); | |
102 | match std::env::var(&env_name) { | |
103 | Ok(ref command) => { | |
104 | let args = shellword_split(command)?; | |
105 | let mut command = Command::new(&args[0]); | |
106 | command.args(&args[1..]); | |
25877d05 | 107 | let output = proxmox_sys::command::run_command(command, None)?; |
16a01c19 DM |
108 | return Ok(Some(firstline(output))); |
109 | } | |
110 | Err(NotUnicode(_)) => bail!(format!("{} contains bad characters", env_name)), | |
bdfa6370 | 111 | Err(NotPresent) => {} |
16a01c19 DM |
112 | } |
113 | ||
114 | Ok(None) | |
115 | } | |
116 | ||
f1a83e97 SR |
117 | pub fn get_default_repository() -> Option<String> { |
118 | std::env::var("PBS_REPOSITORY").ok() | |
119 | } | |
120 | ||
121 | pub fn extract_repository_from_value(param: &Value) -> Result<BackupRepository, Error> { | |
122 | let repo_url = param["repository"] | |
123 | .as_str() | |
124 | .map(String::from) | |
125 | .or_else(get_default_repository) | |
126 | .ok_or_else(|| format_err!("unable to get (default) repository"))?; | |
127 | ||
128 | let repo: BackupRepository = repo_url.parse()?; | |
129 | ||
130 | Ok(repo) | |
131 | } | |
132 | ||
133 | pub fn extract_repository_from_map(param: &HashMap<String, String>) -> Option<BackupRepository> { | |
134 | param | |
135 | .get("repository") | |
136 | .map(String::from) | |
137 | .or_else(get_default_repository) | |
138 | .and_then(|repo_url| repo_url.parse::<BackupRepository>().ok()) | |
139 | } | |
140 | ||
141 | pub fn connect(repo: &BackupRepository) -> Result<HttpClient, Error> { | |
2d5287fb DM |
142 | let rate_limit = RateLimitConfig::default(); // unlimited |
143 | connect_do(repo.host(), repo.port(), repo.auth_id(), rate_limit) | |
f1a83e97 SR |
144 | .map_err(|err| format_err!("error building client for repository {} - {}", repo, err)) |
145 | } | |
146 | ||
2419dc0d DM |
147 | pub fn connect_rate_limited( |
148 | repo: &BackupRepository, | |
2d5287fb | 149 | rate_limit: RateLimitConfig, |
2419dc0d | 150 | ) -> Result<HttpClient, Error> { |
2d5287fb | 151 | connect_do(repo.host(), repo.port(), repo.auth_id(), rate_limit) |
2419dc0d DM |
152 | .map_err(|err| format_err!("error building client for repository {} - {}", repo, err)) |
153 | } | |
154 | ||
155 | fn connect_do( | |
156 | server: &str, | |
157 | port: u16, | |
158 | auth_id: &Authid, | |
2d5287fb | 159 | rate_limit: RateLimitConfig, |
2419dc0d | 160 | ) -> Result<HttpClient, Error> { |
f1a83e97 SR |
161 | let fingerprint = std::env::var(ENV_VAR_PBS_FINGERPRINT).ok(); |
162 | ||
16a01c19 | 163 | let password = get_secret_from_env(ENV_VAR_PBS_PASSWORD)?; |
bdfa6370 | 164 | let options = HttpClientOptions::new_interactive(password, fingerprint).rate_limit(rate_limit); |
f1a83e97 SR |
165 | |
166 | HttpClient::new(server, port, auth_id, options) | |
167 | } | |
168 | ||
169 | /// like get, but simply ignore errors and return Null instead | |
170 | pub async fn try_get(repo: &BackupRepository, url: &str) -> Value { | |
f1a83e97 | 171 | let fingerprint = std::env::var(ENV_VAR_PBS_FINGERPRINT).ok(); |
16a01c19 | 172 | let password = get_secret_from_env(ENV_VAR_PBS_PASSWORD).unwrap_or(None); |
f1a83e97 SR |
173 | |
174 | // ticket cache, but no questions asked | |
bdfa6370 | 175 | let options = HttpClientOptions::new_interactive(password, fingerprint).interactive(false); |
f1a83e97 SR |
176 | |
177 | let client = match HttpClient::new(repo.host(), repo.port(), repo.auth_id(), options) { | |
178 | Ok(v) => v, | |
179 | _ => return Value::Null, | |
180 | }; | |
181 | ||
182 | let mut resp = match client.get(url, None).await { | |
183 | Ok(v) => v, | |
184 | _ => return Value::Null, | |
185 | }; | |
186 | ||
187 | if let Some(map) = resp.as_object_mut() { | |
188 | if let Some(data) = map.remove("data") { | |
189 | return data; | |
190 | } | |
191 | } | |
192 | Value::Null | |
193 | } | |
194 | ||
195 | pub fn complete_backup_group(_arg: &str, param: &HashMap<String, String>) -> Vec<String> { | |
9a1b24b6 | 196 | proxmox_async::runtime::main(async { complete_backup_group_do(param).await }) |
f1a83e97 SR |
197 | } |
198 | ||
199 | pub async fn complete_backup_group_do(param: &HashMap<String, String>) -> Vec<String> { | |
f1a83e97 SR |
200 | let mut result = vec![]; |
201 | ||
202 | let repo = match extract_repository_from_map(param) { | |
203 | Some(v) => v, | |
204 | _ => return result, | |
205 | }; | |
206 | ||
207 | let path = format!("api2/json/admin/datastore/{}/groups", repo.store()); | |
208 | ||
209 | let data = try_get(&repo, &path).await; | |
210 | ||
211 | if let Some(list) = data.as_array() { | |
212 | for item in list { | |
213 | if let (Some(backup_id), Some(backup_type)) = | |
214 | (item["backup-id"].as_str(), item["backup-type"].as_str()) | |
215 | { | |
216 | result.push(format!("{}/{}", backup_type, backup_id)); | |
217 | } | |
218 | } | |
219 | } | |
220 | ||
221 | result | |
222 | } | |
223 | ||
224 | pub fn complete_group_or_snapshot(arg: &str, param: &HashMap<String, String>) -> Vec<String> { | |
9a1b24b6 | 225 | proxmox_async::runtime::main(async { complete_group_or_snapshot_do(arg, param).await }) |
f1a83e97 SR |
226 | } |
227 | ||
bdfa6370 TL |
228 | pub async fn complete_group_or_snapshot_do( |
229 | arg: &str, | |
230 | param: &HashMap<String, String>, | |
231 | ) -> Vec<String> { | |
f1a83e97 SR |
232 | if arg.matches('/').count() < 2 { |
233 | let groups = complete_backup_group_do(param).await; | |
234 | let mut result = vec![]; | |
235 | for group in groups { | |
236 | result.push(group.to_string()); | |
237 | result.push(format!("{}/", group)); | |
238 | } | |
239 | return result; | |
240 | } | |
241 | ||
242 | complete_backup_snapshot_do(param).await | |
243 | } | |
244 | ||
245 | pub fn complete_backup_snapshot(_arg: &str, param: &HashMap<String, String>) -> Vec<String> { | |
9a1b24b6 | 246 | proxmox_async::runtime::main(async { complete_backup_snapshot_do(param).await }) |
f1a83e97 SR |
247 | } |
248 | ||
249 | pub async fn complete_backup_snapshot_do(param: &HashMap<String, String>) -> Vec<String> { | |
f1a83e97 SR |
250 | let mut result = vec![]; |
251 | ||
252 | let repo = match extract_repository_from_map(param) { | |
253 | Some(v) => v, | |
254 | _ => return result, | |
255 | }; | |
256 | ||
257 | let path = format!("api2/json/admin/datastore/{}/snapshots", repo.store()); | |
258 | ||
259 | let data = try_get(&repo, &path).await; | |
260 | ||
261 | if let Some(list) = data.as_array() { | |
262 | for item in list { | |
bdfa6370 TL |
263 | if let (Some(backup_id), Some(backup_type), Some(backup_time)) = ( |
264 | item["backup-id"].as_str(), | |
265 | item["backup-type"].as_str(), | |
266 | item["backup-time"].as_i64(), | |
267 | ) { | |
988d575d WB |
268 | let backup_type = match backup_type.parse() { |
269 | Ok(ty) => ty, | |
270 | Err(_) => { | |
271 | // FIXME: print error in completion? | |
272 | continue; | |
273 | } | |
274 | }; | |
f1a83e97 SR |
275 | if let Ok(snapshot) = BackupDir::new(backup_type, backup_id, backup_time) { |
276 | result.push(snapshot.relative_path().to_str().unwrap().to_owned()); | |
277 | } | |
278 | } | |
279 | } | |
280 | } | |
281 | ||
282 | result | |
283 | } | |
284 | ||
285 | pub fn complete_server_file_name(_arg: &str, param: &HashMap<String, String>) -> Vec<String> { | |
9a1b24b6 | 286 | proxmox_async::runtime::main(async { complete_server_file_name_do(param).await }) |
f1a83e97 SR |
287 | } |
288 | ||
289 | pub async fn complete_server_file_name_do(param: &HashMap<String, String>) -> Vec<String> { | |
f1a83e97 SR |
290 | let mut result = vec![]; |
291 | ||
292 | let repo = match extract_repository_from_map(param) { | |
293 | Some(v) => v, | |
294 | _ => return result, | |
295 | }; | |
296 | ||
297 | let snapshot: BackupDir = match param.get("snapshot") { | |
bdfa6370 TL |
298 | Some(path) => match path.parse() { |
299 | Ok(v) => v, | |
300 | _ => return result, | |
301 | }, | |
f1a83e97 SR |
302 | _ => return result, |
303 | }; | |
304 | ||
9eb78407 | 305 | let query = json_object_to_query(json!({ |
f1a83e97 SR |
306 | "backup-type": snapshot.group().backup_type(), |
307 | "backup-id": snapshot.group().backup_id(), | |
308 | "backup-time": snapshot.backup_time(), | |
bdfa6370 TL |
309 | })) |
310 | .unwrap(); | |
f1a83e97 SR |
311 | |
312 | let path = format!("api2/json/admin/datastore/{}/files?{}", repo.store(), query); | |
313 | ||
314 | let data = try_get(&repo, &path).await; | |
315 | ||
316 | if let Some(list) = data.as_array() { | |
317 | for item in list { | |
318 | if let Some(filename) = item["filename"].as_str() { | |
319 | result.push(filename.to_owned()); | |
320 | } | |
321 | } | |
322 | } | |
323 | ||
324 | result | |
325 | } | |
326 | ||
327 | pub fn complete_archive_name(arg: &str, param: &HashMap<String, String>) -> Vec<String> { | |
328 | complete_server_file_name(arg, param) | |
329 | .iter() | |
9a37bd6c | 330 | .map(|v| pbs_tools::format::strip_server_file_extension(v).to_owned()) |
f1a83e97 SR |
331 | .collect() |
332 | } | |
333 | ||
334 | pub fn complete_pxar_archive_name(arg: &str, param: &HashMap<String, String>) -> Vec<String> { | |
335 | complete_server_file_name(arg, param) | |
336 | .iter() | |
337 | .filter_map(|name| { | |
338 | if name.ends_with(".pxar.didx") { | |
d7eedbd2 | 339 | Some(pbs_tools::format::strip_server_file_extension(name).to_owned()) |
f1a83e97 SR |
340 | } else { |
341 | None | |
342 | } | |
343 | }) | |
344 | .collect() | |
345 | } | |
346 | ||
347 | pub fn complete_img_archive_name(arg: &str, param: &HashMap<String, String>) -> Vec<String> { | |
348 | complete_server_file_name(arg, param) | |
349 | .iter() | |
350 | .filter_map(|name| { | |
351 | if name.ends_with(".img.fidx") { | |
d7eedbd2 | 352 | Some(pbs_tools::format::strip_server_file_extension(name).to_owned()) |
f1a83e97 SR |
353 | } else { |
354 | None | |
355 | } | |
356 | }) | |
357 | .collect() | |
358 | } | |
359 | ||
360 | pub fn complete_chunk_size(_arg: &str, _param: &HashMap<String, String>) -> Vec<String> { | |
f1a83e97 SR |
361 | let mut result = vec![]; |
362 | ||
363 | let mut size = 64; | |
364 | loop { | |
365 | result.push(size.to_string()); | |
366 | size *= 2; | |
bdfa6370 TL |
367 | if size > 4096 { |
368 | break; | |
369 | } | |
f1a83e97 SR |
370 | } |
371 | ||
372 | result | |
373 | } | |
374 | ||
375 | pub fn complete_auth_id(_arg: &str, param: &HashMap<String, String>) -> Vec<String> { | |
9a1b24b6 | 376 | proxmox_async::runtime::main(async { complete_auth_id_do(param).await }) |
f1a83e97 SR |
377 | } |
378 | ||
379 | pub async fn complete_auth_id_do(param: &HashMap<String, String>) -> Vec<String> { | |
f1a83e97 SR |
380 | let mut result = vec![]; |
381 | ||
382 | let repo = match extract_repository_from_map(param) { | |
383 | Some(v) => v, | |
384 | _ => return result, | |
385 | }; | |
386 | ||
387 | let data = try_get(&repo, "api2/json/access/users?include_tokens=true").await; | |
388 | ||
389 | if let Ok(parsed) = serde_json::from_value::<Vec<UserWithTokens>>(data) { | |
390 | for user in parsed { | |
391 | result.push(user.userid.to_string()); | |
392 | for token in user.tokens { | |
393 | result.push(token.tokenid.to_string()); | |
394 | } | |
395 | } | |
396 | }; | |
397 | ||
398 | result | |
399 | } | |
400 | ||
401 | pub fn complete_repository(_arg: &str, _param: &HashMap<String, String>) -> Vec<String> { | |
402 | let mut result = vec![]; | |
403 | ||
404 | let base = match BaseDirectories::with_prefix("proxmox-backup") { | |
405 | Ok(v) => v, | |
406 | _ => return result, | |
407 | }; | |
408 | ||
409 | // usually $HOME/.cache/proxmox-backup/repo-list | |
410 | let path = match base.place_cache_file("repo-list") { | |
411 | Ok(v) => v, | |
412 | _ => return result, | |
413 | }; | |
414 | ||
415 | let data = file_get_json(&path, None).unwrap_or_else(|_| json!({})); | |
416 | ||
417 | if let Some(map) = data.as_object() { | |
418 | for (repo, _count) in map { | |
419 | result.push(repo.to_owned()); | |
420 | } | |
421 | } | |
422 | ||
423 | result | |
424 | } | |
425 | ||
426 | pub fn complete_backup_source(arg: &str, param: &HashMap<String, String>) -> Vec<String> { | |
427 | let mut result = vec![]; | |
428 | ||
429 | let data: Vec<&str> = arg.splitn(2, ':').collect(); | |
430 | ||
431 | if data.len() != 2 { | |
432 | result.push(String::from("root.pxar:/")); | |
433 | result.push(String::from("etc.pxar:/etc")); | |
434 | return result; | |
435 | } | |
436 | ||
b3f279e2 | 437 | let files = complete_file_name(data[1], param); |
f1a83e97 SR |
438 | |
439 | for file in files { | |
440 | result.push(format!("{}:{}", data[0], file)); | |
441 | } | |
442 | ||
443 | result | |
444 | } | |
ff8945fd SR |
445 | |
446 | pub fn base_directories() -> Result<xdg::BaseDirectories, Error> { | |
447 | xdg::BaseDirectories::with_prefix("proxmox-backup").map_err(Error::from) | |
448 | } | |
449 | ||
450 | /// Convenience helper for better error messages: | |
451 | pub fn find_xdg_file( | |
452 | file_name: impl AsRef<std::path::Path>, | |
453 | description: &'static str, | |
454 | ) -> Result<Option<std::path::PathBuf>, Error> { | |
455 | let file_name = file_name.as_ref(); | |
456 | base_directories() | |
457 | .map(|base| base.find_config_file(file_name)) | |
458 | .with_context(|| format!("error searching for {}", description)) | |
459 | } | |
460 | ||
461 | pub fn place_xdg_file( | |
462 | file_name: impl AsRef<std::path::Path>, | |
463 | description: &'static str, | |
464 | ) -> Result<std::path::PathBuf, Error> { | |
465 | let file_name = file_name.as_ref(); | |
466 | base_directories() | |
467 | .and_then(|base| base.place_config_file(file_name).map_err(Error::from)) | |
468 | .with_context(|| format!("failed to place {} in xdg home", description)) | |
469 | } |