From e9acd8bd3c3142cccb021ab5759a8410ef335aaa Mon Sep 17 00:00:00 2001 From: Mathijs van Veluw Date: Sun, 1 Sep 2024 15:52:29 +0200 Subject: Add a CLI feature to backup the SQLite DB (#4906) * Add a CLI feature to backup the SQLite DB Many users request to add the sqlite3 binary to the container image. This isn't really ideal as that might bring in other dependencies and will only bloat the image. There main reason is to create a backup of the database. While there already was a feature within the admin interface to do so (or by using the admin API call), this might not be easy. This PR adds several ways to generate a backup. 1. By calling the Vaultwarden binary with the `backup` command like: - `/vaultwarden backup` - `docker exec -it vaultwarden /vaultwarden backup` 2. By sending the USR1 signal to the running process like: - `kill -s USR1 $(pidof vaultwarden) - `killall -s USR1 vaultwarden) This should help users to more easily create backups of there SQLite database. Also added the Web-Vault version number when using `-v/--version` to the output. Signed-off-by: BlackDex * Spelling and small adjustments Signed-off-by: BlackDex --------- Signed-off-by: BlackDex --- src/api/admin.rs | 32 ++++++++++------------------ src/db/mod.rs | 22 +++++++++++++------ src/main.rs | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++----- src/util.rs | 22 +++++++++++++++++++ 4 files changed, 108 insertions(+), 33 deletions(-) diff --git a/src/api/admin.rs b/src/api/admin.rs index e6be3783..03d86920 100644 --- a/src/api/admin.rs +++ b/src/api/admin.rs @@ -25,7 +25,8 @@ use crate::{ http_client::make_http_request, mail, util::{ - container_base_image, format_naive_datetime_local, get_display_size, is_running_in_container, NumberOrString, + container_base_image, format_naive_datetime_local, get_display_size, get_web_vault_version, + is_running_in_container, NumberOrString, }, CONFIG, VERSION, }; @@ -575,11 +576,6 @@ async fn delete_organization(uuid: &str, _token: AdminToken, mut conn: DbConn) - org.delete(&mut conn).await } -#[derive(Deserialize)] -struct WebVaultVersion { - version: String, -} - #[derive(Deserialize)] struct GitRelease { tag_name: String, @@ -679,18 +675,6 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, mut conn: DbConn) use chrono::prelude::*; use std::net::ToSocketAddrs; - // Get current running versions - let web_vault_version: WebVaultVersion = - match std::fs::read_to_string(format!("{}/{}", CONFIG.web_vault_folder(), "vw-version.json")) { - Ok(s) => serde_json::from_str(&s)?, - _ => match std::fs::read_to_string(format!("{}/{}", CONFIG.web_vault_folder(), "version.json")) { - Ok(s) => serde_json::from_str(&s)?, - _ => WebVaultVersion { - version: String::from("Version file missing"), - }, - }, - }; - // Execute some environment checks let running_within_container = is_running_in_container(); let has_http_access = has_http_access().await; @@ -710,13 +694,16 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, mut conn: DbConn) let ip_header_name = &ip_header.0.unwrap_or_default(); + // Get current running versions + let web_vault_version = get_web_vault_version(); + let diagnostics_json = json!({ "dns_resolved": dns_resolved, "current_release": VERSION, "latest_release": latest_release, "latest_commit": latest_commit, "web_vault_enabled": &CONFIG.web_vault_enabled(), - "web_vault_version": web_vault_version.version.trim_start_matches('v'), + "web_vault_version": web_vault_version, "latest_web_build": latest_web_build, "running_within_container": running_within_container, "container_base_image": if running_within_container { container_base_image() } else { "Not applicable" }, @@ -765,9 +752,12 @@ fn delete_config(_token: AdminToken) -> EmptyResult { } #[post("/config/backup_db")] -async fn backup_db(_token: AdminToken, mut conn: DbConn) -> EmptyResult { +async fn backup_db(_token: AdminToken, mut conn: DbConn) -> ApiResult { if *CAN_BACKUP { - backup_database(&mut conn).await + match backup_database(&mut conn).await { + Ok(f) => Ok(format!("Backup to '{f}' was successful")), + Err(e) => err!(format!("Backup was unsuccessful {e}")), + } } else { err!("Can't back up current DB (Only SQLite supports this feature)"); } diff --git a/src/db/mod.rs b/src/db/mod.rs index 824b3c71..51ffba9c 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -368,23 +368,31 @@ pub mod models; /// Creates a back-up of the sqlite database /// MySQL/MariaDB and PostgreSQL are not supported. -pub async fn backup_database(conn: &mut DbConn) -> Result<(), Error> { +pub async fn backup_database(conn: &mut DbConn) -> Result { db_run! {@raw conn: postgresql, mysql { let _ = conn; err!("PostgreSQL and MySQL/MariaDB do not support this backup feature"); } sqlite { - use std::path::Path; - let db_url = CONFIG.database_url(); - let db_path = Path::new(&db_url).parent().unwrap().to_string_lossy(); - let file_date = chrono::Utc::now().format("%Y%m%d_%H%M%S").to_string(); - diesel::sql_query(format!("VACUUM INTO '{db_path}/db_{file_date}.sqlite3'")).execute(conn)?; - Ok(()) + backup_sqlite_database(conn) } } } +#[cfg(sqlite)] +pub fn backup_sqlite_database(conn: &mut diesel::sqlite::SqliteConnection) -> Result { + use diesel::RunQueryDsl; + let db_url = CONFIG.database_url(); + let db_path = std::path::Path::new(&db_url).parent().unwrap(); + let backup_file = db_path + .join(format!("db_{}.sqlite3", chrono::Utc::now().format("%Y%m%d_%H%M%S"))) + .to_string_lossy() + .into_owned(); + diesel::sql_query(format!("VACUUM INTO '{backup_file}'")).execute(conn)?; + Ok(backup_file) +} + /// Get the SQL Server version pub async fn get_sql_server_version(conn: &mut DbConn) -> String { db_run! {@raw conn: diff --git a/src/main.rs b/src/main.rs index 9f96dc60..6e725483 100644 --- a/src/main.rs +++ b/src/main.rs @@ -38,6 +38,7 @@ use std::{ use tokio::{ fs::File, io::{AsyncBufReadExt, BufReader}, + signal::unix::SignalKind, }; #[macro_use] @@ -97,10 +98,12 @@ USAGE: FLAGS: -h, --help Prints help information - -v, --version Prints the app version + -v, --version Prints the app and web-vault version COMMAND: hash [--preset {bitwarden|owasp}] Generate an Argon2id PHC ADMIN_TOKEN + backup Create a backup of the SQLite database + You can also send the USR1 signal to trigger a backup PRESETS: m= t= p= bitwarden (default) 64MiB, 3 Iterations, 4 Threads @@ -115,11 +118,13 @@ fn parse_args() { let version = VERSION.unwrap_or("(Version info from Git not present)"); if pargs.contains(["-h", "--help"]) { - println!("vaultwarden {version}"); + println!("Vaultwarden {version}"); print!("{HELP}"); exit(0); } else if pargs.contains(["-v", "--version"]) { - println!("vaultwarden {version}"); + let web_vault_version = util::get_web_vault_version(); + println!("Vaultwarden {version}"); + println!("Web-Vault {web_vault_version}"); exit(0); } @@ -174,13 +179,47 @@ fn parse_args() { argon2_timer.elapsed() ); } else { - error!("Unable to generate Argon2id PHC hash."); + println!("Unable to generate Argon2id PHC hash."); exit(1); } + } else if command == "backup" { + match backup_sqlite() { + Ok(f) => { + println!("Backup to '{f}' was successful"); + exit(0); + } + Err(e) => { + println!("Backup failed. {e:?}"); + exit(1); + } + } } exit(0); } } + +fn backup_sqlite() -> Result { + #[cfg(sqlite)] + { + use crate::db::{backup_sqlite_database, DbConnType}; + if DbConnType::from_url(&CONFIG.database_url()).map(|t| t == DbConnType::sqlite).unwrap_or(false) { + use diesel::Connection; + let url = crate::CONFIG.database_url(); + + // Establish a connection to the sqlite database + let mut conn = diesel::sqlite::SqliteConnection::establish(&url)?; + let backup_file = backup_sqlite_database(&mut conn)?; + Ok(backup_file) + } else { + err_silent!("The database type is not SQLite. Backups only works for SQLite databases") + } + } + #[cfg(not(sqlite))] + { + err_silent!("The 'sqlite' feature is not enabled. Backups only works for SQLite databases") + } +} + fn launch_info() { println!( "\ @@ -346,7 +385,7 @@ fn init_logging() -> Result { } #[cfg(not(windows))] { - const SIGHUP: i32 = tokio::signal::unix::SignalKind::hangup().as_raw_value(); + const SIGHUP: i32 = SignalKind::hangup().as_raw_value(); let path = Path::new(&log_file); logger = logger.chain(fern::log_reopen1(path, [SIGHUP])?); } @@ -560,6 +599,22 @@ async fn launch_rocket(pool: db::DbPool, extra_debug: bool) -> Result<(), Error> CONFIG.shutdown(); }); + #[cfg(unix)] + { + tokio::spawn(async move { + let mut signal_user1 = tokio::signal::unix::signal(SignalKind::user_defined1()).unwrap(); + loop { + // If we need more signals to act upon, we might want to use select! here. + // With only one item to listen for this is enough. + let _ = signal_user1.recv().await; + match backup_sqlite() { + Ok(f) => info!("Backup to '{f}' was successful"), + Err(e) => error!("Backup failed. {e:?}"), + } + } + }); + } + let _ = instance.launch().await?; info!("Vaultwarden process exited!"); diff --git a/src/util.rs b/src/util.rs index 9d58a53f..c586798c 100644 --- a/src/util.rs +++ b/src/util.rs @@ -513,6 +513,28 @@ pub fn container_base_image() -> &'static str { } } +#[derive(Deserialize)] +struct WebVaultVersion { + version: String, +} + +pub fn get_web_vault_version() -> String { + let version_files = [ + format!("{}/vw-version.json", CONFIG.web_vault_folder()), + format!("{}/version.json", CONFIG.web_vault_folder()), + ]; + + for version_file in version_files { + if let Ok(version_str) = std::fs::read_to_string(&version_file) { + if let Ok(version) = serde_json::from_str::(&version_str) { + return String::from(version.version.trim_start_matches('v')); + } + } + } + + String::from("Version file missing") +} + // // Deserialization methods // -- cgit v1.2.3