Skip to main content

cloudpub_client/
report.rs

1//! Bug reports with the client's own logs attached - CLOUDPUB-577.
2//!
3//! Support used to open every ticket by asking the user for their logs. The
4//! client already keeps a rolling log set next to its cache, so `clo report`
5//! packs that set together with its own metadata and a **redacted** copy of the
6//! config, and uploads the archive to `/api/contact/report/`.
7//!
8//! The redaction is the part that matters: the config carries the account token
9//! and may carry a PKCS#12 password, and a bug report is read by a human and
10//! kept in our media store. Nothing secret may leave the machine.
11
12use 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
19/// What a redacted value is replaced with.
20pub const REDACTED: &str = "***REDACTED***";
21
22/// Path of the endpoint on the server, relative to the configured server URL.
23const REPORT_PATH: &str = "api/contact/report/";
24
25/// The rolling log set lives here; `init` in base.rs writes into the same dir.
26pub fn log_dir() -> Result<PathBuf> {
27    Ok(dirs::cache_dir()
28        .context("Can't get cache dir")?
29        .join("cloudpub"))
30}
31
32/// Is this config key's value a secret?
33///
34/// Matched on the key rather than on a list of known fields on purpose: a field
35/// added to `ClientConfig` later must not silently start leaking because nobody
36/// remembered to extend a list here.
37fn 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
58/// Strip every secret out of a client config before it is packed.
59///
60/// Fails rather than degrades: a config we cannot parse is a config we cannot
61/// prove is clean, and the caller leaves it out of the archive entirely.
62pub 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/// Client-side facts support needs to make sense of the logs.
70#[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
94/// The rolling log set: `client.log` plus whatever the appender rotated out of
95/// it. Sorted so the archive is reproducible.
96pub 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
117/// Pack a report archive and answer its size in bytes.
118pub 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        // A config that will not parse is left out with a note rather than
134        // packed raw - packing it raw is exactly how a token would escape.
135        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
172/// Where the report is POSTed for a given server URL.
173pub fn report_url(server: &Url) -> Result<Url> {
174    server
175        .join(REPORT_PATH)
176        .context("Failed to build the report URL")
177}
178
179/// Upload a packed archive. The caller supplies the HTTP client so the report
180/// travels over the same TLS setup (custom roots included) as everything else.
181pub 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    // The server answers 413 for an archive over its cap; that one is worth its
221    // own message, because the user can act on it.
222    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
230/// Pack the rolling logs plus a redacted config and upload them.
231///
232/// Says nothing on its own: the CLI prints, the GUI raises a toast.
233pub 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    // The archive is ours alone and may hold the user's logs - do not leave it
280    // lying in the temp dir once it has been sent.
281    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    /// THE test for this feature: the config carries the account token, and a
330    /// bug report is read by a human and stored on our side. The packed archive
331    /// must provably contain none of it - CLOUDPUB-577.
332    #[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        // The rest of the config still has to be there - it is what support
364        // reads the logs against.
365        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        // Invalid TOML that still carries the token.
384        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    /// A one-shot HTTP server: answers the first request with `status` and
451    /// hands back exactly what it received. Enough to prove the request the
452    /// client actually puts on the wire, without pulling in a mock-server dep.
453    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            // Read the headers, then exactly the announced body.
469            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        // The zip really travelled, not just its name.
565        assert!(request.contains("PK"), "archive body missing");
566        // And the account token still is not in the archive it carried.
567        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}