1use anyhow::{bail, Context, Result};
13use std::io::{Read, Write};
14use std::path::{Path, PathBuf};
15use tracing::debug;
16use url::Url;
17use zip::write::SimpleFileOptions;
18
19pub const REDACTED: &str = "***REDACTED***";
21
22const REPORT_PATH: &str = "api/contact/report/";
24
25pub fn log_dir() -> Result<PathBuf> {
27 Ok(dirs::cache_dir()
28 .context("Can't get cache dir")?
29 .join("cloudpub"))
30}
31
32fn is_secret_key(key: &str) -> bool {
38 let key = key.to_ascii_lowercase();
39 key.contains("token") || key.contains("password") || key.contains("secret")
40}
41
42fn redact_value(value: &mut toml::Value) {
43 match value {
44 toml::Value::Table(table) => {
45 for (key, item) in table.iter_mut() {
46 if is_secret_key(key) {
47 *item = toml::Value::String(REDACTED.to_string());
48 } else {
49 redact_value(item);
50 }
51 }
52 }
53 toml::Value::Array(items) => items.iter_mut().for_each(redact_value),
54 _ => {}
55 }
56}
57
58pub fn redact_config(text: &str) -> Result<String> {
63 let mut value: toml::Value =
64 toml::from_str(text).context("Failed to parse the config as TOML")?;
65 redact_value(&mut value);
66 toml::to_string_pretty(&value).context("Failed to serialize the redacted config")
67}
68
69#[derive(Debug, Clone, Default)]
71pub struct ReportMeta {
72 pub version: String,
73 pub platform: String,
74 pub agent_id: String,
75 pub hwid: String,
76 pub server: String,
77 pub message: Option<String>,
78}
79
80impl ReportMeta {
81 fn render(&self) -> String {
82 format!(
83 "version: {}\nplatform: {}\nagent_id: {}\nhwid: {}\nserver: {}\nmessage: {}\n",
84 self.version,
85 self.platform,
86 self.agent_id,
87 self.hwid,
88 self.server,
89 self.message.as_deref().unwrap_or("")
90 )
91 }
92}
93
94pub fn log_files(dir: &Path, stem: &str) -> Result<Vec<PathBuf>> {
97 if !dir.exists() {
98 return Ok(Vec::new());
99 }
100 let mut files: Vec<PathBuf> = std::fs::read_dir(dir)
101 .with_context(|| format!("Failed to read the log dir {:?}", dir))?
102 .filter_map(|entry| entry.ok())
103 .map(|entry| entry.path())
104 .filter(|path| {
105 path.is_file()
106 && path
107 .file_name()
108 .and_then(|name| name.to_str())
109 .map(|name| name.starts_with(stem))
110 .unwrap_or(false)
111 })
112 .collect();
113 files.sort();
114 Ok(files)
115}
116
117pub fn pack_report(
119 out: &Path,
120 logs: &[PathBuf],
121 config: Option<&Path>,
122 meta: &ReportMeta,
123) -> Result<u64> {
124 let file = std::fs::File::create(out)
125 .with_context(|| format!("Failed to create the archive {:?}", out))?;
126 let mut zip = zip::ZipWriter::new(file);
127 let options = SimpleFileOptions::default();
128
129 zip.start_file("metadata.txt", options)?;
130 zip.write_all(meta.render().as_bytes())?;
131
132 if let Some(path) = config {
133 match std::fs::read_to_string(path)
136 .context("unreadable")
137 .and_then(|text| redact_config(&text))
138 {
139 Ok(redacted) => {
140 zip.start_file("client.toml", options)?;
141 zip.write_all(redacted.as_bytes())?;
142 }
143 Err(err) => {
144 debug!("Config {:?} left out of the report: {}", path, err);
145 zip.start_file("client.toml.omitted", options)?;
146 zip.write_all(
147 format!("config omitted, it could not be read or parsed: {}\n", err).as_bytes(),
148 )?;
149 }
150 }
151 }
152
153 for path in logs {
154 let name = path
155 .file_name()
156 .and_then(|name| name.to_str())
157 .context("Log file has no usable name")?;
158 let mut source = std::fs::File::open(path)
159 .with_context(|| format!("Failed to read the log {:?}", path))?;
160 let mut buf = Vec::new();
161 source.read_to_end(&mut buf)?;
162 zip.start_file(format!("logs/{}", name), options)?;
163 zip.write_all(&buf)?;
164 }
165
166 zip.finish().context("Failed to finish the archive")?;
167 Ok(std::fs::metadata(out)
168 .context("Failed to size the archive")?
169 .len())
170}
171
172pub fn report_url(server: &Url) -> Result<Url> {
174 server
175 .join(REPORT_PATH)
176 .context("Failed to build the report URL")
177}
178
179pub async fn upload_report(
182 http: &reqwest::Client,
183 server: &Url,
184 token: &str,
185 archive: &Path,
186 meta: &ReportMeta,
187) -> Result<()> {
188 let bytes = std::fs::read(archive)
189 .with_context(|| format!("Failed to read the archive {:?}", archive))?;
190
191 let part = reqwest::multipart::Part::bytes(bytes)
192 .file_name("report.zip")
193 .mime_str("application/zip")
194 .context("Failed to build the archive part")?;
195
196 let mut form = reqwest::multipart::Form::new()
197 .part("archive", part)
198 .text("version", meta.version.clone())
199 .text("platform", meta.platform.clone())
200 .text("agent_guid", meta.agent_id.clone());
201
202 if let Some(message) = meta.message.clone() {
203 form = form.text("message", message);
204 }
205
206 let url = report_url(server)?;
207 let response = http
208 .post(url.clone())
209 .header("Authorization", format!("Token {}", token))
210 .multipart(form)
211 .send()
212 .await
213 .with_context(|| format!("Failed to send the report to {}", url))?;
214
215 let status = response.status();
216 if status.is_success() {
217 return Ok(());
218 }
219
220 if status == reqwest::StatusCode::PAYLOAD_TOO_LARGE {
223 bail!(crate::t!("report-too-large"));
224 }
225
226 let body = response.text().await.unwrap_or_default();
227 bail!(crate::t!("report-failed", "status" => status.as_u16().to_string(), "body" => body))
228}
229
230pub async fn send_report(
234 config: std::sync::Arc<parking_lot::RwLock<crate::config::ClientConfig>>,
235 message: Option<String>,
236) -> Result<()> {
237 let (server, token, agent_id, hwid, config_path) = {
238 let cfg = config.read();
239 (
240 cfg.server.clone(),
241 cfg.token.as_ref().map(|t| t.to_string()),
242 cfg.agent_id.clone(),
243 cfg.get_hwid(),
244 cfg.get_config_path().clone(),
245 )
246 };
247
248 let Some(token) = token else {
249 bail!(crate::t!("report-not-logged-in"));
250 };
251
252 let meta = ReportMeta {
253 version: cloudpub_common::VERSION.to_string(),
254 platform: cloudpub_common::utils::get_platform(),
255 agent_id,
256 hwid,
257 server: server.to_string(),
258 message,
259 };
260
261 let dir = log_dir()?;
262 let logs = log_files(&dir, "client.log")?;
263 let archive = std::env::temp_dir().join(format!(
264 "cloudpub-report-{}.zip",
265 uuid::Uuid::new_v4().simple()
266 ));
267
268 let size = pack_report(&archive, &logs, Some(&config_path), &meta)?;
269 debug!(
270 "Packed {} log file(s) into {:?} ({} bytes)",
271 logs.len(),
272 archive,
273 size
274 );
275
276 let http = crate::shell::build_http_client(&config)?;
277 let result = upload_report(&http, &server, &token, &archive, &meta).await;
278
279 std::fs::remove_file(&archive).ok();
282
283 result
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289 use std::io::Read;
290
291 const TOKEN: &str = "s3cr3t-token-value";
292
293 fn fixture_config() -> String {
294 format!(
295 r#"
296agent_id = "agent-1"
297server = "https://cloudpub.example/"
298token = "{token}"
299heartbeat_timeout = 30
300
301[transport]
302type = "websocket"
303
304[transport.tcp]
305nodelay = true
306
307[transport.tls]
308hostname = "cloudpub.example"
309pkcs12_password = "another-secret"
310"#,
311 token = TOKEN
312 )
313 }
314
315 fn entries(archive: &Path) -> Vec<(String, String)> {
316 let file = std::fs::File::open(archive).unwrap();
317 let mut zip = zip::ZipArchive::new(file).unwrap();
318 let mut out = Vec::new();
319 for i in 0..zip.len() {
320 let mut entry = zip.by_index(i).unwrap();
321 let name = entry.name().to_string();
322 let mut body = String::new();
323 entry.read_to_string(&mut body).unwrap();
324 out.push((name, body));
325 }
326 out
327 }
328
329 #[test]
333 fn packed_archive_contains_no_token_from_the_config() {
334 let dir = tempdir();
335 let config = dir.join("client.toml");
336 std::fs::write(&config, fixture_config()).unwrap();
337 let archive = dir.join("report.zip");
338
339 pack_report(&archive, &[], Some(&config), &ReportMeta::default()).unwrap();
340
341 let packed = entries(&archive);
342 let all = packed
343 .iter()
344 .map(|(name, body)| format!("{}\n{}", name, body))
345 .collect::<Vec<_>>()
346 .join("\n");
347
348 assert!(
349 !all.contains(TOKEN),
350 "the token leaked into the archive:\n{}",
351 all
352 );
353 assert!(
354 !all.contains("another-secret"),
355 "the pkcs12 password leaked into the archive:\n{}",
356 all
357 );
358 assert!(
359 all.contains(REDACTED),
360 "the secrets were dropped instead of redacted:\n{}",
361 all
362 );
363 assert!(all.contains("cloudpub.example"), "config body missing");
366 assert!(all.contains("agent-1"), "config body missing");
367 }
368
369 #[test]
370 fn redaction_reaches_nested_tables() {
371 let redacted = redact_config(&fixture_config()).unwrap();
372
373 assert!(!redacted.contains(TOKEN));
374 assert!(!redacted.contains("another-secret"));
375 assert_eq!(redacted.matches(REDACTED).count(), 2);
376 assert!(redacted.contains("heartbeat_timeout"));
377 }
378
379 #[test]
380 fn a_config_that_will_not_parse_is_left_out_rather_than_packed_raw() {
381 let dir = tempdir();
382 let config = dir.join("client.toml");
383 std::fs::write(&config, format!("token = \"{}\"\n[[[oops", TOKEN)).unwrap();
385 let archive = dir.join("report.zip");
386
387 pack_report(&archive, &[], Some(&config), &ReportMeta::default()).unwrap();
388
389 let packed = entries(&archive);
390 let all = packed
391 .iter()
392 .map(|(name, body)| format!("{}\n{}", name, body))
393 .collect::<Vec<_>>()
394 .join("\n");
395
396 assert!(!all.contains(TOKEN), "unparsable config leaked its token");
397 assert!(packed.iter().any(|(name, _)| name == "client.toml.omitted"));
398 assert!(!packed.iter().any(|(name, _)| name == "client.toml"));
399 }
400
401 #[test]
402 fn the_archive_carries_the_rolling_log_set_and_the_metadata() {
403 let dir = tempdir();
404 let logs = dir.join("logs");
405 std::fs::create_dir_all(&logs).unwrap();
406 std::fs::write(logs.join("client.log"), "current").unwrap();
407 std::fs::write(logs.join("client.log.1"), "rotated").unwrap();
408 std::fs::write(logs.join("unrelated.txt"), "not ours").unwrap();
409
410 let found = log_files(&logs, "client.log").unwrap();
411 assert_eq!(found.len(), 2, "found {:?}", found);
412
413 let archive = dir.join("report.zip");
414 let meta = ReportMeta {
415 version: "3.4.889".to_string(),
416 platform: "linux-x86_64".to_string(),
417 agent_id: "agent-1".to_string(),
418 hwid: "hw-1".to_string(),
419 server: "https://cloudpub.example/".to_string(),
420 message: Some("cannot publish".to_string()),
421 };
422 let size = pack_report(&archive, &found, None, &meta).unwrap();
423 assert!(size > 0);
424
425 let packed = entries(&archive);
426 let names: Vec<&str> = packed.iter().map(|(name, _)| name.as_str()).collect();
427 assert!(names.contains(&"logs/client.log"), "got {:?}", names);
428 assert!(names.contains(&"logs/client.log.1"), "got {:?}", names);
429 assert!(!names.contains(&"logs/unrelated.txt"), "got {:?}", names);
430
431 let metadata = &packed
432 .iter()
433 .find(|(name, _)| name == "metadata.txt")
434 .unwrap()
435 .1;
436 assert!(metadata.contains("3.4.889"));
437 assert!(metadata.contains("linux-x86_64"));
438 assert!(metadata.contains("cannot publish"));
439 }
440
441 #[test]
442 fn the_report_url_hangs_off_the_configured_server() {
443 let server: Url = "https://cloudpub.example/".parse().unwrap();
444 assert_eq!(
445 report_url(&server).unwrap().as_str(),
446 "https://cloudpub.example/api/contact/report/"
447 );
448 }
449
450 async fn one_shot(
454 status: &'static str,
455 body: &'static str,
456 ) -> (Url, tokio::task::JoinHandle<String>) {
457 use tokio::io::{AsyncReadExt, AsyncWriteExt};
458
459 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
460 let addr = listener.local_addr().unwrap();
461 let url: Url = format!("http://{}/", addr).parse().unwrap();
462
463 let handle = tokio::spawn(async move {
464 let (mut socket, _) = listener.accept().await.unwrap();
465 let mut raw = Vec::new();
466 let mut buf = [0u8; 4096];
467
468 let header_end = loop {
470 let n = socket.read(&mut buf).await.unwrap();
471 raw.extend_from_slice(&buf[..n]);
472 if let Some(pos) = raw.windows(4).position(|w| w == b"\r\n\r\n") {
473 break pos + 4;
474 }
475 if n == 0 {
476 break raw.len();
477 }
478 };
479
480 let headers = String::from_utf8_lossy(&raw[..header_end]).to_string();
481 let length: usize = headers
482 .lines()
483 .find(|line| line.to_ascii_lowercase().starts_with("content-length:"))
484 .and_then(|line| line.split(':').nth(1))
485 .and_then(|value| value.trim().parse().ok())
486 .unwrap_or(0);
487
488 while raw.len() < header_end + length {
489 let n = socket.read(&mut buf).await.unwrap();
490 if n == 0 {
491 break;
492 }
493 raw.extend_from_slice(&buf[..n]);
494 }
495
496 socket
497 .write_all(
498 format!(
499 "HTTP/1.1 {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
500 status,
501 body.len(),
502 body
503 )
504 .as_bytes(),
505 )
506 .await
507 .unwrap();
508 socket.flush().await.unwrap();
509
510 String::from_utf8_lossy(&raw).to_string()
511 });
512
513 (url, handle)
514 }
515
516 fn packed_fixture(dir: &Path) -> PathBuf {
517 let config = dir.join("client.toml");
518 std::fs::write(&config, fixture_config()).unwrap();
519 let archive = dir.join("report.zip");
520 pack_report(&archive, &[], Some(&config), &ReportMeta::default()).unwrap();
521 archive
522 }
523
524 fn meta() -> ReportMeta {
525 ReportMeta {
526 version: "3.4.889".to_string(),
527 platform: "linux-x86_64".to_string(),
528 agent_id: "agent-1".to_string(),
529 hwid: "hw-1".to_string(),
530 server: "http://127.0.0.1/".to_string(),
531 message: Some("cannot publish".to_string()),
532 }
533 }
534
535 #[tokio::test]
536 async fn the_report_is_posted_as_multipart_with_the_account_token() {
537 let dir = tempdir();
538 let archive = packed_fixture(&dir);
539 let (url, server) = one_shot("201 Created", "{}").await;
540
541 upload_report(&reqwest::Client::new(), &url, "tok-42", &archive, &meta())
542 .await
543 .unwrap();
544
545 let request = server.await.unwrap();
546 assert!(
547 request.starts_with("POST /api/contact/report/ "),
548 "wrong request line: {:?}",
549 request.lines().next()
550 );
551 assert!(
552 request.contains("authorization: Token tok-42"),
553 "{}",
554 request
555 );
556 assert!(request.contains("multipart/form-data"), "{}", request);
557 assert!(request.contains(r#"name="archive""#), "{}", request);
558 assert!(request.contains(r#"filename="report.zip""#), "{}", request);
559 assert!(request.contains(r#"name="version""#), "{}", request);
560 assert!(request.contains("3.4.889"), "{}", request);
561 assert!(request.contains(r#"name="agent_guid""#), "{}", request);
562 assert!(request.contains(r#"name="message""#), "{}", request);
563 assert!(request.contains("cannot publish"), "{}", request);
564 assert!(request.contains("PK"), "archive body missing");
566 assert!(
568 !request.contains(TOKEN),
569 "config token went out on the wire"
570 );
571 }
572
573 #[tokio::test]
574 async fn an_oversized_archive_is_reported_as_such() {
575 let dir = tempdir();
576 let archive = packed_fixture(&dir);
577 let (url, server) = one_shot("413 Payload Too Large", "{}").await;
578
579 let err = upload_report(&reqwest::Client::new(), &url, "tok-42", &archive, &meta())
580 .await
581 .unwrap_err();
582 server.await.unwrap();
583
584 assert_eq!(err.to_string(), crate::t!("report-too-large"));
585 }
586
587 #[tokio::test]
588 async fn any_other_refusal_names_the_status() {
589 let dir = tempdir();
590 let archive = packed_fixture(&dir);
591 let (url, server) = one_shot("403 Forbidden", "nope").await;
592
593 let err = upload_report(&reqwest::Client::new(), &url, "tok-42", &archive, &meta())
594 .await
595 .unwrap_err();
596 server.await.unwrap();
597
598 assert!(err.to_string().contains("403"), "{}", err);
599 }
600
601 fn tempdir() -> PathBuf {
602 let dir = std::env::temp_dir().join(format!(
603 "cloudpub-report-test-{}",
604 uuid::Uuid::new_v4().simple()
605 ));
606 std::fs::create_dir_all(&dir).unwrap();
607 dir
608 }
609}