]>
Commit | Line | Data |
---|---|---|
597641fd DM |
1 | use failure::*; |
2 | ||
3 | use http::Uri; | |
4 | use hyper::Body; | |
5 | use hyper::client::Client; | |
6 | use hyper::rt::{self, Future}; | |
ba3a60b2 DM |
7 | use xdg::BaseDirectories; |
8 | use chrono::Utc; | |
597641fd | 9 | |
1fdb4c6f DM |
10 | use http::Request; |
11 | use futures::stream::Stream; | |
12 | ||
ba3a60b2 | 13 | use serde_json::{json, Value}; |
0dffe3f9 | 14 | use url::percent_encoding::{percent_encode, DEFAULT_ENCODE_SET}; |
1fdb4c6f | 15 | |
ba3a60b2 | 16 | use crate::tools::{self, tty}; |
56458d97 | 17 | |
151c6ce2 | 18 | /// HTTP(S) API client |
597641fd | 19 | pub struct HttpClient { |
0dffe3f9 | 20 | username: String, |
597641fd | 21 | server: String, |
a477d688 DM |
22 | |
23 | ticket: Option<String>, | |
24 | token: Option<String> | |
597641fd DM |
25 | } |
26 | ||
ba3a60b2 DM |
27 | fn store_ticket_info(server: &str, username: &str, ticket: &str, token: &str) -> Result<(), Error> { |
28 | ||
29 | let base = BaseDirectories::with_prefix("proxmox-backup")?; | |
30 | ||
31 | // usually /run/user/<uid>/... | |
32 | let path = base.place_runtime_file("tickets")?; | |
33 | ||
34 | let mode = nix::sys::stat::Mode::from_bits_truncate(0o0600); | |
35 | ||
49cf9f3d | 36 | let mut data = tools::file_get_json(&path, Some(json!({})))?; |
ba3a60b2 DM |
37 | |
38 | let now = Utc::now().timestamp(); | |
39 | ||
40 | data[server][username] = json!({ "timestamp": now, "ticket": ticket, "token": token}); | |
41 | ||
42 | let mut new_data = json!({}); | |
43 | ||
44 | let ticket_lifetime = tools::ticket::TICKET_LIFETIME - 60; | |
45 | ||
46 | let empty = serde_json::map::Map::new(); | |
47 | for (server, info) in data.as_object().unwrap_or(&empty) { | |
48 | for (_user, uinfo) in info.as_object().unwrap_or(&empty) { | |
49 | if let Some(timestamp) = uinfo["timestamp"].as_i64() { | |
50 | let age = now - timestamp; | |
51 | if age < ticket_lifetime { | |
52 | new_data[server][username] = uinfo.clone(); | |
53 | } | |
54 | } | |
55 | } | |
56 | } | |
57 | ||
58 | tools::file_set_contents(path, new_data.to_string().as_bytes(), Some(mode))?; | |
59 | ||
60 | Ok(()) | |
61 | } | |
62 | ||
63 | fn load_ticket_info(server: &str, username: &str) -> Option<(String, String)> { | |
64 | let base = match BaseDirectories::with_prefix("proxmox-backup") { | |
65 | Ok(b) => b, | |
66 | _ => return None, | |
67 | }; | |
68 | ||
69 | // usually /run/user/<uid>/... | |
70 | let path = match base.place_runtime_file("tickets") { | |
71 | Ok(p) => p, | |
72 | _ => return None, | |
73 | }; | |
74 | ||
49cf9f3d DM |
75 | let data = match tools::file_get_json(&path, None) { |
76 | Ok(v) => v, | |
77 | _ => return None, | |
78 | }; | |
ba3a60b2 DM |
79 | |
80 | let now = Utc::now().timestamp(); | |
81 | ||
82 | let ticket_lifetime = tools::ticket::TICKET_LIFETIME - 60; | |
83 | ||
84 | if let Some(uinfo) = data[server][username].as_object() { | |
85 | if let Some(timestamp) = uinfo["timestamp"].as_i64() { | |
86 | let age = now - timestamp; | |
87 | if age < ticket_lifetime { | |
88 | let ticket = match uinfo["ticket"].as_str() { | |
89 | Some(t) => t, | |
90 | None => return None, | |
91 | }; | |
92 | let token = match uinfo["token"].as_str() { | |
93 | Some(t) => t, | |
94 | None => return None, | |
21ea0158 | 95 | }; |
ba3a60b2 DM |
96 | return Some((ticket.to_owned(), token.to_owned())); |
97 | } | |
98 | } | |
99 | } | |
100 | ||
101 | None | |
102 | } | |
103 | ||
597641fd DM |
104 | impl HttpClient { |
105 | ||
0dffe3f9 | 106 | pub fn new(server: &str, username: &str) -> Self { |
597641fd DM |
107 | Self { |
108 | server: String::from(server), | |
0dffe3f9 | 109 | username: String::from(username), |
a477d688 DM |
110 | ticket: None, |
111 | token: None, | |
597641fd DM |
112 | } |
113 | } | |
114 | ||
56458d97 WB |
115 | fn get_password(&self) -> Result<String, Error> { |
116 | use std::env::VarError::*; | |
117 | match std::env::var("PBS_PASSWORD") { | |
118 | Ok(p) => return Ok(p), | |
119 | Err(NotUnicode(_)) => bail!("PBS_PASSWORD contains bad characters"), | |
120 | Err(NotPresent) => { | |
121 | // Try another method | |
122 | } | |
123 | } | |
124 | ||
125 | // If we're on a TTY, query the user for a password | |
126 | if tty::stdin_isatty() { | |
127 | return Ok(String::from_utf8(tty::read_password("Password: ")?)?); | |
128 | } | |
129 | ||
130 | bail!("no password input mechanism available"); | |
131 | } | |
132 | ||
a6b75513 | 133 | fn build_client() -> Result<Client<hyper_tls::HttpsConnector<hyper::client::HttpConnector>>, Error> { |
4a3f6517 WB |
134 | let mut builder = native_tls::TlsConnector::builder(); |
135 | // FIXME: We need a CLI option for this! | |
136 | builder.danger_accept_invalid_certs(true); | |
137 | let tlsconnector = builder.build()?; | |
138 | let mut httpc = hyper::client::HttpConnector::new(1); | |
139 | httpc.enforce_http(false); // we want https... | |
140 | let mut https = hyper_tls::HttpsConnector::from((httpc, tlsconnector)); | |
141 | https.https_only(true); // force it! | |
142 | let client = Client::builder().build::<_, Body>(https); | |
a6b75513 DM |
143 | Ok(client) |
144 | } | |
145 | ||
146 | fn run_request( | |
147 | request: Request<Body>, | |
148 | ) -> Result<Value, Error> { | |
149 | let client = Self::build_client()?; | |
597641fd | 150 | |
1adb353d | 151 | let (tx, rx) = std::sync::mpsc::channel(); |
597641fd DM |
152 | |
153 | let future = client | |
154 | .request(request) | |
fc7f0352 | 155 | .map_err(Error::from) |
597641fd DM |
156 | .and_then(|resp| { |
157 | ||
158 | let status = resp.status(); | |
159 | ||
fc7f0352 | 160 | resp.into_body().concat2().map_err(Error::from) |
597641fd DM |
161 | .and_then(move |data| { |
162 | ||
163 | let text = String::from_utf8(data.to_vec()).unwrap(); | |
164 | if status.is_success() { | |
1fdb4c6f DM |
165 | if text.len() > 0 { |
166 | let value: Value = serde_json::from_str(&text)?; | |
167 | Ok(value) | |
168 | } else { | |
169 | Ok(Value::Null) | |
170 | } | |
597641fd | 171 | } else { |
1fdb4c6f | 172 | bail!("HTTP Error {}: {}", status, text); |
597641fd | 173 | } |
597641fd DM |
174 | }) |
175 | }) | |
1adb353d DM |
176 | .then(move |res| { |
177 | tx.send(res).unwrap(); | |
178 | Ok(()) | |
597641fd DM |
179 | }); |
180 | ||
181 | // drop client, else client keeps connectioon open (keep-alive feature) | |
182 | drop(client); | |
183 | ||
184 | rt::run(future); | |
185 | ||
1adb353d | 186 | rx.recv().unwrap() |
1fdb4c6f DM |
187 | } |
188 | ||
a6b75513 DM |
189 | fn run_download( |
190 | request: Request<Body>, | |
191 | mut output: Box<dyn std::io::Write + Send>, | |
192 | ) -> Result<(), Error> { | |
193 | let client = Self::build_client()?; | |
194 | ||
195 | let (tx, rx) = std::sync::mpsc::channel(); | |
196 | ||
197 | let future = client | |
198 | .request(request) | |
199 | .map_err(Error::from) | |
200 | .and_then(move |resp| { | |
201 | ||
b005ed12 | 202 | let _status = resp.status(); // fixme: ?? |
a6b75513 DM |
203 | |
204 | resp.into_body() | |
205 | .map_err(Error::from) | |
206 | .for_each(move |chunk| { | |
207 | output.write_all(&chunk)?; | |
208 | Ok(()) | |
209 | }) | |
210 | ||
211 | }) | |
212 | .then(move |res| { | |
213 | tx.send(res).unwrap(); | |
214 | Ok(()) | |
215 | }); | |
216 | ||
217 | // drop client, else client keeps connectioon open (keep-alive feature) | |
218 | drop(client); | |
219 | ||
220 | rt::run(future); | |
221 | ||
222 | rx.recv().unwrap() | |
223 | } | |
224 | ||
225 | pub fn download(&mut self, path: &str, output: Box<dyn std::io::Write + Send>) -> Result<(), Error> { | |
226 | ||
227 | let path = path.trim_matches('/'); | |
228 | let url: Uri = format!("https://{}:8007/{}", self.server, path).parse()?; | |
229 | ||
230 | let (ticket, _token) = self.login()?; | |
231 | ||
232 | let enc_ticket = percent_encode(ticket.as_bytes(), DEFAULT_ENCODE_SET).to_string(); | |
233 | ||
234 | let request = Request::builder() | |
235 | .method("GET") | |
236 | .uri(url) | |
237 | .header("User-Agent", "proxmox-backup-client/1.0") | |
238 | .header("Cookie", format!("PBSAuthCookie={}", enc_ticket)) | |
239 | .body(Body::empty())?; | |
240 | ||
241 | Self::run_download(request, output) | |
242 | } | |
243 | ||
a477d688 | 244 | pub fn get(&mut self, path: &str) -> Result<Value, Error> { |
1fdb4c6f | 245 | |
591f570b | 246 | let path = path.trim_matches('/'); |
4a3f6517 | 247 | let url: Uri = format!("https://{}:8007/{}", self.server, path).parse()?; |
1fdb4c6f | 248 | |
a4a5c78c | 249 | let (ticket, _token) = self.login()?; |
0dffe3f9 DM |
250 | |
251 | let enc_ticket = percent_encode(ticket.as_bytes(), DEFAULT_ENCODE_SET).to_string(); | |
252 | ||
1fdb4c6f DM |
253 | let request = Request::builder() |
254 | .method("GET") | |
255 | .uri(url) | |
256 | .header("User-Agent", "proxmox-backup-client/1.0") | |
024f11bb DM |
257 | .header("Cookie", format!("PBSAuthCookie={}", enc_ticket)) |
258 | .body(Body::empty())?; | |
259 | ||
260 | Self::run_request(request) | |
261 | } | |
262 | ||
263 | /// like get(), but use existing credentials (never asks for password). | |
264 | /// this simply fails when there is no ticket | |
265 | pub fn try_get(&mut self, path: &str) -> Result<Value, Error> { | |
266 | ||
267 | let path = path.trim_matches('/'); | |
268 | let url: Uri = format!("https://{}:8007/{}", self.server, path).parse()?; | |
269 | ||
270 | let mut credentials = None; | |
271 | ||
272 | if let Some(ref ticket) = self.ticket { | |
273 | if let Some(ref token) = self.token { | |
274 | credentials = Some((ticket.clone(), token.clone())); | |
275 | } | |
276 | } | |
277 | ||
278 | if credentials == None { | |
279 | if let Some((ticket, token)) = load_ticket_info(&self.server, &self.username) { | |
280 | credentials = Some((ticket.clone(), token.clone())); | |
281 | } | |
282 | } | |
283 | ||
284 | if credentials == None { | |
285 | bail!("unable to get credentials"); | |
286 | } | |
287 | ||
288 | let (ticket, _token) = credentials.unwrap(); | |
289 | ||
290 | let enc_ticket = percent_encode(ticket.as_bytes(), DEFAULT_ENCODE_SET).to_string(); | |
291 | ||
292 | let request = Request::builder() | |
293 | .method("GET") | |
294 | .uri(url) | |
295 | .header("User-Agent", "proxmox-backup-client/1.0") | |
0dffe3f9 | 296 | .header("Cookie", format!("PBSAuthCookie={}", enc_ticket)) |
1fdb4c6f DM |
297 | .body(Body::empty())?; |
298 | ||
299 | Self::run_request(request) | |
81da38c1 DM |
300 | } |
301 | ||
6f62c924 DM |
302 | pub fn delete(&mut self, path: &str) -> Result<Value, Error> { |
303 | ||
304 | let path = path.trim_matches('/'); | |
305 | let url: Uri = format!("https://{}:8007/{}", self.server, path).parse()?; | |
306 | ||
307 | let (ticket, token) = self.login()?; | |
308 | ||
309 | let enc_ticket = percent_encode(ticket.as_bytes(), DEFAULT_ENCODE_SET).to_string(); | |
310 | ||
311 | let request = Request::builder() | |
312 | .method("DELETE") | |
313 | .uri(url) | |
314 | .header("User-Agent", "proxmox-backup-client/1.0") | |
315 | .header("Cookie", format!("PBSAuthCookie={}", enc_ticket)) | |
316 | .header("CSRFPreventionToken", token) | |
317 | .body(Body::empty())?; | |
318 | ||
319 | Self::run_request(request) | |
320 | } | |
321 | ||
a477d688 | 322 | pub fn post(&mut self, path: &str) -> Result<Value, Error> { |
81da38c1 DM |
323 | |
324 | let path = path.trim_matches('/'); | |
325 | let url: Uri = format!("https://{}:8007/{}", self.server, path).parse()?; | |
326 | ||
327 | let (ticket, token) = self.login()?; | |
328 | ||
329 | let enc_ticket = percent_encode(ticket.as_bytes(), DEFAULT_ENCODE_SET).to_string(); | |
330 | ||
331 | let request = Request::builder() | |
332 | .method("POST") | |
333 | .uri(url) | |
334 | .header("User-Agent", "proxmox-backup-client/1.0") | |
335 | .header("Cookie", format!("PBSAuthCookie={}", enc_ticket)) | |
336 | .header("CSRFPreventionToken", token) | |
0ffbccce | 337 | .header(hyper::header::CONTENT_TYPE, "application/x-www-form-urlencoded") |
81da38c1 DM |
338 | .body(Body::empty())?; |
339 | ||
340 | Self::run_request(request) | |
1fdb4c6f DM |
341 | } |
342 | ||
0ffbccce DM |
343 | pub fn post_json(&mut self, path: &str, data: Value) -> Result<Value, Error> { |
344 | ||
345 | let path = path.trim_matches('/'); | |
346 | let url: Uri = format!("https://{}:8007/{}", self.server, path).parse()?; | |
347 | ||
348 | let (ticket, token) = self.login()?; | |
349 | ||
350 | let enc_ticket = percent_encode(ticket.as_bytes(), DEFAULT_ENCODE_SET).to_string(); | |
351 | ||
352 | let request = Request::builder() | |
353 | .method("POST") | |
354 | .uri(url) | |
355 | .header("User-Agent", "proxmox-backup-client/1.0") | |
356 | .header("Cookie", format!("PBSAuthCookie={}", enc_ticket)) | |
357 | .header("CSRFPreventionToken", token) | |
358 | .header(hyper::header::CONTENT_TYPE, "application/json") | |
359 | .body(Body::from(data.to_string()))?; | |
360 | ||
361 | Self::run_request(request) | |
362 | } | |
363 | ||
ba3a60b2 | 364 | fn try_login(&mut self, password: &str) -> Result<(String, String), Error> { |
0dffe3f9 DM |
365 | |
366 | let url: Uri = format!("https://{}:8007/{}", self.server, "/api2/json/access/ticket").parse()?; | |
367 | ||
0dffe3f9 DM |
368 | let query = url::form_urlencoded::Serializer::new(String::new()) |
369 | .append_pair("username", &self.username) | |
370 | .append_pair("password", &password) | |
371 | .finish(); | |
372 | ||
373 | let request = Request::builder() | |
374 | .method("POST") | |
375 | .uri(url) | |
376 | .header("User-Agent", "proxmox-backup-client/1.0") | |
377 | .header("Content-Type", "application/x-www-form-urlencoded") | |
378 | .body(Body::from(query))?; | |
379 | ||
380 | let auth_res = Self::run_request(request)?; | |
381 | ||
382 | let ticket = match auth_res["data"]["ticket"].as_str() { | |
383 | Some(t) => t, | |
384 | None => bail!("got unexpected respose for login request."), | |
385 | }; | |
a4a5c78c DM |
386 | let token = match auth_res["data"]["CSRFPreventionToken"].as_str() { |
387 | Some(t) => t, | |
388 | None => bail!("got unexpected respose for login request."), | |
389 | }; | |
0dffe3f9 | 390 | |
ba3a60b2 DM |
391 | Ok((ticket.to_owned(), token.to_owned())) |
392 | } | |
393 | ||
394 | pub fn login(&mut self) -> Result<(String, String), Error> { | |
395 | ||
396 | if let Some(ref ticket) = self.ticket { | |
397 | if let Some(ref token) = self.token { | |
398 | return Ok((ticket.clone(), token.clone())); | |
399 | } | |
400 | } | |
401 | ||
402 | if let Some((ticket, _token)) = load_ticket_info(&self.server, &self.username) { | |
403 | if let Ok((ticket, token)) = self.try_login(&ticket) { | |
404 | let _ = store_ticket_info(&self.server, &self.username, &ticket, &token); | |
405 | return Ok((ticket.to_owned(), token.to_owned())) | |
406 | } | |
407 | } | |
408 | ||
409 | let password = self.get_password()?; | |
410 | let (ticket, token) = self.try_login(&password)?; | |
411 | ||
412 | let _ = store_ticket_info(&self.server, &self.username, &ticket, &token); | |
a477d688 | 413 | |
a4a5c78c | 414 | Ok((ticket.to_owned(), token.to_owned())) |
0dffe3f9 DM |
415 | } |
416 | ||
a477d688 | 417 | pub fn upload(&mut self, content_type: &str, body: Body, path: &str) -> Result<Value, Error> { |
1fdb4c6f | 418 | |
591f570b | 419 | let path = path.trim_matches('/'); |
4a3f6517 | 420 | let url: Uri = format!("https://{}:8007/{}", self.server, path).parse()?; |
1fdb4c6f | 421 | |
a4a5c78c | 422 | let (ticket, token) = self.login()?; |
0dffe3f9 DM |
423 | |
424 | let enc_ticket = percent_encode(ticket.as_bytes(), DEFAULT_ENCODE_SET).to_string(); | |
425 | ||
1fdb4c6f DM |
426 | let request = Request::builder() |
427 | .method("POST") | |
428 | .uri(url) | |
429 | .header("User-Agent", "proxmox-backup-client/1.0") | |
0dffe3f9 | 430 | .header("Cookie", format!("PBSAuthCookie={}", enc_ticket)) |
a4a5c78c | 431 | .header("CSRFPreventionToken", token) |
1fdb4c6f DM |
432 | .header("Content-Type", content_type) |
433 | .body(body)?; | |
434 | ||
435 | Self::run_request(request) | |
597641fd DM |
436 | } |
437 | } |