diff --git a/src/api2/pull.rs b/src/api2/pull.rs index 2a8ffe26..5cfa1f2a 100644 --- a/src/api2/pull.rs +++ b/src/api2/pull.rs @@ -256,6 +256,7 @@ pub async fn pull_group( list.sort_unstable_by(|a, b| a.backup_time.cmp(&b.backup_time)); let auth_info = client.login().await?; + let fingerprint = client.fingerprint(); let last_sync = tgt_store.last_successful_backup(group)?; @@ -269,11 +270,11 @@ pub async fn pull_group( if last_sync_time > backup_time { continue; } } - let new_client = HttpClient::new( - src_repo.host(), - src_repo.user(), - Some(auth_info.ticket.clone()) - )?; + let options = HttpClientOptions::new() + .password(Some(auth_info.ticket.clone())) + .fingerprint(fingerprint.clone()); + + let new_client = HttpClient::new(src_repo.host(), src_repo.user(), options)?; let reader = BackupReader::start( new_client, @@ -406,7 +407,11 @@ async fn pull ( let (remote_config, _digest) = remote::config()?; let remote: remote::Remote = remote_config.lookup("remote", &remote)?; - let client = HttpClient::new(&remote.host, &remote.userid, Some(remote.password.clone()))?; + let options = HttpClientOptions::new() + .password(Some(remote.password.clone())) + .fingerprint(remote.fingerprint.clone()); + + let client = HttpClient::new(&remote.host, &remote.userid, options)?; let _auth_info = client.login() // make sure we can auth .await .map_err(|err| format_err!("remote connection to '{}' failed - {}", remote.host, err))?; diff --git a/src/bin/download-speed.rs b/src/bin/download-speed.rs index c4b36217..7df0454e 100644 --- a/src/bin/download-speed.rs +++ b/src/bin/download-speed.rs @@ -4,7 +4,7 @@ use failure::*; use chrono::{DateTime, Utc}; -use proxmox_backup::client::{HttpClient, BackupReader}; +use proxmox_backup::client::{HttpClient, HttpClientOptions, BackupReader}; pub struct DummyWriter { bytes: usize, @@ -29,7 +29,11 @@ async fn run() -> Result<(), Error> { let username = "root@pam"; - let client = HttpClient::new(host, username, None)?; + let options = HttpClientOptions::new() + .interactive(true) + .ticket_cache(true); + + let client = HttpClient::new(host, username, options)?; let backup_time = "2019-06-28T10:49:48Z".parse::>()?; diff --git a/src/bin/proxmox-backup-client.rs b/src/bin/proxmox-backup-client.rs index 59921f66..30b538ec 100644 --- a/src/bin/proxmox-backup-client.rs +++ b/src/bin/proxmox-backup-client.rs @@ -163,6 +163,15 @@ fn complete_repository(_arg: &str, _param: &HashMap) -> Vec Result { + + let options = HttpClientOptions::new() + .interactive(true) + .ticket_cache(true); + + HttpClient::new(server, userid, options) +} + async fn view_task_result( client: HttpClient, result: Value, @@ -317,7 +326,7 @@ async fn list_backup_groups(param: Value) -> Result { let repo = extract_repository_from_value(¶m)?; - let client = HttpClient::new(repo.host(), repo.user(), None)?; + let client = connect(repo.host(), repo.user())?; let path = format!("api2/json/admin/datastore/{}/groups", repo.store()); @@ -411,7 +420,7 @@ async fn list_snapshots(param: Value) -> Result { let output_format = param["output-format"].as_str().unwrap_or("text").to_owned(); - let client = HttpClient::new(repo.host(), repo.user(), None)?; + let client = connect(repo.host(), repo.user())?; let group = if let Some(path) = param["group"].as_str() { Some(BackupGroup::parse(path)?) @@ -473,7 +482,7 @@ async fn forget_snapshots(param: Value) -> Result { let path = tools::required_string_param(¶m, "snapshot")?; let snapshot = BackupDir::parse(path)?; - let mut client = HttpClient::new(repo.host(), repo.user(), None)?; + let mut client = connect(repo.host(), repo.user())?; let path = format!("api2/json/admin/datastore/{}/snapshots", repo.store()); @@ -503,7 +512,7 @@ async fn api_login(param: Value) -> Result { let repo = extract_repository_from_value(¶m)?; - let client = HttpClient::new(repo.host(), repo.user(), None)?; + let client = connect(repo.host(), repo.user())?; client.login().await?; record_repository(&repo); @@ -563,7 +572,7 @@ async fn dump_catalog(param: Value) -> Result { } }; - let client = HttpClient::new(repo.host(), repo.user(), None)?; + let client = connect(repo.host(), repo.user())?; let client = BackupReader::start( client, @@ -633,7 +642,7 @@ async fn list_snapshot_files(param: Value) -> Result { let output_format = param["output-format"].as_str().unwrap_or("text").to_owned(); - let client = HttpClient::new(repo.host(), repo.user(), None)?; + let client = connect(repo.host(), repo.user())?; let path = format!("api2/json/admin/datastore/{}/files", repo.store()); @@ -682,7 +691,7 @@ async fn start_garbage_collection(param: Value) -> Result { let repo = extract_repository_from_value(¶m)?; let output_format = param["output-format"].as_str().unwrap_or("text").to_owned(); - let mut client = HttpClient::new(repo.host(), repo.user(), None)?; + let mut client = connect(repo.host(), repo.user())?; let path = format!("api2/json/admin/datastore/{}/gc", repo.store()); @@ -903,7 +912,7 @@ async fn create_backup( let backup_time = Utc.timestamp(backup_time_opt.unwrap_or_else(|| Utc::now().timestamp()), 0); - let client = HttpClient::new(repo.host(), repo.user(), None)?; + let client = connect(repo.host(), repo.user())?; record_repository(&repo); println!("Starting backup: {}/{}/{}", backup_type, backup_id, BackupDir::backup_time_to_string(backup_time)); @@ -1161,7 +1170,7 @@ async fn restore(param: Value) -> Result { let archive_name = tools::required_string_param(¶m, "archive-name")?; - let client = HttpClient::new(repo.host(), repo.user(), None)?; + let client = connect(repo.host(), repo.user())?; record_repository(&repo); @@ -1328,7 +1337,7 @@ async fn upload_log(param: Value) -> Result { let snapshot = tools::required_string_param(¶m, "snapshot")?; let snapshot = BackupDir::parse(snapshot)?; - let mut client = HttpClient::new(repo.host(), repo.user(), None)?; + let mut client = connect(repo.host(), repo.user())?; let keyfile = param["keyfile"].as_str().map(PathBuf::from); @@ -1388,7 +1397,7 @@ async fn prune(mut param: Value) -> Result { let repo = extract_repository_from_value(¶m)?; - let mut client = HttpClient::new(repo.host(), repo.user(), None)?; + let mut client = connect(repo.host(), repo.user())?; let path = format!("api2/json/admin/datastore/{}/prune", repo.store()); @@ -1433,7 +1442,7 @@ async fn status(param: Value) -> Result { let output_format = param["output-format"].as_str().unwrap_or("text").to_owned(); - let client = HttpClient::new(repo.host(), repo.user(), None)?; + let client = connect(repo.host(), repo.user())?; let path = format!("api2/json/admin/datastore/{}/status", repo.store()); @@ -1463,7 +1472,13 @@ async fn status(param: Value) -> Result { // like get, but simply ignore errors and return Null instead async fn try_get(repo: &BackupRepository, url: &str) -> Value { - let client = match HttpClient::new(repo.host(), repo.user(), None) { + + let options = HttpClientOptions::new() + .verify_cert(false) // fixme: set verify to true, but howto handle fingerprint ?? + .interactive(false) + .ticket_cache(true); + + let client = match HttpClient::new(repo.host(), repo.user(), options) { Ok(v) => v, _ => return Value::Null, }; @@ -1914,7 +1929,7 @@ async fn mount_do(param: Value, pipe: Option) -> Result { let repo = extract_repository_from_value(¶m)?; let archive_name = tools::required_string_param(¶m, "archive-name")?; let target = tools::required_string_param(¶m, "target")?; - let client = HttpClient::new(repo.host(), repo.user(), None)?; + let client = connect(repo.host(), repo.user())?; record_repository(&repo); @@ -2024,7 +2039,7 @@ async fn mount_do(param: Value, pipe: Option) -> Result { /// Shell to interactively inspect and restore snapshots. async fn catalog_shell(param: Value) -> Result<(), Error> { let repo = extract_repository_from_value(¶m)?; - let client = HttpClient::new(repo.host(), repo.user(), None)?; + let client = connect(repo.host(), repo.user())?; let path = tools::required_string_param(¶m, "snapshot")?; let archive_name = tools::required_string_param(¶m, "archive-name")?; @@ -2159,7 +2174,7 @@ async fn task_list(param: Value) -> Result { let output_format = param["output-format"].as_str().unwrap_or("text").to_owned(); let repo = extract_repository_from_value(¶m)?; - let client = HttpClient::new(repo.host(), repo.user(), None)?; + let client = connect(repo.host(), repo.user())?; let limit = param["limit"].as_u64().unwrap_or(50) as usize; @@ -2208,7 +2223,7 @@ async fn task_log(param: Value) -> Result { let repo = extract_repository_from_value(¶m)?; let upid = tools::required_string_param(¶m, "upid")?; - let client = HttpClient::new(repo.host(), repo.user(), None)?; + let client = connect(repo.host(), repo.user())?; display_task_log(client, upid, true).await?; @@ -2234,7 +2249,7 @@ async fn task_stop(param: Value) -> Result { let repo = extract_repository_from_value(¶m)?; let upid_str = tools::required_string_param(¶m, "upid")?; - let mut client = HttpClient::new(repo.host(), repo.user(), None)?; + let mut client = connect(repo.host(), repo.user())?; let path = format!("api2/json/nodes/localhost/tasks/{}", upid_str); let _ = client.delete(&path, None).await?; diff --git a/src/bin/proxmox-backup-manager.rs b/src/bin/proxmox-backup-manager.rs index 0dac2c16..5a28bdf5 100644 --- a/src/bin/proxmox-backup-manager.rs +++ b/src/bin/proxmox-backup-manager.rs @@ -34,11 +34,16 @@ fn connect() -> Result { let uid = nix::unistd::Uid::current(); + let mut options = HttpClientOptions::new() + .verify_cert(false); // not required for connection to localhost + let client = if uid.is_root() { let ticket = assemble_rsa_ticket(private_auth_key(), "PBS", Some("root@pam"), None)?; - HttpClient::new("localhost", "root@pam", Some(ticket))? + options = options.password(Some(ticket)); + HttpClient::new("localhost", "root@pam", options)? } else { - HttpClient::new("localhost", "root@pam", None)? + options = options.ticket_cache(true).interactive(true); + HttpClient::new("localhost", "root@pam", options)? }; Ok(client) @@ -473,12 +478,14 @@ pub fn complete_remote_datastore_name(_arg: &str, param: &HashMap Result { let username = "root@pam"; - let client = HttpClient::new(host, username, None)?; + let options = HttpClientOptions::new() + .interactive(true) + .ticket_cache(true); + + let client = HttpClient::new(host, username, options)?; let backup_time = chrono::Utc::now(); diff --git a/src/client/http_client.rs b/src/client/http_client.rs index 9fd2b0df..3d5c2de7 100644 --- a/src/client/http_client.rs +++ b/src/client/http_client.rs @@ -1,5 +1,6 @@ use std::io::Write; use std::task::{Context, Poll}; +use std::sync::{Arc, Mutex}; use chrono::Utc; use failure::*; @@ -9,7 +10,7 @@ use http::header::HeaderValue; use http::{Request, Response}; use hyper::Body; use hyper::client::{Client, HttpConnector}; -use openssl::ssl::{SslConnector, SslMethod}; +use openssl::{ssl::{SslConnector, SslMethod}, x509::X509StoreContextRef}; use serde_json::{json, Value}; use percent_encoding::percent_encode; use xdg::BaseDirectories; @@ -29,11 +30,59 @@ pub struct AuthInfo { pub token: String, } +pub struct HttpClientOptions { + password: Option, + fingerprint: Option, + interactive: bool, + ticket_cache: bool, + verify_cert: bool, +} + +impl HttpClientOptions { + + pub fn new() -> Self { + Self { + password: None, + fingerprint: None, + interactive: false, + ticket_cache: false, + verify_cert: true, + } + } + + pub fn password(mut self, password: Option) -> Self { + self.password = password; + self + } + + pub fn fingerprint(mut self, fingerprint: Option) -> Self { + self.fingerprint = fingerprint; + self + } + + pub fn interactive(mut self, interactive: bool) -> Self { + self.interactive = interactive; + self + } + + pub fn ticket_cache(mut self, ticket_cache: bool) -> Self { + self.ticket_cache = ticket_cache; + self + } + + pub fn verify_cert(mut self, verify_cert: bool) -> Self { + self.verify_cert = verify_cert; + self + } +} + /// HTTP(S) API client pub struct HttpClient { client: Client, server: String, + fingerprint: Arc>>, auth: BroadcastFuture, + _options: HttpClientOptions, } /// Delete stored ticket data (logout) @@ -116,23 +165,47 @@ fn load_ticket_info(server: &str, username: &str) -> Option<(String, String)> { impl HttpClient { - pub fn new(server: &str, username: &str, password: Option) -> Result { - let client = Self::build_client(); + pub fn new(server: &str, username: &str, mut options: HttpClientOptions) -> Result { + + let verified_fingerprint = Arc::new(Mutex::new(None)); + + let client = Self::build_client( + options.fingerprint.clone(), + options.interactive, + verified_fingerprint.clone(), + options.verify_cert, + ); + + let password = options.password.take(); let password = if let Some(password) = password { password - } else if let Some((ticket, _token)) = load_ticket_info(server, username) { - ticket } else { - Self::get_password(&username)? + let mut ticket_info = None; + if options.ticket_cache { + ticket_info = load_ticket_info(server, username); + } + if let Some((ticket, _token)) = ticket_info { + ticket + } else { + Self::get_password(&username, options.interactive)? + } }; - let login_future = Self::credentials(client.clone(), server.to_owned(), username.to_owned(), password); + let login_future = Self::credentials( + client.clone(), + server.to_owned(), + username.to_owned(), + password, + options.ticket_cache, + ); Ok(Self { client, server: String::from(server), + fingerprint: verified_fingerprint, auth: BroadcastFuture::new(Box::new(login_future)), + _options: options, }) } @@ -144,7 +217,12 @@ impl HttpClient { self.auth.listen().await } - fn get_password(_username: &str) -> Result { + /// Returns the optional fingerprint passed to the new() constructor. + pub fn fingerprint(&self) -> Option { + (*self.fingerprint.lock().unwrap()).clone() + } + + fn get_password(_username: &str, interactive: bool) -> Result { use std::env::VarError::*; match std::env::var("PBS_PASSWORD") { Ok(p) => return Ok(p), @@ -155,18 +233,90 @@ impl HttpClient { } // If we're on a TTY, query the user for a password - if tty::stdin_isatty() { + if interactive && tty::stdin_isatty() { return Ok(String::from_utf8(tty::read_password("Password: ")?)?); } bail!("no password input mechanism available"); } - fn build_client() -> Client { + fn verify_callback( + valid: bool, ctx: + &mut X509StoreContextRef, + expected_fingerprint: Option, + interactive: bool, + verified_fingerprint: Arc>>, + ) -> bool { + if valid { return true; } + + let cert = match ctx.current_cert() { + Some(cert) => cert, + None => return false, + }; + + let depth = ctx.error_depth(); + if depth != 0 { return false; } + + let fp = match cert.digest(openssl::hash::MessageDigest::sha256()) { + Ok(fp) => fp, + Err(_) => return false, // should not happen + }; + let fp_string = proxmox::tools::digest_to_hex(&fp); + let fp_string = fp_string.as_bytes().chunks(2).map(|v| std::str::from_utf8(v).unwrap()) + .collect::>().join(":"); + + if let Some(expected_fingerprint) = expected_fingerprint { + if expected_fingerprint == fp_string { + *verified_fingerprint.lock().unwrap() = Some(fp_string); + return true; + } else { + return false; + } + } + + // If we're on a TTY, query the user + if interactive && tty::stdin_isatty() { + println!("fingerprint: {}", fp_string); + loop { + print!("Want to trust? (y/n): "); + let _ = std::io::stdout().flush(); + let mut buf = [0u8; 1]; + use std::io::Read; + match std::io::stdin().read_exact(&mut buf) { + Ok(()) => { + if buf[0] == b'y' || buf[0] == b'Y' { + println!("TRUST {}", fp_string); + *verified_fingerprint.lock().unwrap() = Some(fp_string); + return true; + } else if buf[0] == b'n' || buf[0] == b'N' { + return false; + } + } + Err(_) => { + return false; + } + } + } + } + false + } + + fn build_client( + fingerprint: Option, + interactive: bool, + verified_fingerprint: Arc>>, + verify_cert: bool, + ) -> Client { let mut ssl_connector_builder = SslConnector::builder(SslMethod::tls()).unwrap(); - ssl_connector_builder.set_verify(openssl::ssl::SslVerifyMode::NONE); // fixme! + if verify_cert { + ssl_connector_builder.set_verify_callback(openssl::ssl::SslVerifyMode::PEER, move |valid, ctx| { + Self::verify_callback(valid, ctx, fingerprint.clone(), interactive, verified_fingerprint.clone()) + }); + } else { + ssl_connector_builder.set_verify(openssl::ssl::SslVerifyMode::NONE); + } let mut httpc = hyper::client::HttpConnector::new(); httpc.set_nodelay(true); // important for h2 download performance! @@ -339,6 +489,7 @@ impl HttpClient { server: String, username: String, password: String, + use_ticket_cache: bool, ) -> Result { let data = json!({ "username": username, "password": password }); let req = Self::request_builder(&server, "POST", "/api2/json/access/ticket", Some(data)).unwrap(); @@ -349,7 +500,9 @@ impl HttpClient { token: cred["data"]["CSRFPreventionToken"].as_str().unwrap().to_owned(), }; - let _ = store_ticket_info(&server, &auth.username, &auth.ticket, &auth.token); + if use_ticket_cache { + let _ = store_ticket_info(&server, &auth.username, &auth.ticket, &auth.token); + } Ok(auth) }