summaryrefslogtreecommitdiffhomepage
path: root/mullvad-problem-report
diff options
context:
space:
mode:
authorLinus Färnstrand <linus@mullvad.net>2018-07-02 16:10:37 +0200
committerLinus Färnstrand <linus@mullvad.net>2018-07-03 11:33:30 +0200
commit212ab525ed69e3ba47ade75dcc04a3d528ca7ece (patch)
tree154673d53e58a5c4d514279a732a3cd02aea8344 /mullvad-problem-report
parent56922a2b644a1dc6f4b872e2dfcd84571ca354a3 (diff)
downloadmullvadvpn-212ab525ed69e3ba47ade75dcc04a3d528ca7ece.tar.xz
mullvadvpn-212ab525ed69e3ba47ade75dcc04a3d528ca7ece.zip
Move problem-report out to own crate
Diffstat (limited to 'mullvad-problem-report')
-rw-r--r--mullvad-problem-report/Cargo.toml19
-rw-r--r--mullvad-problem-report/build.rs24
-rw-r--r--mullvad-problem-report/src/main.rs594
3 files changed, 637 insertions, 0 deletions
diff --git a/mullvad-problem-report/Cargo.toml b/mullvad-problem-report/Cargo.toml
new file mode 100644
index 0000000000..a78264a2e9
--- /dev/null
+++ b/mullvad-problem-report/Cargo.toml
@@ -0,0 +1,19 @@
+[package]
+name = "mullvad-problem-report"
+version = "2018.2.0-beta1"
+authors = ["Mullvad VPN <admin@mullvad.net>", "Linus Färnstrand <linus@mullvad.net>", "Andrej Mihajlov <and@mullvad.net>"]
+description = "Collect Mullvad VPN logs into a report and send it to support"
+license = "GPL-3.0"
+
+[[bin]]
+name = "problem-report"
+path = "src/main.rs"
+
+[dependencies]
+clap = "2.25"
+error-chain = "0.12"
+lazy_static = "1.0"
+regex = "1.0"
+
+mullvad-paths = { path = "../mullvad-paths" }
+mullvad-rpc = { path = "../mullvad-rpc" }
diff --git a/mullvad-problem-report/build.rs b/mullvad-problem-report/build.rs
new file mode 100644
index 0000000000..f1f507670e
--- /dev/null
+++ b/mullvad-problem-report/build.rs
@@ -0,0 +1,24 @@
+use std::env;
+use std::fs;
+use std::path::PathBuf;
+use std::process::Command;
+
+
+fn main() {
+ let out_dir = PathBuf::from(env::var_os("OUT_DIR").unwrap());
+
+ let product_version = env!("CARGO_PKG_VERSION").replacen(".0", "", 1);
+ fs::write(out_dir.join("product-version.txt"), product_version).unwrap();
+ fs::write(out_dir.join("git-commit-date.txt"), commit_date()).unwrap();
+}
+
+fn commit_date() -> String {
+ let output = Command::new("git")
+ .args(&["log", "-1", "--date=short", "--pretty=format:%cd"])
+ .output()
+ .expect("Unable to get git commit date");
+ ::std::str::from_utf8(&output.stdout)
+ .unwrap()
+ .trim()
+ .to_owned()
+}
diff --git a/mullvad-problem-report/src/main.rs b/mullvad-problem-report/src/main.rs
new file mode 100644
index 0000000000..c4db962ab5
--- /dev/null
+++ b/mullvad-problem-report/src/main.rs
@@ -0,0 +1,594 @@
+//! # License
+//!
+//! Copyright (C) 2017 Amagicom AB
+//!
+//! This program is free software: you can redistribute it and/or modify it under the terms of the
+//! GNU General Public License as published by the Free Software Foundation, either version 3 of
+//! the License, or (at your option) any later version.
+
+#[macro_use]
+extern crate clap;
+#[macro_use]
+extern crate error_chain;
+#[macro_use]
+extern crate lazy_static;
+extern crate regex;
+
+extern crate mullvad_paths;
+extern crate mullvad_rpc;
+
+use error_chain::ChainedError;
+use regex::Regex;
+
+use std::borrow::Cow;
+use std::cmp::min;
+use std::collections::{HashMap, HashSet};
+use std::env;
+use std::ffi::OsStr;
+use std::fs::{self, File};
+use std::io::{self, BufWriter, Read, Seek, SeekFrom, Write};
+use std::path::{Path, PathBuf};
+use std::process::Command;
+
+/// Maximum number of bytes to read from each log file
+const LOG_MAX_READ_BYTES: usize = 1 * 1024 * 1024;
+/// Fit two logs plus some system information in the report.
+const REPORT_MAX_SIZE: usize = 2 * LOG_MAX_READ_BYTES + 16 * 1024;
+
+
+/// Field delimeter in generated problem report
+const LOG_DELIMITER: &'static str = "====================";
+
+/// Line separator character sequence
+#[cfg(not(windows))]
+const LINE_SEPARATOR: &str = "\n";
+
+#[cfg(windows)]
+const LINE_SEPARATOR: &str = "\r\n";
+
+/// Custom macro to write a line to an output formatter that uses platform-specific newline
+/// character sequences.
+macro_rules! write_line {
+ ($fmt:expr $(,)*) => { write!($fmt, "{}", LINE_SEPARATOR) };
+ ($fmt:expr, $pattern:expr $(, $arg:expr)* $(,)*) => {
+ write!($fmt, $pattern, $( $arg ),*)
+ .and_then(|_| write!($fmt, "{}", LINE_SEPARATOR))
+ };
+}
+
+error_chain!{
+ errors {
+ LogDirError(path: PathBuf) {
+ description("Error listing the files in the mullvad-daemon log directory")
+ display(
+ "Error listing the files in the mullvad-daemon log directory: {}",
+ path.to_string_lossy()
+ )
+ }
+ WriteReportError(path: PathBuf) {
+ description("Error writing the problem report file")
+ display("Error writing the problem report file: {}", path.display())
+ }
+ ReadLogError(path: PathBuf) {
+ description("Error reading the contents of log file")
+ display("Error reading the contents of log file: {}", path.display())
+ }
+ RpcError {
+ description("Error during RPC call")
+ }
+ }
+}
+
+quick_main!(run);
+
+fn run() -> Result<()> {
+ let app = clap::App::new("problem-report")
+ .version(daemon_version())
+ .author(crate_authors!())
+ .about("Mullvad VPN problem report tool. Collects logs and sends them to Mullvad support.")
+ .setting(clap::AppSettings::SubcommandRequired)
+ .subcommand(
+ clap::SubCommand::with_name("collect")
+ .about("Collect problem report")
+ .arg(
+ clap::Arg::with_name("output")
+ .help("The destination path for saving the collected report.")
+ .long("output")
+ .short("o")
+ .value_name("PATH")
+ .takes_value(true)
+ .required(true),
+ )
+ .arg(
+ clap::Arg::with_name("extra_logs")
+ .help("Paths to additional log files to be included.")
+ .multiple(true)
+ .value_name("EXTRA LOGS")
+ .takes_value(true)
+ .required(false),
+ )
+ .arg(
+ clap::Arg::with_name("redact")
+ .help("List of words and expressions to remove from the report")
+ .long("redact")
+ .value_name("PHRASE")
+ .multiple(true)
+ .takes_value(true),
+ ),
+ )
+ .subcommand(
+ clap::SubCommand::with_name("send")
+ .about("Send collected problem report")
+ .arg(
+ clap::Arg::with_name("report")
+ .long("report")
+ .short("r")
+ .help("The path to previously collected report file.")
+ .takes_value(true)
+ .required(true),
+ )
+ .arg(
+ clap::Arg::with_name("email")
+ .long("email")
+ .short("e")
+ .help("Reporter's email")
+ .takes_value(true)
+ .required(false),
+ )
+ .arg(
+ clap::Arg::with_name("message")
+ .long("message")
+ .short("m")
+ .help("Reporter's message")
+ .takes_value(true)
+ .required(false),
+ ),
+ );
+
+ let matches = app.get_matches();
+
+ if let Some(collect_matches) = matches.subcommand_matches("collect") {
+ let redact_custom_strings = collect_matches
+ .values_of_lossy("redact")
+ .unwrap_or(Vec::new());
+ let extra_logs = collect_matches
+ .values_of_os("extra_logs")
+ .map(|os_values| os_values.map(Path::new).collect())
+ .unwrap_or(Vec::new());
+ let output_path = Path::new(collect_matches.value_of_os("output").unwrap());
+ collect_report(&extra_logs, output_path, redact_custom_strings)
+ } else if let Some(send_matches) = matches.subcommand_matches("send") {
+ let report_path = Path::new(send_matches.value_of_os("report").unwrap());
+ let user_email = send_matches.value_of("email").unwrap_or("");
+ let user_message = send_matches.value_of("message").unwrap_or("");
+ send_problem_report(user_email, user_message, report_path)
+ } else {
+ unreachable!("No sub command given");
+ }
+}
+
+fn collect_report(
+ extra_logs: &[&Path],
+ output_path: &Path,
+ redact_custom_strings: Vec<String>,
+) -> Result<()> {
+ let mut problem_report = ProblemReport::new(redact_custom_strings);
+
+ match logs_from_log_directory() {
+ Ok(logs) => problem_report.try_add_logs(logs),
+ Err(error) => problem_report.add_error("Failed to list logs in log directory", error),
+ }
+
+ problem_report.add_logs(extra_logs);
+
+ write_problem_report(&output_path, problem_report)
+ .chain_err(|| ErrorKind::WriteReportError(output_path.to_path_buf()))
+}
+
+fn logs_from_log_directory() -> Result<impl Iterator<Item = Result<PathBuf>>> {
+ let log_dir = mullvad_paths::get_log_dir().chain_err(|| "Unable to get log directory")?;
+
+ fs::read_dir(&log_dir)
+ .chain_err(|| ErrorKind::LogDirError(log_dir.clone()))
+ .map(|dir_entries| {
+ let log_extension = Some(OsStr::new("log"));
+
+ dir_entries.filter_map(move |dir_entry_result| match dir_entry_result {
+ Ok(dir_entry) => {
+ let path = dir_entry.path();
+
+ if path.extension() == log_extension {
+ Some(Ok(path))
+ } else {
+ None
+ }
+ }
+ Err(cause) => Some(Err(Error::with_chain(
+ cause,
+ ErrorKind::LogDirError(log_dir.clone()),
+ ))),
+ })
+ })
+}
+
+fn send_problem_report(user_email: &str, user_message: &str, report_path: &Path) -> Result<()> {
+ let report_content = normalize_newlines(
+ read_file_lossy(report_path, REPORT_MAX_SIZE)
+ .chain_err(|| ErrorKind::ReadLogError(report_path.to_path_buf()))?,
+ );
+ let metadata = collect_metadata();
+ let mut rpc_manager = mullvad_rpc::MullvadRpcFactory::new();
+ let mut rpc_client = mullvad_rpc::ProblemReportProxy::connect(&mut rpc_manager)
+ .chain_err(|| ErrorKind::RpcError)?;
+ rpc_client
+ .problem_report(user_email, user_message, &report_content, &metadata)
+ .call()
+ .chain_err(|| ErrorKind::RpcError)
+}
+
+fn write_problem_report(path: &Path, problem_report: ProblemReport) -> io::Result<()> {
+ let file = File::create(path)?;
+ let mut permissions = file.metadata()?.permissions();
+ permissions.set_readonly(true);
+ file.set_permissions(permissions)?;
+ problem_report.write_to(BufWriter::new(file))?;
+ Ok(())
+}
+
+
+#[derive(Debug)]
+struct ProblemReport {
+ metadata: HashMap<String, String>,
+ logs: Vec<(String, String)>,
+ log_paths: HashSet<PathBuf>,
+ redact_custom_strings: Vec<String>,
+}
+
+impl ProblemReport {
+ /// Creates a new problem report with system information. Logs can be added with `add_log`.
+ /// Logs will have all strings in `redact_custom_strings` removed from them.
+ pub fn new(redact_custom_strings: Vec<String>) -> Self {
+ ProblemReport {
+ metadata: collect_metadata(),
+ logs: Vec::new(),
+ log_paths: HashSet::new(),
+ redact_custom_strings,
+ }
+ }
+
+ /// Attach some file logs to this report. This method adds the error chain instead of the log
+ /// contents if an error occurs while reading one of the log files.
+ pub fn add_logs<I>(&mut self, paths: I)
+ where
+ I: IntoIterator,
+ I::Item: AsRef<Path>,
+ {
+ for path in paths {
+ self.add_log(path.as_ref());
+ }
+ }
+
+ /// Tries to attach some file logs to this report.
+ ///
+ /// This method receives a result with an iterator of results of file paths. If any of the
+ /// results are errors, they are collected and displayed in the final report.
+ pub fn try_add_logs<I, P>(&mut self, paths: I)
+ where
+ I: IntoIterator<Item = Result<P>>,
+ P: AsRef<Path>,
+ {
+ for path_result in paths {
+ match path_result {
+ Ok(path) => self.add_log(path.as_ref()),
+ Err(error) => self.add_error("Error getting next log file", error),
+ }
+ }
+ }
+
+ /// Attach a file log to this report. This method adds the error chain instead of the log
+ /// contents if an error occurs while reading the log file.
+ pub fn add_log(&mut self, path: &Path) {
+ if self.log_paths.insert(path.to_owned()) {
+ let content = self.redact(
+ &read_file_lossy(path, LOG_MAX_READ_BYTES)
+ .chain_err(|| ErrorKind::ReadLogError(path.to_path_buf()))
+ .unwrap_or_else(|e| e.display_chain().to_string()),
+ );
+ let path = self.redact(&path.to_string_lossy());
+ self.logs.push((path, content));
+ }
+ }
+
+ /// Attach an error to the report.
+ pub fn add_error<S, E>(&mut self, message: S, error: E)
+ where
+ S: ToString,
+ E: ChainedError,
+ {
+ let redacted_error = self.redact(&error.display_chain().to_string());
+
+ self.logs.push((message.to_string(), redacted_error));
+ }
+
+ fn redact(&self, input: &str) -> String {
+ let out1 = Self::redact_account_number(input);
+ let out2 = Self::redact_home_dir(&out1);
+ let out3 = Self::redact_network_info(&out2);
+ self.redact_custom_strings(&out3).to_string()
+ }
+
+ fn redact_account_number(input: &str) -> Cow<str> {
+ lazy_static! {
+ static ref RE: Regex = Regex::new("\\d{16}").unwrap();
+ }
+ RE.replace_all(input, "[REDACTED ACCOUNT NUMBER]")
+ }
+
+ fn redact_home_dir(input: &str) -> Cow<str> {
+ match env::home_dir() {
+ Some(home) => Cow::from(input.replace(home.to_string_lossy().as_ref(), "~")),
+ None => Cow::from(input),
+ }
+ }
+
+ fn redact_network_info(input: &str) -> Cow<str> {
+ lazy_static! {
+ static ref RE: Regex = {
+ let combined_pattern = format!(
+ "\\b({}|{}|{})\\b",
+ build_ipv4_regex(),
+ build_ipv6_regex(),
+ build_mac_regex()
+ );
+ Regex::new(&combined_pattern).unwrap()
+ };
+ }
+ RE.replace_all(input, "[REDACTED]")
+ }
+
+ fn redact_custom_strings<'a>(&self, input: &'a str) -> Cow<'a, str> {
+ // Can probably me made a lot faster with aho-corasick if optimization is ever needed.
+ let mut out = Cow::from(input);
+ for redact in &self.redact_custom_strings {
+ out = out.replace(redact, "[REDACTED]").into()
+ }
+ out
+ }
+
+ fn write_to<W: Write>(&self, mut output: W) -> io::Result<()> {
+ write_line!(output, "System information:")?;
+ for (key, value) in &self.metadata {
+ write_line!(output, "{}: {}", key, value)?;
+ }
+ write_line!(output)?;
+ for &(ref label, ref content) in &self.logs {
+ write_line!(output, "{}", LOG_DELIMITER)?;
+ write_line!(output, "Log: {}", label)?;
+ write_line!(output, "{}", LOG_DELIMITER)?;
+ output.write_all(content.as_bytes())?;
+ write_line!(output)?;
+ }
+ Ok(())
+ }
+}
+
+fn build_mac_regex() -> String {
+ let octet = "[[:xdigit:]]{2}"; // 0 - ff
+
+ // five pairs of two hexadecimal chars followed by colon or dash
+ // followed by a pair of hexadecimal chars
+ format!("(?:{0}[:-]){{5}}({0})", octet)
+}
+
+fn build_ipv4_regex() -> String {
+ // regex adapted from https://www.regular-expressions.info/ip.html
+
+ let above_250 = "25[0-5]";
+ let above_200 = "2[0-4][0-9]";
+ let above_100 = "1[0-9][0-9]";
+
+ // 100-119 | 120-126 | 128-129 | 130 - 199
+ let above_100_not_127 = "1(?:[01][0-9]|2[0-6]|2[89]|[3-9][0-9])";
+
+ let above_0 = "0?[0-9][0-9]?";
+
+ // matches 0-255, except 127
+ let first_octet = format!(
+ "(?:{}|{}|{}|{})",
+ above_250, above_200, above_100_not_127, above_0
+ );
+
+ // matches 0-255
+ let ip_octet = format!("(?:{}|{}|{}|{})", above_250, above_200, above_100, above_0);
+
+ format!("(?:{0}\\.{1}\\.{1}\\.{1})", first_octet, ip_octet)
+}
+
+fn build_ipv6_regex() -> String {
+ let hextet = "[[:xdigit:]]{1,4}"; // 0 - ffff
+
+ // Matches 1-7 hextets followed by one or two colons
+ // and one last hextet.
+ //
+ // This means that there are many
+ // invalid IPv6 addresses that matches this. E.g.
+ // all that has more than one instance of '::', but we
+ // don't really care.
+ let short = format!("({0}::?){{1,6}}(:{0}){{1,6}}", hextet);
+
+ // Matches addresses without double colon. This is
+ // a separate regex to make it easier to not match
+ // on time
+ let long = format!("({0}:){{7}}{0}", hextet);
+
+ format!("(?:{})|(?:{})", short, long)
+}
+
+/// Helper to lossily read a file to a `String`. If the file size exceeds the given `max_bytes`,
+/// only the last `max_bytes` bytes of the file are read.
+fn read_file_lossy(path: &Path, max_bytes: usize) -> io::Result<String> {
+ let mut file = File::open(path)?;
+ let file_size = file.metadata()?.len();
+
+ if file_size > max_bytes as u64 {
+ file.seek(SeekFrom::Start(file_size - max_bytes as u64))?;
+ }
+
+ let capacity = min(file_size, max_bytes as u64) as usize;
+ let mut buffer = Vec::with_capacity(capacity);
+ file.take(max_bytes as u64).read_to_end(&mut buffer)?;
+ Ok(String::from_utf8_lossy(&buffer).into_owned())
+}
+
+fn collect_metadata() -> HashMap<String, String> {
+ let mut metadata = HashMap::new();
+ metadata.insert(
+ String::from("mullvad-daemon-version"),
+ daemon_version().to_owned(),
+ );
+ metadata.insert(String::from("os"), os_version());
+ metadata
+}
+
+fn daemon_version() -> &'static str {
+ concat!(
+ include_str!(concat!(env!("OUT_DIR"), "/product-version.txt")),
+ " ",
+ include_str!(concat!(env!("OUT_DIR"), "/git-commit-date.txt"))
+ )
+}
+
+#[cfg(target_os = "linux")]
+fn os_version() -> String {
+ format!(
+ "Linux, {}",
+ command_stdout_lossy("lsb_release", &["-ds"])
+ .unwrap_or(String::from("[Failed to get LSB release]"))
+ )
+}
+
+#[cfg(target_os = "macos")]
+fn os_version() -> String {
+ format!(
+ "macOS {}",
+ command_stdout_lossy("sw_vers", &["-productVersion"])
+ .unwrap_or(String::from("[Failed to detect version]"))
+ )
+}
+
+#[cfg(windows)]
+fn os_version() -> String {
+ let system_info =
+ command_stdout_lossy("systeminfo", &["/FO", "LIST"]).unwrap_or_else(String::new);
+
+ let mut os_name = None;
+ let mut os_version = None;
+
+ for info_line in system_info.lines() {
+ let mut info_parts = info_line.split(":");
+
+ match info_parts.next() {
+ Some("OS Name") => os_name = info_parts.next(),
+ Some("OS Version") => os_version = info_parts.next(),
+ _ => {}
+ }
+ }
+
+ match (os_name, os_version) {
+ (None, None) => String::from("Windows [Failed to detect version]"),
+ (Some(os_name), None) => os_name.trim().to_owned(),
+ (None, Some(os_version)) => format!("Windows version {}", os_version.trim()),
+ (Some(os_name), Some(os_version)) => {
+ format!("{} version {}", os_name.trim(), os_version.trim())
+ }
+ }
+}
+
+/// Helper for getting stdout of some command as a String. Ignores the exit code of the command.
+fn command_stdout_lossy(cmd: &str, args: &[&str]) -> Option<String> {
+ Command::new(cmd)
+ .args(args)
+ .output()
+ .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string())
+ .ok()
+}
+
+#[cfg(not(windows))]
+fn normalize_newlines(text: String) -> String {
+ text
+}
+
+#[cfg(windows)]
+fn normalize_newlines(text: String) -> String {
+ text.replace(LINE_SEPARATOR, "\n")
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn redacts_ipv4() {
+ assert_redacts_ipv4("1.2.3.4");
+ assert_redacts_ipv4("10.127.0.1");
+ assert_redacts_ipv4("192.168.1.1");
+ assert_redacts_ipv4("10.0.16.1");
+ assert_redacts_ipv4("173.54.12.32");
+ assert_redacts_ipv4("68.4.4.1");
+ }
+
+ fn assert_redacts_ipv4(input: &str) {
+ let report = ProblemReport::new(vec![]);
+ let actual = report.redact(&format!("pre {} post", input));
+ assert_eq!("pre [REDACTED] post", actual);
+ }
+
+ #[test]
+ fn does_not_redact_localhost_ipv4() {
+ let report = ProblemReport::new(vec![]);
+ let res = report.redact("127.0.0.1");
+ assert_eq!("127.0.0.1", res);
+ }
+
+ #[test]
+ fn redacts_ipv6() {
+ assert_redacts_ipv6("2001:0db8:85a3:0000:0000:8a2e:0370:7334");
+ assert_redacts_ipv6("2001:db8:85a3:0:0:8a2e:370:7334");
+ assert_redacts_ipv6("2001:db8:85a3::8a2e:370:7334");
+ assert_redacts_ipv6("2001:db8:0:0:0:0:2:1");
+ assert_redacts_ipv6("2001:db8::2:1");
+ assert_redacts_ipv6("2001:db8:0000:1:1:1:1:1");
+ assert_redacts_ipv6("2001:db8:0:1:1:1:1:1");
+ assert_redacts_ipv6("2001:db8:0:0:1:0:0:1");
+ assert_redacts_ipv6("2001:db8::1:0:0:1");
+ assert_redacts_ipv6("0::0");
+ assert_redacts_ipv6("0:0:0:0::1");
+ }
+
+ #[test]
+ fn doesnt_redact_not_ipv6() {
+ let report = ProblemReport::new(vec![]);
+ let actual = report.redact("[talpid_core::firewall]");
+ assert_eq!("[talpid_core::firewall]", actual);
+ }
+
+ fn assert_redacts_ipv6(input: &str) {
+ let report = ProblemReport::new(vec![]);
+ let actual = report.redact(&format!("pre {} post", input));
+ assert_eq!("pre [REDACTED] post", actual);
+ }
+
+ #[test]
+ fn test_does_not_redact_localhost_ipv6() {
+ let report = ProblemReport::new(vec![]);
+ let res = report.redact("::1");
+ assert_eq!("::1", res);
+ }
+
+ #[test]
+ fn test_does_not_redact_time() {
+ let report = ProblemReport::new(vec![]);
+ let res = report.redact("09:47:59");
+ assert_eq!("09:47:59", res);
+ }
+}