diff --git a/src/bin/defguard.rs b/src/bin/defguard.rs index 9963f7ac4..b1e5dd731 100644 --- a/src/bin/defguard.rs +++ b/src/bin/defguard.rs @@ -4,67 +4,26 @@ use defguard::{ config::{Command, DefGuardConfig}, db::{init_db, AppEvent, GatewayEvent, User}, grpc::{run_grpc_server, GatewayMap, WorkerState}, - init_dev_env, + init_dev_env, logging, mail::{run_mail_handler, Mail}, run_web_server, wireguard_stats_purge::run_periodic_stats_purge, SERVER_CONFIG, }; -use fern::{ - colors::{Color, ColoredLevelConfig}, - Dispatch, -}; -use log::{LevelFilter, SetLoggerError}; use std::{ fs::read_to_string, - str::FromStr, sync::{Arc, Mutex}, }; use tokio::sync::{broadcast, mpsc::unbounded_channel}; -/// Configures fern logging library. -fn logger_setup(log_level: &str) -> Result<(), SetLoggerError> { - let colors = ColoredLevelConfig::new() - .trace(Color::BrightWhite) - .debug(Color::BrightCyan) - .info(Color::BrightGreen) - .warn(Color::BrightYellow) - .error(Color::BrightRed); - Dispatch::new() - .format(move |out, message, record| { - // explicitly handle potentially malicious escape sequences - let mut formatted_message = String::new(); - for c in message.to_string().chars() { - match c { - '\n' => formatted_message.push_str("\\n"), - '\r' => formatted_message.push_str("\\r"), - '\u{0008}' => formatted_message.push_str("\\u{{0008}}"), - _ => formatted_message.push(c), - } - } - - out.finish(format_args!( - "[{}][{}][{}] {}", - chrono::Local::now().format("%Y-%m-%d %H:%M:%S%.3f"), - colors.color(record.level()), - record.target(), - formatted_message - )); - }) - .level(LevelFilter::from_str(log_level).unwrap_or(LevelFilter::Info)) - .level_for("sqlx", LevelFilter::Warn) - .chain(std::io::stdout()) - .apply() -} - #[tokio::main] async fn main() -> Result<(), anyhow::Error> { if dotenvy::from_filename(".env.local").is_err() { dotenvy::dotenv().ok(); } let config = DefGuardConfig::new(); + logging::init(&config.log_level, &config.log_file)?; SERVER_CONFIG.set(config.clone())?; - logger_setup(&config.log_level)?; match config.openid_signing_key { Some(_) => log::info!("Using RSA OpenID signing key"), None => log::info!("Using HMAC OpenID signing key"), diff --git a/src/config.rs b/src/config.rs index 2f98ca4d3..520b347e1 100644 --- a/src/config.rs +++ b/src/config.rs @@ -4,12 +4,15 @@ use openidconnect::{core::CoreRsaPrivateSigningKey, JsonWebKeyId}; use reqwest::Url; use rsa::{pkcs1::EncodeRsaPrivateKey, pkcs8::DecodePrivateKey, PublicKeyParts, RsaPrivateKey}; -#[derive(Clone, Debug, Parser)] +#[derive(Clone, Parser, Serialize, Debug)] #[command(version)] pub struct DefGuardConfig { #[arg(long, env = "DEFGUARD_LOG_LEVEL", default_value = "info")] pub log_level: String, + #[arg(long, env = "DEFGUARD_LOG_FILE")] + pub log_file: Option, + #[arg(long, env = "DEFGUARD_AUTH_SESSION_LIFETIME")] pub session_auth_lifetime: Option, @@ -54,6 +57,7 @@ pub struct DefGuardConfig { pub default_admin_password: String, #[arg(long, env = "DEFGUARD_OPENID_KEY", value_parser = Self::parse_openid_key)] + #[serde(skip_serializing)] pub openid_signing_key: Option, // relying party id and relying party origin for WebAuthn @@ -133,15 +137,18 @@ pub struct DefGuardConfig { pub disable_stats_purge: bool, #[arg(long, env = "DEFGUARD_STATS_PURGE_FREQUENCY", default_value = "24h")] + #[serde(skip_serializing)] pub stats_purge_frequency: Duration, #[arg(long, env = "DEFGUARD_STATS_PURGE_THRESHOLD", default_value = "30d")] + #[serde(skip_serializing)] pub stats_purge_threshold: Duration, #[arg(long, env = "DEFGUARD_ENROLLMENT_URL", value_parser = Url::parse, default_value = "http://localhost:8080")] pub enrollment_url: Url, #[arg(long, env = "DEFGUARD_ENROLLMENT_TOKEN_TIMEOUT", default_value = "24h")] + #[serde(skip_serializing)] pub enrollment_token_timeout: Duration, #[arg( @@ -149,9 +156,11 @@ pub struct DefGuardConfig { env = "DEFGUARD_ENROLLMENT_SESSION_TIMEOUT", default_value = "10m" )] + #[serde(skip_serializing)] pub enrollment_session_timeout: Duration, #[command(subcommand)] + #[serde(skip_serializing)] pub cmd: Option, } diff --git a/src/db/models/enrollment.rs b/src/db/models/enrollment.rs index bd62d97b5..d405a64dd 100644 --- a/src/db/models/enrollment.rs +++ b/src/db/models/enrollment.rs @@ -1,10 +1,9 @@ use crate::{ db::{DbPool, Settings, User}, - handlers::VERSION, mail::Mail, random::gen_alphanumeric, templates::{self, TemplateError}, - SERVER_CONFIG, + SERVER_CONFIG, VERSION, }; use chrono::{Duration, NaiveDateTime, Utc}; use reqwest::Url; @@ -362,6 +361,7 @@ impl User { &enrollment.id, ) .map_err(|err| EnrollmentError::NotificationError(err.to_string()))?, + attachments: Vec::new(), result_tx: None, }; match mail_tx.send(mail) { diff --git a/src/db/models/user.rs b/src/db/models/user.rs index 3666059fa..c80461983 100644 --- a/src/db/models/user.rs +++ b/src/db/models/user.rs @@ -35,7 +35,7 @@ pub enum MFAMethod { Web3, } -#[derive(Model, PartialEq)] +#[derive(Model, PartialEq, Serialize)] pub struct User { pub id: Option, pub username: String, diff --git a/src/grpc/enrollment.rs b/src/grpc/enrollment.rs index 998da3355..bef7736a8 100644 --- a/src/grpc/enrollment.rs +++ b/src/grpc/enrollment.rs @@ -322,6 +322,7 @@ impl Enrollment { to: user.email.clone(), subject: settings.enrollment_welcome_email_subject.clone().unwrap(), content: self.get_welcome_email_content(&mut *transaction).await?, + attachments: Vec::new(), result_tx: None, }; match mail_tx.send(mail) { @@ -351,6 +352,7 @@ impl Enrollment { to: admin.email.clone(), subject: "[defguard] User enrollment completed".into(), content: templates::enrollment_admin_notification(user, admin)?, + attachments: Vec::new(), result_tx: None, }; match mail_tx.send(mail) { diff --git a/src/handlers/mail.rs b/src/handlers/mail.rs index 670e58589..9840256a2 100644 --- a/src/handlers/mail.rs +++ b/src/handlers/mail.rs @@ -1,21 +1,27 @@ use std::fmt::Display; +use chrono::Utc; +use lettre::message::header::ContentType; use rocket::{ http::Status, serde::json::{serde_json::json, Json}, State, }; -use tokio::sync::oneshot::channel; +use tokio::sync::mpsc::unbounded_channel; use crate::{ appstate::AppState, auth::{AdminRole, SessionInfo}, + config::DefGuardConfig, handlers::{ApiResponse, ApiResult}, - mail::Mail, - templates, + mail::{Attachment, Mail}, + support::dump_config, + templates::{self, support_data_mail}, }; const TEST_MAIL_SUBJECT: &str = "Defguard email test"; +const SUPPORT_EMAIL_ADDRESS: &str = "support@defguard.net"; +const SUPPORT_EMAIL_SUBJECT: &str = "Defguard support data"; #[derive(Clone, Deserialize)] pub struct TestMail { @@ -23,8 +29,8 @@ pub struct TestMail { } /// Handles logging the error and returns ApiResponse that contains it -fn internal_error(from: &str, to: &str, subject: &str, error: &impl Display) -> ApiResponse { - error!("Error sending mail from: {from}, to {to}, subject: {subject}, error: {error}"); +fn internal_error(to: &str, subject: &str, error: &impl Display) -> ApiResponse { + error!("Error sending mail to {to}, subject: {subject}, error: {error}"); ApiResponse { json: json!({ "error": error.to_string(), @@ -45,17 +51,18 @@ pub async fn test_mail( session.user.username, data.to ); - let (tx, rx) = channel(); + let (tx, mut rx) = unbounded_channel(); let mail = Mail { to: data.to.clone(), subject: TEST_MAIL_SUBJECT.to_string(), content: templates::test_mail()?, + attachments: Vec::new(), result_tx: Some(tx), }; - let (from, to, subject) = (data.to.clone(), mail.to.clone(), mail.subject.clone()); + let (to, subject) = (mail.to.clone(), mail.subject.clone()); match appstate.mail_tx.send(mail) { - Ok(_) => match rx.await { - Ok(Ok(_)) => { + Ok(_) => match rx.recv().await { + Some(Ok(_)) => { info!( "User {} sent test mail to {}", session.user.username, data.to @@ -65,9 +72,84 @@ pub async fn test_mail( status: Status::Ok, }) } - Ok(Err(err)) => Ok(internal_error(&from, &to, &subject, &err)), - Err(err) => Ok(internal_error(&from, &to, &subject, &err)), + Some(Err(err)) => Ok(internal_error(&to, &subject, &err)), + None => Ok(internal_error( + &to, + &subject, + &String::from("None received"), + )), }, - Err(err) => Ok(internal_error(&from, &to, &subject, &err)), + Err(err) => Ok(internal_error(&to, &subject, &err)), + } +} + +async fn read_logs(config: &DefGuardConfig) -> String { + let path = match &config.log_file { + Some(path) => path, + None => return "Log file not configured".to_string(), + }; + + match tokio::fs::read_to_string(path).await { + Ok(logs) => logs, + Err(err) => { + error!("Error dumping app logs: {err}"); + format!("Error dumping app logs: {err}") + } + } +} + +#[post("/support", format = "json")] +pub async fn send_support_data( + _admin: AdminRole, + session: SessionInfo, + appstate: &State, +) -> ApiResult { + debug!( + "User {} sending support mail to {}", + session.user.username, SUPPORT_EMAIL_ADDRESS + ); + let config = dump_config(&appstate.pool, &appstate.config).await; + let config = + serde_json::to_string_pretty(&config).unwrap_or("Json formatting error".to_string()); + let config = Attachment { + filename: format!("defguard-support-data-{}.json", Utc::now()), + content: config.into(), + content_type: ContentType::TEXT_PLAIN, + }; + let logs = read_logs(&appstate.config).await; + let logs = Attachment { + filename: format!("defguard-logs-{}.txt", Utc::now()), + content: logs.into(), + content_type: ContentType::TEXT_PLAIN, + }; + let (tx, mut rx) = unbounded_channel(); + let mail = Mail { + to: SUPPORT_EMAIL_ADDRESS.to_string(), + subject: SUPPORT_EMAIL_SUBJECT.to_string(), + content: support_data_mail()?, + attachments: vec![config, logs], + result_tx: Some(tx), + }; + let (to, subject) = (mail.to.clone(), mail.subject.clone()); + match appstate.mail_tx.send(mail) { + Ok(_) => match rx.recv().await { + Some(Ok(_)) => { + info!( + "User {} sent support mail to {}", + session.user.username, SUPPORT_EMAIL_ADDRESS + ); + Ok(ApiResponse { + json: json!({}), + status: Status::Ok, + }) + } + Some(Err(err)) => Ok(internal_error(&to, &subject, &err)), + None => Ok(internal_error( + &to, + &subject, + &String::from("None received"), + )), + }, + Err(err) => Ok(internal_error(&to, &subject, &err)), } } diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index e5f32118c..2cdaeee29 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -4,6 +4,7 @@ use crate::{ auth::SessionInfo, db::{DbPool, User, UserInfo}, error::OriWebError, + VERSION, }; use rocket::{ http::{ContentType, Status}, @@ -11,7 +12,6 @@ use rocket::{ response::{Responder, Response}, serde::json::{serde_json::json, Value}, }; -use std::env; use webauthn_rs::prelude::RegisterPublicKeyCredential; pub(crate) mod app_info; @@ -24,6 +24,7 @@ pub mod openid_clients; #[cfg(feature = "openid")] pub mod openid_flow; pub(crate) mod settings; +pub(crate) mod support; pub(crate) mod user; pub(crate) mod webhooks; #[cfg(feature = "wireguard")] @@ -37,8 +38,6 @@ pub struct ApiResponse { pub status: Status, } -pub const VERSION: &str = env!("CARGO_PKG_VERSION"); - pub type ApiResult = Result; impl<'r, 'o: 'r> Responder<'r, 'o> for OriWebError { diff --git a/src/handlers/settings.rs b/src/handlers/settings.rs index 5d9d3d6cd..fe41ccef8 100644 --- a/src/handlers/settings.rs +++ b/src/handlers/settings.rs @@ -11,7 +11,7 @@ use rocket::{ State, }; -#[get("/settings", format = "json")] +#[get("/", format = "json")] pub async fn get_settings(appstate: &State) -> ApiResult { debug!("Retrieving settings"); let settings = Settings::find_by_id(&appstate.pool, 1).await?; @@ -22,7 +22,7 @@ pub async fn get_settings(appstate: &State) -> ApiResult { }) } -#[put("/settings", format = "json", data = "")] +#[put("/", format = "json", data = "")] pub async fn update_settings( _admin: AdminRole, appstate: &State, @@ -36,7 +36,7 @@ pub async fn update_settings( Ok(ApiResponse::default()) } -#[get("/settings/", format = "json")] +#[get("/", format = "json")] pub async fn set_default_branding( _admin: AdminRole, appstate: &State, diff --git a/src/handlers/support.rs b/src/handlers/support.rs new file mode 100644 index 000000000..efc62004a --- /dev/null +++ b/src/handlers/support.rs @@ -0,0 +1,49 @@ +use super::{ApiResponse, ApiResult}; +use crate::{ + auth::{AdminRole, SessionInfo}, + error::OriWebError, + support::dump_config, + AppState, +}; +use rocket::{http::Status, State}; + +#[get("/configuration", format = "json")] +pub async fn configuration( + _admin: AdminRole, + appstate: &State, + session: SessionInfo, +) -> ApiResult { + debug!("User {} dumping app configuration", session.user.username); + let config = dump_config(&appstate.pool, &appstate.config).await; + info!("User {} dumped app configuration", session.user.username); + Ok(ApiResponse { + json: config, + status: Status::Ok, + }) +} + +#[get("/logs", format = "json")] +pub async fn logs( + _admin: AdminRole, + appstate: &State, + session: SessionInfo, +) -> Result { + debug!("User {} dumping app logs", session.user.username); + if let Some(ref log_file) = appstate.config.log_file { + match tokio::fs::read_to_string(log_file).await { + Ok(logs) => { + info!("User {} dumped app logs", session.user.username); + Ok(logs) + } + Err(err) => { + error!( + "Error dumping app logs for user {}: {err}", + session.user.username + ); + Ok(err.to_string()) + } + } + } else { + Ok("Log file not configured".to_string()) + } +} diff --git a/src/lib.rs b/src/lib.rs index af5ccb064..5fb0df283 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,7 +3,13 @@ #![allow(clippy::unnecessary_lazy_evaluations)] #![allow(clippy::too_many_arguments)] -use crate::db::User; +use crate::{ + db::User, + handlers::{ + mail::send_support_data, + support::{configuration, logs}, + }, +}; #[cfg(feature = "worker")] use crate::handlers::worker::{ @@ -86,8 +92,10 @@ pub mod handlers; pub mod hex; pub mod ldap; pub mod license; +pub mod logging; pub mod mail; pub(crate) mod random; +pub mod support; pub mod templates; pub mod wg_config; pub mod wireguard_stats_purge; @@ -98,6 +106,7 @@ extern crate rocket; #[macro_use] extern crate serde; +pub static VERSION: &str = env!("CARGO_PKG_VERSION"); // TODO: use in more contexts instead of cloning/passing config around pub static SERVER_CONFIG: OnceCell = OnceCell::const_new(); @@ -169,9 +178,6 @@ pub async fn build_webapp( add_group_member, remove_group_member, get_license, - get_settings, - update_settings, - set_default_branding, mfa_enable, mfa_disable, totp_secret, @@ -190,6 +196,11 @@ pub async fn build_webapp( change_self_password, ], ) + .mount( + "/api/v1/settings", + routes![get_settings, update_settings, set_default_branding], + ) + .mount("/api/v1/support", routes![configuration, logs]) .mount( "/api/v1/webhook", routes![ @@ -201,7 +212,7 @@ pub async fn build_webapp( change_enabled ], ) - .mount("/api/v1/mail", routes![test_mail,]); + .mount("/api/v1/mail", routes![test_mail, send_support_data]); #[cfg(feature = "wireguard")] let webapp = webapp.manage(gateway_state).mount( diff --git a/src/logging.rs b/src/logging.rs new file mode 100644 index 000000000..a9a40737c --- /dev/null +++ b/src/logging.rs @@ -0,0 +1,44 @@ +use fern::{ + colors::{Color, ColoredLevelConfig}, + Dispatch, +}; +use log::LevelFilter; +use std::str::FromStr; + +/// Configures fern logging library. +pub fn init(log_level: &str, file: &Option) -> Result<(), fern::InitError> { + let colors = ColoredLevelConfig::new() + .trace(Color::BrightWhite) + .debug(Color::BrightCyan) + .info(Color::BrightGreen) + .warn(Color::BrightYellow) + .error(Color::BrightRed); + let mut dispatch = Dispatch::new() + .format(move |out, message, record| { + // explicitly handle potentially malicious escape sequences + let mut formatted_message = String::new(); + for c in message.to_string().chars() { + match c { + '\n' => formatted_message.push_str("\\n"), + '\r' => formatted_message.push_str("\\r"), + '\u{0008}' => formatted_message.push_str("\\u{{0008}}"), + _ => formatted_message.push(c), + } + } + + out.finish(format_args!( + "[{}][{}][{}] {}", + chrono::Local::now().format("%Y-%m-%d %H:%M:%S%.3f"), + colors.color(record.level()), + record.target(), + formatted_message + )); + }) + .level(LevelFilter::from_str(log_level).unwrap_or(LevelFilter::Info)) + .level_for("sqlx", LevelFilter::Warn) + .chain(std::io::stdout()); + if let Some(file) = file { + dispatch = dispatch.chain(fern::log_file(file)?); + } + Ok(dispatch.apply()?) +} diff --git a/src/mail.rs b/src/mail.rs index bdab61463..46ed82feb 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -2,13 +2,13 @@ use std::time::Duration; use lettre::{ address::AddressError, - message::{header::ContentType, Mailbox}, + message::{header::ContentType, Mailbox, MultiPart, SinglePart}, transport::smtp::{authentication::Credentials, response::Response}, Address, AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor, }; use sqlx::{Pool, Postgres}; use thiserror::Error; -use tokio::sync::{mpsc::UnboundedReceiver, oneshot::Sender}; +use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender}; use crate::db::{models::settings::SmtpEncryption, Settings}; @@ -86,23 +86,47 @@ impl SmtpSettings { } } -#[derive(Debug)] pub struct Mail { pub to: String, pub subject: String, pub content: String, - pub result_tx: Option>>, + pub attachments: Vec, + pub result_tx: Option>>, +} + +pub struct Attachment { + pub filename: String, + pub content: Vec, + pub content_type: ContentType, +} + +impl From for SinglePart { + fn from(attachment: Attachment) -> Self { + lettre::message::Attachment::new(attachment.filename) + .body(attachment.content, attachment.content_type) + } } impl Mail { /// Converts Mail to lettre Message - fn to_message(&self, from: &str) -> Result { - Ok(Message::builder() + fn into_message(self, from: &str) -> Result { + let builder = Message::builder() .from(Self::mailbox(from)?) .to(Self::mailbox(&self.to)?) - .subject(self.subject.clone()) - .header(ContentType::TEXT_HTML) - .body(self.content.clone())?) + .subject(self.subject.clone()); + match self.attachments { + attachments if attachments.is_empty() => Ok(builder + .header(ContentType::TEXT_HTML) + .body(self.content.clone())?), + attachments => { + let mut multipart = + MultiPart::mixed().singlepart(SinglePart::html(self.content.clone())); + for attachment in attachments { + multipart = multipart.singlepart(attachment.into()); + } + Ok(builder.multipart(multipart)?) + } + } } /// Builds Mailbox structure from string representing email address @@ -126,8 +150,8 @@ impl MailHandler { Self { rx, db } } - pub fn send_result( - tx: Option>>, + pub async fn send_result( + tx: Option>>, result: Result, ) { if let Some(tx) = tx { @@ -142,7 +166,8 @@ impl MailHandler { /// Listens on rx channel for messages and sends them via SMTP. pub async fn run(mut self) { while let Some(mail) = self.rx.recv().await { - debug!("Sending mail: {mail:?}"); + let (to, subject) = (mail.to.clone(), mail.subject.clone()); + debug!("Sending mail to: {to}, subject: {subject}"); let settings = match SmtpSettings::get(&self.db).await { Ok(settings) => settings, Err(MailError::SmtpNotConfigured) => { @@ -156,33 +181,33 @@ impl MailHandler { }; // Construct lettre Message - let message: Message = match mail.to_message(&settings.sender) { + let result_tx = mail.result_tx.clone(); + let message: Message = match mail.into_message(&settings.sender) { Ok(message) => message, Err(err) => { - error!("Failed to build message: {mail:?}, {err}"); + error!("Failed to build message to: {to}, subject: {subject}, error: {err}"); continue; } }; // Build mailer and send the message - let (to, subject) = (mail.to, mail.subject); match self.mailer(settings).await { Ok(mailer) => match mailer.send(message).await { Ok(response) => { - Self::send_result(mail.result_tx, Ok(response.clone())); + Self::send_result(result_tx, Ok(response.clone())).await; info!("Mail sent successfully to: {to}, subject: {subject}, response: {response:?}"); } Err(err) => { error!("Mail sending failed to: {to}, subject: {subject}, error: {err}"); - Self::send_result(mail.result_tx, Err(MailError::SmtpError(err))); + Self::send_result(result_tx, Err(MailError::SmtpError(err))).await; } }, Err(MailError::SmtpNotConfigured) => { warn!("SMTP not configured, onboarding email sending skipped"); - Self::send_result(mail.result_tx, Err(MailError::SmtpNotConfigured)); + Self::send_result(result_tx, Err(MailError::SmtpNotConfigured)).await; } Err(err) => { error!("Error building mailer: {err}"); - Self::send_result(mail.result_tx, Err(err)) + Self::send_result(result_tx, Err(err)).await } } } diff --git a/src/support.rs b/src/support.rs new file mode 100644 index 000000000..1d91e6b14 --- /dev/null +++ b/src/support.rs @@ -0,0 +1,57 @@ +use std::{collections::HashMap, fmt::Display}; + +use serde::Serialize; +use serde_json::{json, value::to_value, Value}; +use sqlx::{Pool, Postgres}; + +use crate::{ + config::DefGuardConfig, + db::{models::device::WireguardNetworkDevice, Settings, User, WireguardNetwork}, + VERSION, +}; + +/// Unwraps the result returning a JSON representation of value or error +fn unwrap_json(result: Result) -> Value { + match result { + Ok(value) => to_value(value).expect("conversion to JSON failed"), + Err(err) => json!({"error": err.to_string()}), + } +} + +/// Dumps all data that could be used for debugging. +pub async fn dump_config(db: &Pool, config: &DefGuardConfig) -> Value { + // App settings DB records + let settings = unwrap_json(Settings::all(db).await); + // Networks + let (networks, devices) = match WireguardNetwork::all(db).await { + Ok(networks) => { + // Devices for each network + let mut devices = HashMap::::default(); + for network in &networks { + let network_id = match network.id { + Some(id) => id, + None => continue, + }; + devices.insert( + network_id, + unwrap_json(WireguardNetworkDevice::all_for_network(db, network_id).await), + ); + } + ( + to_value(networks).expect("JSON serialization error"), + to_value(devices).expect("JSON serialization error"), + ) + } + Err(err) => (json!({"error": err.to_string()}), Value::Null), + }; + let users = unwrap_json(User::all(db).await); + + json!({ + "settings": settings, + "networks": networks, + "version": VERSION, + "devices": devices, + "users": users, + "config": config, + }) +} diff --git a/src/templates.rs b/src/templates.rs index c4bc095c6..cdfcad6f8 100644 --- a/src/templates.rs +++ b/src/templates.rs @@ -9,6 +9,7 @@ static MAIL_ENROLLMENT_START: &str = include_str!("../templates/mail_enrollment_ static MAIL_ENROLLMENT_WELCOME: &str = include_str!("../templates/mail_enrollment_welcome.tpl"); static MAIL_ENROLLMENT_ADMIN_NOTIFICATION: &str = include_str!("../templates/mail_enrollment_admin_notification.tpl"); +static MAIL_SUPPORT_DATA: &str = include_str!("../templates/mail_support_data.tpl"); #[derive(Error, Debug)] pub enum TemplateError { @@ -16,6 +17,7 @@ pub enum TemplateError { TemplateError(#[from] tera::Error), } +// sends test message when requested during SMTP configuration process pub fn test_mail() -> Result { let mut tera = Tera::default(); tera.add_raw_template("mail_base", MAIL_BASE)?; @@ -79,6 +81,15 @@ pub fn enrollment_admin_notification(user: &User, admin: &User) -> Result Result { + let mut tera = Tera::default(); + tera.add_raw_template("mail_base", MAIL_BASE)?; + tera.add_raw_template("mail_support_data", MAIL_SUPPORT_DATA)?; + + Ok(tera.render("mail_support_data", &Context::new())?) +} + #[cfg(test)] mod test { use claims::assert_ok; @@ -102,4 +113,9 @@ mod test { fn test_enrollment_welcome_mail() { assert_ok!(enrollment_welcome_mail("Hi there! Welcome to DefGuard.")); } + + #[test] + fn test_support_data_mail() { + assert_ok!(support_data_mail()); + } } diff --git a/templates/mail_support_data.tpl b/templates/mail_support_data.tpl new file mode 100644 index 000000000..500dba152 --- /dev/null +++ b/templates/mail_support_data.tpl @@ -0,0 +1,38 @@ +{% extends "mail_base" %} +{% block content %} +
+ + + + + + +
+ +
+ + + + + + +
+
+

