use anyhow::{bail, Context, Result};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use tracing::debug;
use url::Url;
use zip::write::SimpleFileOptions;
pub const REDACTED: &str = "***REDACTED***";
const REPORT_PATH: &str = "api/contact/report/";
pub fn log_dir() -> Result<PathBuf> {
Ok(dirs::cache_dir()
.context("Can't get cache dir")?
.join("cloudpub"))
}
fn is_secret_key(key: &str) -> bool {
let key = key.to_ascii_lowercase();
key.contains("token") || key.contains("password") || key.contains("secret")
}
fn redact_value(value: &mut toml::Value) {
match value {
toml::Value::Table(table) => {
for (key, item) in table.iter_mut() {
if is_secret_key(key) {
*item = toml::Value::String(REDACTED.to_string());
} else {
redact_value(item);
}
}
}
toml::Value::Array(items) => items.iter_mut().for_each(redact_value),
_ => {}
}
}
pub fn redact_config(text: &str) -> Result<String> {
let mut value: toml::Value =
toml::from_str(text).context("Failed to parse the config as TOML")?;
redact_value(&mut value);
toml::to_string_pretty(&value).context("Failed to serialize the redacted config")
}
#[derive(Debug, Clone, Default)]
pub struct ReportMeta {
pub version: String,
pub platform: String,
pub agent_id: String,
pub hwid: String,
pub server: String,
pub message: Option<String>,
}
impl ReportMeta {
fn render(&self) -> String {
format!(
"version: {}\nplatform: {}\nagent_id: {}\nhwid: {}\nserver: {}\nmessage: {}\n",
self.version,
self.platform,
self.agent_id,
self.hwid,
self.server,
self.message.as_deref().unwrap_or("")
)
}
}
pub fn log_files(dir: &Path, stem: &str) -> Result<Vec<PathBuf>> {
if !dir.exists() {
return Ok(Vec::new());
}
let mut files: Vec<PathBuf> = std::fs::read_dir(dir)
.with_context(|| format!("Failed to read the log dir {:?}", dir))?
.filter_map(|entry| entry.ok())
.map(|entry| entry.path())
.filter(|path| {
path.is_file()
&& path
.file_name()
.and_then(|name| name.to_str())
.map(|name| name.starts_with(stem))
.unwrap_or(false)
})
.collect();
files.sort();
Ok(files)
}
pub fn pack_report(
out: &Path,
logs: &[PathBuf],
config: Option<&Path>,
meta: &ReportMeta,
) -> Result<u64> {
let file = std::fs::File::create(out)
.with_context(|| format!("Failed to create the archive {:?}", out))?;
let mut zip = zip::ZipWriter::new(file);
let options = SimpleFileOptions::default();
zip.start_file("metadata.txt", options)?;
zip.write_all(meta.render().as_bytes())?;
if let Some(path) = config {
match std::fs::read_to_string(path)
.context("unreadable")
.and_then(|text| redact_config(&text))
{
Ok(redacted) => {
zip.start_file("client.toml", options)?;
zip.write_all(redacted.as_bytes())?;
}
Err(err) => {
debug!("Config {:?} left out of the report: {}", path, err);
zip.start_file("client.toml.omitted", options)?;
zip.write_all(
format!("config omitted, it could not be read or parsed: {}\n", err).as_bytes(),
)?;
}
}
}
for path in logs {
let name = path
.file_name()
.and_then(|name| name.to_str())
.context("Log file has no usable name")?;
let mut source = std::fs::File::open(path)
.with_context(|| format!("Failed to read the log {:?}", path))?;
let mut buf = Vec::new();
source.read_to_end(&mut buf)?;
zip.start_file(format!("logs/{}", name), options)?;
zip.write_all(&buf)?;
}
zip.finish().context("Failed to finish the archive")?;
Ok(std::fs::metadata(out)
.context("Failed to size the archive")?
.len())
}
pub fn report_url(server: &Url) -> Result<Url> {
server
.join(REPORT_PATH)
.context("Failed to build the report URL")
}
pub async fn upload_report(
http: &reqwest::Client,
server: &Url,
token: &str,
archive: &Path,
meta: &ReportMeta,
) -> Result<()> {
let bytes = std::fs::read(archive)
.with_context(|| format!("Failed to read the archive {:?}", archive))?;
let part = reqwest::multipart::Part::bytes(bytes)
.file_name("report.zip")
.mime_str("application/zip")
.context("Failed to build the archive part")?;
let mut form = reqwest::multipart::Form::new()
.part("archive", part)
.text("version", meta.version.clone())
.text("platform", meta.platform.clone())
.text("agent_guid", meta.agent_id.clone());
if let Some(message) = meta.message.clone() {
form = form.text("message", message);
}
let url = report_url(server)?;
let response = http
.post(url.clone())
.header("Authorization", format!("Token {}", token))
.multipart(form)
.send()
.await
.with_context(|| format!("Failed to send the report to {}", url))?;
let status = response.status();
if status.is_success() {
return Ok(());
}
if status == reqwest::StatusCode::PAYLOAD_TOO_LARGE {
bail!(crate::t!("report-too-large"));
}
let body = response.text().await.unwrap_or_default();
bail!(crate::t!("report-failed", "status" => status.as_u16().to_string(), "body" => body))
}
pub async fn send_report(
config: std::sync::Arc<parking_lot::RwLock<crate::config::ClientConfig>>,
message: Option<String>,
) -> Result<()> {
let (server, token, agent_id, hwid, config_path) = {
let cfg = config.read();
(
cfg.server.clone(),
cfg.token.as_ref().map(|t| t.to_string()),
cfg.agent_id.clone(),
cfg.get_hwid(),
cfg.get_config_path().clone(),
)
};
let Some(token) = token else {
bail!(crate::t!("report-not-logged-in"));
};
let meta = ReportMeta {
version: cloudpub_common::VERSION.to_string(),
platform: cloudpub_common::utils::get_platform(),
agent_id,
hwid,
server: server.to_string(),
message,
};
let dir = log_dir()?;
let logs = log_files(&dir, "client.log")?;
let archive = std::env::temp_dir().join(format!(
"cloudpub-report-{}.zip",
uuid::Uuid::new_v4().simple()
));
let size = pack_report(&archive, &logs, Some(&config_path), &meta)?;
debug!(
"Packed {} log file(s) into {:?} ({} bytes)",
logs.len(),
archive,
size
);
let http = crate::shell::build_http_client(&config)?;
let result = upload_report(&http, &server, &token, &archive, &meta).await;
std::fs::remove_file(&archive).ok();
result
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Read;
const TOKEN: &str = "s3cr3t-token-value";
fn fixture_config() -> String {
format!(
r#"
agent_id = "agent-1"
server = "https://cloudpub.example/"
token = "{token}"
heartbeat_timeout = 30
[transport]
type = "websocket"
[transport.tcp]
nodelay = true
[transport.tls]
hostname = "cloudpub.example"
pkcs12_password = "another-secret"
"#,
token = TOKEN
)
}
fn entries(archive: &Path) -> Vec<(String, String)> {
let file = std::fs::File::open(archive).unwrap();
let mut zip = zip::ZipArchive::new(file).unwrap();
let mut out = Vec::new();
for i in 0..zip.len() {
let mut entry = zip.by_index(i).unwrap();
let name = entry.name().to_string();
let mut body = String::new();
entry.read_to_string(&mut body).unwrap();
out.push((name, body));
}
out
}
#[test]
fn packed_archive_contains_no_token_from_the_config() {
let dir = tempdir();
let config = dir.join("client.toml");
std::fs::write(&config, fixture_config()).unwrap();
let archive = dir.join("report.zip");
pack_report(&archive, &[], Some(&config), &ReportMeta::default()).unwrap();
let packed = entries(&archive);
let all = packed
.iter()
.map(|(name, body)| format!("{}\n{}", name, body))
.collect::<Vec<_>>()
.join("\n");
assert!(
!all.contains(TOKEN),
"the token leaked into the archive:\n{}",
all
);
assert!(
!all.contains("another-secret"),
"the pkcs12 password leaked into the archive:\n{}",
all
);
assert!(
all.contains(REDACTED),
"the secrets were dropped instead of redacted:\n{}",
all
);
assert!(all.contains("cloudpub.example"), "config body missing");
assert!(all.contains("agent-1"), "config body missing");
}
#[test]
fn redaction_reaches_nested_tables() {
let redacted = redact_config(&fixture_config()).unwrap();
assert!(!redacted.contains(TOKEN));
assert!(!redacted.contains("another-secret"));
assert_eq!(redacted.matches(REDACTED).count(), 2);
assert!(redacted.contains("heartbeat_timeout"));
}
#[test]
fn a_config_that_will_not_parse_is_left_out_rather_than_packed_raw() {
let dir = tempdir();
let config = dir.join("client.toml");
std::fs::write(&config, format!("token = \"{}\"\n[[[oops", TOKEN)).unwrap();
let archive = dir.join("report.zip");
pack_report(&archive, &[], Some(&config), &ReportMeta::default()).unwrap();
let packed = entries(&archive);
let all = packed
.iter()
.map(|(name, body)| format!("{}\n{}", name, body))
.collect::<Vec<_>>()
.join("\n");
assert!(!all.contains(TOKEN), "unparsable config leaked its token");
assert!(packed.iter().any(|(name, _)| name == "client.toml.omitted"));
assert!(!packed.iter().any(|(name, _)| name == "client.toml"));
}
#[test]
fn the_archive_carries_the_rolling_log_set_and_the_metadata() {
let dir = tempdir();
let logs = dir.join("logs");
std::fs::create_dir_all(&logs).unwrap();
std::fs::write(logs.join("client.log"), "current").unwrap();
std::fs::write(logs.join("client.log.1"), "rotated").unwrap();
std::fs::write(logs.join("unrelated.txt"), "not ours").unwrap();
let found = log_files(&logs, "client.log").unwrap();
assert_eq!(found.len(), 2, "found {:?}", found);
let archive = dir.join("report.zip");
let meta = ReportMeta {
version: "3.4.889".to_string(),
platform: "linux-x86_64".to_string(),
agent_id: "agent-1".to_string(),
hwid: "hw-1".to_string(),
server: "https://cloudpub.example/".to_string(),
message: Some("cannot publish".to_string()),
};
let size = pack_report(&archive, &found, None, &meta).unwrap();
assert!(size > 0);
let packed = entries(&archive);
let names: Vec<&str> = packed.iter().map(|(name, _)| name.as_str()).collect();
assert!(names.contains(&"logs/client.log"), "got {:?}", names);
assert!(names.contains(&"logs/client.log.1"), "got {:?}", names);
assert!(!names.contains(&"logs/unrelated.txt"), "got {:?}", names);
let metadata = &packed
.iter()
.find(|(name, _)| name == "metadata.txt")
.unwrap()
.1;
assert!(metadata.contains("3.4.889"));
assert!(metadata.contains("linux-x86_64"));
assert!(metadata.contains("cannot publish"));
}
#[test]
fn the_report_url_hangs_off_the_configured_server() {
let server: Url = "https://cloudpub.example/".parse().unwrap();
assert_eq!(
report_url(&server).unwrap().as_str(),
"https://cloudpub.example/api/contact/report/"
);
}
async fn one_shot(
status: &'static str,
body: &'static str,
) -> (Url, tokio::task::JoinHandle<String>) {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let url: Url = format!("http://{}/", addr).parse().unwrap();
let handle = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut raw = Vec::new();
let mut buf = [0u8; 4096];
let header_end = loop {
let n = socket.read(&mut buf).await.unwrap();
raw.extend_from_slice(&buf[..n]);
if let Some(pos) = raw.windows(4).position(|w| w == b"\r\n\r\n") {
break pos + 4;
}
if n == 0 {
break raw.len();
}
};
let headers = String::from_utf8_lossy(&raw[..header_end]).to_string();
let length: usize = headers
.lines()
.find(|line| line.to_ascii_lowercase().starts_with("content-length:"))
.and_then(|line| line.split(':').nth(1))
.and_then(|value| value.trim().parse().ok())
.unwrap_or(0);
while raw.len() < header_end + length {
let n = socket.read(&mut buf).await.unwrap();
if n == 0 {
break;
}
raw.extend_from_slice(&buf[..n]);
}
socket
.write_all(
format!(
"HTTP/1.1 {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
status,
body.len(),
body
)
.as_bytes(),
)
.await
.unwrap();
socket.flush().await.unwrap();
String::from_utf8_lossy(&raw).to_string()
});
(url, handle)
}
fn packed_fixture(dir: &Path) -> PathBuf {
let config = dir.join("client.toml");
std::fs::write(&config, fixture_config()).unwrap();
let archive = dir.join("report.zip");
pack_report(&archive, &[], Some(&config), &ReportMeta::default()).unwrap();
archive
}
fn meta() -> ReportMeta {
ReportMeta {
version: "3.4.889".to_string(),
platform: "linux-x86_64".to_string(),
agent_id: "agent-1".to_string(),
hwid: "hw-1".to_string(),
server: "http://127.0.0.1/".to_string(),
message: Some("cannot publish".to_string()),
}
}
#[tokio::test]
async fn the_report_is_posted_as_multipart_with_the_account_token() {
let dir = tempdir();
let archive = packed_fixture(&dir);
let (url, server) = one_shot("201 Created", "{}").await;
upload_report(&reqwest::Client::new(), &url, "tok-42", &archive, &meta())
.await
.unwrap();
let request = server.await.unwrap();
assert!(
request.starts_with("POST /api/contact/report/ "),
"wrong request line: {:?}",
request.lines().next()
);
assert!(
request.contains("authorization: Token tok-42"),
"{}",
request
);
assert!(request.contains("multipart/form-data"), "{}", request);
assert!(request.contains(r#"name="archive""#), "{}", request);
assert!(request.contains(r#"filename="report.zip""#), "{}", request);
assert!(request.contains(r#"name="version""#), "{}", request);
assert!(request.contains("3.4.889"), "{}", request);
assert!(request.contains(r#"name="agent_guid""#), "{}", request);
assert!(request.contains(r#"name="message""#), "{}", request);
assert!(request.contains("cannot publish"), "{}", request);
assert!(request.contains("PK"), "archive body missing");
assert!(
!request.contains(TOKEN),
"config token went out on the wire"
);
}
#[tokio::test]
async fn an_oversized_archive_is_reported_as_such() {
let dir = tempdir();
let archive = packed_fixture(&dir);
let (url, server) = one_shot("413 Payload Too Large", "{}").await;
let err = upload_report(&reqwest::Client::new(), &url, "tok-42", &archive, &meta())
.await
.unwrap_err();
server.await.unwrap();
assert_eq!(err.to_string(), crate::t!("report-too-large"));
}
#[tokio::test]
async fn any_other_refusal_names_the_status() {
let dir = tempdir();
let archive = packed_fixture(&dir);
let (url, server) = one_shot("403 Forbidden", "nope").await;
let err = upload_report(&reqwest::Client::new(), &url, "tok-42", &archive, &meta())
.await
.unwrap_err();
server.await.unwrap();
assert!(err.to_string().contains("403"), "{}", err);
}
fn tempdir() -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"cloudpub-report-test-{}",
uuid::Uuid::new_v4().simple()
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
}