+ Support data in attachments. +

+
+
+
+ +
+
+{% endblock content %} diff --git a/web/src/i18n/en/index.ts b/web/src/i18n/en/index.ts index 4300083be..4c0a67623 100644 --- a/web/src/i18n/en/index.ts +++ b/web/src/i18n/en/index.ts @@ -680,8 +680,10 @@ const en: BaseTranslation = { settingsPage: { title: 'Global Settings', tabs: { - basic: 'Basic', + general: 'General', smtp: 'SMTP', + enrollment: 'Enrollment', + support: 'Support', }, messages: { editSuccess: 'Settings updated', @@ -808,7 +810,7 @@ const en: BaseTranslation = { submit: 'Save changes', }, }, - test_form: { + testForm: { title: 'Send test email', fields: { to: { @@ -818,8 +820,8 @@ const en: BaseTranslation = { }, controls: { submit: 'Send', - success: 'Test email sent successfully', - error: 'Error sending test email', + success: 'Test email sent', + error: 'Error sending email', }, }, helper: ` @@ -880,6 +882,19 @@ const en: BaseTranslation = { }, }, }, + debugDataCard: { + title: 'Support data', + body: ` +If you need assistance or you were asked to generate support data by our team (for example on our Matrix support channel: **#defguard-support:teonite.com**), you have two options: +* Either you can configure SMTP settings and click "Send support data" +* Or click "Download support data" and create a bug report in our GitHub attaching this file. +`, + downloadSupportData: 'Download support data', + downloadLogs: 'Download logs', + sendMail: 'Send email', + mailSent: 'Email sent', + mailError: 'Error sending email', + }, licenseCard: { header: 'License & Support Information', licenseCardTitles: { diff --git a/web/src/i18n/i18n-types.ts b/web/src/i18n/i18n-types.ts index 73e847df5..04e9f79fd 100644 --- a/web/src/i18n/i18n-types.ts +++ b/web/src/i18n/i18n-types.ts @@ -1597,13 +1597,21 @@ type RootTranslation = { title: string tabs: { /** - * B​a​s​i​c + * G​e​n​e​r​a​l */ - basic: string + general: string /** * S​M​T​P */ smtp: string + /** + * E​n​r​o​l​l​m​e​n​t + */ + enrollment: string + /** + * S​u​p​p​o​r​t + */ + support: string } messages: { /** @@ -1864,7 +1872,7 @@ type RootTranslation = { submit: string } } - test_form: { + testForm: { /** * S​e​n​d​ ​t​e​s​t​ ​e​m​a​i​l */ @@ -1887,11 +1895,11 @@ type RootTranslation = { */ submit: string /** - * T​e​s​t​ ​e​m​a​i​l​ ​s​e​n​t​ ​s​u​c​c​e​s​s​f​u​l​l​y + * T​e​s​t​ ​e​m​a​i​l​ ​s​e​n​t */ success: string /** - * E​r​r​o​r​ ​s​e​n​d​i​n​g​ ​t​e​s​t​ ​e​m​a​i​l + * E​r​r​o​r​ ​s​e​n​d​i​n​g​ ​e​m​a​i​l */ error: string } @@ -1995,6 +2003,40 @@ type RootTranslation = { } } } + debugDataCard: { + /** + * S​u​p​p​o​r​t​ ​d​a​t​a + */ + title: string + /** + * + ​I​f​ ​y​o​u​ ​n​e​e​d​ ​a​s​s​i​s​t​a​n​c​e​ ​o​r​ ​y​o​u​ ​w​e​r​e​ ​a​s​k​e​d​ ​t​o​ ​g​e​n​e​r​a​t​e​ ​s​u​p​p​o​r​t​ ​d​a​t​a​ ​b​y​ ​o​u​r​ ​t​e​a​m​ ​(​f​o​r​ ​e​x​a​m​p​l​e​ ​o​n​ ​o​u​r​ ​M​a​t​r​i​x​ ​s​u​p​p​o​r​t​ ​c​h​a​n​n​e​l​:​ ​*​*​#​d​e​f​g​u​a​r​d​-​s​u​p​p​o​r​t​:​t​e​o​n​i​t​e​.​c​o​m​*​*​)​,​ ​y​o​u​ ​h​a​v​e​ ​t​w​o​ ​o​p​t​i​o​n​s​:​ + ​*​ ​E​i​t​h​e​r​ ​y​o​u​ ​c​a​n​ ​c​o​n​f​i​g​u​r​e​ ​S​M​T​P​ ​s​e​t​t​i​n​g​s​ ​a​n​d​ ​c​l​i​c​k​ ​"​S​e​n​d​ ​s​u​p​p​o​r​t​ ​d​a​t​a​"​ + ​*​ ​O​r​ ​c​l​i​c​k​ ​"​D​o​w​n​l​o​a​d​ ​s​u​p​p​o​r​t​ ​d​a​t​a​"​ ​a​n​d​ ​c​r​e​a​t​e​ ​a​ ​b​u​g​ ​r​e​p​o​r​t​ ​i​n​ ​o​u​r​ ​G​i​t​H​u​b​ ​a​t​t​a​c​h​i​n​g​ ​t​h​i​s​ ​f​i​l​e​.​ + + */ + body: string + /** + * D​o​w​n​l​o​a​d​ ​s​u​p​p​o​r​t​ ​d​a​t​a + */ + downloadSupportData: string + /** + * D​o​w​n​l​o​a​d​ ​l​o​g​s + */ + downloadLogs: string + /** + * S​e​n​d​ ​e​m​a​i​l + */ + sendMail: string + /** + * E​m​a​i​l​ ​s​e​n​t + */ + mailSent: string + /** + * E​r​r​o​r​ ​s​e​n​d​i​n​g​ ​e​m​a​i​l + */ + mailError: string + } licenseCard: { /** * L​i​c​e​n​s​e​ ​&​ ​S​u​p​p​o​r​t​ ​I​n​f​o​r​m​a​t​i​o​n @@ -4717,13 +4759,21 @@ export type TranslationFunctions = { title: () => LocalizedString tabs: { /** - * Basic + * General */ - basic: () => LocalizedString + general: () => LocalizedString /** * SMTP */ smtp: () => LocalizedString + /** + * Enrollment + */ + enrollment: () => LocalizedString + /** + * Support + */ + support: () => LocalizedString } messages: { /** @@ -4981,7 +5031,7 @@ export type TranslationFunctions = { submit: () => LocalizedString } } - test_form: { + testForm: { /** * Send test email */ @@ -5004,11 +5054,11 @@ export type TranslationFunctions = { */ submit: () => LocalizedString /** - * Test email sent successfully + * Test email sent */ success: () => LocalizedString /** - * Error sending test email + * Error sending email */ error: () => LocalizedString } @@ -5112,6 +5162,40 @@ export type TranslationFunctions = { } } } + debugDataCard: { + /** + * Support data + */ + title: () => LocalizedString + /** + * + If you need assistance or you were asked to generate support data by our team (for example on our Matrix support channel: **#defguard-support:teonite.com**), you have two options: + * Either you can configure SMTP settings and click "Send support data" + * Or click "Download support data" and create a bug report in our GitHub attaching this file. + + */ + body: () => LocalizedString + /** + * Download support data + */ + downloadSupportData: () => LocalizedString + /** + * Download logs + */ + downloadLogs: () => LocalizedString + /** + * Send email + */ + sendMail: () => LocalizedString + /** + * Email sent + */ + mailSent: () => LocalizedString + /** + * Error sending email + */ + mailError: () => LocalizedString + } licenseCard: { /** * License & Support Information diff --git a/web/src/i18n/pl/index.ts b/web/src/i18n/pl/index.ts index ff19f2dcb..599d8100c 100644 --- a/web/src/i18n/pl/index.ts +++ b/web/src/i18n/pl/index.ts @@ -681,8 +681,10 @@ const pl: Translation = { settingsPage: { title: 'Ustawienia Globalne', tabs: { - basic: 'Podstawowe', + general: 'Podstawowe', smtp: 'SMTP', + enrollment: 'Rejestracja', + support: 'Support', }, messages: { editSuccess: 'Ustawienia zaktualizowane.', @@ -807,7 +809,7 @@ const pl: Translation = { submit: 'Save changes', }, }, - test_form: { + testForm: { title: 'Wyślij emaila testowego', fields: { to: { @@ -817,8 +819,8 @@ const pl: Translation = { }, controls: { submit: 'Wyślij', - success: 'Email testowy wysłany pomyślnie', - error: 'Błąd wysyłania emaila testowego', + success: 'Email wysłany pomyślnie', + error: 'Błąd wysyłania emaila', }, }, helper: ` @@ -879,6 +881,19 @@ const pl: Translation = { }, }, }, + debugDataCard: { + title: 'Dane wsparcia technicznego', + body: ` +Jeśli potrzebujesz pomocy lub zostałeś poproszony przez nasz zespół o wygenerowanie danych wsparcia technicznego (np. na naszym kanale Matrix: **#defguard-support:teonite.com**), masz dwie opcje: +* Możesz skonfigurować ustawienia SMTP i kliknąć: "Wyślij dane wsparcia technicznego". +* Lub kliknąć "Pobierz dane wsparcia technicznego" i stworzyć zlecenie w naszym repozytorium GitHub załączając te pliki. +`, + downloadSupportData: 'Pobierz dane wsparcia technicznego', + downloadLogs: 'Pobierz logi', + sendMail: 'Wyślij email', + mailSent: 'Email wysłany', + mailError: 'Error sending email', + }, licenseCard: { header: 'Informacje o licencji i wsparciu technicznym', licenseCardTitles: { diff --git a/web/src/pages/settings/DebugDataCard/DebugDataCard.tsx b/web/src/pages/settings/DebugDataCard/DebugDataCard.tsx new file mode 100644 index 000000000..f02460e8b --- /dev/null +++ b/web/src/pages/settings/DebugDataCard/DebugDataCard.tsx @@ -0,0 +1,125 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; +import { saveAs } from 'file-saver'; +import { useEffect } from 'react'; +import { ReactMarkdown } from 'react-markdown/lib/react-markdown'; + +import { useI18nContext } from '../../../i18n/i18n-react'; +import { Button } from '../../../shared/components/layout/Button/Button'; +import { + ButtonSize, + ButtonStyleVariant, +} from '../../../shared/components/layout/Button/types'; +import { ContentCard } from '../../../shared/components/layout/ContentCard/ContentCard'; +import SvgIconArrowGrayUp from '../../../shared/components/svg/IconArrowGrayUp'; +import SvgIconDownload from '../../../shared/components/svg/IconDownload'; +import { useAppStore } from '../../../shared/hooks/store/useAppStore'; +import useApi from '../../../shared/hooks/useApi'; +import { useToaster } from '../../../shared/hooks/useToaster'; +import { QueryKeys } from '../../../shared/queries'; +import { SMTPError } from '../../../shared/types'; + +export const DebugDataCard = () => { + const { LL } = useI18nContext(); + const toaster = useToaster(); + const settings = useAppStore((state) => state.settings); + const smtp_configured = + settings?.smtp_server && + settings?.smtp_port && + settings?.smtp_user && + settings?.smtp_password && + settings?.smtp_sender; + const { + support: { downloadSupportData, downloadLogs }, + mail: { sendSupportMail }, + } = useApi(); + const { + data: supportData, + isLoading: configLoading, + refetch: fetchConfig, + } = useQuery({ + queryKey: [QueryKeys.FETCH_SUPPORT_DATA], + queryFn: downloadSupportData, + enabled: false, + }); + const { + data: logs, + isLoading: logsLoading, + refetch: fetchLogs, + } = useQuery({ + queryKey: [QueryKeys.FETCH_LOGS], + queryFn: downloadLogs, + enabled: false, + }); + const { mutate: sendMail, isLoading: mailLoading } = useMutation([], sendSupportMail, { + onSuccess: () => { + toaster.success(LL.settingsPage.debugDataCard.mailSent()); + }, + onError: (err: SMTPError) => { + toaster.error( + `${LL.settingsPage.debugDataCard.mailError()}`, + `${err.response?.data.error}` + ); + console.error(err); + }, + }); + useEffect(() => { + if (!supportData || configLoading) { + return; + } + const content = new Blob([JSON.stringify(supportData, null, 2)], { + type: 'text/plain;charset=utf-8', + }); + const timestamp = new Date().toISOString().replaceAll(':', ''); + saveAs(content, `defguard-support-data-${timestamp}.json`); + }, [supportData, configLoading]); + + useEffect(() => { + if (!logs || logsLoading) { + return; + } + const content = new Blob([logs], { type: 'text/plain;charset=utf-8' }); + const timestamp = new Date().toISOString().replaceAll(':', ''); + saveAs(content, `defguard-logs-${timestamp}.json`); + }, [logs, logsLoading]); + + const onSendMail = async () => { + sendMail(); + }; + + return ( + <> + {LL.settingsPage.debugDataCard.title()}} + className="support" + > + {LL.settingsPage.debugDataCard.body()} + +